feat(electrobun): hierarchical state tree (Rule 58)

New files:
- project-state.types.ts: all per-project state interfaces
- project-state.svelte.ts: unified per-project state with version counter
- app-state.svelte.ts: root facade re-exporting all stores as appState.*

Rewired components (no more local $state):
- ProjectCard: reads via appState.agent.* and appState.project.tab.*
- TerminalTabs: state in appState.project.terminals.*
- FileBrowser: state in appState.project.files.*
- CommsTab: state in appState.project.comms.*
- TaskBoardTab: state in appState.project.tasks.*

All follow Rule 57 (no $derived with new objects) and Rule 58
(state tree architecture, components are pure renderers).
This commit is contained in:
Hibryda 2026-03-24 15:20:09 +01:00
parent ae4c07c160
commit 162b5417e4
9 changed files with 870 additions and 400 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import Terminal from './Terminal.svelte';
import { appState } from './app-state.svelte.ts';
interface Props {
projectId: string;
@ -10,23 +11,10 @@
let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props();
interface TermTab {
id: string;
title: string;
}
// svelte-ignore state_referenced_locally
const initialId = projectId;
const firstTabId = `${initialId}-t1`;
let tabs = $state<TermTab[]>([{ id: firstTabId, title: 'shell 1' }]);
let activeTabId = $state<string>(firstTabId);
let expanded = $state(true);
let counter = $state(2);
let mounted = $state<Set<string>>(new Set([firstTabId]));
// Read terminal state from project state tree
function getTerminals() { return appState.project.getState(projectId).terminals; }
function blurTerminal() {
// Force-blur xterm canvas so UI buttons become clickable
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@ -34,37 +22,23 @@
function addTab() {
blurTerminal();
const id = `${projectId}-t${counter}`;
tabs = [...tabs, { id, title: `shell ${counter}` }];
counter++;
activeTabId = id;
mounted = new Set([...mounted, id]);
appState.project.terminals.addTab(projectId);
}
function closeTab(id: string, e: MouseEvent) {
e.stopPropagation();
blurTerminal();
const idx = tabs.findIndex(t => t.id === id);
tabs = tabs.filter(t => t.id !== id);
if (activeTabId === id) {
const next = tabs[Math.min(idx, tabs.length - 1)];
activeTabId = next?.id ?? '';
}
const m = new Set(mounted);
m.delete(id);
mounted = m;
appState.project.terminals.closeTab(projectId, id);
}
function activateTab(id: string) {
blurTerminal();
activeTabId = id;
if (!mounted.has(id)) mounted = new Set([...mounted, id]);
if (!expanded) expanded = true;
appState.project.terminals.activateTab(projectId, id);
}
function toggleExpand() {
blurTerminal();
expanded = !expanded;
appState.project.terminals.toggleExpanded(projectId);
}
</script>
@ -75,11 +49,11 @@
<button
class="expand-btn"
onclick={toggleExpand}
title={expanded ? 'Collapse terminal' : 'Expand terminal'}
title={getTerminals().expanded ? 'Collapse terminal' : 'Expand terminal'}
>
<svg
class="chevron"
class:open={expanded}
class:open={getTerminals().expanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@ -92,19 +66,19 @@
</button>
<div class="term-tabs" role="tablist">
{#each tabs as tab (tab.id)}
{#each getTerminals().tabs as tab (tab.id)}
<!-- div+role="tab" allows a nested <button> for the close action -->
<div
class="term-tab"
class:active={activeTabId === tab.id}
class:active={getTerminals().activeTabId === tab.id}
role="tab"
tabindex={activeTabId === tab.id ? 0 : -1}
aria-selected={activeTabId === tab.id}
tabindex={getTerminals().activeTabId === tab.id ? 0 : -1}
aria-selected={getTerminals().activeTabId === tab.id}
onclick={() => activateTab(tab.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
>
<span class="tab-label">{tab.title}</span>
{#if tabs.length > 1}
{#if getTerminals().tabs.length > 1}
<button
class="tab-close"
aria-label="Close {tab.title}"
@ -119,10 +93,10 @@
<!-- Terminal panes: always in DOM, display toggled.
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
<div class="term-panes" style:display={expanded ? 'block' : 'none'}>
{#each tabs as tab (tab.id)}
{#if mounted.has(tab.id)}
<div class="term-pane" style:display={activeTabId === tab.id ? 'flex' : 'none'}>
<div class="term-panes" style:display={getTerminals().expanded ? 'block' : 'none'}>
{#each getTerminals().tabs as tab (tab.id)}
{#if getTerminals().mounted.has(tab.id)}
<div class="term-pane" style:display={getTerminals().activeTabId === tab.id ? 'flex' : 'none'}>
<Terminal sessionId={tab.id} {cwd} />
</div>
{/if}