Merge settings-modal: daemon config, Get/Set/ConfigChanged, CSS-var theming, settings modal with Stop/Restart

This commit is contained in:
2026-06-15 09:05:45 +07:00
15 changed files with 525 additions and 44 deletions
+24
View File
@@ -327,3 +327,27 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, S
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}
// ---- Settings commands ----
#[tauri::command]
pub async fn get_config(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::GetConfig).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn set_config(
state: BridgeState<'_>,
default_shell: Option<String>,
font_family: Option<String>,
font_size: Option<u16>,
theme: Option<String>,
accent: Option<String>,
) -> Result<Value, String> {
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn shutdown_daemon(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Shutdown).await.map_err(|e| e.to_string())?)
}
+3
View File
@@ -53,6 +53,9 @@ pub fn run() {
bridge::event_log,
bridge::mark_read,
bridge::health,
bridge::get_config,
bridge::set_config,
bridge::shutdown_daemon,
])
.run(tauri::generate_context!())
.expect("error while running spacesh");
+18 -5
View File
@@ -5,11 +5,12 @@ import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar";
import { Wizard } from "./Wizard";
import { ConfirmDelete } from "./ConfirmDelete";
import { Settings } from "./Settings";
import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify";
import { COLORS } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd } from "./socketBridge";
import type { EventRecord, DaemonHealth } from "./socketBridge";
import { COLORS, applyTheme, resolvePalette } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -31,9 +32,11 @@ export function App() {
const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
const [health, setHealth] = useState<DaemonHealth | null>(null);
const [config, setConfigState] = useState<ConfigView | null>(null);
const [connected, setConnected] = useState(false);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
@@ -78,6 +81,7 @@ export function App() {
void refresh();
void seedEvents();
void loadHealth();
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "event") {
const rec = evt.data.record;
@@ -93,6 +97,10 @@ export function App() {
void refresh();
} else if (evt.evt === "exit") {
void refresh();
} else if (evt.evt === "config_changed") {
const c = evt.data.config;
setConfigState(c);
applyTheme(c.theme, c.accent);
} else {
void refresh();
}
@@ -102,6 +110,7 @@ export function App() {
void refresh();
void seedEvents();
void loadHealth();
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
});
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
}, [refresh, seedEvents, loadHealth]);
@@ -129,6 +138,9 @@ export function App() {
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
effectiveFocusRef.current = effectiveFocus;
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]);
const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]);
function selectWorkspace(id: string) {
setActiveId(id);
setFocusedId(null);
@@ -137,7 +149,7 @@ export function App() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} />
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
@@ -146,7 +158,7 @@ export function App() {
)}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} />
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
</div>
</div>
@@ -158,6 +170,7 @@ export function App() {
/>
)}
</div>
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} />}
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
{deleteTarget && (
<ConfirmDelete
+8 -4
View File
@@ -22,6 +22,8 @@ interface Props {
searchSurfaceId: string | null;
searchNonce: number;
onCloseSearch: () => void;
font: { family: string; size: number } | null;
palette: Record<string, string> | null;
}
type Edge = "left" | "right" | "top" | "bottom";
@@ -40,7 +42,7 @@ function shortPath(cwd: string): string {
return leaf ? `~/${leaf}` : cwd;
}
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch }: Props) {
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
const [drop, setDrop] = useState<DropTarget | null>(null);
@@ -78,7 +80,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
}
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch };
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
if (zoomed) {
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
@@ -103,6 +105,8 @@ interface NodeProps {
searchSurfaceId: string | null;
searchNonce: number;
onCloseSearch: () => void;
font: { family: string; size: number } | null;
palette: Record<string, string> | null;
}
function Node({ node, path, ...rest }: NodeProps) {
@@ -112,7 +116,7 @@ function Node({ node, path, ...rest }: NodeProps) {
return <SplitView split={node.split} path={path} {...rest} />;
}
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit<NodeProps, "node" | "path"> & { id: string }) {
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
const focused = focusedId === id;
const dropEdge = drop && drop.id === id ? drop.edge : null;
@@ -181,7 +185,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} />
<TerminalView key={id} surfaceId={id} font={font} palette={palette} />
</div>
{searchSurfaceId === id && (
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from "react";
import { COLORS, FONT, ACCENTS } from "./theme";
import { setConfig, shutdownDaemon, 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 }: { config: ConfigView; health: DaemonHealth | null; onClose: () => 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} />
</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 }: { health: DaemonHealth | null }) {
const [confirm, setConfirm] = useState<null | "stop" | "restart">(null);
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("restart")} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
<button onClick={() => setConfirm("stop")} style={{ padding: "7px 14px", background: "transparent", color: COLORS.stError, border: `1px solid ${COLORS.stError}`, borderRadius: 7, fontSize: 13 }}>Stop</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 }}>
{confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."}
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setConfirm(null)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
<button onClick={() => { const c = confirm; setConfirm(null); void (c === "stop" ? shutdownDaemon() : restartDaemon()); }}
style={{ padding: "5px 12px", background: COLORS.stError, color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600 }}>
{confirm === "stop" ? "Stop" : "Restart"}
</button>
</div>
</div>
)}
</div>
);
}
+39 -3
View File
@@ -9,15 +9,35 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
export function TerminalView({ surfaceId }: { surfaceId: string }) {
function xtermTheme(p: Record<string, string>) {
return {
background: p["bg-panel"],
foreground: p["text-primary"],
cursor: p["text-primary"],
selectionBackground: p["search-match"],
};
}
export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
const ref = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
useEffect(() => {
if (!ref.current) return;
// allowProposedApi is required by the search addon: its match decorations
// call registerMarker/registerDecoration (proposed API). Without it findNext
// throws and the scrollback search counter never updates.
const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000, allowProposedApi: true });
const term = new Terminal({
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
fontSize: font?.size ?? 13,
convertEol: false,
scrollback: 10000,
allowProposedApi: true,
theme: palette ? xtermTheme(palette) : undefined,
});
termRef.current = term;
try {
term.loadAddon(new WebglAddon());
} catch {
@@ -31,6 +51,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
const fit = new FitAddon();
term.loadAddon(fit);
fitRef.current = fit;
// Fit the grid to the container and tell the daemon the new size. Coalesced
// through rAF so a burst of resize callbacks yields one resize per frame.
@@ -76,8 +97,23 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
void detachSurface(surfaceId);
unregisterSearch(surfaceId);
term.dispose();
termRef.current = null;
fitRef.current = null;
};
}, [surfaceId]);
}, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps
// Live re-apply font and theme when config changes without remounting.
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
useEffect(() => {
const t = termRef.current;
if (!t) return;
if (font) {
t.options.fontFamily = `'${font.family}', monospace`;
t.options.fontSize = font.size;
}
if (palette) t.options.theme = xtermTheme(palette);
requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } });
}, [font, palette]); // eslint-disable-line react-hooks/exhaustive-deps
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
}
+3 -2
View File
@@ -29,7 +29,7 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
}
export function TopBar({
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread,
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings,
}: {
active: WorkspaceView | null;
eventsOpen: boolean;
@@ -38,6 +38,7 @@ export function TopBar({
sidebarOpen: boolean;
onToggleSidebar: () => void;
unread: number;
onOpenSettings: () => void;
}) {
return (
<div
@@ -85,7 +86,7 @@ export function TopBar({
)}
</div>
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
<IconBtn icon={<Settings size={16} />} title="Settings (mock)" />
<IconBtn icon={<Settings size={16} />} onClick={onOpenSettings} title="Settings" />
<span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} />
<button
title="Account (mock)"
+5
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { applyTheme } from "./theme";
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
@@ -9,6 +10,10 @@ import "@fontsource-variable/jetbrains-mono";
import "@xterm/xterm/css/xterm.css";
import "./styles.css";
// Apply default theme before React renders so CSS vars are never unset,
// even if the daemon is slow or offline. getConfig() overrides this later.
applyTheme("dark", "blue");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
+37
View File
@@ -94,6 +94,7 @@ export type DaemonEvt =
| { evt: "layout_changed"; data: { workspace_id: string } }
| { evt: "workspace_changed"; data: unknown }
| { evt: "groups_changed"; data: unknown }
| { evt: "config_changed"; data: { config: ConfigView } }
| { evt: "event"; data: { record: EventRecord } }
| { evt: "events_read"; data: { ids: number[] } };
@@ -186,3 +187,39 @@ export async function getHealth(): Promise<DaemonHealth> {
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
await invoke("set_zoom", { workspaceId, surfaceId });
}
// ---- Settings ----
export interface ConfigView {
default_shell: string;
font_family: string;
font_size: number;
theme: "dark" | "light";
accent: string;
}
export async function getConfig(): Promise<ConfigView> {
return await invoke<ConfigView>("get_config");
}
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent">>): Promise<void> {
await invoke("set_config", {
defaultShell: patch.default_shell ?? null,
fontFamily: patch.font_family ?? null,
fontSize: patch.font_size ?? null,
theme: patch.theme ?? null,
accent: patch.accent ?? null,
});
}
export async function shutdownDaemon(): Promise<void> {
try { await invoke("shutdown_daemon"); } catch { /* connection drops as the daemon exits — expected */ }
}
export async function restartDaemon(): Promise<void> {
await shutdownDaemon();
// Let the old process exit; the next request triggers the bridge's
// ensure_daemon respawn (or launchd KeepAlive) and reconnects.
await new Promise((r) => setTimeout(r, 600));
try { await getHealth(); } catch { /* reconnect loop will retry */ }
}
+86 -17
View File
@@ -2,23 +2,23 @@ import type { SurfaceState } from "./layoutTypes";
/** Design tokens — mirror of DOCS/space-sh.pen variables. Single source for the UI. */
export const COLORS = {
accent: "#4C8DFF",
bgApp: "#0E1116",
bgElevated: "#1A2029",
bgHover: "#222A35",
bgPanel: "#0A0D12",
bgSidebar: "#13171F",
borderStrong: "#323C49",
borderSubtle: "#232A33",
textPrimary: "#E6EDF3",
textSecondary: "#8B97A6",
textMuted: "#5A6573",
stWork: "#4C8DFF",
stWait: "#F2B84B",
stDone: "#3FB950",
stError: "#F4544E",
stIdle: "#5A6573",
searchMatch: "#5A4A1F",
accent: "var(--c-accent)",
bgApp: "var(--c-bg-app)",
bgElevated: "var(--c-bg-elevated)",
bgHover: "var(--c-bg-hover)",
bgPanel: "var(--c-bg-panel)",
bgSidebar: "var(--c-bg-sidebar)",
borderStrong: "var(--c-border-strong)",
borderSubtle: "var(--c-border-subtle)",
textPrimary: "var(--c-text-primary)",
textSecondary: "var(--c-text-secondary)",
textMuted: "var(--c-text-muted)",
stWork: "var(--c-st-work)",
stWait: "var(--c-st-wait)",
stDone: "var(--c-st-done)",
stError: "var(--c-st-error)",
stIdle: "var(--c-st-idle)",
searchMatch: "var(--c-search-match)",
} as const;
export const FONT = {
@@ -35,3 +35,72 @@ export const STATE_COLOR: Record<SurfaceState | "stopped", string> = {
idle: COLORS.stIdle,
stopped: COLORS.stIdle,
};
// ---------------------------------------------------------------------------
// Palettes
// ---------------------------------------------------------------------------
type Palette = Record<string, string>;
/** Dark palette — hex values identical to what COLORS contained before. */
const DARK: Palette = {
"bg-app": "#0E1116",
"bg-elevated": "#1A2029",
"bg-hover": "#222A35",
"bg-panel": "#0A0D12",
"bg-sidebar": "#13171F",
"border-strong": "#323C49",
"border-subtle": "#232A33",
"text-primary": "#E6EDF3",
"text-secondary": "#8B97A6",
"text-muted": "#5A6573",
"st-work": "#4C8DFF",
"st-wait": "#F2B84B",
"st-done": "#3FB950",
"st-error": "#F4544E",
"st-idle": "#5A6573",
"search-match": "#5A4A1F",
};
/** Light palette — a readable counterpart for every token. */
const LIGHT: Palette = {
"bg-app": "#F5F7FA",
"bg-elevated": "#FFFFFF",
"bg-hover": "#E8EDF3",
"bg-panel": "#EBEEF3",
"bg-sidebar": "#DDE3EC",
"border-strong": "#B0BAC7",
"border-subtle": "#CDD4DE",
"text-primary": "#0D1117",
"text-secondary": "#4A5568",
"text-muted": "#8898AA",
"st-work": "#2266CC",
"st-wait": "#C07800",
"st-done": "#1A7A30",
"st-error": "#C4231F",
"st-idle": "#8898AA",
"search-match": "#FDE68A",
};
export const ACCENTS: Record<string, string> = {
blue: "#4C8DFF",
teal: "#34D3C2",
purple: "#9B7BFF",
green: "#3FB950",
orange: "#F2934B",
};
export type ThemeName = "dark" | "light";
/** Real color values for consumers that can't use var() (xterm). Keys are the kebab tokens plus "accent". */
export function resolvePalette(theme: ThemeName, accent: string): Record<string, string> {
const base = theme === "light" ? LIGHT : DARK;
return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
}
/** Write the active palette to :root as --c-* custom properties. */
export function applyTheme(theme: ThemeName, accent: string): void {
const p = resolvePalette(theme, accent);
const root = document.documentElement.style;
for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
}
+25
View File
@@ -0,0 +1,25 @@
use serde::{Deserialize, Serialize};
/// Effective (resolved) daemon configuration sent to clients.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfigView {
pub default_shell: String,
pub font_family: String,
pub font_size: u16,
pub theme: String,
pub accent: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_view_round_trips() {
let c = ConfigView {
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
font_size: 13, theme: "dark".into(), accent: "blue".into(),
};
let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back, c);
}
}
+2
View File
@@ -1,4 +1,5 @@
pub mod codec;
pub mod config_view;
pub mod event;
pub mod ids;
pub mod layout;
@@ -6,6 +7,7 @@ pub mod message;
pub mod status;
pub mod workspace;
pub use config_view::ConfigView;
pub use event::{EventKind, EventRecord, MarkReadTarget};
pub use ids::{GroupId, SurfaceId, WorkspaceId};
pub use layout::{LayoutNode, Orient};
+14
View File
@@ -132,6 +132,19 @@ pub enum Cmd {
Health,
Status,
Shutdown,
GetConfig,
SetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
default_shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_size: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accent: Option<String>,
},
}
/// Daemon → subscribers push events. The active subset for M0+M1.
@@ -150,6 +163,7 @@ pub enum Evt {
State { surface_id: SurfaceId, state: SurfaceState },
Event { record: EventRecord },
EventsRead { ids: Vec<u64> },
ConfigChanged { config: crate::config_view::ConfigView },
}
#[cfg(test)]
+111 -3
View File
@@ -4,17 +4,58 @@
//! partial config never breaks startup.
use std::path::Path;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct TerminalConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<u16>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AppearanceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Config {
/// Shell launched for plain (no-command) panels. When unset, the daemon
/// auto-detects the user's login shell.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_shell: Option<String>,
#[serde(default)]
pub terminal: TerminalConfig,
#[serde(default)]
pub appearance: AppearanceConfig,
}
impl Config {
/// Resolve to a client-facing view, applying defaults and the shell resolver.
pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView {
spacesh_proto::config_view::ConfigView {
default_shell: self.resolved_shell(),
font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()),
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
}
}
/// Shell for a plain panel using THIS in-memory config
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
pub fn resolved_shell(&self) -> String {
if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } }
if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } }
if let Some(s) = login_shell() { return s; }
if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } }
"/bin/sh".into()
}
/// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults.
pub fn load() -> Self {
let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() };
@@ -27,6 +68,23 @@ impl Config {
Err(_) => Self::default(),
}
}
/// Persist to `~/.spacesh/config.toml`.
pub fn save(&self) -> std::io::Result<()> {
let dir = crate::lifecycle::spacesh_dir()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
self.save_to(&dir.join("config.toml"))
}
/// Persist to an arbitrary path. Creates the parent directory if needed.
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let s = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
std::fs::write(path, s)
}
}
/// Resolve the shell to spawn for a plain panel.
@@ -34,6 +92,8 @@ impl Config {
/// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell
/// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under
/// launchd, where `$SHELL` is typically absent (so a bash fallback would win).
// retained for the env-override test and potential startup use
#[allow(dead_code)]
pub fn default_shell() -> String {
if let Ok(s) = std::env::var("SPACESH_SHELL") {
if !s.is_empty() { return s; }
@@ -98,4 +158,52 @@ mod tests {
std::env::remove_var("SPACESH_SHELL");
assert_eq!(s, "/tmp/fake-shell");
}
#[test]
fn parses_terminal_and_appearance() {
let dir = std::env::temp_dir().join(format!("spacesh-cfg-sections-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path,
"default_shell = \"/bin/zsh\"\n[terminal]\nfont_family = \"Menlo\"\nfont_size = 15\n[appearance]\ntheme = \"light\"\naccent = \"teal\"\n").unwrap();
let c = Config::from_path(&path);
assert_eq!(c.terminal.font_family.as_deref(), Some("Menlo"));
assert_eq!(c.terminal.font_size, Some(15));
assert_eq!(c.appearance.theme.as_deref(), Some("light"));
assert_eq!(c.appearance.accent.as_deref(), Some("teal"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_sections_default() {
let c = Config::from_path(Path::new("/no/such/cfg.toml"));
assert!(c.terminal.font_family.is_none());
assert!(c.appearance.theme.is_none());
}
#[test]
fn to_view_applies_defaults_and_clamp() {
let mut c = Config::default();
c.terminal.font_size = Some(99);
let v = c.to_view();
assert_eq!(v.font_size, 20);
assert_eq!(v.theme, "dark");
assert_eq!(v.accent, "blue");
assert!(!v.font_family.is_empty());
}
#[test]
fn save_then_reload_round_trips() {
let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
let mut c = Config::default();
c.terminal.font_size = Some(14);
c.appearance.accent = Some("purple".into());
c.save_to(&path).unwrap();
let back = Config::from_path(&path);
assert_eq!(back.terminal.font_size, Some(14));
assert_eq!(back.appearance.accent.as_deref(), Some("purple"));
let _ = std::fs::remove_file(&path);
}
}
+40 -4
View File
@@ -135,6 +135,7 @@ async fn router(
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
// surface_id → set of client ids subscribed (attached).
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();
let mut config = crate::config::Config::load();
while let Some(msg) = rx.recv().await {
match msg {
@@ -177,7 +178,7 @@ async fn router(
ServerMsg::Request { id, cmd, client, out } => {
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients,
&router_tx, &exit_tx, &state_tx, &persister,
&mut event_log, &event_persister, started_at_ms).await;
&mut event_log, &event_persister, started_at_ms, &mut config).await;
}
}
}
@@ -276,6 +277,7 @@ async fn handle_request(
event_log: &mut EventLog,
event_persister: &EventPersister,
started_at_ms: u64,
config: &mut crate::config::Config,
) {
use spacesh_proto::message::SplitDir;
use spacesh_proto::layout::{LayoutNode, Orient};
@@ -298,7 +300,7 @@ async fn handle_request(
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
};
let sid = reg.new_surface_id();
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
let spec = SurfaceSpec {
command: shell, args: args.clone(), cwd: ws.path.clone(),
agent_label: command, cols, rows, autostart: false,
@@ -333,7 +335,7 @@ async fn handle_request(
};
let ws = reg.workspace(&ws_id).cloned().unwrap();
let new_sid = reg.new_surface_id();
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
let (env, hooks_active) = spawn_env(&new_sid, &spec);
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
@@ -406,7 +408,7 @@ async fn handle_request(
let slot = slots.get(i);
let new_sid = reg.new_surface_id();
let command = slot.and_then(|s| s.command.clone());
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
let (env, hooks_active) = spawn_env(&new_sid, &spec);
@@ -649,6 +651,40 @@ async fn handle_request(
let _ = out.send(ok(id, serde_json::Value::Null)).await;
std::process::exit(0);
}
Cmd::GetConfig => {
match serde_json::to_value(config.to_view()) {
Ok(v) => { let _ = out.send(ok(id, v)).await; }
Err(e) => { let _ = out.send(err(id, "INTERNAL", &e.to_string())).await; }
}
}
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
if let Some(v) = &theme {
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
}
if let Some(v) = &accent {
const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
}
let mut next = config.clone();
if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); }
if let Some(v) = theme { next.appearance.theme = Some(v); }
if let Some(v) = accent { next.appearance.accent = Some(v); }
let to_save = next.clone();
match tokio::task::spawn_blocking(move || to_save.save()).await {
Ok(Ok(())) => {
*config = next;
let view = config.to_view();
broadcast_evt(clients, &Envelope::Evt(Evt::ConfigChanged { config: view }));
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Ok(Err(e)) => { let _ = out.send(err(id, "SAVE_FAILED", &e.to_string())).await; }
Err(e) => { let _ = out.send(err(id, "SAVE_FAILED", &e.to_string())).await; }
}
}
}
}