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:
Hibryda 2026-03-20 06:24:24 +01:00
parent 5032021915
commit a020f59cb4
14 changed files with 1741 additions and 189 deletions

View 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();