feat(app): settings modal — terminal, appearance, shell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -5,6 +5,7 @@ import { TopBar } from "./TopBar";
|
|||||||
import { CenterToolbar } from "./CenterToolbar";
|
import { CenterToolbar } from "./CenterToolbar";
|
||||||
import { Wizard } from "./Wizard";
|
import { Wizard } from "./Wizard";
|
||||||
import { ConfirmDelete } from "./ConfirmDelete";
|
import { ConfirmDelete } from "./ConfirmDelete";
|
||||||
|
import { Settings } from "./Settings";
|
||||||
import { EventCenter } from "./EventCenter";
|
import { EventCenter } from "./EventCenter";
|
||||||
import { maybeNotify } from "./notify";
|
import { maybeNotify } from "./notify";
|
||||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
||||||
@@ -31,6 +32,7 @@ export function App() {
|
|||||||
const [events, setEvents] = useState<EventRecord[]>([]);
|
const [events, setEvents] = useState<EventRecord[]>([]);
|
||||||
const [wizard, setWizard] = useState(false);
|
const [wizard, setWizard] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
|
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
|
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
|
||||||
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
||||||
@@ -147,7 +149,7 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
<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={() => setSettingsOpen(true)} />
|
||||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
<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} />}
|
{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 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
@@ -168,6 +170,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} />}
|
||||||
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<ConfirmDelete
|
<ConfirmDelete
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { COLORS, FONT } from "./theme";
|
||||||
|
import { setConfig } from "./socketBridge";
|
||||||
|
import type { ConfigView, DaemonHealth } from "./socketBridge";
|
||||||
|
|
||||||
|
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
||||||
|
const ACCENTS: { id: string; hex: string }[] = [
|
||||||
|
{ id: "blue", hex: "#4C8DFF" }, { id: "teal", hex: "#34D3C2" }, { id: "purple", hex: "#9B7BFF" },
|
||||||
|
{ id: "green", hex: "#3FB950" }, { id: "orange", hex: "#F2934B" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Settings({ config, health, onClose }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { ref.current?.focus(); }, []);
|
||||||
|
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 {config.font_size}</span>
|
||||||
|
<input type="range" min={10} max={20} value={config.font_size} onChange={(e) => void setConfig({ font_size: Number(e.target.value) })} 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 }}>
|
||||||
|
{ACCENTS.map((a) => (
|
||||||
|
<button key={a.id} onClick={() => void setConfig({ accent: a.id })} aria-label={a.id}
|
||||||
|
style={{ width: 26, height: 26, borderRadius: "50%", background: a.hex, cursor: "pointer",
|
||||||
|
border: config.accent === a.id ? `2px solid ${COLORS.textPrimary}` : "2px solid transparent" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
|
||||||
|
<input defaultValue={config.default_shell} onBlur={(e) => void setConfig({ default_shell: e.target.value })}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder — fleshed out in Task 11. Keep the signature stable.
|
||||||
|
function DaemonSection(_props: { health: DaemonHealth | null }) { return null; }
|
||||||
+3
-2
@@ -29,7 +29,7 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread,
|
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
active: WorkspaceView | null;
|
active: WorkspaceView | null;
|
||||||
eventsOpen: boolean;
|
eventsOpen: boolean;
|
||||||
@@ -38,6 +38,7 @@ export function TopBar({
|
|||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
unread: number;
|
unread: number;
|
||||||
|
onOpenSettings: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -85,7 +86,7 @@ export function TopBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
|
<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" }} />
|
<span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} />
|
||||||
<button
|
<button
|
||||||
title="Account (mock)"
|
title="Account (mock)"
|
||||||
|
|||||||
Reference in New Issue
Block a user