Skip to content

Commit f5ca8c4

Browse files
committed
feat(keybindings): add KeybindingsDialog component and related functionality
1 parent 90a9ea4 commit f5ca8c4

13 files changed

Lines changed: 444 additions & 107 deletions

packages/commands/src/commandIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const SHOW_EXTENSIONS = "showExtensions";
55
export const SHOW_SETTINGS = "showSettings";
66
export const SHOW_FIND_FILES = "showFindFiles";
77
export const SHOW_COMMAND_PALETTE = "showCommandPalette";
8+
export const SHOW_KEYBINDINGS = "showKeybindings";
89
export const CLOSE_VIEWER = "closeViewer";
910
export const CLOSE_EDITOR = "closeEditor";
1011
export const DOTDIR_EDITOR_FIND = "dotdir.editorFind";

packages/commands/src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface Keybinding {
3232
mac?: string;
3333
when?: string;
3434
args?: unknown;
35+
source?: string;
3536
}
3637

3738
export interface CommandContribution {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
.keybindings-dialog {
2+
display: flex;
3+
flex-direction: column;
4+
width: 80vw;
5+
max-width: 900px;
6+
height: 70vh;
7+
max-height: 700px;
8+
background: var(--bg, #1e1e2e);
9+
color: var(--fg, #cdd6f4);
10+
border-radius: 8px;
11+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
12+
}
13+
14+
.keybindings-header {
15+
display: flex;
16+
flex-direction: column;
17+
gap: 8px;
18+
padding: 12px 16px;
19+
border-bottom: 1px solid var(--border, #333);
20+
flex-shrink: 0;
21+
}
22+
23+
.keybindings-search {
24+
display: flex;
25+
}
26+
27+
.keybindings-search-input {
28+
flex: 1;
29+
padding: 6px 12px;
30+
border: 1px solid var(--border, #444);
31+
border-radius: 4px;
32+
background: var(--bg-secondary, #181825);
33+
color: var(--fg, #cdd6f4);
34+
font-size: 13px;
35+
outline: none;
36+
}
37+
38+
.keybindings-search-input:focus {
39+
border-color: var(--accent, #89b4fa);
40+
}
41+
42+
.keybindings-toolbar {
43+
display: flex;
44+
gap: 8px;
45+
flex-wrap: wrap;
46+
}
47+
48+
.keybindings-toolbar-button {
49+
padding: 3px 10px;
50+
border: 1px solid var(--border, #444);
51+
border-radius: 4px;
52+
background: transparent;
53+
color: var(--fg-secondary, #a6adc8);
54+
font-size: 12px;
55+
cursor: pointer;
56+
}
57+
58+
.keybindings-toolbar-button:hover {
59+
background: var(--bg-secondary, #181825);
60+
}
61+
62+
.keybindings-toolbar-button.active {
63+
border-color: var(--accent, #89b4fa);
64+
color: var(--accent, #89b4fa);
65+
}
66+
67+
.keybindings-table-container {
68+
flex: 1;
69+
overflow: auto;
70+
min-height: 0;
71+
}
72+
73+
.keybindings-table {
74+
width: 100%;
75+
border-collapse: collapse;
76+
font-size: 13px;
77+
}
78+
79+
.keybindings-table thead {
80+
position: sticky;
81+
top: 0;
82+
z-index: 1;
83+
}
84+
85+
.keybindings-table th {
86+
text-align: left;
87+
padding: 8px 12px;
88+
background: var(--bg-secondary, #181825);
89+
border-bottom: 1px solid var(--border, #333);
90+
color: var(--fg-secondary, #a6adc8);
91+
font-weight: 600;
92+
font-size: 12px;
93+
text-transform: uppercase;
94+
letter-spacing: 0.04em;
95+
}
96+
97+
.keybindings-table td {
98+
padding: 6px 12px;
99+
border-bottom: 1px solid var(--border, #181825);
100+
vertical-align: middle;
101+
}
102+
103+
.keybindings-table tbody tr:hover {
104+
background: var(--bg-hover, rgba(255, 255, 255, 0.03));
105+
}
106+
107+
.col-command {
108+
width: 35%;
109+
overflow: hidden;
110+
text-overflow: ellipsis;
111+
white-space: nowrap;
112+
}
113+
114+
.col-keybinding {
115+
width: 20%;
116+
}
117+
118+
.col-keybinding code {
119+
font-family: monospace;
120+
font-size: 12px;
121+
padding: 2px 6px;
122+
background: var(--bg-secondary, #181825);
123+
border-radius: 3px;
124+
white-space: nowrap;
125+
}
126+
127+
.col-when {
128+
width: 25%;
129+
}
130+
131+
.col-when code {
132+
font-family: monospace;
133+
font-size: 11px;
134+
color: var(--fg-muted, #6c7086);
135+
}
136+
137+
.col-source {
138+
width: 20%;
139+
color: var(--fg-muted, #6c7086);
140+
font-size: 12px;
141+
}
142+
143+
.source-link {
144+
background: none;
145+
border: none;
146+
padding: 0;
147+
color: var(--accent, #89b4fa);
148+
font-size: 12px;
149+
cursor: pointer;
150+
text-decoration: underline;
151+
text-underline-offset: 2px;
152+
}
153+
154+
.source-link:hover {
155+
color: var(--fg, #cdd6f4);
156+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { useCommandRegistry, formatKeybinding, type Command, type KeybindingLayer } from "@dotdirfm/commands";
2+
import { useDialog } from "@/dialogs/dialogContext";
3+
import { cx } from "@dotdirfm/ui-utils";
4+
import { useCallback, useMemo, useRef, useState } from "react";
5+
import { OverlayDialog } from "./OverlayDialog";
6+
import styles from "./KeybindingsDialog.module.css";
7+
8+
interface KeybindingsDialogProps {
9+
onClose: () => void;
10+
}
11+
12+
const LAYER_LABELS: Record<KeybindingLayer, string> = {
13+
user: "User",
14+
extension: "Extension",
15+
default: "Built-in",
16+
};
17+
18+
export function KeybindingsDialog({ onClose }: KeybindingsDialogProps) {
19+
const commandRegistry = useCommandRegistry();
20+
const { showDialog } = useDialog();
21+
const [search, setSearch] = useState("");
22+
const [sortByPrecedence, setSortByPrecedence] = useState(true);
23+
const searchRef = useRef<HTMLInputElement>(null);
24+
25+
const allCommands = useMemo(() => commandRegistry.getAllCommands(), [commandRegistry]);
26+
const commandMap = useMemo(() => {
27+
const map = new Map<string, Command>();
28+
for (const c of allCommands) map.set(c.id, c);
29+
return map;
30+
}, [allCommands]);
31+
32+
const bindingsByLayer = useMemo(() => {
33+
const layers: KeybindingLayer[] = ["user", "extension", "default"];
34+
return layers.map((layer) => ({
35+
layer,
36+
bindings: commandRegistry.getKeybindingsForLayer(layer),
37+
}));
38+
}, [commandRegistry]);
39+
40+
const filteredRows = useMemo(() => {
41+
const q = search.toLowerCase();
42+
const rows: Array<{ command: string; title: string; key: string; when: string; source: string; sourceKey?: string; layer: KeybindingLayer }> = [];
43+
44+
for (const { layer, bindings } of bindingsByLayer) {
45+
for (const b of bindings) {
46+
if (!b.command) continue;
47+
const cmd = commandMap.get(b.command);
48+
const title = cmd?.title ?? b.command;
49+
if (q && !title.toLowerCase().includes(q) && !b.key.toLowerCase().includes(q) && !b.command.toLowerCase().includes(q)) continue;
50+
const sourceName = b.source ?? LAYER_LABELS[layer];
51+
rows.push({
52+
command: b.command,
53+
title,
54+
key: formatKeybinding(b),
55+
when: b.when ?? "",
56+
source: sourceName,
57+
sourceKey: b.source,
58+
layer,
59+
});
60+
}
61+
}
62+
63+
if (sortByPrecedence) {
64+
const layerOrder: KeybindingLayer[] = ["user", "extension", "default"];
65+
rows.sort((a, b) => layerOrder.indexOf(a.layer) - layerOrder.indexOf(b.layer));
66+
}
67+
68+
return rows;
69+
}, [bindingsByLayer, commandMap, search, sortByPrecedence]);
70+
71+
const handleClearSearch = useCallback(() => {
72+
setSearch("");
73+
searchRef.current?.focus();
74+
}, []);
75+
76+
const handleSourceClick = useCallback(
77+
(sourceKey: string) => {
78+
onClose();
79+
showDialog({ type: "extensions", activeExtensionKey: sourceKey });
80+
},
81+
[onClose, showDialog],
82+
);
83+
84+
return (
85+
<OverlayDialog className={cx(styles, "keybindings-dialog")} onClose={onClose} initialFocusRef={searchRef} focusLayer="modal">
86+
<div className={styles["keybindings-header"]}>
87+
<div className={styles["keybindings-search"]}>
88+
<input
89+
ref={searchRef}
90+
type="text"
91+
placeholder="Type to search in keybindings"
92+
value={search}
93+
onChange={(e) => setSearch(e.target.value)}
94+
onKeyDown={(e) => {
95+
if (e.key === "Escape") {
96+
e.stopPropagation();
97+
if (search) handleClearSearch();
98+
else onClose();
99+
}
100+
}}
101+
className={styles["keybindings-search-input"]}
102+
/>
103+
</div>
104+
<div className={styles["keybindings-toolbar"]}>
105+
<button
106+
className={cx(styles, "keybindings-toolbar-button", sortByPrecedence && "active")}
107+
onClick={() => setSortByPrecedence((v) => !v)}
108+
title="Sort by Precedence (Highest first)"
109+
>
110+
Sort by Precedence
111+
</button>
112+
{search && (
113+
<button className={cx(styles, "keybindings-toolbar-button")} onClick={handleClearSearch} title="Clear Keybindings Search Input (Escape)">
114+
Clear
115+
</button>
116+
)}
117+
</div>
118+
</div>
119+
<div className={styles["keybindings-table-container"]}>
120+
<table className={styles["keybindings-table"]}>
121+
<thead>
122+
<tr>
123+
<th className={styles["col-command"]}>Command</th>
124+
<th className={styles["col-keybinding"]}>Keybinding</th>
125+
<th className={styles["col-when"]}>When</th>
126+
<th className={styles["col-source"]}>Source</th>
127+
</tr>
128+
</thead>
129+
<tbody>
130+
{filteredRows.map((row, i) => (
131+
<tr key={`${row.command}-${i}`}>
132+
<td className={styles["col-command"]} title={row.command}>
133+
{row.title}
134+
</td>
135+
<td className={styles["col-keybinding"]}>
136+
<code>{row.key}</code>
137+
</td>
138+
<td className={styles["col-when"]}>
139+
<code>{row.when || "—"}</code>
140+
</td>
141+
<td className={styles["col-source"]}>
142+
{row.sourceKey ? (
143+
<button className={styles["source-link"]} onClick={() => handleSourceClick(row.sourceKey!)} title={`Show ${row.source} extension`}>
144+
{row.source}
145+
</button>
146+
) : (
147+
row.source
148+
)}
149+
</td>
150+
</tr>
151+
))}
152+
</tbody>
153+
</table>
154+
</div>
155+
</OverlayDialog>
156+
);
157+
}

packages/ui/lib/dialogs/dialogContext.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { MoveConfigDialog } from "./MoveConfigDialog";
1717
import { OpenCreateFileDialog } from "./OpenCreateFileDialog";
1818
import { RenameDialog } from "./RenameDialog";
1919
import { SettingsDialog } from "./SettingsDialog";
20+
import { KeybindingsDialog } from "./KeybindingsDialog";
2021

2122
export interface MessageDialogButton {
2223
label: string;
@@ -127,10 +128,14 @@ export type DialogSpec =
127128
}
128129
| {
129130
type: "extensions";
131+
activeExtensionKey?: string;
130132
}
131133
| {
132134
type: "settings";
133135
}
136+
| {
137+
type: "keybindings";
138+
}
134139
| {
135140
type: "findFiles";
136141
initialRequest: FileSearchRequest;
@@ -529,9 +534,11 @@ function renderDialogContent(dialog: DialogSpec, ctx: DialogContextValue, stackI
529534
/>
530535
);
531536
case "extensions":
532-
return <ExtensionsPanel onClose={ctx.closeDialog} />;
537+
return <ExtensionsPanel onClose={ctx.closeDialog} activeExtensionKey={dialog.activeExtensionKey} />;
533538
case "settings":
534539
return <SettingsDialog onClose={ctx.closeDialog} />;
540+
case "keybindings":
541+
return <KeybindingsDialog onClose={ctx.closeDialog} />;
535542
case "findFiles":
536543
return (
537544
<FindFilesDialog

packages/ui/lib/features/commands/builtInCommandContributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
SHOW_COMMAND_PALETTE,
7777
SHOW_FIND_FILES,
7878
SHOW_EXTENSIONS,
79+
SHOW_KEYBINDINGS,
7980
SHOW_SETTINGS,
8081
SWITCH_PANEL,
8182
SHELL_EXECUTE,
@@ -92,6 +93,7 @@ export const builtInCommandContributions: CommandContribution[] = [
9293
{ command: RUN_COMMANDS, title: "Run Commands", category: "View", palette: false },
9394
{ command: SHOW_EXTENSIONS, title: "Show Extensions", shortTitle: "Plugins", category: "View" },
9495
{ command: SHOW_SETTINGS, title: "Show Settings", shortTitle: "Settings", category: "View" },
96+
{ command: SHOW_KEYBINDINGS, title: "Show Keybindings", shortTitle: "Keybindings", category: "View" },
9597
{ command: SHOW_FIND_FILES, title: "Find Files", shortTitle: "Find", category: "View", when: "!dialogOpen" },
9698
{ command: SHOW_COMMAND_PALETTE, title: "Show All Commands", category: "View" },
9799
{ command: CLOSE_VIEWER, title: "Close Viewer", category: "View" },

0 commit comments

Comments
 (0)