detached e452e75

feat(omnibox): add favorites system with persistent storage and keyboard controls

Add comprehensive favorites feature to omnibox with database persistence and intuitive keyboard navigation:

Backend:
- Add favorites table with SQLite migration and SQLC queries
- Implement FavoritesCache with generic cache framework
- Add browser service methods: GetFavorites(), IsFavorite(), ToggleFavorite()
- Add message handlers for favorites operations
- Load favorites cache in parallel at startup

Frontend:
- Add Tab key to switch between History and Favorites views
- Add Space key to toggle favorite status (add/remove)
- Add yellow left border indicator for favorited items
- Add view mode tabs with counts (History/Favorites)
- Add empty state with helpful hints for empty favorites
- Persist view mode preference in localStorage
- Add local filtering in Favorites view

Architecture:
- Refactor to unified bridge system for main-world to isolated-world communication
- Document world isolation patterns (window functions vs CustomEvents)
- Add toast notification bridges
- Fix bootstrap initialization to allow omnibox mounting

UX improvements:
- Hide empty state in History view until user types
- Increase vertical spacing between suggestions (0.85rem)
- Smooth view switching with Tab key
- Immediate visual feedback on Space key press
commit
e452e75543ec4862dad2c793582fb12ec6313366
author
bnema · · 2025-11-16 20:10 UTC
committer
bnema · · 2025-11-16 17:46 UTC
parent
411f859
signature
unsigned
19 files · +1039 -80
M gui/src/components/omnibox/Omnibox.svelte +12 -1
@@ -266,7 +266,7 @@
266266
267267
268268
269- onMount(() => {
269+ onMount(async () => {
270270 syncTheme();
271271 observeThemeChanges();
272272
@@ -281,6 +281,17 @@
281281 // Add global click listener
282282 document.addEventListener('click', handleGlobalClick, true);
283283
284+ // Load search shortcuts and favorites on mount
285+ try {
286+ await Promise.all([
287+ omniboxBridge.fetchSearchShortcuts(),
288+ omniboxBridge.fetchFavorites()
289+ ]);
290+ console.log('✅ Search shortcuts and favorites loaded on mount');
291+ } catch (error) {
292+ console.warn('Failed to load initial data:', error);
293+ }
294+
284295 try {
285296 // Set up global API for Go bridge
286297 window.__dumber_omnibox = {
@@ -266,7 +266,7 @@
266 266
267 267
268 268
269- onMount(() => {269+ onMount(async () => {
270 syncTheme();270 syncTheme();
271 observeThemeChanges();271 observeThemeChanges();
272 272
@@ -281,6 +281,17 @@
281 // Add global click listener281 // Add global click listener
282 document.addEventListener('click', handleGlobalClick, true);282 document.addEventListener('click', handleGlobalClick, true);
283 283
284+ // Load search shortcuts and favorites on mount
285+ try {
286+ await Promise.all([
287+ omniboxBridge.fetchSearchShortcuts(),
288+ omniboxBridge.fetchFavorites()
289+ ]);
290+ console.log('✅ Search shortcuts and favorites loaded on mount');
291+ } catch (error) {
292+ console.warn('Failed to load initial data:', error);
293+ }
294+
284 try {295 try {
285 // Set up global API for Go bridge296 // Set up global API for Go bridge
286 window.__dumber_omnibox = {297 window.__dumber_omnibox = {
M gui/src/components/omnibox/OmniboxInput.svelte +51 -2
@@ -10,9 +10,11 @@
1010
1111 // Reactive state
1212 let mode = $derived(omniboxStore.mode);
13+ let viewMode = $derived(omniboxStore.viewMode);
1314 let inputValue = $derived(omniboxStore.inputValue);
1415 let selectedIndex = $derived(omniboxStore.selectedIndex);
1516 let suggestions = $derived(omniboxStore.suggestions);
17+ let favorites = $derived(omniboxStore.favorites);
1618 let matches = $derived(omniboxStore.matches);
1719 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
1820
@@ -117,8 +119,11 @@
117119 omniboxStore.setFaded(false);
118120
119121 if (mode === 'omnibox') {
120- // Debounced search for omnibox
121- debouncedQuery(value);
122+ // Only query backend if we're in history view
123+ // In favorites view, filtering happens locally via computed state
124+ if (viewMode === 'history') {
125+ debouncedQuery(value);
126+ }
122127 } else if (mode === 'find') {
123128 // Immediate find for search
124129 findInPage(value);
@@ -140,6 +145,26 @@
140145 handleEnterKey(event);
141146 break;
142147
148+ case 'Tab':
149+ // Only handle Tab in omnibox mode (not in find mode)
150+ if (mode === 'omnibox') {
151+ event.preventDefault();
152+ event.stopPropagation();
153+ // Toggle between history and favorites views
154+ const newViewMode = viewMode === 'history' ? 'favorites' : 'history';
155+ omniboxStore.setViewMode(newViewMode);
156+ }
157+ break;
158+
159+ case ' ':
160+ // Only handle Space in omnibox mode when an item is selected
161+ if (mode === 'omnibox' && selectedIndex >= 0) {
162+ event.preventDefault();
163+ event.stopPropagation();
164+ handleSpaceKey();
165+ }
166+ break;
167+
143168 case 'ArrowDown':
144169 case 'ArrowUp':
145170 event.preventDefault();
@@ -182,6 +207,30 @@
182207 }
183208 }
184209
210+ // Handle Space key to toggle favorite
211+ function handleSpaceKey() {
212+ if (mode !== 'omnibox' || selectedIndex < 0) return;
213+
214+ // Get the selected item based on current view mode
215+ const item = viewMode === 'history'
216+ ? suggestions[selectedIndex]
217+ : favorites[selectedIndex];
218+
219+ if (!item || !item.url) return;
220+
221+ // Extract title and favicon
222+ const title = 'title' in item ? item.title : '';
223+ const faviconURL = item.favicon_url || item.favicon || '';
224+
225+ // Toggle the favorite status
226+ omniboxBridge.toggleFavorite(item.url, title, faviconURL);
227+
228+ // Refresh the favorites list after toggling
229+ setTimeout(() => {
230+ omniboxBridge.fetchFavorites();
231+ }, 100);
232+ }
233+
185234 // Handle arrow key navigation
186235 function handleArrowKeys(key: string) {
187236 const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;
@@ -10,9 +10,11 @@
10 10
11 // Reactive state11 // Reactive state
12 let mode = $derived(omniboxStore.mode);12 let mode = $derived(omniboxStore.mode);
13+ let viewMode = $derived(omniboxStore.viewMode);
13 let inputValue = $derived(omniboxStore.inputValue);14 let inputValue = $derived(omniboxStore.inputValue);
14 let selectedIndex = $derived(omniboxStore.selectedIndex);15 let selectedIndex = $derived(omniboxStore.selectedIndex);
15 let suggestions = $derived(omniboxStore.suggestions);16 let suggestions = $derived(omniboxStore.suggestions);
17+ let favorites = $derived(omniboxStore.favorites);
16 let matches = $derived(omniboxStore.matches);18 let matches = $derived(omniboxStore.matches);
17 let searchShortcuts = $derived(omniboxStore.searchShortcuts);19 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
18 20
@@ -117,8 +119,11 @@
117 omniboxStore.setFaded(false);119 omniboxStore.setFaded(false);
118 120
119 if (mode === 'omnibox') {121 if (mode === 'omnibox') {
120- // Debounced search for omnibox122+ // Only query backend if we're in history view
121- debouncedQuery(value);123+ // In favorites view, filtering happens locally via computed state
124+ if (viewMode === 'history') {
125+ debouncedQuery(value);
126+ }
122 } else if (mode === 'find') {127 } else if (mode === 'find') {
123 // Immediate find for search128 // Immediate find for search
124 findInPage(value);129 findInPage(value);
@@ -140,6 +145,26 @@
140 handleEnterKey(event);145 handleEnterKey(event);
141 break;146 break;
142 147
148+ case 'Tab':
149+ // Only handle Tab in omnibox mode (not in find mode)
150+ if (mode === 'omnibox') {
151+ event.preventDefault();
152+ event.stopPropagation();
153+ // Toggle between history and favorites views
154+ const newViewMode = viewMode === 'history' ? 'favorites' : 'history';
155+ omniboxStore.setViewMode(newViewMode);
156+ }
157+ break;
158+
159+ case ' ':
160+ // Only handle Space in omnibox mode when an item is selected
161+ if (mode === 'omnibox' && selectedIndex >= 0) {
162+ event.preventDefault();
163+ event.stopPropagation();
164+ handleSpaceKey();
165+ }
166+ break;
167+
143 case 'ArrowDown':168 case 'ArrowDown':
144 case 'ArrowUp':169 case 'ArrowUp':
145 event.preventDefault();170 event.preventDefault();
@@ -182,6 +207,30 @@
182 }207 }
183 }208 }
184 209
210+ // Handle Space key to toggle favorite
211+ function handleSpaceKey() {
212+ if (mode !== 'omnibox' || selectedIndex < 0) return;
213+
214+ // Get the selected item based on current view mode
215+ const item = viewMode === 'history'
216+ ? suggestions[selectedIndex]
217+ : favorites[selectedIndex];
218+
219+ if (!item || !item.url) return;
220+
221+ // Extract title and favicon
222+ const title = 'title' in item ? item.title : '';
223+ const faviconURL = item.favicon_url || item.favicon || '';
224+
225+ // Toggle the favorite status
226+ omniboxBridge.toggleFavorite(item.url, title, faviconURL);
227+
228+ // Refresh the favorites list after toggling
229+ setTimeout(() => {
230+ omniboxBridge.fetchFavorites();
231+ }, 100);
232+ }
233+
185 // Handle arrow key navigation234 // Handle arrow key navigation
186 function handleArrowKeys(key: string) {235 function handleArrowKeys(key: string) {
187 const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;236 const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;
M gui/src/components/omnibox/OmniboxSuggestions.svelte +153 -19
@@ -9,14 +9,43 @@
99
1010 // Reactive state
1111 let suggestions = $derived(omniboxStore.suggestions);
12+ let favorites = $derived(omniboxStore.favorites);
13+ let viewMode = $derived(omniboxStore.viewMode);
1214 let selectedIndex = $derived(omniboxStore.selectedIndex);
15+ let inputValue = $derived(omniboxStore.inputValue);
1316 let hasContent = $state(false);
1417
15- // Update hasContent when suggestions change
18+ // Debug effect to log favorites changes
1619 $effect(() => {
17- hasContent = omniboxStore.mode === 'omnibox' && suggestions.length > 0;
20+ console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length);
1821 });
1922
23+ // Computed: Get current list based on view mode with local filtering for favorites
24+ let currentList = $derived.by(() => {
25+ const list = viewMode === 'history' ? suggestions : favorites;
26+
27+ // In favorites view with input, filter locally
28+ if (viewMode === 'favorites' && inputValue && inputValue.trim()) {
29+ const query = inputValue.toLowerCase().trim();
30+ return list.filter(fav =>
31+ fav.url.toLowerCase().includes(query) ||
32+ (fav.title && fav.title.toLowerCase().includes(query))
33+ );
34+ }
35+
36+ return list;
37+ });
38+
39+ // Update hasContent - always show in omnibox mode to display header
40+ $effect(() => {
41+ hasContent = omniboxStore.mode === 'omnibox';
42+ });
43+
44+ // Check if a URL is in favorites
45+ function isFavorited(url: string): boolean {
46+ return favorites.some(fav => fav.url === url);
47+ }
48+
2049 // Handle suggestion item mouse enter
2150 function handleItemMouseEnter(index: number) {
2251 omniboxStore.setSelectedIndex(index);
@@ -90,25 +119,57 @@
90119 role="listbox"
91120 aria-label="Search suggestions"
92121 >
93- {#each suggestions as suggestion, index (suggestion.url)}
94- {@const { domain, path } = parseUrl(suggestion.url)}
95- {@const isSelected = index === selectedIndex}
96-
97- <div
98- id="omnibox-item-{index}"
99- class={isSelected ? 'suggestion-item selected' : 'suggestion-item'}
100- role="option"
101- tabindex={isSelected ? 0 : -1}
102- aria-selected={isSelected}
103- onmouseenter={() => handleItemMouseEnter(index)}
104- onclick={() => handleItemClick(suggestion)}
105- onkeydown={(e) => handleItemKeyDown(e, suggestion)}
106- >
122+ <!-- View mode indicator -->
123+ <div class="view-mode-header">
124+ <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}>
125+ History ({suggestions.length})
126+ </span>
127+ <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}>
128+ Favorites ({favorites.length})
129+ </span>
130+ <span class="view-hint">Tab to switch</span>
131+ </div>
132+
133+ {#if currentList.length === 0}
134+ <!-- Empty state -->
135+ {#if viewMode === 'favorites'}
136+ <!-- Always show empty state for favorites -->
137+ <div class="empty-state">
138+ <span class="empty-icon">⭐</span>
139+ <span class="empty-text">No favorites yet</span>
140+ <span class="empty-hint">Press Space on any item in History to add it here</span>
141+ </div>
142+ {:else if inputValue && inputValue.trim()}
143+ <!-- Only show "No results found" for history when user has typed something -->
144+ <div class="empty-state">
145+ <span class="empty-icon">🔍</span>
146+ <span class="empty-text">No results found</span>
147+ </div>
148+ {/if}
149+ {:else}
150+ {#each currentList as item, index (item.url)}
151+ {@const { domain, path } = parseUrl(item.url)}
152+ {@const isSelected = index === selectedIndex}
153+ {@const favicon = item.favicon || item.favicon_url || ''}
154+ {@const isFav = viewMode === 'history' && isFavorited(item.url)}
155+
156+ <div
157+ id="omnibox-item-{index}"
158+ class:suggestion-item={true}
159+ class:selected={isSelected}
160+ class:favorited={isFav}
161+ role="option"
162+ tabindex={isSelected ? 0 : -1}
163+ aria-selected={isSelected}
164+ onmouseenter={() => handleItemMouseEnter(index)}
165+ onclick={() => handleItemClick(item)}
166+ onkeydown={(e) => handleItemKeyDown(e, item)}
167+ >
107168 <!-- Favicon chip -->
108- {#if suggestion.favicon}
169+ {#if favicon}
109170 <div class="suggestion-favicon">
110171 <img
111- src={suggestion.favicon}
172+ src={favicon}
112173 alt=""
113174 class="suggestion-favicon-img"
114175 loading="lazy"
@@ -136,6 +197,7 @@
136197 </div>
137198 </div>
138199 {/each}
200+ {/if}
139201 </div>
140202 {/if}
141203
@@ -151,11 +213,71 @@
151213 border-radius: 0 0 2px 2px;
152214 }
153215
216+ .view-mode-header {
217+ display: flex;
218+ gap: 0.5rem;
219+ align-items: center;
220+ padding: 0.5rem 0.85rem;
221+ border-bottom: 1px solid var(--dynamic-border);
222+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-text));
223+ font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
224+ }
225+
226+ .view-tab {
227+ padding: 0.25rem 0.75rem;
228+ border-radius: 2px;
229+ font-size: 0.85rem;
230+ font-weight: 500;
231+ color: var(--dynamic-muted);
232+ transition: all 100ms ease;
233+ }
234+
235+ .view-tab.active {
236+ color: var(--dynamic-accent);
237+ background: color-mix(in srgb, var(--dynamic-accent) 15%, transparent);
238+ }
239+
240+ .view-hint {
241+ margin-left: auto;
242+ font-size: 0.75rem;
243+ color: var(--dynamic-muted);
244+ opacity: 0.7;
245+ }
246+
247+ .empty-state {
248+ display: flex;
249+ flex-direction: column;
250+ align-items: center;
251+ justify-content: center;
252+ padding: 2rem 1rem;
253+ gap: 0.5rem;
254+ color: var(--dynamic-muted);
255+ text-align: center;
256+ }
257+
258+ .empty-icon {
259+ font-size: 2rem;
260+ opacity: 0.5;
261+ }
262+
263+ .empty-text {
264+ font-size: 0.95rem;
265+ font-weight: 500;
266+ color: var(--dynamic-text);
267+ opacity: 0.7;
268+ }
269+
270+ .empty-hint {
271+ font-size: 0.8rem;
272+ opacity: 0.6;
273+ max-width: 300px;
274+ }
275+
154276 .suggestion-item {
155277 display: flex;
156278 gap: 0.75rem;
157279 align-items: center;
158- padding: 0.65rem 0.85rem;
280+ padding: 0.85rem 0.85rem;
159281 border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
160282 cursor: pointer;
161283 transition: background-color 100ms ease, border-left-color 100ms ease;
@@ -169,6 +291,11 @@
169291 border-bottom: none;
170292 }
171293
294+ /* Favorited items get a permanent yellow left border */
295+ .suggestion-item.favorited {
296+ border-left-color: #f59e0b;
297+ }
298+
172299 .suggestion-item.selected,
173300 .suggestion-item:hover,
174301 .suggestion-item:focus-visible {
@@ -178,6 +305,13 @@
178305 outline: none;
179306 }
180307
308+ /* Keep yellow border even when selected/hover for favorited items */
309+ .suggestion-item.favorited.selected,
310+ .suggestion-item.favorited:hover,
311+ .suggestion-item.favorited:focus-visible {
312+ border-left-color: #f59e0b;
313+ }
314+
181315 .suggestion-item.selected {
182316 font-weight: 500;
183317 }
@@ -9,14 +9,43 @@
9 9
10 // Reactive state10 // Reactive state
11 let suggestions = $derived(omniboxStore.suggestions);11 let suggestions = $derived(omniboxStore.suggestions);
12+ let favorites = $derived(omniboxStore.favorites);
13+ let viewMode = $derived(omniboxStore.viewMode);
12 let selectedIndex = $derived(omniboxStore.selectedIndex);14 let selectedIndex = $derived(omniboxStore.selectedIndex);
15+ let inputValue = $derived(omniboxStore.inputValue);
13 let hasContent = $state(false);16 let hasContent = $state(false);
14 17
15- // Update hasContent when suggestions change18+ // Debug effect to log favorites changes
16 $effect(() => {19 $effect(() => {
17- hasContent = omniboxStore.mode === 'omnibox' && suggestions.length > 0;20+ console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length);
18 });21 });
19 22
23+ // Computed: Get current list based on view mode with local filtering for favorites
24+ let currentList = $derived.by(() => {
25+ const list = viewMode === 'history' ? suggestions : favorites;
26+
27+ // In favorites view with input, filter locally
28+ if (viewMode === 'favorites' && inputValue && inputValue.trim()) {
29+ const query = inputValue.toLowerCase().trim();
30+ return list.filter(fav =>
31+ fav.url.toLowerCase().includes(query) ||
32+ (fav.title && fav.title.toLowerCase().includes(query))
33+ );
34+ }
35+
36+ return list;
37+ });
38+
39+ // Update hasContent - always show in omnibox mode to display header
40+ $effect(() => {
41+ hasContent = omniboxStore.mode === 'omnibox';
42+ });
43+
44+ // Check if a URL is in favorites
45+ function isFavorited(url: string): boolean {
46+ return favorites.some(fav => fav.url === url);
47+ }
48+
20 // Handle suggestion item mouse enter49 // Handle suggestion item mouse enter
21 function handleItemMouseEnter(index: number) {50 function handleItemMouseEnter(index: number) {
22 omniboxStore.setSelectedIndex(index);51 omniboxStore.setSelectedIndex(index);
@@ -90,25 +119,57 @@
90 role="listbox"119 role="listbox"
91 aria-label="Search suggestions"120 aria-label="Search suggestions"
92 >121 >
93- {#each suggestions as suggestion, index (suggestion.url)}122+ <!-- View mode indicator -->
94- {@const { domain, path } = parseUrl(suggestion.url)}123+ <div class="view-mode-header">
95- {@const isSelected = index === selectedIndex}124+ <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}>
96-125+ History ({suggestions.length})
97- <div126+ </span>
98- id="omnibox-item-{index}"127+ <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}>
99- class={isSelected ? 'suggestion-item selected' : 'suggestion-item'}128+ Favorites ({favorites.length})
100- role="option"129+ </span>
101- tabindex={isSelected ? 0 : -1}130+ <span class="view-hint">Tab to switch</span>
102- aria-selected={isSelected}131+ </div>
103- onmouseenter={() => handleItemMouseEnter(index)}132+
104- onclick={() => handleItemClick(suggestion)}133+ {#if currentList.length === 0}
105- onkeydown={(e) => handleItemKeyDown(e, suggestion)}134+ <!-- Empty state -->
106- >135+ {#if viewMode === 'favorites'}
136+ <!-- Always show empty state for favorites -->
137+ <div class="empty-state">
138+ <span class="empty-icon">⭐</span>
139+ <span class="empty-text">No favorites yet</span>
140+ <span class="empty-hint">Press Space on any item in History to add it here</span>
141+ </div>
142+ {:else if inputValue && inputValue.trim()}
143+ <!-- Only show "No results found" for history when user has typed something -->
144+ <div class="empty-state">
145+ <span class="empty-icon">🔍</span>
146+ <span class="empty-text">No results found</span>
147+ </div>
148+ {/if}
149+ {:else}
150+ {#each currentList as item, index (item.url)}
151+ {@const { domain, path } = parseUrl(item.url)}
152+ {@const isSelected = index === selectedIndex}
153+ {@const favicon = item.favicon || item.favicon_url || ''}
154+ {@const isFav = viewMode === 'history' && isFavorited(item.url)}
155+
156+ <div
157+ id="omnibox-item-{index}"
158+ class:suggestion-item={true}
159+ class:selected={isSelected}
160+ class:favorited={isFav}
161+ role="option"
162+ tabindex={isSelected ? 0 : -1}
163+ aria-selected={isSelected}
164+ onmouseenter={() => handleItemMouseEnter(index)}
165+ onclick={() => handleItemClick(item)}
166+ onkeydown={(e) => handleItemKeyDown(e, item)}
167+ >
107 <!-- Favicon chip -->168 <!-- Favicon chip -->
108- {#if suggestion.favicon}169+ {#if favicon}
109 <div class="suggestion-favicon">170 <div class="suggestion-favicon">
110 <img171 <img
111- src={suggestion.favicon}172+ src={favicon}
112 alt=""173 alt=""
113 class="suggestion-favicon-img"174 class="suggestion-favicon-img"
114 loading="lazy"175 loading="lazy"
@@ -136,6 +197,7 @@
136 </div>197 </div>
137 </div>198 </div>
138 {/each}199 {/each}
200+ {/if}
139 </div>201 </div>
140 {/if}202 {/if}
141 203
@@ -151,11 +213,71 @@
151 border-radius: 0 0 2px 2px;213 border-radius: 0 0 2px 2px;
152 }214 }
153 215
216+ .view-mode-header {
217+ display: flex;
218+ gap: 0.5rem;
219+ align-items: center;
220+ padding: 0.5rem 0.85rem;
221+ border-bottom: 1px solid var(--dynamic-border);
222+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-text));
223+ font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
224+ }
225+
226+ .view-tab {
227+ padding: 0.25rem 0.75rem;
228+ border-radius: 2px;
229+ font-size: 0.85rem;
230+ font-weight: 500;
231+ color: var(--dynamic-muted);
232+ transition: all 100ms ease;
233+ }
234+
235+ .view-tab.active {
236+ color: var(--dynamic-accent);
237+ background: color-mix(in srgb, var(--dynamic-accent) 15%, transparent);
238+ }
239+
240+ .view-hint {
241+ margin-left: auto;
242+ font-size: 0.75rem;
243+ color: var(--dynamic-muted);
244+ opacity: 0.7;
245+ }
246+
247+ .empty-state {
248+ display: flex;
249+ flex-direction: column;
250+ align-items: center;
251+ justify-content: center;
252+ padding: 2rem 1rem;
253+ gap: 0.5rem;
254+ color: var(--dynamic-muted);
255+ text-align: center;
256+ }
257+
258+ .empty-icon {
259+ font-size: 2rem;
260+ opacity: 0.5;
261+ }
262+
263+ .empty-text {
264+ font-size: 0.95rem;
265+ font-weight: 500;
266+ color: var(--dynamic-text);
267+ opacity: 0.7;
268+ }
269+
270+ .empty-hint {
271+ font-size: 0.8rem;
272+ opacity: 0.6;
273+ max-width: 300px;
274+ }
275+
154 .suggestion-item {276 .suggestion-item {
155 display: flex;277 display: flex;
156 gap: 0.75rem;278 gap: 0.75rem;
157 align-items: center;279 align-items: center;
158- padding: 0.65rem 0.85rem;280+ padding: 0.85rem 0.85rem;
159 border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);281 border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
160 cursor: pointer;282 cursor: pointer;
161 transition: background-color 100ms ease, border-left-color 100ms ease;283 transition: background-color 100ms ease, border-left-color 100ms ease;
@@ -169,6 +291,11 @@
169 border-bottom: none;291 border-bottom: none;
170 }292 }
171 293
294+ /* Favorited items get a permanent yellow left border */
295+ .suggestion-item.favorited {
296+ border-left-color: #f59e0b;
297+ }
298+
172 .suggestion-item.selected,299 .suggestion-item.selected,
173 .suggestion-item:hover,300 .suggestion-item:hover,
174 .suggestion-item:focus-visible {301 .suggestion-item:focus-visible {
@@ -178,6 +305,13 @@
178 outline: none;305 outline: none;
179 }306 }
180 307
308+ /* Keep yellow border even when selected/hover for favorited items */
309+ .suggestion-item.favorited.selected,
310+ .suggestion-item.favorited:hover,
311+ .suggestion-item.favorited:focus-visible {
312+ border-left-color: #f59e0b;
313+ }
314+
181 .suggestion-item.selected {315 .suggestion-item.selected {
182 font-weight: 500;316 font-weight: 500;
183 }317 }
M gui/src/components/omnibox/messaging.ts +58 -1
@@ -4,7 +4,7 @@
44 * TypeScript interface for Go-JavaScript communication
55 */
66
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, SearchShortcut } from "./types";
7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, Favorite, SearchShortcut } from "./types";
88 import { omniboxStore } from "./stores.svelte.ts";
99
1010 export class OmniboxBridge implements OmniboxMessageBridge {
@@ -118,6 +118,63 @@ export class OmniboxBridge implements OmniboxMessageBridge {
118118 });
119119 }
120120
121+ /**
122+ * Update favorites from Go backend
123+ */
124+ setFavorites(favorites: Favorite[]): void {
125+ console.log("📝 [DEBUG] Received favorites from backend:", favorites);
126+ omniboxStore.updateFavorites(favorites);
127+ }
128+
129+ /**
130+ * Fetch favorites from backend via messaging bridge
131+ */
132+ async fetchFavorites(): Promise<void> {
133+ return new Promise((resolve, reject) => {
134+ // Set up one-time event listener for favorites response
135+ const handleFavorites = ((event: CustomEvent) => {
136+ try {
137+ const { favorites } = event.detail;
138+ if (!Array.isArray(favorites)) {
139+ reject(new Error("Invalid favorites data"));
140+ return;
141+ }
142+ console.log("📝 [DEBUG] Received favorites via CustomEvent:", favorites);
143+ this.setFavorites(favorites as Favorite[]);
144+
145+ // Clean up event listener
146+ document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
147+
148+ resolve();
149+ } catch (error) {
150+ console.error("Failed to process favorites:", error);
151+ document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
152+ reject(error);
153+ }
154+ }) as EventListener;
155+
156+ // Listen for favorites event from main world bridge
157+ document.addEventListener("dumber:favorites", handleFavorites as EventListener);
158+
159+ // Send message to Go backend via CustomEvent bridge (like other omnibox messages)
160+ this.postMessage({ type: "get_favorites" });
161+
162+ // Timeout after 5 seconds
163+ setTimeout(() => {
164+ document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
165+ reject(new Error("Favorites fetch timeout"));
166+ }, 5000);
167+ });
168+ }
169+
170+ /**
171+ * Toggle favorite status for a URL
172+ */
173+ toggleFavorite(url: string, title: string, faviconURL: string): void {
174+ console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL });
175+ this.postMessage({ type: "toggle_favorite", url, title, faviconURL });
176+ }
177+
121178 // Suggestions are returned via setSuggestions() when native handler responds
122179 }
123180
@@ -4,7 +4,7 @@
4 * TypeScript interface for Go-JavaScript communication4 * TypeScript interface for Go-JavaScript communication
5 */5 */
6 6
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, SearchShortcut } from "./types";7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, Favorite, SearchShortcut } from "./types";
8 import { omniboxStore } from "./stores.svelte.ts";8 import { omniboxStore } from "./stores.svelte.ts";
9 9
10 export class OmniboxBridge implements OmniboxMessageBridge {10 export class OmniboxBridge implements OmniboxMessageBridge {
@@ -118,6 +118,63 @@ export class OmniboxBridge implements OmniboxMessageBridge {
118 });118 });
119 }119 }
120 120
121+ /**
122+ * Update favorites from Go backend
123+ */
124+ setFavorites(favorites: Favorite[]): void {
125+ console.log("📝 [DEBUG] Received favorites from backend:", favorites);
126+ omniboxStore.updateFavorites(favorites);
127+ }
128+
129+ /**
130+ * Fetch favorites from backend via messaging bridge
131+ */
132+ async fetchFavorites(): Promise<void> {
133+ return new Promise((resolve, reject) => {
134+ // Set up one-time event listener for favorites response
135+ const handleFavorites = ((event: CustomEvent) => {
136+ try {
137+ const { favorites } = event.detail;
138+ if (!Array.isArray(favorites)) {
139+ reject(new Error("Invalid favorites data"));
140+ return;
141+ }
142+ console.log("📝 [DEBUG] Received favorites via CustomEvent:", favorites);
143+ this.setFavorites(favorites as Favorite[]);
144+
145+ // Clean up event listener
146+ document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
147+
148+ resolve();
149+ } catch (error) {
150+ console.error("Failed to process favorites:", error);
151+ document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
152+ reject(error);
153+ }
154+ }) as EventListener;
155+
156+ // Listen for favorites event from main world bridge
157+ document.addEventListener("dumber:favorites", handleFavorites as EventListener);
158+
159+ // Send message to Go backend via CustomEvent bridge (like other omnibox messages)
160+ this.postMessage({ type: "get_favorites" });
161+
162+ // Timeout after 5 seconds
163+ setTimeout(() => {
164+ document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
165+ reject(new Error("Favorites fetch timeout"));
166+ }, 5000);
167+ });
168+ }
169+
170+ /**
171+ * Toggle favorite status for a URL
172+ */
173+ toggleFavorite(url: string, title: string, faviconURL: string): void {
174+ console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL });
175+ this.postMessage({ type: "toggle_favorite", url, title, faviconURL });
176+ }
177+
121 // Suggestions are returned via setSuggestions() when native handler responds178 // Suggestions are returned via setSuggestions() when native handler responds
122 }179 }
123 180
M gui/src/components/omnibox/stores.svelte.ts +61 -7
@@ -6,7 +6,9 @@
66
77 import type {
88 OmniboxMode,
9+ ViewMode,
910 Suggestion,
11+ Favorite,
1012 FindMatch,
1113 HighlightNode,
1214 OmniboxConfig,
@@ -20,10 +22,25 @@ const DEFAULT_CONFIG: Required<OmniboxConfig> = {
2022 defaultLimit: 10,
2123 };
2224
25+// Load viewMode from localStorage or default to "history"
26+function loadViewMode(): ViewMode {
27+ try {
28+ const stored = localStorage.getItem("dumber_omnibox_viewMode");
29+ if (stored === "favorites" || stored === "history") {
30+ return stored;
31+ }
32+ } catch (e) {
33+ console.warn("Failed to load viewMode from localStorage:", e);
34+ }
35+ return "history";
36+}
37+
2338 // Global state using Svelte 5 runes
2439 let visible = $state(false);
2540 let mode = $state<OmniboxMode>("omnibox");
41+let viewMode = $state<ViewMode>(loadViewMode());
2642 let suggestions = $state<Suggestion[]>([]);
43+let favorites = $state<Favorite[]>([]);
2744 let matches = $state<FindMatch[]>([]);
2845 let selectedIndex = $state(-1);
2946 let activeIndex = $state(-1);
@@ -45,9 +62,15 @@ export const omniboxStore = {
4562 get mode() {
4663 return mode;
4764 },
65+ get viewMode() {
66+ return viewMode;
67+ },
4868 get suggestions() {
4969 return suggestions;
5070 },
71+ get favorites() {
72+ return favorites;
73+ },
5174 get matches() {
5275 return matches;
5376 },
@@ -78,19 +101,28 @@ export const omniboxStore = {
78101
79102 // Computed getters
80103 get hasContent() {
81- return (
82- (mode === "omnibox" && suggestions.length > 0) ||
83- (mode === "find" && matches.length > 0)
84- );
104+ if (mode === "find") {
105+ return matches.length > 0;
106+ }
107+ // In omnibox mode, check the current view
108+ return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0;
85109 },
86110 get totalItems() {
87- return mode === "omnibox" ? suggestions.length : matches.length;
111+ if (mode === "find") {
112+ return matches.length;
113+ }
114+ // In omnibox mode, return count based on current view
115+ return viewMode === "history" ? suggestions.length : favorites.length;
88116 },
89117 get selectedItem() {
90118 if (selectedIndex < 0) return null;
91- return mode === "omnibox"
119+ if (mode === "find") {
120+ return matches[selectedIndex];
121+ }
122+ // In omnibox mode, return item from current view
123+ return viewMode === "history"
92124 ? suggestions[selectedIndex]
93- : matches[selectedIndex];
125+ : favorites[selectedIndex];
94126 },
95127
96128 // Actions
@@ -242,10 +274,32 @@ export const omniboxStore = {
242274 searchShortcuts = shortcuts;
243275 },
244276
277+ setViewMode(newViewMode: ViewMode) {
278+ viewMode = newViewMode;
279+ selectedIndex = -1; // Reset selection when switching views
280+
281+ // Persist to localStorage
282+ try {
283+ localStorage.setItem("dumber_omnibox_viewMode", newViewMode);
284+ } catch (e) {
285+ console.warn("Failed to save viewMode to localStorage:", e);
286+ }
287+ },
288+
289+ updateFavorites(newFavorites: Favorite[]) {
290+ favorites = Array.isArray(newFavorites) ? newFavorites : [];
291+ // Reset selection if we're in favorites view
292+ if (viewMode === "favorites") {
293+ selectedIndex = -1;
294+ }
295+ },
296+
245297 reset() {
246298 visible = false;
247299 mode = "omnibox";
300+ // Keep viewMode persistent - it's user preference
248301 suggestions = [];
302+ favorites = [];
249303 matches = [];
250304 selectedIndex = -1;
251305 activeIndex = -1;
@@ -6,7 +6,9 @@
6 6
7 import type {7 import type {
8 OmniboxMode,8 OmniboxMode,
9+ ViewMode,
9 Suggestion,10 Suggestion,
11+ Favorite,
10 FindMatch,12 FindMatch,
11 HighlightNode,13 HighlightNode,
12 OmniboxConfig,14 OmniboxConfig,
@@ -20,10 +22,25 @@ const DEFAULT_CONFIG: Required<OmniboxConfig> = {
20 defaultLimit: 10,22 defaultLimit: 10,
21 };23 };
22 24
25+// Load viewMode from localStorage or default to "history"
26+function loadViewMode(): ViewMode {
27+ try {
28+ const stored = localStorage.getItem("dumber_omnibox_viewMode");
29+ if (stored === "favorites" || stored === "history") {
30+ return stored;
31+ }
32+ } catch (e) {
33+ console.warn("Failed to load viewMode from localStorage:", e);
34+ }
35+ return "history";
36+}
37+
23 // Global state using Svelte 5 runes38 // Global state using Svelte 5 runes
24 let visible = $state(false);39 let visible = $state(false);
25 let mode = $state<OmniboxMode>("omnibox");40 let mode = $state<OmniboxMode>("omnibox");
41+let viewMode = $state<ViewMode>(loadViewMode());
26 let suggestions = $state<Suggestion[]>([]);42 let suggestions = $state<Suggestion[]>([]);
43+let favorites = $state<Favorite[]>([]);
27 let matches = $state<FindMatch[]>([]);44 let matches = $state<FindMatch[]>([]);
28 let selectedIndex = $state(-1);45 let selectedIndex = $state(-1);
29 let activeIndex = $state(-1);46 let activeIndex = $state(-1);
@@ -45,9 +62,15 @@ export const omniboxStore = {
45 get mode() {62 get mode() {
46 return mode;63 return mode;
47 },64 },
65+ get viewMode() {
66+ return viewMode;
67+ },
48 get suggestions() {68 get suggestions() {
49 return suggestions;69 return suggestions;
50 },70 },
71+ get favorites() {
72+ return favorites;
73+ },
51 get matches() {74 get matches() {
52 return matches;75 return matches;
53 },76 },
@@ -78,19 +101,28 @@ export const omniboxStore = {
78 101
79 // Computed getters102 // Computed getters
80 get hasContent() {103 get hasContent() {
81- return (104+ if (mode === "find") {
82- (mode === "omnibox" && suggestions.length > 0) ||105+ return matches.length > 0;
83- (mode === "find" && matches.length > 0)106+ }
84- );107+ // In omnibox mode, check the current view
108+ return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0;
85 },109 },
86 get totalItems() {110 get totalItems() {
87- return mode === "omnibox" ? suggestions.length : matches.length;111+ if (mode === "find") {
112+ return matches.length;
113+ }
114+ // In omnibox mode, return count based on current view
115+ return viewMode === "history" ? suggestions.length : favorites.length;
88 },116 },
89 get selectedItem() {117 get selectedItem() {
90 if (selectedIndex < 0) return null;118 if (selectedIndex < 0) return null;
91- return mode === "omnibox"119+ if (mode === "find") {
120+ return matches[selectedIndex];
121+ }
122+ // In omnibox mode, return item from current view
123+ return viewMode === "history"
92 ? suggestions[selectedIndex]124 ? suggestions[selectedIndex]
93- : matches[selectedIndex];125+ : favorites[selectedIndex];
94 },126 },
95 127
96 // Actions128 // Actions
@@ -242,10 +274,32 @@ export const omniboxStore = {
242 searchShortcuts = shortcuts;274 searchShortcuts = shortcuts;
243 },275 },
244 276
277+ setViewMode(newViewMode: ViewMode) {
278+ viewMode = newViewMode;
279+ selectedIndex = -1; // Reset selection when switching views
280+
281+ // Persist to localStorage
282+ try {
283+ localStorage.setItem("dumber_omnibox_viewMode", newViewMode);
284+ } catch (e) {
285+ console.warn("Failed to save viewMode to localStorage:", e);
286+ }
287+ },
288+
289+ updateFavorites(newFavorites: Favorite[]) {
290+ favorites = Array.isArray(newFavorites) ? newFavorites : [];
291+ // Reset selection if we're in favorites view
292+ if (viewMode === "favorites") {
293+ selectedIndex = -1;
294+ }
295+ },
296+
245 reset() {297 reset() {
246 visible = false;298 visible = false;
247 mode = "omnibox";299 mode = "omnibox";
300+ // Keep viewMode persistent - it's user preference
248 suggestions = [];301 suggestions = [];
302+ favorites = [];
249 matches = [];303 matches = [];
250 selectedIndex = -1;304 selectedIndex = -1;
251 activeIndex = -1;305 activeIndex = -1;
M gui/src/components/omnibox/types.ts +14 -1
@@ -9,16 +9,26 @@ export interface Suggestion {
99 favicon?: string;
1010 }
1111
12+export interface Favorite {
13+ id: number;
14+ url: string;
15+ title: string;
16+ favicon_url: string;
17+ position: number;
18+}
19+
1220 export interface SearchShortcut {
1321 url: string;
1422 description: string;
1523 }
1624
1725 export interface OmniboxMessage {
18- type: "navigate" | "query" | "get_search_shortcuts";
26+ type: "navigate" | "query" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite";
1927 url?: string;
2028 q?: string;
2129 limit?: number;
30+ title?: string;
31+ faviconURL?: string;
2232 }
2333
2434 export interface FindMatch {
@@ -32,6 +42,7 @@ export interface HighlightNode {
3242 }
3343
3444 export type OmniboxMode = "omnibox" | "find";
45+export type ViewMode = "history" | "favorites";
3546
3647 export interface OmniboxConfig {
3748 maxMatches?: number;
@@ -42,7 +53,9 @@ export interface OmniboxConfig {
4253 export interface OmniboxState {
4354 visible: boolean;
4455 mode: OmniboxMode;
56+ viewMode: ViewMode;
4557 suggestions: Suggestion[];
58+ favorites: Favorite[];
4659 matches: FindMatch[];
4760 selectedIndex: number;
4861 activeIndex: number;
@@ -9,16 +9,26 @@ export interface Suggestion {
9 favicon?: string;9 favicon?: string;
10 }10 }
11 11
12+export interface Favorite {
13+ id: number;
14+ url: string;
15+ title: string;
16+ favicon_url: string;
17+ position: number;
18+}
19+
12 export interface SearchShortcut {20 export interface SearchShortcut {
13 url: string;21 url: string;
14 description: string;22 description: string;
15 }23 }
16 24
17 export interface OmniboxMessage {25 export interface OmniboxMessage {
18- type: "navigate" | "query" | "get_search_shortcuts";26+ type: "navigate" | "query" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite";
19 url?: string;27 url?: string;
20 q?: string;28 q?: string;
21 limit?: number;29 limit?: number;
30+ title?: string;
31+ faviconURL?: string;
22 }32 }
23 33
24 export interface FindMatch {34 export interface FindMatch {
@@ -32,6 +42,7 @@ export interface HighlightNode {
32 }42 }
33 43
34 export type OmniboxMode = "omnibox" | "find";44 export type OmniboxMode = "omnibox" | "find";
45+export type ViewMode = "history" | "favorites";
35 46
36 export interface OmniboxConfig {47 export interface OmniboxConfig {
37 maxMatches?: number;48 maxMatches?: number;
@@ -42,7 +53,9 @@ export interface OmniboxConfig {
42 export interface OmniboxState {53 export interface OmniboxState {
43 visible: boolean;54 visible: boolean;
44 mode: OmniboxMode;55 mode: OmniboxMode;
56+ viewMode: ViewMode;
45 suggestions: Suggestion[];57 suggestions: Suggestion[];
58+ favorites: Favorite[];
46 matches: FindMatch[];59 matches: FindMatch[];
47 selectedIndex: number;60 selectedIndex: number;
48 activeIndex: number;61 activeIndex: number;
M gui/src/components/toast/ToastContainer.svelte +17 -13
@@ -84,19 +84,23 @@
8484 }, 1500);
8585 }
8686
87- // Expose API to global window for Go integration immediately
88- // This must happen outside onMount to ensure functions are available immediately
89- if (typeof window !== 'undefined') {
90- window.__dumber_showToast = addToast;
91- window.__dumber_dismissToast = dismissToast;
92- window.__dumber_clearToasts = clearAllToasts;
93- window.__dumber_showZoomToast = showZoomToast;
94- console.log('✅ Toast functions exposed to window:', {
95- showToast: typeof window.__dumber_showToast,
96- dismissToast: typeof window.__dumber_dismissToast,
97- clearToasts: typeof window.__dumber_clearToasts,
98- showZoomToast: typeof window.__dumber_showZoomToast
99- });
87+ // Listen for toast events from main world bridge
88+ if (typeof document !== 'undefined') {
89+ // Listen for general toast requests
90+ document.addEventListener('dumber:toast', ((event: CustomEvent) => {
91+ const { message, duration, type } = event.detail;
92+ console.log('[ToastContainer] Received toast event:', { message, duration, type });
93+ addToast(message, duration, type);
94+ }) as EventListener);
95+
96+ // Listen for zoom toast requests
97+ document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => {
98+ const { level } = event.detail;
99+ console.log('[ToastContainer] Received zoom toast event:', level);
100+ showZoomToast(level);
101+ }) as EventListener);
102+
103+ console.log('✅ Toast event listeners set up for CustomEvents');
100104 }
101105
102106 // Effect that runs when toasts array changes
@@ -84,19 +84,23 @@
84 }, 1500);84 }, 1500);
85 }85 }
86 86
87- // Expose API to global window for Go integration immediately87+ // Listen for toast events from main world bridge
88- // This must happen outside onMount to ensure functions are available immediately88+ if (typeof document !== 'undefined') {
89- if (typeof window !== 'undefined') {89+ // Listen for general toast requests
90- window.__dumber_showToast = addToast;90+ document.addEventListener('dumber:toast', ((event: CustomEvent) => {
91- window.__dumber_dismissToast = dismissToast;91+ const { message, duration, type } = event.detail;
92- window.__dumber_clearToasts = clearAllToasts;92+ console.log('[ToastContainer] Received toast event:', { message, duration, type });
93- window.__dumber_showZoomToast = showZoomToast;93+ addToast(message, duration, type);
94- console.log('✅ Toast functions exposed to window:', {94+ }) as EventListener);
95- showToast: typeof window.__dumber_showToast,95+
96- dismissToast: typeof window.__dumber_dismissToast,96+ // Listen for zoom toast requests
97- clearToasts: typeof window.__dumber_clearToasts,97+ document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => {
98- showZoomToast: typeof window.__dumber_showZoomToast98+ const { level } = event.detail;
99- });99+ console.log('[ToastContainer] Received zoom toast event:', level);
100+ showZoomToast(level);
101+ }) as EventListener);
102+
103+ console.log('✅ Toast event listeners set up for CustomEvents');
100 }104 }
101 105
102 // Effect that runs when toasts array changes106 // Effect that runs when toasts array changes
M gui/src/injected/bootstrap.ts +7 -15
@@ -181,22 +181,14 @@ export function bootstrapGUI(): void {
181181 console.log("[GUI] DOM ready, starting initialization");
182182 let toastInitialized = false;
183183
184- if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") {
185- console.log("[GUI] Toast already initialized, skipping");
184+ try {
185+ console.log("[GUI] Attempting toast initialization");
186+ await initializeToast();
186187 toastInitialized = true;
187- } else {
188- try {
189- console.log("[GUI] Attempting toast initialization");
190- await initializeToast();
191- if (typeof window.__dumber_showToast !== "function") {
192- throw new Error("Toast functions not exposed after initialization");
193- }
194- toastInitialized = true;
195- console.log("[GUI] Toast initialized successfully");
196- } catch (e) {
197- console.error("❌ Failed to initialize Svelte toast system:", e);
198- throw e;
199- }
188+ console.log("[GUI] Toast initialized successfully");
189+ } catch (e) {
190+ console.error("❌ Failed to initialize Svelte toast system:", e);
191+ throw e;
200192 }
201193
202194 try {
@@ -181,22 +181,14 @@ export function bootstrapGUI(): void {
181 console.log("[GUI] DOM ready, starting initialization");181 console.log("[GUI] DOM ready, starting initialization");
182 let toastInitialized = false;182 let toastInitialized = false;
183 183
184- if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") {184+ try {
185- console.log("[GUI] Toast already initialized, skipping");185+ console.log("[GUI] Attempting toast initialization");
186+ await initializeToast();
186 toastInitialized = true;187 toastInitialized = true;
187- } else {188+ console.log("[GUI] Toast initialized successfully");
188- try {189+ } catch (e) {
189- console.log("[GUI] Attempting toast initialization");190+ console.error("❌ Failed to initialize Svelte toast system:", e);
190- await initializeToast();191+ throw e;
191- if (typeof window.__dumber_showToast !== "function") {
192- throw new Error("Toast functions not exposed after initialization");
193- }
194- toastInitialized = true;
195- console.log("[GUI] Toast initialized successfully");
196- } catch (e) {
197- console.error("❌ Failed to initialize Svelte toast system:", e);
198- throw e;
199- }
200 }192 }
201 193
202 try {194 try {
M gui/src/injected/main-world.ts +81 -21
@@ -32,6 +32,7 @@ declare global {
3232 ) => number | void;
3333 __dumber_showZoomToast?: (level: number) => void;
3434 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35+ __dumber_favorites?: (favorites: unknown[]) => void;
3536 __dumber_webview_id?: string | number;
3637 __dumber_is_active?: boolean;
3738 __dumber_teardown?: () => void;
@@ -292,46 +293,104 @@ interface DumberAPI {
292293 );
293294 }
294295
295- // Omnibox suggestions bridge
296- const omniboxQueue: Suggestion[] = [];
297- let omniboxReady = false;
298-
299- const omniboxDispatch = (suggestions: Suggestion[]) => {
296+ /**
297+ * ============================================================================
298+ * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM
299+ * ============================================================================
300+ *
301+ * Architecture Overview:
302+ * ----------------------
303+ * WebKit runs scripts in different "worlds" for security:
304+ *
305+ * - MAIN WORLD: Where page scripts run
306+ * - Can access window.webkit.messageHandlers (backend communication)
307+ * - Can be accessed/modified by page JavaScript
308+ * - Security risk: page can interfere with our code
309+ *
310+ * - ISOLATED WORLD: Where our UI components run (Svelte)
311+ * - Cannot access window.webkit.messageHandlers directly
312+ * - Protected from page JavaScript interference
313+ * - Security benefit: page cannot read/modify our UI state
314+ *
315+ * Communication Patterns:
316+ * -----------------------
317+ *
318+ * 1. WINDOW FUNCTIONS (require bridge):
319+ * Backend → window.__dumber_xxx() in main world
320+ * Main world → CustomEvent to isolated world
321+ * Example: __dumber_omnibox_suggestions(), __dumber_favorites()
322+ *
323+ * 2. CUSTOMEVENT DISPATCH (no bridge needed):
324+ * Backend → document.dispatchEvent() directly
325+ * Both worlds can listen (shared DOM)
326+ * Example: dumber:ui:shortcut (keyboard shortcuts)
327+ *
328+ * 3. ISOLATED TO BACKEND (reverse bridge):
329+ * Isolated world → CustomEvent "dumber:isolated-message"
330+ * Main world listener → window.webkit.messageHandlers.dumber
331+ * Example: navigation, omnibox queries
332+ *
333+ * ============================================================================
334+ */
335+
336+ /**
337+ * Unified dispatcher for forwarding main-world window calls to isolated world
338+ * Only use this for window function bridges, NOT for events backend already dispatches
339+ */
340+ const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => {
300341 try {
301- document.dispatchEvent(
302- new CustomEvent("dumber:omnibox:suggestions", {
303- detail: { suggestions },
304- }),
305- );
306- // Legacy compatibility
307- document.dispatchEvent(
308- new CustomEvent("dumber:omnibox-suggestions", {
309- detail: { suggestions },
310- }),
311- );
342+ document.dispatchEvent(new CustomEvent(eventName, { detail }));
312343 } catch (err) {
313- console.error("[dumber] Omnibox dispatch error", err);
344+ console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err);
314345 }
315346 };
316347
348+ // ============================================================================
349+ // WINDOW FUNCTION BRIDGES
350+ // These convert window.__dumber_xxx() calls to CustomEvents for isolated world
351+ // ============================================================================
352+
353+ // Omnibox suggestions bridge (queues suggestions until component is ready)
354+ const omniboxQueue: Suggestion[] = [];
355+ let omniboxReady = false;
356+
317357 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
318358 if (omniboxReady) {
319- omniboxDispatch(suggestions);
359+ bridgeDispatch("dumber:omnibox:suggestions", { suggestions });
360+ bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support
320361 } else {
321362 try {
322363 omniboxQueue.push(...suggestions);
323364 } catch (err) {
324- console.error("[dumber] Omnibox queue error", err);
365+ console.error("[dumber-bridge] Omnibox queue error", err);
325366 }
326367 }
327368 };
328369
370+ // Favorites bridge
371+ window.__dumber_favorites = (favorites: unknown[]) => {
372+ bridgeDispatch("dumber:favorites", { favorites });
373+ };
374+
375+ // Toast notification bridges
376+ window.__dumber_showToast = (message: string, duration?: number, type?: "info" | "success" | "error") => {
377+ bridgeDispatch("dumber:toast", { message, duration, type });
378+ };
379+
380+ window.__dumber_showZoomToast = (level: number) => {
381+ bridgeDispatch("dumber:toast:zoom", { level });
382+ };
383+
384+ // Handle omnibox ready signal and flush queued suggestions
329385 const handleOmniboxReady = () => {
330386 omniboxReady = true;
331387 if (omniboxQueue && omniboxQueue.length) {
332388 const items = omniboxQueue.slice();
333389 omniboxQueue.length = 0;
334- items.forEach((s) => omniboxDispatch([s]));
390+ items.forEach((s) => {
391+ bridgeDispatch("dumber:omnibox:suggestions", { suggestions: [s] });
392+ bridgeDispatch("dumber:omnibox-suggestions", { suggestions: [s] });
393+ });
335394 }
336395 };
337396
@@ -340,7 +399,7 @@ interface DumberAPI {
340399 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
341400 });
342401
343- // Unified omnibox API
402+ // Unified omnibox API for backward compatibility
344403 window.__dumber.omnibox = window.__dumber.omnibox || {
345404 suggestions: (suggestions: Suggestion[]) => {
346405 window.__dumber_omnibox_suggestions!(suggestions);
@@ -824,6 +883,7 @@ interface DumberAPI {
824883 window.__dumber_page_bridge_installed = false;
825884 window.__dumber_teardown = undefined;
826885 window.__dumber_omnibox_suggestions = undefined;
886+ window.__dumber_favorites = undefined;
827887 window.__dumber_showToast = undefined;
828888 window.__dumber_showZoomToast = undefined;
829889 window.__dumber_applyDomZoom = undefined;
@@ -32,6 +32,7 @@ declare global {
32 ) => number | void;32 ) => number | void;
33 __dumber_showZoomToast?: (level: number) => void;33 __dumber_showZoomToast?: (level: number) => void;
34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35+ __dumber_favorites?: (favorites: unknown[]) => void;
35 __dumber_webview_id?: string | number;36 __dumber_webview_id?: string | number;
36 __dumber_is_active?: boolean;37 __dumber_is_active?: boolean;
37 __dumber_teardown?: () => void;38 __dumber_teardown?: () => void;
@@ -292,46 +293,104 @@ interface DumberAPI {
292 );293 );
293 }294 }
294 295
295- // Omnibox suggestions bridge296+ /**
296- const omniboxQueue: Suggestion[] = [];297+ * ============================================================================
297- let omniboxReady = false;298+ * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM
298-299+ * ============================================================================
299- const omniboxDispatch = (suggestions: Suggestion[]) => {300+ *
301+ * Architecture Overview:
302+ * ----------------------
303+ * WebKit runs scripts in different "worlds" for security:
304+ *
305+ * - MAIN WORLD: Where page scripts run
306+ * - Can access window.webkit.messageHandlers (backend communication)
307+ * - Can be accessed/modified by page JavaScript
308+ * - Security risk: page can interfere with our code
309+ *
310+ * - ISOLATED WORLD: Where our UI components run (Svelte)
311+ * - Cannot access window.webkit.messageHandlers directly
312+ * - Protected from page JavaScript interference
313+ * - Security benefit: page cannot read/modify our UI state
314+ *
315+ * Communication Patterns:
316+ * -----------------------
317+ *
318+ * 1. WINDOW FUNCTIONS (require bridge):
319+ * Backend → window.__dumber_xxx() in main world
320+ * Main world → CustomEvent to isolated world
321+ * Example: __dumber_omnibox_suggestions(), __dumber_favorites()
322+ *
323+ * 2. CUSTOMEVENT DISPATCH (no bridge needed):
324+ * Backend → document.dispatchEvent() directly
325+ * Both worlds can listen (shared DOM)
326+ * Example: dumber:ui:shortcut (keyboard shortcuts)
327+ *
328+ * 3. ISOLATED TO BACKEND (reverse bridge):
329+ * Isolated world → CustomEvent "dumber:isolated-message"
330+ * Main world listener → window.webkit.messageHandlers.dumber
331+ * Example: navigation, omnibox queries
332+ *
333+ * ============================================================================
334+ */
335+
336+ /**
337+ * Unified dispatcher for forwarding main-world window calls to isolated world
338+ * Only use this for window function bridges, NOT for events backend already dispatches
339+ */
340+ const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => {
300 try {341 try {
301- document.dispatchEvent(342+ document.dispatchEvent(new CustomEvent(eventName, { detail }));
302- new CustomEvent("dumber:omnibox:suggestions", {
303- detail: { suggestions },
304- }),
305- );
306- // Legacy compatibility
307- document.dispatchEvent(
308- new CustomEvent("dumber:omnibox-suggestions", {
309- detail: { suggestions },
310- }),
311- );
312 } catch (err) {343 } catch (err) {
313- console.error("[dumber] Omnibox dispatch error", err);344+ console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err);
314 }345 }
315 };346 };
316 347
348+ // ============================================================================
349+ // WINDOW FUNCTION BRIDGES
350+ // These convert window.__dumber_xxx() calls to CustomEvents for isolated world
351+ // ============================================================================
352+
353+ // Omnibox suggestions bridge (queues suggestions until component is ready)
354+ const omniboxQueue: Suggestion[] = [];
355+ let omniboxReady = false;
356+
317 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {357 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
318 if (omniboxReady) {358 if (omniboxReady) {
319- omniboxDispatch(suggestions);359+ bridgeDispatch("dumber:omnibox:suggestions", { suggestions });
360+ bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support
320 } else {361 } else {
321 try {362 try {
322 omniboxQueue.push(...suggestions);363 omniboxQueue.push(...suggestions);
323 } catch (err) {364 } catch (err) {
324- console.error("[dumber] Omnibox queue error", err);365+ console.error("[dumber-bridge] Omnibox queue error", err);
325 }366 }
326 }367 }
327 };368 };
328 369
370+ // Favorites bridge
371+ window.__dumber_favorites = (favorites: unknown[]) => {
372+ bridgeDispatch("dumber:favorites", { favorites });
373+ };
374+
375+ // Toast notification bridges
376+ window.__dumber_showToast = (message: string, duration?: number, type?: "info" | "success" | "error") => {
377+ bridgeDispatch("dumber:toast", { message, duration, type });
378+ };
379+
380+ window.__dumber_showZoomToast = (level: number) => {
381+ bridgeDispatch("dumber:toast:zoom", { level });
382+ };
383+
384+ // Handle omnibox ready signal and flush queued suggestions
329 const handleOmniboxReady = () => {385 const handleOmniboxReady = () => {
330 omniboxReady = true;386 omniboxReady = true;
331 if (omniboxQueue && omniboxQueue.length) {387 if (omniboxQueue && omniboxQueue.length) {
332 const items = omniboxQueue.slice();388 const items = omniboxQueue.slice();
333 omniboxQueue.length = 0;389 omniboxQueue.length = 0;
334- items.forEach((s) => omniboxDispatch([s]));390+ items.forEach((s) => {
391+ bridgeDispatch("dumber:omnibox:suggestions", { suggestions: [s] });
392+ bridgeDispatch("dumber:omnibox-suggestions", { suggestions: [s] });
393+ });
335 }394 }
336 };395 };
337 396
@@ -340,7 +399,7 @@ interface DumberAPI {
340 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);399 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
341 });400 });
342 401
343- // Unified omnibox API402+ // Unified omnibox API for backward compatibility
344 window.__dumber.omnibox = window.__dumber.omnibox || {403 window.__dumber.omnibox = window.__dumber.omnibox || {
345 suggestions: (suggestions: Suggestion[]) => {404 suggestions: (suggestions: Suggestion[]) => {
346 window.__dumber_omnibox_suggestions!(suggestions);405 window.__dumber_omnibox_suggestions!(suggestions);
@@ -824,6 +883,7 @@ interface DumberAPI {
824 window.__dumber_page_bridge_installed = false;883 window.__dumber_page_bridge_installed = false;
825 window.__dumber_teardown = undefined;884 window.__dumber_teardown = undefined;
826 window.__dumber_omnibox_suggestions = undefined;885 window.__dumber_omnibox_suggestions = undefined;
886+ window.__dumber_favorites = undefined;
827 window.__dumber_showToast = undefined;887 window.__dumber_showToast = undefined;
828 window.__dumber_showZoomToast = undefined;888 window.__dumber_showZoomToast = undefined;
829 window.__dumber_applyDomZoom = undefined;889 window.__dumber_applyDomZoom = undefined;
M internal/app/browser/browser.go +9 -0
@@ -186,6 +186,15 @@ func (app *BrowserApp) loadCachesParallel(ctx context.Context) error {
186186 return nil
187187 })
188188
189+ // Load favorites cache in parallel
190+ g.Go(func() error {
191+ if err := app.browserService.LoadFavoritesCacheFromDB(ctx); err != nil {
192+ log.Printf("[cache] Failed to load favorites cache: %v", err)
193+ return err
194+ }
195+ return nil
196+ })
197+
189198 // Load fuzzy search cache in parallel for instant dmenu access
190199 g.Go(func() error {
191200 if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {
@@ -186,6 +186,15 @@ func (app *BrowserApp) loadCachesParallel(ctx context.Context) error {
186 return nil186 return nil
187 })187 })
188 188
189+ // Load favorites cache in parallel
190+ g.Go(func() error {
191+ if err := app.browserService.LoadFavoritesCacheFromDB(ctx); err != nil {
192+ log.Printf("[cache] Failed to load favorites cache: %v", err)
193+ return err
194+ }
195+ return nil
196+ })
197+
189 // Load fuzzy search cache in parallel for instant dmenu access198 // Load fuzzy search cache in parallel for instant dmenu access
190 g.Go(func() error {199 g.Go(func() error {
191 if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {200 if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {
M internal/app/messaging/handler.go +120 -0
@@ -50,6 +50,9 @@ type Message struct {
5050 // Wails fetch bridge
5151 ID string `json:"id"`
5252 Payload json.RawMessage `json:"payload"`
53+ // Favorites operations
54+ Title string `json:"title"`
55+ FaviconURL string `json:"faviconURL"`
5356 }
5457
5558 // NewHandler creates a new message handler
@@ -110,6 +113,12 @@ func (h *Handler) Handle(payload string) {
110113 h.handleGetSearchShortcuts(msg)
111114 case "get_color_palettes":
112115 h.handleGetColorPalettes(msg)
116+ case "get_favorites":
117+ h.handleGetFavorites(msg)
118+ case "toggle_favorite":
119+ h.handleToggleFavorite(msg)
120+ case "is_favorite":
121+ h.handleIsFavorite(msg)
113122 }
114123 }
115124
@@ -568,3 +577,114 @@ func (h *Handler) handleGetColorPalettes(msg Message) {
568577 log.Printf("[messaging] Sending color palettes to JavaScript")
569578 _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
570579 }
580+
581+// handleGetFavorites sends all favorites to JavaScript
582+func (h *Handler) handleGetFavorites(msg Message) {
583+ if h.webView == nil {
584+ log.Printf("[messaging] Cannot provide favorites - no webview available")
585+ return
586+ }
587+
588+ if h.webView.IsDestroyed() {
589+ log.Printf("[messaging] Cannot provide favorites - webview is destroyed")
590+ return
591+ }
592+
593+ // Get favorites from browser service
594+ favorites, err := h.browserService.GetFavorites(context.Background())
595+ if err != nil {
596+ log.Printf("[messaging] Failed to get favorites: %v", err)
597+ _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to get favorites')")
598+ return
599+ }
600+
601+ // Marshal to JSON
602+ b, err := json.Marshal(favorites)
603+ if err != nil {
604+ log.Printf("[messaging] Failed to marshal favorites: %v", err)
605+ _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to load favorites')")
606+ return
607+ }
608+
609+ // Inject the favorites into the page
610+ log.Printf("[messaging] Sending %d favorites to JavaScript", len(favorites))
611+ _ = h.webView.InjectScript("window.__dumber_favorites && window.__dumber_favorites(" + string(b) + ")")
612+}
613+
614+// handleToggleFavorite adds or removes a URL from favorites
615+func (h *Handler) handleToggleFavorite(msg Message) {
616+ if h.webView == nil {
617+ log.Printf("[messaging] Cannot toggle favorite - no webview available")
618+ return
619+ }
620+
621+ if h.webView.IsDestroyed() {
622+ log.Printf("[messaging] Cannot toggle favorite - webview is destroyed")
623+ return
624+ }
625+
626+ if msg.URL == "" {
627+ log.Printf("[messaging] Cannot toggle favorite - URL is empty")
628+ return
629+ }
630+
631+ // Toggle the favorite
632+ added, err := h.browserService.ToggleFavorite(context.Background(), msg.URL, msg.Title, msg.FaviconURL)
633+ if err != nil {
634+ log.Printf("[messaging] Failed to toggle favorite for %s: %v", msg.URL, err)
635+ _ = h.webView.InjectScript("window.__dumber_favorite_toggled_error && window.__dumber_favorite_toggled_error('Failed to toggle favorite')")
636+ return
637+ }
638+
639+ // Send result back to JavaScript
640+ result := map[string]interface{}{
641+ "url": msg.URL,
642+ "added": added,
643+ }
644+ b, err := json.Marshal(result)
645+ if err != nil {
646+ log.Printf("[messaging] Failed to marshal toggle result: %v", err)
647+ return
648+ }
649+
650+ log.Printf("[messaging] Favorite toggled for %s (added: %v)", msg.URL, added)
651+ _ = h.webView.InjectScript("window.__dumber_favorite_toggled && window.__dumber_favorite_toggled(" + string(b) + ")")
652+}
653+
654+// handleIsFavorite checks if a URL is favorited
655+func (h *Handler) handleIsFavorite(msg Message) {
656+ if h.webView == nil {
657+ log.Printf("[messaging] Cannot check favorite - no webview available")
658+ return
659+ }
660+
661+ if h.webView.IsDestroyed() {
662+ log.Printf("[messaging] Cannot check favorite - webview is destroyed")
663+ return
664+ }
665+
666+ if msg.URL == "" {
667+ log.Printf("[messaging] Cannot check favorite - URL is empty")
668+ return
669+ }
670+
671+ // Check if the URL is favorited
672+ isFavorite, err := h.browserService.IsFavorite(context.Background(), msg.URL)
673+ if err != nil {
674+ log.Printf("[messaging] Failed to check if favorite for %s: %v", msg.URL, err)
675+ return
676+ }
677+
678+ // Send result back to JavaScript
679+ result := map[string]interface{}{
680+ "url": msg.URL,
681+ "isFavorite": isFavorite,
682+ }
683+ b, err := json.Marshal(result)
684+ if err != nil {
685+ log.Printf("[messaging] Failed to marshal is_favorite result: %v", err)
686+ return
687+ }
688+
689+ _ = h.webView.InjectScript("window.__dumber_is_favorite && window.__dumber_is_favorite(" + string(b) + ")")
690+}
@@ -50,6 +50,9 @@ type Message struct {
50 // Wails fetch bridge50 // Wails fetch bridge
51 ID string `json:"id"`51 ID string `json:"id"`
52 Payload json.RawMessage `json:"payload"`52 Payload json.RawMessage `json:"payload"`
53+ // Favorites operations
54+ Title string `json:"title"`
55+ FaviconURL string `json:"faviconURL"`
53 }56 }
54 57
55 // NewHandler creates a new message handler58 // NewHandler creates a new message handler
@@ -110,6 +113,12 @@ func (h *Handler) Handle(payload string) {
110 h.handleGetSearchShortcuts(msg)113 h.handleGetSearchShortcuts(msg)
111 case "get_color_palettes":114 case "get_color_palettes":
112 h.handleGetColorPalettes(msg)115 h.handleGetColorPalettes(msg)
116+ case "get_favorites":
117+ h.handleGetFavorites(msg)
118+ case "toggle_favorite":
119+ h.handleToggleFavorite(msg)
120+ case "is_favorite":
121+ h.handleIsFavorite(msg)
113 }122 }
114 }123 }
115 124
@@ -568,3 +577,114 @@ func (h *Handler) handleGetColorPalettes(msg Message) {
568 log.Printf("[messaging] Sending color palettes to JavaScript")577 log.Printf("[messaging] Sending color palettes to JavaScript")
569 _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")578 _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
570 }579 }
580+
581+// handleGetFavorites sends all favorites to JavaScript
582+func (h *Handler) handleGetFavorites(msg Message) {
583+ if h.webView == nil {
584+ log.Printf("[messaging] Cannot provide favorites - no webview available")
585+ return
586+ }
587+
588+ if h.webView.IsDestroyed() {
589+ log.Printf("[messaging] Cannot provide favorites - webview is destroyed")
590+ return
591+ }
592+
593+ // Get favorites from browser service
594+ favorites, err := h.browserService.GetFavorites(context.Background())
595+ if err != nil {
596+ log.Printf("[messaging] Failed to get favorites: %v", err)
597+ _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to get favorites')")
598+ return
599+ }
600+
601+ // Marshal to JSON
602+ b, err := json.Marshal(favorites)
603+ if err != nil {
604+ log.Printf("[messaging] Failed to marshal favorites: %v", err)
605+ _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to load favorites')")
606+ return
607+ }
608+
609+ // Inject the favorites into the page
610+ log.Printf("[messaging] Sending %d favorites to JavaScript", len(favorites))
611+ _ = h.webView.InjectScript("window.__dumber_favorites && window.__dumber_favorites(" + string(b) + ")")
612+}
613+
614+// handleToggleFavorite adds or removes a URL from favorites
615+func (h *Handler) handleToggleFavorite(msg Message) {
616+ if h.webView == nil {
617+ log.Printf("[messaging] Cannot toggle favorite - no webview available")
618+ return
619+ }
620+
621+ if h.webView.IsDestroyed() {
622+ log.Printf("[messaging] Cannot toggle favorite - webview is destroyed")
623+ return
624+ }
625+
626+ if msg.URL == "" {
627+ log.Printf("[messaging] Cannot toggle favorite - URL is empty")
628+ return
629+ }
630+
631+ // Toggle the favorite
632+ added, err := h.browserService.ToggleFavorite(context.Background(), msg.URL, msg.Title, msg.FaviconURL)
633+ if err != nil {
634+ log.Printf("[messaging] Failed to toggle favorite for %s: %v", msg.URL, err)
635+ _ = h.webView.InjectScript("window.__dumber_favorite_toggled_error && window.__dumber_favorite_toggled_error('Failed to toggle favorite')")
636+ return
637+ }
638+
639+ // Send result back to JavaScript
640+ result := map[string]interface{}{
641+ "url": msg.URL,
642+ "added": added,
643+ }
644+ b, err := json.Marshal(result)
645+ if err != nil {
646+ log.Printf("[messaging] Failed to marshal toggle result: %v", err)
647+ return
648+ }
649+
650+ log.Printf("[messaging] Favorite toggled for %s (added: %v)", msg.URL, added)
651+ _ = h.webView.InjectScript("window.__dumber_favorite_toggled && window.__dumber_favorite_toggled(" + string(b) + ")")
652+}
653+
654+// handleIsFavorite checks if a URL is favorited
655+func (h *Handler) handleIsFavorite(msg Message) {
656+ if h.webView == nil {
657+ log.Printf("[messaging] Cannot check favorite - no webview available")
658+ return
659+ }
660+
661+ if h.webView.IsDestroyed() {
662+ log.Printf("[messaging] Cannot check favorite - webview is destroyed")
663+ return
664+ }
665+
666+ if msg.URL == "" {
667+ log.Printf("[messaging] Cannot check favorite - URL is empty")
668+ return
669+ }
670+
671+ // Check if the URL is favorited
672+ isFavorite, err := h.browserService.IsFavorite(context.Background(), msg.URL)
673+ if err != nil {
674+ log.Printf("[messaging] Failed to check if favorite for %s: %v", msg.URL, err)
675+ return
676+ }
677+
678+ // Send result back to JavaScript
679+ result := map[string]interface{}{
680+ "url": msg.URL,
681+ "isFavorite": isFavorite,
682+ }
683+ b, err := json.Marshal(result)
684+ if err != nil {
685+ log.Printf("[messaging] Failed to marshal is_favorite result: %v", err)
686+ return
687+ }
688+
689+ _ = h.webView.InjectScript("window.__dumber_is_favorite && window.__dumber_is_favorite(" + string(b) + ")")
690+}
A internal/cache/favorites_cache.go +93 -0
@@ -0,0 +1,93 @@
1+package cache
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/cache/generic"
8+ "github.com/bnema/dumber/internal/db"
9+)
10+
11+// FavoritesDBOperations implements DatabaseOperations for favorites cache.
12+// Handles loading, persisting, and deleting favorites from the database.
13+type FavoritesDBOperations struct {
14+ queries db.DatabaseQuerier
15+}
16+
17+// NewFavoritesDBOperations creates a new FavoritesDBOperations instance.
18+func NewFavoritesDBOperations(queries db.DatabaseQuerier) *FavoritesDBOperations {
19+ return &FavoritesDBOperations{
20+ queries: queries,
21+ }
22+}
23+
24+// LoadAll loads all favorites from the database.
25+// Returns a map of URL -> Favorite.
26+func (f *FavoritesDBOperations) LoadAll(ctx context.Context) (map[string]db.Favorite, error) {
27+ favorites, err := f.queries.GetAllFavorites(ctx)
28+ if err != nil {
29+ return nil, err
30+ }
31+
32+ result := make(map[string]db.Favorite, len(favorites))
33+ for _, fav := range favorites {
34+ result[fav.Url] = fav
35+ }
36+
37+ return result, nil
38+}
39+
40+// Persist saves a favorite to the database.
41+// Uses UPSERT logic: if URL exists, updates metadata; otherwise creates new.
42+func (f *FavoritesDBOperations) Persist(ctx context.Context, url string, favorite db.Favorite) error {
43+ // Check if favorite already exists
44+ _, err := f.queries.GetFavoriteByURL(ctx, url)
45+ if err == sql.ErrNoRows {
46+ // Create new favorite
47+ return f.queries.CreateFavorite(ctx, url, favorite.Title, favorite.FaviconUrl)
48+ } else if err != nil {
49+ return err
50+ }
51+
52+ // Update existing favorite
53+ return f.queries.UpdateFavorite(ctx, favorite.Title, favorite.FaviconUrl, url)
54+}
55+
56+// Delete removes a favorite from the database.
57+func (f *FavoritesDBOperations) Delete(ctx context.Context, url string) error {
58+ return f.queries.DeleteFavorite(ctx, url)
59+}
60+
61+// FavoritesCache is a specialized cache for favorites.
62+// It wraps GenericCache with favorites-specific helper methods.
63+type FavoritesCache struct {
64+ *generic.GenericCache[string, db.Favorite]
65+}
66+
67+// NewFavoritesCache creates a new favorites cache.
68+func NewFavoritesCache(queries db.DatabaseQuerier) *FavoritesCache {
69+ dbOps := NewFavoritesDBOperations(queries)
70+ return &FavoritesCache{
71+ GenericCache: generic.NewGenericCache(dbOps),
72+ }
73+}
74+
75+// GetAll returns all favorites as a slice, ordered by position.
76+// This is a convenience method for the frontend.
77+func (f *FavoritesCache) GetAll() []db.Favorite {
78+ // Get all favorites from the cache using the List method
79+ favorites := f.GenericCache.List()
80+
81+ // Sort by position
82+ // Note: favorites should already be ordered by position from DB load,
83+ // but this ensures consistency even if items were added at runtime.
84+ for i := 0; i < len(favorites)-1; i++ {
85+ for j := i + 1; j < len(favorites); j++ {
86+ if favorites[i].Position > favorites[j].Position {
87+ favorites[i], favorites[j] = favorites[j], favorites[i]
88+ }
89+ }
90+ }
91+
92+ return favorites
93+}
@@ -0,0 +1,93 @@
1+package cache
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/cache/generic"
8+ "github.com/bnema/dumber/internal/db"
9+)
10+
11+// FavoritesDBOperations implements DatabaseOperations for favorites cache.
12+// Handles loading, persisting, and deleting favorites from the database.
13+type FavoritesDBOperations struct {
14+ queries db.DatabaseQuerier
15+}
16+
17+// NewFavoritesDBOperations creates a new FavoritesDBOperations instance.
18+func NewFavoritesDBOperations(queries db.DatabaseQuerier) *FavoritesDBOperations {
19+ return &FavoritesDBOperations{
20+ queries: queries,
21+ }
22+}
23+
24+// LoadAll loads all favorites from the database.
25+// Returns a map of URL -> Favorite.
26+func (f *FavoritesDBOperations) LoadAll(ctx context.Context) (map[string]db.Favorite, error) {
27+ favorites, err := f.queries.GetAllFavorites(ctx)
28+ if err != nil {
29+ return nil, err
30+ }
31+
32+ result := make(map[string]db.Favorite, len(favorites))
33+ for _, fav := range favorites {
34+ result[fav.Url] = fav
35+ }
36+
37+ return result, nil
38+}
39+
40+// Persist saves a favorite to the database.
41+// Uses UPSERT logic: if URL exists, updates metadata; otherwise creates new.
42+func (f *FavoritesDBOperations) Persist(ctx context.Context, url string, favorite db.Favorite) error {
43+ // Check if favorite already exists
44+ _, err := f.queries.GetFavoriteByURL(ctx, url)
45+ if err == sql.ErrNoRows {
46+ // Create new favorite
47+ return f.queries.CreateFavorite(ctx, url, favorite.Title, favorite.FaviconUrl)
48+ } else if err != nil {
49+ return err
50+ }
51+
52+ // Update existing favorite
53+ return f.queries.UpdateFavorite(ctx, favorite.Title, favorite.FaviconUrl, url)
54+}
55+
56+// Delete removes a favorite from the database.
57+func (f *FavoritesDBOperations) Delete(ctx context.Context, url string) error {
58+ return f.queries.DeleteFavorite(ctx, url)
59+}
60+
61+// FavoritesCache is a specialized cache for favorites.
62+// It wraps GenericCache with favorites-specific helper methods.
63+type FavoritesCache struct {
64+ *generic.GenericCache[string, db.Favorite]
65+}
66+
67+// NewFavoritesCache creates a new favorites cache.
68+func NewFavoritesCache(queries db.DatabaseQuerier) *FavoritesCache {
69+ dbOps := NewFavoritesDBOperations(queries)
70+ return &FavoritesCache{
71+ GenericCache: generic.NewGenericCache(dbOps),
72+ }
73+}
74+
75+// GetAll returns all favorites as a slice, ordered by position.
76+// This is a convenience method for the frontend.
77+func (f *FavoritesCache) GetAll() []db.Favorite {
78+ // Get all favorites from the cache using the List method
79+ favorites := f.GenericCache.List()
80+
81+ // Sort by position
82+ // Note: favorites should already be ordered by position from DB load,
83+ // but this ensures consistency even if items were added at runtime.
84+ for i := 0; i < len(favorites)-1; i++ {
85+ for j := i + 1; j < len(favorites); j++ {
86+ if favorites[i].Position > favorites[j].Position {
87+ favorites[i], favorites[j] = favorites[j], favorites[i]
88+ }
89+ }
90+ }
91+
92+ return favorites
93+}
A internal/db/favorites.sql.go +158 -0
@@ -0,0 +1,158 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: favorites.sql
5+
6+package db
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const CreateFavorite = `-- name: CreateFavorite :exec
14+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
15+VALUES (
16+ ?,
17+ ?,
18+ ?,
19+ COALESCE((SELECT MAX(position) + 1 FROM favorites), 0),
20+ CURRENT_TIMESTAMP,
21+ CURRENT_TIMESTAMP
22+)
23+`
24+
25+// Insert a new favorite with auto-incremented position
26+func (q *Queries) CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error {
27+ _, err := q.db.ExecContext(ctx, CreateFavorite, url, title, faviconUrl)
28+ return err
29+}
30+
31+const DeleteFavorite = `-- name: DeleteFavorite :exec
32+DELETE FROM favorites
33+WHERE url = ?
34+`
35+
36+// Delete a favorite by URL
37+func (q *Queries) DeleteFavorite(ctx context.Context, url string) error {
38+ _, err := q.db.ExecContext(ctx, DeleteFavorite, url)
39+ return err
40+}
41+
42+const GetAllFavorites = `-- name: GetAllFavorites :many
43+SELECT id, url, title, favicon_url, position, created_at, updated_at
44+FROM favorites
45+ORDER BY position ASC
46+`
47+
48+// Get all favorites ordered by position
49+func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {
50+ rows, err := q.db.QueryContext(ctx, GetAllFavorites)
51+ if err != nil {
52+ return nil, err
53+ }
54+ defer rows.Close()
55+ items := []Favorite{}
56+ for rows.Next() {
57+ var i Favorite
58+ if err := rows.Scan(
59+ &i.ID,
60+ &i.Url,
61+ &i.Title,
62+ &i.FaviconUrl,
63+ &i.Position,
64+ &i.CreatedAt,
65+ &i.UpdatedAt,
66+ ); err != nil {
67+ return nil, err
68+ }
69+ items = append(items, i)
70+ }
71+ if err := rows.Close(); err != nil {
72+ return nil, err
73+ }
74+ if err := rows.Err(); err != nil {
75+ return nil, err
76+ }
77+ return items, nil
78+}
79+
80+const GetFavoriteByURL = `-- name: GetFavoriteByURL :one
81+SELECT id, url, title, favicon_url, position, created_at, updated_at
82+FROM favorites
83+WHERE url = ?
84+LIMIT 1
85+`
86+
87+// Get a specific favorite by URL
88+func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) {
89+ row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)
90+ var i Favorite
91+ err := row.Scan(
92+ &i.ID,
93+ &i.Url,
94+ &i.Title,
95+ &i.FaviconUrl,
96+ &i.Position,
97+ &i.CreatedAt,
98+ &i.UpdatedAt,
99+ )
100+ return i, err
101+}
102+
103+const GetFavoriteCount = `-- name: GetFavoriteCount :one
104+SELECT COUNT(*) as count
105+FROM favorites
106+`
107+
108+// Get total count of favorites
109+func (q *Queries) GetFavoriteCount(ctx context.Context) (int64, error) {
110+ row := q.db.QueryRowContext(ctx, GetFavoriteCount)
111+ var count int64
112+ err := row.Scan(&count)
113+ return count, err
114+}
115+
116+const IsFavorite = `-- name: IsFavorite :one
117+SELECT COUNT(*) as count
118+FROM favorites
119+WHERE url = ?
120+LIMIT 1
121+`
122+
123+// Check if a URL is favorited
124+func (q *Queries) IsFavorite(ctx context.Context, url string) (int64, error) {
125+ row := q.db.QueryRowContext(ctx, IsFavorite, url)
126+ var count int64
127+ err := row.Scan(&count)
128+ return count, err
129+}
130+
131+const UpdateFavorite = `-- name: UpdateFavorite :exec
132+UPDATE favorites
133+SET
134+ title = ?,
135+ favicon_url = ?,
136+ updated_at = CURRENT_TIMESTAMP
137+WHERE url = ?
138+`
139+
140+// Update favorite metadata (title, favicon)
141+func (q *Queries) UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error {
142+ _, err := q.db.ExecContext(ctx, UpdateFavorite, title, faviconUrl, url)
143+ return err
144+}
145+
146+const UpdateFavoritePosition = `-- name: UpdateFavoritePosition :exec
147+UPDATE favorites
148+SET
149+ position = ?,
150+ updated_at = CURRENT_TIMESTAMP
151+WHERE url = ?
152+`
153+
154+// Update the position of a favorite
155+func (q *Queries) UpdateFavoritePosition(ctx context.Context, position int64, url string) error {
156+ _, err := q.db.ExecContext(ctx, UpdateFavoritePosition, position, url)
157+ return err
158+}
@@ -0,0 +1,158 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: favorites.sql
5+
6+package db
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const CreateFavorite = `-- name: CreateFavorite :exec
14+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
15+VALUES (
16+ ?,
17+ ?,
18+ ?,
19+ COALESCE((SELECT MAX(position) + 1 FROM favorites), 0),
20+ CURRENT_TIMESTAMP,
21+ CURRENT_TIMESTAMP
22+)
23+`
24+
25+// Insert a new favorite with auto-incremented position
26+func (q *Queries) CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error {
27+ _, err := q.db.ExecContext(ctx, CreateFavorite, url, title, faviconUrl)
28+ return err
29+}
30+
31+const DeleteFavorite = `-- name: DeleteFavorite :exec
32+DELETE FROM favorites
33+WHERE url = ?
34+`
35+
36+// Delete a favorite by URL
37+func (q *Queries) DeleteFavorite(ctx context.Context, url string) error {
38+ _, err := q.db.ExecContext(ctx, DeleteFavorite, url)
39+ return err
40+}
41+
42+const GetAllFavorites = `-- name: GetAllFavorites :many
43+SELECT id, url, title, favicon_url, position, created_at, updated_at
44+FROM favorites
45+ORDER BY position ASC
46+`
47+
48+// Get all favorites ordered by position
49+func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {
50+ rows, err := q.db.QueryContext(ctx, GetAllFavorites)
51+ if err != nil {
52+ return nil, err
53+ }
54+ defer rows.Close()
55+ items := []Favorite{}
56+ for rows.Next() {
57+ var i Favorite
58+ if err := rows.Scan(
59+ &i.ID,
60+ &i.Url,
61+ &i.Title,
62+ &i.FaviconUrl,
63+ &i.Position,
64+ &i.CreatedAt,
65+ &i.UpdatedAt,
66+ ); err != nil {
67+ return nil, err
68+ }
69+ items = append(items, i)
70+ }
71+ if err := rows.Close(); err != nil {
72+ return nil, err
73+ }
74+ if err := rows.Err(); err != nil {
75+ return nil, err
76+ }
77+ return items, nil
78+}
79+
80+const GetFavoriteByURL = `-- name: GetFavoriteByURL :one
81+SELECT id, url, title, favicon_url, position, created_at, updated_at
82+FROM favorites
83+WHERE url = ?
84+LIMIT 1
85+`
86+
87+// Get a specific favorite by URL
88+func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) {
89+ row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)
90+ var i Favorite
91+ err := row.Scan(
92+ &i.ID,
93+ &i.Url,
94+ &i.Title,
95+ &i.FaviconUrl,
96+ &i.Position,
97+ &i.CreatedAt,
98+ &i.UpdatedAt,
99+ )
100+ return i, err
101+}
102+
103+const GetFavoriteCount = `-- name: GetFavoriteCount :one
104+SELECT COUNT(*) as count
105+FROM favorites
106+`
107+
108+// Get total count of favorites
109+func (q *Queries) GetFavoriteCount(ctx context.Context) (int64, error) {
110+ row := q.db.QueryRowContext(ctx, GetFavoriteCount)
111+ var count int64
112+ err := row.Scan(&count)
113+ return count, err
114+}
115+
116+const IsFavorite = `-- name: IsFavorite :one
117+SELECT COUNT(*) as count
118+FROM favorites
119+WHERE url = ?
120+LIMIT 1
121+`
122+
123+// Check if a URL is favorited
124+func (q *Queries) IsFavorite(ctx context.Context, url string) (int64, error) {
125+ row := q.db.QueryRowContext(ctx, IsFavorite, url)
126+ var count int64
127+ err := row.Scan(&count)
128+ return count, err
129+}
130+
131+const UpdateFavorite = `-- name: UpdateFavorite :exec
132+UPDATE favorites
133+SET
134+ title = ?,
135+ favicon_url = ?,
136+ updated_at = CURRENT_TIMESTAMP
137+WHERE url = ?
138+`
139+
140+// Update favorite metadata (title, favicon)
141+func (q *Queries) UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error {
142+ _, err := q.db.ExecContext(ctx, UpdateFavorite, title, faviconUrl, url)
143+ return err
144+}
145+
146+const UpdateFavoritePosition = `-- name: UpdateFavoritePosition :exec
147+UPDATE favorites
148+SET
149+ position = ?,
150+ updated_at = CURRENT_TIMESTAMP
151+WHERE url = ?
152+`
153+
154+// Update the position of a favorite
155+func (q *Queries) UpdateFavoritePosition(ctx context.Context, position int64, url string) error {
156+ _, err := q.db.ExecContext(ctx, UpdateFavoritePosition, position, url)
157+ return err
158+}
M internal/db/interfaces.go +13 -0
@@ -36,11 +36,24 @@ type CertificateQuerier interface {
3636 DeleteExpiredCertificateValidations(ctx context.Context) error
3737 }
3838
39+// FavoritesQuerier defines the interface for favorites-related database operations
40+type FavoritesQuerier interface {
41+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
42+ GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
43+ CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error
44+ UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error
45+ DeleteFavorite(ctx context.Context, url string) error
46+ IsFavorite(ctx context.Context, url string) (int64, error)
47+ UpdateFavoritePosition(ctx context.Context, position int64, url string) error
48+ GetFavoriteCount(ctx context.Context) (int64, error)
49+}
50+
3951 // DatabaseQuerier combines all database operation interfaces
4052 type DatabaseQuerier interface {
4153 ZoomQuerier
4254 HistoryQuerier
4355 CertificateQuerier
56+ FavoritesQuerier
4457 }
4558
4659 // Ensure that *Queries implements DatabaseQuerier interface
@@ -36,11 +36,24 @@ type CertificateQuerier interface {
36 DeleteExpiredCertificateValidations(ctx context.Context) error36 DeleteExpiredCertificateValidations(ctx context.Context) error
37 }37 }
38 38
39+// FavoritesQuerier defines the interface for favorites-related database operations
40+type FavoritesQuerier interface {
41+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
42+ GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
43+ CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error
44+ UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error
45+ DeleteFavorite(ctx context.Context, url string) error
46+ IsFavorite(ctx context.Context, url string) (int64, error)
47+ UpdateFavoritePosition(ctx context.Context, position int64, url string) error
48+ GetFavoriteCount(ctx context.Context) (int64, error)
49+}
50+
39 // DatabaseQuerier combines all database operation interfaces51 // DatabaseQuerier combines all database operation interfaces
40 type DatabaseQuerier interface {52 type DatabaseQuerier interface {
41 ZoomQuerier53 ZoomQuerier
42 HistoryQuerier54 HistoryQuerier
43 CertificateQuerier55 CertificateQuerier
56+ FavoritesQuerier
44 }57 }
45 58
46 // Ensure that *Queries implements DatabaseQuerier interface59 // Ensure that *Queries implements DatabaseQuerier interface
M internal/db/models.go +10 -0
@@ -17,6 +17,16 @@ type CertificateValidation struct {
1717 ExpiresAt sql.NullTime `json:"expires_at"`
1818 }
1919
20+type Favorite struct {
21+ ID int64 `json:"id"`
22+ Url string `json:"url"`
23+ Title sql.NullString `json:"title"`
24+ FaviconUrl sql.NullString `json:"favicon_url"`
25+ Position int64 `json:"position"`
26+ CreatedAt sql.NullTime `json:"created_at"`
27+ UpdatedAt sql.NullTime `json:"updated_at"`
28+}
29+
2030 type FuzzyCacheMetadatum struct {
2131 ID int64 `json:"id"`
2232 Version int64 `json:"version"`
@@ -17,6 +17,16 @@ type CertificateValidation struct {
17 ExpiresAt sql.NullTime `json:"expires_at"`17 ExpiresAt sql.NullTime `json:"expires_at"`
18 }18 }
19 19
20+type Favorite struct {
21+ ID int64 `json:"id"`
22+ Url string `json:"url"`
23+ Title sql.NullString `json:"title"`
24+ FaviconUrl sql.NullString `json:"favicon_url"`
25+ Position int64 `json:"position"`
26+ CreatedAt sql.NullTime `json:"created_at"`
27+ UpdatedAt sql.NullTime `json:"updated_at"`
28+}
29+
20 type FuzzyCacheMetadatum struct {30 type FuzzyCacheMetadatum struct {
21 ID int64 `json:"id"`31 ID int64 `json:"id"`
22 Version int64 `json:"version"`32 Version int64 `json:"version"`
M internal/db/querier.go +16 -0
@@ -13,13 +13,23 @@ type Querier interface {
1313 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
1414 // Cleanup zoom level entries older than specified days
1515 CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error
16+ // Insert a new favorite with auto-incremented position
17+ CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error
1618 DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error
1719 DeleteExpiredCertificateValidations(ctx context.Context) error
20+ // Delete a favorite by URL
21+ DeleteFavorite(ctx context.Context, url string) error
1822 DeleteHistory(ctx context.Context, id int64) error
1923 // Delete zoom level setting for a domain
2024 DeleteZoomLevel(ctx context.Context, domain string) error
25+ // Get all favorites ordered by position
26+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
2127 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
2228 GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
29+ // Get a specific favorite by URL
30+ GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
31+ // Get total count of favorites
32+ GetFavoriteCount(ctx context.Context) (int64, error)
2333 GetHistory(ctx context.Context, limit int64) ([]History, error)
2434 GetHistoryEntry(ctx context.Context, url string) (History, error)
2535 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
@@ -27,6 +37,8 @@ type Querier interface {
2737 GetZoomLevel(ctx context.Context, domain string) (float64, error)
2838 // Get zoom level for domain with default fallback
2939 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
40+ // Check if a URL is favorited
41+ IsFavorite(ctx context.Context, url string) (int64, error)
3042 ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
3143 // List all zoom level settings ordered by most recently updated
3244 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
@@ -34,6 +46,10 @@ type Querier interface {
3446 // Set or update zoom level for a domain with validation
3547 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
3648 StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error
49+ // Update favorite metadata (title, favicon)
50+ UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error
51+ // Update the position of a favorite
52+ UpdateFavoritePosition(ctx context.Context, position int64, url string) error
3753 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
3854 }
3955
@@ -13,13 +13,23 @@ type Querier interface {
13 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error13 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
14 // Cleanup zoom level entries older than specified days14 // Cleanup zoom level entries older than specified days
15 CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error15 CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error
16+ // Insert a new favorite with auto-incremented position
17+ CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error
16 DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error18 DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error
17 DeleteExpiredCertificateValidations(ctx context.Context) error19 DeleteExpiredCertificateValidations(ctx context.Context) error
20+ // Delete a favorite by URL
21+ DeleteFavorite(ctx context.Context, url string) error
18 DeleteHistory(ctx context.Context, id int64) error22 DeleteHistory(ctx context.Context, id int64) error
19 // Delete zoom level setting for a domain23 // Delete zoom level setting for a domain
20 DeleteZoomLevel(ctx context.Context, domain string) error24 DeleteZoomLevel(ctx context.Context, domain string) error
25+ // Get all favorites ordered by position
26+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)27 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22 GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)28 GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
29+ // Get a specific favorite by URL
30+ GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
31+ // Get total count of favorites
32+ GetFavoriteCount(ctx context.Context) (int64, error)
23 GetHistory(ctx context.Context, limit int64) ([]History, error)33 GetHistory(ctx context.Context, limit int64) ([]History, error)
24 GetHistoryEntry(ctx context.Context, url string) (History, error)34 GetHistoryEntry(ctx context.Context, url string) (History, error)
25 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)35 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
@@ -27,6 +37,8 @@ type Querier interface {
27 GetZoomLevel(ctx context.Context, domain string) (float64, error)37 GetZoomLevel(ctx context.Context, domain string) (float64, error)
28 // Get zoom level for domain with default fallback38 // Get zoom level for domain with default fallback
29 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)39 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
40+ // Check if a URL is favorited
41+ IsFavorite(ctx context.Context, url string) (int64, error)
30 ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)42 ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
31 // List all zoom level settings ordered by most recently updated43 // List all zoom level settings ordered by most recently updated
32 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)44 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
@@ -34,6 +46,10 @@ type Querier interface {
34 // Set or update zoom level for a domain with validation46 // Set or update zoom level for a domain with validation
35 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error47 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
36 StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error48 StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error
49+ // Update favorite metadata (title, favicon)
50+ UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error
51+ // Update the position of a favorite
52+ UpdateFavoritePosition(ctx context.Context, position int64, url string) error
37 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error53 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
38 }54 }
39 55
A internal/db/queries/favorites.sql +58 -0
@@ -0,0 +1,58 @@
1+-- name: GetAllFavorites :many
2+-- Get all favorites ordered by position
3+SELECT *
4+FROM favorites
5+ORDER BY position ASC;
6+
7+-- name: GetFavoriteByURL :one
8+-- Get a specific favorite by URL
9+SELECT *
10+FROM favorites
11+WHERE url = ?
12+LIMIT 1;
13+
14+-- name: CreateFavorite :exec
15+-- Insert a new favorite with auto-incremented position
16+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
17+VALUES (
18+ ?,
19+ ?,
20+ ?,
21+ COALESCE((SELECT MAX(position) + 1 FROM favorites), 0),
22+ CURRENT_TIMESTAMP,
23+ CURRENT_TIMESTAMP
24+);
25+
26+-- name: UpdateFavorite :exec
27+-- Update favorite metadata (title, favicon)
28+UPDATE favorites
29+SET
30+ title = ?,
31+ favicon_url = ?,
32+ updated_at = CURRENT_TIMESTAMP
33+WHERE url = ?;
34+
35+-- name: DeleteFavorite :exec
36+-- Delete a favorite by URL
37+DELETE FROM favorites
38+WHERE url = ?;
39+
40+-- name: IsFavorite :one
41+-- Check if a URL is favorited
42+SELECT COUNT(*) as count
43+FROM favorites
44+WHERE url = ?
45+LIMIT 1;
46+
47+-- name: UpdateFavoritePosition :exec
48+-- Update the position of a favorite
49+UPDATE favorites
50+SET
51+ position = ?,
52+ updated_at = CURRENT_TIMESTAMP
53+WHERE url = ?;
54+
55+-- name: GetFavoriteCount :one
56+-- Get total count of favorites
57+SELECT COUNT(*) as count
58+FROM favorites;
@@ -0,0 +1,58 @@
1+-- name: GetAllFavorites :many
2+-- Get all favorites ordered by position
3+SELECT *
4+FROM favorites
5+ORDER BY position ASC;
6+
7+-- name: GetFavoriteByURL :one
8+-- Get a specific favorite by URL
9+SELECT *
10+FROM favorites
11+WHERE url = ?
12+LIMIT 1;
13+
14+-- name: CreateFavorite :exec
15+-- Insert a new favorite with auto-incremented position
16+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
17+VALUES (
18+ ?,
19+ ?,
20+ ?,
21+ COALESCE((SELECT MAX(position) + 1 FROM favorites), 0),
22+ CURRENT_TIMESTAMP,
23+ CURRENT_TIMESTAMP
24+);
25+
26+-- name: UpdateFavorite :exec
27+-- Update favorite metadata (title, favicon)
28+UPDATE favorites
29+SET
30+ title = ?,
31+ favicon_url = ?,
32+ updated_at = CURRENT_TIMESTAMP
33+WHERE url = ?;
34+
35+-- name: DeleteFavorite :exec
36+-- Delete a favorite by URL
37+DELETE FROM favorites
38+WHERE url = ?;
39+
40+-- name: IsFavorite :one
41+-- Check if a URL is favorited
42+SELECT COUNT(*) as count
43+FROM favorites
44+WHERE url = ?
45+LIMIT 1;
46+
47+-- name: UpdateFavoritePosition :exec
48+-- Update the position of a favorite
49+UPDATE favorites
50+SET
51+ position = ?,
52+ updated_at = CURRENT_TIMESTAMP
53+WHERE url = ?;
54+
55+-- name: GetFavoriteCount :one
56+-- Get total count of favorites
57+SELECT COUNT(*) as count
58+FROM favorites;
A internal/migrations/008_add_favorites.sql +18 -0
@@ -0,0 +1,18 @@
1+-- Add favorites table for omnibox favorites feature
2+-- Store user's favorited URLs with metadata for quick access
3+
4+CREATE TABLE IF NOT EXISTS favorites (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ favicon_url TEXT,
9+ position INTEGER NOT NULL DEFAULT 0,
10+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
11+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+-- Create index for performance on position for ordered retrieval
15+CREATE INDEX IF NOT EXISTS idx_favorites_position ON favorites(position);
16+
17+-- Create index for URL lookups
18+CREATE INDEX IF NOT EXISTS idx_favorites_url ON favorites(url);
@@ -0,0 +1,18 @@
1+-- Add favorites table for omnibox favorites feature
2+-- Store user's favorited URLs with metadata for quick access
3+
4+CREATE TABLE IF NOT EXISTS favorites (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ favicon_url TEXT,
9+ position INTEGER NOT NULL DEFAULT 0,
10+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
11+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+-- Create index for performance on position for ordered retrieval
15+CREATE INDEX IF NOT EXISTS idx_favorites_position ON favorites(position);
16+
17+-- Create index for URL lookups
18+CREATE INDEX IF NOT EXISTS idx_favorites_url ON favorites(url);
M internal/services/browser.go +90 -0
@@ -50,6 +50,7 @@ type BrowserService struct {
5050 guiBundle string
5151 zoomCache *cache.ZoomCache // In-memory cache for zoom levels
5252 certCache *cache.CertValidationsCache // In-memory cache for certificate validations
53+ favoritesCache *cache.FavoritesCache // In-memory cache for favorites
5354 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu
5455 historyQueue chan historyUpdate // Queue for batched history writes
5556 historyFlushDone chan bool // Signal when history flush is complete (buffered)
@@ -97,6 +98,7 @@ func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserS
9798 guiBundle: "",
9899 zoomCache: cache.NewZoomCache(queries),
99100 certCache: cache.NewCertValidationsCache(queries),
101+ favoritesCache: cache.NewFavoritesCache(queries),
100102 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults
101103 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
102104 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking
@@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error {
576578 return nil
577579 }
578580
581+// LoadFavoritesCacheFromDB loads all favorites from the database into the cache.
582+// This is typically called once at startup for fast RAM-first access.
583+func (s *BrowserService) LoadFavoritesCacheFromDB(ctx context.Context) error {
584+ if err := s.favoritesCache.Load(ctx); err != nil {
585+ return fmt.Errorf("failed to load favorites: %w", err)
586+ }
587+
588+ loadedCount := len(s.favoritesCache.List())
589+ log.Printf("Loaded %d favorites into cache", loadedCount)
590+ return nil
591+}
592+
593+// FavoriteEntry represents a favorite for the frontend (similar to HistoryEntry).
594+type FavoriteEntry struct {
595+ ID int64 `json:"id"`
596+ URL string `json:"url"`
597+ Title string `json:"title"`
598+ FaviconURL string `json:"favicon_url"`
599+ Position int64 `json:"position"`
600+}
601+
602+// GetFavorites returns all favorites ordered by position.
603+func (s *BrowserService) GetFavorites(ctx context.Context) ([]FavoriteEntry, error) {
604+ favorites := s.favoritesCache.GetAll()
605+
606+ result := make([]FavoriteEntry, len(favorites))
607+ for i, fav := range favorites {
608+ result[i] = FavoriteEntry{
609+ ID: fav.ID,
610+ URL: fav.Url,
611+ Title: fav.Title.String,
612+ FaviconURL: fav.FaviconUrl.String,
613+ Position: fav.Position,
614+ }
615+ }
616+
617+ return result, nil
618+}
619+
620+// IsFavorite checks if a URL is in the favorites list.
621+func (s *BrowserService) IsFavorite(ctx context.Context, url string) (bool, error) {
622+ if url == "" {
623+ return false, nil
624+ }
625+
626+ _, exists := s.favoritesCache.Get(url)
627+ return exists, nil
628+}
629+
630+// ToggleFavorite adds or removes a URL from favorites.
631+// Returns true if the URL was added, false if it was removed.
632+func (s *BrowserService) ToggleFavorite(ctx context.Context, url, title, faviconURL string) (bool, error) {
633+ if url == "" {
634+ return false, fmt.Errorf("URL cannot be empty")
635+ }
636+
637+ // Check if already favorited
638+ _, exists := s.favoritesCache.Get(url)
639+
640+ if exists {
641+ // Remove from favorites
642+ if err := s.favoritesCache.Delete(url); err != nil {
643+ return false, fmt.Errorf("failed to remove favorite: %w", err)
644+ }
645+ log.Printf("Removed favorite: %s", url)
646+ return false, nil
647+ }
648+
649+ // Add to favorites
650+ favorite := db.Favorite{
651+ Url: url,
652+ Title: sql.NullString{String: title, Valid: title != ""},
653+ FaviconUrl: sql.NullString{String: faviconURL, Valid: faviconURL != ""},
654+ }
655+
656+ if err := s.favoritesCache.Set(url, favorite); err != nil {
657+ return false, fmt.Errorf("failed to add favorite: %w", err)
658+ }
659+
660+ log.Printf("Added favorite: %s", url)
661+ return true, nil
662+}
663+
579664 // FlushAllCaches flushes all in-memory caches to ensure pending writes complete.
580665 // This is typically called during shutdown to prevent data loss.
581666 func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
@@ -589,6 +674,11 @@ func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
589674 return fmt.Errorf("failed to flush cert cache: %w", err)
590675 }
591676
677+ // Flush favorites cache
678+ if err := s.favoritesCache.Flush(); err != nil {
679+ return fmt.Errorf("failed to flush favorites cache: %w", err)
680+ }
681+
592682 // Save fuzzy cache to binary file for instant dmenu access
593683 s.fuzzyCache.OnApplicationExit(ctx)
594684
@@ -50,6 +50,7 @@ type BrowserService struct {
50 guiBundle string50 guiBundle string
51 zoomCache *cache.ZoomCache // In-memory cache for zoom levels51 zoomCache *cache.ZoomCache // In-memory cache for zoom levels
52 certCache *cache.CertValidationsCache // In-memory cache for certificate validations52 certCache *cache.CertValidationsCache // In-memory cache for certificate validations
53+ favoritesCache *cache.FavoritesCache // In-memory cache for favorites
53 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu54 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu
54 historyQueue chan historyUpdate // Queue for batched history writes55 historyQueue chan historyUpdate // Queue for batched history writes
55 historyFlushDone chan bool // Signal when history flush is complete (buffered)56 historyFlushDone chan bool // Signal when history flush is complete (buffered)
@@ -97,6 +98,7 @@ func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserS
97 guiBundle: "",98 guiBundle: "",
98 zoomCache: cache.NewZoomCache(queries),99 zoomCache: cache.NewZoomCache(queries),
99 certCache: cache.NewCertValidationsCache(queries),100 certCache: cache.NewCertValidationsCache(queries),
101+ favoritesCache: cache.NewFavoritesCache(queries),
100 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults102 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults
101 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates103 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
102 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking104 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking
@@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error {
576 return nil578 return nil
577 }579 }
578 580
581+// LoadFavoritesCacheFromDB loads all favorites from the database into the cache.
582+// This is typically called once at startup for fast RAM-first access.
583+func (s *BrowserService) LoadFavoritesCacheFromDB(ctx context.Context) error {
584+ if err := s.favoritesCache.Load(ctx); err != nil {
585+ return fmt.Errorf("failed to load favorites: %w", err)
586+ }
587+
588+ loadedCount := len(s.favoritesCache.List())
589+ log.Printf("Loaded %d favorites into cache", loadedCount)
590+ return nil
591+}
592+
593+// FavoriteEntry represents a favorite for the frontend (similar to HistoryEntry).
594+type FavoriteEntry struct {
595+ ID int64 `json:"id"`
596+ URL string `json:"url"`
597+ Title string `json:"title"`
598+ FaviconURL string `json:"favicon_url"`
599+ Position int64 `json:"position"`
600+}
601+
602+// GetFavorites returns all favorites ordered by position.
603+func (s *BrowserService) GetFavorites(ctx context.Context) ([]FavoriteEntry, error) {
604+ favorites := s.favoritesCache.GetAll()
605+
606+ result := make([]FavoriteEntry, len(favorites))
607+ for i, fav := range favorites {
608+ result[i] = FavoriteEntry{
609+ ID: fav.ID,
610+ URL: fav.Url,
611+ Title: fav.Title.String,
612+ FaviconURL: fav.FaviconUrl.String,
613+ Position: fav.Position,
614+ }
615+ }
616+
617+ return result, nil
618+}
619+
620+// IsFavorite checks if a URL is in the favorites list.
621+func (s *BrowserService) IsFavorite(ctx context.Context, url string) (bool, error) {
622+ if url == "" {
623+ return false, nil
624+ }
625+
626+ _, exists := s.favoritesCache.Get(url)
627+ return exists, nil
628+}
629+
630+// ToggleFavorite adds or removes a URL from favorites.
631+// Returns true if the URL was added, false if it was removed.
632+func (s *BrowserService) ToggleFavorite(ctx context.Context, url, title, faviconURL string) (bool, error) {
633+ if url == "" {
634+ return false, fmt.Errorf("URL cannot be empty")
635+ }
636+
637+ // Check if already favorited
638+ _, exists := s.favoritesCache.Get(url)
639+
640+ if exists {
641+ // Remove from favorites
642+ if err := s.favoritesCache.Delete(url); err != nil {
643+ return false, fmt.Errorf("failed to remove favorite: %w", err)
644+ }
645+ log.Printf("Removed favorite: %s", url)
646+ return false, nil
647+ }
648+
649+ // Add to favorites
650+ favorite := db.Favorite{
651+ Url: url,
652+ Title: sql.NullString{String: title, Valid: title != ""},
653+ FaviconUrl: sql.NullString{String: faviconURL, Valid: faviconURL != ""},
654+ }
655+
656+ if err := s.favoritesCache.Set(url, favorite); err != nil {
657+ return false, fmt.Errorf("failed to add favorite: %w", err)
658+ }
659+
660+ log.Printf("Added favorite: %s", url)
661+ return true, nil
662+}
663+
579 // FlushAllCaches flushes all in-memory caches to ensure pending writes complete.664 // FlushAllCaches flushes all in-memory caches to ensure pending writes complete.
580 // This is typically called during shutdown to prevent data loss.665 // This is typically called during shutdown to prevent data loss.
581 func (s *BrowserService) FlushAllCaches(ctx context.Context) error {666 func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
@@ -589,6 +674,11 @@ func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
589 return fmt.Errorf("failed to flush cert cache: %w", err)674 return fmt.Errorf("failed to flush cert cache: %w", err)
590 }675 }
591 676
677+ // Flush favorites cache
678+ if err := s.favoritesCache.Flush(); err != nil {
679+ return fmt.Errorf("failed to flush favorites cache: %w", err)
680+ }
681+
592 // Save fuzzy cache to binary file for instant dmenu access682 // Save fuzzy cache to binary file for instant dmenu access
593 s.fuzzyCache.OnApplicationExit(ctx)683 s.fuzzyCache.OnApplicationExit(ctx)
594 684

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