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 @@ | |||
| 266 | 266 | ||
| 267 | 267 | ||
| 268 | 268 | ||
| 269 | - | onMount(() => { | |
| 269 | + | onMount(async () => { | |
| 270 | 270 | syncTheme(); | |
| 271 | 271 | observeThemeChanges(); | |
| 272 | 272 | ||
| @@ -281,6 +281,17 @@ | |||
| 281 | 281 | // Add global click listener | |
| 282 | 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 | 295 | try { | |
| 285 | 296 | // Set up global API for Go bridge | |
| 286 | 297 | 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 listener | 281 | // 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 bridge | 296 | // 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 @@ | |||
| 10 | 10 | ||
| 11 | 11 | // Reactive state | |
| 12 | 12 | let mode = $derived(omniboxStore.mode); | |
| 13 | + | let viewMode = $derived(omniboxStore.viewMode); | |
| 13 | 14 | let inputValue = $derived(omniboxStore.inputValue); | |
| 14 | 15 | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 15 | 16 | let suggestions = $derived(omniboxStore.suggestions); | |
| 17 | + | let favorites = $derived(omniboxStore.favorites); | |
| 16 | 18 | let matches = $derived(omniboxStore.matches); | |
| 17 | 19 | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | |
| 18 | 20 | ||
| @@ -117,8 +119,11 @@ | |||
| 117 | 119 | omniboxStore.setFaded(false); | |
| 118 | 120 | ||
| 119 | 121 | 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 | + | } | |
| 122 | 127 | } else if (mode === 'find') { | |
| 123 | 128 | // Immediate find for search | |
| 124 | 129 | findInPage(value); | |
| @@ -140,6 +145,26 @@ | |||
| 140 | 145 | handleEnterKey(event); | |
| 141 | 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 | 168 | case 'ArrowDown': | |
| 144 | 169 | case 'ArrowUp': | |
| 145 | 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 | 234 | // Handle arrow key navigation | |
| 186 | 235 | function handleArrowKeys(key: string) { | |
| 187 | 236 | const totalItems = mode === 'omnibox' ? suggestions.length : matches.length; | |
| @@ -10,9 +10,11 @@ | |||||
| 10 | 10 | ||||
| 11 | // Reactive state | 11 | // 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 omnibox | 122 | + | // 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 search | 128 | // 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 navigation | 234 | // 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 @@ | |||
| 9 | 9 | ||
| 10 | 10 | // Reactive state | |
| 11 | 11 | let suggestions = $derived(omniboxStore.suggestions); | |
| 12 | + | let favorites = $derived(omniboxStore.favorites); | |
| 13 | + | let viewMode = $derived(omniboxStore.viewMode); | |
| 12 | 14 | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 15 | + | let inputValue = $derived(omniboxStore.inputValue); | |
| 13 | 16 | let hasContent = $state(false); | |
| 14 | 17 | ||
| 15 | - | // Update hasContent when suggestions change | |
| 18 | + | // Debug effect to log favorites changes | |
| 16 | 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 | 49 | // Handle suggestion item mouse enter | |
| 21 | 50 | function handleItemMouseEnter(index: number) { | |
| 22 | 51 | omniboxStore.setSelectedIndex(index); | |
| @@ -90,25 +119,57 @@ | |||
| 90 | 119 | role="listbox" | |
| 91 | 120 | aria-label="Search suggestions" | |
| 92 | 121 | > | |
| 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 | + | > | |
| 107 | 168 | <!-- Favicon chip --> | |
| 108 | - | {#if suggestion.favicon} | |
| 169 | + | {#if favicon} | |
| 109 | 170 | <div class="suggestion-favicon"> | |
| 110 | 171 | <img | |
| 111 | - | src={suggestion.favicon} | |
| 172 | + | src={favicon} | |
| 112 | 173 | alt="" | |
| 113 | 174 | class="suggestion-favicon-img" | |
| 114 | 175 | loading="lazy" | |
| @@ -136,6 +197,7 @@ | |||
| 136 | 197 | </div> | |
| 137 | 198 | </div> | |
| 138 | 199 | {/each} | |
| 200 | + | {/if} | |
| 139 | 201 | </div> | |
| 140 | 202 | {/if} | |
| 141 | 203 | ||
| @@ -151,11 +213,71 @@ | |||
| 151 | 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 | 276 | .suggestion-item { | |
| 155 | 277 | display: flex; | |
| 156 | 278 | gap: 0.75rem; | |
| 157 | 279 | align-items: center; | |
| 158 | - | padding: 0.65rem 0.85rem; | |
| 280 | + | padding: 0.85rem 0.85rem; | |
| 159 | 281 | border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent); | |
| 160 | 282 | cursor: pointer; | |
| 161 | 283 | transition: background-color 100ms ease, border-left-color 100ms ease; | |
| @@ -169,6 +291,11 @@ | |||
| 169 | 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 | 299 | .suggestion-item.selected, | |
| 173 | 300 | .suggestion-item:hover, | |
| 174 | 301 | .suggestion-item:focus-visible { | |
| @@ -178,6 +305,13 @@ | |||
| 178 | 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 | 315 | .suggestion-item.selected { | |
| 182 | 316 | font-weight: 500; | |
| 183 | 317 | } | |
| @@ -9,14 +9,43 @@ | |||||
| 9 | 9 | ||||
| 10 | // Reactive state | 10 | // 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 change | 18 | + | // 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 enter | 49 | // 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 | - | <div | 126 | + | </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 | <img | 171 | <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 @@ | |||
| 4 | 4 | * 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 | 8 | import { omniboxStore } from "./stores.svelte.ts"; | |
| 9 | 9 | ||
| 10 | 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 | 178 | // Suggestions are returned via setSuggestions() when native handler responds | |
| 122 | 179 | } | |
| 123 | 180 | ||
| @@ -4,7 +4,7 @@ | |||||
| 4 | * TypeScript interface for Go-JavaScript communication | 4 | * 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 responds | 178 | // 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 @@ | |||
| 6 | 6 | ||
| 7 | 7 | import type { | |
| 8 | 8 | OmniboxMode, | |
| 9 | + | ViewMode, | |
| 9 | 10 | Suggestion, | |
| 11 | + | Favorite, | |
| 10 | 12 | FindMatch, | |
| 11 | 13 | HighlightNode, | |
| 12 | 14 | OmniboxConfig, | |
| @@ -20,10 +22,25 @@ const DEFAULT_CONFIG: Required<OmniboxConfig> = { | |||
| 20 | 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 | 38 | // Global state using Svelte 5 runes | |
| 24 | 39 | let visible = $state(false); | |
| 25 | 40 | let mode = $state<OmniboxMode>("omnibox"); | |
| 41 | + | let viewMode = $state<ViewMode>(loadViewMode()); | |
| 26 | 42 | let suggestions = $state<Suggestion[]>([]); | |
| 43 | + | let favorites = $state<Favorite[]>([]); | |
| 27 | 44 | let matches = $state<FindMatch[]>([]); | |
| 28 | 45 | let selectedIndex = $state(-1); | |
| 29 | 46 | let activeIndex = $state(-1); | |
| @@ -45,9 +62,15 @@ export const omniboxStore = { | |||
| 45 | 62 | get mode() { | |
| 46 | 63 | return mode; | |
| 47 | 64 | }, | |
| 65 | + | get viewMode() { | |
| 66 | + | return viewMode; | |
| 67 | + | }, | |
| 48 | 68 | get suggestions() { | |
| 49 | 69 | return suggestions; | |
| 50 | 70 | }, | |
| 71 | + | get favorites() { | |
| 72 | + | return favorites; | |
| 73 | + | }, | |
| 51 | 74 | get matches() { | |
| 52 | 75 | return matches; | |
| 53 | 76 | }, | |
| @@ -78,19 +101,28 @@ export const omniboxStore = { | |||
| 78 | 101 | ||
| 79 | 102 | // Computed getters | |
| 80 | 103 | 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; | |
| 85 | 109 | }, | |
| 86 | 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 | 117 | get selectedItem() { | |
| 90 | 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 | 124 | ? suggestions[selectedIndex] | |
| 93 | - | : matches[selectedIndex]; | |
| 125 | + | : favorites[selectedIndex]; | |
| 94 | 126 | }, | |
| 95 | 127 | ||
| 96 | 128 | // Actions | |
| @@ -242,10 +274,32 @@ export const omniboxStore = { | |||
| 242 | 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 | 297 | reset() { | |
| 246 | 298 | visible = false; | |
| 247 | 299 | mode = "omnibox"; | |
| 300 | + | // Keep viewMode persistent - it's user preference | |
| 248 | 301 | suggestions = []; | |
| 302 | + | favorites = []; | |
| 249 | 303 | matches = []; | |
| 250 | 304 | selectedIndex = -1; | |
| 251 | 305 | 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 runes | 38 | // 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 getters | 102 | // 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 | // Actions | 128 | // 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 { | |||
| 9 | 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 | 20 | export interface SearchShortcut { | |
| 13 | 21 | url: string; | |
| 14 | 22 | description: string; | |
| 15 | 23 | } | |
| 16 | 24 | ||
| 17 | 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 | 27 | url?: string; | |
| 20 | 28 | q?: string; | |
| 21 | 29 | limit?: number; | |
| 30 | + | title?: string; | |
| 31 | + | faviconURL?: string; | |
| 22 | 32 | } | |
| 23 | 33 | ||
| 24 | 34 | export interface FindMatch { | |
| @@ -32,6 +42,7 @@ export interface HighlightNode { | |||
| 32 | 42 | } | |
| 33 | 43 | ||
| 34 | 44 | export type OmniboxMode = "omnibox" | "find"; | |
| 45 | + | export type ViewMode = "history" | "favorites"; | |
| 35 | 46 | ||
| 36 | 47 | export interface OmniboxConfig { | |
| 37 | 48 | maxMatches?: number; | |
| @@ -42,7 +53,9 @@ export interface OmniboxConfig { | |||
| 42 | 53 | export interface OmniboxState { | |
| 43 | 54 | visible: boolean; | |
| 44 | 55 | mode: OmniboxMode; | |
| 56 | + | viewMode: ViewMode; | |
| 45 | 57 | suggestions: Suggestion[]; | |
| 58 | + | favorites: Favorite[]; | |
| 46 | 59 | matches: FindMatch[]; | |
| 47 | 60 | selectedIndex: number; | |
| 48 | 61 | 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 @@ | |||
| 84 | 84 | }, 1500); | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 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'); | |
| 100 | 104 | } | |
| 101 | 105 | ||
| 102 | 106 | // 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 immediately | 87 | + | // Listen for toast events from main world bridge |
| 88 | - | // This must happen outside onMount to ensure functions are available immediately | 88 | + | 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_showZoomToast | 98 | + | 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 changes | 106 | // Effect that runs when toasts array changes | ||
M gui/src/injected/bootstrap.ts +7 -15
| @@ -181,22 +181,14 @@ export function bootstrapGUI(): void { | |||
| 181 | 181 | console.log("[GUI] DOM ready, starting initialization"); | |
| 182 | 182 | let toastInitialized = false; | |
| 183 | 183 | ||
| 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(); | |
| 186 | 187 | 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; | |
| 200 | 192 | } | |
| 201 | 193 | ||
| 202 | 194 | 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 { | |||
| 32 | 32 | ) => number | void; | |
| 33 | 33 | __dumber_showZoomToast?: (level: number) => void; | |
| 34 | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | |
| 35 | + | __dumber_favorites?: (favorites: unknown[]) => void; | |
| 35 | 36 | __dumber_webview_id?: string | number; | |
| 36 | 37 | __dumber_is_active?: boolean; | |
| 37 | 38 | __dumber_teardown?: () => void; | |
| @@ -292,46 +293,104 @@ interface DumberAPI { | |||
| 292 | 293 | ); | |
| 293 | 294 | } | |
| 294 | 295 | ||
| 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>) => { | |
| 300 | 341 | 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 })); | |
| 312 | 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 | 357 | window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => { | |
| 318 | 358 | if (omniboxReady) { | |
| 319 | - | omniboxDispatch(suggestions); | |
| 359 | + | bridgeDispatch("dumber:omnibox:suggestions", { suggestions }); | |
| 360 | + | bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support | |
| 320 | 361 | } else { | |
| 321 | 362 | try { | |
| 322 | 363 | omniboxQueue.push(...suggestions); | |
| 323 | 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 | 385 | const handleOmniboxReady = () => { | |
| 330 | 386 | omniboxReady = true; | |
| 331 | 387 | if (omniboxQueue && omniboxQueue.length) { | |
| 332 | 388 | const items = omniboxQueue.slice(); | |
| 333 | 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 | 399 | document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady); | |
| 341 | 400 | }); | |
| 342 | 401 | ||
| 343 | - | // Unified omnibox API | |
| 402 | + | // Unified omnibox API for backward compatibility | |
| 344 | 403 | window.__dumber.omnibox = window.__dumber.omnibox || { | |
| 345 | 404 | suggestions: (suggestions: Suggestion[]) => { | |
| 346 | 405 | window.__dumber_omnibox_suggestions!(suggestions); | |
| @@ -824,6 +883,7 @@ interface DumberAPI { | |||
| 824 | 883 | window.__dumber_page_bridge_installed = false; | |
| 825 | 884 | window.__dumber_teardown = undefined; | |
| 826 | 885 | window.__dumber_omnibox_suggestions = undefined; | |
| 886 | + | window.__dumber_favorites = undefined; | |
| 827 | 887 | window.__dumber_showToast = undefined; | |
| 828 | 888 | window.__dumber_showZoomToast = undefined; | |
| 829 | 889 | 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 bridge | 296 | + | /** |
| 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 API | 402 | + | // 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 { | |||
| 186 | 186 | 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 | 198 | // Load fuzzy search cache in parallel for instant dmenu access | |
| 190 | 199 | g.Go(func() error { | |
| 191 | 200 | if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil { | |
| @@ -186,6 +186,15 @@ func (app *BrowserApp) loadCachesParallel(ctx context.Context) error { | |||||
| 186 | return nil | 186 | 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 access | 198 | // 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 { | |||
| 50 | 50 | // Wails fetch bridge | |
| 51 | 51 | ID string `json:"id"` | |
| 52 | 52 | Payload json.RawMessage `json:"payload"` | |
| 53 | + | // Favorites operations | |
| 54 | + | Title string `json:"title"` | |
| 55 | + | FaviconURL string `json:"faviconURL"` | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | // NewHandler creates a new message handler | |
| @@ -110,6 +113,12 @@ func (h *Handler) Handle(payload string) { | |||
| 110 | 113 | h.handleGetSearchShortcuts(msg) | |
| 111 | 114 | case "get_color_palettes": | |
| 112 | 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 | 577 | log.Printf("[messaging] Sending color palettes to JavaScript") | |
| 569 | 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 | + | } | |
| @@ -50,6 +50,9 @@ type Message struct { | |||||
| 50 | // Wails fetch bridge | 50 | // 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 handler | 58 | // 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 { | |||
| 36 | 36 | 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 | 51 | // DatabaseQuerier combines all database operation interfaces | |
| 40 | 52 | type DatabaseQuerier interface { | |
| 41 | 53 | ZoomQuerier | |
| 42 | 54 | HistoryQuerier | |
| 43 | 55 | CertificateQuerier | |
| 56 | + | FavoritesQuerier | |
| 44 | 57 | } | |
| 45 | 58 | ||
| 46 | 59 | // Ensure that *Queries implements DatabaseQuerier interface | |
| @@ -36,11 +36,24 @@ type CertificateQuerier interface { | |||||
| 36 | DeleteExpiredCertificateValidations(ctx context.Context) error | 36 | 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 interfaces | 51 | // DatabaseQuerier combines all database operation interfaces | ||
| 40 | type DatabaseQuerier interface { | 52 | type DatabaseQuerier interface { | ||
| 41 | ZoomQuerier | 53 | ZoomQuerier | ||
| 42 | HistoryQuerier | 54 | HistoryQuerier | ||
| 43 | CertificateQuerier | 55 | CertificateQuerier | ||
| 56 | + | FavoritesQuerier | |||
| 44 | } | 57 | } | ||
| 45 | 58 | ||||
| 46 | // Ensure that *Queries implements DatabaseQuerier interface | 59 | // Ensure that *Queries implements DatabaseQuerier interface | ||
M internal/db/models.go +10 -0
| @@ -17,6 +17,16 @@ type CertificateValidation struct { | |||
| 17 | 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 | 30 | type FuzzyCacheMetadatum struct { | |
| 21 | 31 | ID int64 `json:"id"` | |
| 22 | 32 | 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 { | |||
| 13 | 13 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |
| 14 | 14 | // Cleanup zoom level entries older than specified days | |
| 15 | 15 | 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 | 18 | DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error | |
| 17 | 19 | DeleteExpiredCertificateValidations(ctx context.Context) error | |
| 20 | + | // Delete a favorite by URL | |
| 21 | + | DeleteFavorite(ctx context.Context, url string) error | |
| 18 | 22 | DeleteHistory(ctx context.Context, id int64) error | |
| 19 | 23 | // Delete zoom level setting for a domain | |
| 20 | 24 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 25 | + | // Get all favorites ordered by position | |
| 26 | + | GetAllFavorites(ctx context.Context) ([]Favorite, error) | |
| 21 | 27 | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | |
| 22 | 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 | 33 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 24 | 34 | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 25 | 35 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| @@ -27,6 +37,8 @@ type Querier interface { | |||
| 27 | 37 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | |
| 28 | 38 | // Get zoom level for domain with default fallback | |
| 29 | 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 | 42 | ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) | |
| 31 | 43 | // List all zoom level settings ordered by most recently updated | |
| 32 | 44 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |
| @@ -34,6 +46,10 @@ type Querier interface { | |||
| 34 | 46 | // Set or update zoom level for a domain with validation | |
| 35 | 47 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | |
| 36 | 48 | 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 | 53 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |
| 38 | 54 | } | |
| 39 | 55 | ||
| @@ -13,13 +13,23 @@ type Querier interface { | |||||
| 13 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | 13 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | ||
| 14 | // Cleanup zoom level entries older than specified days | 14 | // Cleanup zoom level entries older than specified days | ||
| 15 | CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error | 15 | 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) error | 18 | DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error | ||
| 17 | DeleteExpiredCertificateValidations(ctx context.Context) error | 19 | 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) error | 22 | DeleteHistory(ctx context.Context, id int64) error | ||
| 19 | // Delete zoom level setting for a domain | 23 | // Delete zoom level setting for a domain | ||
| 20 | DeleteZoomLevel(ctx context.Context, domain string) error | 24 | 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 fallback | 38 | // 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 updated | 43 | // 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 validation | 46 | // Set or update zoom level for a domain with validation | ||
| 35 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | 47 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | ||
| 36 | StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error | 48 | 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) error | 53 | 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 { | |||
| 50 | 50 | guiBundle string | |
| 51 | 51 | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | |
| 52 | 52 | certCache *cache.CertValidationsCache // In-memory cache for certificate validations | |
| 53 | + | favoritesCache *cache.FavoritesCache // In-memory cache for favorites | |
| 53 | 54 | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu | |
| 54 | 55 | historyQueue chan historyUpdate // Queue for batched history writes | |
| 55 | 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 | 98 | guiBundle: "", | |
| 98 | 99 | zoomCache: cache.NewZoomCache(queries), | |
| 99 | 100 | certCache: cache.NewCertValidationsCache(queries), | |
| 101 | + | favoritesCache: cache.NewFavoritesCache(queries), | |
| 100 | 102 | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | |
| 101 | 103 | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |
| 102 | 104 | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |
| @@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |||
| 576 | 578 | 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 | 664 | // FlushAllCaches flushes all in-memory caches to ensure pending writes complete. | |
| 580 | 665 | // This is typically called during shutdown to prevent data loss. | |
| 581 | 666 | func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |
| @@ -589,6 +674,11 @@ func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |||
| 589 | 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 | 682 | // Save fuzzy cache to binary file for instant dmenu access | |
| 593 | 683 | s.fuzzyCache.OnApplicationExit(ctx) | |
| 594 | 684 | ||
| @@ -50,6 +50,7 @@ type BrowserService struct { | |||||
| 50 | guiBundle string | 50 | guiBundle string | ||
| 51 | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | 51 | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | ||
| 52 | certCache *cache.CertValidationsCache // In-memory cache for certificate validations | 52 | 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 dmenu | 54 | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu | ||
| 54 | historyQueue chan historyUpdate // Queue for batched history writes | 55 | 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 defaults | 102 | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | ||
| 101 | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | 103 | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | ||
| 102 | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | 104 | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | ||
| @@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |||||
| 576 | return nil | 578 | 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 access | 682 | // Save fuzzy cache to binary file for instant dmenu access | ||
| 593 | s.fuzzyCache.OnApplicationExit(ctx) | 683 | s.fuzzyCache.OnApplicationExit(ctx) | ||
| 594 | 684 | ||||