refactor(electrobun): centralize all shared state into global stores

New stores:
- ui-store.svelte.ts: settingsOpen, paletteOpen, searchOpen, notifDrawerOpen,
  showWizard, settingsCategory, projectToDelete, showAddGroup, newGroupName
- project-tabs-store.svelte.ts: per-project activeTab + activatedTabs via Map

Wired:
- App.svelte: 8 inline $state removed, reads/writes via ui-store
- ProjectCard: activeTab/activatedTabs from project-tabs-store
- SettingsDrawer: activeCategory from ui-store
- CommandPalette: 4 commands call ui-store directly (no CustomEvent dispatch)

Components are now pure view layers reading from stores.
This commit is contained in:
Hibryda 2026-03-23 20:26:07 +01:00
parent c88577a34a
commit 2b1194c809
6 changed files with 230 additions and 60 deletions

View file

@ -27,17 +27,20 @@
import {
getNotifications, clearAll as clearNotifications, getNotifCount,
} from './notifications-store.svelte.ts';
import {
getSettingsOpen, setSettingsOpen, toggleSettings,
getPaletteOpen, setPaletteOpen, togglePalette,
getSearchOpen, toggleSearch,
getNotifDrawerOpen, setNotifDrawerOpen, toggleNotifDrawer,
getShowWizard, setShowWizard,
getProjectToDelete, setProjectToDelete,
getShowAddGroup, setShowAddGroup, toggleAddGroup,
getNewGroupName, setNewGroupName, resetAddGroupForm,
toggleWizard,
} from './ui-store.svelte.ts';
// ── Local UI state (view-only, not domain state) ────────────
// ── Local view-only state (not shared across components) ────
let appReady = $state(false);
let settingsOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let searchOpen = $state(false);
let showWizard = $state(false);
let projectToDelete = $state<string | null>(null);
let showAddGroup = $state(false);
let newGroupName = $state('');
let sessionStart = $state(Date.now());
// ── Blink timer ─────────────────────────────────────────────
@ -81,28 +84,28 @@
// ── Wizard handler ──────────────────────────────────────────
function handleWizardCreated(result: WizardResult) {
addProjectFromWizard(result);
showWizard = false;
setShowWizard(false);
}
async function confirmDeleteProject() {
if (!projectToDelete) return;
await deleteProject(projectToDelete);
projectToDelete = null;
const toDelete = getProjectToDelete();
if (!toDelete) return;
await deleteProject(toDelete);
setProjectToDelete(null);
}
// ── Group add (local UI + store) ────────────────────────────
async function handleAddGroup() {
const name = newGroupName.trim();
const name = getNewGroupName().trim();
if (!name) return;
await wsAddGroup(name);
showAddGroup = false;
newGroupName = '';
resetAddGroupForm();
}
// ── Notification drawer ─────────────────────────────────────
function handleClearNotifications() {
clearNotifications();
drawerOpen = false;
setNotifDrawerOpen(false);
}
// ── Toast ref for agent notifications ───────────────────────
@ -183,8 +186,8 @@
trackAllProjects();
});
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; });
keybindingStore.on('palette', () => { togglePalette(); });
keybindingStore.on('settings', () => { toggleSettings(); });
keybindingStore.on('group1', () => setActiveGroup(getGroups()[0]?.id));
keybindingStore.on('group2', () => setActiveGroup(getGroups()[1]?.id));
keybindingStore.on('group3', () => setActiveGroup(getGroups()[2]?.id));
@ -194,7 +197,7 @@
function handleSearchShortcut(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
searchOpen = !searchOpen;
toggleSearch();
}
}
document.addEventListener('keydown', handleSearchShortcut);
@ -202,10 +205,10 @@
function handlePaletteCommand(e: Event) {
const detail = (e as CustomEvent).detail;
switch (detail) {
case 'settings': settingsOpen = !settingsOpen; break;
case 'search': searchOpen = !searchOpen; break;
case 'new-project': showWizard = true; break;
case 'toggle-sidebar': settingsOpen = !settingsOpen; break;
case 'settings': toggleSettings(); break;
case 'search': toggleSearch(); break;
case 'new-project': setShowWizard(true); break;
case 'toggle-sidebar': toggleSettings(); break;
default: console.log(`[palette] unhandled command: ${detail}`);
}
}
@ -221,15 +224,15 @@
</script>
<SplashScreen ready={appReady} />
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
<SettingsDrawer open={getSettingsOpen()} onClose={() => setSettingsOpen(false)} />
<CommandPalette open={getPaletteOpen()} onClose={() => setPaletteOpen(false)} />
<SearchOverlay open={getSearchOpen()} onClose={() => setSearchOpen(false)} />
<ToastContainer bind:this={toastRef} />
<NotifDrawer
open={drawerOpen}
open={getNotifDrawerOpen()}
notifications={getNotifications()}
onClear={handleClearNotifications}
onClose={() => drawerOpen = false}
onClose={() => setNotifDrawerOpen(false)}
/>
<div
@ -262,7 +265,7 @@
<!-- Add group button -->
<button
class="group-btn add-group-btn"
onclick={() => showAddGroup = !showAddGroup}
onclick={() => toggleAddGroup()}
aria-label="Add group"
title="Add group"
>
@ -270,14 +273,15 @@
</button>
</div>
{#if showAddGroup}
{#if getShowAddGroup()}
<div class="add-group-form">
<input
class="add-group-input"
type="text"
placeholder="Group name"
bind:value={newGroupName}
onkeydown={(e) => { if (e.key === 'Enter') handleAddGroup(); if (e.key === 'Escape') showAddGroup = false; }}
value={getNewGroupName()}
oninput={(e) => setNewGroupName((e.target as HTMLInputElement).value)}
onkeydown={(e) => { if (e.key === 'Enter') handleAddGroup(); if (e.key === 'Escape') setShowAddGroup(false); }}
autofocus
/>
</div>
@ -286,7 +290,7 @@
<!-- Add project button -->
<button
class="sidebar-icon"
onclick={() => showWizard = !showWizard}
onclick={() => toggleWizard()}
aria-label="Add project"
title="Add project"
>
@ -300,8 +304,8 @@
<!-- Settings gear -->
<button
class="sidebar-icon"
class:active={settingsOpen}
onclick={() => settingsOpen = !settingsOpen}
class:active={getSettingsOpen()}
onclick={() => toggleSettings()}
aria-label="Settings (Ctrl+,)"
title="Settings (Ctrl+,)"
data-testid="settings-btn"
@ -347,9 +351,9 @@
</div>
<!-- Project wizard overlay (display toggle) -->
<div style:display={showWizard ? 'contents' : 'none'}>
<div style:display={getShowWizard() ? 'contents' : 'none'}>
<ProjectWizard
onClose={() => showWizard = false}
onClose={() => setShowWizard(false)}
onCreated={handleWizardCreated}
groupId={getActiveGroupId()}
groups={getGroups().map(g => ({ id: g.id, name: g.name }))}
@ -358,11 +362,11 @@
</div>
<!-- Delete project confirmation -->
{#if projectToDelete}
{#if getProjectToDelete()}
<div class="delete-overlay" role="listitem">
<p class="delete-text">Delete project "{getProjects().find(p => p.id === projectToDelete)?.name}"?</p>
<p class="delete-text">Delete project "{getProjects().find(p => p.id === getProjectToDelete())?.name}"?</p>
<div class="add-card-actions">
<button class="add-cancel" onclick={() => projectToDelete = null}>Cancel</button>
<button class="add-cancel" onclick={() => setProjectToDelete(null)}>Cancel</button>
<button class="delete-confirm" onclick={confirmDeleteProject}>Delete</button>
</div>
</div>
@ -382,8 +386,8 @@
<button
class="right-icon notif-btn"
class:active={drawerOpen}
onclick={() => drawerOpen = !drawerOpen}
class:active={getNotifDrawerOpen()}
onclick={() => toggleNotifDrawer()}
aria-label="{getNotifCount() > 0 ? `${getNotifCount()} notifications` : 'Notifications'}"
title="Notifications"
>

View file

@ -1,6 +1,9 @@
<script lang="ts">
import { tick } from 'svelte';
import { t } from './i18n.svelte.ts';
import {
toggleSettings, toggleSearch, setShowWizard,
} from './ui-store.svelte.ts';
interface Props {
open: boolean;
@ -17,7 +20,7 @@
action: () => void;
}
// Build commands — actions dispatch via CustomEvent so App.svelte can handle
// Dispatch for commands not yet handled by stores
function dispatch(name: string) {
window.dispatchEvent(new CustomEvent('palette-command', { detail: name }));
}
@ -33,9 +36,9 @@
const COMMAND_DEFS: CommandDef[] = [
{ id: 'new-terminal', labelKey: 'palette.newTerminal', shortcut: 'Ctrl+`', action: () => dispatch('new-terminal') },
{ id: 'settings', labelKey: 'palette.openSettings', shortcut: 'Ctrl+,', action: () => dispatch('settings') },
{ id: 'search', labelKey: 'palette.searchMessages', shortcut: 'Ctrl+Shift+F', action: () => dispatch('search') },
{ id: 'new-project', labelKey: 'palette.addProject', descKey: 'palette.addProjectDesc', action: () => dispatch('new-project') },
{ id: 'settings', labelKey: 'palette.openSettings', shortcut: 'Ctrl+,', action: () => toggleSettings() },
{ id: 'search', labelKey: 'palette.searchMessages', shortcut: 'Ctrl+Shift+F', action: () => toggleSearch() },
{ id: 'new-project', labelKey: 'palette.addProject', descKey: 'palette.addProjectDesc', action: () => setShowWizard(true) },
{ id: 'clear-agent', labelKey: 'palette.clearAgent', descKey: 'palette.clearAgentDesc', action: () => dispatch('clear-agent') },
{ id: 'copy-cost', labelKey: 'palette.copyCost', action: () => dispatch('copy-cost') },
{ id: 'docs', labelKey: 'palette.openDocs', shortcut: 'F1', action: () => dispatch('docs') },
@ -47,7 +50,7 @@
{ id: 'close-tab', labelKey: 'palette.closeTab', shortcut: 'Ctrl+W', action: () => dispatch('close-tab') },
{ id: 'toggle-terminal', labelKey: 'palette.toggleTerminal', shortcut: 'Ctrl+J', action: () => dispatch('toggle-terminal') },
{ id: 'reload-plugins', labelKey: 'palette.reloadPlugins', action: () => dispatch('reload-plugins') },
{ id: 'toggle-sidebar', labelKey: 'palette.toggleSidebar', shortcut: 'Ctrl+B', action: () => dispatch('toggle-sidebar') },
{ id: 'toggle-sidebar', labelKey: 'palette.toggleSidebar', shortcut: 'Ctrl+B', action: () => toggleSettings() },
{ id: 'zoom-in', labelKey: 'palette.zoomIn', shortcut: 'Ctrl+=', action: () => dispatch('zoom-in') },
{ id: 'zoom-out', labelKey: 'palette.zoomOut', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
];

View file

@ -13,8 +13,10 @@
loadLastSession,
type AgentStatus, type AgentMessage,
} from './agent-store.svelte.ts';
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory' | 'comms' | 'tasks';
import {
getActiveTab, setActiveTab, isTabActivated,
ALL_TABS, type ProjectTab,
} from './project-tabs-store.svelte.ts';
interface Props {
id: string;
@ -117,11 +119,8 @@
showCloneDialog = false;
}
let activeTab = $state<ProjectTab>('model');
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
const ALL_TABS: ProjectTab[] = ['model', 'docs', 'context', 'files', 'ssh', 'memory', 'comms', 'tasks'];
// Derived from project-tabs-store for reactive reads
let activeTab = $derived(getActiveTab(id));
// ── Load last session on mount ──────────────────────────────────────
$effect(() => {
@ -129,8 +128,7 @@
});
function setTab(tab: ProjectTab) {
activeTab = tab;
activatedTabs = new Set([...activatedTabs, tab]);
setActiveTab(id, tab);
}
function handleSend(text: string) {

View file

@ -10,6 +10,10 @@
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
import DiagnosticsTab from './settings/DiagnosticsTab.svelte';
import { t } from './i18n.svelte.ts';
import {
getSettingsCategory, setSettingsCategory,
type SettingsCategory,
} from './ui-store.svelte.ts';
interface Props {
open: boolean;
@ -18,7 +22,7 @@
let { open, onClose }: Props = $props();
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard' | 'diagnostics';
type CategoryId = SettingsCategory;
interface Category {
id: CategoryId;
@ -43,7 +47,7 @@
CATEGORY_DEFS.map(d => ({ id: d.id, label: t(d.key as any), icon: d.icon }))
);
let activeCategory = $state<CategoryId>('appearance');
let activeCategory = $derived(getSettingsCategory());
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
@ -83,7 +87,7 @@
<button
class="cat-btn"
class:active={activeCategory === cat.id}
onclick={() => activeCategory = cat.id}
onclick={() => setSettingsCategory(cat.id)}
aria-current={activeCategory === cat.id ? 'page' : undefined}
>
<span class="cat-icon" aria-hidden="true">{cat.icon}</span>

View file

@ -0,0 +1,67 @@
/**
* Project tabs store per-project tab state.
*
* Tracks which tab is active and which tabs have been activated (PERSISTED-LAZY
* pattern) for each project card. This allows cross-component access e.g.,
* StatusBar can show which tab is active, palette commands can switch tabs.
*/
// ── Types ────────────────────────────────────────────────────────────────
export type ProjectTab =
| 'model' | 'docs' | 'context' | 'files'
| 'ssh' | 'memory' | 'comms' | 'tasks';
export const ALL_TABS: ProjectTab[] = [
'model', 'docs', 'context', 'files', 'ssh', 'memory', 'comms', 'tasks',
];
interface TabState {
activeTab: ProjectTab;
activatedTabs: Set<ProjectTab>;
}
// ── State ────────────────────────────────────────────────────────────────
let _tabs = $state<Map<string, TabState>>(new Map());
// ── Internal helper ──────────────────────────────────────────────────────
function ensureEntry(projectId: string): TabState {
let entry = _tabs.get(projectId);
if (!entry) {
entry = { activeTab: 'model', activatedTabs: new Set(['model']) };
_tabs.set(projectId, entry);
// Trigger reactivity by reassigning the map
_tabs = new Map(_tabs);
}
return entry;
}
// ── Getters ──────────────────────────────────────────────────────────────
export function getActiveTab(projectId: string): ProjectTab {
return _tabs.get(projectId)?.activeTab ?? 'model';
}
export function isTabActivated(projectId: string, tab: ProjectTab): boolean {
return _tabs.get(projectId)?.activatedTabs.has(tab) ?? (tab === 'model');
}
// ── Actions ──────────────────────────────────────────────────────────────
export function setActiveTab(projectId: string, tab: ProjectTab): void {
const entry = ensureEntry(projectId);
entry.activeTab = tab;
entry.activatedTabs = new Set([...entry.activatedTabs, tab]);
// Trigger reactivity
_tabs = new Map(_tabs);
}
/** Remove tab state when a project is deleted. */
export function removeProject(projectId: string): void {
if (_tabs.has(projectId)) {
_tabs.delete(projectId);
_tabs = new Map(_tabs);
}
}

View file

@ -0,0 +1,94 @@
/**
* UI store global UI overlay/drawer/panel state.
*
* Single source of truth for ephemeral UI state that multiple components
* need to read or write (e.g., palette commands opening settings from anywhere).
*
* Components are pure view layers: they read from this store and call
* its methods to mutate state. No component should own $state that
* other components need.
*/
// ── Settings drawer ──────────────────────────────────────────────────────
type SettingsCategory =
| 'appearance' | 'agents' | 'security' | 'projects'
| 'orchestration' | 'machines' | 'advanced' | 'marketplace'
| 'keyboard' | 'diagnostics';
let _settingsOpen = $state(false);
let _settingsCategory = $state<SettingsCategory>('appearance');
export function getSettingsOpen(): boolean { return _settingsOpen; }
export function setSettingsOpen(v: boolean): void { _settingsOpen = v; }
export function toggleSettings(): void { _settingsOpen = !_settingsOpen; }
export function getSettingsCategory(): SettingsCategory { return _settingsCategory; }
export function setSettingsCategory(c: SettingsCategory): void { _settingsCategory = c; }
/** Open settings drawer directly to a specific category. */
export function openSettingsCategory(category: SettingsCategory): void {
_settingsCategory = category;
_settingsOpen = true;
}
// ── Command palette ──────────────────────────────────────────────────────
let _paletteOpen = $state(false);
export function getPaletteOpen(): boolean { return _paletteOpen; }
export function setPaletteOpen(v: boolean): void { _paletteOpen = v; }
export function togglePalette(): void { _paletteOpen = !_paletteOpen; }
// ── Search overlay ───────────────────────────────────────────────────────
let _searchOpen = $state(false);
export function getSearchOpen(): boolean { return _searchOpen; }
export function setSearchOpen(v: boolean): void { _searchOpen = v; }
export function toggleSearch(): void { _searchOpen = !_searchOpen; }
// ── Notification drawer ──────────────────────────────────────────────────
let _notifDrawerOpen = $state(false);
export function getNotifDrawerOpen(): boolean { return _notifDrawerOpen; }
export function setNotifDrawerOpen(v: boolean): void { _notifDrawerOpen = v; }
export function toggleNotifDrawer(): void { _notifDrawerOpen = !_notifDrawerOpen; }
// ── Project wizard ───────────────────────────────────────────────────────
let _showWizard = $state(false);
export function getShowWizard(): boolean { return _showWizard; }
export function setShowWizard(v: boolean): void { _showWizard = v; }
export function toggleWizard(): void { _showWizard = !_showWizard; }
// ── Delete project confirmation ──────────────────────────────────────────
let _projectToDelete = $state<string | null>(null);
export function getProjectToDelete(): string | null { return _projectToDelete; }
export function setProjectToDelete(id: string | null): void { _projectToDelete = id; }
// ── Add group form ───────────────────────────────────────────────────────
let _showAddGroup = $state(false);
let _newGroupName = $state('');
export function getShowAddGroup(): boolean { return _showAddGroup; }
export function setShowAddGroup(v: boolean): void { _showAddGroup = v; }
export function toggleAddGroup(): void { _showAddGroup = !_showAddGroup; }
export function getNewGroupName(): string { return _newGroupName; }
export function setNewGroupName(v: string): void { _newGroupName = v; }
/** Reset add-group form after submission. */
export function resetAddGroupForm(): void {
_showAddGroup = false;
_newGroupName = '';
}
// ── Type re-export ───────────────────────────────────────────────────────
export type { SettingsCategory };