detached 9b64c67

feat: implement wave1 A1-A3 stability and performance fixes

commit
9b64c671230fcb8f11875c8bebf9f49e42e2feb5
author
brice · · 2026-02-07 05:00 UTC
parent
edc44c9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +758 -298
M internal/application/port/webkit_context.go +15 -0
@@ -3,6 +3,15 @@ package port
33
44 import "context"
55
6+// WebKitCookiePolicy controls cookie acceptance behavior for NetworkSession.
7+type WebKitCookiePolicy string
8+
9+const (
10+ WebKitCookiePolicyAlways WebKitCookiePolicy = "always"
11+ WebKitCookiePolicyNoThirdParty WebKitCookiePolicy = "no_third_party"
12+ WebKitCookiePolicyNever WebKitCookiePolicy = "never"
13+)
14+
615 // MemoryPressureConfig holds memory pressure settings for a WebKit process.
716 // Zero values mean "use WebKit defaults".
817 type MemoryPressureConfig struct {
@@ -42,6 +51,12 @@ type WebKitContextOptions struct {
4251 // CacheDir is the directory for cache data.
4352 CacheDir string
4453
54+ // CookiePolicy controls cookie acceptance behavior.
55+ CookiePolicy WebKitCookiePolicy
56+
57+ // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
58+ ITPEnabled bool
59+
4560 // WebProcessMemory configures memory pressure for web processes.
4661 // nil means use WebKit defaults.
4762 WebProcessMemory *MemoryPressureConfig
@@ -3,6 +3,15 @@ package port
3 3
4 import "context"4 import "context"
5 5
6+// WebKitCookiePolicy controls cookie acceptance behavior for NetworkSession.
7+type WebKitCookiePolicy string
8+
9+const (
10+ WebKitCookiePolicyAlways WebKitCookiePolicy = "always"
11+ WebKitCookiePolicyNoThirdParty WebKitCookiePolicy = "no_third_party"
12+ WebKitCookiePolicyNever WebKitCookiePolicy = "never"
13+)
14+
6 // MemoryPressureConfig holds memory pressure settings for a WebKit process.15 // MemoryPressureConfig holds memory pressure settings for a WebKit process.
7 // Zero values mean "use WebKit defaults".16 // Zero values mean "use WebKit defaults".
8 type MemoryPressureConfig struct {17 type MemoryPressureConfig struct {
@@ -42,6 +51,12 @@ type WebKitContextOptions struct {
42 // CacheDir is the directory for cache data.51 // CacheDir is the directory for cache data.
43 CacheDir string52 CacheDir string
44 53
54+ // CookiePolicy controls cookie acceptance behavior.
55+ CookiePolicy WebKitCookiePolicy
56+
57+ // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
58+ ITPEnabled bool
59+
45 // WebProcessMemory configures memory pressure for web processes.60 // WebProcessMemory configures memory pressure for web processes.
46 // nil means use WebKit defaults.61 // nil means use WebKit defaults.
47 WebProcessMemory *MemoryPressureConfig62 WebProcessMemory *MemoryPressureConfig
M internal/application/usecase/navigate.go +196 -45
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "net/url"
67 "strings"
78 "sync"
89 "time"
@@ -20,12 +21,25 @@ const (
2021
2122 // logURLMaxLen is the max length for URLs in log messages.
2223 logURLMaxLen = 60
24+
25+ // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26+ historyWorkerFlushInterval = 100 * time.Millisecond
2327 )
2428
2529 // historyRecord holds data for async history recording.
2630 type historyRecord struct {
27- url string
28- timestamp time.Time
31+ url string
32+ visits int
33+}
34+
35+type paneHistoryState struct {
36+ lastRawURL string
37+ lastCanonicalURL string
38+ lastRecordedAt time.Time
39+}
40+
41+type historyCanonicalizationOptions struct {
42+ StripTrackingParams bool
2943 }
3044
3145 // NavigateUseCase handles URL navigation with history recording and zoom application.
@@ -33,7 +47,8 @@ type NavigateUseCase struct {
3347 historyRepo repository.HistoryRepository
3448 zoomRepo repository.ZoomRepository
3549 defaultZoom float64
36- recentVisits sync.Map // key: url, value: time.Time - for deduplication
50+ recentMu sync.Mutex
51+ recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
3752
3853 // Async history recording
3954 historyQueue chan historyRecord
@@ -57,6 +72,7 @@ func NewNavigateUseCase(
5772 historyRepo: historyRepo,
5873 zoomRepo: zoomRepo,
5974 defaultZoom: defaultZoom,
75+ recentVisits: make(map[string]paneHistoryState),
6076 historyQueue: make(chan historyRecord, historyQueueSize),
6177 done: make(chan struct{}),
6278 ctx: context.Background(),
@@ -117,33 +133,64 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
117133 // This prevents inflation from redirects and rapid navigation.
118134 const historyDeduplicationWindow = 2 * time.Second
119135
136+// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
137+// are removed while canonicalizing URLs for history deduplication/storage.
138+const stripTrackingParamsForHistoryDedup = true
139+
120140 // RecordHistory queues a history entry for async recording.
121141 // This is non-blocking to avoid SQLite I/O on the GTK main thread.
122142 // Should be called on LoadCommitted when URI is guaranteed correct.
123-func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
143+func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
124144 log := logging.FromContext(ctx)
145+ rawURL = strings.TrimSpace(rawURL)
146+ if rawURL == "" {
147+ return
148+ }
125149
126- // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)
127- url = normalizeURLForHistory(url)
150+ url := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
151+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
152+ })
153+ if url == "" {
154+ return
155+ }
156+
157+ if paneID == "" {
158+ paneID = "__default__"
159+ }
128160
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
131161 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- }
162+
163+ uc.recentMu.Lock()
164+ state := uc.recentVisits[paneID]
165+
166+ // Ignore in-page hash transitions for history persistence.
167+ if isHashOnlyTransition(state.lastRawURL, rawURL) {
168+ state.lastRawURL = rawURL
169+ uc.recentVisits[paneID] = state
170+ uc.recentMu.Unlock()
171+ return
137172 }
138- uc.recentVisits.Store(url, now)
173+
174+ // Per-pane short-window deduplication by canonical URL.
175+ if state.lastCanonicalURL == url && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
176+ state.lastRawURL = rawURL
177+ uc.recentVisits[paneID] = state
178+ uc.recentMu.Unlock()
179+ return
180+ }
181+
182+ state.lastRawURL = rawURL
183+ state.lastCanonicalURL = url
184+ state.lastRecordedAt = now
185+ uc.recentVisits[paneID] = state
186+ uc.recentMu.Unlock()
139187
140188 // Non-blocking send to async queue
141189 select {
142- case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
143- log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history record queued")
190+ case uc.historyQueue <- historyRecord{url: url, visits: 1}:
144191 default:
145192 // Queue full - log warning but don't block navigation
146- log.Warn().Str("url", url).Msg("history queue full, dropping record")
193+ log.Warn().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history queue full, dropping record")
147194 }
148195 }
149196
@@ -156,22 +203,44 @@ func (uc *NavigateUseCase) historyWorker() {
156203 Str("component", "history-worker").
157204 Logger()
158205
206+ ticker := time.NewTicker(historyWorkerFlushInterval)
207+ defer ticker.Stop()
208+
209+ pending := make(map[string]int)
210+
211+ flushPending := func() {
212+ if len(pending) == 0 {
213+ return
214+ }
215+ for historyURL, visits := range pending {
216+ uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
217+ }
218+ clear(pending)
219+ }
220+
221+ drainQueue := func() {
222+ for {
223+ select {
224+ case record := <-uc.historyQueue:
225+ pending[record.url] += record.visits
226+ default:
227+ return
228+ }
229+ }
230+ }
231+
159232 for {
160233 select {
161234 case record := <-uc.historyQueue:
162- uc.persistHistory(uc.ctx, record)
235+ pending[record.url] += record.visits
236+ case <-ticker.C:
237+ flushPending()
163238 case <-uc.done:
164- // Drain remaining records before shutdown
165239 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- }
240+ drainQueue()
241+ flushPending()
242+ log.Debug().Msg("history worker shutdown complete")
243+ return
175244 }
176245 }
177246 }
@@ -189,13 +258,16 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
189258 }
190259
191260 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")
261+ for i := 0; i < max(1, record.visits); i++ {
262+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
263+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
264+ return
265+ }
195266 }
196267 } else {
197268 // Create new entry
198269 entry := entity.NewHistoryEntry(record.url, "")
270+ entry.VisitCount = int64(max(1, record.visits))
199271 if err := uc.historyRepo.Save(ctx, entry); err != nil {
200272 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
201273 }
@@ -206,8 +278,10 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
206278 func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
207279 log := logging.FromContext(ctx)
208280
209- // Normalize URL to match storage (e.g., github.com/ -> github.com)
210- url = normalizeURLForHistory(url)
281+ // Canonicalize URL the same way history records are persisted.
282+ url = canonicalizeURLForHistory(url, historyCanonicalizationOptions{
283+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
284+ })
211285 log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title")
212286
213287 entry, err := uc.historyRepo.FindByURL(ctx, url)
@@ -252,18 +326,95 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
252326
253327 // normalizeURLForHistory normalizes a URL for history storage.
254328 // Strips trailing slash to avoid duplicates like github.com vs github.com/
255-func 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, "/")
329+func normalizeURLForHistory(raw string) string {
330+ return canonicalizeURLForHistory(raw, historyCanonicalizationOptions{
331+ StripTrackingParams: false,
332+ })
333+}
334+
335+func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
336+ raw = strings.TrimSpace(raw)
337+ if raw == "" {
338+ return ""
339+ }
340+
341+ parsed, err := url.Parse(raw)
342+ if err != nil {
343+ return strings.TrimSuffix(raw, "/")
344+ }
345+
346+ parsed.Scheme = strings.ToLower(parsed.Scheme)
347+ parsed.Host = strings.ToLower(parsed.Host)
348+ parsed.Fragment = ""
349+ parsed.Path = normalizePathForHistory(parsed.Path)
350+
351+ query := parsed.Query()
352+ if opts.StripTrackingParams {
353+ for key := range query {
354+ if isTrackingQueryParam(key) {
355+ query.Del(key)
356+ }
264357 }
265- // For paths like "https://github.com/user/" - also strip
266- return strings.TrimSuffix(url, "/")
267358 }
268- return url
359+ parsed.RawQuery = query.Encode()
360+
361+ return parsed.String()
362+}
363+
364+func normalizePathForHistory(path string) string {
365+ if path == "/" {
366+ return ""
367+ }
368+ if strings.HasSuffix(path, "/") {
369+ return strings.TrimSuffix(path, "/")
370+ }
371+ return path
372+}
373+
374+func isTrackingQueryParam(key string) bool {
375+ key = strings.ToLower(strings.TrimSpace(key))
376+ if key == "" {
377+ return false
378+ }
379+ if strings.HasPrefix(key, "utm_") {
380+ return true
381+ }
382+ switch key {
383+ case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
384+ return true
385+ }
386+ return false
387+}
388+
389+func isHashOnlyTransition(previous, current string) bool {
390+ if previous == "" || current == "" || previous == current {
391+ return false
392+ }
393+
394+ prevParsed, prevErr := url.Parse(previous)
395+ currParsed, currErr := url.Parse(current)
396+ if prevErr != nil || currErr != nil {
397+ return false
398+ }
399+
400+ if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) {
401+ return false
402+ }
403+ if !strings.EqualFold(prevParsed.Host, currParsed.Host) {
404+ return false
405+ }
406+ if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) {
407+ return false
408+ }
409+ if prevParsed.RawQuery != currParsed.RawQuery {
410+ return false
411+ }
412+ return prevParsed.Fragment != currParsed.Fragment
413+}
414+
415+func max(a, b int) int {
416+ if a > b {
417+ return a
418+ }
419+ return b
269420 }
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "net/url"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "time"9 "time"
@@ -20,12 +21,25 @@ const (
20 21
21 // logURLMaxLen is the max length for URLs in log messages.22 // logURLMaxLen is the max length for URLs in log messages.
22 logURLMaxLen = 6023 logURLMaxLen = 60
24+
25+ // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26+ historyWorkerFlushInterval = 100 * time.Millisecond
23 )27 )
24 28
25 // historyRecord holds data for async history recording.29 // historyRecord holds data for async history recording.
26 type historyRecord struct {30 type historyRecord struct {
27- url string31+ url string
28- timestamp time.Time32+ visits int
33+}
34+
35+type paneHistoryState struct {
36+ lastRawURL string
37+ lastCanonicalURL string
38+ lastRecordedAt time.Time
39+}
40+
41+type historyCanonicalizationOptions struct {
42+ StripTrackingParams bool
29 }43 }
30 44
31 // NavigateUseCase handles URL navigation with history recording and zoom application.45 // NavigateUseCase handles URL navigation with history recording and zoom application.
@@ -33,7 +47,8 @@ type NavigateUseCase struct {
33 historyRepo repository.HistoryRepository47 historyRepo repository.HistoryRepository
34 zoomRepo repository.ZoomRepository48 zoomRepo repository.ZoomRepository
35 defaultZoom float6449 defaultZoom float64
36- recentVisits sync.Map // key: url, value: time.Time - for deduplication50+ recentMu sync.Mutex
51+ recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
37 52
38 // Async history recording53 // Async history recording
39 historyQueue chan historyRecord54 historyQueue chan historyRecord
@@ -57,6 +72,7 @@ func NewNavigateUseCase(
57 historyRepo: historyRepo,72 historyRepo: historyRepo,
58 zoomRepo: zoomRepo,73 zoomRepo: zoomRepo,
59 defaultZoom: defaultZoom,74 defaultZoom: defaultZoom,
75+ recentVisits: make(map[string]paneHistoryState),
60 historyQueue: make(chan historyRecord, historyQueueSize),76 historyQueue: make(chan historyRecord, historyQueueSize),
61 done: make(chan struct{}),77 done: make(chan struct{}),
62 ctx: context.Background(),78 ctx: context.Background(),
@@ -117,33 +133,64 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
117 // This prevents inflation from redirects and rapid navigation.133 // This prevents inflation from redirects and rapid navigation.
118 const historyDeduplicationWindow = 2 * time.Second134 const historyDeduplicationWindow = 2 * time.Second
119 135
136+// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
137+// are removed while canonicalizing URLs for history deduplication/storage.
138+const stripTrackingParamsForHistoryDedup = true
139+
120 // RecordHistory queues a history entry for async recording.140 // RecordHistory queues a history entry for async recording.
121 // This is non-blocking to avoid SQLite I/O on the GTK main thread.141 // 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.142 // Should be called on LoadCommitted when URI is guaranteed correct.
123-func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {143+func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
124 log := logging.FromContext(ctx)144 log := logging.FromContext(ctx)
145+ rawURL = strings.TrimSpace(rawURL)
146+ if rawURL == "" {
147+ return
148+ }
125 149
126- // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)150+ url := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
127- url = normalizeURLForHistory(url)151+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
152+ })
153+ if url == "" {
154+ return
155+ }
156+
157+ if paneID == "" {
158+ paneID = "__default__"
159+ }
128 160
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()161 now := time.Now()
132- if lastTime, ok := uc.recentVisits.Load(url); ok {162+
133- if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {163+ uc.recentMu.Lock()
134- log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window")164+ state := uc.recentVisits[paneID]
135- return165+
136- }166+ // Ignore in-page hash transitions for history persistence.
167+ if isHashOnlyTransition(state.lastRawURL, rawURL) {
168+ state.lastRawURL = rawURL
169+ uc.recentVisits[paneID] = state
170+ uc.recentMu.Unlock()
171+ return
137 }172 }
138- uc.recentVisits.Store(url, now)173+
174+ // Per-pane short-window deduplication by canonical URL.
175+ if state.lastCanonicalURL == url && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
176+ state.lastRawURL = rawURL
177+ uc.recentVisits[paneID] = state
178+ uc.recentMu.Unlock()
179+ return
180+ }
181+
182+ state.lastRawURL = rawURL
183+ state.lastCanonicalURL = url
184+ state.lastRecordedAt = now
185+ uc.recentVisits[paneID] = state
186+ uc.recentMu.Unlock()
139 187
140 // Non-blocking send to async queue188 // Non-blocking send to async queue
141 select {189 select {
142- case uc.historyQueue <- historyRecord{url: url, timestamp: now}:190+ case uc.historyQueue <- historyRecord{url: url, visits: 1}:
143- log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history record queued")
144 default:191 default:
145 // Queue full - log warning but don't block navigation192 // Queue full - log warning but don't block navigation
146- log.Warn().Str("url", url).Msg("history queue full, dropping record")193+ log.Warn().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history queue full, dropping record")
147 }194 }
148 }195 }
149 196
@@ -156,22 +203,44 @@ func (uc *NavigateUseCase) historyWorker() {
156 Str("component", "history-worker").203 Str("component", "history-worker").
157 Logger()204 Logger()
158 205
206+ ticker := time.NewTicker(historyWorkerFlushInterval)
207+ defer ticker.Stop()
208+
209+ pending := make(map[string]int)
210+
211+ flushPending := func() {
212+ if len(pending) == 0 {
213+ return
214+ }
215+ for historyURL, visits := range pending {
216+ uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
217+ }
218+ clear(pending)
219+ }
220+
221+ drainQueue := func() {
222+ for {
223+ select {
224+ case record := <-uc.historyQueue:
225+ pending[record.url] += record.visits
226+ default:
227+ return
228+ }
229+ }
230+ }
231+
159 for {232 for {
160 select {233 select {
161 case record := <-uc.historyQueue:234 case record := <-uc.historyQueue:
162- uc.persistHistory(uc.ctx, record)235+ pending[record.url] += record.visits
236+ case <-ticker.C:
237+ flushPending()
163 case <-uc.done:238 case <-uc.done:
164- // Drain remaining records before shutdown
165 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")239 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
166- for {240+ drainQueue()
167- select {241+ flushPending()
168- case record := <-uc.historyQueue:242+ log.Debug().Msg("history worker shutdown complete")
169- uc.persistHistory(uc.ctx, record)243+ return
170- default:
171- log.Debug().Msg("history worker shutdown complete")
172- return
173- }
174- }
175 }244 }
176 }245 }
177 }246 }
@@ -189,13 +258,16 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
189 }258 }
190 259
191 if existing != nil {260 if existing != nil {
192- // Increment visit count261+ for i := 0; i < max(1, record.visits); i++ {
193- if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {262+ 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")263+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
264+ return
265+ }
195 }266 }
196 } else {267 } else {
197 // Create new entry268 // Create new entry
198 entry := entity.NewHistoryEntry(record.url, "")269 entry := entity.NewHistoryEntry(record.url, "")
270+ entry.VisitCount = int64(max(1, record.visits))
199 if err := uc.historyRepo.Save(ctx, entry); err != nil {271 if err := uc.historyRepo.Save(ctx, entry); err != nil {
200 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")272 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
201 }273 }
@@ -206,8 +278,10 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
206 func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {278 func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
207 log := logging.FromContext(ctx)279 log := logging.FromContext(ctx)
208 280
209- // Normalize URL to match storage (e.g., github.com/ -> github.com)281+ // Canonicalize URL the same way history records are persisted.
210- url = normalizeURLForHistory(url)282+ url = canonicalizeURLForHistory(url, historyCanonicalizationOptions{
283+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
284+ })
211 log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title")285 log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title")
212 286
213 entry, err := uc.historyRepo.FindByURL(ctx, url)287 entry, err := uc.historyRepo.FindByURL(ctx, url)
@@ -252,18 +326,95 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
252 326
253 // normalizeURLForHistory normalizes a URL for history storage.327 // normalizeURLForHistory normalizes a URL for history storage.
254 // Strips trailing slash to avoid duplicates like github.com vs github.com/328 // Strips trailing slash to avoid duplicates like github.com vs github.com/
255-func normalizeURLForHistory(url string) string {329+func normalizeURLForHistory(raw string) string {
256- // Don't strip if URL is just the protocol + domain (e.g., "https://github.com")330+ return canonicalizeURLForHistory(raw, historyCanonicalizationOptions{
257- // Only strip trailing slash from paths331+ StripTrackingParams: false,
258- if strings.HasSuffix(url, "/") {332+ })
259- // Check if it's just domain with trailing slash (e.g., "https://github.com/")333+}
260- // by counting slashes - protocol has 2, domain adds 0, path adds more334+
261- if strings.Count(url, "/") == 3 {335+func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
262- // It's "https://domain.com/" - strip the trailing slash336+ raw = strings.TrimSpace(raw)
263- return strings.TrimSuffix(url, "/")337+ if raw == "" {
338+ return ""
339+ }
340+
341+ parsed, err := url.Parse(raw)
342+ if err != nil {
343+ return strings.TrimSuffix(raw, "/")
344+ }
345+
346+ parsed.Scheme = strings.ToLower(parsed.Scheme)
347+ parsed.Host = strings.ToLower(parsed.Host)
348+ parsed.Fragment = ""
349+ parsed.Path = normalizePathForHistory(parsed.Path)
350+
351+ query := parsed.Query()
352+ if opts.StripTrackingParams {
353+ for key := range query {
354+ if isTrackingQueryParam(key) {
355+ query.Del(key)
356+ }
264 }357 }
265- // For paths like "https://github.com/user/" - also strip
266- return strings.TrimSuffix(url, "/")
267 }358 }
268- return url359+ parsed.RawQuery = query.Encode()
360+
361+ return parsed.String()
362+}
363+
364+func normalizePathForHistory(path string) string {
365+ if path == "/" {
366+ return ""
367+ }
368+ if strings.HasSuffix(path, "/") {
369+ return strings.TrimSuffix(path, "/")
370+ }
371+ return path
372+}
373+
374+func isTrackingQueryParam(key string) bool {
375+ key = strings.ToLower(strings.TrimSpace(key))
376+ if key == "" {
377+ return false
378+ }
379+ if strings.HasPrefix(key, "utm_") {
380+ return true
381+ }
382+ switch key {
383+ case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
384+ return true
385+ }
386+ return false
387+}
388+
389+func isHashOnlyTransition(previous, current string) bool {
390+ if previous == "" || current == "" || previous == current {
391+ return false
392+ }
393+
394+ prevParsed, prevErr := url.Parse(previous)
395+ currParsed, currErr := url.Parse(current)
396+ if prevErr != nil || currErr != nil {
397+ return false
398+ }
399+
400+ if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) {
401+ return false
402+ }
403+ if !strings.EqualFold(prevParsed.Host, currParsed.Host) {
404+ return false
405+ }
406+ if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) {
407+ return false
408+ }
409+ if prevParsed.RawQuery != currParsed.RawQuery {
410+ return false
411+ }
412+ return prevParsed.Fragment != currParsed.Fragment
413+}
414+
415+func max(a, b int) int {
416+ if a > b {
417+ return a
418+ }
419+ return b
269 }420 }
A internal/application/usecase/navigate_test.go +60 -0
@@ -0,0 +1,60 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
14+ got := canonicalizeURLForHistory(
15+ "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
16+ historyCanonicalizationOptions{StripTrackingParams: true},
17+ )
18+
19+ require.Equal(t, "https://example.com/path?a=2&b=1", got)
20+}
21+
22+func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
23+ got := canonicalizeURLForHistory(
24+ "https://example.com/path/?utm_source=newsletter&b=1#section-1",
25+ historyCanonicalizationOptions{StripTrackingParams: false},
26+ )
27+
28+ require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
29+}
30+
31+func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {
32+ ctx := context.Background()
33+ repo := repomocks.NewMockHistoryRepository(t)
34+
35+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
36+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
37+ return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
38+ })).Return(nil).Once()
39+
40+ uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
41+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
42+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
43+ uc.Close()
44+}
45+
46+func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
47+ ctx := context.Background()
48+ repo := repomocks.NewMockHistoryRepository(t)
49+
50+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
51+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
52+ return entry.URL == "https://example.com/article" && entry.VisitCount == 2
53+ })).Return(nil).Once()
54+
55+ uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
56+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
57+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
58+ uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
59+ uc.Close()
60+}
@@ -0,0 +1,60 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
14+ got := canonicalizeURLForHistory(
15+ "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
16+ historyCanonicalizationOptions{StripTrackingParams: true},
17+ )
18+
19+ require.Equal(t, "https://example.com/path?a=2&b=1", got)
20+}
21+
22+func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
23+ got := canonicalizeURLForHistory(
24+ "https://example.com/path/?utm_source=newsletter&b=1#section-1",
25+ historyCanonicalizationOptions{StripTrackingParams: false},
26+ )
27+
28+ require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
29+}
30+
31+func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {
32+ ctx := context.Background()
33+ repo := repomocks.NewMockHistoryRepository(t)
34+
35+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
36+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
37+ return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
38+ })).Return(nil).Once()
39+
40+ uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
41+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
42+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
43+ uc.Close()
44+}
45+
46+func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
47+ ctx := context.Background()
48+ repo := repomocks.NewMockHistoryRepository(t)
49+
50+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
51+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
52+ return entry.URL == "https://example.com/article" && entry.VisitCount == 2
53+ })).Return(nil).Once()
54+
55+ uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
56+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
57+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
58+ uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
59+ uc.Close()
60+}
M internal/bootstrap/webkit_stack.go +21 -4
@@ -46,7 +46,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
4646 configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
4747 logging.Trace().Mark("render_env")
4848
49- wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings)
49+ wkOpts := buildWebKitContextOptions(cfg, input.DataDir, input.CacheDir, &perfSettings)
50+ logger.Info().
51+ Str("cookie_policy", string(wkOpts.CookiePolicy)).
52+ Bool("itp_enabled", wkOpts.ITPEnabled).
53+ Msg("webkit privacy configuration")
5054 wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
5155 if err != nil {
5256 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
@@ -97,10 +101,23 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo
97101 return perfSettings
98102 }
99103
100-func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions {
104+func buildWebKitContextOptions(
105+ cfg *config.Config,
106+ dataDir, cacheDir string,
107+ perfSettings *config.ResolvedPerformanceSettings,
108+) port.WebKitContextOptions {
109+ cookiePolicy := port.WebKitCookiePolicyNoThirdParty
110+ itpEnabled := true
111+ if cfg != nil {
112+ cookiePolicy = port.WebKitCookiePolicy(cfg.Privacy.CookiePolicy)
113+ itpEnabled = cfg.Privacy.ITPEnabled
114+ }
115+
101116 wkOpts := port.WebKitContextOptions{
102- DataDir: dataDir,
103- CacheDir: cacheDir,
117+ DataDir: dataDir,
118+ CacheDir: cacheDir,
119+ CookiePolicy: cookiePolicy,
120+ ITPEnabled: itpEnabled,
104121 }
105122
106123 if hasWebProcessMemoryConfig(perfSettings) {
@@ -46,7 +46,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
46 configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)46 configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
47 logging.Trace().Mark("render_env")47 logging.Trace().Mark("render_env")
48 48
49- wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings)49+ wkOpts := buildWebKitContextOptions(cfg, input.DataDir, input.CacheDir, &perfSettings)
50+ logger.Info().
51+ Str("cookie_policy", string(wkOpts.CookiePolicy)).
52+ Bool("itp_enabled", wkOpts.ITPEnabled).
53+ Msg("webkit privacy configuration")
50 wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)54 wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
51 if err != nil {55 if err != nil {
52 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")56 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
@@ -97,10 +101,23 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo
97 return perfSettings101 return perfSettings
98 }102 }
99 103
100-func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions {104+func buildWebKitContextOptions(
105+ cfg *config.Config,
106+ dataDir, cacheDir string,
107+ perfSettings *config.ResolvedPerformanceSettings,
108+) port.WebKitContextOptions {
109+ cookiePolicy := port.WebKitCookiePolicyNoThirdParty
110+ itpEnabled := true
111+ if cfg != nil {
112+ cookiePolicy = port.WebKitCookiePolicy(cfg.Privacy.CookiePolicy)
113+ itpEnabled = cfg.Privacy.ITPEnabled
114+ }
115+
101 wkOpts := port.WebKitContextOptions{116 wkOpts := port.WebKitContextOptions{
102- DataDir: dataDir,117+ DataDir: dataDir,
103- CacheDir: cacheDir,118+ CacheDir: cacheDir,
119+ CookiePolicy: cookiePolicy,
120+ ITPEnabled: itpEnabled,
104 }121 }
105 122
106 if hasWebProcessMemoryConfig(perfSettings) {123 if hasWebProcessMemoryConfig(perfSettings) {
A internal/bootstrap/webkit_stack_test.go +49 -0
@@ -0,0 +1,49 @@
1+package bootstrap
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) {
13+ cfg := config.DefaultConfig()
14+ cfg.Privacy.CookiePolicy = config.CookiePolicyNever
15+ cfg.Privacy.ITPEnabled = false
16+
17+ perf := &config.ResolvedPerformanceSettings{
18+ WebProcessMemoryLimitMB: 1024,
19+ WebProcessMemoryPollIntervalSec: 11,
20+ WebProcessMemoryConservativeThreshold: 0.25,
21+ WebProcessMemoryStrictThreshold: 0.5,
22+ NetworkProcessMemoryLimitMB: 512,
23+ NetworkProcessMemoryPollIntervalSec: 13,
24+ }
25+
26+ opts := buildWebKitContextOptions(cfg, "/tmp/data", "/tmp/cache", perf)
27+
28+ assert.Equal(t, "/tmp/data", opts.DataDir)
29+ assert.Equal(t, "/tmp/cache", opts.CacheDir)
30+ assert.Equal(t, port.WebKitCookiePolicyNever, opts.CookiePolicy)
31+ assert.False(t, opts.ITPEnabled)
32+
33+ require.NotNil(t, opts.WebProcessMemory)
34+ assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)
35+ assert.Equal(t, 11.0, opts.WebProcessMemory.PollIntervalSec)
36+ assert.Equal(t, 0.25, opts.WebProcessMemory.ConservativeThreshold)
37+ assert.Equal(t, 0.5, opts.WebProcessMemory.StrictThreshold)
38+
39+ require.NotNil(t, opts.NetworkProcessMemory)
40+ assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)
41+ assert.Equal(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec)
42+}
43+
44+func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
45+ opts := buildWebKitContextOptions(nil, "/tmp/data", "/tmp/cache", &config.ResolvedPerformanceSettings{})
46+
47+ assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
48+ assert.True(t, opts.ITPEnabled)
49+}
@@ -0,0 +1,49 @@
1+package bootstrap
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) {
13+ cfg := config.DefaultConfig()
14+ cfg.Privacy.CookiePolicy = config.CookiePolicyNever
15+ cfg.Privacy.ITPEnabled = false
16+
17+ perf := &config.ResolvedPerformanceSettings{
18+ WebProcessMemoryLimitMB: 1024,
19+ WebProcessMemoryPollIntervalSec: 11,
20+ WebProcessMemoryConservativeThreshold: 0.25,
21+ WebProcessMemoryStrictThreshold: 0.5,
22+ NetworkProcessMemoryLimitMB: 512,
23+ NetworkProcessMemoryPollIntervalSec: 13,
24+ }
25+
26+ opts := buildWebKitContextOptions(cfg, "/tmp/data", "/tmp/cache", perf)
27+
28+ assert.Equal(t, "/tmp/data", opts.DataDir)
29+ assert.Equal(t, "/tmp/cache", opts.CacheDir)
30+ assert.Equal(t, port.WebKitCookiePolicyNever, opts.CookiePolicy)
31+ assert.False(t, opts.ITPEnabled)
32+
33+ require.NotNil(t, opts.WebProcessMemory)
34+ assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)
35+ assert.Equal(t, 11.0, opts.WebProcessMemory.PollIntervalSec)
36+ assert.Equal(t, 0.25, opts.WebProcessMemory.ConservativeThreshold)
37+ assert.Equal(t, 0.5, opts.WebProcessMemory.StrictThreshold)
38+
39+ require.NotNil(t, opts.NetworkProcessMemory)
40+ assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)
41+ assert.Equal(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec)
42+}
43+
44+func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
45+ opts := buildWebKitContextOptions(nil, "/tmp/data", "/tmp/cache", &config.ResolvedPerformanceSettings{})
46+
47+ assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
48+ assert.True(t, opts.ITPEnabled)
49+}
M internal/infrastructure/config/defaults.go +4 -0
@@ -153,6 +153,10 @@ func DefaultConfig() *Config {
153153 SampleMemory: false,
154154 DebugFrames: false,
155155 },
156+ Privacy: PrivacyConfig{
157+ CookiePolicy: CookiePolicyNoThirdParty,
158+ ITPEnabled: true,
159+ },
156160 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
157161 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
158162 Workspace: WorkspaceConfig{
@@ -153,6 +153,10 @@ func DefaultConfig() *Config {
153 SampleMemory: false,153 SampleMemory: false,
154 DebugFrames: false,154 DebugFrames: false,
155 },155 },
156+ Privacy: PrivacyConfig{
157+ CookiePolicy: CookiePolicyNoThirdParty,
158+ ITPEnabled: true,
159+ },
156 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability160 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
157 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%161 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
158 Workspace: WorkspaceConfig{162 Workspace: WorkspaceConfig{
M internal/infrastructure/config/defaults_test.go +2 -0
@@ -10,4 +10,6 @@ func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) {
1010 cfg := DefaultConfig()
1111 assert.Equal(t, "info", cfg.Logging.Level)
1212 assert.False(t, cfg.Logging.CaptureGTKLogs)
13+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
14+ assert.True(t, cfg.Privacy.ITPEnabled)
1315 }
@@ -10,4 +10,6 @@ func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) {
10 cfg := DefaultConfig()10 cfg := DefaultConfig()
11 assert.Equal(t, "info", cfg.Logging.Level)11 assert.Equal(t, "info", cfg.Logging.Level)
12 assert.False(t, cfg.Logging.CaptureGTKLogs)12 assert.False(t, cfg.Logging.CaptureGTKLogs)
13+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
14+ assert.True(t, cfg.Privacy.ITPEnabled)
13 }15 }
M internal/infrastructure/config/loader.go +17 -0
@@ -201,6 +201,17 @@ func normalizeConfig(config *Config) {
201201 config.Rendering.GSKRenderer = GSKRendererAuto
202202 }
203203
204+ switch strings.ToLower(string(config.Privacy.CookiePolicy)) {
205+ case "", string(CookiePolicyNoThirdParty):
206+ config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
207+ case string(CookiePolicyAlways):
208+ config.Privacy.CookiePolicy = CookiePolicyAlways
209+ case string(CookiePolicyNever):
210+ config.Privacy.CookiePolicy = CookiePolicyNever
211+ default:
212+ config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
213+ }
214+
204215 switch config.Appearance.ColorScheme {
205216 case ThemePreferDark, ThemePreferLight, ThemeDefault:
206217 case "":
@@ -373,6 +384,7 @@ func (m *Manager) setDefaults() {
373384 m.setDebugDefaults(defaults)
374385 m.setAppearanceDefaults(defaults)
375386 m.setRenderingDefaults(defaults)
387+ m.setPrivacyDefaults(defaults)
376388 m.setWorkspaceDefaults(defaults)
377389 m.setContentFilteringDefaults(defaults)
378390 m.setClipboardDefaults(defaults)
@@ -447,6 +459,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) {
447459 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
448460 }
449461
462+func (m *Manager) setPrivacyDefaults(defaults *Config) {
463+ m.viper.SetDefault("privacy.cookie_policy", string(defaults.Privacy.CookiePolicy))
464+ m.viper.SetDefault("privacy.itp_enabled", defaults.Privacy.ITPEnabled)
465+}
466+
450467 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
451468 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
452469 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
@@ -201,6 +201,17 @@ func normalizeConfig(config *Config) {
201 config.Rendering.GSKRenderer = GSKRendererAuto201 config.Rendering.GSKRenderer = GSKRendererAuto
202 }202 }
203 203
204+ switch strings.ToLower(string(config.Privacy.CookiePolicy)) {
205+ case "", string(CookiePolicyNoThirdParty):
206+ config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
207+ case string(CookiePolicyAlways):
208+ config.Privacy.CookiePolicy = CookiePolicyAlways
209+ case string(CookiePolicyNever):
210+ config.Privacy.CookiePolicy = CookiePolicyNever
211+ default:
212+ config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
213+ }
214+
204 switch config.Appearance.ColorScheme {215 switch config.Appearance.ColorScheme {
205 case ThemePreferDark, ThemePreferLight, ThemeDefault:216 case ThemePreferDark, ThemePreferLight, ThemeDefault:
206 case "":217 case "":
@@ -373,6 +384,7 @@ func (m *Manager) setDefaults() {
373 m.setDebugDefaults(defaults)384 m.setDebugDefaults(defaults)
374 m.setAppearanceDefaults(defaults)385 m.setAppearanceDefaults(defaults)
375 m.setRenderingDefaults(defaults)386 m.setRenderingDefaults(defaults)
387+ m.setPrivacyDefaults(defaults)
376 m.setWorkspaceDefaults(defaults)388 m.setWorkspaceDefaults(defaults)
377 m.setContentFilteringDefaults(defaults)389 m.setContentFilteringDefaults(defaults)
378 m.setClipboardDefaults(defaults)390 m.setClipboardDefaults(defaults)
@@ -447,6 +459,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) {
447 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)459 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
448 }460 }
449 461
462+func (m *Manager) setPrivacyDefaults(defaults *Config) {
463+ m.viper.SetDefault("privacy.cookie_policy", string(defaults.Privacy.CookiePolicy))
464+ m.viper.SetDefault("privacy.itp_enabled", defaults.Privacy.ITPEnabled)
465+}
466+
450 func (m *Manager) setWorkspaceDefaults(defaults *Config) {467 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
451 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)468 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
452 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)469 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
A internal/infrastructure/config/loader_test.go +25 -0
@@ -0,0 +1,25 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestSetPrivacyDefaults(t *testing.T) {
11+ mgr := &Manager{viper: viper.New()}
12+ mgr.setDefaults()
13+
14+ assert.Equal(t, "no_third_party", mgr.viper.GetString("privacy.cookie_policy"))
15+ assert.True(t, mgr.viper.GetBool("privacy.itp_enabled"))
16+}
17+
18+func TestNormalizeConfig_PrivacyCookiePolicy(t *testing.T) {
19+ cfg := DefaultConfig()
20+ cfg.Privacy.CookiePolicy = CookiePolicy("INVALID")
21+
22+ normalizeConfig(cfg)
23+
24+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
25+}
@@ -0,0 +1,25 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestSetPrivacyDefaults(t *testing.T) {
11+ mgr := &Manager{viper: viper.New()}
12+ mgr.setDefaults()
13+
14+ assert.Equal(t, "no_third_party", mgr.viper.GetString("privacy.cookie_policy"))
15+ assert.True(t, mgr.viper.GetBool("privacy.itp_enabled"))
16+}
17+
18+func TestNormalizeConfig_PrivacyCookiePolicy(t *testing.T) {
19+ cfg := DefaultConfig()
20+ cfg.Privacy.CookiePolicy = CookiePolicy("INVALID")
21+
22+ normalizeConfig(cfg)
23+
24+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
25+}
M internal/infrastructure/config/schema.go +23 -0
@@ -27,6 +27,8 @@ type Config struct {
2727 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
2828 // Rendering controls WebKit, GTK and compositor behavior.
2929 Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"`
30+ // Privacy controls cookie acceptance and tracking prevention.
31+ Privacy PrivacyConfig `mapstructure:"privacy" yaml:"privacy" toml:"privacy"`
3032 // Media controls video playback and hardware acceleration
3133 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
3234 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
@@ -48,6 +50,18 @@ const (
4850 RenderingModeCPU RenderingMode = "cpu"
4951 )
5052
53+// CookiePolicy controls cookie acceptance behavior.
54+type CookiePolicy string
55+
56+const (
57+ // CookiePolicyAlways accepts all cookies.
58+ CookiePolicyAlways CookiePolicy = "always"
59+ // CookiePolicyNoThirdParty blocks third-party cookies.
60+ CookiePolicyNoThirdParty CookiePolicy = "no_third_party"
61+ // CookiePolicyNever blocks all cookies.
62+ CookiePolicyNever CookiePolicy = "never"
63+)
64+
5165 // GSKRendererMode controls the GTK Scene Kit renderer selection.
5266 type GSKRendererMode string
5367
@@ -103,6 +117,15 @@ type RenderingConfig struct {
103117 DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`
104118 }
105119
120+// PrivacyConfig controls cookie behavior and tracking prevention.
121+type PrivacyConfig struct {
122+ // CookiePolicy controls cookie acceptance behavior.
123+ // Values: "always", "no_third_party", "never"
124+ CookiePolicy CookiePolicy `mapstructure:"cookie_policy" yaml:"cookie_policy" toml:"cookie_policy"`
125+ // ITPEnabled enables Intelligent Tracking Prevention.
126+ ITPEnabled bool `mapstructure:"itp_enabled" yaml:"itp_enabled" toml:"itp_enabled"`
127+}
128+
106129 // HardwareDecodingMode controls video hardware acceleration.
107130 type HardwareDecodingMode string
108131
@@ -27,6 +27,8 @@ type Config struct {
27 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`27 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
28 // Rendering controls WebKit, GTK and compositor behavior.28 // Rendering controls WebKit, GTK and compositor behavior.
29 Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"`29 Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"`
30+ // Privacy controls cookie acceptance and tracking prevention.
31+ Privacy PrivacyConfig `mapstructure:"privacy" yaml:"privacy" toml:"privacy"`
30 // Media controls video playback and hardware acceleration32 // Media controls video playback and hardware acceleration
31 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`33 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
32 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).34 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
@@ -48,6 +50,18 @@ const (
48 RenderingModeCPU RenderingMode = "cpu"50 RenderingModeCPU RenderingMode = "cpu"
49 )51 )
50 52
53+// CookiePolicy controls cookie acceptance behavior.
54+type CookiePolicy string
55+
56+const (
57+ // CookiePolicyAlways accepts all cookies.
58+ CookiePolicyAlways CookiePolicy = "always"
59+ // CookiePolicyNoThirdParty blocks third-party cookies.
60+ CookiePolicyNoThirdParty CookiePolicy = "no_third_party"
61+ // CookiePolicyNever blocks all cookies.
62+ CookiePolicyNever CookiePolicy = "never"
63+)
64+
51 // GSKRendererMode controls the GTK Scene Kit renderer selection.65 // GSKRendererMode controls the GTK Scene Kit renderer selection.
52 type GSKRendererMode string66 type GSKRendererMode string
53 67
@@ -103,6 +117,15 @@ type RenderingConfig struct {
103 DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`117 DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`
104 }118 }
105 119
120+// PrivacyConfig controls cookie behavior and tracking prevention.
121+type PrivacyConfig struct {
122+ // CookiePolicy controls cookie acceptance behavior.
123+ // Values: "always", "no_third_party", "never"
124+ CookiePolicy CookiePolicy `mapstructure:"cookie_policy" yaml:"cookie_policy" toml:"cookie_policy"`
125+ // ITPEnabled enables Intelligent Tracking Prevention.
126+ ITPEnabled bool `mapstructure:"itp_enabled" yaml:"itp_enabled" toml:"itp_enabled"`
127+}
128+
106 // HardwareDecodingMode controls video hardware acceleration.129 // HardwareDecodingMode controls video hardware acceleration.
107 type HardwareDecodingMode string130 type HardwareDecodingMode string
108 131
M internal/infrastructure/config/validation.go +13 -0
@@ -26,6 +26,7 @@ func validateConfig(config *Config) error {
2626 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
2727 validationErrors = append(validationErrors, validateOmnibox(config)...)
2828 validationErrors = append(validationErrors, validateRendering(config)...)
29+ validationErrors = append(validationErrors, validatePrivacy(config)...)
2930 validationErrors = append(validationErrors, validateColorScheme(config)...)
3031 validationErrors = append(validationErrors, validateSession(config)...)
3132 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
@@ -323,6 +324,18 @@ func validateRendering(config *Config) []string {
323324 return validationErrors
324325 }
325326
327+func validatePrivacy(config *Config) []string {
328+ switch config.Privacy.CookiePolicy {
329+ case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
330+ return nil
331+ default:
332+ return []string{fmt.Sprintf(
333+ "privacy.cookie_policy must be one of: always, no_third_party, never (got: %s)",
334+ config.Privacy.CookiePolicy,
335+ )}
336+ }
337+}
338+
326339 func validateColorScheme(config *Config) []string {
327340 switch config.Appearance.ColorScheme {
328341 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
@@ -26,6 +26,7 @@ func validateConfig(config *Config) error {
26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
27 validationErrors = append(validationErrors, validateOmnibox(config)...)27 validationErrors = append(validationErrors, validateOmnibox(config)...)
28 validationErrors = append(validationErrors, validateRendering(config)...)28 validationErrors = append(validationErrors, validateRendering(config)...)
29+ validationErrors = append(validationErrors, validatePrivacy(config)...)
29 validationErrors = append(validationErrors, validateColorScheme(config)...)30 validationErrors = append(validationErrors, validateColorScheme(config)...)
30 validationErrors = append(validationErrors, validateSession(config)...)31 validationErrors = append(validationErrors, validateSession(config)...)
31 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)32 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
@@ -323,6 +324,18 @@ func validateRendering(config *Config) []string {
323 return validationErrors324 return validationErrors
324 }325 }
325 326
327+func validatePrivacy(config *Config) []string {
328+ switch config.Privacy.CookiePolicy {
329+ case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
330+ return nil
331+ default:
332+ return []string{fmt.Sprintf(
333+ "privacy.cookie_policy must be one of: always, no_third_party, never (got: %s)",
334+ config.Privacy.CookiePolicy,
335+ )}
336+ }
337+}
338+
326 func validateColorScheme(config *Config) []string {339 func validateColorScheme(config *Config) []string {
327 switch config.Appearance.ColorScheme {340 switch config.Appearance.ColorScheme {
328 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":341 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
A internal/infrastructure/config/validation_test.go +36 -0
@@ -0,0 +1,36 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ cookiePolicy CookiePolicy
14+ wantErr bool
15+ }{
16+ {name: "always", cookiePolicy: CookiePolicyAlways, wantErr: false},
17+ {name: "no_third_party", cookiePolicy: CookiePolicyNoThirdParty, wantErr: false},
18+ {name: "never", cookiePolicy: CookiePolicyNever, wantErr: false},
19+ {name: "invalid", cookiePolicy: CookiePolicy("bad_policy"), wantErr: true},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ cfg := DefaultConfig()
25+ cfg.Privacy.CookiePolicy = tt.cookiePolicy
26+
27+ err := validateConfig(cfg)
28+ if tt.wantErr {
29+ require.Error(t, err)
30+ assert.Contains(t, err.Error(), "privacy.cookie_policy")
31+ return
32+ }
33+ require.NoError(t, err)
34+ })
35+ }
36+}
@@ -0,0 +1,36 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ cookiePolicy CookiePolicy
14+ wantErr bool
15+ }{
16+ {name: "always", cookiePolicy: CookiePolicyAlways, wantErr: false},
17+ {name: "no_third_party", cookiePolicy: CookiePolicyNoThirdParty, wantErr: false},
18+ {name: "never", cookiePolicy: CookiePolicyNever, wantErr: false},
19+ {name: "invalid", cookiePolicy: CookiePolicy("bad_policy"), wantErr: true},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ cfg := DefaultConfig()
25+ cfg.Privacy.CookiePolicy = tt.cookiePolicy
26+
27+ err := validateConfig(cfg)
28+ if tt.wantErr {
29+ require.Error(t, err)
30+ assert.Contains(t, err.Error(), "privacy.cookie_policy")
31+ return
32+ }
33+ require.NoError(t, err)
34+ })
35+ }
36+}
M internal/infrastructure/persistence/sqlite/history_repo.go +71 -26
@@ -6,7 +6,9 @@ import (
66 "errors"
77 "fmt"
88 "strings"
9+ "sync/atomic"
910 "time"
11+ "unicode"
1012
1113 "github.com/bnema/dumber/internal/domain/entity"
1214 "github.com/bnema/dumber/internal/domain/repository"
@@ -16,6 +18,8 @@ import (
1618
1719 const logURLMaxLen = 60
1820
21+var expectedFTSFailureCount atomic.Uint64
22+
1923 type historyRepo struct {
2024 queries *sqlc.Queries
2125 }
@@ -68,28 +72,15 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
6872 }
6973
7074 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
71- log := logging.FromContext(ctx)
72-
73- // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators
74- // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens
75- // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms
76- normalizedQuery := strings.NewReplacer(
77- ".", " ",
78- "/", " ",
79- ).Replace(query)
80-
81- // Split query into words and add prefix matching to each
82- // This enables multi-word searches like "github issues" -> "github* issues*"
83- words := strings.Fields(normalizedQuery)
75+ words := sanitizeFTS5QueryTokens(query)
8476 if len(words) == 0 {
8577 return []entity.HistoryMatch{}, nil
8678 }
8779
8880 // Build FTS5 query: "word1* word2*" (implicit AND between terms)
89- // Sanitize each word to remove FTS5 special characters
9081 parts := make([]string, len(words))
9182 for i, word := range words {
92- parts[i] = sanitizeFTS5Word(word) + "*"
83+ parts[i] = word + "*"
9384 }
9485 ftsQuery := strings.Join(parts, " ")
9586
@@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
9788 // Use domain-boosted query for URL search to prioritize domain matches
9889 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
9990 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
100- Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true},
91+ Term: sql.NullString{String: words[0], Valid: true},
10192 Query: ftsQuery,
10293 Limit: int64(limit),
10394 })
10495 if urlErr != nil {
105- log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
96+ logFTSSearchError(ctx, "url", query, urlErr)
10697 urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}
10798 }
10899
@@ -111,7 +102,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
111102 Limit: int64(limit),
112103 })
113104 if titleErr != nil {
114- log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")
105+ logFTSSearchError(ctx, "title", query, titleErr)
115106 titleRows = []sqlc.History{}
116107 }
117108
@@ -163,23 +154,77 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
163154 }
164155 }
165156
166-// sanitizeFTS5Word removes FTS5 special characters from a search word.
167-// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
168-// Note: Periods are handled earlier by replacing with spaces (tokenizer separator)
157+// sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
158+func sanitizeFTS5QueryTokens(query string) []string {
159+ normalized := normalizeFTSSeparators(query)
160+ rawTokens := strings.Fields(normalized)
161+ if len(rawTokens) == 0 {
162+ return nil
163+ }
164+
165+ tokens := make([]string, 0, len(rawTokens))
166+ for _, token := range rawTokens {
167+ sanitized := sanitizeFTS5Word(token)
168+ if sanitized == "" {
169+ continue
170+ }
171+ upper := strings.ToUpper(sanitized)
172+ if upper == "AND" || upper == "OR" || upper == "NOT" || upper == "NEAR" {
173+ continue
174+ }
175+ tokens = append(tokens, sanitized)
176+ }
177+ return tokens
178+}
179+
180+// normalizeFTSSeparators replaces non-alphanumeric separators with spaces.
181+func normalizeFTSSeparators(s string) string {
182+ var b strings.Builder
183+ b.Grow(len(s))
184+ for _, r := range s {
185+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
186+ b.WriteRune(r)
187+ } else {
188+ b.WriteByte(' ')
189+ }
190+ }
191+ return b.String()
192+}
193+
194+// sanitizeFTS5Word keeps only alphanumeric runes in a single token.
169195 func sanitizeFTS5Word(word string) string {
170- // Remove characters that have special meaning in FTS5
171196 var result strings.Builder
197+ result.Grow(len(word))
172198 for _, r := range word {
173- switch r {
174- case '"', '*', '(', ')', ':', '^', '-', '/':
175- // Skip FTS5 special characters
176- default:
199+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
177200 result.WriteRune(r)
178201 }
179202 }
180203 return result.String()
181204 }
182205
206+func logFTSSearchError(ctx context.Context, part, query string, err error) {
207+ log := logging.FromContext(ctx)
208+ if isExpectedFTSError(err) {
209+ count := expectedFTSFailureCount.Add(1)
210+ if count == 1 || count%100 == 0 {
211+ log.Debug().
212+ Uint64("expected_error_count", count).
213+ Str("part", part).
214+ Msg("expected FTS errors observed")
215+ }
216+ return
217+ }
218+ log.Debug().Err(err).Str("part", part).Str("query", query).Msg("FTS search failed")
219+}
220+
221+func isExpectedFTSError(err error) bool {
222+ msg := strings.ToLower(err.Error())
223+ return strings.Contains(msg, "malformed match") ||
224+ strings.Contains(msg, "fts5: syntax error") ||
225+ strings.Contains(msg, "unterminated string")
226+}
227+
183228 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
184229 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
185230 Limit: int64(limit),
@@ -6,7 +6,9 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "strings"8 "strings"
9+ "sync/atomic"
9 "time"10 "time"
11+ "unicode"
10 12
11 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/repository"14 "github.com/bnema/dumber/internal/domain/repository"
@@ -16,6 +18,8 @@ import (
16 18
17 const logURLMaxLen = 6019 const logURLMaxLen = 60
18 20
21+var expectedFTSFailureCount atomic.Uint64
22+
19 type historyRepo struct {23 type historyRepo struct {
20 queries *sqlc.Queries24 queries *sqlc.Queries
21 }25 }
@@ -68,28 +72,15 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
68 }72 }
69 73
70 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {74 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
71- log := logging.FromContext(ctx)75+ words := sanitizeFTS5QueryTokens(query)
72-
73- // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators
74- // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens
75- // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms
76- normalizedQuery := strings.NewReplacer(
77- ".", " ",
78- "/", " ",
79- ).Replace(query)
80-
81- // Split query into words and add prefix matching to each
82- // This enables multi-word searches like "github issues" -> "github* issues*"
83- words := strings.Fields(normalizedQuery)
84 if len(words) == 0 {76 if len(words) == 0 {
85 return []entity.HistoryMatch{}, nil77 return []entity.HistoryMatch{}, nil
86 }78 }
87 79
88 // Build FTS5 query: "word1* word2*" (implicit AND between terms)80 // Build FTS5 query: "word1* word2*" (implicit AND between terms)
89- // Sanitize each word to remove FTS5 special characters
90 parts := make([]string, len(words))81 parts := make([]string, len(words))
91 for i, word := range words {82 for i, word := range words {
92- parts[i] = sanitizeFTS5Word(word) + "*"83+ parts[i] = word + "*"
93 }84 }
94 ftsQuery := strings.Join(parts, " ")85 ftsQuery := strings.Join(parts, " ")
95 86
@@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
97 // Use domain-boosted query for URL search to prioritize domain matches88 // Use domain-boosted query for URL search to prioritize domain matches
98 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)89 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
99 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{90 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
100- Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true},91+ Term: sql.NullString{String: words[0], Valid: true},
101 Query: ftsQuery,92 Query: ftsQuery,
102 Limit: int64(limit),93 Limit: int64(limit),
103 })94 })
104 if urlErr != nil {95 if urlErr != nil {
105- log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")96+ logFTSSearchError(ctx, "url", query, urlErr)
106 urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}97 urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}
107 }98 }
108 99
@@ -111,7 +102,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
111 Limit: int64(limit),102 Limit: int64(limit),
112 })103 })
113 if titleErr != nil {104 if titleErr != nil {
114- log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")105+ logFTSSearchError(ctx, "title", query, titleErr)
115 titleRows = []sqlc.History{}106 titleRows = []sqlc.History{}
116 }107 }
117 108
@@ -163,23 +154,77 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
163 }154 }
164 }155 }
165 156
166-// sanitizeFTS5Word removes FTS5 special characters from a search word.157+// sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
167-// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR158+func sanitizeFTS5QueryTokens(query string) []string {
168-// Note: Periods are handled earlier by replacing with spaces (tokenizer separator)159+ normalized := normalizeFTSSeparators(query)
160+ rawTokens := strings.Fields(normalized)
161+ if len(rawTokens) == 0 {
162+ return nil
163+ }
164+
165+ tokens := make([]string, 0, len(rawTokens))
166+ for _, token := range rawTokens {
167+ sanitized := sanitizeFTS5Word(token)
168+ if sanitized == "" {
169+ continue
170+ }
171+ upper := strings.ToUpper(sanitized)
172+ if upper == "AND" || upper == "OR" || upper == "NOT" || upper == "NEAR" {
173+ continue
174+ }
175+ tokens = append(tokens, sanitized)
176+ }
177+ return tokens
178+}
179+
180+// normalizeFTSSeparators replaces non-alphanumeric separators with spaces.
181+func normalizeFTSSeparators(s string) string {
182+ var b strings.Builder
183+ b.Grow(len(s))
184+ for _, r := range s {
185+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
186+ b.WriteRune(r)
187+ } else {
188+ b.WriteByte(' ')
189+ }
190+ }
191+ return b.String()
192+}
193+
194+// sanitizeFTS5Word keeps only alphanumeric runes in a single token.
169 func sanitizeFTS5Word(word string) string {195 func sanitizeFTS5Word(word string) string {
170- // Remove characters that have special meaning in FTS5
171 var result strings.Builder196 var result strings.Builder
197+ result.Grow(len(word))
172 for _, r := range word {198 for _, r := range word {
173- switch r {199+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
174- case '"', '*', '(', ')', ':', '^', '-', '/':
175- // Skip FTS5 special characters
176- default:
177 result.WriteRune(r)200 result.WriteRune(r)
178 }201 }
179 }202 }
180 return result.String()203 return result.String()
181 }204 }
182 205
206+func logFTSSearchError(ctx context.Context, part, query string, err error) {
207+ log := logging.FromContext(ctx)
208+ if isExpectedFTSError(err) {
209+ count := expectedFTSFailureCount.Add(1)
210+ if count == 1 || count%100 == 0 {
211+ log.Debug().
212+ Uint64("expected_error_count", count).
213+ Str("part", part).
214+ Msg("expected FTS errors observed")
215+ }
216+ return
217+ }
218+ log.Debug().Err(err).Str("part", part).Str("query", query).Msg("FTS search failed")
219+}
220+
221+func isExpectedFTSError(err error) bool {
222+ msg := strings.ToLower(err.Error())
223+ return strings.Contains(msg, "malformed match") ||
224+ strings.Contains(msg, "fts5: syntax error") ||
225+ strings.Contains(msg, "unterminated string")
226+}
227+
183 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {228 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
184 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{229 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
185 Limit: int64(limit),230 Limit: int64(limit),
M internal/infrastructure/persistence/sqlite/history_repo_test.go +50 -0
@@ -429,6 +429,35 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
429429 assert.Empty(t, results)
430430 }
431431
432+func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
443+ URL: "https://example.com",
444+ Title: "Example",
445+ }))
446+
447+ invalidQueries := []string{
448+ "!!!",
449+ "___",
450+ "AND OR NOT NEAR",
451+ `"***"`,
452+ }
453+
454+ for _, q := range invalidQueries {
455+ results, err := repo.Search(ctx, q, 10)
456+ require.NoError(t, err, "query %q should return early without SQL errors", q)
457+ assert.Empty(t, results, "query %q should have no valid tokens", q)
458+ }
459+}
460+
432461 func TestHistoryRepository_Search_NoResults(t *testing.T) {
433462 ctx := historyTestCtx()
434463 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -557,6 +586,27 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
557586 }
558587 }
559588
589+func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
590+ ctx := historyTestCtx()
591+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
592+
593+ db, err := sqlite.NewConnection(ctx, dbPath)
594+ require.NoError(t, err)
595+ t.Cleanup(func() { _ = db.Close() })
596+
597+ repo := sqlite.NewHistoryRepository(db)
598+
599+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
600+ URL: "https://github.com/bnema/dumber",
601+ Title: "Dumber",
602+ }))
603+
604+ results, err := repo.Search(ctx, "AND github OR", 10)
605+ require.NoError(t, err)
606+ require.Len(t, results, 1)
607+ assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
608+}
609+
560610 func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561611 ctx := historyTestCtx()
562612 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -429,6 +429,35 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
429 assert.Empty(t, results)429 assert.Empty(t, results)
430 }430 }
431 431
432+func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
443+ URL: "https://example.com",
444+ Title: "Example",
445+ }))
446+
447+ invalidQueries := []string{
448+ "!!!",
449+ "___",
450+ "AND OR NOT NEAR",
451+ `"***"`,
452+ }
453+
454+ for _, q := range invalidQueries {
455+ results, err := repo.Search(ctx, q, 10)
456+ require.NoError(t, err, "query %q should return early without SQL errors", q)
457+ assert.Empty(t, results, "query %q should have no valid tokens", q)
458+ }
459+}
460+
432 func TestHistoryRepository_Search_NoResults(t *testing.T) {461 func TestHistoryRepository_Search_NoResults(t *testing.T) {
433 ctx := historyTestCtx()462 ctx := historyTestCtx()
434 dbPath := filepath.Join(t.TempDir(), "dumber.db")463 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -557,6 +586,27 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
557 }586 }
558 }587 }
559 588
589+func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
590+ ctx := historyTestCtx()
591+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
592+
593+ db, err := sqlite.NewConnection(ctx, dbPath)
594+ require.NoError(t, err)
595+ t.Cleanup(func() { _ = db.Close() })
596+
597+ repo := sqlite.NewHistoryRepository(db)
598+
599+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
600+ URL: "https://github.com/bnema/dumber",
601+ Title: "Dumber",
602+ }))
603+
604+ results, err := repo.Search(ctx, "AND github OR", 10)
605+ require.NoError(t, err)
606+ require.Len(t, results, 1)
607+ assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
608+}
609+
560 func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {610 func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561 ctx := historyTestCtx()611 ctx := historyTestCtx()
562 dbPath := filepath.Join(t.TempDir(), "dumber.db")612 dbPath := filepath.Join(t.TempDir(), "dumber.db")
M internal/infrastructure/webkit/context.go +25 -6
@@ -37,8 +37,10 @@ type WebKitContext struct {
3737 // This MUST be called before creating any WebViews to ensure they use persistent storage.
3838 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
3939 return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{
40- DataDir: dataDir,
41- CacheDir: cacheDir,
40+ DataDir: dataDir,
41+ CacheDir: cacheDir,
42+ CookiePolicy: port.WebKitCookiePolicyNoThirdParty,
43+ ITPEnabled: true,
4244 })
4345 }
4446
@@ -71,7 +73,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
7173
7274 // Create persistent network session FIRST
7375 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
74- if err := wkCtx.initNetworkSession(); err != nil {
76+ if err := wkCtx.initNetworkSession(opts); err != nil {
7577 return nil, fmt.Errorf("failed to init network session: %w", err)
7678 }
7779
@@ -112,7 +114,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
112114 }
113115
114116 // initNetworkSession creates and configures the persistent network session.
115-func (c *WebKitContext) initNetworkSession() error {
117+func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error {
116118 // Create persistent network session
117119 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
118120 if session == nil {
@@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error {
158160
159161 cookiePath := filepath.Join(c.dataDir, "cookies.db")
160162 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)
161- cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdPartyValue)
163+ cookiePolicy, cookiePolicyLabel := mapCookiePolicy(opts.CookiePolicy)
164+ cookieManager.SetAcceptPolicy(cookiePolicy)
165+ session.SetItpEnabled(opts.ITPEnabled)
162166
163- c.logger.Debug().
167+ c.logger.Info().
164168 Str("cookie_path", cookiePath).
169+ Str("cookie_policy", cookiePolicyLabel).
170+ Bool("itp_enabled", opts.ITPEnabled).
165171 Msg("cookie storage configured")
166172
167173 // Enable persistent credential storage
@@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error {
182188 return nil
183189 }
184190
191+func mapCookiePolicy(policy port.WebKitCookiePolicy) (webkit.CookieAcceptPolicy, string) {
192+ switch policy {
193+ case port.WebKitCookiePolicyAlways:
194+ return webkit.CookiePolicyAcceptAlwaysValue, string(port.WebKitCookiePolicyAlways)
195+ case port.WebKitCookiePolicyNever:
196+ return webkit.CookiePolicyAcceptNeverValue, string(port.WebKitCookiePolicyNever)
197+ case port.WebKitCookiePolicyNoThirdParty, "":
198+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
199+ default:
200+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
201+ }
202+}
203+
185204 // Context returns the shared WebContext.
186205 func (c *WebKitContext) Context() *webkit.WebContext {
187206 c.mu.RLock()
@@ -37,8 +37,10 @@ type WebKitContext struct {
37 // This MUST be called before creating any WebViews to ensure they use persistent storage.37 // This MUST be called before creating any WebViews to ensure they use persistent storage.
38 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {38 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
39 return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{39 return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{
40- DataDir: dataDir,40+ DataDir: dataDir,
41- CacheDir: cacheDir,41+ CacheDir: cacheDir,
42+ CookiePolicy: port.WebKitCookiePolicyNoThirdParty,
43+ ITPEnabled: true,
42 })44 })
43 }45 }
44 46
@@ -71,7 +73,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
71 73
72 // Create persistent network session FIRST74 // Create persistent network session FIRST
73 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"75 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
74- if err := wkCtx.initNetworkSession(); err != nil {76+ if err := wkCtx.initNetworkSession(opts); err != nil {
75 return nil, fmt.Errorf("failed to init network session: %w", err)77 return nil, fmt.Errorf("failed to init network session: %w", err)
76 }78 }
77 79
@@ -112,7 +114,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
112 }114 }
113 115
114 // initNetworkSession creates and configures the persistent network session.116 // initNetworkSession creates and configures the persistent network session.
115-func (c *WebKitContext) initNetworkSession() error {117+func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error {
116 // Create persistent network session118 // Create persistent network session
117 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)119 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
118 if session == nil {120 if session == nil {
@@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error {
158 160
159 cookiePath := filepath.Join(c.dataDir, "cookies.db")161 cookiePath := filepath.Join(c.dataDir, "cookies.db")
160 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)162 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)
161- cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdPartyValue)163+ cookiePolicy, cookiePolicyLabel := mapCookiePolicy(opts.CookiePolicy)
164+ cookieManager.SetAcceptPolicy(cookiePolicy)
165+ session.SetItpEnabled(opts.ITPEnabled)
162 166
163- c.logger.Debug().167+ c.logger.Info().
164 Str("cookie_path", cookiePath).168 Str("cookie_path", cookiePath).
169+ Str("cookie_policy", cookiePolicyLabel).
170+ Bool("itp_enabled", opts.ITPEnabled).
165 Msg("cookie storage configured")171 Msg("cookie storage configured")
166 172
167 // Enable persistent credential storage173 // Enable persistent credential storage
@@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error {
182 return nil188 return nil
183 }189 }
184 190
191+func mapCookiePolicy(policy port.WebKitCookiePolicy) (webkit.CookieAcceptPolicy, string) {
192+ switch policy {
193+ case port.WebKitCookiePolicyAlways:
194+ return webkit.CookiePolicyAcceptAlwaysValue, string(port.WebKitCookiePolicyAlways)
195+ case port.WebKitCookiePolicyNever:
196+ return webkit.CookiePolicyAcceptNeverValue, string(port.WebKitCookiePolicyNever)
197+ case port.WebKitCookiePolicyNoThirdParty, "":
198+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
199+ default:
200+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
201+ }
202+}
203+
185 // Context returns the shared WebContext.204 // Context returns the shared WebContext.
186 func (c *WebKitContext) Context() *webkit.WebContext {205 func (c *WebKitContext) Context() *webkit.WebContext {
187 c.mu.RLock()206 c.mu.RLock()
M internal/ui/component/omnibox.go +108 -207
@@ -3,7 +3,6 @@ package component
33 import (
44 "context"
55 "fmt"
6- "math"
76 "strings"
87 "sync"
98 "time"
@@ -21,15 +20,12 @@ import (
2120 "github.com/jwijenbergh/puregotk/v4/glib"
2221 "github.com/jwijenbergh/puregotk/v4/graphene"
2322 "github.com/jwijenbergh/puregotk/v4/gtk"
24- "github.com/jwijenbergh/puregotk/v4/pango"
2523 )
2624
2725 const (
2826 debounceDelayMs = 50
2927 endBoxSpacing = 6
3028 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31- ghostPositionRetryMs = 16
32- ghostPositionMaxAttempts = 5
3329 minGhostInputLength = 1
3430 )
3531
@@ -76,8 +72,6 @@ type Omnibox struct {
7672 zoomLabel *gtk.Label
7773 bangBadge *gtk.Label
7874 entry *gtk.SearchEntry
79- entryOverlay *gtk.Overlay // Overlay for ghost text
80- ghostLabel *gtk.Label // Ghost text completion label
8175 scrolledWin *gtk.ScrolledWindow
8276 listBox *gtk.ListBox
8377
@@ -123,6 +117,8 @@ type Omnibox struct {
123117 debounceTimer *time.Timer
124118 debounceMu sync.Mutex
125119 lastQuery string // Prevent duplicate searches
120+ searchToken uint64
121+ ghostToken uint64
126122
127123 // Scaling
128124 uiScale float64
@@ -512,30 +508,6 @@ func (o *Omnibox) initEntry() error {
512508 placeholder := defaultOmniboxPlaceholder
513509 o.entry.SetPlaceholderText(&placeholder)
514510
515- // Create overlay for ghost text
516- o.entryOverlay = gtk.NewOverlay()
517- if o.entryOverlay == nil {
518- return errNilWidget("entryOverlay")
519- }
520- o.entryOverlay.SetChild(&o.entry.Widget)
521- o.entryOverlay.SetHexpand(true)
522-
523- // Create ghost label for autocomplete suffix
524- o.ghostLabel = gtk.NewLabel(nil)
525- if o.ghostLabel == nil {
526- return errNilWidget("ghostLabel")
527- }
528- o.ghostLabel.AddCssClass("omnibox-ghost")
529- o.ghostLabel.SetHalign(gtk.AlignStartValue)
530- o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
531- o.ghostLabel.SetXalign(0)
532- o.ghostLabel.SetVisible(false)
533- o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
534- o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
535-
536- o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
537- o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
538-
539511 return nil
540512 }
541513
@@ -636,7 +608,7 @@ func (o *Omnibox) assembleWidgets() {
636608
637609 o.scrolledWin.SetChild(&o.listBox.Widget)
638610 o.mainBox.Append(&o.headerBox.Widget)
639- o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
611+ o.mainBox.Append(&o.entry.Widget)
640612 o.mainBox.Append(&o.scrolledWin.Widget)
641613 o.outerBox.Append(&o.mainBox.Widget)
642614 }
@@ -850,169 +822,83 @@ func (o *Omnibox) onEntryChanged() {
850822 o.debounceMu.Unlock()
851823 }
852824
853-// setGhostText displays a ghost completion suffix in the overlay label.
854-// The ghost text shows what would complete the current input.
855-// originalInput is the input that was used for the autocomplete query.
856-// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
825+// setGhostText applies inline completion using entry selection.
826+// It writes full text and selects [len(originalInput):end] as the ghost suffix.
857827 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
858- if o.ghostLabel == nil || suffix == "" {
828+ if o.entry == nil || suffix == "" {
859829 o.clearGhostText()
860830 return
861831 }
862832
863- // Store ghost state optimistically - will be validated on main thread
833+ displayText := originalInput + suffix
834+ if fullText != "" {
835+ displayText = fullText
836+ }
837+ selectionStart := utf8.RuneCountInString(originalInput)
838+
864839 o.mu.Lock()
840+ o.realInput = originalInput
865841 o.ghostSuffix = suffix
866842 o.ghostFullText = fullText
867843 o.hasGhostText = true
868844 o.mu.Unlock()
869845
870- o.scheduleGhostPosition(originalInput, suffix, 0)
871-}
872-
873-func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
874846 var cb glib.SourceFunc = func(uintptr) bool {
875- if o.ghostLabel == nil || o.entry == nil {
876- return false
877- }
878- if o.shouldSkipGhostUpdate(originalInput) {
879- return false
880- }
881- if !o.ghostPositionReady(attempt) {
882- o.retryGhostPosition(originalInput, suffix, attempt+1)
847+ if o.entry == nil {
883848 return false
884849 }
885850
886- marginStart, ok := o.computeGhostMarginStart(originalInput)
887- if !ok {
851+ o.mu.RLock()
852+ currentInput := o.realInput
853+ o.mu.RUnlock()
854+ if currentInput != originalInput {
888855 return false
889856 }
890- o.applyGhostPosition(suffix, marginStart)
891- return false
892- }
893- glib.IdleAdd(&cb, 0)
894-}
895-
896-func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
897- if originalInput == "" {
898- return false
899- }
900- return o.entry.GetText() != originalInput
901-}
902-
903-func (o *Omnibox) ghostPositionReady(attempt int) bool {
904- entryWidth := o.entry.GetAllocatedWidth()
905- overlayWidth := 0
906- if o.entryOverlay != nil {
907- overlayWidth = o.entryOverlay.GetAllocatedWidth()
908- }
909-
910- if entryWidth > 0 && overlayWidth > 0 {
911- return true
912- }
913-
914- // Max attempts reached but widgets still not allocated - give up
915- if attempt >= ghostPositionMaxAttempts {
916- log := logging.FromContext(o.ctx)
917- log.Debug().
918- Int("attempt", attempt).
919- Int("entryWidth", entryWidth).
920- Int("overlayWidth", overlayWidth).
921- Msg("ghost position timeout, clearing ghost text")
922- o.clearGhostText()
923- return false
924- }
857+ o.mu.Lock()
858+ o.isAcceptingGhost = true
859+ o.mu.Unlock()
925860
926- return false
927-}
861+ o.entry.SetText(displayText)
862+ o.entry.SelectRegion(selectionStart, -1)
928863
929-func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {
930- var retryCb glib.SourceFunc = func(uintptr) bool {
931- o.scheduleGhostPosition(originalInput, suffix, attempt)
864+ o.mu.Lock()
865+ o.isAcceptingGhost = false
866+ o.mu.Unlock()
932867 return false
933868 }
934- glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
935-}
936-
937-func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
938- pangoCtx := o.entry.GetPangoContext()
939- if pangoCtx == nil {
940- return 0, false
941- }
942-
943- textLayout := pango.NewLayout(pangoCtx)
944- defer textLayout.Unref() // Prevent memory leak
945- textLayout.SetText(originalInput, len(originalInput))
946-
947- var widthPx, heightPx int
948- textLayout.GetPixelSize(&widthPx, &heightPx)
949-
950- marginStart := widthPx
951- delegate := o.entry.GetDelegate()
952- if delegate != nil && o.entryOverlay != nil {
953- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
954- if textWidget != nil {
955- var textBounds graphene.Rect
956- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
957- textStart := float64(textBounds.GetX())
958- cursorPos := uint(utf8.RuneCountInString(originalInput))
959- var strongRect, weakRect graphene.Rect
960- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
961- cursorX := float64(textBounds.GetX() + strongRect.GetX())
962- marginStart = int(math.Round(cursorX))
963- if cursorX < textStart {
964- marginStart = int(math.Round(textStart))
965- }
966- }
967- }
968- }
969-
970- if marginStart < 0 {
971- marginStart = 0
972- }
973- if o.entryOverlay != nil {
974- var entryBounds graphene.Rect
975- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
976- minStart := int(math.Round(float64(entryBounds.GetX())))
977- if marginStart < minStart {
978- marginStart = minStart
979- }
980- }
981- }
982-
983- return marginStart, true
984-}
985-
986-func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
987- o.ghostLabel.SetText(suffix)
988- o.ghostLabel.SetMarginStart(marginStart)
989- o.ghostLabel.SetVisible(true)
990- emptyPlaceholder := ""
991- o.entry.SetPlaceholderText(&emptyPlaceholder)
869+ glib.IdleAdd(&cb, 0)
992870 }
993871
994872 // clearGhostText hides the ghost completion text.
995873 func (o *Omnibox) clearGhostText() {
996874 o.mu.Lock()
875+ realInput := o.realInput
876+ hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
877+ o.ghostToken++
997878 o.ghostSuffix = ""
998879 o.ghostFullText = ""
999880 o.hasGhostText = false
1000881 o.mu.Unlock()
1001882
1002- if o.ghostLabel == nil {
883+ if !hadGhost || o.entry == nil {
1003884 return
1004885 }
1005886
1006887 var cb glib.SourceFunc = func(uintptr) bool {
1007- if o.ghostLabel != nil {
1008- o.ghostLabel.SetVisible(false)
1009- o.ghostLabel.SetText("")
888+ if o.entry == nil {
889+ return false
1010890 }
1011- // Restore placeholder text
1012- if o.entry != nil {
1013- placeholder := defaultOmniboxPlaceholder
1014- o.entry.SetPlaceholderText(&placeholder)
891+ o.mu.Lock()
892+ o.isAcceptingGhost = true
893+ o.mu.Unlock()
894+ if o.entry.GetText() != realInput {
895+ o.entry.SetText(realInput)
1015896 }
897+ o.entry.SelectRegion(-1, -1)
898+ o.entry.SetPosition(-1)
899+ o.mu.Lock()
900+ o.isAcceptingGhost = false
901+ o.mu.Unlock()
1016902 return false
1017903 }
1018904 glib.IdleAdd(&cb, 0)
@@ -1021,31 +907,13 @@ func (o *Omnibox) clearGhostText() {
1021907 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
1022908 // This prevents stale background results from wiping newer ghost text.
1023909 func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1024- if o.entry == nil {
910+ o.mu.RLock()
911+ currentInput := o.realInput
912+ o.mu.RUnlock()
913+ if currentInput != expectedInput {
1025914 return
1026915 }
1027-
1028- var cb glib.SourceFunc = func(uintptr) bool {
1029- if o.entry == nil {
1030- return false
1031- }
1032- if o.entry.GetText() != expectedInput {
1033- return false
1034- }
1035- o.mu.Lock()
1036- o.ghostSuffix = ""
1037- o.ghostFullText = ""
1038- o.hasGhostText = false
1039- o.mu.Unlock()
1040- if o.ghostLabel != nil {
1041- o.ghostLabel.SetVisible(false)
1042- o.ghostLabel.SetText("")
1043- }
1044- placeholder := defaultOmniboxPlaceholder
1045- o.entry.SetPlaceholderText(&placeholder)
1046- return false
1047- }
1048- glib.IdleAdd(&cb, 0)
916+ o.clearGhostText()
1049917 }
1050918
1051919 // acceptGhostCompletion accepts the ghost text and fills the input.
@@ -1058,17 +926,18 @@ func (o *Omnibox) acceptGhostCompletion() {
1058926 fullText := o.ghostFullText
1059927 o.isAcceptingGhost = true
1060928 o.realInput = fullText
929+ o.ghostToken++
1061930 o.ghostSuffix = ""
1062931 o.ghostFullText = ""
1063932 o.hasGhostText = false
1064933 o.mu.Unlock()
1065934
1066- // Update entry text and clear ghost label
1067- o.entry.SetText(fullText)
1068- o.entry.SetPosition(-1) // Move cursor to end
1069-
1070- if o.ghostLabel != nil {
1071- o.ghostLabel.SetVisible(false)
935+ if o.entry != nil {
936+ if o.entry.GetText() != fullText {
937+ o.entry.SetText(fullText)
938+ }
939+ o.entry.SelectRegion(-1, -1)
940+ o.entry.SetPosition(-1)
1072941 }
1073942
1074943 o.mu.Lock()
@@ -1179,11 +1048,20 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
11791048 }
11801049 }
11811050
1051+ o.mu.Lock()
1052+ o.ghostToken++
1053+ ghostToken := o.ghostToken
1054+ searchToken := o.searchToken
1055+ o.mu.Unlock()
1056+
11821057 go func() {
11831058 suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
11841059 VisibleURLs: visibleURLs,
11851060 AllowBangs: false,
11861061 })
1062+ if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) {
1063+ return
1064+ }
11871065 if suggestion == nil {
11881066 o.clearGhostTextIfInput(entryText)
11891067 return
@@ -1192,6 +1070,15 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
11921070 }()
11931071 }
11941072
1073+func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool {
1074+ o.mu.RLock()
1075+ defer o.mu.RUnlock()
1076+ return o.visible &&
1077+ o.searchToken == searchToken &&
1078+ o.ghostToken == ghostToken &&
1079+ o.realInput == query
1080+}
1081+
11951082 // performSearch executes the search based on current view mode and query.
11961083 func (o *Omnibox) performSearch() {
11971084 o.mu.RLock()
@@ -1215,6 +1102,11 @@ func (o *Omnibox) performSearch() {
12151102 o.lastQuery = query
12161103 o.debounceMu.Unlock()
12171104
1105+ o.mu.Lock()
1106+ o.searchToken++
1107+ token := o.searchToken
1108+ o.mu.Unlock()
1109+
12181110 if strings.HasPrefix(query, "!") {
12191111 o.updateBangDetection(query)
12201112 o.loadBangSuggestions(query)
@@ -1224,23 +1116,23 @@ func (o *Omnibox) performSearch() {
12241116 o.clearBangState()
12251117
12261118 if mode == ViewModeFavorites {
1227- o.loadFavorites(query)
1119+ o.loadFavorites(query, token)
12281120 return
12291121 }
12301122
12311123 // History search
12321124 if query == "" {
1233- o.loadInitialHistory()
1125+ o.loadInitialHistory(token)
12341126 return
12351127 }
12361128
12371129 // Perform fuzzy history search in background
1238- o.searchHistory(query, o.effectiveMaxRows())
1130+ o.searchHistory(query, o.effectiveMaxRows(), token)
12391131 }
12401132
12411133 // searchHistory runs a fuzzy history search in a background goroutine.
12421134 // query is the search text; limit caps the number of results.
1243-func (o *Omnibox) searchHistory(query string, limit int) {
1135+func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
12441136 go func() {
12451137 ctx := o.ctx
12461138 log := logging.FromContext(ctx)
@@ -1291,13 +1183,13 @@ func (o *Omnibox) searchHistory(query string, limit int) {
12911183 })
12921184 }
12931185
1294- // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input
1295- o.idleAddUpdateSuggestions(suggestions, query)
1186+ // Marshal back to GTK main thread, passing query for stale-result guarding.
1187+ o.idleAddUpdateSuggestions(suggestions, query, token)
12961188 }()
12971189 }
12981190
12991191 // loadInitialHistory loads history based on InitialBehavior config.
1300-func (o *Omnibox) loadInitialHistory() {
1192+func (o *Omnibox) loadInitialHistory(token uint64) {
13011193 if o.historyUC == nil {
13021194 return
13031195 }
@@ -1312,7 +1204,7 @@ func (o *Omnibox) loadInitialHistory() {
13121204
13131205 switch o.initialBehavior {
13141206 case "none":
1315- o.idleAddUpdateSuggestions(nil, "")
1207+ o.idleAddUpdateSuggestions(nil, "", token)
13161208 return
13171209
13181210 case "most_visited", "recent", "":
@@ -1357,12 +1249,12 @@ func (o *Omnibox) loadInitialHistory() {
13571249 }
13581250 }
13591251
1360- o.idleAddUpdateSuggestions(suggestions, "")
1252+ o.idleAddUpdateSuggestions(suggestions, "", token)
13611253 }()
13621254 }
13631255
13641256 // loadFavorites loads favorites, optionally filtered by query.
1365-func (o *Omnibox) loadFavorites(query string) {
1257+func (o *Omnibox) loadFavorites(query string, token uint64) {
13661258 if o.favoritesUC == nil {
13671259 return
13681260 }
@@ -1395,7 +1287,7 @@ func (o *Omnibox) loadFavorites(query string) {
13951287 })
13961288 }
13971289
1398- o.idleAddUpdateFavorites(favorites, query)
1290+ o.idleAddUpdateFavorites(favorites, query, token)
13991291 }()
14001292 }
14011293
@@ -2000,7 +1892,10 @@ func (o *Omnibox) toggleFavorite() {
20001892 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
20011893
20021894 // Refresh favorites list and show toast
2003- o.loadFavorites(o.entry.GetText())
1895+ o.mu.RLock()
1896+ currentToken := o.searchToken
1897+ o.mu.RUnlock()
1898+ o.loadFavorites(o.entry.GetText(), currentToken)
20041899 if o.onToast != nil {
20051900 cb := glib.SourceFunc(func(_ uintptr) bool {
20061901 o.onToast(ctx, "Favorite removed", ToastSuccess)
@@ -2139,13 +2034,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
21392034 o.isAcceptingGhost = false
21402035 o.mu.Unlock()
21412036
2142- // Clear any stale ghost text display
2143- if o.ghostLabel != nil {
2144- o.ghostLabel.SetVisible(false)
2145- }
2146-
21472037 // Set initial query
21482038 o.entry.SetText(query)
2039+ o.entry.SelectRegion(-1, -1)
21492040
21502041 // Determine if we expect content initially
21512042 // No content expected if: no query AND initialBehavior is "none"
@@ -2268,9 +2159,11 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
22682159 }
22692160
22702161 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2271-// The query parameter is passed through to ensure ghost text uses the correct input.
2272-func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
2162+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) {
22732163 var cb glib.SourceFunc = func(data uintptr) bool {
2164+ if !o.isSearchTokenCurrent(token) {
2165+ return false
2166+ }
22742167 o.updateSuggestions(suggestions, query)
22752168 return false // One-shot callback
22762169 }
@@ -2293,15 +2186,23 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
22932186 }
22942187
22952188 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2296-// The query parameter is passed through to ensure ghost text uses the correct input.
2297-func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
2189+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) {
22982190 var cb glib.SourceFunc = func(data uintptr) bool {
2191+ if !o.isSearchTokenCurrent(token) {
2192+ return false
2193+ }
22992194 o.updateFavorites(favorites, query)
23002195 return false // One-shot callback, return false to remove source
23012196 }
23022197 glib.IdleAdd(&cb, 0)
23032198 }
23042199
2200+func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2201+ o.mu.RLock()
2202+ defer o.mu.RUnlock()
2203+ return o.visible && o.searchToken == token
2204+}
2205+
23052206 // Widget returns the omnibox widget for embedding in an overlay.
23062207 func (o *Omnibox) Widget() *gtk.Widget {
23072208 if o.outerBox == nil {
@@ -3,7 +3,6 @@ package component
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "math"
7 "strings"6 "strings"
8 "sync"7 "sync"
9 "time"8 "time"
@@ -21,15 +20,12 @@ import (
21 "github.com/jwijenbergh/puregotk/v4/glib"20 "github.com/jwijenbergh/puregotk/v4/glib"
22 "github.com/jwijenbergh/puregotk/v4/graphene"21 "github.com/jwijenbergh/puregotk/v4/graphene"
23 "github.com/jwijenbergh/puregotk/v4/gtk"22 "github.com/jwijenbergh/puregotk/v4/gtk"
24- "github.com/jwijenbergh/puregotk/v4/pango"
25 )23 )
26 24
27 const (25 const (
28 debounceDelayMs = 5026 debounceDelayMs = 50
29 endBoxSpacing = 627 endBoxSpacing = 6
30 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"28 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31- ghostPositionRetryMs = 16
32- ghostPositionMaxAttempts = 5
33 minGhostInputLength = 129 minGhostInputLength = 1
34 )30 )
35 31
@@ -76,8 +72,6 @@ type Omnibox struct {
76 zoomLabel *gtk.Label72 zoomLabel *gtk.Label
77 bangBadge *gtk.Label73 bangBadge *gtk.Label
78 entry *gtk.SearchEntry74 entry *gtk.SearchEntry
79- entryOverlay *gtk.Overlay // Overlay for ghost text
80- ghostLabel *gtk.Label // Ghost text completion label
81 scrolledWin *gtk.ScrolledWindow75 scrolledWin *gtk.ScrolledWindow
82 listBox *gtk.ListBox76 listBox *gtk.ListBox
83 77
@@ -123,6 +117,8 @@ type Omnibox struct {
123 debounceTimer *time.Timer117 debounceTimer *time.Timer
124 debounceMu sync.Mutex118 debounceMu sync.Mutex
125 lastQuery string // Prevent duplicate searches119 lastQuery string // Prevent duplicate searches
120+ searchToken uint64
121+ ghostToken uint64
126 122
127 // Scaling123 // Scaling
128 uiScale float64124 uiScale float64
@@ -512,30 +508,6 @@ func (o *Omnibox) initEntry() error {
512 placeholder := defaultOmniboxPlaceholder508 placeholder := defaultOmniboxPlaceholder
513 o.entry.SetPlaceholderText(&placeholder)509 o.entry.SetPlaceholderText(&placeholder)
514 510
515- // Create overlay for ghost text
516- o.entryOverlay = gtk.NewOverlay()
517- if o.entryOverlay == nil {
518- return errNilWidget("entryOverlay")
519- }
520- o.entryOverlay.SetChild(&o.entry.Widget)
521- o.entryOverlay.SetHexpand(true)
522-
523- // Create ghost label for autocomplete suffix
524- o.ghostLabel = gtk.NewLabel(nil)
525- if o.ghostLabel == nil {
526- return errNilWidget("ghostLabel")
527- }
528- o.ghostLabel.AddCssClass("omnibox-ghost")
529- o.ghostLabel.SetHalign(gtk.AlignStartValue)
530- o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
531- o.ghostLabel.SetXalign(0)
532- o.ghostLabel.SetVisible(false)
533- o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
534- o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
535-
536- o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
537- o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
538-
539 return nil511 return nil
540 }512 }
541 513
@@ -636,7 +608,7 @@ func (o *Omnibox) assembleWidgets() {
636 608
637 o.scrolledWin.SetChild(&o.listBox.Widget)609 o.scrolledWin.SetChild(&o.listBox.Widget)
638 o.mainBox.Append(&o.headerBox.Widget)610 o.mainBox.Append(&o.headerBox.Widget)
639- o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support611+ o.mainBox.Append(&o.entry.Widget)
640 o.mainBox.Append(&o.scrolledWin.Widget)612 o.mainBox.Append(&o.scrolledWin.Widget)
641 o.outerBox.Append(&o.mainBox.Widget)613 o.outerBox.Append(&o.mainBox.Widget)
642 }614 }
@@ -850,169 +822,83 @@ func (o *Omnibox) onEntryChanged() {
850 o.debounceMu.Unlock()822 o.debounceMu.Unlock()
851 }823 }
852 824
853-// setGhostText displays a ghost completion suffix in the overlay label.825+// setGhostText applies inline completion using entry selection.
854-// The ghost text shows what would complete the current input.826+// It writes full text and selects [len(originalInput):end] as the ghost suffix.
855-// originalInput is the input that was used for the autocomplete query.
856-// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
857 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {827 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
858- if o.ghostLabel == nil || suffix == "" {828+ if o.entry == nil || suffix == "" {
859 o.clearGhostText()829 o.clearGhostText()
860 return830 return
861 }831 }
862 832
863- // Store ghost state optimistically - will be validated on main thread833+ displayText := originalInput + suffix
834+ if fullText != "" {
835+ displayText = fullText
836+ }
837+ selectionStart := utf8.RuneCountInString(originalInput)
838+
864 o.mu.Lock()839 o.mu.Lock()
840+ o.realInput = originalInput
865 o.ghostSuffix = suffix841 o.ghostSuffix = suffix
866 o.ghostFullText = fullText842 o.ghostFullText = fullText
867 o.hasGhostText = true843 o.hasGhostText = true
868 o.mu.Unlock()844 o.mu.Unlock()
869 845
870- o.scheduleGhostPosition(originalInput, suffix, 0)
871-}
872-
873-func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
874 var cb glib.SourceFunc = func(uintptr) bool {846 var cb glib.SourceFunc = func(uintptr) bool {
875- if o.ghostLabel == nil || o.entry == nil {847+ if o.entry == nil {
876- return false
877- }
878- if o.shouldSkipGhostUpdate(originalInput) {
879- return false
880- }
881- if !o.ghostPositionReady(attempt) {
882- o.retryGhostPosition(originalInput, suffix, attempt+1)
883 return false848 return false
884 }849 }
885 850
886- marginStart, ok := o.computeGhostMarginStart(originalInput)851+ o.mu.RLock()
887- if !ok {852+ currentInput := o.realInput
853+ o.mu.RUnlock()
854+ if currentInput != originalInput {
888 return false855 return false
889 }856 }
890- o.applyGhostPosition(suffix, marginStart)857+ o.mu.Lock()
891- return false858+ o.isAcceptingGhost = true
892- }859+ o.mu.Unlock()
893- glib.IdleAdd(&cb, 0)
894-}
895-
896-func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
897- if originalInput == "" {
898- return false
899- }
900- return o.entry.GetText() != originalInput
901-}
902-
903-func (o *Omnibox) ghostPositionReady(attempt int) bool {
904- entryWidth := o.entry.GetAllocatedWidth()
905- overlayWidth := 0
906- if o.entryOverlay != nil {
907- overlayWidth = o.entryOverlay.GetAllocatedWidth()
908- }
909-
910- if entryWidth > 0 && overlayWidth > 0 {
911- return true
912- }
913-
914- // Max attempts reached but widgets still not allocated - give up
915- if attempt >= ghostPositionMaxAttempts {
916- log := logging.FromContext(o.ctx)
917- log.Debug().
918- Int("attempt", attempt).
919- Int("entryWidth", entryWidth).
920- Int("overlayWidth", overlayWidth).
921- Msg("ghost position timeout, clearing ghost text")
922- o.clearGhostText()
923- return false
924- }
925 860
926- return false861+ o.entry.SetText(displayText)
927-}862+ o.entry.SelectRegion(selectionStart, -1)
928 863
929-func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {864+ o.mu.Lock()
930- var retryCb glib.SourceFunc = func(uintptr) bool {865+ o.isAcceptingGhost = false
931- o.scheduleGhostPosition(originalInput, suffix, attempt)866+ o.mu.Unlock()
932 return false867 return false
933 }868 }
934- glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)869+ glib.IdleAdd(&cb, 0)
935-}
936-
937-func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
938- pangoCtx := o.entry.GetPangoContext()
939- if pangoCtx == nil {
940- return 0, false
941- }
942-
943- textLayout := pango.NewLayout(pangoCtx)
944- defer textLayout.Unref() // Prevent memory leak
945- textLayout.SetText(originalInput, len(originalInput))
946-
947- var widthPx, heightPx int
948- textLayout.GetPixelSize(&widthPx, &heightPx)
949-
950- marginStart := widthPx
951- delegate := o.entry.GetDelegate()
952- if delegate != nil && o.entryOverlay != nil {
953- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
954- if textWidget != nil {
955- var textBounds graphene.Rect
956- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
957- textStart := float64(textBounds.GetX())
958- cursorPos := uint(utf8.RuneCountInString(originalInput))
959- var strongRect, weakRect graphene.Rect
960- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
961- cursorX := float64(textBounds.GetX() + strongRect.GetX())
962- marginStart = int(math.Round(cursorX))
963- if cursorX < textStart {
964- marginStart = int(math.Round(textStart))
965- }
966- }
967- }
968- }
969-
970- if marginStart < 0 {
971- marginStart = 0
972- }
973- if o.entryOverlay != nil {
974- var entryBounds graphene.Rect
975- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
976- minStart := int(math.Round(float64(entryBounds.GetX())))
977- if marginStart < minStart {
978- marginStart = minStart
979- }
980- }
981- }
982-
983- return marginStart, true
984-}
985-
986-func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
987- o.ghostLabel.SetText(suffix)
988- o.ghostLabel.SetMarginStart(marginStart)
989- o.ghostLabel.SetVisible(true)
990- emptyPlaceholder := ""
991- o.entry.SetPlaceholderText(&emptyPlaceholder)
992 }870 }
993 871
994 // clearGhostText hides the ghost completion text.872 // clearGhostText hides the ghost completion text.
995 func (o *Omnibox) clearGhostText() {873 func (o *Omnibox) clearGhostText() {
996 o.mu.Lock()874 o.mu.Lock()
875+ realInput := o.realInput
876+ hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
877+ o.ghostToken++
997 o.ghostSuffix = ""878 o.ghostSuffix = ""
998 o.ghostFullText = ""879 o.ghostFullText = ""
999 o.hasGhostText = false880 o.hasGhostText = false
1000 o.mu.Unlock()881 o.mu.Unlock()
1001 882
1002- if o.ghostLabel == nil {883+ if !hadGhost || o.entry == nil {
1003 return884 return
1004 }885 }
1005 886
1006 var cb glib.SourceFunc = func(uintptr) bool {887 var cb glib.SourceFunc = func(uintptr) bool {
1007- if o.ghostLabel != nil {888+ if o.entry == nil {
1008- o.ghostLabel.SetVisible(false)889+ return false
1009- o.ghostLabel.SetText("")
1010 }890 }
1011- // Restore placeholder text891+ o.mu.Lock()
1012- if o.entry != nil {892+ o.isAcceptingGhost = true
1013- placeholder := defaultOmniboxPlaceholder893+ o.mu.Unlock()
1014- o.entry.SetPlaceholderText(&placeholder)894+ if o.entry.GetText() != realInput {
895+ o.entry.SetText(realInput)
1015 }896 }
897+ o.entry.SelectRegion(-1, -1)
898+ o.entry.SetPosition(-1)
899+ o.mu.Lock()
900+ o.isAcceptingGhost = false
901+ o.mu.Unlock()
1016 return false902 return false
1017 }903 }
1018 glib.IdleAdd(&cb, 0)904 glib.IdleAdd(&cb, 0)
@@ -1021,31 +907,13 @@ func (o *Omnibox) clearGhostText() {
1021 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.907 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
1022 // This prevents stale background results from wiping newer ghost text.908 // This prevents stale background results from wiping newer ghost text.
1023 func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {909 func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1024- if o.entry == nil {910+ o.mu.RLock()
911+ currentInput := o.realInput
912+ o.mu.RUnlock()
913+ if currentInput != expectedInput {
1025 return914 return
1026 }915 }
1027-916+ o.clearGhostText()
1028- var cb glib.SourceFunc = func(uintptr) bool {
1029- if o.entry == nil {
1030- return false
1031- }
1032- if o.entry.GetText() != expectedInput {
1033- return false
1034- }
1035- o.mu.Lock()
1036- o.ghostSuffix = ""
1037- o.ghostFullText = ""
1038- o.hasGhostText = false
1039- o.mu.Unlock()
1040- if o.ghostLabel != nil {
1041- o.ghostLabel.SetVisible(false)
1042- o.ghostLabel.SetText("")
1043- }
1044- placeholder := defaultOmniboxPlaceholder
1045- o.entry.SetPlaceholderText(&placeholder)
1046- return false
1047- }
1048- glib.IdleAdd(&cb, 0)
1049 }917 }
1050 918
1051 // acceptGhostCompletion accepts the ghost text and fills the input.919 // acceptGhostCompletion accepts the ghost text and fills the input.
@@ -1058,17 +926,18 @@ func (o *Omnibox) acceptGhostCompletion() {
1058 fullText := o.ghostFullText926 fullText := o.ghostFullText
1059 o.isAcceptingGhost = true927 o.isAcceptingGhost = true
1060 o.realInput = fullText928 o.realInput = fullText
929+ o.ghostToken++
1061 o.ghostSuffix = ""930 o.ghostSuffix = ""
1062 o.ghostFullText = ""931 o.ghostFullText = ""
1063 o.hasGhostText = false932 o.hasGhostText = false
1064 o.mu.Unlock()933 o.mu.Unlock()
1065 934
1066- // Update entry text and clear ghost label935+ if o.entry != nil {
1067- o.entry.SetText(fullText)936+ if o.entry.GetText() != fullText {
1068- o.entry.SetPosition(-1) // Move cursor to end937+ o.entry.SetText(fullText)
1069-938+ }
1070- if o.ghostLabel != nil {939+ o.entry.SelectRegion(-1, -1)
1071- o.ghostLabel.SetVisible(false)940+ o.entry.SetPosition(-1)
1072 }941 }
1073 942
1074 o.mu.Lock()943 o.mu.Lock()
@@ -1179,11 +1048,20 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1179 }1048 }
1180 }1049 }
1181 1050
1051+ o.mu.Lock()
1052+ o.ghostToken++
1053+ ghostToken := o.ghostToken
1054+ searchToken := o.searchToken
1055+ o.mu.Unlock()
1056+
1182 go func() {1057 go func() {
1183 suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{1058 suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1184 VisibleURLs: visibleURLs,1059 VisibleURLs: visibleURLs,
1185 AllowBangs: false,1060 AllowBangs: false,
1186 })1061 })
1062+ if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) {
1063+ return
1064+ }
1187 if suggestion == nil {1065 if suggestion == nil {
1188 o.clearGhostTextIfInput(entryText)1066 o.clearGhostTextIfInput(entryText)
1189 return1067 return
@@ -1192,6 +1070,15 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1192 }()1070 }()
1193 }1071 }
1194 1072
1073+func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool {
1074+ o.mu.RLock()
1075+ defer o.mu.RUnlock()
1076+ return o.visible &&
1077+ o.searchToken == searchToken &&
1078+ o.ghostToken == ghostToken &&
1079+ o.realInput == query
1080+}
1081+
1195 // performSearch executes the search based on current view mode and query.1082 // performSearch executes the search based on current view mode and query.
1196 func (o *Omnibox) performSearch() {1083 func (o *Omnibox) performSearch() {
1197 o.mu.RLock()1084 o.mu.RLock()
@@ -1215,6 +1102,11 @@ func (o *Omnibox) performSearch() {
1215 o.lastQuery = query1102 o.lastQuery = query
1216 o.debounceMu.Unlock()1103 o.debounceMu.Unlock()
1217 1104
1105+ o.mu.Lock()
1106+ o.searchToken++
1107+ token := o.searchToken
1108+ o.mu.Unlock()
1109+
1218 if strings.HasPrefix(query, "!") {1110 if strings.HasPrefix(query, "!") {
1219 o.updateBangDetection(query)1111 o.updateBangDetection(query)
1220 o.loadBangSuggestions(query)1112 o.loadBangSuggestions(query)
@@ -1224,23 +1116,23 @@ func (o *Omnibox) performSearch() {
1224 o.clearBangState()1116 o.clearBangState()
1225 1117
1226 if mode == ViewModeFavorites {1118 if mode == ViewModeFavorites {
1227- o.loadFavorites(query)1119+ o.loadFavorites(query, token)
1228 return1120 return
1229 }1121 }
1230 1122
1231 // History search1123 // History search
1232 if query == "" {1124 if query == "" {
1233- o.loadInitialHistory()1125+ o.loadInitialHistory(token)
1234 return1126 return
1235 }1127 }
1236 1128
1237 // Perform fuzzy history search in background1129 // Perform fuzzy history search in background
1238- o.searchHistory(query, o.effectiveMaxRows())1130+ o.searchHistory(query, o.effectiveMaxRows(), token)
1239 }1131 }
1240 1132
1241 // searchHistory runs a fuzzy history search in a background goroutine.1133 // searchHistory runs a fuzzy history search in a background goroutine.
1242 // query is the search text; limit caps the number of results.1134 // query is the search text; limit caps the number of results.
1243-func (o *Omnibox) searchHistory(query string, limit int) {1135+func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1244 go func() {1136 go func() {
1245 ctx := o.ctx1137 ctx := o.ctx
1246 log := logging.FromContext(ctx)1138 log := logging.FromContext(ctx)
@@ -1291,13 +1183,13 @@ func (o *Omnibox) searchHistory(query string, limit int) {
1291 })1183 })
1292 }1184 }
1293 1185
1294- // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input1186+ // Marshal back to GTK main thread, passing query for stale-result guarding.
1295- o.idleAddUpdateSuggestions(suggestions, query)1187+ o.idleAddUpdateSuggestions(suggestions, query, token)
1296 }()1188 }()
1297 }1189 }
1298 1190
1299 // loadInitialHistory loads history based on InitialBehavior config.1191 // loadInitialHistory loads history based on InitialBehavior config.
1300-func (o *Omnibox) loadInitialHistory() {1192+func (o *Omnibox) loadInitialHistory(token uint64) {
1301 if o.historyUC == nil {1193 if o.historyUC == nil {
1302 return1194 return
1303 }1195 }
@@ -1312,7 +1204,7 @@ func (o *Omnibox) loadInitialHistory() {
1312 1204
1313 switch o.initialBehavior {1205 switch o.initialBehavior {
1314 case "none":1206 case "none":
1315- o.idleAddUpdateSuggestions(nil, "")1207+ o.idleAddUpdateSuggestions(nil, "", token)
1316 return1208 return
1317 1209
1318 case "most_visited", "recent", "":1210 case "most_visited", "recent", "":
@@ -1357,12 +1249,12 @@ func (o *Omnibox) loadInitialHistory() {
1357 }1249 }
1358 }1250 }
1359 1251
1360- o.idleAddUpdateSuggestions(suggestions, "")1252+ o.idleAddUpdateSuggestions(suggestions, "", token)
1361 }()1253 }()
1362 }1254 }
1363 1255
1364 // loadFavorites loads favorites, optionally filtered by query.1256 // loadFavorites loads favorites, optionally filtered by query.
1365-func (o *Omnibox) loadFavorites(query string) {1257+func (o *Omnibox) loadFavorites(query string, token uint64) {
1366 if o.favoritesUC == nil {1258 if o.favoritesUC == nil {
1367 return1259 return
1368 }1260 }
@@ -1395,7 +1287,7 @@ func (o *Omnibox) loadFavorites(query string) {
1395 })1287 })
1396 }1288 }
1397 1289
1398- o.idleAddUpdateFavorites(favorites, query)1290+ o.idleAddUpdateFavorites(favorites, query, token)
1399 }()1291 }()
1400 }1292 }
1401 1293
@@ -2000,7 +1892,10 @@ func (o *Omnibox) toggleFavorite() {
2000 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")1892 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
2001 1893
2002 // Refresh favorites list and show toast1894 // Refresh favorites list and show toast
2003- o.loadFavorites(o.entry.GetText())1895+ o.mu.RLock()
1896+ currentToken := o.searchToken
1897+ o.mu.RUnlock()
1898+ o.loadFavorites(o.entry.GetText(), currentToken)
2004 if o.onToast != nil {1899 if o.onToast != nil {
2005 cb := glib.SourceFunc(func(_ uintptr) bool {1900 cb := glib.SourceFunc(func(_ uintptr) bool {
2006 o.onToast(ctx, "Favorite removed", ToastSuccess)1901 o.onToast(ctx, "Favorite removed", ToastSuccess)
@@ -2139,13 +2034,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2139 o.isAcceptingGhost = false2034 o.isAcceptingGhost = false
2140 o.mu.Unlock()2035 o.mu.Unlock()
2141 2036
2142- // Clear any stale ghost text display
2143- if o.ghostLabel != nil {
2144- o.ghostLabel.SetVisible(false)
2145- }
2146-
2147 // Set initial query2037 // Set initial query
2148 o.entry.SetText(query)2038 o.entry.SetText(query)
2039+ o.entry.SelectRegion(-1, -1)
2149 2040
2150 // Determine if we expect content initially2041 // Determine if we expect content initially
2151 // No content expected if: no query AND initialBehavior is "none"2042 // No content expected if: no query AND initialBehavior is "none"
@@ -2268,9 +2159,11 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2268 }2159 }
2269 2160
2270 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.2161 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2271-// The query parameter is passed through to ensure ghost text uses the correct input.2162+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) {
2272-func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
2273 var cb glib.SourceFunc = func(data uintptr) bool {2163 var cb glib.SourceFunc = func(data uintptr) bool {
2164+ if !o.isSearchTokenCurrent(token) {
2165+ return false
2166+ }
2274 o.updateSuggestions(suggestions, query)2167 o.updateSuggestions(suggestions, query)
2275 return false // One-shot callback2168 return false // One-shot callback
2276 }2169 }
@@ -2293,15 +2186,23 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2293 }2186 }
2294 2187
2295 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.2188 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2296-// The query parameter is passed through to ensure ghost text uses the correct input.2189+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) {
2297-func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
2298 var cb glib.SourceFunc = func(data uintptr) bool {2190 var cb glib.SourceFunc = func(data uintptr) bool {
2191+ if !o.isSearchTokenCurrent(token) {
2192+ return false
2193+ }
2299 o.updateFavorites(favorites, query)2194 o.updateFavorites(favorites, query)
2300 return false // One-shot callback, return false to remove source2195 return false // One-shot callback, return false to remove source
2301 }2196 }
2302 glib.IdleAdd(&cb, 0)2197 glib.IdleAdd(&cb, 0)
2303 }2198 }
2304 2199
2200+func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2201+ o.mu.RLock()
2202+ defer o.mu.RUnlock()
2203+ return o.visible && o.searchToken == token
2204+}
2205+
2305 // Widget returns the omnibox widget for embedding in an overlay.2206 // Widget returns the omnibox widget for embedding in an overlay.
2306 func (o *Omnibox) Widget() *gtk.Widget {2207 func (o *Omnibox) Widget() *gtk.Widget {
2307 if o.outerBox == nil {2208 if o.outerBox == nil {
A internal/ui/component/omnibox_async_guard_test.go +42 -0
@@ -0,0 +1,42 @@
1+package component
2+
3+import "testing"
4+
5+func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
6+ o := &Omnibox{}
7+ o.visible = true
8+ o.searchToken = 3
9+
10+ if !o.isSearchTokenCurrent(3) {
11+ t.Fatalf("expected token to be current")
12+ }
13+ if o.isSearchTokenCurrent(2) {
14+ t.Fatalf("expected stale token to be rejected")
15+ }
16+
17+ o.visible = false
18+ if o.isSearchTokenCurrent(3) {
19+ t.Fatalf("expected hidden omnibox to reject token")
20+ }
21+}
22+
23+func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
24+ o := &Omnibox{}
25+ o.visible = true
26+ o.searchToken = 8
27+ o.ghostToken = 5
28+ o.realInput = "git"
29+
30+ if !o.isGhostTokenCurrent(8, 5, "git") {
31+ t.Fatalf("expected ghost token to be current")
32+ }
33+ if o.isGhostTokenCurrent(7, 5, "git") {
34+ t.Fatalf("expected stale search token to be rejected")
35+ }
36+ if o.isGhostTokenCurrent(8, 4, "git") {
37+ t.Fatalf("expected stale ghost token to be rejected")
38+ }
39+ if o.isGhostTokenCurrent(8, 5, "github") {
40+ t.Fatalf("expected query mismatch to be rejected")
41+ }
42+}
@@ -0,0 +1,42 @@
1+package component
2+
3+import "testing"
4+
5+func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
6+ o := &Omnibox{}
7+ o.visible = true
8+ o.searchToken = 3
9+
10+ if !o.isSearchTokenCurrent(3) {
11+ t.Fatalf("expected token to be current")
12+ }
13+ if o.isSearchTokenCurrent(2) {
14+ t.Fatalf("expected stale token to be rejected")
15+ }
16+
17+ o.visible = false
18+ if o.isSearchTokenCurrent(3) {
19+ t.Fatalf("expected hidden omnibox to reject token")
20+ }
21+}
22+
23+func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
24+ o := &Omnibox{}
25+ o.visible = true
26+ o.searchToken = 8
27+ o.ghostToken = 5
28+ o.realInput = "git"
29+
30+ if !o.isGhostTokenCurrent(8, 5, "git") {
31+ t.Fatalf("expected ghost token to be current")
32+ }
33+ if o.isGhostTokenCurrent(7, 5, "git") {
34+ t.Fatalf("expected stale search token to be rejected")
35+ }
36+ if o.isGhostTokenCurrent(8, 4, "git") {
37+ t.Fatalf("expected stale ghost token to be rejected")
38+ }
39+ if o.isGhostTokenCurrent(8, 5, "github") {
40+ t.Fatalf("expected query mismatch to be rejected")
41+ }
42+}
M internal/ui/coordinator/content.go +0 -9
@@ -998,9 +998,6 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID
998998 // onSPANavigation records history when URL changes via JavaScript (History API).
999999 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
10001000 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
1001- log := logging.FromContext(ctx)
1002- log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
1003-
10041001 // Update domain model with current URI for session snapshots
10051002 c.updatePaneURI(paneID, url)
10061003
@@ -1461,12 +1458,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
14611458 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
14621459 // These are typically triggered by JavaScript redirects (window.location)
14631460 isExternal := urlutil.IsExternalScheme(uri)
1464- log.Debug().
1465- Str("pane_id", string(paneID)).
1466- Str("uri", uri).
1467- Bool("is_external", isExternal).
1468- Bool("is_loading", wv.IsLoading()).
1469- Msg("OnURIChanged")
14701461
14711462 if isExternal {
14721463 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
@@ -998,9 +998,6 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID
998 // onSPANavigation records history when URL changes via JavaScript (History API).998 // onSPANavigation records history when URL changes via JavaScript (History API).
999 // This handles SPA navigation like YouTube search, where the URL changes without a page load.999 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
1000 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {1000 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
1001- log := logging.FromContext(ctx)
1002- log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
1003-
1004 // Update domain model with current URI for session snapshots1001 // Update domain model with current URI for session snapshots
1005 c.updatePaneURI(paneID, url)1002 c.updatePaneURI(paneID, url)
1006 1003
@@ -1461,12 +1458,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1461 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)1458 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1462 // These are typically triggered by JavaScript redirects (window.location)1459 // These are typically triggered by JavaScript redirects (window.location)
1463 isExternal := urlutil.IsExternalScheme(uri)1460 isExternal := urlutil.IsExternalScheme(uri)
1464- log.Debug().
1465- Str("pane_id", string(paneID)).
1466- Str("uri", uri).
1467- Bool("is_external", isExternal).
1468- Bool("is_loading", wv.IsLoading()).
1469- Msg("OnURIChanged")
1470 1461
1471 if isExternal {1462 if isExternal {
1472 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")1463 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
M internal/ui/coordinator/navigation.go +1 -1
@@ -233,7 +233,7 @@ func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity
233233 return
234234 }
235235
236- c.navigateUC.RecordHistory(ctx, url)
236+ c.navigateUC.RecordHistory(ctx, string(paneID), url)
237237 }
238238
239239 // ActiveWebView returns the WebView for the active pane (for zoom operations).
@@ -233,7 +233,7 @@ func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity
233 return233 return
234 }234 }
235 235
236- c.navigateUC.RecordHistory(ctx, url)236+ c.navigateUC.RecordHistory(ctx, string(paneID), url)
237 }237 }
238 238
239 // ActiveWebView returns the WebView for the active pane (for zoom operations).239 // ActiveWebView returns the WebView for the active pane (for zoom operations).

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