v0.29.0 21b3150

navigate.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close