v0.11.0 dc50343

stores.svelte.ts RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close