detached f83a7ce

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.
44
55 ## [Unreleased]
66
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+
710 ## [0.14.1] - 2025-11-18
811
912 ### 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-1810 ## [0.14.1] - 2025-11-18
8 11
9 ### Added12 ### Added
M gui/src/components/omnibox/Omnibox.svelte +8 -1
@@ -122,6 +122,11 @@
122122 inputElement.focus();
123123 // Enable keyboard event blocking in main world when omnibox opens
124124 enablePageEventBlocking();
125+
126+ // Fetch initial history if opening in omnibox mode with empty input
127+ if (mode === 'omnibox' && omniboxStore.inputValue === '') {
128+ omniboxBridge.fetchInitialHistory();
129+ }
125130 } else if (!visible) {
126131 // Disable keyboard event blocking when omnibox closes
127132 disablePageEventBlocking();
@@ -367,8 +372,10 @@
367372
368373 // Auto-open omnibox on about:blank pages
369374 if (window.location.href === 'about:blank') {
370- console.log('🚀 Auto-opening omnibox for about:blank');
375+ console.log('Auto-opening omnibox for about:blank');
371376 omniboxStore.open('omnibox');
377+ // Fetch initial history for empty omnibox
378+ omniboxBridge.fetchInitialHistory();
372379 }
373380 } catch (error) {
374381 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 opens123 // 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 closes131 // Disable keyboard event blocking when omnibox closes
127 disablePageEventBlocking();132 disablePageEventBlocking();
@@ -367,8 +372,10 @@
367 372
368 // Auto-open omnibox on about:blank pages373 // 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 @@
122122 // Only query backend if we're in history view
123123 // In favorites view, filtering happens locally via computed state
124124 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+ }
126132 }
127133 } else if (mode === 'find') {
128134 // Immediate find for search
@@ -122,7 +122,13 @@
122 // Only query backend if we're in history view122 // Only query backend if we're in history view
123 // In favorites view, filtering happens locally via computed state123 // 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 search134 // Immediate find for search
M gui/src/components/omnibox/messaging.ts +10 -0
@@ -66,6 +66,16 @@ export class OmniboxBridge implements OmniboxMessageBridge {
6666 this.postMessage({ type: "query", q, limit: lim });
6767 }
6868
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+
6979 /**
7080 * Fetch search shortcuts from backend via messaging bridge
7181 */
@@ -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 bridge80 * 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 {
2323 }
2424
2525 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";
2727 url?: string;
2828 q?: string;
2929 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) {
9191 return
9292 }
9393 h.handleQuery(msg)
94+ case "omnibox_initial_history":
95+ if h.webView == nil {
96+ return
97+ }
98+ h.handleOmniboxInitialHistory(msg)
9499 case "wails":
95100 h.handleWailsBridge(msg)
96101 case "theme":
@@ -340,6 +345,101 @@ func (h *Handler) handleQuery(msg Message) {
340345 }
341346 }
342347
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+
343443 // handleWailsBridge processes Wails runtime bridge calls for homepage
344444 func (h *Handler) handleWailsBridge(msg Message) {
345445 // Payload contains { methodID, methodName?, args }
@@ -91,6 +91,11 @@ func (h *Handler) Handle(payload string) {
91 return91 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 homepage443 // 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 {
4444 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
4545 // ContentFiltering controls ad blocking and content filtering
4646 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering"`
47+ // Omnibox controls the omnibox behavior (initial history display)
48+ Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox"`
4749 }
4850
4951 // RenderingMode selects GPU vs CPU rendering.
@@ -183,6 +185,13 @@ type ContentFilteringConfig struct {
183185 Whitelist []string `mapstructure:"whitelist" yaml:"whitelist"`
184186 }
185187
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+
186195 type DebugConfig struct {
187196 // Enable WebKit internal debug logging
188197 EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"`
@@ -756,6 +765,9 @@ func (m *Manager) setDefaults() {
756765 // Content filtering
757766 m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
758767 m.viper.SetDefault("content_filtering.whitelist", defaults.ContentFiltering.Whitelist)
768+
769+ // Omnibox defaults
770+ m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
759771 }
760772
761773 // 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 filtering45 // 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 logging196 // 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 filtering765 // 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 (
1818 // Appearance defaults
1919 defaultFontSize = 16 // points
2020
21+ // Omnibox defaults
22+ defaultOmniboxInitialBehavior = "recent"
23+
2124 // Workspace defaults
2225 defaultPaneActivationShortcut = "ctrl+p"
2326 defaultPaneTimeoutMilliseconds = 3000
@@ -214,6 +217,9 @@ func DefaultConfig() *Config {
214217 "gql.twitch.tv", // GraphQL API
215218 },
216219 },
220+ Omnibox: OmniboxConfig{
221+ InitialBehavior: defaultOmniboxInitialBehavior,
222+ },
217223 }
218224 }
219225
@@ -18,6 +18,9 @@ const (
18 // Appearance defaults18 // Appearance defaults
19 defaultFontSize = 16 // points19 defaultFontSize = 16 // points
20 20
21+ // Omnibox defaults
22+ defaultOmniboxInitialBehavior = "recent"
23+
21 // Workspace defaults24 // Workspace defaults
22 defaultPaneActivationShortcut = "ctrl+p"25 defaultPaneActivationShortcut = "ctrl+p"
23 defaultPaneTimeoutMilliseconds = 300026 defaultPaneTimeoutMilliseconds = 3000
@@ -214,6 +217,9 @@ func DefaultConfig() *Config {
214 "gql.twitch.tv", // GraphQL API217 "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 {
168168 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
169169 }
170170
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+
171178 // If there are validation errors, return them
172179 if len(validationErrors) > 0 {
173180 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 them178 // 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
133133 return items, nil
134134 }
135135
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+
136174 const SearchHistory = `-- name: SearchHistory :many
137175 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
138176 FROM history
@@ -133,6 +133,44 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset
133 return items, nil133 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 :many174 const SearchHistory = `-- name: SearchHistory :many
137 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url175 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 {
2020 GetHistory(ctx context.Context, limit int64) ([]History, error)
2121 GetHistoryEntry(ctx context.Context, url string) (History, error)
2222 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
23+ GetMostVisited(ctx context.Context, limit int64) ([]History, error)
2324 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
2425 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
2526 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) error25 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
25 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error26 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
M internal/db/querier.go +1 -0
@@ -33,6 +33,7 @@ type Querier interface {
3333 GetHistory(ctx context.Context, limit int64) ([]History, error)
3434 GetHistoryEntry(ctx context.Context, url string) (History, error)
3535 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
36+ GetMostVisited(ctx context.Context, limit int64) ([]History, error)
3637 // Get zoom level for a specific domain
3738 GetZoomLevel(ctx context.Context, domain string) (float64, error)
3839 // 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 domain37 // 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 fallback39 // Get zoom level for domain with default fallback
M internal/db/queries/history.sql +7 -1
@@ -39,4 +39,10 @@ LIMIT 1;
3939
4040 -- name: DeleteHistory :exec
4141 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 :exec40 -- name: DeleteHistory :exec
41 DELETE FROM history41 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,
297297 return result, nil
298298 }
299299
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+
300329 // SearchHistory searches browser history.
301330 func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
302331 if query == "" {
@@ -297,6 +297,35 @@ func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit,
297 return result, nil297 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 == "" {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close