v0.23.2 bfd5eb4

navigate.go RawHistoryFolder

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

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