9ca0164d0b
The Tauri bridge connected to the daemon once at startup and held a single stream with no recovery: when the daemon exited (Restart/Stop, crash, or an update), the reader emitted spacesh:disconnected and died, and every later request went through the dead writer forever — the GUI was permanently stuck (settings frozen, offline). Since the bridge is Rust-side state that survives a webview reload, even Cmd+R didn't recover it. - bridge.rs: requests now reconnect-and-retry on failure with a single-flight guard (generation counter) so concurrent failures collapse into one reconnect and never open duplicate connections; a 5s reply timeout catches silently-dropped connections. ensure_daemon respawns the daemon if it exited. On success the bridge emits spacesh:reconnected. - App.tsx: on spacesh:reconnected, bump a connection epoch that keys LayoutEngine, remounting terminals so they re-attach (snapshot + live stream) to the restarted daemon; also reload health/config/status. - Settings: drop the Stop button — with lazy daemon spawn any GUI request resurrects the daemon, so an in-GUI "stop" is contradictory. Restart now works end to end (shutdown → reconnect respawns → panels re-attach). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
6.7 KiB
TypeScript
110 lines
6.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { COLORS, FONT, ACCENTS } from "./theme";
|
|
import { setConfig, restartDaemon } from "./socketBridge";
|
|
import type { ConfigView, DaemonHealth } from "./socketBridge";
|
|
|
|
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
|
|
|
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useEffect(() => { ref.current?.focus(); }, []);
|
|
|
|
// Fix 2: local state for font-size slider — committed only on pointer release.
|
|
const [sizeLocal, setSizeLocal] = useState(config.font_size);
|
|
useEffect(() => { setSizeLocal(config.font_size); }, [config.font_size]);
|
|
|
|
// Fix 3: controlled shell input — synced from config, committed on blur.
|
|
const [shellLocal, setShellLocal] = useState(config.default_shell);
|
|
useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]);
|
|
return (
|
|
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
|
|
style={{ width: 520, maxHeight: "80vh", overflowY: "auto", background: COLORS.bgApp, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 14, padding: 24, color: COLORS.textPrimary, fontFamily: FONT.ui }}>
|
|
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>Settings</div>
|
|
|
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
|
|
<select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })}
|
|
style={{ width: "100%", padding: 8, marginBottom: 10, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }}>
|
|
{FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
|
|
</select>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
|
|
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
|
|
<input type="range" min={10} max={20} value={sizeLocal}
|
|
onChange={(e) => setSizeLocal(Number(e.target.value))}
|
|
onPointerUp={() => void setConfig({ font_size: sizeLocal })}
|
|
style={{ flex: 1 }} />
|
|
</div>
|
|
|
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Theme</div>
|
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
|
{(["dark", "light"] as const).map((t) => (
|
|
<button key={t} onClick={() => void setConfig({ theme: t })}
|
|
style={{ flex: 1, padding: "8px 0", borderRadius: 8, fontSize: 13, textTransform: "capitalize",
|
|
background: config.theme === t ? COLORS.accent : COLORS.bgElevated, color: config.theme === t ? "#fff" : COLORS.textPrimary,
|
|
border: `1px solid ${COLORS.borderStrong}` }}>{t}</button>
|
|
))}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Accent</div>
|
|
<div style={{ display: "flex", gap: 10, marginBottom: 18 }}>
|
|
{Object.entries(ACCENTS).map(([id, hex]) => (
|
|
<button key={id} onClick={() => void setConfig({ accent: id })} aria-label={id}
|
|
style={{ width: 26, height: 26, borderRadius: "50%", background: hex, cursor: "pointer",
|
|
border: config.accent === id ? `2px solid ${COLORS.textPrimary}` : "2px solid transparent" }} />
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
|
|
<input value={shellLocal} onChange={(e) => setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })}
|
|
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
|
|
|
|
<DaemonSection health={health} onReload={onReload} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function fmtUptime(ms: number): string {
|
|
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
|
if (s < 60) return `${s}s`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
}
|
|
|
|
function DaemonSection({ health, onReload }: { health: DaemonHealth | null; onReload: () => void }) {
|
|
const [confirm, setConfirm] = useState(false);
|
|
// Tick so uptime counts up live while the modal is open.
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const t = setInterval(() => setTick((n) => n + 1), 1000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
return (
|
|
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
|
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
|
|
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
|
|
{health ? (<>
|
|
<div>version {health.version} · pid {health.pid}</div>
|
|
<div>uptime {fmtUptime(health.started_at_ms)}</div>
|
|
</>) : <div>offline</div>}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
|
<button onClick={() => setConfirm(true)} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
|
|
</div>
|
|
{confirm && (
|
|
<div style={{ marginTop: 10, padding: 10, borderRadius: 8, background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}` }}>
|
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>
|
|
Restart the daemon? Running sessions end and respawn; panels re-attach automatically.
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
|
<button onClick={() => setConfirm(false)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
|
|
<button onClick={() => { setConfirm(false); void restartDaemon().then(onReload); }}
|
|
style={{ padding: "5px 12px", background: COLORS.stError, color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600 }}>
|
|
Restart
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|