- Global unhandledrejection handler with IPC+network filtering - Agent dispatcher heartbeat uses handleInfraError (was fire-and-forget) - All stores: layout, workspace, anchors, theme, plugins, machines, wake-scheduler — silent failures replaced with handleInfraError - initGlobalErrorHandler() called in App.svelte onMount
194 lines
5.1 KiB
TypeScript
194 lines
5.1 KiB
TypeScript
import {
|
|
listSessions,
|
|
saveSession,
|
|
deleteSession,
|
|
updateSessionTitle,
|
|
touchSession,
|
|
saveLayout,
|
|
loadLayout,
|
|
updateSessionGroup,
|
|
type PersistedSession,
|
|
} from '../adapters/session-bridge';
|
|
import { handleInfraError } from '../utils/handle-error';
|
|
|
|
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
|
|
|
|
export type PaneType = 'terminal' | 'agent' | 'markdown' | 'ssh' | 'context' | 'empty';
|
|
|
|
export interface Pane {
|
|
id: string;
|
|
type: PaneType;
|
|
title: string;
|
|
shell?: string;
|
|
cwd?: string;
|
|
args?: string[];
|
|
group?: string;
|
|
focused: boolean;
|
|
remoteMachineId?: string;
|
|
}
|
|
|
|
let panes = $state<Pane[]>([]);
|
|
let activePreset = $state<LayoutPreset>('1-col');
|
|
let focusedPaneId = $state<string | null>(null);
|
|
let initialized = false;
|
|
|
|
// --- Persistence helpers (fire-and-forget with error logging) ---
|
|
|
|
function persistSession(pane: Pane): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const session: PersistedSession = {
|
|
id: pane.id,
|
|
type: pane.type,
|
|
title: pane.title,
|
|
shell: pane.shell,
|
|
cwd: pane.cwd,
|
|
args: pane.args,
|
|
group_name: pane.group ?? '',
|
|
created_at: now,
|
|
last_used_at: now,
|
|
};
|
|
saveSession(session).catch(e => handleInfraError(e, 'layout.persistSession'));
|
|
}
|
|
|
|
function persistLayout(): void {
|
|
saveLayout({
|
|
preset: activePreset,
|
|
pane_ids: panes.map(p => p.id),
|
|
}).catch(e => handleInfraError(e, 'layout.persistLayout'));
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
export function getPanes(): Pane[] {
|
|
return panes;
|
|
}
|
|
|
|
export function getActivePreset(): LayoutPreset {
|
|
return activePreset;
|
|
}
|
|
|
|
export function getFocusedPaneId(): string | null {
|
|
return focusedPaneId;
|
|
}
|
|
|
|
export function addPane(pane: Omit<Pane, 'focused'>): void {
|
|
panes.push({ ...pane, focused: false });
|
|
focusPane(pane.id);
|
|
autoPreset();
|
|
persistSession({ ...pane, focused: false });
|
|
persistLayout();
|
|
}
|
|
|
|
export function removePane(id: string): void {
|
|
panes = panes.filter(p => p.id !== id);
|
|
if (focusedPaneId === id) {
|
|
focusedPaneId = panes.length > 0 ? panes[0].id : null;
|
|
}
|
|
autoPreset();
|
|
deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
|
|
persistLayout();
|
|
}
|
|
|
|
export function focusPane(id: string): void {
|
|
focusedPaneId = id;
|
|
panes = panes.map(p => ({ ...p, focused: p.id === id }));
|
|
touchSession(id).catch(e => handleInfraError(e, 'layout.touchSession'));
|
|
}
|
|
|
|
export function focusPaneByIndex(index: number): void {
|
|
if (index >= 0 && index < panes.length) {
|
|
focusPane(panes[index].id);
|
|
}
|
|
}
|
|
|
|
export function setPreset(preset: LayoutPreset): void {
|
|
activePreset = preset;
|
|
persistLayout();
|
|
}
|
|
|
|
export function renamePaneTitle(id: string, title: string): void {
|
|
const pane = panes.find(p => p.id === id);
|
|
if (pane) {
|
|
pane.title = title;
|
|
updateSessionTitle(id, title).catch(e => handleInfraError(e, 'layout.updateTitle'));
|
|
}
|
|
}
|
|
|
|
export function setPaneGroup(id: string, group: string): void {
|
|
const pane = panes.find(p => p.id === id);
|
|
if (pane) {
|
|
pane.group = group || undefined;
|
|
updateSessionGroup(id, group).catch(e => handleInfraError(e, 'layout.updateGroup'));
|
|
}
|
|
}
|
|
|
|
/** Restore panes and layout from SQLite on app startup */
|
|
export async function restoreFromDb(): Promise<void> {
|
|
if (initialized) return;
|
|
initialized = true;
|
|
|
|
try {
|
|
const [sessions, layout] = await Promise.all([listSessions(), loadLayout()]);
|
|
|
|
if (layout.preset) {
|
|
activePreset = layout.preset as LayoutPreset;
|
|
}
|
|
|
|
// Restore panes in layout order, falling back to DB order
|
|
const sessionMap = new Map(sessions.map(s => [s.id, s]));
|
|
const orderedIds = layout.pane_ids.length > 0 ? layout.pane_ids : sessions.map(s => s.id);
|
|
|
|
for (const id of orderedIds) {
|
|
const s = sessionMap.get(id);
|
|
if (!s) continue;
|
|
panes.push({
|
|
id: s.id,
|
|
type: s.type as PaneType,
|
|
title: s.title,
|
|
shell: s.shell ?? undefined,
|
|
cwd: s.cwd ?? undefined,
|
|
args: s.args ?? undefined,
|
|
group: s.group_name || undefined,
|
|
focused: false,
|
|
});
|
|
}
|
|
|
|
if (panes.length > 0) {
|
|
focusPane(panes[0].id);
|
|
}
|
|
} catch (e) {
|
|
handleInfraError(e, 'layout.restoreFromDb');
|
|
}
|
|
}
|
|
|
|
function autoPreset(): void {
|
|
const count = panes.length;
|
|
if (count <= 1) activePreset = '1-col';
|
|
else if (count === 2) activePreset = '2-col';
|
|
else if (count === 3) activePreset = 'master-stack';
|
|
else activePreset = '2x2';
|
|
}
|
|
|
|
/** CSS grid-template for current preset */
|
|
export function getGridTemplate(): { columns: string; rows: string } {
|
|
switch (activePreset) {
|
|
case '1-col':
|
|
return { columns: '1fr', rows: '1fr' };
|
|
case '2-col':
|
|
return { columns: '1fr 1fr', rows: '1fr' };
|
|
case '3-col':
|
|
return { columns: '1fr 1fr 1fr', rows: '1fr' };
|
|
case '2x2':
|
|
return { columns: '1fr 1fr', rows: '1fr 1fr' };
|
|
case 'master-stack':
|
|
return { columns: '2fr 1fr', rows: '1fr 1fr' };
|
|
}
|
|
}
|
|
|
|
/** For master-stack: first pane spans full height */
|
|
export function getPaneGridArea(index: number): string | undefined {
|
|
if (activePreset === 'master-stack' && index === 0) {
|
|
return '1 / 1 / 3 / 2';
|
|
}
|
|
return undefined;
|
|
}
|