v0.27.1 c78ce83

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

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