feat(omnibox): add configurable initial history display (recent/most_visited/none)
- commit
- f83a7ce3636b5b03b2d209c184d11301992a5c61
- author
- bnema · · 2025-11-18 21:08 UTC
- parent
- 890e687
- signature
- unsigned
15 files · +235 -4
M CHANGELOG.md +3 -0
| @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ### Added | |
| 8 | + | - **Omnibox initial history display**: Configurable behavior when opening empty omnibox via `omnibox.initial_behavior` ("recent", "most_visited", "none"). Shows initial history on open and when input is cleared. Default: recent visits. | |
| 9 | + | ||
| 7 | 10 | ## [0.14.1] - 2025-11-18 | |
| 8 | 11 | ||
| 9 | 12 | ### Added | |
| @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. | |||||
| 4 | 4 | ||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | + | ### Added | |||
| 8 | + | - **Omnibox initial history display**: Configurable behavior when opening empty omnibox via `omnibox.initial_behavior` ("recent", "most_visited", "none"). Shows initial history on open and when input is cleared. Default: recent visits. | |||
| 9 | + | ||||
| 7 | ## [0.14.1] - 2025-11-18 | 10 | ## [0.14.1] - 2025-11-18 | ||
| 8 | 11 | ||||
| 9 | ### Added | 12 | ### Added | ||
M gui/src/components/omnibox/Omnibox.svelte +8 -1
| @@ -122,6 +122,11 @@ | |||
| 122 | 122 | inputElement.focus(); | |
| 123 | 123 | // Enable keyboard event blocking in main world when omnibox opens | |
| 124 | 124 | enablePageEventBlocking(); | |
| 125 | + | ||
| 126 | + | // Fetch initial history if opening in omnibox mode with empty input | |
| 127 | + | if (mode === 'omnibox' && omniboxStore.inputValue === '') { | |
| 128 | + | omniboxBridge.fetchInitialHistory(); | |
| 129 | + | } | |
| 125 | 130 | } else if (!visible) { | |
| 126 | 131 | // Disable keyboard event blocking when omnibox closes | |
| 127 | 132 | disablePageEventBlocking(); | |
| @@ -367,8 +372,10 @@ | |||
| 367 | 372 | ||
| 368 | 373 | // Auto-open omnibox on about:blank pages | |
| 369 | 374 | if (window.location.href === 'about:blank') { | |
| 370 | - | console.log('🚀 Auto-opening omnibox for about:blank'); | |
| 375 | + | console.log('Auto-opening omnibox for about:blank'); | |
| 371 | 376 | omniboxStore.open('omnibox'); | |
| 377 | + | // Fetch initial history for empty omnibox | |
| 378 | + | omniboxBridge.fetchInitialHistory(); | |
| 372 | 379 | } | |
| 373 | 380 | } catch (error) { | |
| 374 | 381 | console.error('❌ Failed to set up omnibox global API:', error); | |
| @@ -122,6 +122,11 @@ | |||||
| 122 | inputElement.focus(); | 122 | inputElement.focus(); | ||
| 123 | // Enable keyboard event blocking in main world when omnibox opens | 123 | // Enable keyboard event blocking in main world when omnibox opens | ||
| 124 | enablePageEventBlocking(); | 124 | enablePageEventBlocking(); | ||
| 125 | + | ||||
| 126 | + | // Fetch initial history if opening in omnibox mode with empty input | |||
| 127 | + | if (mode === 'omnibox' && omniboxStore.inputValue === '') { | |||
| 128 | + | omniboxBridge.fetchInitialHistory(); | |||
| 129 | + | } | |||
| 125 | } else if (!visible) { | 130 | } else if (!visible) { | ||
| 126 | // Disable keyboard event blocking when omnibox closes | 131 | // Disable keyboard event blocking when omnibox closes | ||
| 127 | disablePageEventBlocking(); | 132 | disablePageEventBlocking(); | ||
| @@ -367,8 +372,10 @@ | |||||
| 367 | 372 | ||||
| 368 | // Auto-open omnibox on about:blank pages | 373 | // Auto-open omnibox on about:blank pages | ||
| 369 | if (window.location.href === 'about:blank') { | 374 | if (window.location.href === 'about:blank') { | ||
| 370 | - | console.log('🚀 Auto-opening omnibox for about:blank'); | 375 | + | console.log('Auto-opening omnibox for about:blank'); |
| 371 | omniboxStore.open('omnibox'); | 376 | omniboxStore.open('omnibox'); | ||
| 377 | + | // Fetch initial history for empty omnibox | |||
| 378 | + | omniboxBridge.fetchInitialHistory(); | |||
| 372 | } | 379 | } | ||
| 373 | } catch (error) { | 380 | } catch (error) { | ||
| 374 | console.error('❌ Failed to set up omnibox global API:', error); | 381 | console.error('❌ Failed to set up omnibox global API:', error); | ||
M gui/src/components/omnibox/OmniboxInput.svelte +7 -1
| @@ -122,7 +122,13 @@ | |||
| 122 | 122 | // Only query backend if we're in history view | |
| 123 | 123 | // In favorites view, filtering happens locally via computed state | |
| 124 | 124 | if (viewMode === 'history') { | |
| 125 | - | debouncedQuery(value); | |
| 125 | + | if (value === '') { | |
| 126 | + | // Input is empty - fetch initial history based on config | |
| 127 | + | omniboxBridge.fetchInitialHistory(); | |
| 128 | + | } else { | |
| 129 | + | // Input has content - perform search | |
| 130 | + | debouncedQuery(value); | |
| 131 | + | } | |
| 126 | 132 | } | |
| 127 | 133 | } else if (mode === 'find') { | |
| 128 | 134 | // Immediate find for search | |
| @@ -122,7 +122,13 @@ | |||||
| 122 | // Only query backend if we're in history view | 122 | // Only query backend if we're in history view | ||
| 123 | // In favorites view, filtering happens locally via computed state | 123 | // In favorites view, filtering happens locally via computed state | ||
| 124 | if (viewMode === 'history') { | 124 | if (viewMode === 'history') { | ||
| 125 | - | debouncedQuery(value); | 125 | + | if (value === '') { |
| 126 | + | // Input is empty - fetch initial history based on config | |||
| 127 | + | omniboxBridge.fetchInitialHistory(); | |||
| 128 | + | } else { | |||
| 129 | + | // Input has content - perform search | |||
| 130 | + | debouncedQuery(value); | |||
| 131 | + | } | |||
| 126 | } | 132 | } | ||
| 127 | } else if (mode === 'find') { | 133 | } else if (mode === 'find') { | ||
| 128 | // Immediate find for search | 134 | // Immediate find for search | ||
M gui/src/components/omnibox/messaging.ts +10 -0
| @@ -66,6 +66,16 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||
| 66 | 66 | this.postMessage({ type: "query", q, limit: lim }); | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | + | /** | |
| 70 | + | * Fetch initial history for empty omnibox | |
| 71 | + | */ | |
| 72 | + | fetchInitialHistory(limit?: number): void { | |
| 73 | + | const lim = limit || omniboxStore.config.defaultLimit; | |
| 74 | + | console.log("[DEBUG] Fetching initial history:", { limit: lim }); | |
| 75 | + | // Send to native handler; Go will compute suggestions based on config and call setSuggestions | |
| 76 | + | this.postMessage({ type: "omnibox_initial_history", limit: lim }); | |
| 77 | + | } | |
| 78 | + | ||
| 69 | 79 | /** | |
| 70 | 80 | * Fetch search shortcuts from backend via messaging bridge | |
| 71 | 81 | */ | |
| @@ -66,6 +66,16 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||||
| 66 | this.postMessage({ type: "query", q, limit: lim }); | 66 | this.postMessage({ type: "query", q, limit: lim }); | ||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | + | /** | |||
| 70 | + | * Fetch initial history for empty omnibox | |||
| 71 | + | */ | |||
| 72 | + | fetchInitialHistory(limit?: number): void { | |||
| 73 | + | const lim = limit || omniboxStore.config.defaultLimit; | |||
| 74 | + | console.log("[DEBUG] Fetching initial history:", { limit: lim }); | |||
| 75 | + | // Send to native handler; Go will compute suggestions based on config and call setSuggestions | |||
| 76 | + | this.postMessage({ type: "omnibox_initial_history", limit: lim }); | |||
| 77 | + | } | |||
| 78 | + | ||||
| 69 | /** | 79 | /** | ||
| 70 | * Fetch search shortcuts from backend via messaging bridge | 80 | * Fetch search shortcuts from backend via messaging bridge | ||
| 71 | */ | 81 | */ | ||
M gui/src/components/omnibox/types.ts +1 -1
| @@ -23,7 +23,7 @@ export interface SearchShortcut { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | export interface OmniboxMessage { | |
| 26 | - | type: "navigate" | "query" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; | |
| 26 | + | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; | |
| 27 | 27 | url?: string; | |
| 28 | 28 | q?: string; | |
| 29 | 29 | limit?: number; | |
| @@ -23,7 +23,7 @@ export interface SearchShortcut { | |||||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | export interface OmniboxMessage { | 25 | export interface OmniboxMessage { | ||
| 26 | - | type: "navigate" | "query" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; | 26 | + | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; |
| 27 | url?: string; | 27 | url?: string; | ||
| 28 | q?: string; | 28 | q?: string; | ||
| 29 | limit?: number; | 29 | limit?: number; | ||
M internal/app/messaging/handler.go +100 -0
| @@ -91,6 +91,11 @@ func (h *Handler) Handle(payload string) { | |||
| 91 | 91 | return | |
| 92 | 92 | } | |
| 93 | 93 | h.handleQuery(msg) | |
| 94 | + | case "omnibox_initial_history": | |
| 95 | + | if h.webView == nil { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | h.handleOmniboxInitialHistory(msg) | |
| 94 | 99 | case "wails": | |
| 95 | 100 | h.handleWailsBridge(msg) | |
| 96 | 101 | case "theme": | |
| @@ -340,6 +345,101 @@ func (h *Handler) handleQuery(msg Message) { | |||
| 340 | 345 | } | |
| 341 | 346 | } | |
| 342 | 347 | ||
| 348 | + | // handleOmniboxInitialHistory processes initial history display for empty omnibox | |
| 349 | + | func (h *Handler) handleOmniboxInitialHistory(msg Message) { | |
| 350 | + | if h.webView == nil || h.browserService == nil { | |
| 351 | + | return | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | ctx := context.Background() | |
| 355 | + | limit := msg.Limit | |
| 356 | + | if limit <= 0 { | |
| 357 | + | limit = 10 | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // Get config to determine behavior | |
| 361 | + | cfg, err := h.browserService.GetConfig(ctx) | |
| 362 | + | if err != nil { | |
| 363 | + | log.Printf("[ERROR] Failed to get config: %v", err) | |
| 364 | + | return | |
| 365 | + | } | |
| 366 | + | behavior := cfg.Omnibox.InitialBehavior | |
| 367 | + | ||
| 368 | + | type suggestion struct { | |
| 369 | + | URL string `json:"url"` | |
| 370 | + | Favicon string `json:"favicon,omitempty"` | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | results := make([]suggestion, 0, limit) | |
| 374 | + | seen := make(map[string]struct{}, limit*2) | |
| 375 | + | ||
| 376 | + | // Fetch history based on config | |
| 377 | + | var entries []interface{} | |
| 378 | + | ||
| 379 | + | if behavior == "recent" { | |
| 380 | + | historyEntries, histErr := h.browserService.GetRecentHistory(ctx, limit) | |
| 381 | + | err = histErr | |
| 382 | + | for _, e := range historyEntries { | |
| 383 | + | entries = append(entries, e) | |
| 384 | + | } | |
| 385 | + | } else if behavior == "most_visited" { | |
| 386 | + | historyEntries, histErr := h.browserService.GetMostVisited(ctx, limit) | |
| 387 | + | err = histErr | |
| 388 | + | for _, e := range historyEntries { | |
| 389 | + | entries = append(entries, e) | |
| 390 | + | } | |
| 391 | + | } else if behavior == "none" { | |
| 392 | + | // Return empty results | |
| 393 | + | entries = []interface{}{} | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | if err != nil { | |
| 397 | + | log.Printf("[ERROR] Failed to fetch initial history: %v", err) | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // Build suggestions from entries with deduplication | |
| 402 | + | for _, entry := range entries { | |
| 403 | + | bb, _ := json.Marshal(entry) | |
| 404 | + | var m map[string]any | |
| 405 | + | _ = json.Unmarshal(bb, &m) | |
| 406 | + | ||
| 407 | + | var url string | |
| 408 | + | if s, ok := m["url"].(string); ok { | |
| 409 | + | url = s | |
| 410 | + | } else if s, ok := m["URL"].(string); ok { | |
| 411 | + | url = s | |
| 412 | + | } | |
| 413 | + | if url == "" { | |
| 414 | + | continue | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | // Skip duplicates | |
| 418 | + | if _, ok := seen[url]; ok { | |
| 419 | + | continue | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | favicon := "" | |
| 423 | + | if s, ok := m["favicon_url"].(string); ok && s != "" { | |
| 424 | + | favicon = s | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | results = append(results, suggestion{URL: url, Favicon: favicon}) | |
| 428 | + | seen[url] = struct{}{} | |
| 429 | + | if len(results) >= limit { | |
| 430 | + | break | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // Inject back to GUI | |
| 435 | + | if b, err := json.Marshal(results); err == nil { | |
| 436 | + | script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))" | |
| 437 | + | if err := h.webView.InjectScript(script); err != nil { | |
| 438 | + | log.Printf("[ERROR] Failed to inject initial history: %v", err) | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 343 | 443 | // handleWailsBridge processes Wails runtime bridge calls for homepage | |
| 344 | 444 | func (h *Handler) handleWailsBridge(msg Message) { | |
| 345 | 445 | // Payload contains { methodID, methodName?, args } | |
| @@ -91,6 +91,11 @@ func (h *Handler) Handle(payload string) { | |||||
| 91 | return | 91 | return | ||
| 92 | } | 92 | } | ||
| 93 | h.handleQuery(msg) | 93 | h.handleQuery(msg) | ||
| 94 | + | case "omnibox_initial_history": | |||
| 95 | + | if h.webView == nil { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | h.handleOmniboxInitialHistory(msg) | |||
| 94 | case "wails": | 99 | case "wails": | ||
| 95 | h.handleWailsBridge(msg) | 100 | h.handleWailsBridge(msg) | ||
| 96 | case "theme": | 101 | case "theme": | ||
| @@ -340,6 +345,101 @@ func (h *Handler) handleQuery(msg Message) { | |||||
| 340 | } | 345 | } | ||
| 341 | } | 346 | } | ||
| 342 | 347 | ||||
| 348 | + | // handleOmniboxInitialHistory processes initial history display for empty omnibox | |||
| 349 | + | func (h *Handler) handleOmniboxInitialHistory(msg Message) { | |||
| 350 | + | if h.webView == nil || h.browserService == nil { | |||
| 351 | + | return | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | ctx := context.Background() | |||
| 355 | + | limit := msg.Limit | |||
| 356 | + | if limit <= 0 { | |||
| 357 | + | limit = 10 | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // Get config to determine behavior | |||
| 361 | + | cfg, err := h.browserService.GetConfig(ctx) | |||
| 362 | + | if err != nil { | |||
| 363 | + | log.Printf("[ERROR] Failed to get config: %v", err) | |||
| 364 | + | return | |||
| 365 | + | } | |||
| 366 | + | behavior := cfg.Omnibox.InitialBehavior | |||
| 367 | + | ||||
| 368 | + | type suggestion struct { | |||
| 369 | + | URL string `json:"url"` | |||
| 370 | + | Favicon string `json:"favicon,omitempty"` | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | results := make([]suggestion, 0, limit) | |||
| 374 | + | seen := make(map[string]struct{}, limit*2) | |||
| 375 | + | ||||
| 376 | + | // Fetch history based on config | |||
| 377 | + | var entries []interface{} | |||
| 378 | + | ||||
| 379 | + | if behavior == "recent" { | |||
| 380 | + | historyEntries, histErr := h.browserService.GetRecentHistory(ctx, limit) | |||
| 381 | + | err = histErr | |||
| 382 | + | for _, e := range historyEntries { | |||
| 383 | + | entries = append(entries, e) | |||
| 384 | + | } | |||
| 385 | + | } else if behavior == "most_visited" { | |||
| 386 | + | historyEntries, histErr := h.browserService.GetMostVisited(ctx, limit) | |||
| 387 | + | err = histErr | |||
| 388 | + | for _, e := range historyEntries { | |||
| 389 | + | entries = append(entries, e) | |||
| 390 | + | } | |||
| 391 | + | } else if behavior == "none" { | |||
| 392 | + | // Return empty results | |||
| 393 | + | entries = []interface{}{} | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | if err != nil { | |||
| 397 | + | log.Printf("[ERROR] Failed to fetch initial history: %v", err) | |||
| 398 | + | return | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | // Build suggestions from entries with deduplication | |||
| 402 | + | for _, entry := range entries { | |||
| 403 | + | bb, _ := json.Marshal(entry) | |||
| 404 | + | var m map[string]any | |||
| 405 | + | _ = json.Unmarshal(bb, &m) | |||
| 406 | + | ||||
| 407 | + | var url string | |||
| 408 | + | if s, ok := m["url"].(string); ok { | |||
| 409 | + | url = s | |||
| 410 | + | } else if s, ok := m["URL"].(string); ok { | |||
| 411 | + | url = s | |||
| 412 | + | } | |||
| 413 | + | if url == "" { | |||
| 414 | + | continue | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | // Skip duplicates | |||
| 418 | + | if _, ok := seen[url]; ok { | |||
| 419 | + | continue | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | favicon := "" | |||
| 423 | + | if s, ok := m["favicon_url"].(string); ok && s != "" { | |||
| 424 | + | favicon = s | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | results = append(results, suggestion{URL: url, Favicon: favicon}) | |||
| 428 | + | seen[url] = struct{}{} | |||
| 429 | + | if len(results) >= limit { | |||
| 430 | + | break | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // Inject back to GUI | |||
| 435 | + | if b, err := json.Marshal(results); err == nil { | |||
| 436 | + | script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))" | |||
| 437 | + | if err := h.webView.InjectScript(script); err != nil { | |||
| 438 | + | log.Printf("[ERROR] Failed to inject initial history: %v", err) | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 343 | // handleWailsBridge processes Wails runtime bridge calls for homepage | 443 | // handleWailsBridge processes Wails runtime bridge calls for homepage | ||
| 344 | func (h *Handler) handleWailsBridge(msg Message) { | 444 | func (h *Handler) handleWailsBridge(msg Message) { | ||
| 345 | // Payload contains { methodID, methodName?, args } | 445 | // Payload contains { methodID, methodName?, args } | ||
M internal/config/config.go +12 -0
| @@ -44,6 +44,8 @@ type Config struct { | |||
| 44 | 44 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"` | |
| 45 | 45 | // ContentFiltering controls ad blocking and content filtering | |
| 46 | 46 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering"` | |
| 47 | + | // Omnibox controls the omnibox behavior (initial history display) | |
| 48 | + | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox"` | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -183,6 +185,13 @@ type ContentFilteringConfig struct { | |||
| 183 | 185 | Whitelist []string `mapstructure:"whitelist" yaml:"whitelist"` | |
| 184 | 186 | } | |
| 185 | 187 | ||
| 188 | + | // OmniboxConfig holds omnibox behavior preferences | |
| 189 | + | type OmniboxConfig struct { | |
| 190 | + | // InitialBehavior controls what to show when omnibox opens with empty input | |
| 191 | + | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | |
| 192 | + | InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior"` | |
| 193 | + | } | |
| 194 | + | ||
| 186 | 195 | type DebugConfig struct { | |
| 187 | 196 | // Enable WebKit internal debug logging | |
| 188 | 197 | EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"` | |
| @@ -756,6 +765,9 @@ func (m *Manager) setDefaults() { | |||
| 756 | 765 | // Content filtering | |
| 757 | 766 | m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled) | |
| 758 | 767 | m.viper.SetDefault("content_filtering.whitelist", defaults.ContentFiltering.Whitelist) | |
| 768 | + | ||
| 769 | + | // Omnibox defaults | |
| 770 | + | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | |
| 759 | 771 | } | |
| 760 | 772 | ||
| 761 | 773 | // createDefaultConfig creates a default configuration file. | |
| @@ -44,6 +44,8 @@ type Config struct { | |||||
| 44 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"` | 44 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"` | ||
| 45 | // ContentFiltering controls ad blocking and content filtering | 45 | // ContentFiltering controls ad blocking and content filtering | ||
| 46 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering"` | 46 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering"` | ||
| 47 | + | // Omnibox controls the omnibox behavior (initial history display) | |||
| 48 | + | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox"` | |||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 49 | // RenderingMode selects GPU vs CPU rendering. | 51 | // RenderingMode selects GPU vs CPU rendering. | ||
| @@ -183,6 +185,13 @@ type ContentFilteringConfig struct { | |||||
| 183 | Whitelist []string `mapstructure:"whitelist" yaml:"whitelist"` | 185 | Whitelist []string `mapstructure:"whitelist" yaml:"whitelist"` | ||
| 184 | } | 186 | } | ||
| 185 | 187 | ||||
| 188 | + | // OmniboxConfig holds omnibox behavior preferences | |||
| 189 | + | type OmniboxConfig struct { | |||
| 190 | + | // InitialBehavior controls what to show when omnibox opens with empty input | |||
| 191 | + | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | |||
| 192 | + | InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior"` | |||
| 193 | + | } | |||
| 194 | + | ||||
| 186 | type DebugConfig struct { | 195 | type DebugConfig struct { | ||
| 187 | // Enable WebKit internal debug logging | 196 | // Enable WebKit internal debug logging | ||
| 188 | EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"` | 197 | EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"` | ||
| @@ -756,6 +765,9 @@ func (m *Manager) setDefaults() { | |||||
| 756 | // Content filtering | 765 | // Content filtering | ||
| 757 | m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled) | 766 | m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled) | ||
| 758 | m.viper.SetDefault("content_filtering.whitelist", defaults.ContentFiltering.Whitelist) | 767 | m.viper.SetDefault("content_filtering.whitelist", defaults.ContentFiltering.Whitelist) | ||
| 768 | + | ||||
| 769 | + | // Omnibox defaults | |||
| 770 | + | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | |||
| 759 | } | 771 | } | ||
| 760 | 772 | ||||
| 761 | // createDefaultConfig creates a default configuration file. | 773 | // createDefaultConfig creates a default configuration file. | ||
M internal/config/defaults.go +6 -0
| @@ -18,6 +18,9 @@ const ( | |||
| 18 | 18 | // Appearance defaults | |
| 19 | 19 | defaultFontSize = 16 // points | |
| 20 | 20 | ||
| 21 | + | // Omnibox defaults | |
| 22 | + | defaultOmniboxInitialBehavior = "recent" | |
| 23 | + | ||
| 21 | 24 | // Workspace defaults | |
| 22 | 25 | defaultPaneActivationShortcut = "ctrl+p" | |
| 23 | 26 | defaultPaneTimeoutMilliseconds = 3000 | |
| @@ -214,6 +217,9 @@ func DefaultConfig() *Config { | |||
| 214 | 217 | "gql.twitch.tv", // GraphQL API | |
| 215 | 218 | }, | |
| 216 | 219 | }, | |
| 220 | + | Omnibox: OmniboxConfig{ | |
| 221 | + | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 222 | + | }, | |
| 217 | 223 | } | |
| 218 | 224 | } | |
| 219 | 225 | ||
| @@ -18,6 +18,9 @@ const ( | |||||
| 18 | // Appearance defaults | 18 | // Appearance defaults | ||
| 19 | defaultFontSize = 16 // points | 19 | defaultFontSize = 16 // points | ||
| 20 | 20 | ||||
| 21 | + | // Omnibox defaults | |||
| 22 | + | defaultOmniboxInitialBehavior = "recent" | |||
| 23 | + | ||||
| 21 | // Workspace defaults | 24 | // Workspace defaults | ||
| 22 | defaultPaneActivationShortcut = "ctrl+p" | 25 | defaultPaneActivationShortcut = "ctrl+p" | ||
| 23 | defaultPaneTimeoutMilliseconds = 3000 | 26 | defaultPaneTimeoutMilliseconds = 3000 | ||
| @@ -214,6 +217,9 @@ func DefaultConfig() *Config { | |||||
| 214 | "gql.twitch.tv", // GraphQL API | 217 | "gql.twitch.tv", // GraphQL API | ||
| 215 | }, | 218 | }, | ||
| 216 | }, | 219 | }, | ||
| 220 | + | Omnibox: OmniboxConfig{ | |||
| 221 | + | InitialBehavior: defaultOmniboxInitialBehavior, | |||
| 222 | + | }, | |||
| 217 | } | 223 | } | ||
| 218 | } | 224 | } | ||
| 219 | 225 | ||||
M internal/config/validation.go +7 -0
| @@ -168,6 +168,13 @@ func validateConfig(config *Config) error { | |||
| 168 | 168 | validationErrors = append(validationErrors, "logging.max_age must be non-negative") | |
| 169 | 169 | } | |
| 170 | 170 | ||
| 171 | + | // Validate omnibox initial behavior | |
| 172 | + | if config.Omnibox.InitialBehavior != "recent" && | |
| 173 | + | config.Omnibox.InitialBehavior != "most_visited" && | |
| 174 | + | config.Omnibox.InitialBehavior != "none" { | |
| 175 | + | validationErrors = append(validationErrors, fmt.Sprintf("omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", config.Omnibox.InitialBehavior)) | |
| 176 | + | } | |
| 177 | + | ||
| 171 | 178 | // If there are validation errors, return them | |
| 172 | 179 | if len(validationErrors) > 0 { | |
| 173 | 180 | return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) | |
| @@ -168,6 +168,13 @@ func validateConfig(config *Config) error { | |||||
| 168 | validationErrors = append(validationErrors, "logging.max_age must be non-negative") | 168 | validationErrors = append(validationErrors, "logging.max_age must be non-negative") | ||
| 169 | } | 169 | } | ||
| 170 | 170 | ||||
| 171 | + | // Validate omnibox initial behavior | |||
| 172 | + | if config.Omnibox.InitialBehavior != "recent" && | |||
| 173 | + | config.Omnibox.InitialBehavior != "most_visited" && | |||
| 174 | + | config.Omnibox.InitialBehavior != "none" { | |||
| 175 | + | validationErrors = append(validationErrors, fmt.Sprintf("omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", config.Omnibox.InitialBehavior)) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 171 | // If there are validation errors, return them | 178 | // If there are validation errors, return them | ||
| 172 | if len(validationErrors) > 0 { | 179 | if len(validationErrors) > 0 { | ||
| 173 | return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) | 180 | return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) | ||
M internal/db/history.sql.go +38 -0
| @@ -133,6 +133,44 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset | |||
| 133 | 133 | return items, nil | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | + | const GetMostVisited = `-- name: GetMostVisited :many | |
| 137 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |
| 138 | + | FROM history | |
| 139 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 140 | + | LIMIT ? | |
| 141 | + | ` | |
| 142 | + | ||
| 143 | + | func (q *Queries) GetMostVisited(ctx context.Context, limit int64) ([]History, error) { | |
| 144 | + | rows, err := q.db.QueryContext(ctx, GetMostVisited, limit) | |
| 145 | + | if err != nil { | |
| 146 | + | return nil, err | |
| 147 | + | } | |
| 148 | + | defer rows.Close() | |
| 149 | + | items := []History{} | |
| 150 | + | for rows.Next() { | |
| 151 | + | var i History | |
| 152 | + | if err := rows.Scan( | |
| 153 | + | &i.ID, | |
| 154 | + | &i.Url, | |
| 155 | + | &i.Title, | |
| 156 | + | &i.VisitCount, | |
| 157 | + | &i.LastVisited, | |
| 158 | + | &i.CreatedAt, | |
| 159 | + | &i.FaviconUrl, | |
| 160 | + | ); err != nil { | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | items = append(items, i) | |
| 164 | + | } | |
| 165 | + | if err := rows.Close(); err != nil { | |
| 166 | + | return nil, err | |
| 167 | + | } | |
| 168 | + | if err := rows.Err(); err != nil { | |
| 169 | + | return nil, err | |
| 170 | + | } | |
| 171 | + | return items, nil | |
| 172 | + | } | |
| 173 | + | ||
| 136 | 174 | const SearchHistory = `-- name: SearchHistory :many | |
| 137 | 175 | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |
| 138 | 176 | FROM history | |
| @@ -133,6 +133,44 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset | |||||
| 133 | return items, nil | 133 | return items, nil | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | + | const GetMostVisited = `-- name: GetMostVisited :many | |||
| 137 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |||
| 138 | + | FROM history | |||
| 139 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 140 | + | LIMIT ? | |||
| 141 | + | ` | |||
| 142 | + | ||||
| 143 | + | func (q *Queries) GetMostVisited(ctx context.Context, limit int64) ([]History, error) { | |||
| 144 | + | rows, err := q.db.QueryContext(ctx, GetMostVisited, limit) | |||
| 145 | + | if err != nil { | |||
| 146 | + | return nil, err | |||
| 147 | + | } | |||
| 148 | + | defer rows.Close() | |||
| 149 | + | items := []History{} | |||
| 150 | + | for rows.Next() { | |||
| 151 | + | var i History | |||
| 152 | + | if err := rows.Scan( | |||
| 153 | + | &i.ID, | |||
| 154 | + | &i.Url, | |||
| 155 | + | &i.Title, | |||
| 156 | + | &i.VisitCount, | |||
| 157 | + | &i.LastVisited, | |||
| 158 | + | &i.CreatedAt, | |||
| 159 | + | &i.FaviconUrl, | |||
| 160 | + | ); err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | items = append(items, i) | |||
| 164 | + | } | |||
| 165 | + | if err := rows.Close(); err != nil { | |||
| 166 | + | return nil, err | |||
| 167 | + | } | |||
| 168 | + | if err := rows.Err(); err != nil { | |||
| 169 | + | return nil, err | |||
| 170 | + | } | |||
| 171 | + | return items, nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 136 | const SearchHistory = `-- name: SearchHistory :many | 174 | const SearchHistory = `-- name: SearchHistory :many | ||
| 137 | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | 175 | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | ||
| 138 | FROM history | 176 | FROM history | ||
M internal/db/interfaces.go +1 -0
| @@ -20,6 +20,7 @@ type HistoryQuerier interface { | |||
| 20 | 20 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 21 | 21 | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 22 | 22 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| 23 | + | GetMostVisited(ctx context.Context, limit int64) ([]History, error) | |
| 23 | 24 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |
| 24 | 25 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |
| 25 | 26 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |
| @@ -20,6 +20,7 @@ type HistoryQuerier interface { | |||||
| 20 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 20 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 21 | GetHistoryEntry(ctx context.Context, url string) (History, error) | 21 | GetHistoryEntry(ctx context.Context, url string) (History, error) | ||
| 22 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | 22 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | ||
| 23 | + | GetMostVisited(ctx context.Context, limit int64) ([]History, error) | |||
| 23 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | 24 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | ||
| 24 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | 25 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | ||
| 25 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | 26 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | ||
M internal/db/querier.go +1 -0
| @@ -33,6 +33,7 @@ type Querier interface { | |||
| 33 | 33 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 34 | 34 | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 35 | 35 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| 36 | + | GetMostVisited(ctx context.Context, limit int64) ([]History, error) | |
| 36 | 37 | // Get zoom level for a specific domain | |
| 37 | 38 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | |
| 38 | 39 | // Get zoom level for domain with default fallback | |
| @@ -33,6 +33,7 @@ type Querier interface { | |||||
| 33 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 33 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 34 | GetHistoryEntry(ctx context.Context, url string) (History, error) | 34 | GetHistoryEntry(ctx context.Context, url string) (History, error) | ||
| 35 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | 35 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | ||
| 36 | + | GetMostVisited(ctx context.Context, limit int64) ([]History, error) | |||
| 36 | // Get zoom level for a specific domain | 37 | // Get zoom level for a specific domain | ||
| 37 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | 38 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | ||
| 38 | // Get zoom level for domain with default fallback | 39 | // Get zoom level for domain with default fallback | ||
M internal/db/queries/history.sql +7 -1
| @@ -39,4 +39,10 @@ LIMIT 1; | |||
| 39 | 39 | ||
| 40 | 40 | -- name: DeleteHistory :exec | |
| 41 | 41 | DELETE FROM history | |
| 42 | - | WHERE id = ?;⏎̸ | |
| 42 | + | WHERE id = ?; | |
| 43 | + | ||
| 44 | + | -- name: GetMostVisited :many | |
| 45 | + | SELECT * | |
| 46 | + | FROM history | |
| 47 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 48 | + | LIMIT ?;⏎̸ | |
| @@ -39,4 +39,10 @@ LIMIT 1; | |||||
| 39 | 39 | ||||
| 40 | -- name: DeleteHistory :exec | 40 | -- name: DeleteHistory :exec | ||
| 41 | DELETE FROM history | 41 | DELETE FROM history | ||
| 42 | - | WHERE id = ?;⏎̸ | 42 | + | WHERE id = ?; |
| 43 | + | ||||
| 44 | + | -- name: GetMostVisited :many | |||
| 45 | + | SELECT * | |||
| 46 | + | FROM history | |||
| 47 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 48 | + | LIMIT ?;⏎̸ | |||
A internal/migrations/009_add_visit_count_index.sql +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | -- Add composite index for GetMostVisited query performance | |
| 2 | + | -- Orders by visit_count DESC, last_visited DESC | |
| 3 | + | ||
| 4 | + | CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited | |
| 5 | + | ON history(visit_count DESC, last_visited DESC); | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | -- Add composite index for GetMostVisited query performance | |||
| 2 | + | -- Orders by visit_count DESC, last_visited DESC | |||
| 3 | + | ||||
| 4 | + | CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited | |||
| 5 | + | ON history(visit_count DESC, last_visited DESC); | |||
M internal/services/browser.go +29 -0
| @@ -297,6 +297,35 @@ func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, | |||
| 297 | 297 | return result, nil | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | + | // GetMostVisited returns the most visited browser history entries. | |
| 301 | + | func (s *BrowserService) GetMostVisited(ctx context.Context, limit int) ([]HistoryEntry, error) { | |
| 302 | + | if limit <= 0 { | |
| 303 | + | limit = 10 // Default limit | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | entries, err := s.dbQueries.GetMostVisited(ctx, int64(limit)) | |
| 307 | + | if err != nil { | |
| 308 | + | return nil, err | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | result := make([]HistoryEntry, len(entries)) | |
| 312 | + | for i, entry := range entries { | |
| 313 | + | // Defensive cast to int32 to prevent overflow | |
| 314 | + | vc := clampToInt32(entry.VisitCount.Int64) | |
| 315 | + | result[i] = HistoryEntry{ | |
| 316 | + | ID: entry.ID, | |
| 317 | + | URL: entry.Url, | |
| 318 | + | Title: entry.Title.String, | |
| 319 | + | FaviconURL: entry.FaviconUrl.String, | |
| 320 | + | VisitCount: vc, | |
| 321 | + | LastVisited: entry.LastVisited.Time, | |
| 322 | + | CreatedAt: entry.CreatedAt.Time, | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | return result, nil | |
| 327 | + | } | |
| 328 | + | ||
| 300 | 329 | // SearchHistory searches browser history. | |
| 301 | 330 | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | |
| 302 | 331 | if query == "" { | |
| @@ -297,6 +297,35 @@ func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, | |||||
| 297 | return result, nil | 297 | return result, nil | ||
| 298 | } | 298 | } | ||
| 299 | 299 | ||||
| 300 | + | // GetMostVisited returns the most visited browser history entries. | |||
| 301 | + | func (s *BrowserService) GetMostVisited(ctx context.Context, limit int) ([]HistoryEntry, error) { | |||
| 302 | + | if limit <= 0 { | |||
| 303 | + | limit = 10 // Default limit | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | entries, err := s.dbQueries.GetMostVisited(ctx, int64(limit)) | |||
| 307 | + | if err != nil { | |||
| 308 | + | return nil, err | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | result := make([]HistoryEntry, len(entries)) | |||
| 312 | + | for i, entry := range entries { | |||
| 313 | + | // Defensive cast to int32 to prevent overflow | |||
| 314 | + | vc := clampToInt32(entry.VisitCount.Int64) | |||
| 315 | + | result[i] = HistoryEntry{ | |||
| 316 | + | ID: entry.ID, | |||
| 317 | + | URL: entry.Url, | |||
| 318 | + | Title: entry.Title.String, | |||
| 319 | + | FaviconURL: entry.FaviconUrl.String, | |||
| 320 | + | VisitCount: vc, | |||
| 321 | + | LastVisited: entry.LastVisited.Time, | |||
| 322 | + | CreatedAt: entry.CreatedAt.Time, | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | return result, nil | |||
| 327 | + | } | |||
| 328 | + | ||||
| 300 | // SearchHistory searches browser history. | 329 | // SearchHistory searches browser history. | ||
| 301 | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | 330 | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | ||
| 302 | if query == "" { | 331 | if query == "" { | ||