1package usecase
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/repository"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16// NavigateUseCase handles URL navigation with history recording and zoom application.
17type NavigateUseCase struct {
18 historyRepo repository.HistoryRepository
19 zoomRepo repository.ZoomRepository
20 defaultZoom float64
21 recentVisits sync.Map // key: url, value: time.Time - for deduplication
22}
23
24// NewNavigateUseCase creates a new navigation use case.
25// defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
26func NewNavigateUseCase(
27 historyRepo repository.HistoryRepository,
28 zoomRepo repository.ZoomRepository,
29 defaultZoom float64,
30) *NavigateUseCase {
31 if defaultZoom <= 0 {
32 defaultZoom = entity.ZoomDefault
33 }
34 return &NavigateUseCase{
35 historyRepo: historyRepo,
36 zoomRepo: zoomRepo,
37 defaultZoom: defaultZoom,
38 }
39}
40
41// NavigateInput contains parameters for navigation.
42type NavigateInput struct {
43 URL string
44 PaneID string
45 WebView port.WebView
46}
47
48// NavigateOutput contains the result of navigation.
49type NavigateOutput struct {
50 AppliedZoom float64
51}
52
53// Execute navigates to a URL and records history.
54// Zoom is applied later via LoadCommitted event in ContentCoordinator.
55func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
56 log := logging.FromContext(ctx)
57 log.Debug().
58 Str("url", input.URL).
59 Str("pane_id", input.PaneID).
60 Msg("navigating to URL")
61
62 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
63 // History is recorded on LoadCommitted when URI is guaranteed correct
64 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
65 return nil, fmt.Errorf("failed to load URL: %w", err)
66 }
67
68 log.Info().
69 Str("url", input.URL).
70 Msg("navigation initiated")
71
72 return &NavigateOutput{
73 AppliedZoom: uc.defaultZoom,
74 }, nil
75}
76
77// historyDeduplicationWindow is the time window for deduplicating history visits.
78// Visits to the same URL within this window count as a single visit.
79// This prevents inflation from redirects and rapid navigation.
80const historyDeduplicationWindow = 2 * time.Second
81
82// RecordHistory saves or updates the history entry.
83// Should be called on LoadCommitted when URI is guaranteed correct.
84func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85 log := logging.FromContext(ctx)
86
87 // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)
88 url = normalizeURLForHistory(url)
89
90 // Time-based deduplication: skip if same URL was recorded recently
91 now := time.Now()
92 if lastTime, ok := uc.recentVisits.Load(url); ok {
93 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
94 log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window")
95 return
96 }
97 }
98 uc.recentVisits.Store(url, now)
99
100 // Check if entry exists
101 existing, err := uc.historyRepo.FindByURL(ctx, url)
102 if err != nil {
103 log.Warn().Err(err).Str("url", url).Msg("failed to check history")
104 return
105 }
106
107 if existing != nil {
108 // Increment visit count
109 if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
110 log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
111 }
112 } else {
113 // Create new entry
114 entry := entity.NewHistoryEntry(url, "")
115 if err := uc.historyRepo.Save(ctx, entry); err != nil {
116 log.Warn().Err(err).Str("url", url).Msg("failed to save history")
117 }
118 }
119}
120
121// UpdateHistoryTitle updates the title of a history entry after page load.
122func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
123 log := logging.FromContext(ctx)
124
125 // Normalize URL to match storage (e.g., github.com/ -> github.com)
126 url = normalizeURLForHistory(url)
127 log.Debug().Str("url", url).Str("title", title).Msg("updating history title")
128
129 entry, err := uc.historyRepo.FindByURL(ctx, url)
130 if err != nil {
131 return fmt.Errorf("failed to find history entry: %w", err)
132 }
133
134 if entry == nil {
135 // Entry doesn't exist - don't create it here
136 // Initial navigation should have already created the entry
137 // This can happen if URL changed during page load (SPA, redirect)
138 log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")
139 return nil
140 }
141
142 // Update existing entry's title
143 entry.Title = title
144 if err := uc.historyRepo.Save(ctx, entry); err != nil {
145 return fmt.Errorf("failed to update history title: %w", err)
146 }
147
148 return nil
149}
150
151// Reload reloads the current page.
152func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
153 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
154 log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
155
156 if bypassCache {
157 return webview.ReloadBypassCache(ctx)
158 }
159 return webview.Reload(ctx)
160}
161
162// GoBack navigates back in history.
163func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
164 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
165
166 if !webview.CanGoBack() {
167 log.Debug().Msg("cannot go back - no history")
168 return nil
169 }
170
171 log.Debug().Msg("navigating back")
172 return webview.GoBack(ctx)
173}
174
175// GoForward navigates forward in history.
176func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
177 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
178
179 if !webview.CanGoForward() {
180 log.Debug().Msg("cannot go forward - no history")
181 return nil
182 }
183
184 log.Debug().Msg("navigating forward")
185 return webview.GoForward(ctx)
186}
187
188// Stop stops the current page load.
189func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
190 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
191 log.Debug().Msg("stopping page load")
192 return webview.Stop(ctx)
193}
194
195// normalizeURLForHistory normalizes a URL for history storage.
196// Strips trailing slash to avoid duplicates like github.com vs github.com/
197func normalizeURLForHistory(url string) string {
198 // Don't strip if URL is just the protocol + domain (e.g., "https://github.com")
199 // Only strip trailing slash from paths
200 if strings.HasSuffix(url, "/") {
201 // Check if it's just domain with trailing slash (e.g., "https://github.com/")
202 // by counting slashes - protocol has 2, domain adds 0, path adds more
203 if strings.Count(url, "/") == 3 {
204 // It's "https://domain.com/" - strip the trailing slash
205 return strings.TrimSuffix(url, "/")
206 }
207 // For paths like "https://github.com/user/" - also strip
208 return strings.TrimSuffix(url, "/")
209 }
210 return url
211}