v0.26.1 75cd777

KeyCaptureModal.svelte RawHistoryFolder

1<script lang="ts">
2  import { onMount, untrack } from "svelte";
3  import { Button } from "$lib/components/ui/button";
4  import * as AlertDialog from "$lib/components/ui/alert-dialog";
5  import { Keyboard, X, Plus } from "@lucide/svelte";
6  import { formatKey } from "$lib/utils/keys";
7
8  type KeybindingEntry = {
9    action: string;
10    description: string;
11    keys: string[];
12    default_keys: string[];
13    is_custom: boolean;
14  };
15
16  interface Props {
17    binding: KeybindingEntry;
18    onSave: (keys: string[]) => void;
19    onCancel: () => void;
20  }
21
22  let { binding, onSave, onCancel }: Props = $props();
23
24  // Initialize with a plain copy of the binding keys using $state.snapshot()
25  // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value.
26  let newKeys = $state<string[]>(
27    untrack(() => $state.snapshot(binding.keys) as string[])
28  );
29  let isCapturing = $state(false);
30  let capturedKey = $state<string | null>(null);
31  let isSaving = $state(false);
32
33  // Symbol keys that are already shifted (pressing shift produces these characters)
34  // Don't add 'shift' modifier for these since they're self-describing
35  const SHIFTED_SYMBOLS = new Set([
36    "{", "}", "(", ")", "<", ">", "!", "@", "#", "$", "%", "^", "&", "*",
37    "+", "_", ":", '"', "?", "|", "~",
38  ]);
39
40  function handleKeyDown(e: KeyboardEvent) {
41    if (!isCapturing) return;
42    e.preventDefault();
43    e.stopPropagation();
44
45    const parts: string[] = [];
46    if (e.ctrlKey) parts.push("ctrl");
47    if (e.altKey) parts.push("alt");
48
49    // Ignore modifier-only presses
50    if (["control", "alt", "shift", "meta"].includes(e.key.toLowerCase())) {
51      return;
52    }
53
54    // Escape closes the modal
55    if (e.key === "Escape") {
56      onCancel();
57      return;
58    }
59
60    // Normalize key name
61    const key = e.key === " " ? "space" : e.key.toLowerCase();
62
63    // Only add shift modifier for letter keys and non-symbol keys
64    // Symbol keys like { } + etc. are already the shifted result
65    if (e.shiftKey && !SHIFTED_SYMBOLS.has(e.key)) {
66      parts.push("shift");
67    }
68
69    parts.push(key);
70    capturedKey = parts.join("+");
71    isCapturing = false;
72  }
73
74  function addCapturedKey() {
75    if (capturedKey && !newKeys.includes(capturedKey)) {
76      newKeys = [...newKeys, capturedKey];
77    }
78    capturedKey = null;
79  }
80
81  function removeKey(index: number) {
82    newKeys = newKeys.filter((_, i) => i !== index);
83  }
84
85  function handleSave() {
86    // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers
87    const keysToSave = $state.snapshot(newKeys) as string[];
88    // Auto-add any pending captured key before saving
89    if (capturedKey && !keysToSave.includes(capturedKey)) {
90      keysToSave.push(capturedKey);
91    }
92    isSaving = true;
93    try {
94      onSave(keysToSave);
95    } finally {
96      // Reset isSaving so modal remains dismissible if save fails
97      isSaving = false;
98    }
99  }
100
101  onMount(() => {
102    const handler = (e: KeyboardEvent) => handleKeyDown(e);
103    window.addEventListener("keydown", handler, true);
104    return () => window.removeEventListener("keydown", handler, true);
105  });
106</script>
107
108<AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}>
109  <AlertDialog.Content class="max-w-md">
110    <AlertDialog.Header>
111      <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
112      <AlertDialog.Description>
113        Configure keybindings for <strong>{binding.action}</strong>
114      </AlertDialog.Description>
115    </AlertDialog.Header>
116
117    <div class="space-y-4 py-4">
118      <!-- Current bindings -->
119      <div class="space-y-2">
120        <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
121          Current Bindings
122        </div>
123        <div class="flex flex-wrap gap-2">
124          {#each newKeys as key, i (key)}
125            <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
126              <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
127              <button
128                type="button"
129                onclick={() => removeKey(i)}
130                class="text-muted-foreground hover:text-foreground"
131              >
132                <X class="size-3" />
133              </button>
134            </div>
135          {/each}
136          {#if newKeys.length === 0}
137            <span class="text-sm text-muted-foreground">(no bindings)</span>
138          {/if}
139        </div>
140      </div>
141
142      <!-- Capture area -->
143      <div class="space-y-2">
144        <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
145          Add New Binding
146        </div>
147        <div class="flex gap-2">
148          <button
149            type="button"
150            onclick={() => { isCapturing = true; capturedKey = null; }}
151            class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}"
152          >
153            <Keyboard class="size-5" />
154            {#if isCapturing}
155              Press a key combination...
156            {:else if capturedKey}
157              <kbd class="font-mono">{formatKey(capturedKey)}</kbd>
158            {:else}
159              Click to capture key
160            {/if}
161          </button>
162          {#if capturedKey}
163            <Button variant="outline" onclick={addCapturedKey}>
164              <Plus class="mr-1 size-4" />
165              Add
166            </Button>
167          {/if}
168        </div>
169      </div>
170
171      <!-- Default hint -->
172      {#if binding.default_keys.length > 0}
173        <div class="text-xs text-muted-foreground">
174          Default: {binding.default_keys.map(formatKey).join(", ")}
175        </div>
176      {/if}
177    </div>
178
179    <AlertDialog.Footer>
180      <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
181      <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action>
182    </AlertDialog.Footer>
183  </AlertDialog.Content>
184</AlertDialog.Root>

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