Merge backlog-polish: search fix, app icon, shell config, lock-test fix, GUI backlog
@@ -246,6 +246,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -382,6 +388,12 @@ dependencies = [
|
|||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -403,6 +415,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ioctl-rs"
|
name = "ioctl-rs"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -736,6 +758,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial"
|
name = "serial"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -889,6 +920,7 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"fs2",
|
"fs2",
|
||||||
"futures",
|
"futures",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"spacesh-core",
|
"spacesh-core",
|
||||||
@@ -897,6 +929,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -986,6 +1019,47 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -1200,6 +1274,15 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|||||||
@@ -26,5 +26,7 @@ portable-pty = "0.8"
|
|||||||
alacritty_terminal = "0.25"
|
alacritty_terminal = "0.25"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
toml = "0.8"
|
||||||
|
libc = "0.2"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-search": "^0.16.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
@@ -1463,6 +1464,15 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xterm/addon-search": {
|
"node_modules/@xterm/addon-search": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-search": "^0.16.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 985 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#222B3D"/>
|
||||||
|
<stop offset="1" stop-color="#0E131C"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- app tile -->
|
||||||
|
<rect x="84" y="84" width="856" height="856" rx="196" fill="url(#bg)"/>
|
||||||
|
<rect x="84" y="84" width="856" height="856" rx="196" fill="none" stroke="#3A4459" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- workspace window -->
|
||||||
|
<rect x="214" y="270" width="596" height="452" rx="40" fill="#0B0F17" stroke="#33405A" stroke-width="4"/>
|
||||||
|
<!-- vertical split -->
|
||||||
|
<line x1="512" y1="318" x2="512" y2="674" stroke="#33405A" stroke-width="4"/>
|
||||||
|
<!-- focused left pane glow + accent edge -->
|
||||||
|
<rect x="214" y="270" width="298" height="452" rx="40" fill="#4F9CF9" opacity="0.12"/>
|
||||||
|
<path d="M226 312 q0 -30 30 -30 h214 q30 0 30 30 v368 q0 30 -30 30 h-214 q-30 0 -30 -30 Z" fill="none" stroke="#4F9CF9" stroke-width="8"/>
|
||||||
|
|
||||||
|
<!-- prompt chevron (filled) + cursor (left pane) -->
|
||||||
|
<path d="M282 426 L300 408 L364 472 q8 8 0 16 L300 552 L282 534 L338 480 Z" fill="#34D3C2"/>
|
||||||
|
<rect x="388" y="454" width="92" height="54" rx="11" fill="#34D3C2"/>
|
||||||
|
|
||||||
|
<!-- right pane: log lines -->
|
||||||
|
<g fill="#46546E">
|
||||||
|
<rect x="556" y="384" width="196" height="28" rx="14"/>
|
||||||
|
<rect x="556" y="450" width="150" height="28" rx="14"/>
|
||||||
|
<rect x="556" y="516" width="178" height="28" rx="14"/>
|
||||||
|
<rect x="556" y="582" width="120" height="28" rx="14"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 665 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 984 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -12,5 +12,15 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
|
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
|
||||||
"security": { "csp": null }
|
"security": { "csp": null }
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import type { EventRecord, DaemonHealth } from "./socketBridge";
|
|||||||
import { leafIds } from "./layoutTypes";
|
import { leafIds } from "./layoutTypes";
|
||||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||||
|
|
||||||
|
/** Read a boolean UI flag from localStorage, falling back to `def`. */
|
||||||
|
function loadFlag(key: string, def: boolean): boolean {
|
||||||
|
try { const v = localStorage.getItem(key); return v === null ? def : v === "1"; }
|
||||||
|
catch { return def; }
|
||||||
|
}
|
||||||
|
function saveFlag(key: string, value: boolean): void {
|
||||||
|
try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
|
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
|
||||||
@@ -21,7 +30,8 @@ export function App() {
|
|||||||
const [states, setStates] = useState<Record<string, SurfaceState>>({});
|
const [states, setStates] = useState<Record<string, SurfaceState>>({});
|
||||||
const [events, setEvents] = useState<EventRecord[]>([]);
|
const [events, setEvents] = useState<EventRecord[]>([]);
|
||||||
const [wizard, setWizard] = useState(false);
|
const [wizard, setWizard] = useState(false);
|
||||||
const [eventsOpen, setEventsOpen] = useState(true);
|
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
|
||||||
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
@@ -104,6 +114,9 @@ export function App() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]);
|
||||||
|
useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]);
|
||||||
|
|
||||||
const unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
|
const unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
|
||||||
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
||||||
const leaves = active ? leafIds(active.layout) : [];
|
const leaves = active ? leafIds(active.layout) : [];
|
||||||
@@ -117,9 +130,9 @@ 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)} unread={unread} />
|
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} />
|
||||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||||
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />
|
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />}
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
{active && (
|
{active && (
|
||||||
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} />
|
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Maximize2, Minimize2, RotateCw } from "lucide-react";
|
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
import { StatusRing } from "./StatusRing";
|
import { StatusRing } from "./StatusRing";
|
||||||
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
||||||
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
||||||
import { setRatios, restartSurface, setZoom } from "./socketBridge";
|
import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -18,6 +18,16 @@ interface Props {
|
|||||||
zoomed: string | null;
|
zoomed: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Edge = "left" | "right" | "top" | "bottom";
|
||||||
|
interface DropTarget { id: string; edge: Edge }
|
||||||
|
|
||||||
|
function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge {
|
||||||
|
const px = (clientX - r.left) / r.width;
|
||||||
|
const py = (clientY - r.top) / r.height;
|
||||||
|
const d: Record<Edge, number> = { left: px, right: 1 - px, top: py, bottom: 1 - py };
|
||||||
|
return (Object.keys(d) as Edge[]).reduce((a, b) => (d[b] < d[a] ? b : a), "left");
|
||||||
|
}
|
||||||
|
|
||||||
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
|
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
|
||||||
function shortPath(cwd: string): string {
|
function shortPath(cwd: string): string {
|
||||||
const leaf = cwd.split("/").filter(Boolean).pop();
|
const leaf = cwd.split("/").filter(Boolean).pop();
|
||||||
@@ -25,43 +35,91 @@ function shortPath(cwd: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) {
|
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: 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);
|
||||||
|
const dropRef = useRef<DropTarget | null>(null);
|
||||||
|
dropRef.current = drop;
|
||||||
|
|
||||||
|
const startPanelDrag = (srcId: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.clientX, startY = e.clientY;
|
||||||
|
let active = false;
|
||||||
|
const prevUserSelect = document.body.style.userSelect;
|
||||||
|
const move = (ev: MouseEvent) => {
|
||||||
|
if (!active) {
|
||||||
|
if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 5) return;
|
||||||
|
active = true;
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
}
|
||||||
|
const el = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest("[data-surface-id]") as HTMLElement | null;
|
||||||
|
const tid = el?.getAttribute("data-surface-id");
|
||||||
|
if (!el || !tid || tid === srcId) { setDrop(null); return; }
|
||||||
|
setDrop({ id: tid, edge: edgeAt(ev.clientX, ev.clientY, el.getBoundingClientRect()) });
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener("mousemove", move);
|
||||||
|
window.removeEventListener("mouseup", up);
|
||||||
|
document.body.style.userSelect = prevUserSelect;
|
||||||
|
const d = dropRef.current;
|
||||||
|
setDrop(null);
|
||||||
|
if (active && d && d.id !== srcId) void moveSurface(srcId, d.id, d.edge);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", move);
|
||||||
|
window.addEventListener("mouseup", up);
|
||||||
|
};
|
||||||
|
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
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 };
|
||||||
if (zoomed) {
|
if (zoomed) {
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||||
<Node workspaceId={workspaceId} node={{ leaf: { surface_id: zoomed } }} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
|
<Node node={{ leaf: { surface_id: zoomed } }} path={[]} {...shared} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||||
<Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
|
<Node node={layout} path={[]} {...shared} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: {
|
interface NodeProps {
|
||||||
workspaceId: string; node: LayoutNode; path: number[];
|
workspaceId: string; node: LayoutNode; path: number[];
|
||||||
running: Record<string, boolean>; states: Record<string, SurfaceState>;
|
running: Record<string, boolean>; states: Record<string, SurfaceState>;
|
||||||
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
|
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
|
||||||
zoomed: string | null;
|
zoomed: string | null;
|
||||||
}) {
|
drop: DropTarget | null;
|
||||||
|
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Node({ node, path, ...rest }: NodeProps) {
|
||||||
if ("leaf" in node) {
|
if ("leaf" in node) {
|
||||||
const id = node.leaf.surface_id;
|
return <Leaf id={node.leaf.surface_id} {...rest} />;
|
||||||
|
}
|
||||||
|
return <SplitView split={node.split} path={path} {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||||
const focused = focusedId === id;
|
const focused = focusedId === id;
|
||||||
|
const dropEdge = drop && drop.id === id ? drop.edge : null;
|
||||||
|
|
||||||
const card = (inner: React.ReactNode) => (
|
const card = (inner: React.ReactNode) => (
|
||||||
<div
|
<div
|
||||||
|
data-surface-id={id}
|
||||||
onMouseDown={() => onFocus(id)}
|
onMouseDown={() => onFocus(id)}
|
||||||
style={{
|
style={{
|
||||||
display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
||||||
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
||||||
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
|
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
|
{dropEdge && <DropIndicator edge={dropEdge} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,7 +148,12 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o
|
|||||||
const state = states[id] ?? "idle";
|
const state = states[id] ?? "idle";
|
||||||
return card(
|
return card(
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
|
<div
|
||||||
|
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
|
||||||
|
title="Drag to move this panel"
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
|
||||||
|
>
|
||||||
|
<GripVertical size={13} color={COLORS.textMuted} />
|
||||||
<StatusRing state={state} running={true} />
|
<StatusRing state={state} running={true} />
|
||||||
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
|
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
|
||||||
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
|
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
|
||||||
@@ -109,61 +172,74 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const { orient, ratios, children } = node.split;
|
|
||||||
const dir = orient === "h" ? "row" : "column";
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
|
|
||||||
{children.map((child, i) => (
|
|
||||||
<Pane key={i} grow={ratios[i] ?? 1} isLast={i === children.length - 1} orient={orient}
|
|
||||||
onResize={(deltaFrac) => {
|
|
||||||
const next = [...ratios];
|
|
||||||
next[i] = Math.max(0.05, next[i] + deltaFrac);
|
|
||||||
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
|
|
||||||
void setRatios(workspaceId, path, next);
|
|
||||||
}}>
|
|
||||||
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
|
|
||||||
</Pane>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) {
|
function DropIndicator({ edge }: { edge: Edge }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const base: React.CSSProperties = { position: "absolute", background: `${COLORS.accent}55`, border: `2px solid ${COLORS.accent}`, pointerEvents: "none", boxSizing: "border-box", zIndex: 5 };
|
||||||
const startDrag = (e: React.MouseEvent) => {
|
const map: Record<Edge, React.CSSProperties> = {
|
||||||
|
left: { ...base, top: 0, bottom: 0, left: 0, width: "50%" },
|
||||||
|
right: { ...base, top: 0, bottom: 0, right: 0, width: "50%" },
|
||||||
|
top: { ...base, left: 0, right: 0, top: 0, height: "50%" },
|
||||||
|
bottom: { ...base, left: 0, right: 0, bottom: 0, height: "50%" },
|
||||||
|
};
|
||||||
|
return <div style={map[edge]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SplitView({ split, path, ...rest }: Omit<NodeProps, "node"> & { split: Extract<LayoutNode, { split: unknown }>["split"] }) {
|
||||||
|
const { orient, ratios, children } = split;
|
||||||
|
const { workspaceId } = rest;
|
||||||
|
const dir = orient === "h" ? "row" : "column";
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [live, setLive] = useState<number[] | null>(null);
|
||||||
|
// Drop any local override once the authoritative ratios arrive from the daemon.
|
||||||
|
useEffect(() => { setLive(null); }, [ratios.join(",")]);
|
||||||
|
const effective = live ?? ratios;
|
||||||
|
|
||||||
|
const startDrag = (i: number, e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const parent = ref.current?.parentElement;
|
e.stopPropagation();
|
||||||
if (!parent) return;
|
const container = containerRef.current;
|
||||||
const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
|
if (!container) return;
|
||||||
let last = orient === "h" ? e.clientX : e.clientY;
|
const total = orient === "h" ? container.clientWidth : container.clientHeight;
|
||||||
|
if (total <= 0) return;
|
||||||
|
const start = orient === "h" ? e.clientX : e.clientY;
|
||||||
|
const base = [...ratios];
|
||||||
|
const sum = base.reduce((a, b) => a + b, 0) || 1;
|
||||||
const move = (ev: MouseEvent) => {
|
const move = (ev: MouseEvent) => {
|
||||||
const cur = orient === "h" ? ev.clientX : ev.clientY;
|
const cur = orient === "h" ? ev.clientX : ev.clientY;
|
||||||
const delta = (cur - last) / total;
|
// Accumulated delta from drag start — not an incremental step — so the
|
||||||
last = cur;
|
// panel tracks the pointer 1:1 instead of crawling one echo at a time.
|
||||||
onResize(delta);
|
const deltaFrac = ((cur - start) / total) * sum;
|
||||||
|
const next = [...base];
|
||||||
|
next[i] = Math.max(0.05, base[i] + deltaFrac);
|
||||||
|
next[i + 1] = Math.max(0.05, (base[i + 1] ?? 1) - deltaFrac);
|
||||||
|
setLive(next);
|
||||||
};
|
};
|
||||||
const up = () => {
|
const up = () => {
|
||||||
window.removeEventListener("mousemove", move);
|
window.removeEventListener("mousemove", move);
|
||||||
window.removeEventListener("mouseup", up);
|
window.removeEventListener("mouseup", up);
|
||||||
|
setLive((cur) => { if (cur) void setRatios(workspaceId, path, cur); return cur; });
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", move);
|
window.addEventListener("mousemove", move);
|
||||||
window.addEventListener("mouseup", up);
|
window.addEventListener("mouseup", up);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={containerRef} style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
|
||||||
<div ref={ref} style={{ flexGrow: grow, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "hidden", position: "relative" }}>
|
{children.map((child, i) => (
|
||||||
{children}
|
<div key={i} style={{ flexGrow: effective[i] ?? 1, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "visible", position: "relative", display: "flex" }}>
|
||||||
</div>
|
<Node node={child} path={[...path, i]} {...rest} />
|
||||||
{!isLast && (
|
{i < children.length - 1 && (
|
||||||
<div onMouseDown={startDrag}
|
<div onMouseDown={(e) => startDrag(i, e)}
|
||||||
style={{
|
style={{
|
||||||
flex: "0 0 10px",
|
position: "absolute", zIndex: 4,
|
||||||
cursor: orient === "h" ? "col-resize" : "row-resize",
|
...(orient === "h"
|
||||||
background: "transparent",
|
? { top: 0, bottom: 0, right: -5, width: 10, cursor: "col-resize" }
|
||||||
|
: { left: 0, right: 0, bottom: -5, height: 10, cursor: "row-resize" }),
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,16 +45,17 @@ export function SearchBar({
|
|||||||
};
|
};
|
||||||
}, [surfaceId]);
|
}, [surfaceId]);
|
||||||
|
|
||||||
function run(forward: boolean) {
|
function run(forward: boolean, override?: string) {
|
||||||
if (!surfaceId) return;
|
if (!surfaceId) return;
|
||||||
const addon = getSearch(surfaceId);
|
const addon = getSearch(surfaceId);
|
||||||
if (!addon || !term) {
|
const query = override ?? term;
|
||||||
|
if (!addon || !query) {
|
||||||
addon?.clearDecorations();
|
addon?.clearDecorations();
|
||||||
setCount({ index: -1, total: 0 });
|
setCount({ index: -1, total: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (forward) addon.findNext(term, SEARCH_OPTS);
|
if (forward) addon.findNext(query, SEARCH_OPTS);
|
||||||
else addon.findPrevious(term, SEARCH_OPTS);
|
else addon.findPrevious(query, SEARCH_OPTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,9 +80,14 @@ export function SearchBar({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTerm(e.target.value);
|
const value = e.target.value;
|
||||||
|
setTerm(value);
|
||||||
|
if (!value) {
|
||||||
setCount({ index: -1, total: 0 });
|
setCount({ index: -1, total: 0 });
|
||||||
if (surfaceId) getSearch(surfaceId)?.clearDecorations();
|
if (surfaceId) getSearch(surfaceId)?.clearDecorations();
|
||||||
|
} else {
|
||||||
|
run(true, value); // search-as-you-type
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
|
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
|
||||||
import { registerSearch, unregisterSearch } from "./searchRegistry";
|
import { registerSearch, unregisterSearch } from "./searchRegistry";
|
||||||
|
|
||||||
@@ -25,6 +26,27 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
|
|||||||
term.loadAddon(search);
|
term.loadAddon(search);
|
||||||
registerSearch(surfaceId, search);
|
registerSearch(surfaceId, search);
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(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.
|
||||||
|
let rafId = 0;
|
||||||
|
let lastCols = 0, lastRows = 0;
|
||||||
|
const doFit = () => {
|
||||||
|
rafId = 0;
|
||||||
|
try { fit.fit(); } catch { return; }
|
||||||
|
if (term.cols !== lastCols || term.rows !== lastRows) {
|
||||||
|
lastCols = term.cols;
|
||||||
|
lastRows = term.rows;
|
||||||
|
void resizeSurface(surfaceId, term.cols, term.rows);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleFit = () => { if (!rafId) rafId = requestAnimationFrame(doFit); };
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(scheduleFit);
|
||||||
|
ro.observe(ref.current);
|
||||||
|
|
||||||
// Input → daemon.
|
// Input → daemon.
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
void sendInput(surfaceId, encoder.encode(data));
|
void sendInput(surfaceId, encoder.encode(data));
|
||||||
@@ -38,14 +60,15 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
|
|||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (res.snapshot) term.write(res.snapshot);
|
if (res.snapshot) term.write(res.snapshot);
|
||||||
if (res.cols && res.rows) {
|
// Fit to the actual container rather than the daemon's stored geometry,
|
||||||
term.resize(res.cols, res.rows);
|
// then push the resulting size back so the PTY reflows to match.
|
||||||
void resizeSurface(surfaceId, res.cols, res.rows);
|
scheduleFit();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
ro.disconnect();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
void detachSurface(surfaceId);
|
void detachSurface(surfaceId);
|
||||||
unregisterSearch(surfaceId);
|
unregisterSearch(surfaceId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FolderGit2, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
|
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
|
||||||
import { COLORS, FONT } from "./theme";
|
import { COLORS, FONT } from "./theme";
|
||||||
import type { WorkspaceView } from "./layoutTypes";
|
import type { WorkspaceView } from "./layoutTypes";
|
||||||
import { leafIds } from "./layoutTypes";
|
import { leafIds } from "./layoutTypes";
|
||||||
@@ -29,11 +29,14 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
active, eventsOpen, onToggleEvents, unread,
|
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread,
|
||||||
}: {
|
}: {
|
||||||
active: WorkspaceView | null;
|
active: WorkspaceView | null;
|
||||||
eventsOpen: boolean;
|
eventsOpen: boolean;
|
||||||
onToggleEvents: () => void;
|
onToggleEvents: () => void;
|
||||||
|
onShowEvents: () => void;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
onToggleSidebar: () => void;
|
||||||
unread: number;
|
unread: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -44,8 +47,8 @@ export function TopBar({
|
|||||||
borderBottom: `1px solid ${COLORS.borderSubtle}`,
|
borderBottom: `1px solid ${COLORS.borderSubtle}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* macOS traffic-light spacer — real lights are drawn by the window chrome. */}
|
{/* Left: sidebar toggle, flush to the left edge. */}
|
||||||
<div style={{ width: 60, flex: "0 0 60px" }} />
|
<IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
|
||||||
|
|
||||||
{/* Workspace breadcrumb */}
|
{/* Workspace breadcrumb */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
@@ -67,10 +70,9 @@ export function TopBar({
|
|||||||
|
|
||||||
{/* Right cluster */}
|
{/* Right cluster */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
|
|
||||||
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
|
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
|
||||||
<div style={{ position: "relative", display: "flex" }}>
|
<div style={{ position: "relative", display: "flex" }}>
|
||||||
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" />
|
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
|
||||||
{unread > 0 && (
|
{unread > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px",
|
position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px",
|
||||||
@@ -82,6 +84,7 @@ export function TopBar({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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} />} title="Settings (mock)" />
|
||||||
<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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { PresetPicker, PRESETS } from "./PresetPicker";
|
import { PresetPicker, PRESETS } from "./PresetPicker";
|
||||||
import { openWorkspace, applyPreset } from "./socketBridge";
|
import { openWorkspace, applyPreset } from "./socketBridge";
|
||||||
|
|
||||||
@@ -6,10 +6,24 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
|||||||
const [path, setPath] = useState(".");
|
const [path, setPath] = useState(".");
|
||||||
const [preset, setPreset] = useState("2x2");
|
const [preset, setPreset] = useState("2x2");
|
||||||
const [agents, setAgents] = useState<string[]>([]);
|
const [agents, setAgents] = useState<string[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const pathRef = useRef<HTMLInputElement>(null);
|
||||||
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
|
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
|
||||||
const agentChoices = ["shell", "claude", "codex", "gemini"];
|
const agentChoices = ["shell", "claude", "codex", "gemini"];
|
||||||
|
|
||||||
|
// Grab focus on open — otherwise keystrokes leak to the xterm panel behind us
|
||||||
|
// (its helper textarea sits at z-index 1000 and keeps the live focus).
|
||||||
|
useEffect(() => {
|
||||||
|
pathRef.current?.focus();
|
||||||
|
pathRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
const ws = await openWorkspace(path);
|
const ws = await openWorkspace(path);
|
||||||
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
||||||
const a = agents[i] ?? "shell";
|
const a = agents[i] ?? "shell";
|
||||||
@@ -17,14 +31,32 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
|||||||
});
|
});
|
||||||
await applyPreset(ws, preset, slotSpecs);
|
await applyPreset(ws, preset, slotSpecs);
|
||||||
onDone(ws);
|
onDone(ws);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep keyboard events inside the modal and add the obvious shortcuts.
|
||||||
|
function onKeyDown(e: React.KeyboardEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
||||||
|
else if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "SELECT") { e.preventDefault(); void create(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "fixed", inset: 0, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
<div
|
||||||
<div style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}>
|
onMouseDown={onCancel}
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}
|
||||||
|
>
|
||||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
|
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
|
||||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
|
||||||
<input value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
<input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
||||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
|
||||||
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
||||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
||||||
@@ -36,9 +68,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
|||||||
</select>
|
</select>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{error && <div style={{ margin: "0 0 14px", padding: "8px 10px", background: "#3A1418", border: "1px solid #6B2230", borderRadius: 8, fontSize: 12, color: "#FF9AA6" }}>{error}</div>}
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
|
||||||
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
|
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
|
||||||
<button onClick={() => void create()} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>Create workspace</button>
|
<button onClick={() => void create()} disabled={busy} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700, opacity: busy ? 0.6 : 1 }}>
|
||||||
|
{busy ? "Creating…" : "Create workspace"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,3 +22,5 @@ thiserror.workspace = true
|
|||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
fs2.workspace = true
|
fs2.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! Daemon configuration loaded from `~/.spacesh/config.toml`.
|
||||||
|
//!
|
||||||
|
//! The file is optional; every field has a sane fallback so a missing or
|
||||||
|
//! partial config never breaks startup.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Shell launched for plain (no-command) panels. When unset, the daemon
|
||||||
|
/// auto-detects the user's login shell.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_shell: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// 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() };
|
||||||
|
Self::from_path(&dir.join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_path(path: &Path) -> Self {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(s) => toml::from_str(&s).unwrap_or_default(),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the shell to spawn for a plain panel.
|
||||||
|
///
|
||||||
|
/// 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).
|
||||||
|
pub fn default_shell() -> String {
|
||||||
|
if let Ok(s) = std::env::var("SPACESH_SHELL") {
|
||||||
|
if !s.is_empty() { return s; }
|
||||||
|
}
|
||||||
|
if let Some(s) = Config::load().default_shell {
|
||||||
|
if !s.is_empty() { return s; }
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current user's login shell from the passwd database (`getpwuid`).
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn login_shell() -> Option<String> {
|
||||||
|
use std::ffi::CStr;
|
||||||
|
// SAFETY: getpwuid returns a pointer into a static buffer valid until the
|
||||||
|
// next libc passwd call; we copy the string out immediately on this thread.
|
||||||
|
unsafe {
|
||||||
|
let pw = libc::getpwuid(libc::getuid());
|
||||||
|
if pw.is_null() { return None; }
|
||||||
|
let shell = (*pw).pw_shell;
|
||||||
|
if shell.is_null() { return None; }
|
||||||
|
let s = CStr::from_ptr(shell).to_str().ok()?;
|
||||||
|
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn login_shell() -> Option<String> { None }
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_is_default() {
|
||||||
|
let c = Config::from_path(Path::new("/no/such/spacesh/config.toml"));
|
||||||
|
assert!(c.default_shell.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_default_shell() {
|
||||||
|
let dir = std::env::temp_dir().join("spacesh-cfg-test");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap();
|
||||||
|
let c = Config::from_path(&path);
|
||||||
|
assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_wins() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell");
|
||||||
|
let s = default_shell();
|
||||||
|
std::env::remove_var("SPACESH_SHELL");
|
||||||
|
assert_eq!(s, "/tmp/fake-shell");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ pub fn socket_path() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn lock_path() -> Result<PathBuf> {
|
pub fn lock_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(p) = std::env::var("SPACESH_LOCK") {
|
||||||
|
if !p.is_empty() {
|
||||||
|
return Ok(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(spacesh_dir()?.join("daemon.lock"))
|
Ok(spacesh_dir()?.join("daemon.lock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +69,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lock_is_exclusive_within_process() {
|
fn lock_is_exclusive_within_process() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
// Use a private lock path so a real running daemon (which holds the
|
||||||
|
// global ~/.spacesh/daemon.lock) can't make this test flake.
|
||||||
|
let tmp = std::env::temp_dir().join("spacesh-lock-exclusive-test.lock");
|
||||||
|
std::env::set_var("SPACESH_LOCK", &tmp);
|
||||||
let first = acquire_instance_lock().unwrap();
|
let first = acquire_instance_lock().unwrap();
|
||||||
assert!(first.is_some(), "first acquire should succeed");
|
assert!(first.is_some(), "first acquire should succeed");
|
||||||
// A second attempt from the same process on the same fd path:
|
// A second attempt from the same process on the same fd path:
|
||||||
@@ -72,6 +82,8 @@ mod tests {
|
|||||||
let second = acquire_instance_lock().unwrap();
|
let second = acquire_instance_lock().unwrap();
|
||||||
assert!(second.is_none(), "second acquire should be blocked");
|
assert!(second.is_none(), "second acquire should be blocked");
|
||||||
drop(first);
|
drop(first);
|
||||||
|
std::env::remove_var("SPACESH_LOCK");
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod config;
|
||||||
mod event_log;
|
mod event_log;
|
||||||
mod event_store;
|
mod event_store;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ async fn handle_request(
|
|||||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
||||||
};
|
};
|
||||||
let sid = reg.new_surface_id();
|
let sid = reg.new_surface_id();
|
||||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
|
||||||
let spec = SurfaceSpec {
|
let spec = SurfaceSpec {
|
||||||
command: shell, args: args.clone(), cwd: ws.path.clone(),
|
command: shell, args: args.clone(), cwd: ws.path.clone(),
|
||||||
agent_label: command, cols, rows, autostart: false,
|
agent_label: command, cols, rows, autostart: false,
|
||||||
@@ -333,7 +333,7 @@ async fn handle_request(
|
|||||||
};
|
};
|
||||||
let ws = reg.workspace(&ws_id).cloned().unwrap();
|
let ws = reg.workspace(&ws_id).cloned().unwrap();
|
||||||
let new_sid = reg.new_surface_id();
|
let new_sid = reg.new_surface_id();
|
||||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
|
||||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
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);
|
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()) {
|
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
|
||||||
@@ -406,7 +406,7 @@ async fn handle_request(
|
|||||||
let slot = slots.get(i);
|
let slot = slots.get(i);
|
||||||
let new_sid = reg.new_surface_id();
|
let new_sid = reg.new_surface_id();
|
||||||
let command = slot.and_then(|s| s.command.clone());
|
let command = slot.and_then(|s| s.command.clone());
|
||||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
|
||||||
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
|
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 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);
|
let (env, hooks_active) = spawn_env(&new_sid, &spec);
|
||||||
|
|||||||