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
315
ui-electrobun/src/mainview/settings/KeyboardSettings.svelte
Normal file
315
ui-electrobun/src/mainview/settings/KeyboardSettings.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue