Groups Sidebar: - SQLite groups table (4 seeded: Development, Testing, DevOps, Research) - Left icon rail with emoji group icons, Ctrl+1-4 switching - Active group highlighted, projects filtered by group Project Cloning: - Clone button on project cards (fork icon) - git worktree add via Bun.spawn (array form, no shell strings) - 3-clone limit, branch name validation, pending-status pattern - Clone cards: WT badge + branch name + accent top border - Chain link SVG icons between linked clones in grid Keyboard Shortcuts: - keybinding-store.svelte.ts: 16 defaults across 4 categories - Two-scope: document capture + terminal focus guard - KeyboardSettings.svelte: search, click-to-capture, conflict detection - Per-binding reset + Reset All Custom Window: - titleBarStyle: "hidden" — no native title bar - Vertical "AGOR" text in left sidebar (writing-mode: vertical-rl) - Floating window controls badge (minimize/maximize/close) - Draggable region via -webkit-app-region: drag - Window frame persisted to SQLite (debounced 500ms) Window is resizable by default (Electrobun BrowserWindow).
168 lines
7.4 KiB
TypeScript
168 lines
7.4 KiB
TypeScript
/**
|
|
* Svelte 5 rune-based keybinding store.
|
|
* Manages global keyboard shortcuts with user-customizable chords.
|
|
* Persists overrides via settings RPC (only non-default bindings saved).
|
|
*
|
|
* Usage:
|
|
* import { keybindingStore } from './keybinding-store.svelte.ts';
|
|
* await keybindingStore.init(rpc);
|
|
*
|
|
* // Register a command handler
|
|
* keybindingStore.on('palette', () => paletteOpen = true);
|
|
*
|
|
* // Install the global keydown listener (call once)
|
|
* keybindingStore.installListener();
|
|
*/
|
|
|
|
// ── Minimal RPC interface ────────────────────────────────────────────────────
|
|
|
|
interface SettingsRpc {
|
|
request: {
|
|
"keybindings.getAll"(p: Record<string, never>): Promise<{ keybindings: Record<string, string> }>;
|
|
"keybindings.set"(p: { id: string; chord: string }): Promise<{ ok: boolean }>;
|
|
"keybindings.reset"(p: { id: string }): Promise<{ ok: boolean }>;
|
|
};
|
|
}
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface Keybinding {
|
|
id: string;
|
|
label: string;
|
|
category: "Global" | "Navigation" | "Terminal" | "Settings";
|
|
chord: string;
|
|
defaultChord: string;
|
|
}
|
|
|
|
// ── Default bindings ─────────────────────────────────────────────────────────
|
|
|
|
const DEFAULTS: Keybinding[] = [
|
|
{ id: "palette", label: "Command Palette", category: "Global", chord: "Ctrl+K", defaultChord: "Ctrl+K" },
|
|
{ id: "settings", label: "Open Settings", category: "Global", chord: "Ctrl+,", defaultChord: "Ctrl+," },
|
|
{ id: "group1", label: "Switch to Group 1", category: "Navigation", chord: "Ctrl+1", defaultChord: "Ctrl+1" },
|
|
{ id: "group2", label: "Switch to Group 2", category: "Navigation", chord: "Ctrl+2", defaultChord: "Ctrl+2" },
|
|
{ id: "group3", label: "Switch to Group 3", category: "Navigation", chord: "Ctrl+3", defaultChord: "Ctrl+3" },
|
|
{ id: "group4", label: "Switch to Group 4", category: "Navigation", chord: "Ctrl+4", defaultChord: "Ctrl+4" },
|
|
{ id: "newTerminal", label: "New Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+T", defaultChord: "Ctrl+Shift+T" },
|
|
{ id: "closeTab", label: "Close Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+W", defaultChord: "Ctrl+Shift+W" },
|
|
{ id: "nextTab", label: "Next Terminal Tab", category: "Terminal", chord: "Ctrl+]", defaultChord: "Ctrl+]" },
|
|
{ id: "prevTab", label: "Previous Terminal Tab", category: "Terminal", chord: "Ctrl+[", defaultChord: "Ctrl+[" },
|
|
{ id: "search", label: "Global Search", category: "Global", chord: "Ctrl+Shift+F", defaultChord: "Ctrl+Shift+F" },
|
|
{ id: "notifications",label: "Notification Center", category: "Global", chord: "Ctrl+Shift+N", defaultChord: "Ctrl+Shift+N" },
|
|
{ id: "minimize", label: "Minimize Window", category: "Global", chord: "Ctrl+M", defaultChord: "Ctrl+M" },
|
|
{ id: "toggleFiles", label: "Toggle Files Tab", category: "Navigation", chord: "Ctrl+Shift+E", defaultChord: "Ctrl+Shift+E" },
|
|
{ id: "toggleMemory", label: "Toggle Memory Tab", category: "Navigation", chord: "Ctrl+Shift+M", defaultChord: "Ctrl+Shift+M" },
|
|
{ id: "reload", label: "Reload App", category: "Settings", chord: "Ctrl+R", defaultChord: "Ctrl+R" },
|
|
];
|
|
|
|
// ── Chord serialisation helpers ───────────────────────────────────────────────
|
|
|
|
export function chordFromEvent(e: KeyboardEvent): string {
|
|
const parts: string[] = [];
|
|
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
|
|
if (e.shiftKey) parts.push("Shift");
|
|
if (e.altKey) parts.push("Alt");
|
|
const key = e.key === " " ? "Space" : e.key;
|
|
// Exclude pure modifier keys
|
|
if (!["Control", "Shift", "Alt", "Meta"].includes(key)) {
|
|
parts.push(key.length === 1 ? key.toUpperCase() : key);
|
|
}
|
|
return parts.join("+");
|
|
}
|
|
|
|
function matchesChord(e: KeyboardEvent, chord: string): boolean {
|
|
return chordFromEvent(e) === chord;
|
|
}
|
|
|
|
// ── Store ────────────────────────────────────────────────────────────────────
|
|
|
|
function createKeybindingStore() {
|
|
let bindings = $state<Keybinding[]>(DEFAULTS.map((b) => ({ ...b })));
|
|
let rpc: SettingsRpc | null = null;
|
|
const handlers = new Map<string, () => void>();
|
|
let listenerInstalled = false;
|
|
|
|
/** Load persisted overrides and merge with defaults. */
|
|
async function init(rpcInstance: SettingsRpc): Promise<void> {
|
|
rpc = rpcInstance;
|
|
try {
|
|
const { keybindings: overrides } = await rpc.request["keybindings.getAll"]({});
|
|
bindings = DEFAULTS.map((b) => ({
|
|
...b,
|
|
chord: overrides[b.id] ?? b.defaultChord,
|
|
}));
|
|
} catch (err) {
|
|
console.error("[keybinding-store] Failed to load keybindings:", err);
|
|
}
|
|
}
|
|
|
|
/** Set a custom chord for a binding id. Persists to SQLite. */
|
|
function setChord(id: string, chord: string): void {
|
|
bindings = bindings.map((b) => b.id === id ? { ...b, chord } : b);
|
|
rpc?.request["keybindings.set"]({ id, chord }).catch(console.error);
|
|
}
|
|
|
|
/** Reset a binding to its default chord. Removes SQLite override. */
|
|
function resetChord(id: string): void {
|
|
const def = DEFAULTS.find((b) => b.id === id);
|
|
if (!def) return;
|
|
bindings = bindings.map((b) => b.id === id ? { ...b, chord: def.defaultChord } : b);
|
|
rpc?.request["keybindings.reset"]({ id }).catch(console.error);
|
|
}
|
|
|
|
/** Reset all bindings to defaults. */
|
|
function resetAll(): void {
|
|
for (const b of bindings) {
|
|
if (b.chord !== b.defaultChord) resetChord(b.id);
|
|
}
|
|
}
|
|
|
|
/** Register a handler for a binding id. */
|
|
function on(id: string, handler: () => void): void {
|
|
handlers.set(id, handler);
|
|
}
|
|
|
|
/** Install a global capture-phase keydown listener. Idempotent. */
|
|
function installListener(): () => void {
|
|
if (listenerInstalled) return () => {};
|
|
listenerInstalled = true;
|
|
|
|
function handleKeydown(e: KeyboardEvent): void {
|
|
// Never intercept keys when focus is inside a terminal canvas
|
|
const target = e.target as Element | null;
|
|
if (target?.closest(".terminal-container, .xterm")) return;
|
|
|
|
const chord = chordFromEvent(e);
|
|
if (!chord) return;
|
|
|
|
for (const b of bindings) {
|
|
if (b.chord === chord) {
|
|
const handler = handlers.get(b.id);
|
|
if (handler) {
|
|
e.preventDefault();
|
|
handler();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener("keydown", handleKeydown, { capture: true });
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeydown, { capture: true });
|
|
listenerInstalled = false;
|
|
};
|
|
}
|
|
|
|
return {
|
|
get bindings() { return bindings; },
|
|
init,
|
|
setChord,
|
|
resetChord,
|
|
resetAll,
|
|
on,
|
|
installListener,
|
|
};
|
|
}
|
|
|
|
export const keybindingStore = createKeybindingStore();
|