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,315 @@
<script lang="ts">
import { keybindingStore, chordFromEvent, type Keybinding } from '../keybinding-store.svelte.ts';
// ── State ─────────────────────────────────────────────────────
let searchQuery = $state('');
let capturingId = $state<string | null>(null);
let conflictWarning = $state<string | null>(null);
// ── Derived filtered list ──────────────────────────────────────
let filtered = $derived(
searchQuery.trim()
? keybindingStore.bindings.filter(
(b) =>
b.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.chord.toLowerCase().includes(searchQuery.toLowerCase())
)
: keybindingStore.bindings
);
// ── Category groups ────────────────────────────────────────────
let grouped = $derived(
filtered.reduce<Record<string, Keybinding[]>>((acc, b) => {
(acc[b.category] ??= []).push(b);
return acc;
}, {})
);
const CATEGORY_ORDER = ['Global', 'Navigation', 'Terminal', 'Settings'];
// ── Capture mode ──────────────────────────────────────────────
function startCapture(id: string) {
capturingId = id;
conflictWarning = null;
}
function handleCaptureKeydown(e: KeyboardEvent, id: string) {
e.preventDefault();
e.stopPropagation();
const chord = chordFromEvent(e);
if (!chord || chord === 'Escape') {
capturingId = null;
conflictWarning = null;
return;
}
// Skip bare modifier key presses (no actual key yet)
if (!chord.match(/[A-Z0-9,.\[\]\\/'`\-=; ]|F\d+|Enter|Tab|Space|Backspace|Delete|Arrow/)) {
return;
}
// Conflict check
const conflict = keybindingStore.bindings.find(
(b) => b.id !== id && b.chord === chord
);
if (conflict) {
conflictWarning = `Conflicts with "${conflict.label}"`;
} else {
conflictWarning = null;
}
keybindingStore.setChord(id, chord);
capturingId = null;
}
function resetAll() {
keybindingStore.resetAll();
conflictWarning = null;
}
function isModified(b: Keybinding): boolean {
return b.chord !== b.defaultChord;
}
</script>
<div class="kb-settings">
<!-- Toolbar -->
<div class="kb-toolbar">
<input
class="kb-search"
type="search"
placeholder="Search shortcuts…"
bind:value={searchQuery}
aria-label="Search keyboard shortcuts"
/>
<button class="kb-reset-all" onclick={resetAll} title="Reset all shortcuts to defaults">
Reset All
</button>
</div>
{#if conflictWarning}
<div class="kb-conflict-banner" role="alert">
Warning: {conflictWarning}
</div>
{/if}
<!-- Table by category -->
{#each CATEGORY_ORDER as category}
{#if grouped[category]?.length}
<div class="kb-category">
<div class="kb-category-header">{category}</div>
<div class="kb-table">
{#each grouped[category] as binding (binding.id)}
<div class="kb-row" class:modified={isModified(binding)}>
<span class="kb-label">{binding.label}</span>
<!-- Chord cell: click to capture -->
{#if capturingId === binding.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="kb-chord capturing"
tabindex="0"
role="button"
aria-label="Press new key combination for {binding.label}"
autofocus
onkeydown={(e) => handleCaptureKeydown(e, binding.id)}
onblur={() => capturingId = null}
>
Press keys…
</div>
{:else}
<button
class="kb-chord"
onclick={() => startCapture(binding.id)}
title="Click to rebind"
aria-label="Current shortcut for {binding.label}: {binding.chord}. Click to change."
>
{binding.chord}
</button>
{/if}
<!-- Reset button: only shown when modified -->
{#if isModified(binding)}
<button
class="kb-reset"
onclick={() => keybindingStore.resetChord(binding.id)}
title="Reset to {binding.defaultChord}"
aria-label="Reset {binding.label} to default"
>
{binding.defaultChord}
</button>
{:else}
<span class="kb-reset-placeholder"></span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/each}
{#if filtered.length === 0}
<p class="kb-empty">No shortcuts match "{searchQuery}"</p>
{/if}
</div>
<style>
.kb-settings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.kb-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.kb-search {
flex: 1;
height: 1.75rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.3rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding: 0 0.5rem;
outline: none;
}
.kb-search:focus { border-color: var(--ctp-mauve); }
.kb-reset-all {
padding: 0.25rem 0.625rem;
background: transparent;
border: 1px solid var(--ctp-surface1);
border-radius: 0.3rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s;
}
.kb-reset-all:hover {
border-color: var(--ctp-red);
color: var(--ctp-red);
}
.kb-conflict-banner {
padding: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
border: 1px solid var(--ctp-yellow);
border-radius: 0.3rem;
color: var(--ctp-yellow);
font-size: 0.75rem;
}
.kb-category {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kb-category-header {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ctp-overlay0);
padding: 0 0.25rem;
}
.kb-table {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.kb-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
transition: background 0.1s;
}
.kb-row:hover { background: var(--ctp-surface0); }
.kb-row.modified { background: color-mix(in srgb, var(--ctp-mauve) 6%, transparent); }
.kb-label {
font-size: 0.8125rem;
color: var(--ctp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb-chord {
padding: 0.125rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--term-font-family);
font-size: 0.75rem;
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
white-space: nowrap;
}
.kb-chord:hover:not(.capturing) {
border-color: var(--ctp-mauve);
background: var(--ctp-surface1);
}
.kb-chord.capturing {
border-color: var(--ctp-mauve);
background: color-mix(in srgb, var(--ctp-mauve) 15%, var(--ctp-surface0));
color: var(--ctp-mauve);
animation: pulse-capture 0.8s ease-in-out infinite;
outline: none;
}
@keyframes pulse-capture {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
.kb-reset {
padding: 0.125rem 0.375rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s;
}
.kb-reset:hover {
border-color: var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.kb-reset-placeholder {
width: 5rem; /* Reserve space so layout stays stable */
}
.kb-empty {
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
padding: 2rem 0;
}
</style>