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
16const (
17 // historyQueueSize is the buffer size for the async history queue.
18 // If the queue is full, new records are dropped with a warning.
19 historyQueueSize = 100
20)
21
22// historyRecord holds data for async history recording.
23type historyRecord struct {
24 url string
25 timestamp time.Time
26}
27
28// NavigateUseCase handles URL navigation with history recording and zoom application.
29type NavigateUseCase struct {
30 historyRepo repository.HistoryRepository
31 zoomRepo repository.ZoomRepository
32 defaultZoom float64
33 recentVisits sync.Map // key: url, value: time.Time - for deduplication
34
35 // Async history recording
36 historyQueue chan historyRecord
37 done chan struct{}
38 wg sync.WaitGroup
39 ctx context.Context // Base context for background worker
40}
41
42// NewNavigateUseCase creates a new navigation use case.
43// defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
44func NewNavigateUseCase(
45 historyRepo repository.HistoryRepository,
46 zoomRepo repository.ZoomRepository,
47 defaultZoom float64,
48) *NavigateUseCase {
49 if defaultZoom <= 0 {
50 defaultZoom = entity.ZoomDefault
51 }
52
53 uc := &NavigateUseCase{
54 historyRepo: historyRepo,
55 zoomRepo: zoomRepo,
56 defaultZoom: defaultZoom,
57 historyQueue: make(chan historyRecord, historyQueueSize),
58 done: make(chan struct{}),
59 ctx: context.Background(),
60 }
61
62 // Start background history worker
63 uc.wg.Add(1)
64 go uc.historyWorker()
65
66 return uc
67}
68
69// Close shuts down the background history worker and drains any pending records.
70// This should be called when the application is shutting down.
71func (uc *NavigateUseCase) Close() {
72 close(uc.done)
73 uc.wg.Wait()
74}
75
76// NavigateInput contains parameters for navigation.
77type NavigateInput struct {
78 URL string
79 PaneID string
80 WebView port.WebView
81}
82
83// NavigateOutput contains the result of navigation.
84type NavigateOutput struct {
85 AppliedZoom float64
86}
87
88// Execute navigates to a URL and records history.
89// Zoom is applied later via LoadCommitted event in ContentCoordinator.
90func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
91 log := logging.FromContext(ctx)
92 log.Debug().
93 Str("url", input.URL).
94 Str("pane_id", input.PaneID).
95 Msg("navigating to URL")
96
97 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
98 // History is recorded on LoadCommitted when URI is guaranteed correct
99 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
100 return nil, fmt.Errorf("failed to load URL: %w", err)
101 }
102
103 log.Info().
104 Str("url", input.URL).
105 Msg("navigation initiated")
106
107 return &NavigateOutput{
108 AppliedZoom: uc.defaultZoom,
109 }, nil
110}
111
112// historyDeduplicationWindow is the time window for deduplicating history visits.
113// Visits to the same URL within this window count as a single visit.
114// This prevents inflation from redirects and rapid navigation.
115const historyDeduplicationWindow = 2 * time.Second
116
117// RecordHistory queues a history entry for async recording.
118// This is non-blocking to avoid SQLite I/O on the GTK main thread.
119// Should be called on LoadCommitted when URI is guaranteed correct.
120func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
121 log := logging.FromContext(ctx)
122
123 // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)
124 url = normalizeURLForHistory(url)
125
126 // Time-based deduplication: skip if same URL was recorded recently
127 // This check is fast (sync.Map lookup) and stays on the main thread
128 now := time.Now()
129 if lastTime, ok := uc.recentVisits.Load(url); ok {
130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
131 log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window")
132 return
133 }
134 }
135 uc.recentVisits.Store(url, now)
136
137 // Non-blocking send to async queue
138 select {
139 case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
140 log.Debug().Str("url", url).Msg("history record queued")
141 default:
142 // Queue full - log warning but don't block navigation
143 log.Warn().Str("url", url).Msg("history queue full, dropping record")
144 }
145}
146
147// historyWorker is a background goroutine that processes history records.
148// It drains the queue and persists records to the database without blocking the UI.
149func (uc *NavigateUseCase) historyWorker() {
150 defer uc.wg.Done()
151
152 log := logging.FromContext(uc.ctx).With().
153 Str("component", "history-worker").
154 Logger()
155
156 for {
157 select {
158 case record := <-uc.historyQueue:
159 uc.persistHistory(uc.ctx, record)
160 case <-uc.done:
161 // Drain remaining records before shutdown
162 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
163 for {
164 select {
165 case record := <-uc.historyQueue:
166 uc.persistHistory(uc.ctx, record)
167 default:
168 log.Debug().Msg("history worker shutdown complete")
169 return
170 }
171 }
172 }
173 }
174}
175
176// persistHistory writes a history record to the database.
177// Called from the background worker goroutine.
178func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
179 log := logging.FromContext(ctx)
180
181 // Check if entry exists
182 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
183 if err != nil {
184 log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
185 return
186 }
187
188 if existing != nil {
189 // Increment visit count
190 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
191 log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
192 }
193 } else {
194 // Create new entry
195 entry := entity.NewHistoryEntry(record.url, "")
196 if err := uc.historyRepo.Save(ctx, entry); err != nil {
197 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
198 }
199 }
200}
201
202// UpdateHistoryTitle updates the title of a history entry after page load.
203func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
204 log := logging.FromContext(ctx)
205
206 // Normalize URL to match storage (e.g., github.com/ -> github.com)
207 url = normalizeURLForHistory(url)
208 log.Debug().Str("url", url).Str("title", title).Msg("updating history title")
209
210 entry, err := uc.historyRepo.FindByURL(ctx, url)
211 if err != nil {
212 return fmt.Errorf("failed to find history entry: %w", err)
213 }
214
215 if entry == nil {
216 // Entry doesn't exist - don't create it here
217 // Initial navigation should have already created the entry
218 // This can happen if URL changed during page load (SPA, redirect)
219 log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")
220 return nil
221 }
222
223 // Update existing entry's title
224 entry.Title = title
225 if err := uc.historyRepo.Save(ctx, entry); err != nil {
226 return fmt.Errorf("failed to update history title: %w", err)
227 }
228
229 return nil
230}
231
232// Reload reloads the current page.
233func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
234 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
235 log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
236
237 if bypassCache {
238 return webview.ReloadBypassCache(ctx)
239 }
240 return webview.Reload(ctx)
241}
242
243// GoBack navigates back in history.
244func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
245 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
246
247 if !webview.CanGoBack() {
248 log.Debug().Msg("cannot go back - no history")
249 return nil
250 }
251
252 log.Debug().Msg("navigating back")
253 return webview.GoBack(ctx)
254}
255
256// GoForward navigates forward in history.
257func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
258 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
259
260 if !webview.CanGoForward() {
261 log.Debug().Msg("cannot go forward - no history")
262 return nil
263 }
264
265 log.Debug().Msg("navigating forward")
266 return webview.GoForward(ctx)
267}
268
269// Stop stops the current page load.
270func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
271 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
272 log.Debug().Msg("stopping page load")
273 return webview.Stop(ctx)
274}
275
276// normalizeURLForHistory normalizes a URL for history storage.
277// Strips trailing slash to avoid duplicates like github.com vs github.com/
278func normalizeURLForHistory(url string) string {
279 // Don't strip if URL is just the protocol + domain (e.g., "https://github.com")
280 // Only strip trailing slash from paths
281 if strings.HasSuffix(url, "/") {
282 // Check if it's just domain with trailing slash (e.g., "https://github.com/")
283 // by counting slashes - protocol has 2, domain adds 0, path adds more
284 if strings.Count(url, "/") == 3 {
285 // It's "https://domain.com/" - strip the trailing slash
286 return strings.TrimSuffix(url, "/")
287 }
288 // For paths like "https://github.com/user/" - also strip
289 return strings.TrimSuffix(url, "/")
290 }
291 return url
292}