v0.27.2 5960dd8

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

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