v0.27.1 c78ce83

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

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