diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 1b20ade..5548541 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -327,3 +327,27 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result) -> Result { 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 { + 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, + font_family: Option, + font_size: Option, + theme: Option, + accent: Option, +) -> Result { + 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 { + data_of(state.request(Cmd::Shutdown).await.map_err(|e| e.to_string())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 35a4bc9..9bf792c 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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"); diff --git a/app/src/App.tsx b/app/src/App.tsx index b9b4d2e..600a539 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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([]); const [wizard, setWizard] = useState(false); const [deleteTarget, setDeleteTarget] = useState(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(null); + const [config, setConfigState] = useState(null); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); const [searchSurfaceId, setSearchSurfaceId] = useState(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 (
- setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} /> + setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
{sidebarOpen && setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
@@ -146,7 +158,7 @@ export function App() { )}
{active - ? setSearchSurfaceId(null)} /> + ? setSearchSurfaceId(null)} font={termFont} palette={termPalette} /> :
No workspace — create one to begin.
}
@@ -158,6 +170,7 @@ export function App() { /> )}
+ {settingsOpen && config && setSettingsOpen(false)} />} {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} {deleteTarget && ( void; + font: { family: string; size: number } | null; + palette: Record | 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(null); @@ -78,7 +80,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f if (!layout) { return
Empty workspace — apply a preset to add panels.
; } - 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 (
@@ -103,6 +105,8 @@ interface NodeProps { searchSurfaceId: string | null; searchNonce: number; onCloseSearch: () => void; + font: { family: string; size: number } | null; + palette: Record | null; } function Node({ node, path, ...rest }: NodeProps) { @@ -112,7 +116,7 @@ function Node({ node, path, ...rest }: NodeProps) { return ; } -function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit & { id: string }) { +function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit & { 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); }} />}
- +
{searchSurfaceId === id && ( diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx new file mode 100644 index 0000000..4334fe5 --- /dev/null +++ b/app/src/Settings.tsx @@ -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(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 ( +
+
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 }}> +
Settings
+ +
Terminal font
+ +
+ Size {sizeLocal} + setSizeLocal(Number(e.target.value))} + onPointerUp={() => void setConfig({ font_size: sizeLocal })} + style={{ flex: 1 }} /> +
+ +
Theme
+
+ {(["dark", "light"] as const).map((t) => ( + + ))} +
+
Accent
+
+ {Object.entries(ACCENTS).map(([id, hex]) => ( +
+ +
Default shell (empty = auto)
+ 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 }} /> + + +
+
+ ); +} + +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); + return ( +
+
Daemon
+
+ {health ? (<> +
version {health.version} · pid {health.pid}
+
uptime {fmtUptime(health.started_at_ms)}
+ ) :
offline
} +
+
+ + +
+ {confirm && ( +
+
+ {confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index f16ebb6..cc5817a 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -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) { + 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 | null }) { const ref = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(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
; } diff --git a/app/src/TopBar.tsx b/app/src/TopBar.tsx index df90abc..fbff10c 100644 --- a/app/src/TopBar.tsx +++ b/app/src/TopBar.tsx @@ -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 (
} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> - } title="Settings (mock)" /> + } onClick={onOpenSettings} title="Settings" />