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:
parent
ae4c07c160
commit
162b5417e4
9 changed files with 870 additions and 400 deletions
|
|
@ -31,10 +31,12 @@
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let scrollEl: HTMLDivElement;
|
let scrollEl: HTMLDivElement;
|
||||||
|
// These need $state because Svelte 5 template bindings require reactive
|
||||||
|
// declarations. Rule 58 exception: component-scoped UI state that drives
|
||||||
|
// template output but has no cross-component readers.
|
||||||
let promptText = $state("");
|
let promptText = $state("");
|
||||||
let expandedTools = $state<Set<string>>(new Set());
|
let expandedTools = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
// Drag-resize state
|
|
||||||
let agentPaneEl: HTMLDivElement;
|
let agentPaneEl: HTMLDivElement;
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,29 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appRpc } from './rpc.ts';
|
import { appRpc } from './rpc.ts';
|
||||||
|
import { appState } from './app-state.svelte.ts';
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Channel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
groupId: string;
|
|
||||||
createdBy: string;
|
|
||||||
memberCount: number;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChannelMessage {
|
|
||||||
id: string;
|
|
||||||
channelId: string;
|
|
||||||
fromAgent: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
senderName: string;
|
|
||||||
senderRole: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Agent {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
groupId: string;
|
|
||||||
tier: number;
|
|
||||||
status: string;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DM {
|
|
||||||
id: string;
|
|
||||||
fromAgent: string;
|
|
||||||
toAgent: string;
|
|
||||||
content: string;
|
|
||||||
read: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
senderName: string | null;
|
|
||||||
senderRole: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
projectId: string;
|
||||||
/** Agent ID for this project's perspective (defaults to 'admin'). */
|
/** Agent ID for this project's perspective (defaults to 'admin'). */
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { groupId, agentId = 'admin' }: Props = $props();
|
let { groupId, projectId, agentId = 'admin' }: Props = $props();
|
||||||
|
|
||||||
// ── State ────────────────────────────────────────────────────────────
|
// Read comms state from project state tree
|
||||||
|
function getComms() { return appState.project.getState(projectId).comms; }
|
||||||
type TabMode = 'channels' | 'dms';
|
|
||||||
let mode = $state<TabMode>('channels');
|
|
||||||
|
|
||||||
let channels = $state<Channel[]>([]);
|
|
||||||
let agents = $state<Agent[]>([]);
|
|
||||||
let activeChannelId = $state<string | null>(null);
|
|
||||||
let activeDmAgentId = $state<string | null>(null);
|
|
||||||
let channelMessages = $state<ChannelMessage[]>([]);
|
|
||||||
let dmMessages = $state<DM[]>([]);
|
|
||||||
let input = $state('');
|
|
||||||
let loading = $state(false);
|
|
||||||
// Feature 7: Channel member list
|
|
||||||
let channelMembers = $state<Array<{ agentId: string; name: string; role: string }>>([]);
|
|
||||||
let showMembers = $state(false);
|
|
||||||
|
|
||||||
// ── Data fetching ────────────────────────────────────────────────────
|
// ── Data fetching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadChannels() {
|
async function loadChannels() {
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['btmsg.listChannels']({ groupId });
|
const res = await appRpc.request['btmsg.listChannels']({ groupId });
|
||||||
channels = res.channels;
|
appState.project.comms.setState(projectId, 'channels', res.channels);
|
||||||
if (channels.length > 0 && !activeChannelId) {
|
const c = getComms();
|
||||||
activeChannelId = channels[0].id;
|
if (c.channels.length > 0 && !c.activeChannelId) {
|
||||||
await loadChannelMessages(channels[0].id);
|
appState.project.comms.setState(projectId, 'activeChannelId', c.channels[0].id);
|
||||||
|
await loadChannelMessages(c.channels[0].id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CommsTab] loadChannels:', err);
|
console.error('[CommsTab] loadChannels:', err);
|
||||||
|
|
@ -86,7 +33,8 @@
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['btmsg.getAgents']({ groupId });
|
const res = await appRpc.request['btmsg.getAgents']({ groupId });
|
||||||
agents = res.agents.filter((a: Agent) => a.id !== agentId);
|
appState.project.comms.setState(projectId, 'agents',
|
||||||
|
res.agents.filter((a: { id: string }) => a.id !== agentId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CommsTab] loadAgents:', err);
|
console.error('[CommsTab] loadAgents:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -94,70 +42,69 @@
|
||||||
|
|
||||||
async function loadChannelMessages(channelId: string) {
|
async function loadChannelMessages(channelId: string) {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
appState.project.comms.setState(projectId, 'loading', true);
|
||||||
const res = await appRpc.request['btmsg.getChannelMessages']({
|
const res = await appRpc.request['btmsg.getChannelMessages']({
|
||||||
channelId, limit: 100,
|
channelId, limit: 100,
|
||||||
});
|
});
|
||||||
channelMessages = res.messages;
|
appState.project.comms.setState(projectId, 'channelMessages', res.messages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CommsTab] loadChannelMessages:', err);
|
console.error('[CommsTab] loadChannelMessages:', err);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
appState.project.comms.setState(projectId, 'loading', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDmMessages(otherId: string) {
|
async function loadDmMessages(otherId: string) {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
appState.project.comms.setState(projectId, 'loading', true);
|
||||||
const res = await appRpc.request['btmsg.listMessages']({
|
const res = await appRpc.request['btmsg.listMessages']({
|
||||||
agentId, otherId, limit: 50,
|
agentId, otherId, limit: 50,
|
||||||
});
|
});
|
||||||
dmMessages = res.messages;
|
appState.project.comms.setState(projectId, 'dmMessages', res.messages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CommsTab] loadDmMessages:', err);
|
console.error('[CommsTab] loadDmMessages:', err);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
appState.project.comms.setState(projectId, 'loading', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectChannel(id: string) {
|
function selectChannel(id: string) {
|
||||||
activeChannelId = id;
|
appState.project.comms.setMulti(projectId, { activeChannelId: id, showMembers: false });
|
||||||
showMembers = false;
|
|
||||||
loadChannelMessages(id);
|
loadChannelMessages(id);
|
||||||
loadChannelMembers(id);
|
loadChannelMembers(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature 7: Load channel members
|
|
||||||
async function loadChannelMembers(channelId: string) {
|
async function loadChannelMembers(channelId: string) {
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
|
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
|
||||||
channelMembers = res.members;
|
appState.project.comms.setState(projectId, 'channelMembers', res.members);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CommsTab] loadChannelMembers:', err);
|
console.error('[CommsTab] loadChannelMembers:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDm(otherId: string) {
|
function selectDm(otherId: string) {
|
||||||
activeDmAgentId = otherId;
|
appState.project.comms.setState(projectId, 'activeDmAgentId', otherId);
|
||||||
loadDmMessages(otherId);
|
loadDmMessages(otherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = input.trim();
|
const c = getComms();
|
||||||
|
const text = c.input.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
input = '';
|
appState.project.comms.setState(projectId, 'input', '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === 'channels' && activeChannelId) {
|
if (c.mode === 'channels' && c.activeChannelId) {
|
||||||
await appRpc.request['btmsg.sendChannelMessage']({
|
await appRpc.request['btmsg.sendChannelMessage']({
|
||||||
channelId: activeChannelId, fromAgent: agentId, content: text,
|
channelId: c.activeChannelId, fromAgent: agentId, content: text,
|
||||||
});
|
});
|
||||||
await loadChannelMessages(activeChannelId);
|
await loadChannelMessages(c.activeChannelId);
|
||||||
} else if (mode === 'dms' && activeDmAgentId) {
|
} else if (c.mode === 'dms' && c.activeDmAgentId) {
|
||||||
await appRpc.request['btmsg.sendMessage']({
|
await appRpc.request['btmsg.sendMessage']({
|
||||||
fromAgent: agentId, toAgent: activeDmAgentId, content: text,
|
fromAgent: agentId, toAgent: c.activeDmAgentId, content: text,
|
||||||
});
|
});
|
||||||
await loadDmMessages(activeDmAgentId);
|
await loadDmMessages(c.activeDmAgentId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CommsTab] sendMessage:', err);
|
console.error('[CommsTab] sendMessage:', err);
|
||||||
|
|
@ -175,28 +122,28 @@
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// Feature 4: Listen for push events
|
|
||||||
function onNewMessage(payload: { groupId: string; channelId?: string }) {
|
function onNewMessage(payload: { groupId: string; channelId?: string }) {
|
||||||
if (mode === 'channels' && activeChannelId) {
|
const c = getComms();
|
||||||
if (!payload.channelId || payload.channelId === activeChannelId) {
|
if (c.mode === 'channels' && c.activeChannelId) {
|
||||||
loadChannelMessages(activeChannelId);
|
if (!payload.channelId || payload.channelId === c.activeChannelId) {
|
||||||
|
loadChannelMessages(c.activeChannelId);
|
||||||
}
|
}
|
||||||
} else if (mode === 'dms' && activeDmAgentId) {
|
} else if (c.mode === 'dms' && c.activeDmAgentId) {
|
||||||
loadDmMessages(activeDmAgentId);
|
loadDmMessages(c.activeDmAgentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use onMount instead of $effect — loadChannels/loadAgents write to $state
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadChannels();
|
loadChannels();
|
||||||
loadAgents();
|
loadAgents();
|
||||||
appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
|
appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
if (mode === 'channels' && activeChannelId) {
|
const c = getComms();
|
||||||
loadChannelMessages(activeChannelId);
|
if (c.mode === 'channels' && c.activeChannelId) {
|
||||||
} else if (mode === 'dms' && activeDmAgentId) {
|
loadChannelMessages(c.activeChannelId);
|
||||||
loadDmMessages(activeDmAgentId);
|
} else if (c.mode === 'dms' && c.activeDmAgentId) {
|
||||||
|
loadDmMessages(c.activeDmAgentId);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -211,38 +158,38 @@
|
||||||
<div class="comms-mode-bar">
|
<div class="comms-mode-bar">
|
||||||
<button
|
<button
|
||||||
class="mode-btn"
|
class="mode-btn"
|
||||||
class:active={mode === 'channels'}
|
class:active={getComms().mode === 'channels'}
|
||||||
onclick={() => { mode = 'channels'; }}
|
onclick={() => { appState.project.comms.setState(projectId, 'mode', 'channels'); }}
|
||||||
>Channels</button>
|
>Channels</button>
|
||||||
<button
|
<button
|
||||||
class="mode-btn"
|
class="mode-btn"
|
||||||
class:active={mode === 'dms'}
|
class:active={getComms().mode === 'dms'}
|
||||||
onclick={() => { mode = 'dms'; loadAgents(); }}
|
onclick={() => { appState.project.comms.setState(projectId, 'mode', 'dms'); loadAgents(); }}
|
||||||
>DMs</button>
|
>DMs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="comms-body">
|
<div class="comms-body">
|
||||||
<!-- Sidebar: channel list or agent list -->
|
<!-- Sidebar: channel list or agent list -->
|
||||||
<div class="comms-sidebar">
|
<div class="comms-sidebar">
|
||||||
{#if mode === 'channels'}
|
{#if getComms().mode === 'channels'}
|
||||||
{#each channels as ch}
|
{#each getComms().channels as ch}
|
||||||
<button
|
<button
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
class:active={activeChannelId === ch.id}
|
class:active={getComms().activeChannelId === ch.id}
|
||||||
onclick={() => selectChannel(ch.id)}
|
onclick={() => selectChannel(ch.id)}
|
||||||
>
|
>
|
||||||
<span class="ch-hash">#</span>
|
<span class="ch-hash">#</span>
|
||||||
<span class="ch-name">{ch.name}</span>
|
<span class="ch-name">{ch.name}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if channels.length === 0}
|
{#if getComms().channels.length === 0}
|
||||||
<div class="sidebar-empty">No channels</div>
|
<div class="sidebar-empty">No channels</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#each agents as ag}
|
{#each getComms().agents as ag}
|
||||||
<button
|
<button
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
class:active={activeDmAgentId === ag.id}
|
class:active={getComms().activeDmAgentId === ag.id}
|
||||||
onclick={() => selectDm(ag.id)}
|
onclick={() => selectDm(ag.id)}
|
||||||
>
|
>
|
||||||
<span class="agent-dot {ag.status}"></span>
|
<span class="agent-dot {ag.status}"></span>
|
||||||
|
|
@ -253,7 +200,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if agents.length === 0}
|
{#if getComms().agents.length === 0}
|
||||||
<div class="sidebar-empty">No agents</div>
|
<div class="sidebar-empty">No agents</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -261,11 +208,11 @@
|
||||||
|
|
||||||
<!-- Message area -->
|
<!-- Message area -->
|
||||||
<div class="comms-messages">
|
<div class="comms-messages">
|
||||||
{#if loading}
|
{#if getComms().loading}
|
||||||
<div class="msg-loading">Loading...</div>
|
<div class="msg-loading">Loading...</div>
|
||||||
{:else if mode === 'channels'}
|
{:else if getComms().mode === 'channels'}
|
||||||
<div class="msg-list">
|
<div class="msg-list">
|
||||||
{#each channelMessages as msg}
|
{#each getComms().channelMessages as msg}
|
||||||
<div class="msg-row">
|
<div class="msg-row">
|
||||||
<span class="msg-sender">{msg.senderName}</span>
|
<span class="msg-sender">{msg.senderName}</span>
|
||||||
<span class="msg-role">{msg.senderRole}</span>
|
<span class="msg-role">{msg.senderRole}</span>
|
||||||
|
|
@ -273,36 +220,36 @@
|
||||||
<div class="msg-content">{msg.content}</div>
|
<div class="msg-content">{msg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if channelMessages.length === 0}
|
{#if getComms().channelMessages.length === 0}
|
||||||
<div class="msg-empty">No messages in this channel</div>
|
<div class="msg-empty">No messages in this channel</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="msg-list">
|
<div class="msg-list">
|
||||||
{#each dmMessages as msg}
|
{#each getComms().dmMessages as msg}
|
||||||
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
|
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
|
||||||
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
|
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
|
||||||
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
|
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
|
||||||
<div class="msg-content">{msg.content}</div>
|
<div class="msg-content">{msg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if dmMessages.length === 0 && activeDmAgentId}
|
{#if getComms().dmMessages.length === 0 && getComms().activeDmAgentId}
|
||||||
<div class="msg-empty">No messages yet</div>
|
<div class="msg-empty">No messages yet</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !activeDmAgentId}
|
{#if !getComms().activeDmAgentId}
|
||||||
<div class="msg-empty">Select an agent to message</div>
|
<div class="msg-empty">Select an agent to message</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Feature 7: Channel member list toggle -->
|
<!-- Feature 7: Channel member list toggle -->
|
||||||
{#if mode === 'channels' && activeChannelId}
|
{#if getComms().mode === 'channels' && getComms().activeChannelId}
|
||||||
<button class="members-toggle" onclick={() => showMembers = !showMembers}>
|
<button class="members-toggle" onclick={() => appState.project.comms.setState(projectId, 'showMembers', !getComms().showMembers)}>
|
||||||
Members ({channelMembers.length})
|
Members ({getComms().channelMembers.length})
|
||||||
</button>
|
</button>
|
||||||
{#if showMembers}
|
{#if getComms().showMembers}
|
||||||
<div class="members-list">
|
<div class="members-list">
|
||||||
{#each channelMembers as m}
|
{#each getComms().channelMembers as m}
|
||||||
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
|
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,11 +261,12 @@
|
||||||
<input
|
<input
|
||||||
class="msg-input"
|
class="msg-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={mode === 'channels' ? 'Message channel...' : 'Send DM...'}
|
placeholder={getComms().mode === 'channels' ? 'Message channel...' : 'Send DM...'}
|
||||||
bind:value={input}
|
value={getComms().input}
|
||||||
|
oninput={(e) => appState.project.comms.setState(projectId, 'input', (e.target as HTMLInputElement).value)}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
/>
|
/>
|
||||||
<button class="msg-send-btn" onclick={sendMessage} disabled={!input.trim()}>
|
<button class="msg-send-btn" onclick={sendMessage} disabled={!getComms().input.trim()}>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,38 +3,17 @@
|
||||||
import CodeEditor from './CodeEditor.svelte';
|
import CodeEditor from './CodeEditor.svelte';
|
||||||
import PdfViewer from './PdfViewer.svelte';
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
import CsvTable from './CsvTable.svelte';
|
import CsvTable from './CsvTable.svelte';
|
||||||
|
import { appState } from './app-state.svelte.ts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { cwd }: Props = $props();
|
let { cwd, projectId }: Props = $props();
|
||||||
|
|
||||||
interface DirEntry {
|
// Read file state from project state tree
|
||||||
name: string;
|
function getFiles() { return appState.project.getState(projectId).files; }
|
||||||
type: 'file' | 'dir';
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tree state: track loaded children and open/closed per path
|
|
||||||
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
|
|
||||||
let openDirs = $state<Set<string>>(new Set());
|
|
||||||
let loadingDirs = $state<Set<string>>(new Set());
|
|
||||||
let selectedFile = $state<string | null>(null);
|
|
||||||
|
|
||||||
// File viewer state
|
|
||||||
let fileContent = $state<string | null>(null);
|
|
||||||
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
|
|
||||||
let fileSize = $state(0);
|
|
||||||
let fileError = $state<string | null>(null);
|
|
||||||
let fileLoading = $state(false);
|
|
||||||
let isDirty = $state(false);
|
|
||||||
let editorContent = $state('');
|
|
||||||
// Fix #6: Request token to discard stale file load responses
|
|
||||||
let fileRequestToken = 0;
|
|
||||||
// Feature 2: Track mtime at read time for conflict detection
|
|
||||||
let readMtimeMs = $state(0);
|
|
||||||
let showConflictDialog = $state(false);
|
|
||||||
|
|
||||||
// Extension-based type detection
|
// Extension-based type detection
|
||||||
const CODE_EXTS = new Set([
|
const CODE_EXTS = new Set([
|
||||||
|
|
@ -81,117 +60,124 @@
|
||||||
return map[ext] ?? 'text';
|
return map[ext] ?? 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
|
function getSelectedType(): FileType {
|
||||||
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
|
const f = getFiles().selectedFile;
|
||||||
|
return f ? detectFileType(f) : 'text';
|
||||||
|
}
|
||||||
|
function getSelectedName(): string {
|
||||||
|
const f = getFiles().selectedFile;
|
||||||
|
return f ? f.split('/').pop() ?? '' : '';
|
||||||
|
}
|
||||||
|
|
||||||
/** Load directory entries via RPC. */
|
/** Load directory entries via RPC. */
|
||||||
async function loadDir(dirPath: string) {
|
async function loadDir(dirPath: string) {
|
||||||
if (childrenCache.has(dirPath)) return;
|
const f = getFiles();
|
||||||
const key = dirPath;
|
if (f.childrenCache.has(dirPath)) return;
|
||||||
loadingDirs = new Set([...loadingDirs, key]);
|
f.loadingDirs.add(dirPath);
|
||||||
|
appState.project.files.setState(projectId, 'loadingDirs', new Set(f.loadingDirs));
|
||||||
try {
|
try {
|
||||||
const result = await appRpc.request["files.list"]({ path: dirPath });
|
const result = await appRpc.request["files.list"]({ path: dirPath });
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error(`[files.list] ${dirPath}: ${result.error}`);
|
console.error(`[files.list] ${dirPath}: ${result.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = new Map(childrenCache);
|
f.childrenCache.set(dirPath, result.entries);
|
||||||
next.set(dirPath, result.entries);
|
appState.project.files.setState(projectId, 'childrenCache', new Map(f.childrenCache));
|
||||||
childrenCache = next;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[files.list]', err);
|
console.error('[files.list]', err);
|
||||||
} finally {
|
} finally {
|
||||||
const s = new Set(loadingDirs);
|
f.loadingDirs.delete(dirPath);
|
||||||
s.delete(key);
|
appState.project.files.setState(projectId, 'loadingDirs', new Set(f.loadingDirs));
|
||||||
loadingDirs = s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle a directory open/closed. Lazy-loads on first open. */
|
/** Toggle a directory open/closed. Lazy-loads on first open. */
|
||||||
async function toggleDir(dirPath: string) {
|
async function toggleDir(dirPath: string) {
|
||||||
const s = new Set(openDirs);
|
const f = getFiles();
|
||||||
|
const s = new Set(f.openDirs);
|
||||||
if (s.has(dirPath)) {
|
if (s.has(dirPath)) {
|
||||||
s.delete(dirPath);
|
s.delete(dirPath);
|
||||||
openDirs = s;
|
|
||||||
} else {
|
} else {
|
||||||
s.add(dirPath);
|
s.add(dirPath);
|
||||||
openDirs = s;
|
|
||||||
await loadDir(dirPath);
|
|
||||||
}
|
}
|
||||||
|
appState.project.files.setState(projectId, 'openDirs', s);
|
||||||
|
if (s.has(dirPath)) await loadDir(dirPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select and load a file. Fix #6: uses request token to discard stale responses. */
|
/** Select and load a file. Fix #6: uses request token to discard stale responses. */
|
||||||
async function selectFile(filePath: string) {
|
async function selectFile(filePath: string) {
|
||||||
if (selectedFile === filePath) return;
|
const f = getFiles();
|
||||||
selectedFile = filePath;
|
if (f.selectedFile === filePath) return;
|
||||||
isDirty = false;
|
appState.project.files.setMulti(projectId, {
|
||||||
fileContent = null;
|
selectedFile: filePath, isDirty: false, fileContent: null,
|
||||||
fileError = null;
|
fileError: null, fileLoading: true,
|
||||||
fileLoading = true;
|
});
|
||||||
const token = ++fileRequestToken;
|
const token = appState.project.files.nextRequestToken(projectId);
|
||||||
|
|
||||||
const type = detectFileType(filePath);
|
const type = detectFileType(filePath);
|
||||||
|
|
||||||
// PDF uses its own loader via PdfViewer
|
|
||||||
if (type === 'pdf') {
|
if (type === 'pdf') {
|
||||||
fileLoading = false;
|
appState.project.files.setState(projectId, 'fileLoading', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Images: read as base64 for display
|
|
||||||
if (type === 'image') {
|
if (type === 'image') {
|
||||||
try {
|
try {
|
||||||
const result = await appRpc.request["files.read"]({ path: filePath });
|
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||||
if (token !== fileRequestToken) return;
|
if (token !== appState.project.files.getRequestToken(projectId)) return;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
fileError = result.error;
|
appState.project.files.setState(projectId, 'fileError', result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileContent = result.content ?? '';
|
appState.project.files.setMulti(projectId, {
|
||||||
fileEncoding = result.encoding;
|
fileContent: result.content ?? '', fileEncoding: result.encoding, fileSize: result.size,
|
||||||
fileSize = result.size;
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (token !== fileRequestToken) return;
|
if (token !== appState.project.files.getRequestToken(projectId)) return;
|
||||||
fileError = err instanceof Error ? err.message : String(err);
|
appState.project.files.setState(projectId, 'fileError', err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
if (token === fileRequestToken) fileLoading = false;
|
if (token === appState.project.files.getRequestToken(projectId)) {
|
||||||
|
appState.project.files.setState(projectId, 'fileLoading', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await appRpc.request["files.read"]({ path: filePath });
|
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||||
if (token !== fileRequestToken) return;
|
if (token !== appState.project.files.getRequestToken(projectId)) return;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
fileError = result.error;
|
appState.project.files.setState(projectId, 'fileError', result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileContent = result.content ?? '';
|
appState.project.files.setMulti(projectId, {
|
||||||
fileEncoding = result.encoding;
|
fileContent: result.content ?? '', fileEncoding: result.encoding,
|
||||||
fileSize = result.size;
|
fileSize: result.size, editorContent: result.content ?? '',
|
||||||
editorContent = fileContent;
|
});
|
||||||
// Feature 2: Record mtime at read time
|
|
||||||
try {
|
try {
|
||||||
const stat = await appRpc.request["files.stat"]({ path: filePath });
|
const stat = await appRpc.request["files.stat"]({ path: filePath });
|
||||||
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs;
|
if (token === appState.project.files.getRequestToken(projectId) && !stat.error) {
|
||||||
|
appState.project.files.setState(projectId, 'readMtimeMs', stat.mtimeMs);
|
||||||
|
}
|
||||||
} catch { /* non-critical */ }
|
} catch { /* non-critical */ }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (token !== fileRequestToken) return;
|
if (token !== appState.project.files.getRequestToken(projectId)) return;
|
||||||
fileError = err instanceof Error ? err.message : String(err);
|
appState.project.files.setState(projectId, 'fileError', err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
if (token === fileRequestToken) fileLoading = false;
|
if (token === appState.project.files.getRequestToken(projectId)) {
|
||||||
|
appState.project.files.setState(projectId, 'fileLoading', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
|
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
|
||||||
async function saveFile() {
|
async function saveFile() {
|
||||||
if (!selectedFile || !isDirty) return;
|
const f = getFiles();
|
||||||
|
if (!f.selectedFile || !f.isDirty) return;
|
||||||
try {
|
try {
|
||||||
// Feature 2: Check if file was modified externally since we read it
|
if (f.readMtimeMs > 0) {
|
||||||
if (readMtimeMs > 0) {
|
const stat = await appRpc.request["files.stat"]({ path: f.selectedFile });
|
||||||
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
if (!stat.error && stat.mtimeMs > f.readMtimeMs) {
|
||||||
if (!stat.error && stat.mtimeMs > readMtimeMs) {
|
appState.project.files.setState(projectId, 'showConflictDialog', true);
|
||||||
showConflictDialog = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,19 +189,18 @@
|
||||||
|
|
||||||
/** Force-save, bypassing conflict check. */
|
/** Force-save, bypassing conflict check. */
|
||||||
async function doSave() {
|
async function doSave() {
|
||||||
if (!selectedFile) return;
|
const f = getFiles();
|
||||||
|
if (!f.selectedFile) return;
|
||||||
try {
|
try {
|
||||||
const result = await appRpc.request["files.write"]({
|
const result = await appRpc.request["files.write"]({
|
||||||
path: selectedFile,
|
path: f.selectedFile, content: f.editorContent,
|
||||||
content: editorContent,
|
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
isDirty = false;
|
appState.project.files.setMulti(projectId, {
|
||||||
fileContent = editorContent;
|
isDirty: false, fileContent: f.editorContent, showConflictDialog: false,
|
||||||
showConflictDialog = false;
|
});
|
||||||
// Update mtime after successful save
|
const stat = await appRpc.request["files.stat"]({ path: f.selectedFile });
|
||||||
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
if (!stat.error) appState.project.files.setState(projectId, 'readMtimeMs', stat.mtimeMs);
|
||||||
if (!stat.error) readMtimeMs = stat.mtimeMs;
|
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
console.error('[files.write]', result.error);
|
console.error('[files.write]', result.error);
|
||||||
}
|
}
|
||||||
|
|
@ -226,18 +211,21 @@
|
||||||
|
|
||||||
/** Reload file from disk (discard local changes). */
|
/** Reload file from disk (discard local changes). */
|
||||||
async function reloadFile() {
|
async function reloadFile() {
|
||||||
showConflictDialog = false;
|
appState.project.files.setState(projectId, 'showConflictDialog', false);
|
||||||
if (selectedFile) {
|
const f = getFiles();
|
||||||
isDirty = false;
|
if (f.selectedFile) {
|
||||||
const saved = selectedFile;
|
appState.project.files.setState(projectId, 'isDirty', false);
|
||||||
selectedFile = null;
|
const saved = f.selectedFile;
|
||||||
|
appState.project.files.setState(projectId, 'selectedFile', null);
|
||||||
await selectFile(saved);
|
await selectFile(saved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditorChange(newContent: string) {
|
function onEditorChange(newContent: string) {
|
||||||
editorContent = newContent;
|
const f = getFiles();
|
||||||
isDirty = newContent !== fileContent;
|
appState.project.files.setMulti(projectId, {
|
||||||
|
editorContent: newContent, isDirty: newContent !== f.fileContent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileIcon(name: string): string {
|
function fileIcon(name: string): string {
|
||||||
|
|
@ -264,12 +252,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load root directory on mount — use onMount, NOT $effect
|
// Load root directory on mount — use onMount, NOT $effect
|
||||||
// $effect reads openDirs (via new Set(openDirs)) and writes to it → infinite loop
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (cwd) {
|
if (cwd) {
|
||||||
loadDir(cwd);
|
loadDir(cwd);
|
||||||
openDirs = new Set([cwd]);
|
appState.project.files.setState(projectId, 'openDirs', new Set([cwd]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -278,41 +265,41 @@
|
||||||
<!-- Tree panel -->
|
<!-- Tree panel -->
|
||||||
<div class="fb-tree">
|
<div class="fb-tree">
|
||||||
{#snippet renderEntries(dirPath: string, depth: number)}
|
{#snippet renderEntries(dirPath: string, depth: number)}
|
||||||
{#if childrenCache.has(dirPath)}
|
{#if getFiles().childrenCache.has(dirPath)}
|
||||||
{#each childrenCache.get(dirPath) ?? [] as entry}
|
{#each getFiles().childrenCache.get(dirPath) ?? [] as entry}
|
||||||
{@const fullPath = `${dirPath}/${entry.name}`}
|
{@const fullPath = `${dirPath}/${entry.name}`}
|
||||||
{#if entry.type === 'dir'}
|
{#if entry.type === 'dir'}
|
||||||
<button
|
<button
|
||||||
class="fb-row fb-dir"
|
class="fb-row fb-dir"
|
||||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||||
onclick={() => toggleDir(fullPath)}
|
onclick={() => toggleDir(fullPath)}
|
||||||
aria-expanded={openDirs.has(fullPath)}
|
aria-expanded={getFiles().openDirs.has(fullPath)}
|
||||||
>
|
>
|
||||||
<span class="fb-chevron" class:open={openDirs.has(fullPath)}>
|
<span class="fb-chevron" class:open={getFiles().openDirs.has(fullPath)}>
|
||||||
{#if loadingDirs.has(fullPath)}...{:else}>{/if}
|
{#if getFiles().loadingDirs.has(fullPath)}...{:else}>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="fb-name">{entry.name}</span>
|
<span class="fb-name">{entry.name}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if openDirs.has(fullPath)}
|
{#if getFiles().openDirs.has(fullPath)}
|
||||||
{@render renderEntries(fullPath, depth + 1)}
|
{@render renderEntries(fullPath, depth + 1)}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="fb-row fb-file"
|
class="fb-row fb-file"
|
||||||
class:selected={selectedFile === fullPath}
|
class:selected={getFiles().selectedFile === fullPath}
|
||||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||||
onclick={() => selectFile(fullPath)}
|
onclick={() => selectFile(fullPath)}
|
||||||
title={`${entry.name} (${formatSize(entry.size)})`}
|
title={`${entry.name} (${formatSize(entry.size)})`}
|
||||||
>
|
>
|
||||||
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
|
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
|
||||||
<span class="fb-name">{entry.name}</span>
|
<span class="fb-name">{entry.name}</span>
|
||||||
{#if selectedFile === fullPath && isDirty}
|
{#if getFiles().selectedFile === fullPath && getFiles().isDirty}
|
||||||
<span class="dirty-dot" title="Unsaved changes"></span>
|
<span class="dirty-dot" title="Unsaved changes"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{:else if loadingDirs.has(dirPath)}
|
{:else if getFiles().loadingDirs.has(dirPath)}
|
||||||
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
|
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
@ -321,7 +308,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feature 2: Conflict dialog -->
|
<!-- Feature 2: Conflict dialog -->
|
||||||
{#if showConflictDialog}
|
{#if getFiles().showConflictDialog}
|
||||||
<div class="conflict-overlay">
|
<div class="conflict-overlay">
|
||||||
<div class="conflict-dialog">
|
<div class="conflict-dialog">
|
||||||
<p class="conflict-title">File modified externally</p>
|
<p class="conflict-title">File modified externally</p>
|
||||||
|
|
@ -329,7 +316,7 @@
|
||||||
<div class="conflict-actions">
|
<div class="conflict-actions">
|
||||||
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
|
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
|
||||||
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
|
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
|
||||||
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button>
|
<button class="conflict-btn cancel" onclick={() => appState.project.files.setState(projectId, 'showConflictDialog', false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -337,53 +324,53 @@
|
||||||
|
|
||||||
<!-- Viewer panel -->
|
<!-- Viewer panel -->
|
||||||
<div class="fb-viewer">
|
<div class="fb-viewer">
|
||||||
{#if !selectedFile}
|
{#if !getFiles().selectedFile}
|
||||||
<div class="fb-empty">Select a file to view</div>
|
<div class="fb-empty">Select a file to view</div>
|
||||||
{:else if fileLoading}
|
{:else if getFiles().fileLoading}
|
||||||
<div class="fb-empty">Loading...</div>
|
<div class="fb-empty">Loading...</div>
|
||||||
{:else if fileError}
|
{:else if getFiles().fileError}
|
||||||
<div class="fb-error">{fileError}</div>
|
<div class="fb-error">{getFiles().fileError}</div>
|
||||||
{:else if selectedType === 'pdf'}
|
{:else if getSelectedType() === 'pdf'}
|
||||||
<PdfViewer filePath={selectedFile} />
|
<PdfViewer filePath={getFiles().selectedFile ?? ''} />
|
||||||
{:else if selectedType === 'csv' && fileContent != null}
|
{:else if getSelectedType() === 'csv' && getFiles().fileContent != null}
|
||||||
<CsvTable content={fileContent} filename={selectedName} />
|
<CsvTable content={getFiles().fileContent ?? ''} filename={getSelectedName()} />
|
||||||
{:else if selectedType === 'image' && fileContent}
|
{:else if getSelectedType() === 'image' && getFiles().fileContent}
|
||||||
{@const ext = getExt(selectedName)}
|
{@const ext = getExt(getSelectedName())}
|
||||||
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
|
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
|
||||||
<div class="fb-image-wrap">
|
<div class="fb-image-wrap">
|
||||||
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
|
<div class="fb-image-label">{getSelectedName()} ({formatSize(getFiles().fileSize)})</div>
|
||||||
<img
|
<img
|
||||||
class="fb-image"
|
class="fb-image"
|
||||||
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
|
src="data:{mime};base64,{getFiles().fileEncoding === 'base64' ? getFiles().fileContent : btoa(getFiles().fileContent ?? '')}"
|
||||||
alt={selectedName}
|
alt={getSelectedName()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if selectedType === 'code' && fileContent != null}
|
{:else if getSelectedType() === 'code' && getFiles().fileContent != null}
|
||||||
<div class="fb-editor-header">
|
<div class="fb-editor-header">
|
||||||
<span class="fb-editor-path" title={selectedFile}>
|
<span class="fb-editor-path" title={getFiles().selectedFile ?? ''}>
|
||||||
{selectedName}
|
{getSelectedName()}
|
||||||
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
{#if getFiles().isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
<span class="fb-editor-size">{formatSize(getFiles().fileSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
content={fileContent}
|
content={getFiles().fileContent ?? ''}
|
||||||
lang={extToLang(selectedFile)}
|
lang={extToLang(getFiles().selectedFile ?? '')}
|
||||||
onsave={saveFile}
|
onsave={saveFile}
|
||||||
onchange={onEditorChange}
|
onchange={onEditorChange}
|
||||||
onblur={saveFile}
|
onblur={saveFile}
|
||||||
/>
|
/>
|
||||||
{:else if fileContent != null}
|
{:else if getFiles().fileContent != null}
|
||||||
<!-- Raw text fallback -->
|
<!-- Raw text fallback -->
|
||||||
<div class="fb-editor-header">
|
<div class="fb-editor-header">
|
||||||
<span class="fb-editor-path" title={selectedFile}>
|
<span class="fb-editor-path" title={getFiles().selectedFile ?? ''}>
|
||||||
{selectedName}
|
{getSelectedName()}
|
||||||
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
{#if getFiles().isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
<span class="fb-editor-size">{formatSize(getFiles().fileSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
content={fileContent}
|
content={getFiles().fileContent ?? ''}
|
||||||
lang="text"
|
lang="text"
|
||||||
onsave={saveFile}
|
onsave={saveFile}
|
||||||
onchange={onEditorChange}
|
onchange={onEditorChange}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,8 @@
|
||||||
import DocsTab from './DocsTab.svelte';
|
import DocsTab from './DocsTab.svelte';
|
||||||
import SshTab from './SshTab.svelte';
|
import SshTab from './SshTab.svelte';
|
||||||
import StatusDot from './ui/StatusDot.svelte';
|
import StatusDot from './ui/StatusDot.svelte';
|
||||||
import {
|
import { appState, type AgentStatus, type AgentMessage, type ProjectTab } from './app-state.svelte.ts';
|
||||||
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
import { ALL_TABS } from './project-tabs-store.svelte.ts';
|
||||||
loadLastSession,
|
|
||||||
type AgentStatus, type AgentMessage,
|
|
||||||
} from './agent-store.svelte.ts';
|
|
||||||
import {
|
|
||||||
getActiveTab, setActiveTab, isTabActivated,
|
|
||||||
ALL_TABS, type ProjectTab,
|
|
||||||
} from './project-tabs-store.svelte.ts';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -61,9 +54,7 @@
|
||||||
|
|
||||||
// ── Agent session (reactive from store) ──────────────────────────
|
// ── Agent session (reactive from store) ──────────────────────────
|
||||||
const EMPTY_MESSAGES: AgentMessage[] = [];
|
const EMPTY_MESSAGES: AgentMessage[] = [];
|
||||||
// ALL agent session state as plain getter functions — NO $derived.
|
function getAgentSession() { return appState.agent.getSession(id); }
|
||||||
// $derived + store getters that return new refs = infinite loops in Svelte 5.
|
|
||||||
function getAgentSession() { return getSession(id); }
|
|
||||||
function getAgentStatus(): AgentStatus { return getAgentSession()?.status ?? 'idle'; }
|
function getAgentStatus(): AgentStatus { return getAgentSession()?.status ?? 'idle'; }
|
||||||
function getAgentMessages(): AgentMessage[] { return getAgentSession()?.messages ?? []; }
|
function getAgentMessages(): AgentMessage[] { return getAgentSession()?.messages ?? []; }
|
||||||
function getAgentCost(): number { return getAgentSession()?.costUsd ?? 0; }
|
function getAgentCost(): number { return getAgentSession()?.costUsd ?? 0; }
|
||||||
|
|
@ -107,7 +98,7 @@
|
||||||
return getAgentMessages().length > 0 ? getAgentMessages() : EMPTY_MESSAGES;
|
return getAgentMessages().length > 0 ? getAgentMessages() : EMPTY_MESSAGES;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clone dialog state ──────────────────────────────────────────
|
// ── Clone dialog state (needs $state for template bindings) ─────
|
||||||
let showCloneDialog = $state(false);
|
let showCloneDialog = $state(false);
|
||||||
let cloneBranchName = $state('');
|
let cloneBranchName = $state('');
|
||||||
let cloneError = $state('');
|
let cloneError = $state('');
|
||||||
|
|
@ -129,33 +120,31 @@
|
||||||
showCloneDialog = false;
|
showCloneDialog = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from project-tabs-store for reactive reads
|
// Tab state from project state tree
|
||||||
function getCurrentTab() { return getActiveTab(id); }
|
function getCurrentTab() { return appState.project.tab.getActiveTab(id); }
|
||||||
|
|
||||||
// ── Load last session on mount (once, not reactive) ─────────────────
|
// ── Load last session on mount (once, not reactive) ─────────────────
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
onMount(() => { loadLastSession(id); });
|
onMount(() => { appState.agent.loadLastSession(id); });
|
||||||
|
|
||||||
function setTab(tab: ProjectTab) {
|
function setTab(tab: ProjectTab) {
|
||||||
setActiveTab(id, tab);
|
appState.project.tab.setActiveTab(id, tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSend(text: string) {
|
function handleSend(text: string) {
|
||||||
if (hasSession(id)) {
|
if (appState.agent.hasSession(id)) {
|
||||||
// Session exists — send follow-up prompt
|
appState.agent.sendPrompt(id, text).catch((err) => {
|
||||||
sendPrompt(id, text).catch((err) => {
|
|
||||||
console.error('[agent.prompt] error:', err);
|
console.error('[agent.prompt] error:', err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No session — start a new agent
|
appState.agent.startAgent(id, provider, text, { cwd, model }).catch((err) => {
|
||||||
startAgent(id, provider, text, { cwd, model }).catch((err) => {
|
|
||||||
console.error('[agent.start] error:', err);
|
console.error('[agent.start] error:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
stopAgent(id).catch((err) => {
|
appState.agent.stopAgent(id).catch((err) => {
|
||||||
console.error('[agent.stop] error:', err);
|
console.error('[agent.stop] error:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +362,7 @@
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-label="Files"
|
aria-label="Files"
|
||||||
>
|
>
|
||||||
<FileBrowser {cwd} />
|
<FileBrowser {cwd} projectId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SSH tab -->
|
<!-- SSH tab -->
|
||||||
|
|
@ -409,7 +398,7 @@
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-label="Comms"
|
aria-label="Comms"
|
||||||
>
|
>
|
||||||
<CommsTab {groupId} agentId={id} />
|
<CommsTab {groupId} projectId={id} agentId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tasks tab (kanban board) -->
|
<!-- Tasks tab (kanban board) -->
|
||||||
|
|
@ -421,7 +410,7 @@
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-label="Tasks"
|
aria-label="Tasks"
|
||||||
>
|
>
|
||||||
<TaskBoardTab {groupId} agentId={id} />
|
<TaskBoardTab {groupId} projectId={id} agentId={id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appRpc } from './rpc.ts';
|
import { appRpc } from './rpc.ts';
|
||||||
|
import { appState, type Task } from './app-state.svelte.ts';
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Task {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
assignedTo: string | null;
|
|
||||||
createdBy: string;
|
|
||||||
groupId: string;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
projectId: string;
|
||||||
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
|
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { groupId, agentId = 'admin' }: Props = $props();
|
let { groupId, projectId, agentId = 'admin' }: Props = $props();
|
||||||
|
|
||||||
// ── State ────────────────────────────────────────────────────────────
|
// Read task state from project state tree
|
||||||
|
function getTasks() { return appState.project.getState(projectId).tasks; }
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
|
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
|
||||||
const COL_LABELS: Record<string, string> = {
|
const COL_LABELS: Record<string, string> = {
|
||||||
|
|
@ -35,26 +26,11 @@
|
||||||
low: 'var(--ctp-teal)',
|
low: 'var(--ctp-teal)',
|
||||||
};
|
};
|
||||||
|
|
||||||
let tasks = $state<Task[]>([]);
|
|
||||||
let showCreateForm = $state(false);
|
|
||||||
let newTitle = $state('');
|
|
||||||
let newDesc = $state('');
|
|
||||||
let newPriority = $state('medium');
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
// Drag state
|
|
||||||
let draggedTaskId = $state<string | null>(null);
|
|
||||||
let dragOverCol = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Fix #7 (Codex audit): Poll token to discard stale responses
|
|
||||||
let pollToken = $state(0);
|
|
||||||
|
|
||||||
// ── Derived ──────────────────────────────────────────────────────────
|
// ── Derived ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// NO $derived — .reduce creates new objects every evaluation → infinite loop
|
|
||||||
function getTasksByCol(): Record<string, Task[]> {
|
function getTasksByCol(): Record<string, Task[]> {
|
||||||
return COLUMNS.reduce((acc, col) => {
|
return COLUMNS.reduce((acc, col) => {
|
||||||
acc[col] = tasks.filter(t => t.status === col);
|
acc[col] = getTasks().tasks.filter(t => t.status === col);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, Task[]>);
|
}, {} as Record<string, Task[]>);
|
||||||
}
|
}
|
||||||
|
|
@ -62,50 +38,50 @@
|
||||||
// ── Data fetching ────────────────────────────────────────────────────
|
// ── Data fetching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
// Fix #7 (Codex audit): Capture token before async call, discard if stale
|
const tokenAtStart = appState.project.tasks.nextPollToken(projectId);
|
||||||
const tokenAtStart = ++pollToken;
|
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
||||||
if (tokenAtStart < pollToken) return; // Stale response — discard
|
if (tokenAtStart < appState.project.tasks.getPollToken(projectId)) return;
|
||||||
tasks = res.tasks;
|
appState.project.tasks.setState(projectId, 'tasks', res.tasks);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TaskBoard] loadTasks:', err);
|
console.error('[TaskBoard] loadTasks:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTask() {
|
async function createTask() {
|
||||||
const title = newTitle.trim();
|
const t = getTasks();
|
||||||
if (!title) { error = 'Title required'; return; }
|
const title = t.newTitle.trim();
|
||||||
error = '';
|
if (!title) { appState.project.tasks.setState(projectId, 'error', 'Title required'); return; }
|
||||||
|
appState.project.tasks.setState(projectId, 'error', '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['bttask.createTask']({
|
const res = await appRpc.request['bttask.createTask']({
|
||||||
title,
|
title,
|
||||||
description: newDesc.trim(),
|
description: t.newDesc.trim(),
|
||||||
priority: newPriority,
|
priority: t.newPriority,
|
||||||
groupId,
|
groupId,
|
||||||
createdBy: agentId,
|
createdBy: agentId,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
newTitle = '';
|
appState.project.tasks.setMulti(projectId, {
|
||||||
newDesc = '';
|
newTitle: '', newDesc: '', newPriority: 'medium', showCreateForm: false,
|
||||||
newPriority = 'medium';
|
});
|
||||||
showCreateForm = false;
|
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} else {
|
} else {
|
||||||
error = res.error ?? 'Failed to create task';
|
appState.project.tasks.setState(projectId, 'error', res.error ?? 'Failed to create task');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TaskBoard] createTask:', err);
|
console.error('[TaskBoard] createTask:', err);
|
||||||
error = 'Failed to create task';
|
appState.project.tasks.setState(projectId, 'error', 'Failed to create task');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveTask(taskId: string, newStatus: string) {
|
async function moveTask(taskId: string, newStatus: string) {
|
||||||
const task = tasks.find(t => t.id === taskId);
|
const t = getTasks();
|
||||||
|
const task = t.tasks.find(tk => tk.id === taskId);
|
||||||
if (!task || task.status === newStatus) return;
|
if (!task || task.status === newStatus) return;
|
||||||
|
|
||||||
pollToken++; // Fix #7: Invalidate in-flight polls
|
appState.project.tasks.nextPollToken(projectId); // Invalidate in-flight polls
|
||||||
try {
|
try {
|
||||||
const res = await appRpc.request['bttask.updateTaskStatus']({
|
const res = await appRpc.request['bttask.updateTaskStatus']({
|
||||||
taskId,
|
taskId,
|
||||||
|
|
@ -113,13 +89,12 @@
|
||||||
expectedVersion: task.version,
|
expectedVersion: task.version,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// Optimistic update
|
|
||||||
task.status = newStatus;
|
task.status = newStatus;
|
||||||
task.version = res.newVersion ?? task.version + 1;
|
task.version = res.newVersion ?? task.version + 1;
|
||||||
tasks = [...tasks]; // trigger reactivity
|
appState.project.tasks.setState(projectId, 'tasks', [...t.tasks]);
|
||||||
} else {
|
} else {
|
||||||
error = res.error ?? 'Version conflict';
|
appState.project.tasks.setState(projectId, 'error', res.error ?? 'Version conflict');
|
||||||
await loadTasks(); // reload on conflict
|
await loadTasks();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TaskBoard] moveTask:', err);
|
console.error('[TaskBoard] moveTask:', err);
|
||||||
|
|
@ -130,7 +105,8 @@
|
||||||
async function deleteTask(taskId: string) {
|
async function deleteTask(taskId: string) {
|
||||||
try {
|
try {
|
||||||
await appRpc.request['bttask.deleteTask']({ taskId });
|
await appRpc.request['bttask.deleteTask']({ taskId });
|
||||||
tasks = tasks.filter(t => t.id !== taskId);
|
appState.project.tasks.setState(projectId, 'tasks',
|
||||||
|
getTasks().tasks.filter(t => t.id !== taskId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TaskBoard] deleteTask:', err);
|
console.error('[TaskBoard] deleteTask:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +115,7 @@
|
||||||
// ── Drag handlers ────────────────────────────────────────────────────
|
// ── Drag handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function onDragStart(e: DragEvent, taskId: string) {
|
function onDragStart(e: DragEvent, taskId: string) {
|
||||||
draggedTaskId = taskId;
|
appState.project.tasks.setState(projectId, 'draggedTaskId', taskId);
|
||||||
if (e.dataTransfer) {
|
if (e.dataTransfer) {
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('text/plain', taskId);
|
e.dataTransfer.setData('text/plain', taskId);
|
||||||
|
|
@ -148,38 +124,35 @@
|
||||||
|
|
||||||
function onDragOver(e: DragEvent, col: string) {
|
function onDragOver(e: DragEvent, col: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOverCol = col;
|
appState.project.tasks.setState(projectId, 'dragOverCol', col);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragLeave() {
|
function onDragLeave() {
|
||||||
dragOverCol = null;
|
appState.project.tasks.setState(projectId, 'dragOverCol', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(e: DragEvent, col: string) {
|
function onDrop(e: DragEvent, col: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOverCol = null;
|
appState.project.tasks.setState(projectId, 'dragOverCol', null);
|
||||||
if (draggedTaskId) {
|
const t = getTasks();
|
||||||
moveTask(draggedTaskId, col);
|
if (t.draggedTaskId) {
|
||||||
draggedTaskId = null;
|
moveTask(t.draggedTaskId, col);
|
||||||
|
appState.project.tasks.setState(projectId, 'draggedTaskId', null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnd() {
|
function onDragEnd() {
|
||||||
draggedTaskId = null;
|
appState.project.tasks.setMulti(projectId, { draggedTaskId: null, dragOverCol: null });
|
||||||
dragOverCol = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init + event-driven updates (Feature 4) ─────────────────────────
|
// ── Init + event-driven updates (Feature 4) ─────────────────────────
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// Feature 4: Listen for push events, fallback to 30s poll
|
|
||||||
function onTaskChanged(payload: { groupId: string }) {
|
function onTaskChanged(payload: { groupId: string }) {
|
||||||
if (!payload.groupId || payload.groupId === groupId) loadTasks();
|
if (!payload.groupId || payload.groupId === groupId) loadTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use onMount instead of $effect — loadTasks() writes to $state (pollToken++)
|
|
||||||
// which would trigger $effect re-run → infinite loop
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadTasks();
|
loadTasks();
|
||||||
|
|
@ -196,38 +169,44 @@
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="tb-toolbar">
|
<div class="tb-toolbar">
|
||||||
<span class="tb-title">Task Board</span>
|
<span class="tb-title">Task Board</span>
|
||||||
<span class="tb-count">{tasks.length} tasks</span>
|
<span class="tb-count">{getTasks().tasks.length} tasks</span>
|
||||||
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
<button class="tb-add-btn" onclick={() => { appState.project.tasks.setState(projectId, 'showCreateForm', !getTasks().showCreateForm); }}>
|
||||||
{showCreateForm ? 'Cancel' : '+ Task'}
|
{getTasks().showCreateForm ? 'Cancel' : '+ Task'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create form -->
|
<!-- Create form -->
|
||||||
{#if showCreateForm}
|
{#if getTasks().showCreateForm}
|
||||||
<div class="tb-create-form">
|
<div class="tb-create-form">
|
||||||
<input
|
<input
|
||||||
class="tb-input"
|
class="tb-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Task title"
|
placeholder="Task title"
|
||||||
bind:value={newTitle}
|
value={getTasks().newTitle}
|
||||||
|
oninput={(e) => appState.project.tasks.setState(projectId, 'newTitle', (e.target as HTMLInputElement).value)}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
|
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
class="tb-input tb-desc"
|
class="tb-input tb-desc"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Description (optional)"
|
placeholder="Description (optional)"
|
||||||
bind:value={newDesc}
|
value={getTasks().newDesc}
|
||||||
|
oninput={(e) => appState.project.tasks.setState(projectId, 'newDesc', (e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
<div class="tb-form-row">
|
<div class="tb-form-row">
|
||||||
<select class="tb-select" bind:value={newPriority}>
|
<select
|
||||||
|
class="tb-select"
|
||||||
|
value={getTasks().newPriority}
|
||||||
|
onchange={(e) => appState.project.tasks.setState(projectId, 'newPriority', (e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
<option value="low">Low</option>
|
<option value="low">Low</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</option>
|
<option value="high">High</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="tb-submit" onclick={createTask}>Create</button>
|
<button class="tb-submit" onclick={createTask}>Create</button>
|
||||||
</div>
|
</div>
|
||||||
{#if error}
|
{#if getTasks().error}
|
||||||
<span class="tb-error">{error}</span>
|
<span class="tb-error">{getTasks().error}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -237,7 +216,7 @@
|
||||||
{#each COLUMNS as col}
|
{#each COLUMNS as col}
|
||||||
<div
|
<div
|
||||||
class="tb-column"
|
class="tb-column"
|
||||||
class:drag-over={dragOverCol === col}
|
class:drag-over={getTasks().dragOverCol === col}
|
||||||
ondragover={(e) => onDragOver(e, col)}
|
ondragover={(e) => onDragOver(e, col)}
|
||||||
ondragleave={onDragLeave}
|
ondragleave={onDragLeave}
|
||||||
ondrop={(e) => onDrop(e, col)}
|
ondrop={(e) => onDrop(e, col)}
|
||||||
|
|
@ -253,7 +232,7 @@
|
||||||
{#each getTasksByCol()[col] ?? [] as task (task.id)}
|
{#each getTasksByCol()[col] ?? [] as task (task.id)}
|
||||||
<div
|
<div
|
||||||
class="tb-card"
|
class="tb-card"
|
||||||
class:dragging={draggedTaskId === task.id}
|
class:dragging={getTasks().draggedTaskId === task.id}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => onDragStart(e, task.id)}
|
ondragstart={(e) => onDragStart(e, task.id)}
|
||||||
ondragend={onDragEnd}
|
ondragend={onDragEnd}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Terminal from './Terminal.svelte';
|
import Terminal from './Terminal.svelte';
|
||||||
|
import { appState } from './app-state.svelte.ts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -10,23 +11,10 @@
|
||||||
|
|
||||||
let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props();
|
let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props();
|
||||||
|
|
||||||
interface TermTab {
|
// Read terminal state from project state tree
|
||||||
id: string;
|
function getTerminals() { return appState.project.getState(projectId).terminals; }
|
||||||
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]));
|
|
||||||
|
|
||||||
function blurTerminal() {
|
function blurTerminal() {
|
||||||
// Force-blur xterm canvas so UI buttons become clickable
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
document.activeElement.blur();
|
document.activeElement.blur();
|
||||||
}
|
}
|
||||||
|
|
@ -34,37 +22,23 @@
|
||||||
|
|
||||||
function addTab() {
|
function addTab() {
|
||||||
blurTerminal();
|
blurTerminal();
|
||||||
const id = `${projectId}-t${counter}`;
|
appState.project.terminals.addTab(projectId);
|
||||||
tabs = [...tabs, { id, title: `shell ${counter}` }];
|
|
||||||
counter++;
|
|
||||||
activeTabId = id;
|
|
||||||
mounted = new Set([...mounted, id]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTab(id: string, e: MouseEvent) {
|
function closeTab(id: string, e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
blurTerminal();
|
blurTerminal();
|
||||||
const idx = tabs.findIndex(t => t.id === id);
|
appState.project.terminals.closeTab(projectId, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateTab(id: string) {
|
function activateTab(id: string) {
|
||||||
blurTerminal();
|
blurTerminal();
|
||||||
activeTabId = id;
|
appState.project.terminals.activateTab(projectId, id);
|
||||||
if (!mounted.has(id)) mounted = new Set([...mounted, id]);
|
|
||||||
if (!expanded) expanded = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
blurTerminal();
|
blurTerminal();
|
||||||
expanded = !expanded;
|
appState.project.terminals.toggleExpanded(projectId);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -75,11 +49,11 @@
|
||||||
<button
|
<button
|
||||||
class="expand-btn"
|
class="expand-btn"
|
||||||
onclick={toggleExpand}
|
onclick={toggleExpand}
|
||||||
title={expanded ? 'Collapse terminal' : 'Expand terminal'}
|
title={getTerminals().expanded ? 'Collapse terminal' : 'Expand terminal'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="chevron"
|
class="chevron"
|
||||||
class:open={expanded}
|
class:open={getTerminals().expanded}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|
@ -92,19 +66,19 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="term-tabs" role="tablist">
|
<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+role="tab" allows a nested <button> for the close action -->
|
||||||
<div
|
<div
|
||||||
class="term-tab"
|
class="term-tab"
|
||||||
class:active={activeTabId === tab.id}
|
class:active={getTerminals().activeTabId === tab.id}
|
||||||
role="tab"
|
role="tab"
|
||||||
tabindex={activeTabId === tab.id ? 0 : -1}
|
tabindex={getTerminals().activeTabId === tab.id ? 0 : -1}
|
||||||
aria-selected={activeTabId === tab.id}
|
aria-selected={getTerminals().activeTabId === tab.id}
|
||||||
onclick={() => activateTab(tab.id)}
|
onclick={() => activateTab(tab.id)}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
|
||||||
>
|
>
|
||||||
<span class="tab-label">{tab.title}</span>
|
<span class="tab-label">{tab.title}</span>
|
||||||
{#if tabs.length > 1}
|
{#if getTerminals().tabs.length > 1}
|
||||||
<button
|
<button
|
||||||
class="tab-close"
|
class="tab-close"
|
||||||
aria-label="Close {tab.title}"
|
aria-label="Close {tab.title}"
|
||||||
|
|
@ -119,10 +93,10 @@
|
||||||
|
|
||||||
<!-- Terminal panes: always in DOM, display toggled.
|
<!-- Terminal panes: always in DOM, display toggled.
|
||||||
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
|
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
|
||||||
<div class="term-panes" style:display={expanded ? 'block' : 'none'}>
|
<div class="term-panes" style:display={getTerminals().expanded ? 'block' : 'none'}>
|
||||||
{#each tabs as tab (tab.id)}
|
{#each getTerminals().tabs as tab (tab.id)}
|
||||||
{#if mounted.has(tab.id)}
|
{#if getTerminals().mounted.has(tab.id)}
|
||||||
<div class="term-pane" style:display={activeTabId === tab.id ? 'flex' : 'none'}>
|
<div class="term-pane" style:display={getTerminals().activeTabId === tab.id ? 'flex' : 'none'}>
|
||||||
<Terminal sessionId={tab.id} {cwd} />
|
<Terminal sessionId={tab.id} {cwd} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
190
ui-electrobun/src/mainview/app-state.svelte.ts
Normal file
190
ui-electrobun/src/mainview/app-state.svelte.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* Root state tree — unified API surface for all application state.
|
||||||
|
*
|
||||||
|
* Components import `appState` and access sub-domains:
|
||||||
|
* appState.ui.getSettingsOpen()
|
||||||
|
* appState.project.getState(id)
|
||||||
|
* appState.project.terminals.addTab(id)
|
||||||
|
*
|
||||||
|
* This file is a pure re-export facade. No new state lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── UI store ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSettingsOpen, setSettingsOpen, toggleSettings,
|
||||||
|
getSettingsCategory, setSettingsCategory, openSettingsCategory,
|
||||||
|
getPaletteOpen, setPaletteOpen, togglePalette,
|
||||||
|
getSearchOpen, setSearchOpen, toggleSearch,
|
||||||
|
getNotifDrawerOpen, setNotifDrawerOpen, toggleNotifDrawer,
|
||||||
|
getShowWizard, setShowWizard, toggleWizard,
|
||||||
|
getProjectToDelete, setProjectToDelete,
|
||||||
|
getShowAddGroup, setShowAddGroup, toggleAddGroup,
|
||||||
|
getNewGroupName, setNewGroupName, resetAddGroupForm,
|
||||||
|
} from './ui-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── Workspace store ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
getProjects, setProjects, getGroups, setGroups,
|
||||||
|
getActiveGroupId, setActiveGroup,
|
||||||
|
getFilteredProjects, getTotalCostDerived, getTotalTokensDerived,
|
||||||
|
getMountedGroupIds, getActiveGroup as getActiveGroupObj,
|
||||||
|
addProject, addProjectFromWizard, deleteProject,
|
||||||
|
cloneCountForProject, handleClone,
|
||||||
|
addGroup, loadGroupsFromDb, loadProjectsFromDb, trackAllProjects,
|
||||||
|
getTotalCost, getTotalTokens,
|
||||||
|
} from './workspace-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── Agent store ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
startAgent, stopAgent, sendPrompt,
|
||||||
|
getSession, hasSession, clearSession,
|
||||||
|
loadLastSession, purgeSession, purgeProjectSessions,
|
||||||
|
setAgentToastFn, setRetentionConfig, loadRetentionConfig,
|
||||||
|
type AgentSession, type AgentMessage, type AgentStatus,
|
||||||
|
} from './agent-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── Project state (per-project tree) ──────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
getProjectState,
|
||||||
|
getActiveTab, isTabActivated, setActiveTab,
|
||||||
|
addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded,
|
||||||
|
setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken,
|
||||||
|
setCommsState, setCommsMulti,
|
||||||
|
setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken,
|
||||||
|
removeProject as removeProjectState,
|
||||||
|
} from './project-state.svelte.ts';
|
||||||
|
|
||||||
|
// ── Health store ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
trackProject, recordActivity, recordToolDone,
|
||||||
|
recordTokenSnapshot, setProjectStatus,
|
||||||
|
getProjectHealth, getAttentionQueue, getHealthAggregates,
|
||||||
|
getActiveTools, getToolHistogram,
|
||||||
|
untrackProject, stopHealthTick,
|
||||||
|
} from './health-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── Notifications store ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
getNotifications, getNotifCount,
|
||||||
|
addNotification, removeNotification, clearAll as clearNotifications,
|
||||||
|
} from './notifications-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── Blink store ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBlinkVisible, startBlink, stopBlink,
|
||||||
|
} from './blink-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── Theme store ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { themeStore } from './theme-store.svelte.ts';
|
||||||
|
|
||||||
|
// ── i18n ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { t, setLocale, getLocale, getDir } from './i18n.svelte.ts';
|
||||||
|
|
||||||
|
// ── Unified API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const appState = {
|
||||||
|
ui: {
|
||||||
|
getSettingsOpen, setSettingsOpen, toggleSettings,
|
||||||
|
getSettingsCategory, setSettingsCategory, openSettingsCategory,
|
||||||
|
getPaletteOpen, setPaletteOpen, togglePalette,
|
||||||
|
getSearchOpen, setSearchOpen, toggleSearch,
|
||||||
|
getNotifDrawerOpen, setNotifDrawerOpen, toggleNotifDrawer,
|
||||||
|
getShowWizard, setShowWizard, toggleWizard,
|
||||||
|
getProjectToDelete, setProjectToDelete,
|
||||||
|
getShowAddGroup, setShowAddGroup, toggleAddGroup,
|
||||||
|
getNewGroupName, setNewGroupName, resetAddGroupForm,
|
||||||
|
},
|
||||||
|
|
||||||
|
workspace: {
|
||||||
|
getProjects, setProjects, getGroups, setGroups,
|
||||||
|
getActiveGroupId, setActiveGroup,
|
||||||
|
getFilteredProjects, getTotalCostDerived, getTotalTokensDerived,
|
||||||
|
getMountedGroupIds, getActiveGroup: getActiveGroupObj,
|
||||||
|
addProject, addProjectFromWizard, deleteProject,
|
||||||
|
cloneCountForProject, handleClone,
|
||||||
|
addGroup, loadGroupsFromDb, loadProjectsFromDb, trackAllProjects,
|
||||||
|
getTotalCost, getTotalTokens,
|
||||||
|
},
|
||||||
|
|
||||||
|
agent: {
|
||||||
|
startAgent, stopAgent, sendPrompt,
|
||||||
|
getSession, hasSession, clearSession,
|
||||||
|
loadLastSession, purgeSession, purgeProjectSessions,
|
||||||
|
setToastFn: setAgentToastFn,
|
||||||
|
setRetentionConfig, loadRetentionConfig,
|
||||||
|
},
|
||||||
|
|
||||||
|
project: {
|
||||||
|
getState: getProjectState,
|
||||||
|
removeState: removeProjectState,
|
||||||
|
|
||||||
|
tab: {
|
||||||
|
getActiveTab, isTabActivated, setActiveTab,
|
||||||
|
},
|
||||||
|
|
||||||
|
terminals: {
|
||||||
|
addTab: addTerminalTab,
|
||||||
|
closeTab: closeTerminalTab,
|
||||||
|
activateTab: activateTerminalTab,
|
||||||
|
toggleExpanded: toggleTerminalExpanded,
|
||||||
|
},
|
||||||
|
|
||||||
|
files: {
|
||||||
|
setState: setFileState,
|
||||||
|
setMulti: setFileMulti,
|
||||||
|
nextRequestToken: nextFileRequestToken,
|
||||||
|
getRequestToken: getFileRequestToken,
|
||||||
|
},
|
||||||
|
|
||||||
|
comms: {
|
||||||
|
setState: setCommsState,
|
||||||
|
setMulti: setCommsMulti,
|
||||||
|
},
|
||||||
|
|
||||||
|
tasks: {
|
||||||
|
setState: setTaskState,
|
||||||
|
setMulti: setTaskMulti,
|
||||||
|
nextPollToken: nextTaskPollToken,
|
||||||
|
getPollToken: getTaskPollToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
health: {
|
||||||
|
trackProject, recordActivity, recordToolDone,
|
||||||
|
recordTokenSnapshot, setProjectStatus,
|
||||||
|
getProjectHealth, getAttentionQueue, getHealthAggregates,
|
||||||
|
getActiveTools, getToolHistogram,
|
||||||
|
untrackProject, stopHealthTick,
|
||||||
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
getAll: getNotifications, getCount: getNotifCount,
|
||||||
|
add: addNotification, remove: removeNotification, clear: clearNotifications,
|
||||||
|
},
|
||||||
|
|
||||||
|
blink: {
|
||||||
|
getVisible: getBlinkVisible, start: startBlink, stop: stopBlink,
|
||||||
|
},
|
||||||
|
|
||||||
|
theme: themeStore,
|
||||||
|
|
||||||
|
i18n: { t, setLocale, getLocale, getDir },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { AgentSession, AgentMessage, AgentStatus };
|
||||||
|
export type {
|
||||||
|
ProjectState, TermTab, TerminalState, FileState,
|
||||||
|
CommsState, TaskState, TabState, DirEntry,
|
||||||
|
Channel, ChannelMessage, CommsAgent, DM, ChannelMember, Task,
|
||||||
|
ProjectTab,
|
||||||
|
} from './project-state.types.ts';
|
||||||
247
ui-electrobun/src/mainview/project-state.svelte.ts
Normal file
247
ui-electrobun/src/mainview/project-state.svelte.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
/**
|
||||||
|
* Per-project state tree — unified state for all project sub-domains.
|
||||||
|
*
|
||||||
|
* Uses version counter pattern (not Map reassignment) to avoid
|
||||||
|
* Svelte 5 infinite reactive loops. Plain Map is NOT reactive;
|
||||||
|
* _version is the sole reactive signal.
|
||||||
|
*
|
||||||
|
* Components read via getter functions (which read _version to subscribe)
|
||||||
|
* and mutate via action functions (which bump _version to notify).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProjectTab, ProjectState, FileState, CommsState, TaskState,
|
||||||
|
} from './project-state.types.ts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ProjectTab, ProjectState, TerminalState, FileState,
|
||||||
|
CommsState, TaskState, TabState, DirEntry,
|
||||||
|
TermTab, Channel, ChannelMessage, CommsAgent, DM, ChannelMember, Task,
|
||||||
|
} from './project-state.types.ts';
|
||||||
|
|
||||||
|
// ── Internal state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _projects = new Map<string, ProjectState>();
|
||||||
|
let _version = $state(0);
|
||||||
|
|
||||||
|
// ── Factory ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createProjectState(projectId: string): ProjectState {
|
||||||
|
const firstTabId = `${projectId}-t1`;
|
||||||
|
return {
|
||||||
|
terminals: {
|
||||||
|
tabs: [{ id: firstTabId, title: 'shell 1' }],
|
||||||
|
activeTabId: firstTabId,
|
||||||
|
expanded: true,
|
||||||
|
nextId: 2,
|
||||||
|
mounted: new Set([firstTabId]),
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
childrenCache: new Map(),
|
||||||
|
openDirs: new Set(),
|
||||||
|
loadingDirs: new Set(),
|
||||||
|
selectedFile: null,
|
||||||
|
fileContent: null,
|
||||||
|
fileEncoding: 'utf8',
|
||||||
|
fileSize: 0,
|
||||||
|
fileError: null,
|
||||||
|
fileLoading: false,
|
||||||
|
isDirty: false,
|
||||||
|
editorContent: '',
|
||||||
|
fileRequestToken: 0,
|
||||||
|
readMtimeMs: 0,
|
||||||
|
showConflictDialog: false,
|
||||||
|
},
|
||||||
|
comms: {
|
||||||
|
mode: 'channels',
|
||||||
|
channels: [],
|
||||||
|
agents: [],
|
||||||
|
activeChannelId: null,
|
||||||
|
activeDmAgentId: null,
|
||||||
|
channelMessages: [],
|
||||||
|
dmMessages: [],
|
||||||
|
input: '',
|
||||||
|
loading: false,
|
||||||
|
channelMembers: [],
|
||||||
|
showMembers: false,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
tasks: [],
|
||||||
|
showCreateForm: false,
|
||||||
|
newTitle: '',
|
||||||
|
newDesc: '',
|
||||||
|
newPriority: 'medium',
|
||||||
|
error: '',
|
||||||
|
draggedTaskId: null,
|
||||||
|
dragOverCol: null,
|
||||||
|
pollToken: 0,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
activeTab: 'model',
|
||||||
|
activatedTabs: new Set<ProjectTab>(['model']),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProject(projectId: string): ProjectState {
|
||||||
|
let state = _projects.get(projectId);
|
||||||
|
if (!state) {
|
||||||
|
state = createProjectState(projectId);
|
||||||
|
_projects.set(projectId, state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic getter (reads _version for reactivity) ────────────────────────
|
||||||
|
|
||||||
|
export function getProjectState(projectId: string): ProjectState {
|
||||||
|
void _version;
|
||||||
|
return ensureProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Version bump (called by all actions) ──────────────────────────────────
|
||||||
|
|
||||||
|
function bump(): void { _version++; }
|
||||||
|
|
||||||
|
// ── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getActiveTab(projectId: string): ProjectTab {
|
||||||
|
void _version;
|
||||||
|
return _projects.get(projectId)?.tab.activeTab ?? 'model';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTabActivated(projectId: string, tab: ProjectTab): boolean {
|
||||||
|
void _version;
|
||||||
|
return _projects.get(projectId)?.tab.activatedTabs.has(tab) ?? (tab === 'model');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveTab(projectId: string, tab: ProjectTab): void {
|
||||||
|
const state = ensureProject(projectId);
|
||||||
|
state.tab.activeTab = tab;
|
||||||
|
state.tab.activatedTabs.add(tab);
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Terminal actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function addTerminalTab(projectId: string): void {
|
||||||
|
const t = ensureProject(projectId).terminals;
|
||||||
|
const id = `${projectId}-t${t.nextId}`;
|
||||||
|
t.tabs = [...t.tabs, { id, title: `shell ${t.nextId}` }];
|
||||||
|
t.nextId++;
|
||||||
|
t.activeTabId = id;
|
||||||
|
t.mounted.add(id);
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeTerminalTab(projectId: string, tabId: string): void {
|
||||||
|
const t = ensureProject(projectId).terminals;
|
||||||
|
const idx = t.tabs.findIndex(tab => tab.id === tabId);
|
||||||
|
t.tabs = t.tabs.filter(tab => tab.id !== tabId);
|
||||||
|
if (t.activeTabId === tabId) {
|
||||||
|
const next = t.tabs[Math.min(idx, t.tabs.length - 1)];
|
||||||
|
t.activeTabId = next?.id ?? '';
|
||||||
|
}
|
||||||
|
t.mounted.delete(tabId);
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateTerminalTab(projectId: string, tabId: string): void {
|
||||||
|
const t = ensureProject(projectId).terminals;
|
||||||
|
t.activeTabId = tabId;
|
||||||
|
t.mounted.add(tabId);
|
||||||
|
if (!t.expanded) t.expanded = true;
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTerminalExpanded(projectId: string): void {
|
||||||
|
const t = ensureProject(projectId).terminals;
|
||||||
|
t.expanded = !t.expanded;
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function setFileState<K extends keyof FileState>(
|
||||||
|
projectId: string, key: K, value: FileState[K],
|
||||||
|
): void {
|
||||||
|
const f = ensureProject(projectId).files;
|
||||||
|
f[key] = value;
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFileMulti(
|
||||||
|
projectId: string, updates: Partial<FileState>,
|
||||||
|
): void {
|
||||||
|
const f = ensureProject(projectId).files;
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
(f as unknown as Record<string, unknown>)[k] = v;
|
||||||
|
}
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextFileRequestToken(projectId: string): number {
|
||||||
|
const f = ensureProject(projectId).files;
|
||||||
|
return ++f.fileRequestToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileRequestToken(projectId: string): number {
|
||||||
|
void _version;
|
||||||
|
return ensureProject(projectId).files.fileRequestToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comms actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function setCommsState<K extends keyof CommsState>(
|
||||||
|
projectId: string, key: K, value: CommsState[K],
|
||||||
|
): void {
|
||||||
|
const c = ensureProject(projectId).comms;
|
||||||
|
c[key] = value;
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCommsMulti(
|
||||||
|
projectId: string, updates: Partial<CommsState>,
|
||||||
|
): void {
|
||||||
|
const c = ensureProject(projectId).comms;
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
(c as unknown as Record<string, unknown>)[k] = v;
|
||||||
|
}
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function setTaskState<K extends keyof TaskState>(
|
||||||
|
projectId: string, key: K, value: TaskState[K],
|
||||||
|
): void {
|
||||||
|
const t = ensureProject(projectId).tasks;
|
||||||
|
t[key] = value;
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTaskMulti(
|
||||||
|
projectId: string, updates: Partial<TaskState>,
|
||||||
|
): void {
|
||||||
|
const t = ensureProject(projectId).tasks;
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
(t as unknown as Record<string, unknown>)[k] = v;
|
||||||
|
}
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTaskPollToken(projectId: string): number {
|
||||||
|
const t = ensureProject(projectId).tasks;
|
||||||
|
return ++t.pollToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskPollToken(projectId: string): number {
|
||||||
|
void _version;
|
||||||
|
return ensureProject(projectId).tasks.pollToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function removeProject(projectId: string): void {
|
||||||
|
if (_projects.delete(projectId)) bump();
|
||||||
|
}
|
||||||
154
ui-electrobun/src/mainview/project-state.types.ts
Normal file
154
ui-electrobun/src/mainview/project-state.types.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* Per-project state tree — type definitions.
|
||||||
|
*
|
||||||
|
* Separated from project-state.svelte.ts to keep that file under 300 lines.
|
||||||
|
* This file is plain .ts (no runes needed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProjectTab } from './project-tabs-store.svelte.ts';
|
||||||
|
export type { ProjectTab };
|
||||||
|
|
||||||
|
// ── Terminal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TermTab {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalState {
|
||||||
|
tabs: TermTab[];
|
||||||
|
activeTabId: string;
|
||||||
|
expanded: boolean;
|
||||||
|
nextId: number;
|
||||||
|
mounted: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DirEntry {
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'dir';
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileState {
|
||||||
|
childrenCache: Map<string, DirEntry[]>;
|
||||||
|
openDirs: Set<string>;
|
||||||
|
loadingDirs: Set<string>;
|
||||||
|
selectedFile: string | null;
|
||||||
|
fileContent: string | null;
|
||||||
|
fileEncoding: 'utf8' | 'base64';
|
||||||
|
fileSize: number;
|
||||||
|
fileError: string | null;
|
||||||
|
fileLoading: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
editorContent: string;
|
||||||
|
fileRequestToken: number;
|
||||||
|
readMtimeMs: number;
|
||||||
|
showConflictDialog: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comms ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
groupId: string;
|
||||||
|
createdBy: string;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMessage {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
fromAgent: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
senderName: string;
|
||||||
|
senderRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommsAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
groupId: string;
|
||||||
|
tier: number;
|
||||||
|
status: string;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DM {
|
||||||
|
id: string;
|
||||||
|
fromAgent: string;
|
||||||
|
toAgent: string;
|
||||||
|
content: string;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
senderName: string | null;
|
||||||
|
senderRole: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMember {
|
||||||
|
agentId: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommsState {
|
||||||
|
mode: 'channels' | 'dms';
|
||||||
|
channels: Channel[];
|
||||||
|
agents: CommsAgent[];
|
||||||
|
activeChannelId: string | null;
|
||||||
|
activeDmAgentId: string | null;
|
||||||
|
channelMessages: ChannelMessage[];
|
||||||
|
dmMessages: DM[];
|
||||||
|
input: string;
|
||||||
|
loading: boolean;
|
||||||
|
channelMembers: ChannelMember[];
|
||||||
|
showMembers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tasks ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
assignedTo: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
groupId: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskState {
|
||||||
|
tasks: Task[];
|
||||||
|
showCreateForm: boolean;
|
||||||
|
newTitle: string;
|
||||||
|
newDesc: string;
|
||||||
|
newPriority: string;
|
||||||
|
error: string;
|
||||||
|
draggedTaskId: string | null;
|
||||||
|
dragOverCol: string | null;
|
||||||
|
pollToken: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TabState {
|
||||||
|
activeTab: ProjectTab;
|
||||||
|
activatedTabs: Set<ProjectTab>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root per-project state ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProjectState {
|
||||||
|
terminals: TerminalState;
|
||||||
|
files: FileState;
|
||||||
|
comms: CommsState;
|
||||||
|
tasks: TaskState;
|
||||||
|
tab: TabState;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue