1/**
2 * Omnibox State Management
3 *
4 * Svelte 5 rune-based state management for omnibox functionality
5 */
6
7import type {
8 OmniboxMode,
9 ViewMode,
10 Suggestion,
11 Favorite,
12 FindMatch,
13 HighlightNode,
14 OmniboxConfig,
15 SearchShortcut,
16} from "./types";
17
18// Default configuration
19const DEFAULT_CONFIG: Required<OmniboxConfig> = {
20 maxMatches: 2000,
21 debounceDelay: 120,
22 defaultLimit: 10,
23};
24
25// Load viewMode from localStorage or default to "history"
26function 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
38// Global state using Svelte 5 runes
39let visible = $state(false);
40let mode = $state<OmniboxMode>("omnibox");
41let viewMode = $state<ViewMode>(loadViewMode());
42let suggestions = $state<Suggestion[]>([]);
43let favorites = $state<Favorite[]>([]);
44let matches = $state<FindMatch[]>([]);
45let selectedIndex = $state(-1);
46let activeIndex = $state(-1);
47let faded = $state(false);
48let inputValue = $state("");
49let highlightNodes = $state<HighlightNode[]>([]);
50let prevOverflow = $state("");
51let debounceTimer = $state(0);
52let searchShortcuts = $state<Record<string, SearchShortcut>>({});
53
54// Configuration
55let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
56
57export const omniboxStore = {
58 // State getters
59 get visible() {
60 return visible;
61 },
62 get mode() {
63 return mode;
64 },
65 get viewMode() {
66 return viewMode;
67 },
68 get suggestions() {
69 return suggestions;
70 },
71 get favorites() {
72 return favorites;
73 },
74 get matches() {
75 return matches;
76 },
77 get selectedIndex() {
78 return selectedIndex;
79 },
80 get activeIndex() {
81 return activeIndex;
82 },
83 get faded() {
84 return faded;
85 },
86 get inputValue() {
87 return inputValue;
88 },
89 get highlightNodes() {
90 return highlightNodes;
91 },
92 get prevOverflow() {
93 return prevOverflow;
94 },
95 get config() {
96 return config;
97 },
98 get searchShortcuts() {
99 return searchShortcuts;
100 },
101
102 // Computed getters
103 get hasContent() {
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;
109 },
110 get totalItems() {
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;
116 },
117 get selectedItem() {
118 if (selectedIndex < 0) return null;
119 if (mode === "find") {
120 return matches[selectedIndex];
121 }
122 // In omnibox mode, return item from current view
123 return viewMode === "history"
124 ? suggestions[selectedIndex]
125 : favorites[selectedIndex];
126 },
127
128 // Actions
129 setVisible(value: boolean) {
130 if (value && !visible) {
131 // Store scroll state when opening
132 prevOverflow = document.documentElement.style.overflow;
133 document.documentElement.style.overflow = "hidden";
134 } else if (!value && visible) {
135 // Restore scroll state when closing
136 document.documentElement.style.overflow = prevOverflow || "";
137 prevOverflow = "";
138
139 // Clear find highlights when closing
140 if (mode === "find") {
141 this.clearHighlights();
142 }
143 }
144 visible = value;
145 },
146
147 setMode(newMode: OmniboxMode) {
148 // Clear previous mode's state
149 if (newMode === "omnibox") {
150 // Switching to omnibox mode - clear find state
151 this.clearHighlights();
152 } else if (newMode === "find") {
153 // Switching to find mode - clear suggestions
154 this.updateSuggestions([]);
155 }
156
157 mode = newMode;
158 selectedIndex = -1;
159 faded = false;
160 inputValue = ""; // Clear input for fresh start
161 },
162
163 setInputValue(value: string) {
164 inputValue = value;
165 },
166
167 setFaded(value: boolean) {
168 faded = value;
169 },
170
171 setSelectedIndex(index: number) {
172 const maxIndex = this.totalItems - 1;
173 if (maxIndex < 0) {
174 selectedIndex = -1;
175 } else {
176 selectedIndex = Math.max(0, Math.min(index, maxIndex));
177 }
178 },
179
180 selectNext() {
181 if (this.totalItems === 0) return;
182 const newIndex =
183 selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems;
184 this.setSelectedIndex(newIndex);
185 faded = true;
186 },
187
188 selectPrevious() {
189 if (this.totalItems === 0) return;
190 const newIndex =
191 selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1;
192 this.setSelectedIndex(newIndex);
193 faded = true;
194 },
195
196 open(newMode: OmniboxMode = "omnibox", initialQuery?: string) {
197 this.setMode(newMode);
198 this.setVisible(true);
199 if (typeof initialQuery === "string") {
200 inputValue = initialQuery;
201 }
202 },
203
204 close() {
205 this.setVisible(false);
206 faded = false;
207 },
208
209 toggle() {
210 if (!visible) {
211 // When opening via toggle (Ctrl+L), always open in omnibox mode
212 this.setMode("omnibox");
213 this.setVisible(true);
214 } else {
215 this.setVisible(false);
216 }
217 },
218
219 updateSuggestions(newSuggestions: Suggestion[]) {
220 suggestions = Array.isArray(newSuggestions) ? newSuggestions : [];
221 selectedIndex = -1;
222 },
223
224 updateMatches(newMatches: FindMatch[]) {
225 matches = Array.isArray(newMatches) ? newMatches : [];
226 selectedIndex = newMatches.length > 0 ? 0 : -1;
227 },
228
229 clearHighlights() {
230 try {
231 highlightNodes.forEach(({ span, text }) => {
232 const parent = span.parentNode;
233 if (parent) {
234 parent.replaceChild(text, span);
235 parent.normalize();
236 }
237 });
238 } catch (error) {
239 console.warn("Error clearing highlights:", error);
240 }
241 highlightNodes = [];
242 matches = [];
243 selectedIndex = -1;
244 activeIndex = -1;
245 },
246
247 addHighlightNode(node: HighlightNode) {
248 highlightNodes = [...highlightNodes, node];
249 },
250
251 setActiveIndex(index: number) {
252 activeIndex = index;
253 },
254
255 setDebounceTimer(timerId: number) {
256 if (debounceTimer) {
257 clearTimeout(debounceTimer);
258 }
259 debounceTimer = timerId;
260 },
261
262 clearDebounceTimer() {
263 if (debounceTimer) {
264 clearTimeout(debounceTimer);
265 debounceTimer = 0;
266 }
267 },
268
269 updateConfig(newConfig: Partial<OmniboxConfig>) {
270 config = { ...config, ...newConfig };
271 },
272
273 updateSearchShortcuts(shortcuts: Record<string, SearchShortcut>) {
274 searchShortcuts = shortcuts;
275 },
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
297 reset() {
298 visible = false;
299 mode = "omnibox";
300 // Keep viewMode persistent - it's user preference
301 suggestions = [];
302 favorites = [];
303 matches = [];
304 selectedIndex = -1;
305 activeIndex = -1;
306 faded = false;
307 inputValue = "";
308 prevOverflow = "";
309 this.clearHighlights();
310 this.clearDebounceTimer();
311 },
312};