detached a258d03

feat(webui): add shortcuts table component for managing search shortcuts

commit
a258d03c1de2791c144b281e06cc98fb851eb448
author
bnema · · 2025-12-21 13:53 UTC
parent
d178634
signature
unsigned
1 file · +170 -0
A webui/src/pages/config/ShortcutsTable.svelte +170 -0
@@ -0,0 +1,170 @@
1+<script lang="ts">
2+ import { Check, Plus, Trash2, X } from "@lucide/svelte";
3+ import { Button } from "$lib/components/ui/button";
4+ import { Input } from "$lib/components/ui/input";
5+ import * as Table from "$lib/components/ui/table";
6+
7+ type SearchShortcut = {
8+ url: string;
9+ description: string;
10+ };
11+
12+ interface Props {
13+ shortcuts: Record<string, SearchShortcut>;
14+ onUpdate: (shortcuts: Record<string, SearchShortcut>) => void;
15+ }
16+
17+ let { shortcuts, onUpdate }: Props = $props();
18+
19+ let editingKey = $state<string | null>(null);
20+ let draftKey = $state("");
21+ let draftUrl = $state("");
22+ let draftDescription = $state("");
23+
24+ let newKey = $state("");
25+ let newUrl = $state("");
26+ let newDescription = $state("");
27+
28+ const entries = $derived(
29+ Object.entries(shortcuts ?? {}).sort(([a], [b]) => a.localeCompare(b)),
30+ );
31+
32+ function startEdit(key: string, shortcut: SearchShortcut) {
33+ editingKey = key;
34+ draftKey = key;
35+ draftUrl = shortcut.url;
36+ draftDescription = shortcut.description;
37+ }
38+
39+ function cancelEdit() {
40+ editingKey = null;
41+ draftKey = "";
42+ draftUrl = "";
43+ draftDescription = "";
44+ }
45+
46+ function commitEdit() {
47+ if (!editingKey) return;
48+ const nextKey = draftKey.trim();
49+ const nextUrl = draftUrl.trim();
50+ const nextDescription = draftDescription.trim();
51+
52+ const updated: Record<string, SearchShortcut> = { ...shortcuts };
53+ delete updated[editingKey];
54+ if (nextKey) {
55+ updated[nextKey] = { url: nextUrl, description: nextDescription };
56+ }
57+ onUpdate(updated);
58+ cancelEdit();
59+ }
60+
61+ function removeShortcut(key: string) {
62+ const updated: Record<string, SearchShortcut> = { ...shortcuts };
63+ delete updated[key];
64+ onUpdate(updated);
65+ }
66+
67+ function addShortcut() {
68+ const key = newKey.trim();
69+ if (!key) return;
70+ const updated: Record<string, SearchShortcut> = { ...shortcuts };
71+ updated[key] = { url: newUrl.trim(), description: newDescription.trim() };
72+ onUpdate(updated);
73+ newKey = "";
74+ newUrl = "";
75+ newDescription = "";
76+ }
77+</script>
78+
79+<div class="space-y-4">
80+ <Table.Root class="rounded-md border border-border">
81+ <Table.Header>
82+ <Table.Row>
83+ <Table.Head class="w-[140px]">Prefix</Table.Head>
84+ <Table.Head>URL Template</Table.Head>
85+ <Table.Head>Description</Table.Head>
86+ <Table.Head class="w-[160px] text-right">Actions</Table.Head>
87+ </Table.Row>
88+ </Table.Header>
89+ <Table.Body>
90+ {#if entries.length === 0}
91+ <Table.Row>
92+ <Table.Cell colspan={4} class="py-8 text-center text-sm text-muted-foreground">
93+ No shortcuts yet. Add one below.
94+ </Table.Cell>
95+ </Table.Row>
96+ {/if}
97+ {#each entries as [key, shortcut] (key)}
98+ <Table.Row>
99+ {#if editingKey === key}
100+ <Table.Cell>
101+ <Input bind:value={draftKey} class="h-8" />
102+ </Table.Cell>
103+ <Table.Cell>
104+ <Input bind:value={draftUrl} class="h-8" />
105+ </Table.Cell>
106+ <Table.Cell>
107+ <Input bind:value={draftDescription} class="h-8" />
108+ </Table.Cell>
109+ <Table.Cell class="text-right">
110+ <div class="flex justify-end gap-2">
111+ <Button variant="ghost" size="icon" onclick={commitEdit} type="button" aria-label="Save">
112+ <Check size={16} />
113+ </Button>
114+ <Button variant="ghost" size="icon" onclick={cancelEdit} type="button" aria-label="Cancel">
115+ <X size={16} />
116+ </Button>
117+ </div>
118+ </Table.Cell>
119+ {:else}
120+ <Table.Cell class="font-mono text-sm">{key}</Table.Cell>
121+ <Table.Cell class="truncate max-w-[360px]">{shortcut.url}</Table.Cell>
122+ <Table.Cell class="text-muted-foreground">{shortcut.description}</Table.Cell>
123+ <Table.Cell class="text-right">
124+ <div class="flex justify-end gap-2">
125+ <Button
126+ variant="outline"
127+ size="sm"
128+ onclick={() => startEdit(key, shortcut)}
129+ type="button"
130+ >
131+ Edit
132+ </Button>
133+ <Button
134+ variant="ghost"
135+ size="icon"
136+ onclick={() => removeShortcut(key)}
137+ type="button"
138+ aria-label={`Delete ${key}`}
139+ >
140+ <Trash2 size={16} />
141+ </Button>
142+ </div>
143+ </Table.Cell>
144+ {/if}
145+ </Table.Row>
146+ {/each}
147+ </Table.Body>
148+ </Table.Root>
149+
150+ <div class="grid gap-3 md:grid-cols-[140px_1fr_1fr_140px] items-end">
151+ <div class="space-y-1">
152+ <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Prefix</span>
153+ <Input bind:value={newKey} placeholder="g" />
154+ </div>
155+ <div class="space-y-1">
156+ <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">URL Template</span>
157+ <Input bind:value={newUrl} placeholder="https://www.google.com/search?q=%s" />
158+ </div>
159+ <div class="space-y-1">
160+ <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Description</span>
161+ <Input bind:value={newDescription} placeholder="Google" />
162+ </div>
163+ <div class="flex md:justify-end">
164+ <Button variant="default" onclick={addShortcut} type="button" class="w-full md:w-auto">
165+ <Plus size={16} />
166+ Add Shortcut
167+ </Button>
168+ </div>
169+ </div>
170+</div>
@@ -0,0 +1,170 @@
1+<script lang="ts">
2+ import { Check, Plus, Trash2, X } from "@lucide/svelte";
3+ import { Button } from "$lib/components/ui/button";
4+ import { Input } from "$lib/components/ui/input";
5+ import * as Table from "$lib/components/ui/table";
6+
7+ type SearchShortcut = {
8+ url: string;
9+ description: string;
10+ };
11+
12+ interface Props {
13+ shortcuts: Record<string, SearchShortcut>;
14+ onUpdate: (shortcuts: Record<string, SearchShortcut>) => void;
15+ }
16+
17+ let { shortcuts, onUpdate }: Props = $props();
18+
19+ let editingKey = $state<string | null>(null);
20+ let draftKey = $state("");
21+ let draftUrl = $state("");
22+ let draftDescription = $state("");
23+
24+ let newKey = $state("");
25+ let newUrl = $state("");
26+ let newDescription = $state("");
27+
28+ const entries = $derived(
29+ Object.entries(shortcuts ?? {}).sort(([a], [b]) => a.localeCompare(b)),
30+ );
31+
32+ function startEdit(key: string, shortcut: SearchShortcut) {
33+ editingKey = key;
34+ draftKey = key;
35+ draftUrl = shortcut.url;
36+ draftDescription = shortcut.description;
37+ }
38+
39+ function cancelEdit() {
40+ editingKey = null;
41+ draftKey = "";
42+ draftUrl = "";
43+ draftDescription = "";
44+ }
45+
46+ function commitEdit() {
47+ if (!editingKey) return;
48+ const nextKey = draftKey.trim();
49+ const nextUrl = draftUrl.trim();
50+ const nextDescription = draftDescription.trim();
51+
52+ const updated: Record<string, SearchShortcut> = { ...shortcuts };
53+ delete updated[editingKey];
54+ if (nextKey) {
55+ updated[nextKey] = { url: nextUrl, description: nextDescription };
56+ }
57+ onUpdate(updated);
58+ cancelEdit();
59+ }
60+
61+ function removeShortcut(key: string) {
62+ const updated: Record<string, SearchShortcut> = { ...shortcuts };
63+ delete updated[key];
64+ onUpdate(updated);
65+ }
66+
67+ function addShortcut() {
68+ const key = newKey.trim();
69+ if (!key) return;
70+ const updated: Record<string, SearchShortcut> = { ...shortcuts };
71+ updated[key] = { url: newUrl.trim(), description: newDescription.trim() };
72+ onUpdate(updated);
73+ newKey = "";
74+ newUrl = "";
75+ newDescription = "";
76+ }
77+</script>
78+
79+<div class="space-y-4">
80+ <Table.Root class="rounded-md border border-border">
81+ <Table.Header>
82+ <Table.Row>
83+ <Table.Head class="w-[140px]">Prefix</Table.Head>
84+ <Table.Head>URL Template</Table.Head>
85+ <Table.Head>Description</Table.Head>
86+ <Table.Head class="w-[160px] text-right">Actions</Table.Head>
87+ </Table.Row>
88+ </Table.Header>
89+ <Table.Body>
90+ {#if entries.length === 0}
91+ <Table.Row>
92+ <Table.Cell colspan={4} class="py-8 text-center text-sm text-muted-foreground">
93+ No shortcuts yet. Add one below.
94+ </Table.Cell>
95+ </Table.Row>
96+ {/if}
97+ {#each entries as [key, shortcut] (key)}
98+ <Table.Row>
99+ {#if editingKey === key}
100+ <Table.Cell>
101+ <Input bind:value={draftKey} class="h-8" />
102+ </Table.Cell>
103+ <Table.Cell>
104+ <Input bind:value={draftUrl} class="h-8" />
105+ </Table.Cell>
106+ <Table.Cell>
107+ <Input bind:value={draftDescription} class="h-8" />
108+ </Table.Cell>
109+ <Table.Cell class="text-right">
110+ <div class="flex justify-end gap-2">
111+ <Button variant="ghost" size="icon" onclick={commitEdit} type="button" aria-label="Save">
112+ <Check size={16} />
113+ </Button>
114+ <Button variant="ghost" size="icon" onclick={cancelEdit} type="button" aria-label="Cancel">
115+ <X size={16} />
116+ </Button>
117+ </div>
118+ </Table.Cell>
119+ {:else}
120+ <Table.Cell class="font-mono text-sm">{key}</Table.Cell>
121+ <Table.Cell class="truncate max-w-[360px]">{shortcut.url}</Table.Cell>
122+ <Table.Cell class="text-muted-foreground">{shortcut.description}</Table.Cell>
123+ <Table.Cell class="text-right">
124+ <div class="flex justify-end gap-2">
125+ <Button
126+ variant="outline"
127+ size="sm"
128+ onclick={() => startEdit(key, shortcut)}
129+ type="button"
130+ >
131+ Edit
132+ </Button>
133+ <Button
134+ variant="ghost"
135+ size="icon"
136+ onclick={() => removeShortcut(key)}
137+ type="button"
138+ aria-label={`Delete ${key}`}
139+ >
140+ <Trash2 size={16} />
141+ </Button>
142+ </div>
143+ </Table.Cell>
144+ {/if}
145+ </Table.Row>
146+ {/each}
147+ </Table.Body>
148+ </Table.Root>
149+
150+ <div class="grid gap-3 md:grid-cols-[140px_1fr_1fr_140px] items-end">
151+ <div class="space-y-1">
152+ <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Prefix</span>
153+ <Input bind:value={newKey} placeholder="g" />
154+ </div>
155+ <div class="space-y-1">
156+ <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">URL Template</span>
157+ <Input bind:value={newUrl} placeholder="https://www.google.com/search?q=%s" />
158+ </div>
159+ <div class="space-y-1">
160+ <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Description</span>
161+ <Input bind:value={newDescription} placeholder="Google" />
162+ </div>
163+ <div class="flex md:justify-end">
164+ <Button variant="default" onclick={addShortcut} type="button" class="w-full md:w-auto">
165+ <Plus size={16} />
166+ Add Shortcut
167+ </Button>
168+ </div>
169+ </div>
170+</div>

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