feat(electrobun): wire EVERYTHING — all settings persist, theme editor, marketplace

All settings wired to SQLite persistence:
- AgentSettings: shell, CWD, permissions, providers (JSON blob)
- SecuritySettings: branch policies (JSON array)
- ProjectSettings: per-project via setProject RPC
- OrchestrationSettings: wake, anchors, notifications
- AdvancedSettings: logging, OTLP, plugins, import/export JSON

Theme Editor:
- 26 color pickers (14 Accents + 12 Neutrals)
- Live CSS var preview as you pick colors
- Save custom theme to SQLite, cancel reverts
- Import/export theme as JSON
- Custom themes in dropdown with delete button

Extensions Marketplace:
- 8-plugin demo catalog (Browse/Installed tabs)
- Search/filter by name or tag
- Install/uninstall with SQLite persistence
- Plugin cards with emoji icons, tags, version

Terminal font hot-swap:
- fontStore.onTermFontChange() → xterm.js options update + fitAddon.fit()
- Resize notification to PTY daemon after font change

All 7 settings categories functional. Every control persists and takes effect.
This commit is contained in:
Hibryda 2026-03-20 05:45:10 +01:00
parent 6002a379e4
commit 5032021915
20 changed files with 1005 additions and 271 deletions

View file

@ -1,7 +1,10 @@
<script lang="ts">
import { THEMES, THEME_GROUPS, type ThemeId } from '../themes.ts';
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { fontStore } from '../font-store.svelte.ts';
import ThemeEditor from './ThemeEditor.svelte';
const UI_FONTS = [
{ value: '', label: 'System Default' },
@ -22,7 +25,7 @@
{ value: 'monospace', label: 'monospace' },
];
// ── Local reactive state (mirrors store) ───────────────────────────────────
// ── Local reactive state ───────────────────────────────────────────────────
let themeId = $state<ThemeId>(themeStore.currentTheme);
let uiFont = $state(fontStore.uiFontFamily);
let uiFontSize = $state(fontStore.uiFontSize);
@ -32,7 +35,11 @@
let cursorBlink = $state(true);
let scrollback = $state(1000);
// Keep local state in sync when store changes (e.g. after initTheme)
interface CustomThemeMeta { id: string; name: string; }
let customThemes = $state<CustomThemeMeta[]>([]);
let showEditor = $state(false);
// Keep local state in sync when store changes
$effect(() => { themeId = themeStore.currentTheme; });
$effect(() => { uiFont = fontStore.uiFontFamily; });
$effect(() => { uiFontSize = fontStore.uiFontSize; });
@ -44,28 +51,33 @@
let uiFontOpen = $state(false);
let termFontOpen = $state(false);
// ── All themes (built-in + custom) ────────────────────────────────────────
let allThemes = $derived<ThemeMeta[]>([
...THEMES,
...customThemes.map(t => ({ id: t.id as ThemeId, label: t.name, group: 'Custom', isDark: true })),
]);
let allGroups = $derived([...THEME_GROUPS, ...(customThemes.length > 0 ? ['Custom'] : [])]);
// ── Derived labels ─────────────────────────────────────────────────────────
let themeLabel = $derived(THEMES.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
let themeLabel = $derived(allThemes.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
let uiFontLabel = $derived(UI_FONTS.find(f => f.value === uiFont)?.label ?? 'System Default');
let termFontLabel = $derived(TERM_FONTS.find(f => f.value === termFont)?.label ?? 'Default (JetBrains Mono)');
// ── Actions ────────────────────────────────────────────────────────────────
function selectTheme(id: ThemeId): void {
themeId = id;
themeOpen = false;
themeId = id; themeOpen = false;
themeStore.setTheme(id);
appRpc?.request['settings.set']({ key: 'theme', value: id }).catch(console.error);
}
function selectUIFont(value: string): void {
uiFont = value;
uiFontOpen = false;
uiFont = value; uiFontOpen = false;
fontStore.setUIFont(value, uiFontSize);
}
function selectTermFont(value: string): void {
termFont = value;
termFontOpen = false;
termFont = value; termFontOpen = false;
fontStore.setTermFont(value, termFontSize);
}
@ -79,19 +91,64 @@
fontStore.setTermFont(termFont, termFontSize);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
function persistCursorStyle(v: string) {
cursorStyle = v;
appRpc?.request['settings.set']({ key: 'cursor_style', value: v }).catch(console.error);
}
function persistCursorBlink(v: boolean) {
cursorBlink = v;
appRpc?.request['settings.set']({ key: 'cursor_blink', value: String(v) }).catch(console.error);
}
function persistScrollback(v: number) {
scrollback = v;
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
function handleOutsideClick(e: MouseEvent): void {
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
}
function handleKey(e: KeyboardEvent): void {
if (e.key === 'Escape') closeAll();
async function deleteCustomTheme(id: string) {
await appRpc?.request['themes.deleteCustom']({ id }).catch(console.error);
customThemes = customThemes.filter(t => t.id !== id);
if (themeId === id) selectTheme('mocha');
}
function onEditorSave(id: string, name: string) {
customThemes = [...customThemes, { id, name }];
showEditor = false;
selectTheme(id as ThemeId);
}
function onEditorCancel() { showEditor = false; }
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['cursor_style']) cursorStyle = settings['cursor_style'];
if (settings['cursor_blink']) cursorBlink = settings['cursor_blink'] !== 'false';
if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 1000;
const res = await appRpc.request['themes.getCustom']({}).catch(() => ({ themes: [] }));
customThemes = res.themes.map(t => ({ id: t.id, name: t.name }));
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" onclick={handleOutsideClick} onkeydown={handleKey}>
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && closeAll()}>
{#if showEditor}
<ThemeEditor
baseThemeId={themeId}
initialPalette={getPalette(themeId)}
onSave={onEditorSave}
onCancel={onEditorCancel}
/>
{:else}
<h3 class="sh">Theme</h3>
<div class="field">
<div class="dd-wrap">
@ -101,20 +158,32 @@
</button>
{#if themeOpen}
<ul class="dd-list" role="listbox">
{#each THEME_GROUPS as group}
{#each allGroups as group}
<li class="dd-group-label" role="presentation">{group}</li>
{#each THEMES.filter(t => t.group === group) as t}
{#each allThemes.filter(t => t.group === group) as t}
<li class="dd-item" class:sel={themeId === t.id}
role="option" aria-selected={themeId === t.id}
tabindex="0"
role="option" aria-selected={themeId === t.id} tabindex="0"
onclick={() => selectTheme(t.id)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
>{t.label}</li>
>
<span class="dd-item-label">{t.label}</span>
{#if t.group === 'Custom'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="del-btn" title="Delete theme"
onclick={e => { e.stopPropagation(); deleteCustomTheme(t.id); }}
onkeydown={e => e.key === 'Enter' && (e.stopPropagation(), deleteCustomTheme(t.id))}
role="button" tabindex="0" aria-label="Delete {t.label}">✕</span>
{/if}
</li>
{/each}
{/each}
</ul>
{/if}
</div>
<div class="theme-actions">
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>Edit Theme</button>
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>+ Custom</button>
</div>
</div>
<h3 class="sh">UI Font</h3>
@ -127,10 +196,8 @@
{#if uiFontOpen}
<ul class="dd-list" role="listbox">
{#each UI_FONTS as f}
<li class="dd-item" class:sel={uiFont === f.value}
role="option" aria-selected={uiFont === f.value}
tabindex="0"
onclick={() => selectUIFont(f.value)}
<li class="dd-item" class:sel={uiFont === f.value} role="option" aria-selected={uiFont === f.value}
tabindex="0" onclick={() => selectUIFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectUIFont(f.value)}
>{f.label}</li>
{/each}
@ -154,10 +221,8 @@
{#if termFontOpen}
<ul class="dd-list" role="listbox">
{#each TERM_FONTS as f}
<li class="dd-item" class:sel={termFont === f.value}
role="option" aria-selected={termFont === f.value}
tabindex="0"
onclick={() => selectTermFont(f.value)}
<li class="dd-item" class:sel={termFont === f.value} role="option" aria-selected={termFont === f.value}
tabindex="0" onclick={() => selectTermFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTermFont(f.value)}
>{f.label}</li>
{/each}
@ -175,20 +240,24 @@
<div class="field row">
<div class="seg">
{#each ['block', 'line', 'underline'] as s}
<button class:active={cursorStyle === s} onclick={() => cursorStyle = s}>{s[0].toUpperCase() + s.slice(1)}</button>
<button class:active={cursorStyle === s} onclick={() => persistCursorStyle(s)}>{s[0].toUpperCase() + s.slice(1)}</button>
{/each}
</div>
<label class="toggle-row">
<span>Blink</span>
<button class="toggle" class:on={cursorBlink} onclick={() => cursorBlink = !cursorBlink}>{cursorBlink ? 'On' : 'Off'}</button>
<button class="toggle" class:on={cursorBlink} onclick={() => persistCursorBlink(!cursorBlink)}>{cursorBlink ? 'On' : 'Off'}</button>
</label>
</div>
<h3 class="sh">Scrollback</h3>
<div class="field row">
<input type="number" class="num-in" min="100" max="100000" step="100" bind:value={scrollback} aria-label="Scrollback lines" />
<input type="number" class="num-in" min="100" max="100000" step="100" value={scrollback}
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)}
aria-label="Scrollback lines" />
<span class="hint">lines (100100k)</span>
</div>
{/if}
</div>
<style>
@ -216,18 +285,28 @@
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.dd-group-label {
padding: 0.25rem 0.5rem 0.125rem;
font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0);
border-top: 1px solid var(--ctp-surface0);
padding: 0.25rem 0.5rem 0.125rem; font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0); border-top: 1px solid var(--ctp-surface0);
}
.dd-group-label:first-child { border-top: none; }
.dd-item {
display: flex; align-items: center; justify-content: space-between;
padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8125rem; color: var(--ctp-subtext1);
cursor: pointer; outline: none; list-style: none;
}
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
.dd-item.sel { background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent); color: var(--ctp-mauve); font-weight: 500; }
.dd-item-label { flex: 1; }
.del-btn { font-size: 0.7rem; color: var(--ctp-overlay0); padding: 0.1rem 0.2rem; border-radius: 0.15rem; }
.del-btn:hover { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 10%, transparent); }
.theme-actions { display: flex; gap: 0.375rem; margin-top: 0.25rem; }
.theme-action-btn {
padding: 0.2rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem; color: var(--ctp-subtext1); font-size: 0.75rem; cursor: pointer;
font-family: var(--ui-font-family);
}
.theme-action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.stepper { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
.stepper button { width: 1.375rem; height: 1.375rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.2rem; color: var(--ctp-text); font-size: 0.875rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }