Merge settings-modal: daemon config, Get/Set/ConfigChanged, CSS-var theming, settings modal with Stop/Restart
This commit is contained in:
@@ -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())?)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user