v0.20.0 c741a39

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
16// NavigateUseCase handles URL navigation with history recording and zoom application.
17type NavigateUseCase struct {
18	historyRepo  repository.HistoryRepository
19	zoomRepo     repository.ZoomRepository
20	defaultZoom  float64
21	recentVisits sync.Map // key: url, value: time.Time - for deduplication
22}
23
24// NewNavigateUseCase creates a new navigation use case.
25// defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
26func NewNavigateUseCase(
27	historyRepo repository.HistoryRepository,
28	zoomRepo repository.ZoomRepository,
29	defaultZoom float64,
30) *NavigateUseCase {
31	if defaultZoom <= 0 {
32		defaultZoom = entity.ZoomDefault
33	}
34	return &NavigateUseCase{
35		historyRepo: historyRepo,
36		zoomRepo:    zoomRepo,
37		defaultZoom: defaultZoom,
38	}
39}
40
41// NavigateInput contains parameters for navigation.
42type NavigateInput struct {
43	URL     string
44	PaneID  string
45	WebView port.WebView
46}
47
48// NavigateOutput contains the result of navigation.
49type NavigateOutput struct {
50	AppliedZoom float64
51}
52
53// Execute navigates to a URL and records history.
54// Zoom is applied later via LoadCommitted event in ContentCoordinator.
55func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
56	log := logging.FromContext(ctx)
57	log.Debug().
58		Str("url", input.URL).
59		Str("pane_id", input.PaneID).
60		Msg("navigating to URL")
61
62	// Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
63	// History is recorded on LoadCommitted when URI is guaranteed correct
64	if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
65		return nil, fmt.Errorf("failed to load URL: %w", err)
66	}
67
68	log.Info().
69		Str("url", input.URL).
70		Msg("navigation initiated")
71
72	return &NavigateOutput{
73		AppliedZoom: uc.defaultZoom,
74	}, nil
75}
76
77// historyDeduplicationWindow is the time window for deduplicating history visits.
78// Visits to the same URL within this window count as a single visit.
79// This prevents inflation from redirects and rapid navigation.
80const historyDeduplicationWindow = 2 * time.Second
81
82// RecordHistory saves or updates the history entry.
83// Should be called on LoadCommitted when URI is guaranteed correct.
84func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85	log := logging.FromContext(ctx)
86
87	// Normalize URL to avoid duplicates (e.g., github.com vs github.com/)
88	url = normalizeURLForHistory(url)
89
90	// Time-based deduplication: skip if same URL was recorded recently
91	now := time.Now()
92	if lastTime, ok := uc.recentVisits.Load(url); ok {
93		if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
94			log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window")
95			return
96		}
97	}
98	uc.recentVisits.Store(url, now)
99
100	// Check if entry exists
101	existing, err := uc.historyRepo.FindByURL(ctx, url)
102	if err != nil {
103		log.Warn().Err(err).Str("url", url).Msg("failed to check history")
104		return
105	}
106
107	if existing != nil {
108		// Increment visit count
109		if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
110			log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
111		}
112	} else {
113		// Create new entry
114		entry := entity.NewHistoryEntry(url, "")
115		if err := uc.historyRepo.Save(ctx, entry); err != nil {
116			log.Warn().Err(err).Str("url", url).Msg("failed to save history")
117		}
118	}
119}
120
121// UpdateHistoryTitle updates the title of a history entry after page load.
122func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
123	log := logging.FromContext(ctx)
124
125	// Normalize URL to match storage (e.g., github.com/ -> github.com)
126	url = normalizeURLForHistory(url)
127	log.Debug().Str("url", url).Str("title", title).Msg("updating history title")
128
129	entry, err := uc.historyRepo.FindByURL(ctx, url)
130	if err != nil {
131		return fmt.Errorf("failed to find history entry: %w", err)
132	}
133
134	if entry == nil {
135		// Entry doesn't exist - don't create it here
136		// Initial navigation should have already created the entry
137		// This can happen if URL changed during page load (SPA, redirect)
138		log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")
139		return nil
140	}
141
142	// Update existing entry's title
143	entry.Title = title
144	if err := uc.historyRepo.Save(ctx, entry); err != nil {
145		return fmt.Errorf("failed to update history title: %w", err)
146	}
147
148	return nil
149}
150
151// Reload reloads the current page.
152func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
153	log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
154	log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
155
156	if bypassCache {
157		return webview.ReloadBypassCache(ctx)
158	}
159	return webview.Reload(ctx)
160}
161
162// GoBack navigates back in history.
163func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
164	log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
165
166	if !webview.CanGoBack() {
167		log.Debug().Msg("cannot go back - no history")
168		return nil
169	}
170
171	log.Debug().Msg("navigating back")
172	return webview.GoBack(ctx)
173}
174
175// GoForward navigates forward in history.
176func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
177	log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
178
179	if !webview.CanGoForward() {
180		log.Debug().Msg("cannot go forward - no history")
181		return nil
182	}
183
184	log.Debug().Msg("navigating forward")
185	return webview.GoForward(ctx)
186}
187
188// Stop stops the current page load.
189func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
190	log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
191	log.Debug().Msg("stopping page load")
192	return webview.Stop(ctx)
193}
194
195// normalizeURLForHistory normalizes a URL for history storage.
196// Strips trailing slash to avoid duplicates like github.com vs github.com/
197func normalizeURLForHistory(url string) string {
198	// Don't strip if URL is just the protocol + domain (e.g., "https://github.com")
199	// Only strip trailing slash from paths
200	if strings.HasSuffix(url, "/") {
201		// Check if it's just domain with trailing slash (e.g., "https://github.com/")
202		// by counting slashes - protocol has 2, domain adds 0, path adds more
203		if strings.Count(url, "/") == 3 {
204			// It's "https://domain.com/" - strip the trailing slash
205			return strings.TrimSuffix(url, "/")
206		}
207		// For paths like "https://github.com/user/" - also strip
208		return strings.TrimSuffix(url, "/")
209	}
210	return url
211}

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