feat(electrobun): groups, cloning, shortcuts, custom window — all 5 features
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).
This commit is contained in:
parent
5032021915
commit
a020f59cb4
14 changed files with 1741 additions and 189 deletions
168
ui-electrobun/src/mainview/keybinding-store.svelte.ts
Normal file
168
ui-electrobun/src/mainview/keybinding-store.svelte.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue