v0.23.3 fc8669a

state.svelte.ts RawHistoryFolder

1// ═══════════════════════════════════════════════════════════════════════════════
2// Homepage Global State (Svelte 5 Runes)
3// Single module-level $state with exported store object
4// ═══════════════════════════════════════════════════════════════════════════════
5
6import type {
7  HistoryEntry,
8  TimelineGroup,
9  DomainStat,
10  DailyVisitCount,
11  HourlyDistribution,
12  Favorite,
13  Folder,
14  Tag,
15  Analytics,
16  PanelType,
17  HistoryCleanupRange,
18} from './types';
19
20// ═══════════════════════════════════════════════════════════════════════════════
21// HISTORY STATE
22// ═══════════════════════════════════════════════════════════════════════════════
23
24let history = $state<HistoryEntry[]>([]);
25let timeline = $state<TimelineGroup[]>([]);
26let historyLoading = $state(true);
27let historyOffset = $state(0);
28let hasMoreHistory = $state(true);
29let historySearchQuery = $state('');
30let historySearchResults = $state<HistoryEntry[]>([]);
31let historySearching = $state(false);
32
33// ═══════════════════════════════════════════════════════════════════════════════
34// FAVORITES STATE
35// ═══════════════════════════════════════════════════════════════════════════════
36
37let favorites = $state<Favorite[]>([]);
38let folders = $state<Folder[]>([]);
39let tags = $state<Tag[]>([]);
40let favoritesLoading = $state(true);
41let selectedFolderId = $state<number | null>(null);
42let selectedTagIds = $state<number[]>([]);
43let editingFavorite = $state<Favorite | null>(null);
44
45// ═══════════════════════════════════════════════════════════════════════════════
46// ANALYTICS STATE
47// ═══════════════════════════════════════════════════════════════════════════════
48
49let analytics = $state<Analytics | null>(null);
50let analyticsLoading = $state(true);
51let domainStats = $state<DomainStat[]>([]);
52let dailyVisits = $state<DailyVisitCount[]>([]);
53let hourlyDistribution = $state<HourlyDistribution[]>([]);
54
55// ═══════════════════════════════════════════════════════════════════════════════
56// UI STATE
57// ═══════════════════════════════════════════════════════════════════════════════
58
59let activePanel = $state<PanelType>('history');
60let commandPaletteOpen = $state(false);
61let commandPaletteQuery = $state('');
62let focusedIndex = $state(-1); // -1 means no item focused (focus on search input)
63let confirmModalOpen = $state(false);
64let confirmModalMessage = $state('');
65let confirmModalAction = $state<(() => void) | null>(null);
66let cleanupModalOpen = $state(false);
67let cleanupRange = $state<HistoryCleanupRange>('hour');
68let pendingKeyPrefix = $state<string | null>(null);
69
70// ═══════════════════════════════════════════════════════════════════════════════
71// HELPER FUNCTIONS
72// ═══════════════════════════════════════════════════════════════════════════════
73
74function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
75  const groups = new Map<string, HistoryEntry[]>();
76  const today = new Date();
77  today.setHours(0, 0, 0, 0);
78  const yesterday = new Date(today);
79  yesterday.setDate(yesterday.getDate() - 1);
80
81  for (const entry of entries) {
82    const date = new Date(entry.last_visited);
83    const dateKey = date.toISOString().split('T')[0] ?? '';
84    if (!groups.has(dateKey)) {
85      groups.set(dateKey, []);
86    }
87    groups.get(dateKey)!.push(entry);
88  }
89
90  const result: TimelineGroup[] = [];
91  for (const [dateKey, items] of groups) {
92    const date = new Date(dateKey);
93    let label: string;
94
95    if (date >= today) {
96      label = 'Today';
97    } else if (date >= yesterday) {
98      label = 'Yesterday';
99    } else {
100      label = date.toLocaleDateString(undefined, {
101        weekday: 'short',
102        month: 'short',
103        day: 'numeric',
104      });
105    }
106
107    result.push({ date: dateKey, label, entries: items });
108  }
109
110  return result.sort((a, b) => b.date.localeCompare(a.date));
111}
112
113// ═══════════════════════════════════════════════════════════════════════════════
114// EXPORTED STORE
115// ═══════════════════════════════════════════════════════════════════════════════
116
117export const homepageState = {
118  // ─────────────────────────────────────────────────────────────────────────────
119  // History Getters
120  // ─────────────────────────────────────────────────────────────────────────────
121  get history() { return history; },
122  get timeline() { return timeline; },
123  get historyLoading() { return historyLoading; },
124  get historyOffset() { return historyOffset; },
125  get hasMoreHistory() { return hasMoreHistory; },
126  get historySearchQuery() { return historySearchQuery; },
127  get historySearchResults() { return historySearchResults; },
128  get historySearching() { return historySearching; },
129
130  // ─────────────────────────────────────────────────────────────────────────────
131  // Favorites Getters
132  // ─────────────────────────────────────────────────────────────────────────────
133  get favorites() { return favorites; },
134  get folders() { return folders; },
135  get tags() { return tags; },
136  get favoritesLoading() { return favoritesLoading; },
137  get selectedFolderId() { return selectedFolderId; },
138  get selectedTagIds() { return selectedTagIds; },
139  get editingFavorite() { return editingFavorite; },
140
141  // ─────────────────────────────────────────────────────────────────────────────
142  // Analytics Getters
143  // ─────────────────────────────────────────────────────────────────────────────
144  get analytics() { return analytics; },
145  get analyticsLoading() { return analyticsLoading; },
146  get domainStats() { return domainStats; },
147  get dailyVisits() { return dailyVisits; },
148  get hourlyDistribution() { return hourlyDistribution; },
149
150  // ─────────────────────────────────────────────────────────────────────────────
151  // UI Getters
152  // ─────────────────────────────────────────────────────────────────────────────
153  get activePanel() { return activePanel; },
154  get commandPaletteOpen() { return commandPaletteOpen; },
155  get commandPaletteQuery() { return commandPaletteQuery; },
156  get focusedIndex() { return focusedIndex; },
157  get confirmModalOpen() { return confirmModalOpen; },
158  get confirmModalMessage() { return confirmModalMessage; },
159  get cleanupModalOpen() { return cleanupModalOpen; },
160  get cleanupRange() { return cleanupRange; },
161  get pendingKeyPrefix() { return pendingKeyPrefix; },
162
163  // ─────────────────────────────────────────────────────────────────────────────
164  // Computed Properties
165  // ─────────────────────────────────────────────────────────────────────────────
166  get filteredFavorites(): Favorite[] {
167    let result = favorites;
168
169    // Filter by folder
170    if (selectedFolderId !== null) {
171      result = result.filter(f => f.folder_id === selectedFolderId);
172    }
173
174    // Filter by tags
175    if (selectedTagIds.length > 0) {
176      result = result.filter(f =>
177        f.tags?.some(t => selectedTagIds.includes(t.id))
178      );
179    }
180
181    return result;
182  },
183
184  get currentList(): HistoryEntry[] | Favorite[] {
185    switch (activePanel) {
186      case 'history':
187        return historySearchQuery ? historySearchResults : history;
188      case 'favorites':
189        return this.filteredFavorites;
190      default:
191        return [];
192    }
193  },
194
195  get currentListLength(): number {
196    return this.currentList.length;
197  },
198
199  // ─────────────────────────────────────────────────────────────────────────────
200  // History Actions
201  // ─────────────────────────────────────────────────────────────────────────────
202  setHistory(items: HistoryEntry[]) {
203    history = items;
204    timeline = groupHistoryByDate(items);
205  },
206
207  appendHistory(items: HistoryEntry[]) {
208    history = [...history, ...items];
209    timeline = groupHistoryByDate(history);
210  },
211
212  setHistoryLoading(loading: boolean) {
213    historyLoading = loading;
214  },
215
216  setHistoryOffset(offset: number) {
217    historyOffset = offset;
218  },
219
220  setHasMoreHistory(hasMore: boolean) {
221    hasMoreHistory = hasMore;
222  },
223
224  deleteHistoryEntry(id: number) {
225    history = history.filter(h => h.id !== id);
226    timeline = groupHistoryByDate(history);
227    historySearchResults = historySearchResults.filter(h => h.id !== id);
228  },
229
230  clearHistory() {
231    history = [];
232    timeline = [];
233    historySearchResults = [];
234    historyOffset = 0;
235    hasMoreHistory = true;
236  },
237
238  setHistorySearchQuery(q: string) {
239    historySearchQuery = q;
240    if (!q) {
241      historySearchResults = [];
242    }
243  },
244
245  setHistorySearchResults(results: HistoryEntry[]) {
246    historySearchResults = results;
247  },
248
249  setHistorySearching(searching: boolean) {
250    historySearching = searching;
251  },
252
253  // ─────────────────────────────────────────────────────────────────────────────
254  // Favorites Actions
255  // ─────────────────────────────────────────────────────────────────────────────
256  setFavorites(items: Favorite[]) {
257    favorites = items;
258  },
259
260  setFolders(items: Folder[]) {
261    folders = items;
262  },
263
264  setTags(items: Tag[]) {
265    tags = items;
266  },
267
268  setFavoritesLoading(loading: boolean) {
269    favoritesLoading = loading;
270  },
271
272  selectFolder(id: number | null) {
273    selectedFolderId = id;
274    focusedIndex = 0;
275  },
276
277  toggleTag(id: number) {
278    if (selectedTagIds.includes(id)) {
279      selectedTagIds = selectedTagIds.filter(t => t !== id);
280    } else {
281      selectedTagIds = [...selectedTagIds, id];
282    }
283    focusedIndex = 0;
284  },
285
286  clearTagSelection() {
287    selectedTagIds = [];
288    focusedIndex = 0;
289  },
290
291  setEditingFavorite(fav: Favorite | null) {
292    editingFavorite = fav;
293  },
294
295  updateFavorite(updated: Favorite) {
296    favorites = favorites.map(f => f.id === updated.id ? updated : f);
297  },
298
299  deleteFavorite(id: number) {
300    favorites = favorites.filter(f => f.id !== id);
301  },
302
303  addFolder(folder: Folder) {
304    folders = [...folders, folder];
305  },
306
307  updateFolder(updated: Folder) {
308    folders = folders.map(f => f.id === updated.id ? updated : f);
309  },
310
311  deleteFolder(id: number) {
312    folders = folders.filter(f => f.id !== id);
313    // Clear folder selection if deleted folder was selected
314    if (selectedFolderId === id) {
315      selectedFolderId = null;
316    }
317    // Update favorites that were in this folder
318    favorites = favorites.map(f =>
319      f.folder_id === id ? { ...f, folder_id: null } : f
320    );
321  },
322
323  addTag(tag: Tag) {
324    tags = [...tags, tag];
325  },
326
327  updateTag(updated: Tag) {
328    tags = tags.map(t => t.id === updated.id ? updated : t);
329  },
330
331  deleteTag(id: number) {
332    tags = tags.filter(t => t.id !== id);
333    selectedTagIds = selectedTagIds.filter(t => t !== id);
334    // Remove tag from all favorites
335    favorites = favorites.map(f => ({
336      ...f,
337      tags: f.tags?.filter(t => t.id !== id),
338    }));
339  },
340
341  // ─────────────────────────────────────────────────────────────────────────────
342  // Analytics Actions
343  // ─────────────────────────────────────────────────────────────────────────────
344  setAnalytics(data: Analytics | null) {
345    analytics = data;
346    if (data) {
347      domainStats = data.top_domains ?? [];
348      dailyVisits = data.daily_visits ?? [];
349      hourlyDistribution = data.hourly_distribution ?? [];
350    }
351  },
352
353  setAnalyticsLoading(loading: boolean) {
354    analyticsLoading = loading;
355  },
356
357  setDomainStats(stats: DomainStat[]) {
358    domainStats = stats;
359  },
360
361  // ─────────────────────────────────────────────────────────────────────────────
362  // UI Actions
363  // ─────────────────────────────────────────────────────────────────────────────
364  setActivePanel(panel: PanelType) {
365    activePanel = panel;
366    focusedIndex = -1; // Clear focus, let search input be focused
367    // Clear search when switching panels
368    historySearchQuery = '';
369    historySearchResults = [];
370  },
371
372  openCommandPalette() {
373    commandPaletteOpen = true;
374    commandPaletteQuery = '';
375  },
376
377  closeCommandPalette() {
378    commandPaletteOpen = false;
379    commandPaletteQuery = '';
380  },
381
382  setCommandPaletteQuery(q: string) {
383    commandPaletteQuery = q;
384  },
385
386  setFocusedIndex(i: number) {
387    focusedIndex = Math.max(-1, i);
388  },
389
390  focusNext() {
391    const maxIndex = this.currentListLength - 1;
392    if (maxIndex < 0) return;
393    // If unfocused (-1), go to first item
394    if (focusedIndex === -1) {
395      focusedIndex = 0;
396    } else {
397      focusedIndex = Math.min(focusedIndex + 1, maxIndex);
398    }
399  },
400
401  focusPrev() {
402    // If at first item or unfocused, stay at -1 (return to search)
403    if (focusedIndex <= 0) {
404      focusedIndex = -1;
405    } else {
406      focusedIndex = focusedIndex - 1;
407    }
408  },
409
410  focusFirst() {
411    focusedIndex = 0;
412  },
413
414  focusLast() {
415    focusedIndex = Math.max(0, this.currentListLength - 1);
416  },
417
418  clearFocus() {
419    focusedIndex = -1;
420  },
421
422  // Confirmation modal
423  showConfirm(message: string, action: () => void) {
424    confirmModalMessage = message;
425    confirmModalAction = action;
426    confirmModalOpen = true;
427  },
428
429  confirmAction() {
430    confirmModalAction?.();
431    confirmModalOpen = false;
432    confirmModalAction = null;
433    confirmModalMessage = '';
434  },
435
436  cancelConfirm() {
437    confirmModalOpen = false;
438    confirmModalAction = null;
439    confirmModalMessage = '';
440  },
441
442  // Cleanup modal
443  openCleanupModal() {
444    cleanupModalOpen = true;
445    cleanupRange = 'hour';
446  },
447
448  closeCleanupModal() {
449    cleanupModalOpen = false;
450  },
451
452  setCleanupRange(range: HistoryCleanupRange) {
453    cleanupRange = range;
454  },
455
456  // Keyboard prefix state (for 'g' and 'D' prefix indicators)
457  setPendingKeyPrefix(prefix: string | null) {
458    pendingKeyPrefix = prefix;
459  },
460
461  // ─────────────────────────────────────────────────────────────────────────────
462  // Reset
463  // ─────────────────────────────────────────────────────────────────────────────
464  reset() {
465    // History
466    history = [];
467    timeline = [];
468    historyLoading = true;
469    historyOffset = 0;
470    hasMoreHistory = true;
471    historySearchQuery = '';
472    historySearchResults = [];
473    historySearching = false;
474
475    // Favorites
476    favorites = [];
477    folders = [];
478    tags = [];
479    favoritesLoading = true;
480    selectedFolderId = null;
481    selectedTagIds = [];
482    editingFavorite = null;
483
484    // Analytics
485    analytics = null;
486    analyticsLoading = true;
487    domainStats = [];
488    dailyVisits = [];
489    hourlyDistribution = [];
490
491    // UI
492    activePanel = 'history';
493    commandPaletteOpen = false;
494    commandPaletteQuery = '';
495    focusedIndex = -1;
496    confirmModalOpen = false;
497    confirmModalMessage = '';
498    confirmModalAction = null;
499    cleanupModalOpen = false;
500    cleanupRange = 'hour';
501  },
502};

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