v0.28.0 3cb3770

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

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