1package usecase
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/domain/repository"
14 "github.com/bnema/dumber/internal/logging"
15)
16
17const (
18 // historyQueueSize is the buffer size for the async history queue.
19 // If the queue is full, new records are dropped with a warning.
20 historyQueueSize = 100
21
22 // logURLMaxLen is the max length for URLs in log messages.
23 logURLMaxLen = 60
24
25 // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26 historyWorkerFlushInterval = 100 * time.Millisecond
27
28 // historyFallbackIncrementCap bounds DB writes when delta-increment API is unavailable.
29 historyFallbackIncrementCap = 256
30)
31
32// historyRecord holds data for async history recording.
33type historyRecord struct {
34 url string
35 title string // non-empty for title-update-only records
36 visits int
37}
38
39type historyVisitDeltaIncrementer interface {
40 IncrementVisitCountBy(ctx context.Context, url string, delta int) error
41}
42
43type historyMetadataUpdater interface {
44 UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
45}
46
47type paneHistoryState struct {
48 lastRawURL string
49 lastCanonicalURL string
50 lastRecordedAt time.Time
51}
52
53type historyCanonicalizationOptions struct {
54 StripTrackingParams bool
55}
56
57// NavigateUseCase handles URL navigation with history recording and zoom application.
58type NavigateUseCase struct {
59 historyRepo repository.HistoryRepository
60 zoomRepo repository.ZoomRepository
61 defaultZoom float64
62 recentMu sync.Mutex
63 recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
64 closeOnce sync.Once
65
66 // Async history recording
67 historyQueue chan historyRecord
68 done chan struct{}
69 wg sync.WaitGroup
70 ctx context.Context // Base context for background worker
71}
72
73// NewNavigateUseCase creates a new navigation use case.
74// defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
75func NewNavigateUseCase(
76 historyRepo repository.HistoryRepository,
77 zoomRepo repository.ZoomRepository,
78 defaultZoom float64,
79) *NavigateUseCase {
80 if defaultZoom <= 0 {
81 defaultZoom = entity.ZoomDefault
82 }
83
84 uc := &NavigateUseCase{
85 historyRepo: historyRepo,
86 zoomRepo: zoomRepo,
87 defaultZoom: defaultZoom,
88 recentVisits: make(map[string]paneHistoryState),
89 historyQueue: make(chan historyRecord, historyQueueSize),
90 done: make(chan struct{}),
91 ctx: context.Background(),
92 }
93
94 // Start background history worker
95 uc.wg.Add(1)
96 go uc.historyWorker()
97
98 return uc
99}
100
101// Close shuts down the background history worker and drains any pending records.
102// This should be called when the application is shutting down.
103func (uc *NavigateUseCase) Close() {
104 uc.closeOnce.Do(func() {
105 close(uc.done)
106 })
107 uc.wg.Wait()
108}
109
110// NavigateInput contains parameters for navigation.
111type NavigateInput struct {
112 URL string
113 PaneID string
114 WebView port.WebView
115}
116
117// NavigateOutput contains the result of navigation.
118type NavigateOutput struct {
119 AppliedZoom float64
120}
121
122// Execute navigates to a URL and records history.
123// Zoom is applied later via LoadCommitted event in ContentCoordinator.
124func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
125 log := logging.FromContext(ctx)
126 log.Debug().
127 Str("url", input.URL).
128 Str("pane_id", input.PaneID).
129 Msg("navigating to URL")
130
131 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
132 // History is recorded on LoadCommitted when URI is guaranteed correct
133 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
134 return nil, fmt.Errorf("failed to load URL: %w", err)
135 }
136
137 log.Info().
138 Str("url", input.URL).
139 Msg("navigation initiated")
140
141 return &NavigateOutput{
142 AppliedZoom: uc.defaultZoom,
143 }, nil
144}
145
146// historyDeduplicationWindow is the time window for deduplicating history visits.
147// Visits to the same URL within this window count as a single visit.
148// This prevents inflation from redirects and rapid navigation.
149const historyDeduplicationWindow = 2 * time.Second
150
151// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
152// are removed while canonicalizing URLs for history deduplication/storage.
153const stripTrackingParamsForHistoryDedup = true
154
155// RecordHistory queues a history entry for async recording.
156// This is non-blocking to avoid SQLite I/O on the GTK main thread.
157// Should be called on LoadCommitted when URI is guaranteed correct.
158func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
159 log := logging.FromContext(ctx)
160 rawURL = strings.TrimSpace(rawURL)
161 if rawURL == "" {
162 return
163 }
164
165 canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
166 StripTrackingParams: stripTrackingParamsForHistoryDedup,
167 })
168 if canonicalURL == "" {
169 return
170 }
171
172 paneID = normalizePaneID(paneID)
173
174 now := time.Now()
175
176 uc.recentMu.Lock()
177 state := uc.recentVisits[paneID]
178
179 // Ignore in-page hash transitions for history persistence.
180 if isHashOnlyTransition(state.lastRawURL, rawURL) {
181 state.lastRawURL = rawURL
182 uc.recentVisits[paneID] = state
183 uc.recentMu.Unlock()
184 return
185 }
186
187 // Per-pane short-window deduplication by canonical URL.
188 if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
189 state.lastRawURL = rawURL
190 uc.recentVisits[paneID] = state
191 uc.recentMu.Unlock()
192 return
193 }
194
195 state.lastRawURL = rawURL
196 state.lastCanonicalURL = canonicalURL
197 state.lastRecordedAt = now
198 uc.recentVisits[paneID] = state
199 uc.recentMu.Unlock()
200
201 // Non-blocking send to async queue
202 select {
203 case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}:
204 default:
205 // Queue full - log warning but don't block navigation
206 log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record")
207 }
208}
209
210// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
211// Callers should invoke this when a pane is closed.
212func (uc *NavigateUseCase) ClearPaneHistory(paneID string) {
213 paneID = normalizePaneID(paneID)
214 uc.recentMu.Lock()
215 delete(uc.recentVisits, paneID)
216 uc.recentMu.Unlock()
217}
218
219// historyWorker is a background goroutine that processes history records.
220// It drains the queue and persists records to the database without blocking the UI.
221func (uc *NavigateUseCase) historyWorker() {
222 defer uc.wg.Done()
223
224 log := logging.FromContext(uc.ctx).With().
225 Str("component", "history-worker").
226 Logger()
227
228 ticker := time.NewTicker(historyWorkerFlushInterval)
229 defer ticker.Stop()
230
231 pendingVisits := make(map[string]int)
232 pendingTitles := make(map[string]string)
233
234 processRecord := func(record historyRecord) {
235 if record.title != "" {
236 pendingTitles[record.url] = record.title
237 }
238 if record.visits > 0 {
239 pendingVisits[record.url] += record.visits
240 }
241 }
242
243 flushPending := func() {
244 visitCount := len(pendingVisits)
245 titleCount := len(pendingTitles)
246 if visitCount == 0 && titleCount == 0 {
247 return
248 }
249
250 log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251
252 // Swap maps first, then materialize immutable slices before invoking
253 // persistence callbacks. This keeps any re-enqueued records isolated to
254 // the fresh pending maps for the next flush cycle.
255 visits := pendingVisits
256 titles := pendingTitles
257 pendingVisits = make(map[string]int)
258 pendingTitles = make(map[string]string)
259
260 visitRecords := make([]historyRecord, 0, len(visits))
261 for historyURL, v := range visits {
262 visitRecords = append(visitRecords, historyRecord{url: historyURL, visits: v})
263 }
264 titleUpdates := make([]historyRecord, 0, len(titles))
265 for historyURL, title := range titles {
266 titleUpdates = append(titleUpdates, historyRecord{url: historyURL, title: title})
267 }
268
269 for _, record := range visitRecords {
270 uc.persistHistory(uc.ctx, record)
271 }
272 for _, record := range titleUpdates {
273 uc.persistTitleUpdate(uc.ctx, record.url, record.title)
274 }
275 }
276
277 drainQueue := func() {
278 for {
279 select {
280 case record := <-uc.historyQueue:
281 processRecord(record)
282 default:
283 return
284 }
285 }
286 }
287
288 for {
289 select {
290 case record := <-uc.historyQueue:
291 processRecord(record)
292 case <-ticker.C:
293 flushPending()
294 case <-uc.done:
295 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
296 drainQueue()
297 flushPending()
298 log.Debug().Msg("history worker shutdown complete")
299 return
300 }
301 }
302}
303
304// persistHistory writes a history record to the database.
305// Called from the background worker goroutine.
306func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
307 log := logging.FromContext(ctx)
308
309 // Check if entry exists
310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
311 if err != nil {
312 log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
313 return
314 }
315
316 if existing != nil {
317 delta := max(1, record.visits)
318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
320 log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")
321 return
322 }
323 return
324 }
325 iterations := delta
326 if iterations > historyFallbackIncrementCap {
327 log.Warn().
328 Str("url", record.url).
329 Int("delta", delta).
330 Int("cap", historyFallbackIncrementCap).
331 Msg("visit count fallback increment capped")
332 iterations = historyFallbackIncrementCap
333 }
334 for i := 0; i < iterations; i++ {
335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
336 log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
337 return
338 }
339 }
340 } else {
341 // Create new entry
342 entry := entity.NewHistoryEntry(record.url, "")
343 entry.VisitCount = int64(max(1, record.visits))
344 if err := uc.historyRepo.Save(ctx, entry); err != nil {
345 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
346 }
347 }
348}
349
350// persistTitleUpdate writes a title update to the database.
351// Called from the background worker goroutine.
352func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) {
353 log := logging.FromContext(ctx)
354
355 if title == "" {
356 return
357 }
358
359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
360 if err != nil {
361 log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")
362 return
363 }
364 if entry == nil {
365 return
366 }
367
368 entry.Title = title
369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
370 if err := updater.UpdateMetadata(ctx, entry); err != nil {
371 log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")
372 }
373 return
374 }
375 if err := uc.historyRepo.Save(ctx, entry); err != nil {
376 log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")
377 }
378}
379
380// UpdateHistoryTitle queues a title update for a history entry. The actual
381// DB write happens in the background historyWorker to avoid concurrent
382// repo access from the GTK main thread and the worker goroutine.
383func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
384 historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
385 StripTrackingParams: stripTrackingParamsForHistoryDedup,
386 })
387 if historyURL == "" {
388 return
389 }
390
391 // Non-blocking send — same queue as visit records.
392 select {
393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394 default:
395 logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")
396 }
397}
398
399// Reload reloads the current page.
400func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
401 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
402 log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
403
404 if bypassCache {
405 return webview.ReloadBypassCache(ctx)
406 }
407 return webview.Reload(ctx)
408}
409
410// Stop stops the current page load.
411func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
412 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
413 log.Debug().Msg("stopping page load")
414 return webview.Stop(ctx)
415}
416
417func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
418 raw = strings.TrimSpace(raw)
419 if raw == "" {
420 return ""
421 }
422
423 parsed, err := url.Parse(raw)
424 if err != nil {
425 return strings.TrimSuffix(raw, "/")
426 }
427
428 parsed.Scheme = strings.ToLower(parsed.Scheme)
429 parsed.Host = strings.ToLower(parsed.Host)
430 parsed.Fragment = ""
431 parsed.Path = normalizePathForHistory(parsed.Path)
432
433 query := parsed.Query()
434 if opts.StripTrackingParams {
435 for key := range query {
436 if isTrackingQueryParam(key) {
437 query.Del(key)
438 }
439 }
440 }
441 parsed.RawQuery = query.Encode()
442
443 return parsed.String()
444}
445
446func normalizePathForHistory(path string) string {
447 if path == "/" {
448 return ""
449 }
450 if strings.HasSuffix(path, "/") {
451 return strings.TrimSuffix(path, "/")
452 }
453 return path
454}
455
456func isTrackingQueryParam(key string) bool {
457 key = strings.ToLower(strings.TrimSpace(key))
458 if key == "" {
459 return false
460 }
461 if strings.HasPrefix(key, "utm_") {
462 return true
463 }
464 switch key {
465 case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
466 return true
467 }
468 return false
469}
470
471func isHashOnlyTransition(previous, current string) bool {
472 if previous == "" || current == "" || previous == current {
473 return false
474 }
475
476 prevParsed, prevErr := url.Parse(previous)
477 currParsed, currErr := url.Parse(current)
478 if prevErr != nil || currErr != nil {
479 return false
480 }
481
482 if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) {
483 return false
484 }
485 if !strings.EqualFold(prevParsed.Host, currParsed.Host) {
486 return false
487 }
488 if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) {
489 return false
490 }
491 if prevParsed.RawQuery != currParsed.RawQuery {
492 return false
493 }
494 return prevParsed.Fragment != currParsed.Fragment
495}
496
497func normalizePaneID(paneID string) string {
498 if strings.TrimSpace(paneID) == "" {
499 return "__default__"
500 }
501 return paneID
502}