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