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:
parent
6002a379e4
commit
5032021915
20 changed files with 1005 additions and 271 deletions
|
|
@ -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 (100–100k)</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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue