v0.23.3 fc8669a

messaging.ts RawHistoryFolder

1// ═══════════════════════════════════════════════════════════════════════════════
2// Homepage WebKit Bridge
3// Communication layer between frontend and Go backend via WebKit message handlers
4// ═══════════════════════════════════════════════════════════════════════════════
5
6import type {
7  MessageType,
8  HistoryEntry,
9  Folder,
10  Tag,
11  Favorite,
12  DomainStat,
13  Analytics,
14  HistoryCleanupRange,
15  FolderCreateRequest,
16  FolderUpdateRequest,
17  TagCreateRequest,
18  TagUpdateRequest,
19} from './types';
20import { homepageState } from './state.svelte';
21
22// ═══════════════════════════════════════════════════════════════════════════════
23// REQUEST TRACKING
24// ═══════════════════════════════════════════════════════════════════════════════
25
26interface PendingRequest<T = unknown> {
27  resolve: (value: T) => void;
28  reject: (error: Error) => void;
29  timeout: ReturnType<typeof setTimeout>;
30}
31
32const pendingRequests = new Map<string, PendingRequest>();
33const REQUEST_TIMEOUT_MS = 10_000;
34
35let callbacksInitialized = false;
36
37// ═══════════════════════════════════════════════════════════════════════════════
38// WEBKIT BRIDGE UTILITIES
39// ═══════════════════════════════════════════════════════════════════════════════
40
41function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
42  const bridge = (window as any).webkit?.messageHandlers?.dumber;
43  if (bridge && typeof bridge.postMessage === 'function') {
44    return bridge;
45  }
46  return null;
47}
48
49function getWebViewId(): number {
50  return (window as any).__dumber_webview_id || 0;
51}
52
53function generateRequestId(type: string): string {
54  return `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
55}
56
57// ═══════════════════════════════════════════════════════════════════════════════
58// CALLBACK INITIALIZATION
59// ═══════════════════════════════════════════════════════════════════════════════
60
61function initializeCallbacks(): void {
62  if (callbacksInitialized) return;
63
64  // Generic response handler - accepts a single Response object from Go backend
65  (window as any).__dumber_homepage_response = (
66    response: { requestId: string; success: boolean; data?: unknown; error?: string }
67  ) => {
68    const request = pendingRequests.get(response.requestId);
69    if (!request) return;
70
71    clearTimeout(request.timeout);
72    pendingRequests.delete(response.requestId);
73
74    if (response.success) {
75      request.resolve(response.data);
76    } else {
77      request.reject(new Error(response.error || 'Unknown error'));
78    }
79  };
80
81  // Legacy callbacks for backward compatibility
82  // These can receive either raw data or a Response object
83  (window as any).__dumber_history_timeline = (dataOrResponse: unknown) => {
84    handleLegacyOrResponseCallback(dataOrResponse);
85  };
86
87  (window as any).__dumber_folders = (dataOrResponse: unknown) => {
88    handleLegacyOrResponseCallback(dataOrResponse);
89  };
90
91  (window as any).__dumber_tags = (dataOrResponse: unknown) => {
92    handleLegacyOrResponseCallback(dataOrResponse);
93  };
94
95  (window as any).__dumber_favorites = (dataOrResponse: unknown) => {
96    handleLegacyOrResponseCallback(dataOrResponse);
97  };
98
99  (window as any).__dumber_analytics = (dataOrResponse: unknown) => {
100    handleLegacyOrResponseCallback(dataOrResponse);
101  };
102
103  (window as any).__dumber_domain_stats = (dataOrResponse: unknown) => {
104    handleLegacyOrResponseCallback(dataOrResponse);
105  };
106
107  (window as any).__dumber_history_search_results = (dataOrResponse: unknown) => {
108    handleLegacyOrResponseCallback(dataOrResponse);
109  };
110
111  (window as any).__dumber_history_deleted = (dataOrResponse: unknown) => {
112    handleLegacyOrResponseCallback(dataOrResponse);
113  };
114
115  (window as any).__dumber_history_cleared = (dataOrResponse: unknown) => {
116    handleLegacyOrResponseCallback(dataOrResponse);
117  };
118
119  (window as any).__dumber_error = (errorOrResponse: unknown) => {
120    // Handle both formats: direct error string or Response object with error
121    const response = errorOrResponse as { requestId?: string; error?: string } | string;
122    if (typeof response === 'string') {
123      // Legacy format: direct error string
124      console.error('[messaging] Legacy error:', response);
125    } else if (response && typeof response === 'object') {
126      // Response object format
127      const request = pendingRequests.get(response.requestId || 'default');
128      if (request) {
129        clearTimeout(request.timeout);
130        pendingRequests.delete(response.requestId || 'default');
131        request.reject(new Error(response.error || 'Unknown error'));
132      }
133    }
134  };
135
136  // Listen for favorites changes from omnibox (e.g., when user toggles favorite)
137  document.addEventListener('dumber:favorites-changed', () => {
138    console.log('[homepage] Favorites changed event received, refreshing...');
139    fetchFavorites();
140  });
141
142  callbacksInitialized = true;
143}
144
145// Handle both legacy raw data and new Response object format
146function handleLegacyOrResponseCallback(dataOrResponse: unknown): void {
147  // Check if it's a Response object (has requestId and success fields)
148  const response = dataOrResponse as { requestId?: string; success?: boolean; data?: unknown; error?: string };
149  if (response && typeof response === 'object' && 'requestId' in response && 'success' in response) {
150    // New Response object format
151    const request = pendingRequests.get(response.requestId || 'default');
152    if (request) {
153      clearTimeout(request.timeout);
154      pendingRequests.delete(response.requestId || 'default');
155      if (response.success) {
156        request.resolve(response.data);
157      } else {
158        request.reject(new Error(response.error || 'Unknown error'));
159      }
160    }
161  } else {
162    // Legacy format: raw data (for backward compatibility)
163    // Try to find any pending request (not ideal but maintains compatibility)
164    console.warn('[messaging] Received legacy callback format without requestId');
165  }
166}
167
168// ═══════════════════════════════════════════════════════════════════════════════
169// CORE SEND FUNCTION
170// ═══════════════════════════════════════════════════════════════════════════════
171
172async function sendMessage<T = unknown>(
173  type: MessageType,
174  payload?: Record<string, unknown>
175): Promise<T> {
176  initializeCallbacks();
177
178  const bridge = getWebKitBridge();
179  if (!bridge) {
180    throw new Error('WebKit message handler not available');
181  }
182
183  const requestId = generateRequestId(type);
184
185  return new Promise<T>((resolve, reject) => {
186    const timeout = setTimeout(() => {
187      pendingRequests.delete(requestId);
188      reject(new Error(`Request timeout: ${type}`));
189    }, REQUEST_TIMEOUT_MS);
190
191    pendingRequests.set(requestId, { resolve: resolve as any, reject, timeout });
192
193    try {
194      bridge.postMessage({
195        type,
196        webviewId: getWebViewId(),
197        payload: { requestId, ...payload },
198      });
199    } catch (error) {
200      clearTimeout(timeout);
201      pendingRequests.delete(requestId);
202      reject(error);
203    }
204  });
205}
206
207// ═══════════════════════════════════════════════════════════════════════════════
208// HISTORY API
209// ═══════════════════════════════════════════════════════════════════════════════
210
211export async function fetchHistoryTimeline(
212  limit: number = 50,
213  offset: number = 0
214): Promise<HistoryEntry[]> {
215  homepageState.setHistoryLoading(true);
216  try {
217    const data = await sendMessage<HistoryEntry[]>('history_timeline', {
218      limit,
219      offset,
220    });
221    const entries = Array.isArray(data) ? data : [];
222
223    if (offset === 0) {
224      homepageState.setHistory(entries);
225    } else {
226      homepageState.appendHistory(entries);
227    }
228
229    homepageState.setHistoryOffset(offset + entries.length);
230    homepageState.setHasMoreHistory(entries.length === limit);
231
232    return entries;
233  } catch (error) {
234    console.error('[messaging] fetchHistoryTimeline error:', error);
235    if (offset === 0) {
236      homepageState.setHistory([]);
237    }
238    homepageState.setHasMoreHistory(false);
239    return [];
240  } finally {
241    homepageState.setHistoryLoading(false);
242  }
243}
244
245export async function searchHistoryFTS(query: string): Promise<HistoryEntry[]> {
246  if (!query.trim()) {
247    homepageState.setHistorySearchResults([]);
248    return [];
249  }
250
251  homepageState.setHistorySearching(true);
252  try {
253    const data = await sendMessage<HistoryEntry[]>('history_search_fts', {
254      query,
255      limit: 100,
256    });
257    const results = Array.isArray(data) ? data : [];
258    homepageState.setHistorySearchResults(results);
259    return results;
260  } catch (error) {
261    console.error('[messaging] searchHistoryFTS error:', error);
262    homepageState.setHistorySearchResults([]);
263    return [];
264  } finally {
265    homepageState.setHistorySearching(false);
266  }
267}
268
269export async function deleteHistoryEntry(id: number): Promise<void> {
270  try {
271    await sendMessage('history_delete_entry', { id });
272    homepageState.deleteHistoryEntry(id);
273  } catch (error) {
274    console.error('[messaging] deleteHistoryEntry error:', error);
275    throw error;
276  }
277}
278
279export async function deleteHistoryByRange(range: HistoryCleanupRange): Promise<void> {
280  try {
281    await sendMessage('history_delete_range', { range });
282    // Refresh history after deletion
283    await fetchHistoryTimeline();
284  } catch (error) {
285    console.error('[messaging] deleteHistoryByRange error:', error);
286    throw error;
287  }
288}
289
290export async function clearAllHistory(): Promise<void> {
291  try {
292    await sendMessage('history_clear_all');
293    homepageState.clearHistory();
294  } catch (error) {
295    console.error('[messaging] clearAllHistory error:', error);
296    throw error;
297  }
298}
299
300export async function deleteHistoryByDomain(domain: string): Promise<void> {
301  try {
302    await sendMessage('history_delete_domain', { domain });
303    // Refresh history after deletion
304    await fetchHistoryTimeline();
305  } catch (error) {
306    console.error('[messaging] deleteHistoryByDomain error:', error);
307    throw error;
308  }
309}
310
311// ═══════════════════════════════════════════════════════════════════════════════
312// FOLDERS API
313// ═══════════════════════════════════════════════════════════════════════════════
314
315export async function fetchFolders(): Promise<Folder[]> {
316  try {
317    const data = await sendMessage<Folder[]>('folder_list');
318    const folders = Array.isArray(data) ? data : [];
319    homepageState.setFolders(folders);
320    return folders;
321  } catch (error) {
322    console.error('[messaging] fetchFolders error:', error);
323    homepageState.setFolders([]);
324    return [];
325  }
326}
327
328export async function createFolder(req: FolderCreateRequest): Promise<Folder> {
329  const folder = await sendMessage<Folder>('folder_create', { ...req });
330  homepageState.addFolder(folder);
331  return folder;
332}
333
334export async function updateFolder(req: FolderUpdateRequest): Promise<void> {
335  await sendMessage('folder_update', { ...req });
336  homepageState.updateFolder({
337    ...homepageState.folders.find(f => f.id === req.id)!,
338    name: req.name,
339    icon: req.icon ?? null,
340  });
341}
342
343export async function deleteFolder(id: number): Promise<void> {
344  await sendMessage('folder_delete', { id });
345  homepageState.deleteFolder(id);
346}
347
348// ═══════════════════════════════════════════════════════════════════════════════
349// TAGS API
350// ═══════════════════════════════════════════════════════════════════════════════
351
352export async function fetchTags(): Promise<Tag[]> {
353  try {
354    const data = await sendMessage<Tag[]>('tag_list');
355    const tags = Array.isArray(data) ? data : [];
356    homepageState.setTags(tags);
357    return tags;
358  } catch (error) {
359    console.error('[messaging] fetchTags error:', error);
360    homepageState.setTags([]);
361    return [];
362  }
363}
364
365export async function createTag(req: TagCreateRequest): Promise<Tag> {
366  const tag = await sendMessage<Tag>('tag_create', { ...req });
367  homepageState.addTag(tag);
368  return tag;
369}
370
371export async function updateTag(req: TagUpdateRequest): Promise<void> {
372  await sendMessage('tag_update', { ...req });
373  const existing = homepageState.tags.find(t => t.id === req.id);
374  if (existing) {
375    homepageState.updateTag({
376      ...existing,
377      name: req.name ?? existing.name,
378      color: req.color ?? existing.color,
379    });
380  }
381}
382
383export async function deleteTag(id: number): Promise<void> {
384  await sendMessage('tag_delete', { id });
385  homepageState.deleteTag(id);
386}
387
388export async function assignTag(favoriteId: number, tagId: number): Promise<void> {
389  await sendMessage('tag_assign', { favorite_id: favoriteId, tag_id: tagId });
390  // Update local state
391  const favorite = homepageState.favorites.find(f => f.id === favoriteId);
392  const tag = homepageState.tags.find(t => t.id === tagId);
393  if (favorite && tag) {
394    homepageState.updateFavorite({
395      ...favorite,
396      tags: [...(favorite.tags || []), tag],
397    });
398  }
399}
400
401export async function removeTag(favoriteId: number, tagId: number): Promise<void> {
402  await sendMessage('tag_remove', { favorite_id: favoriteId, tag_id: tagId });
403  // Update local state
404  const favorite = homepageState.favorites.find(f => f.id === favoriteId);
405  if (favorite) {
406    homepageState.updateFavorite({
407      ...favorite,
408      tags: favorite.tags?.filter(t => t.id !== tagId),
409    });
410  }
411}
412
413// ═══════════════════════════════════════════════════════════════════════════════
414// FAVORITES API
415// ═══════════════════════════════════════════════════════════════════════════════
416
417export async function fetchFavorites(): Promise<Favorite[]> {
418  homepageState.setFavoritesLoading(true);
419  try {
420    const data = await sendMessage<Favorite[]>('favorite_list');
421    const favorites = Array.isArray(data) ? data : [];
422    homepageState.setFavorites(favorites);
423    return favorites;
424  } catch (error) {
425    console.error('[messaging] fetchFavorites error:', error);
426    homepageState.setFavorites([]);
427    return [];
428  } finally {
429    homepageState.setFavoritesLoading(false);
430  }
431}
432
433export async function setFavoriteShortcut(
434  favoriteId: number,
435  shortcutKey: number | null
436): Promise<void> {
437  await sendMessage('favorite_set_shortcut', {
438    favorite_id: favoriteId,
439    shortcut_key: shortcutKey,
440  });
441  // Update local state
442  const favorite = homepageState.favorites.find(f => f.id === favoriteId);
443  if (favorite) {
444    // Clear shortcut from any other favorite that had this key
445    if (shortcutKey !== null) {
446      for (const f of homepageState.favorites) {
447        if (f.shortcut_key === shortcutKey && f.id !== favoriteId) {
448          homepageState.updateFavorite({ ...f, shortcut_key: null });
449        }
450      }
451    }
452    homepageState.updateFavorite({ ...favorite, shortcut_key: shortcutKey });
453  }
454}
455
456export async function getFavoriteByShortcut(shortcutKey: number): Promise<Favorite | null> {
457  try {
458    const data = await sendMessage<Favorite | null>('favorite_get_by_shortcut', {
459      shortcut_key: shortcutKey,
460    });
461    return data;
462  } catch (error) {
463    console.error('[messaging] getFavoriteByShortcut error:', error);
464    return null;
465  }
466}
467
468export async function setFavoriteFolder(
469  favoriteId: number,
470  folderId: number | null
471): Promise<void> {
472  await sendMessage('favorite_set_folder', {
473    favorite_id: favoriteId,
474    folder_id: folderId,
475  });
476  // Update local state
477  const favorite = homepageState.favorites.find(f => f.id === favoriteId);
478  if (favorite) {
479    homepageState.updateFavorite({ ...favorite, folder_id: folderId });
480  }
481}
482
483// ═══════════════════════════════════════════════════════════════════════════════
484// ANALYTICS API
485// ═══════════════════════════════════════════════════════════════════════════════
486
487export async function fetchAnalytics(): Promise<Analytics | null> {
488  homepageState.setAnalyticsLoading(true);
489  try {
490    const data = await sendMessage<Analytics>('history_analytics');
491    homepageState.setAnalytics(data);
492    return data;
493  } catch (error) {
494    console.error('[messaging] fetchAnalytics error:', error);
495    homepageState.setAnalytics(null);
496    return null;
497  } finally {
498    homepageState.setAnalyticsLoading(false);
499  }
500}
501
502export async function fetchDomainStats(limit: number = 20): Promise<DomainStat[]> {
503  try {
504    const data = await sendMessage<DomainStat[]>('history_domain_stats', { limit });
505    const stats = Array.isArray(data) ? data : [];
506    homepageState.setDomainStats(stats);
507    return stats;
508  } catch (error) {
509    console.error('[messaging] fetchDomainStats error:', error);
510    homepageState.setDomainStats([]);
511    return [];
512  }
513}
514
515// ═══════════════════════════════════════════════════════════════════════════════
516// INITIALIZATION
517// ═══════════════════════════════════════════════════════════════════════════════
518
519export async function initializeHomepage(): Promise<void> {
520  // Initialize callbacks first
521  initializeCallbacks();
522
523  // Fetch all initial data in parallel
524  await Promise.all([
525    fetchHistoryTimeline(),
526    fetchFolders(),
527    fetchTags(),
528    fetchFavorites(),
529    fetchAnalytics(),
530  ]);
531}
532
533// ═══════════════════════════════════════════════════════════════════════════════
534// NAVIGATION
535// ═══════════════════════════════════════════════════════════════════════════════
536
537export function navigateTo(url: string): void {
538  window.location.href = url;
539}

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