v0.26.1 75cd777

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	// 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}

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