v0.26.2 3cd9d2e

bridge.ts RawHistoryFolder

1/**
2 * WebKit bridge utilities for communicating with the Go backend.
3 *
4 * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived).
5 * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it
6 * fails silently, dropping the message entirely. This module provides safe
7 * serialization that converts Proxies to plain objects before sending.
8 */
9
10export interface BridgeMessage {
11  type: string;
12  webviewId: number;
13  payload: Record<string, unknown>;
14}
15
16interface WebKitBridge {
17  postMessage: (msg: unknown) => void;
18}
19
20/**
21 * Get the WebKit message handler bridge, if available.
22 */
23export function getWebKitBridge(): WebKitBridge | null {
24  // eslint-disable-next-line @typescript-eslint/no-explicit-any
25  const bridge = (window as any).webkit?.messageHandlers?.dumber;
26  return bridge && typeof bridge.postMessage === "function" ? bridge : null;
27}
28
29/**
30 * Get the current webview ID assigned by the Go backend.
31 */
32export function getWebViewId(): number {
33  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34  return (window as any).__dumber_webview_id || 0;
35}
36
37/**
38 * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects.
39 * Uses JSON serialization which strips Proxy wrappers.
40 *
41 * @throws Error if the object contains non-serializable values (functions, circular refs, etc.)
42 */
43export function toPlainObject<T>(obj: T): T {
44  try {
45    return JSON.parse(JSON.stringify(obj));
46  } catch (err) {
47    const message = err instanceof Error ? err.message : String(err);
48    throw new Error(`Failed to serialize object for bridge: ${message}`);
49  }
50}
51
52/**
53 * Post a message to the Go backend via WebKit bridge.
54 *
55 * Automatically:
56 * - Converts Svelte 5 Proxy objects to plain objects
57 * - Adds webviewId if not provided
58 * - Logs errors if serialization fails
59 *
60 * @throws Error if bridge is not available or serialization fails
61 */
62export function postMessage(msg: BridgeMessage): void {
63  const bridge = getWebKitBridge();
64  if (!bridge) {
65    throw new Error("WebKit bridge not available");
66  }
67
68  // Ensure webviewId is set
69  if (!msg.webviewId) {
70    msg.webviewId = getWebViewId();
71  }
72
73  // Convert to plain object to strip Svelte 5 Proxies
74  // This is critical - WebKit silently drops messages with Proxy objects
75  const plainMsg = toPlainObject(msg);
76  bridge.postMessage(plainMsg);
77}
78
79/**
80 * Internal callback registry using Map for tracking pending requests.
81 * Callbacks must also be exposed on window for Go backend to call,
82 * but we track them here for proper cleanup and to prevent leaks.
83 */
84const pendingCallbacks = new Map<string, {
85  cleanup: () => void;
86  timeoutId: ReturnType<typeof setTimeout>;
87}>();
88
89let callbackCounter = 0;
90const CALLBACK_TIMEOUT_MS = 30000;
91
92/**
93 * Post a message and set up success/error callbacks with automatic cleanup.
94 * Uses unique IDs to prevent naming conflicts between concurrent requests.
95 *
96 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
97 * @param payload - Message payload (will be serialized to strip Proxies)
98 * @param successCallback - Base name for success callback (will be made unique)
99 * @param errorCallback - Base name for error callback (will be made unique)
100 * @param onSuccess - Handler for success response
101 * @param onError - Handler for error response
102 */
103export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
104  type: string,
105  payload: TPayload,
106  successCallback: string,
107  errorCallback: string,
108  onSuccess: (response: TResponse) => void,
109  onError: (error: string) => void,
110): void {
111  const id = ++callbackCounter;
112  const successKey = `${successCallback}_${id}`;
113  const errorKey = `${errorCallback}_${id}`;
114
115  // Cleanup removes from both window and our tracking Map
116  const cleanup = () => {
117    const entry = pendingCallbacks.get(successKey);
118    if (entry) {
119      clearTimeout(entry.timeoutId);
120      pendingCallbacks.delete(successKey);
121    }
122    // eslint-disable-next-line @typescript-eslint/no-explicit-any
123    delete (window as any)[successKey];
124    // eslint-disable-next-line @typescript-eslint/no-explicit-any
125    delete (window as any)[errorKey];
126  };
127
128  // Timeout prevents memory leaks if backend never responds
129  const timeoutId = setTimeout(() => {
130    cleanup();
131    onError("Request timed out");
132  }, CALLBACK_TIMEOUT_MS);
133
134  // Track in our Map for proper lifecycle management
135  pendingCallbacks.set(successKey, { cleanup, timeoutId });
136
137  // Expose on window for Go backend (it calls window[callbackName](response))
138  // eslint-disable-next-line @typescript-eslint/no-explicit-any
139  (window as any)[successKey] = (response: TResponse) => {
140    cleanup();
141    onSuccess(response);
142  };
143  // eslint-disable-next-line @typescript-eslint/no-explicit-any
144  (window as any)[errorKey] = (msg: unknown) => {
145    cleanup();
146    onError(typeof msg === "string" ? msg : "Unknown error");
147  };
148
149  try {
150    postMessage({
151      type,
152      webviewId: getWebViewId(),
153      payload: {
154        ...payload,
155        successCallback: successKey,
156        errorCallback: errorKey,
157      },
158    });
159  } catch (err) {
160    cleanup();
161    onError(err instanceof Error ? err.message : String(err));
162  }
163}

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