v0.26.2 3cd9d2e

KeybindingsTab.svelte RawHistoryFolder

1<script lang="ts">
2  import { onMount } from "svelte";
3  import { Button } from "$lib/components/ui/button";
4  import { Spinner } from "$lib/components/ui/spinner";
5  import * as Card from "$lib/components/ui/card";
6  import * as AlertDialog from "$lib/components/ui/alert-dialog";
7  import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte";
8  import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge";
9  import KeybindingGroup from "./KeybindingGroup.svelte";
10  import KeyCaptureModal from "./KeyCaptureModal.svelte";
11
12  type KeybindingEntry = {
13    action: string;
14    description: string;
15    keys: string[];
16    default_keys: string[];
17    is_custom: boolean;
18  };
19
20  type KeybindingGroupData = {
21    mode: string;
22    display_name: string;
23    bindings: KeybindingEntry[];
24    activation?: string;
25  };
26
27  type KeybindingsConfig = {
28    groups: KeybindingGroupData[];
29  };
30
31  type KeybindingConflict = {
32    conflicting_action: string;
33    conflicting_mode: string;
34    key: string;
35  };
36
37  let loading = $state(true);
38  let error = $state<string | null>(null);
39  let keybindings = $state<KeybindingsConfig | null>(null);
40  let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
41  let resetAllDialogOpen = $state(false);
42  let successMessage = $state<string | null>(null);
43  let conflicts = $state<KeybindingConflict[]>([]);
44  let hasPendingReset = $state(false);
45  let savingReset = $state(false);
46
47  // Type-safe callback setup helper
48  function setupCallback<T>(name: string, handler: (data: T) => void): void {
49    (window as unknown as Record<string, (data: T) => void>)[name] = handler;
50  }
51
52  async function loadKeybindings() {
53    loading = true;
54    error = null;
55
56    if (!getWebKitBridge()) {
57      error = "WebKit bridge not available";
58      loading = false;
59      return;
60    }
61
62    setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => {
63      keybindings = data;
64      loading = false;
65    });
66    setupCallback<string>("__dumber_keybindings_error", (msg) => {
67      error = typeof msg === "string" ? msg : "Failed to load keybindings";
68      loading = false;
69    });
70
71    try {
72      postMessage({
73        type: "get_keybindings",
74        webviewId: getWebViewId(),
75        payload: {},
76      });
77    } catch (err) {
78      error = err instanceof Error ? err.message : "Failed to load keybindings";
79      loading = false;
80    }
81  }
82
83  function startEdit(mode: string, action: string, entry: KeybindingEntry) {
84    editingBinding = { mode, action, entry };
85  }
86
87  function showSuccess(message: string) {
88    successMessage = message;
89    setTimeout(() => { successMessage = null; }, 3000);
90  }
91
92  async function saveBinding(keys: string[]) {
93    if (!editingBinding) return;
94
95    if (!getWebKitBridge()) {
96      error = "WebKit bridge not available";
97      return;
98    }
99
100    // Timeout to detect if backend never responds (message silently dropped)
101    const timeoutId = setTimeout(() => {
102      error = "Save timed out - backend did not respond";
103    }, 5000);
104
105    setupCallback<{ status: string; conflicts?: KeybindingConflict[] }>(
106      "__dumber_keybinding_set",
107      (response) => {
108        clearTimeout(timeoutId);
109        if (!response || response.status !== "success") {
110          error = "Failed to save keybinding: invalid response";
111          return;
112        }
113        conflicts = response.conflicts?.length ? response.conflicts : [];
114        loadKeybindings();
115        editingBinding = null;
116        showSuccess("Keybinding saved successfully");
117      }
118    );
119    setupCallback<string>("__dumber_keybinding_set_error", (msg) => {
120      clearTimeout(timeoutId);
121      error = typeof msg === "string" ? msg : "Failed to save keybinding";
122    });
123
124    // Create plain payload - extract primitive values explicitly to avoid any proxy issues
125    const mode = String(editingBinding.mode);
126    const action = String(editingBinding.action);
127    const payload = { mode, action, keys: [...keys] };
128
129    try {
130      postMessage({
131        type: "set_keybinding",
132        webviewId: getWebViewId(),
133        payload,
134      });
135    } catch (err) {
136      clearTimeout(timeoutId);
137      error = err instanceof Error ? err.message : "Failed to save keybinding";
138    }
139  }
140
141  async function resetBinding(mode: string, action: string) {
142    if (!getWebKitBridge()) return;
143
144    setupCallback<void>("__dumber_keybinding_reset", () => {
145      conflicts = [];
146      loadKeybindings();
147      showSuccess("Keybinding reset to default");
148    });
149    setupCallback<string>("__dumber_keybinding_reset_error", (msg) => {
150      error = typeof msg === "string" ? msg : "Failed to reset keybinding";
151    });
152
153    try {
154      postMessage({
155        type: "reset_keybinding",
156        webviewId: getWebViewId(),
157        payload: { mode, action },
158      });
159    } catch (err) {
160      error = err instanceof Error ? err.message : "Failed to reset keybinding";
161    }
162  }
163
164  // Reset All: update local state to defaults WITHOUT saving
165  function resetAllBindings() {
166    if (!keybindings) return;
167
168    // Reset each binding's keys to default_keys in local state
169    const resetGroups = keybindings.groups.map(group => ({
170      ...group,
171      bindings: group.bindings.map(binding => ({
172        ...binding,
173        keys: [...binding.default_keys],
174        is_custom: false,
175      })),
176    }));
177
178    keybindings = { groups: resetGroups };
179    hasPendingReset = true;
180    conflicts = [];
181    resetAllDialogOpen = false;
182    showSuccess("Preview: all keybindings reset to defaults. Click Save to apply.");
183  }
184
185  // Actually save the reset to backend
186  async function saveResetAll() {
187    if (!getWebKitBridge()) return;
188
189    savingReset = true;
190
191    setupCallback<void>("__dumber_keybindings_reset_all", () => {
192      hasPendingReset = false;
193      savingReset = false;
194      loadKeybindings();
195      showSuccess("All keybindings saved successfully");
196    });
197    setupCallback<string>("__dumber_keybindings_reset_all_error", (msg) => {
198      error = typeof msg === "string" ? msg : "Failed to save keybindings";
199      savingReset = false;
200    });
201
202    try {
203      postMessage({
204        type: "reset_all_keybindings",
205        webviewId: getWebViewId(),
206        payload: {},
207      });
208    } catch (err) {
209      error = err instanceof Error ? err.message : "Failed to save keybindings";
210      savingReset = false;
211    }
212  }
213
214  // Cancel pending reset and reload from backend
215  function cancelReset() {
216    hasPendingReset = false;
217    loadKeybindings();
218  }
219
220  onMount(() => {
221    loadKeybindings();
222  });
223</script>
224
225<Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
226  <Card.Header>
227    <div class="flex items-center justify-between">
228      <div>
229        <Card.Title>Keybindings</Card.Title>
230        <Card.Description>
231          Customize keyboard shortcuts for all modes.
232        </Card.Description>
233      </div>
234      <div class="flex gap-2">
235        {#if hasPendingReset}
236          <Button variant="outline" size="sm" onclick={cancelReset} disabled={savingReset}>
237            Cancel
238          </Button>
239          <Button size="sm" onclick={saveResetAll} disabled={savingReset}>
240            {savingReset ? "Saving…" : "Save"}
241          </Button>
242        {:else}
243          <Button variant="outline" size="sm" onclick={loadKeybindings}>
244            <RefreshCw class="mr-1 size-4" />
245            Refresh
246          </Button>
247          <AlertDialog.Root bind:open={resetAllDialogOpen}>
248            <AlertDialog.Trigger>
249              {#snippet child({ props })}
250                <Button variant="outline" size="sm" {...props}>
251                  <RotateCcw class="mr-1 size-4" />
252                  Reset All
253                </Button>
254              {/snippet}
255            </AlertDialog.Trigger>
256            <AlertDialog.Content>
257              <AlertDialog.Header>
258                <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
259                <AlertDialog.Description>
260                  This will preview all keybindings with their default values. You'll need to click Save to apply.
261                </AlertDialog.Description>
262              </AlertDialog.Header>
263              <AlertDialog.Footer>
264                <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
265                <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
266              </AlertDialog.Footer>
267            </AlertDialog.Content>
268          </AlertDialog.Root>
269        {/if}
270      </div>
271    </div>
272  </Card.Header>
273  <Card.Content class="space-y-6">
274    {#if hasPendingReset}
275      <div class="flex items-center gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
276        <AlertTriangle class="size-4" />
277        <span>Unsaved changes: all keybindings previewed with defaults. Click <strong>Save</strong> to apply or <strong>Cancel</strong> to discard.</span>
278      </div>
279    {/if}
280    {#if loading}
281      <div class="flex items-center justify-center py-8">
282        <Spinner class="size-6" />
283      </div>
284    {:else if error}
285      <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
286        <AlertTriangle class="size-4" />
287        {error}
288      </div>
289    {/if}
290    {#if successMessage}
291      <div class="flex items-center gap-2 rounded-md border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-600 dark:text-green-400">
292        <CheckCircle class="size-4" />
293        {successMessage}
294      </div>
295    {/if}
296    {#if conflicts.length > 0}
297      <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm">
298        <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
299          <AlertTriangle class="size-4" />
300          <span class="font-medium">Keybinding conflicts detected</span>
301        </div>
302        <ul class="mt-2 list-disc pl-6 text-muted-foreground">
303          {#each conflicts as conflict (conflict.key + conflict.conflicting_action)}
304            <li>
305              <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
306              <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
307            </li>
308          {/each}
309        </ul>
310        <button
311          type="button"
312          class="mt-2 text-xs text-muted-foreground hover:text-foreground"
313          onclick={() => conflicts = []}
314        >
315          Dismiss
316        </button>
317      </div>
318    {/if}
319    {#if !loading && !error && keybindings}
320      {#each keybindings.groups as group (group.mode)}
321        <KeybindingGroup
322          {group}
323          onEdit={(action, entry) => startEdit(group.mode, action, entry)}
324          onReset={(action) => resetBinding(group.mode, action)}
325        />
326      {/each}
327    {/if}
328    {#if !loading && !keybindings && !error}
329      <div class="text-center text-muted-foreground py-8">No keybindings available</div>
330    {/if}
331  </Card.Content>
332</Card.Root>
333
334{#if editingBinding}
335  <KeyCaptureModal
336    binding={editingBinding.entry}
337    onSave={saveBinding}
338    onCancel={() => (editingBinding = null)}
339  />
340{/if}

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