feat: implement wave1 A1-A3 stability and performance fixes
- commit
- 9b64c671230fcb8f11875c8bebf9f49e42e2feb5
- author
- brice · · 2026-02-07 05:00 UTC
- parent
- edc44c9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 3 | 3 | ||
| 4 | 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 | 15 | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | |
| 7 | 16 | // Zero values mean "use WebKit defaults". | |
| 8 | 17 | type MemoryPressureConfig struct { | |
| @@ -42,6 +51,12 @@ type WebKitContextOptions struct { | |||
| 42 | 51 | // CacheDir is the directory for cache data. | |
| 43 | 52 | 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 | 60 | // WebProcessMemory configures memory pressure for web processes. | |
| 46 | 61 | // nil means use WebKit defaults. | |
| 47 | 62 | 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 string | 52 | 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 *MemoryPressureConfig | 62 | WebProcessMemory *MemoryPressureConfig | ||
M internal/application/usecase/navigate.go +196 -45
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "net/url" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| @@ -20,12 +21,25 @@ const ( | |||
| 20 | 21 | ||
| 21 | 22 | // logURLMaxLen is the max length for URLs in log messages. | |
| 22 | 23 | logURLMaxLen = 60 | |
| 24 | + | ||
| 25 | + | // historyWorkerFlushInterval coalesces bursts into fewer persistence writes. | |
| 26 | + | historyWorkerFlushInterval = 100 * time.Millisecond | |
| 23 | 27 | ) | |
| 24 | 28 | ||
| 25 | 29 | // historyRecord holds data for async history recording. | |
| 26 | 30 | 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 | |
| 29 | 43 | } | |
| 30 | 44 | ||
| 31 | 45 | // NavigateUseCase handles URL navigation with history recording and zoom application. | |
| @@ -33,7 +47,8 @@ type NavigateUseCase struct { | |||
| 33 | 47 | historyRepo repository.HistoryRepository | |
| 34 | 48 | zoomRepo repository.ZoomRepository | |
| 35 | 49 | 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 | |
| 37 | 52 | ||
| 38 | 53 | // Async history recording | |
| 39 | 54 | historyQueue chan historyRecord | |
| @@ -57,6 +72,7 @@ func NewNavigateUseCase( | |||
| 57 | 72 | historyRepo: historyRepo, | |
| 58 | 73 | zoomRepo: zoomRepo, | |
| 59 | 74 | defaultZoom: defaultZoom, | |
| 75 | + | recentVisits: make(map[string]paneHistoryState), | |
| 60 | 76 | historyQueue: make(chan historyRecord, historyQueueSize), | |
| 61 | 77 | done: make(chan struct{}), | |
| 62 | 78 | ctx: context.Background(), | |
| @@ -117,33 +133,64 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||
| 117 | 133 | // This prevents inflation from redirects and rapid navigation. | |
| 118 | 134 | 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 | 140 | // RecordHistory queues a history entry for async recording. | |
| 121 | 141 | // This is non-blocking to avoid SQLite I/O on the GTK main thread. | |
| 122 | 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 | 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/) | |
| 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 | + | } | |
| 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 | 161 | 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 | |
| 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 | 188 | // Non-blocking send to async queue | |
| 141 | 189 | 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}: | |
| 144 | 191 | default: | |
| 145 | 192 | // 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 | 203 | Str("component", "history-worker"). | |
| 157 | 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 | 232 | for { | |
| 160 | 233 | select { | |
| 161 | 234 | case record := <-uc.historyQueue: | |
| 162 | - | uc.persistHistory(uc.ctx, record) | |
| 235 | + | pending[record.url] += record.visits | |
| 236 | + | case <-ticker.C: | |
| 237 | + | flushPending() | |
| 163 | 238 | case <-uc.done: | |
| 164 | - | // Drain remaining records before shutdown | |
| 165 | 239 | 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 | |
| 175 | 244 | } | |
| 176 | 245 | } | |
| 177 | 246 | } | |
| @@ -189,13 +258,16 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 189 | 258 | } | |
| 190 | 259 | ||
| 191 | 260 | 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 | + | } | |
| 195 | 266 | } | |
| 196 | 267 | } else { | |
| 197 | 268 | // Create new entry | |
| 198 | 269 | entry := entity.NewHistoryEntry(record.url, "") | |
| 270 | + | entry.VisitCount = int64(max(1, record.visits)) | |
| 199 | 271 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | |
| 200 | 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 | 278 | func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error { | |
| 207 | 279 | log := logging.FromContext(ctx) | |
| 208 | 280 | ||
| 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 | + | }) | |
| 211 | 285 | log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title") | |
| 212 | 286 | ||
| 213 | 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 | 327 | // normalizeURLForHistory normalizes a URL for history storage. | |
| 254 | 328 | // 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 | + | } | |
| 264 | 357 | } | |
| 265 | - | // For paths like "https://github.com/user/" - also strip | |
| 266 | - | return strings.TrimSuffix(url, "/") | |
| 267 | 358 | } | |
| 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 | |
| 269 | 420 | } | |
| @@ -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 = 60 | 23 | 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 string | 31 | + | url string |
| 28 | - | timestamp time.Time | 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 | |||
| 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.HistoryRepository | 47 | historyRepo repository.HistoryRepository | ||
| 34 | zoomRepo repository.ZoomRepository | 48 | zoomRepo repository.ZoomRepository | ||
| 35 | defaultZoom float64 | 49 | 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 | |||
| 37 | 52 | ||||
| 38 | // Async history recording | 53 | // Async history recording | ||
| 39 | historyQueue chan historyRecord | 54 | 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.Second | 134 | 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 | - | return | 165 | + | |
| 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 queue | 188 | // 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 navigation | 192 | // 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 count | 261 | + | 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 entry | 268 | // 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 paths | 331 | + | 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 more | 334 | + | |
| 261 | - | if strings.Count(url, "/") == 3 { | 335 | + | func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string { |
| 262 | - | // It's "https://domain.com/" - strip the trailing slash | 336 | + | 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 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 | |||
| 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 { | |||
| 46 | 46 | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | |
| 47 | 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 | 54 | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 51 | 55 | if err != nil { | |
| 52 | 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 | 101 | 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 | 116 | wkOpts := port.WebKitContextOptions{ | |
| 102 | - | DataDir: dataDir, | |
| 103 | - | CacheDir: cacheDir, | |
| 117 | + | DataDir: dataDir, | |
| 118 | + | CacheDir: cacheDir, | |
| 119 | + | CookiePolicy: cookiePolicy, | |
| 120 | + | ITPEnabled: itpEnabled, | |
| 104 | 121 | } | |
| 105 | 122 | ||
| 106 | 123 | 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 perfSettings | 101 | 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 { | |||
| 153 | 153 | SampleMemory: false, | |
| 154 | 154 | DebugFrames: false, | |
| 155 | 155 | }, | |
| 156 | + | Privacy: PrivacyConfig{ | |
| 157 | + | CookiePolicy: CookiePolicyNoThirdParty, | |
| 158 | + | ITPEnabled: true, | |
| 159 | + | }, | |
| 156 | 160 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | |
| 157 | 161 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | |
| 158 | 162 | 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 readability | 160 | 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) { | |||
| 10 | 10 | cfg := DefaultConfig() | |
| 11 | 11 | assert.Equal(t, "info", cfg.Logging.Level) | |
| 12 | 12 | assert.False(t, cfg.Logging.CaptureGTKLogs) | |
| 13 | + | assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy) | |
| 14 | + | assert.True(t, cfg.Privacy.ITPEnabled) | |
| 13 | 15 | } | |
| @@ -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) { | |||
| 201 | 201 | 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 | 215 | switch config.Appearance.ColorScheme { | |
| 205 | 216 | case ThemePreferDark, ThemePreferLight, ThemeDefault: | |
| 206 | 217 | case "": | |
| @@ -373,6 +384,7 @@ func (m *Manager) setDefaults() { | |||
| 373 | 384 | m.setDebugDefaults(defaults) | |
| 374 | 385 | m.setAppearanceDefaults(defaults) | |
| 375 | 386 | m.setRenderingDefaults(defaults) | |
| 387 | + | m.setPrivacyDefaults(defaults) | |
| 376 | 388 | m.setWorkspaceDefaults(defaults) | |
| 377 | 389 | m.setContentFilteringDefaults(defaults) | |
| 378 | 390 | m.setClipboardDefaults(defaults) | |
| @@ -447,6 +459,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) { | |||
| 447 | 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 | 467 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |
| 451 | 468 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | |
| 452 | 469 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | |
| @@ -201,6 +201,17 @@ func normalizeConfig(config *Config) { | |||||
| 201 | config.Rendering.GSKRenderer = GSKRendererAuto | 201 | 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 { | |||
| 27 | 27 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | |
| 28 | 28 | // Rendering controls WebKit, GTK and compositor behavior. | |
| 29 | 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 | 32 | // Media controls video playback and hardware acceleration | |
| 31 | 33 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | |
| 32 | 34 | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | |
| @@ -48,6 +50,18 @@ const ( | |||
| 48 | 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 | 65 | // GSKRendererMode controls the GTK Scene Kit renderer selection. | |
| 52 | 66 | type GSKRendererMode string | |
| 53 | 67 | ||
| @@ -103,6 +117,15 @@ type RenderingConfig struct { | |||
| 103 | 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 | 129 | // HardwareDecodingMode controls video hardware acceleration. | |
| 107 | 130 | type HardwareDecodingMode string | |
| 108 | 131 | ||
| @@ -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 acceleration | 32 | // 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 string | 66 | 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 string | 130 | type HardwareDecodingMode string | ||
| 108 | 131 | ||||
M internal/infrastructure/config/validation.go +13 -0
| @@ -26,6 +26,7 @@ func validateConfig(config *Config) error { | |||
| 26 | 26 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | |
| 27 | 27 | validationErrors = append(validationErrors, validateOmnibox(config)...) | |
| 28 | 28 | validationErrors = append(validationErrors, validateRendering(config)...) | |
| 29 | + | validationErrors = append(validationErrors, validatePrivacy(config)...) | |
| 29 | 30 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 30 | 31 | validationErrors = append(validationErrors, validateSession(config)...) | |
| 31 | 32 | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |
| @@ -323,6 +324,18 @@ func validateRendering(config *Config) []string { | |||
| 323 | 324 | 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 | 339 | func validateColorScheme(config *Config) []string { | |
| 327 | 340 | switch config.Appearance.ColorScheme { | |
| 328 | 341 | 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 validationErrors | 324 | 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 ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "strings" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "time" | |
| 11 | + | "unicode" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 14 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -16,6 +18,8 @@ import ( | |||
| 16 | 18 | ||
| 17 | 19 | const logURLMaxLen = 60 | |
| 18 | 20 | ||
| 21 | + | var expectedFTSFailureCount atomic.Uint64 | |
| 22 | + | ||
| 19 | 23 | type historyRepo struct { | |
| 20 | 24 | 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 | 74 | 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) | |
| 84 | 76 | if len(words) == 0 { | |
| 85 | 77 | return []entity.HistoryMatch{}, nil | |
| 86 | 78 | } | |
| 87 | 79 | ||
| 88 | 80 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | |
| 89 | - | // Sanitize each word to remove FTS5 special characters | |
| 90 | 81 | parts := make([]string, len(words)) | |
| 91 | 82 | for i, word := range words { | |
| 92 | - | parts[i] = sanitizeFTS5Word(word) + "*" | |
| 83 | + | parts[i] = word + "*" | |
| 93 | 84 | } | |
| 94 | 85 | ftsQuery := strings.Join(parts, " ") | |
| 95 | 86 | ||
| @@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 97 | 88 | // Use domain-boosted query for URL search to prioritize domain matches | |
| 98 | 89 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | |
| 99 | 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 | 92 | Query: ftsQuery, | |
| 102 | 93 | Limit: int64(limit), | |
| 103 | 94 | }) | |
| 104 | 95 | if urlErr != nil { | |
| 105 | - | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") | |
| 96 | + | logFTSSearchError(ctx, "url", query, urlErr) | |
| 106 | 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 | 102 | Limit: int64(limit), | |
| 112 | 103 | }) | |
| 113 | 104 | if titleErr != nil { | |
| 114 | - | log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed") | |
| 105 | + | logFTSSearchError(ctx, "title", query, titleErr) | |
| 115 | 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. | |
| 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. | |
| 169 | 195 | func sanitizeFTS5Word(word string) string { | |
| 170 | - | // Remove characters that have special meaning in FTS5 | |
| 171 | 196 | var result strings.Builder | |
| 197 | + | result.Grow(len(word)) | |
| 172 | 198 | for _, r := range word { | |
| 173 | - | switch r { | |
| 174 | - | case '"', '*', '(', ')', ':', '^', '-', '/': | |
| 175 | - | // Skip FTS5 special characters | |
| 176 | - | default: | |
| 199 | + | if unicode.IsLetter(r) || unicode.IsNumber(r) { | |
| 177 | 200 | result.WriteRune(r) | |
| 178 | 201 | } | |
| 179 | 202 | } | |
| 180 | 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 | 228 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 184 | 229 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 185 | 230 | 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 = 60 | 19 | const logURLMaxLen = 60 | ||
| 18 | 20 | ||||
| 21 | + | var expectedFTSFailureCount atomic.Uint64 | |||
| 22 | + | ||||
| 19 | type historyRepo struct { | 23 | type historyRepo struct { | ||
| 20 | queries *sqlc.Queries | 24 | 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{}, nil | 77 | 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 matches | 88 | // 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 NEAR | 158 | + | 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.Builder | 196 | 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) { | |||
| 429 | 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 | 461 | func TestHistoryRepository_Search_NoResults(t *testing.T) { | |
| 433 | 462 | ctx := historyTestCtx() | |
| 434 | 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 | 610 | func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |
| 561 | 611 | ctx := historyTestCtx() | |
| 562 | 612 | 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 { | |||
| 37 | 37 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | |
| 38 | 38 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | |
| 39 | 39 | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | |
| 40 | - | DataDir: dataDir, | |
| 41 | - | CacheDir: cacheDir, | |
| 40 | + | DataDir: dataDir, | |
| 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 | 74 | // Create persistent network session FIRST | |
| 73 | 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 | 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 | 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 | 118 | // Create persistent network session | |
| 117 | 119 | session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir) | |
| 118 | 120 | if session == nil { | |
| @@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error { | |||
| 158 | 160 | ||
| 159 | 161 | cookiePath := filepath.Join(c.dataDir, "cookies.db") | |
| 160 | 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 | 168 | Str("cookie_path", cookiePath). | |
| 169 | + | Str("cookie_policy", cookiePolicyLabel). | |
| 170 | + | Bool("itp_enabled", opts.ITPEnabled). | |
| 165 | 171 | Msg("cookie storage configured") | |
| 166 | 172 | ||
| 167 | 173 | // Enable persistent credential storage | |
| @@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error { | |||
| 182 | 188 | 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 | 204 | // Context returns the shared WebContext. | |
| 186 | 205 | func (c *WebKitContext) Context() *webkit.WebContext { | |
| 187 | 206 | 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 FIRST | 74 | // 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 session | 118 | // 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 storage | 173 | // Enable persistent credential storage | ||
| @@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error { | |||||
| 182 | return nil | 188 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "math" | |
| 7 | 6 | "strings" | |
| 8 | 7 | "sync" | |
| 9 | 8 | "time" | |
| @@ -21,15 +20,12 @@ import ( | |||
| 21 | 20 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 22 | 21 | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 23 | 22 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 24 | - | "github.com/jwijenbergh/puregotk/v4/pango" | |
| 25 | 23 | ) | |
| 26 | 24 | ||
| 27 | 25 | const ( | |
| 28 | 26 | debounceDelayMs = 50 | |
| 29 | 27 | endBoxSpacing = 6 | |
| 30 | 28 | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | |
| 31 | - | ghostPositionRetryMs = 16 | |
| 32 | - | ghostPositionMaxAttempts = 5 | |
| 33 | 29 | minGhostInputLength = 1 | |
| 34 | 30 | ) | |
| 35 | 31 | ||
| @@ -76,8 +72,6 @@ type Omnibox struct { | |||
| 76 | 72 | zoomLabel *gtk.Label | |
| 77 | 73 | bangBadge *gtk.Label | |
| 78 | 74 | entry *gtk.SearchEntry | |
| 79 | - | entryOverlay *gtk.Overlay // Overlay for ghost text | |
| 80 | - | ghostLabel *gtk.Label // Ghost text completion label | |
| 81 | 75 | scrolledWin *gtk.ScrolledWindow | |
| 82 | 76 | listBox *gtk.ListBox | |
| 83 | 77 | ||
| @@ -123,6 +117,8 @@ type Omnibox struct { | |||
| 123 | 117 | debounceTimer *time.Timer | |
| 124 | 118 | debounceMu sync.Mutex | |
| 125 | 119 | lastQuery string // Prevent duplicate searches | |
| 120 | + | searchToken uint64 | |
| 121 | + | ghostToken uint64 | |
| 126 | 122 | ||
| 127 | 123 | // Scaling | |
| 128 | 124 | uiScale float64 | |
| @@ -512,30 +508,6 @@ func (o *Omnibox) initEntry() error { | |||
| 512 | 508 | placeholder := defaultOmniboxPlaceholder | |
| 513 | 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 | 511 | return nil | |
| 540 | 512 | } | |
| 541 | 513 | ||
| @@ -636,7 +608,7 @@ func (o *Omnibox) assembleWidgets() { | |||
| 636 | 608 | ||
| 637 | 609 | o.scrolledWin.SetChild(&o.listBox.Widget) | |
| 638 | 610 | 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) | |
| 640 | 612 | o.mainBox.Append(&o.scrolledWin.Widget) | |
| 641 | 613 | o.outerBox.Append(&o.mainBox.Widget) | |
| 642 | 614 | } | |
| @@ -850,169 +822,83 @@ func (o *Omnibox) onEntryChanged() { | |||
| 850 | 822 | o.debounceMu.Unlock() | |
| 851 | 823 | } | |
| 852 | 824 | ||
| 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. | |
| 857 | 827 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |
| 858 | - | if o.ghostLabel == nil || suffix == "" { | |
| 828 | + | if o.entry == nil || suffix == "" { | |
| 859 | 829 | o.clearGhostText() | |
| 860 | 830 | return | |
| 861 | 831 | } | |
| 862 | 832 | ||
| 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 | + | ||
| 864 | 839 | o.mu.Lock() | |
| 840 | + | o.realInput = originalInput | |
| 865 | 841 | o.ghostSuffix = suffix | |
| 866 | 842 | o.ghostFullText = fullText | |
| 867 | 843 | o.hasGhostText = true | |
| 868 | 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 | 846 | 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 { | |
| 883 | 848 | return false | |
| 884 | 849 | } | |
| 885 | 850 | ||
| 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 { | |
| 888 | 855 | return false | |
| 889 | 856 | } | |
| 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() | |
| 925 | 860 | ||
| 926 | - | return false | |
| 927 | - | } | |
| 861 | + | o.entry.SetText(displayText) | |
| 862 | + | o.entry.SelectRegion(selectionStart, -1) | |
| 928 | 863 | ||
| 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() | |
| 932 | 867 | return false | |
| 933 | 868 | } | |
| 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) | |
| 992 | 870 | } | |
| 993 | 871 | ||
| 994 | 872 | // clearGhostText hides the ghost completion text. | |
| 995 | 873 | func (o *Omnibox) clearGhostText() { | |
| 996 | 874 | o.mu.Lock() | |
| 875 | + | realInput := o.realInput | |
| 876 | + | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |
| 877 | + | o.ghostToken++ | |
| 997 | 878 | o.ghostSuffix = "" | |
| 998 | 879 | o.ghostFullText = "" | |
| 999 | 880 | o.hasGhostText = false | |
| 1000 | 881 | o.mu.Unlock() | |
| 1001 | 882 | ||
| 1002 | - | if o.ghostLabel == nil { | |
| 883 | + | if !hadGhost || o.entry == nil { | |
| 1003 | 884 | return | |
| 1004 | 885 | } | |
| 1005 | 886 | ||
| 1006 | 887 | 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 | |
| 1010 | 890 | } | |
| 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) | |
| 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 | 902 | return false | |
| 1017 | 903 | } | |
| 1018 | 904 | glib.IdleAdd(&cb, 0) | |
| @@ -1021,31 +907,13 @@ func (o *Omnibox) clearGhostText() { | |||
| 1021 | 907 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | |
| 1022 | 908 | // This prevents stale background results from wiping newer ghost text. | |
| 1023 | 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 | 914 | return | |
| 1026 | 915 | } | |
| 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() | |
| 1049 | 917 | } | |
| 1050 | 918 | ||
| 1051 | 919 | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| @@ -1058,17 +926,18 @@ func (o *Omnibox) acceptGhostCompletion() { | |||
| 1058 | 926 | fullText := o.ghostFullText | |
| 1059 | 927 | o.isAcceptingGhost = true | |
| 1060 | 928 | o.realInput = fullText | |
| 929 | + | o.ghostToken++ | |
| 1061 | 930 | o.ghostSuffix = "" | |
| 1062 | 931 | o.ghostFullText = "" | |
| 1063 | 932 | o.hasGhostText = false | |
| 1064 | 933 | o.mu.Unlock() | |
| 1065 | 934 | ||
| 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) | |
| 1072 | 941 | } | |
| 1073 | 942 | ||
| 1074 | 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 | 1057 | go func() { | |
| 1183 | 1058 | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | |
| 1184 | 1059 | VisibleURLs: visibleURLs, | |
| 1185 | 1060 | AllowBangs: false, | |
| 1186 | 1061 | }) | |
| 1062 | + | if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) { | |
| 1063 | + | return | |
| 1064 | + | } | |
| 1187 | 1065 | if suggestion == nil { | |
| 1188 | 1066 | o.clearGhostTextIfInput(entryText) | |
| 1189 | 1067 | 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 | 1082 | // performSearch executes the search based on current view mode and query. | |
| 1196 | 1083 | func (o *Omnibox) performSearch() { | |
| 1197 | 1084 | o.mu.RLock() | |
| @@ -1215,6 +1102,11 @@ func (o *Omnibox) performSearch() { | |||
| 1215 | 1102 | o.lastQuery = query | |
| 1216 | 1103 | o.debounceMu.Unlock() | |
| 1217 | 1104 | ||
| 1105 | + | o.mu.Lock() | |
| 1106 | + | o.searchToken++ | |
| 1107 | + | token := o.searchToken | |
| 1108 | + | o.mu.Unlock() | |
| 1109 | + | ||
| 1218 | 1110 | if strings.HasPrefix(query, "!") { | |
| 1219 | 1111 | o.updateBangDetection(query) | |
| 1220 | 1112 | o.loadBangSuggestions(query) | |
| @@ -1224,23 +1116,23 @@ func (o *Omnibox) performSearch() { | |||
| 1224 | 1116 | o.clearBangState() | |
| 1225 | 1117 | ||
| 1226 | 1118 | if mode == ViewModeFavorites { | |
| 1227 | - | o.loadFavorites(query) | |
| 1119 | + | o.loadFavorites(query, token) | |
| 1228 | 1120 | return | |
| 1229 | 1121 | } | |
| 1230 | 1122 | ||
| 1231 | 1123 | // History search | |
| 1232 | 1124 | if query == "" { | |
| 1233 | - | o.loadInitialHistory() | |
| 1125 | + | o.loadInitialHistory(token) | |
| 1234 | 1126 | return | |
| 1235 | 1127 | } | |
| 1236 | 1128 | ||
| 1237 | 1129 | // Perform fuzzy history search in background | |
| 1238 | - | o.searchHistory(query, o.effectiveMaxRows()) | |
| 1130 | + | o.searchHistory(query, o.effectiveMaxRows(), token) | |
| 1239 | 1131 | } | |
| 1240 | 1132 | ||
| 1241 | 1133 | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1242 | 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 | 1136 | go func() { | |
| 1245 | 1137 | ctx := o.ctx | |
| 1246 | 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 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) | |
| 1296 | 1188 | }() | |
| 1297 | 1189 | } | |
| 1298 | 1190 | ||
| 1299 | 1191 | // loadInitialHistory loads history based on InitialBehavior config. | |
| 1300 | - | func (o *Omnibox) loadInitialHistory() { | |
| 1192 | + | func (o *Omnibox) loadInitialHistory(token uint64) { | |
| 1301 | 1193 | if o.historyUC == nil { | |
| 1302 | 1194 | return | |
| 1303 | 1195 | } | |
| @@ -1312,7 +1204,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1312 | 1204 | ||
| 1313 | 1205 | switch o.initialBehavior { | |
| 1314 | 1206 | case "none": | |
| 1315 | - | o.idleAddUpdateSuggestions(nil, "") | |
| 1207 | + | o.idleAddUpdateSuggestions(nil, "", token) | |
| 1316 | 1208 | return | |
| 1317 | 1209 | ||
| 1318 | 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 | 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 | 1258 | if o.favoritesUC == nil { | |
| 1367 | 1259 | 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 | 1892 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | |
| 2001 | 1893 | ||
| 2002 | 1894 | // 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 | 1899 | if o.onToast != nil { | |
| 2005 | 1900 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 2006 | 1901 | o.onToast(ctx, "Favorite removed", ToastSuccess) | |
| @@ -2139,13 +2034,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2139 | 2034 | o.isAcceptingGhost = false | |
| 2140 | 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 | 2037 | // Set initial query | |
| 2148 | 2038 | o.entry.SetText(query) | |
| 2039 | + | o.entry.SelectRegion(-1, -1) | |
| 2149 | 2040 | ||
| 2150 | 2041 | // Determine if we expect content initially | |
| 2151 | 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 | 2161 | // 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) { | |
| 2273 | 2163 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 2164 | + | if !o.isSearchTokenCurrent(token) { | |
| 2165 | + | return false | |
| 2166 | + | } | |
| 2274 | 2167 | o.updateSuggestions(suggestions, query) | |
| 2275 | 2168 | 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 | 2188 | // 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) { | |
| 2298 | 2190 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 2191 | + | if !o.isSearchTokenCurrent(token) { | |
| 2192 | + | return false | |
| 2193 | + | } | |
| 2299 | 2194 | o.updateFavorites(favorites, query) | |
| 2300 | 2195 | return false // One-shot callback, return false to remove source | |
| 2301 | 2196 | } | |
| 2302 | 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 | 2206 | // Widget returns the omnibox widget for embedding in an overlay. | |
| 2306 | 2207 | func (o *Omnibox) Widget() *gtk.Widget { | |
| 2307 | 2208 | 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 = 50 | 26 | debounceDelayMs = 50 | ||
| 29 | endBoxSpacing = 6 | 27 | 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 = 1 | 29 | minGhostInputLength = 1 | ||
| 34 | ) | 30 | ) | ||
| 35 | 31 | ||||
| @@ -76,8 +72,6 @@ type Omnibox struct { | |||||
| 76 | zoomLabel *gtk.Label | 72 | zoomLabel *gtk.Label | ||
| 77 | bangBadge *gtk.Label | 73 | bangBadge *gtk.Label | ||
| 78 | entry *gtk.SearchEntry | 74 | entry *gtk.SearchEntry | ||
| 79 | - | entryOverlay *gtk.Overlay // Overlay for ghost text | |||
| 80 | - | ghostLabel *gtk.Label // Ghost text completion label | |||
| 81 | scrolledWin *gtk.ScrolledWindow | 75 | scrolledWin *gtk.ScrolledWindow | ||
| 82 | listBox *gtk.ListBox | 76 | listBox *gtk.ListBox | ||
| 83 | 77 | ||||
| @@ -123,6 +117,8 @@ type Omnibox struct { | |||||
| 123 | debounceTimer *time.Timer | 117 | debounceTimer *time.Timer | ||
| 124 | debounceMu sync.Mutex | 118 | debounceMu sync.Mutex | ||
| 125 | lastQuery string // Prevent duplicate searches | 119 | lastQuery string // Prevent duplicate searches | ||
| 120 | + | searchToken uint64 | |||
| 121 | + | ghostToken uint64 | |||
| 126 | 122 | ||||
| 127 | // Scaling | 123 | // Scaling | ||
| 128 | uiScale float64 | 124 | uiScale float64 | ||
| @@ -512,30 +508,6 @@ func (o *Omnibox) initEntry() error { | |||||
| 512 | placeholder := defaultOmniboxPlaceholder | 508 | 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 nil | 511 | 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 support | 611 | + | 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 | return | 830 | return | ||
| 861 | } | 831 | } | ||
| 862 | 832 | ||||
| 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 | + | ||||
| 864 | o.mu.Lock() | 839 | o.mu.Lock() | ||
| 840 | + | o.realInput = originalInput | |||
| 865 | o.ghostSuffix = suffix | 841 | o.ghostSuffix = suffix | ||
| 866 | o.ghostFullText = fullText | 842 | o.ghostFullText = fullText | ||
| 867 | o.hasGhostText = true | 843 | 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 false | 848 | 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 false | 855 | return false | ||
| 889 | } | 856 | } | ||
| 890 | - | o.applyGhostPosition(suffix, marginStart) | 857 | + | o.mu.Lock() |
| 891 | - | return false | 858 | + | 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 false | 861 | + | 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 false | 867 | 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 = false | 880 | 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 | return | 884 | 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 text | 891 | + | o.mu.Lock() |
| 1012 | - | if o.entry != nil { | 892 | + | o.isAcceptingGhost = true |
| 1013 | - | placeholder := defaultOmniboxPlaceholder | 893 | + | 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 false | 902 | 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 | return | 914 | 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.ghostFullText | 926 | fullText := o.ghostFullText | ||
| 1059 | o.isAcceptingGhost = true | 927 | o.isAcceptingGhost = true | ||
| 1060 | o.realInput = fullText | 928 | o.realInput = fullText | ||
| 929 | + | o.ghostToken++ | |||
| 1061 | o.ghostSuffix = "" | 930 | o.ghostSuffix = "" | ||
| 1062 | o.ghostFullText = "" | 931 | o.ghostFullText = "" | ||
| 1063 | o.hasGhostText = false | 932 | o.hasGhostText = false | ||
| 1064 | o.mu.Unlock() | 933 | o.mu.Unlock() | ||
| 1065 | 934 | ||||
| 1066 | - | // Update entry text and clear ghost label | 935 | + | if o.entry != nil { |
| 1067 | - | o.entry.SetText(fullText) | 936 | + | if o.entry.GetText() != fullText { |
| 1068 | - | o.entry.SetPosition(-1) // Move cursor to end | 937 | + | 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 | return | 1067 | 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 = query | 1102 | 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 | return | 1120 | return | ||
| 1229 | } | 1121 | } | ||
| 1230 | 1122 | ||||
| 1231 | // History search | 1123 | // History search | ||
| 1232 | if query == "" { | 1124 | if query == "" { | ||
| 1233 | - | o.loadInitialHistory() | 1125 | + | o.loadInitialHistory(token) |
| 1234 | return | 1126 | return | ||
| 1235 | } | 1127 | } | ||
| 1236 | 1128 | ||||
| 1237 | // Perform fuzzy history search in background | 1129 | // 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.ctx | 1137 | 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 input | 1186 | + | // 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 | return | 1194 | 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 | return | 1208 | 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 | return | 1259 | 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 toast | 1894 | // 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 = false | 2034 | 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 query | 2037 | // 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 initially | 2041 | // 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 callback | 2168 | 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 source | 2195 | 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 | |||
| 998 | 998 | // onSPANavigation records history when URL changes via JavaScript (History API). | |
| 999 | 999 | // This handles SPA navigation like YouTube search, where the URL changes without a page load. | |
| 1000 | 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 | 1001 | // Update domain model with current URI for session snapshots | |
| 1005 | 1002 | c.updatePaneURI(paneID, url) | |
| 1006 | 1003 | ||
| @@ -1461,12 +1458,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 1461 | 1458 | // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.) | |
| 1462 | 1459 | // These are typically triggered by JavaScript redirects (window.location) | |
| 1463 | 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 | 1462 | if isExternal { | |
| 1472 | 1463 | 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 snapshots | 1001 | // 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 | |||
| 233 | 233 | return | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | - | c.navigateUC.RecordHistory(ctx, url) | |
| 236 | + | c.navigateUC.RecordHistory(ctx, string(paneID), url) | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | // 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 | return | 233 | 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). | ||