diff --git a/Cargo.lock b/Cargo.lock index 21360d4..99ddc86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,6 +246,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -382,6 +388,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -403,6 +415,16 @@ dependencies = [ "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]] name = "ioctl-rs" version = "0.1.6" @@ -736,6 +758,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serial" version = "0.4.0" @@ -889,6 +920,7 @@ dependencies = [ "dirs", "fs2", "futures", + "libc", "serde", "serde_json", "spacesh-core", @@ -897,6 +929,7 @@ dependencies = [ "thiserror", "tokio", "tokio-util", + "toml", ] [[package]] @@ -986,6 +1019,47 @@ dependencies = [ "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]] name = "unicode-ident" version = "1.0.24" @@ -1200,6 +1274,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 849d892..f22dd74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,5 +26,7 @@ portable-pty = "0.8" alacritty_terminal = "0.25" fs2 = "0.4" dirs = "5" +toml = "0.8" +libc = "0.2" clap = { version = "4", features = ["derive"] } clap_complete = "4" diff --git a/app/package-lock.json b/app/package-lock.json index f6be5ad..75a4d5c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "@fontsource/inter": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", @@ -1463,6 +1464,15 @@ "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": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", diff --git a/app/package.json b/app/package.json index e751bc6..fe0da1d 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,7 @@ "@fontsource/inter": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", diff --git a/app/src-tauri/icons/128x128.png b/app/src-tauri/icons/128x128.png index 843d237..4acbb30 100644 Binary files a/app/src-tauri/icons/128x128.png and b/app/src-tauri/icons/128x128.png differ diff --git a/app/src-tauri/icons/128x128@2x.png b/app/src-tauri/icons/128x128@2x.png index dc5a4a2..d846336 100644 Binary files a/app/src-tauri/icons/128x128@2x.png and b/app/src-tauri/icons/128x128@2x.png differ diff --git a/app/src-tauri/icons/32x32.png b/app/src-tauri/icons/32x32.png index c091288..f4442e0 100644 Binary files a/app/src-tauri/icons/32x32.png and b/app/src-tauri/icons/32x32.png differ diff --git a/app/src-tauri/icons/64x64.png b/app/src-tauri/icons/64x64.png new file mode 100644 index 0000000..0e1ef55 Binary files /dev/null and b/app/src-tauri/icons/64x64.png differ diff --git a/app/src-tauri/icons/Square107x107Logo.png b/app/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..1f740e0 Binary files /dev/null and b/app/src-tauri/icons/Square107x107Logo.png differ diff --git a/app/src-tauri/icons/Square142x142Logo.png b/app/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..aca3e5a Binary files /dev/null and b/app/src-tauri/icons/Square142x142Logo.png differ diff --git a/app/src-tauri/icons/Square150x150Logo.png b/app/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..60d531a Binary files /dev/null and b/app/src-tauri/icons/Square150x150Logo.png differ diff --git a/app/src-tauri/icons/Square284x284Logo.png b/app/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..056b497 Binary files /dev/null and b/app/src-tauri/icons/Square284x284Logo.png differ diff --git a/app/src-tauri/icons/Square30x30Logo.png b/app/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..1eea751 Binary files /dev/null and b/app/src-tauri/icons/Square30x30Logo.png differ diff --git a/app/src-tauri/icons/Square310x310Logo.png b/app/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..67812ed Binary files /dev/null and b/app/src-tauri/icons/Square310x310Logo.png differ diff --git a/app/src-tauri/icons/Square44x44Logo.png b/app/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..cba4685 Binary files /dev/null and b/app/src-tauri/icons/Square44x44Logo.png differ diff --git a/app/src-tauri/icons/Square71x71Logo.png b/app/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..41921b4 Binary files /dev/null and b/app/src-tauri/icons/Square71x71Logo.png differ diff --git a/app/src-tauri/icons/Square89x89Logo.png b/app/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..af028cf Binary files /dev/null and b/app/src-tauri/icons/Square89x89Logo.png differ diff --git a/app/src-tauri/icons/StoreLogo.png b/app/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..f4a5cda Binary files /dev/null and b/app/src-tauri/icons/StoreLogo.png differ diff --git a/app/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/app/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/app/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4ddaa31 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f866651 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..435f8d0 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..e03feb3 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3a28db8 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..82c28d2 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0a8615b Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fb4fa36 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d1aee0f Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..9413d66 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..af82c70 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..24c512a Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..80e708b Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..92eb5be Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fd94d13 Binary files /dev/null and b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons/android/values/ic_launcher_background.xml b/app/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/app/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/app/src-tauri/icons/icon.icns b/app/src-tauri/icons/icon.icns new file mode 100644 index 0000000..4d229e5 Binary files /dev/null and b/app/src-tauri/icons/icon.icns differ diff --git a/app/src-tauri/icons/icon.ico b/app/src-tauri/icons/icon.ico new file mode 100644 index 0000000..65c6a96 Binary files /dev/null and b/app/src-tauri/icons/icon.ico differ diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png index c091288..4be068d 100644 Binary files a/app/src-tauri/icons/icon.png and b/app/src-tauri/icons/icon.png differ diff --git a/app/src-tauri/icons/icon.svg b/app/src-tauri/icons/icon.svg new file mode 100644 index 0000000..b6a5564 --- /dev/null +++ b/app/src-tauri/icons/icon.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@1x.png b/app/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..e2939ac Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..63a30e2 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@2x.png b/app/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..63a30e2 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@3x.png b/app/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..50eb241 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@1x.png b/app/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..55e9673 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..3cf641f Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@2x.png b/app/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..3cf641f Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@3x.png b/app/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..33aad69 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@1x.png b/app/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..63a30e2 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..204ca4a Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@2x.png b/app/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..204ca4a Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@3x.png b/app/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..e7d9a7c Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-512@2x.png b/app/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..f78df53 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-60x60@2x.png b/app/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..e7d9a7c Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-60x60@3x.png b/app/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..fb71707 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-76x76@1x.png b/app/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..83fc2cf Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-76x76@2x.png b/app/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..8780a0d Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..f85e811 Binary files /dev/null and b/app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 160dcc4..83b218f 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -12,5 +12,15 @@ "app": { "windows": [{ "title": "spacesh", "width": 1100, "height": 720 }], "security": { "csp": null } + }, + "bundle": { + "active": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] } } diff --git a/app/src/App.tsx b/app/src/App.tsx index bbf58cc..70c2648 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,6 +13,15 @@ import type { EventRecord, DaemonHealth } from "./socketBridge"; import { leafIds } 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() { const [groups, setGroups] = useState([]); const [workspaces, setWorkspaces] = useState([]); @@ -21,7 +30,8 @@ export function App() { const [states, setStates] = useState>({}); const [events, setEvents] = useState([]); 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(null); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); @@ -104,6 +114,9 @@ export function App() { 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 active = workspaces.find((w) => w.id === activeId) ?? null; const leaves = active ? leafIds(active.layout) : []; @@ -117,9 +130,9 @@ export function App() { return (
- setEventsOpen((v) => !v)} unread={unread} /> + setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} />
- setWizard(true)} health={health} connected={connected} /> + {sidebarOpen && setWizard(true)} health={health} connected={connected} />}
{active && ( { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index e1c9e8b..623ffba 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -1,10 +1,10 @@ -import { useRef } from "react"; -import { Maximize2, Minimize2, RotateCw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react"; import { TerminalView } from "./TerminalView"; import { StatusRing } from "./StatusRing"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; -import { setRatios, restartSurface, setZoom } from "./socketBridge"; +import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge"; interface Props { workspaceId: string; @@ -18,6 +18,16 @@ interface Props { 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 = { 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 ~/ style label for the panel header. */ function shortPath(cwd: string): string { const leaf = cwd.split("/").filter(Boolean).pop(); @@ -25,145 +35,211 @@ function shortPath(cwd: string): string { } 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(null); + const dropRef = useRef(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) { return
Empty workspace — apply a preset to add panels.
; } + const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag }; if (zoomed) { return (
- +
); } return (
- +
); } -function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: { +interface NodeProps { workspaceId: string; node: LayoutNode; path: number[]; running: Record; states: Record; surfaces: Record; focusedId: string | null; onFocus: (id: string) => void; zoomed: string | null; -}) { + drop: DropTarget | null; + onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void; +} + +function Node({ node, path, ...rest }: NodeProps) { if ("leaf" in node) { - const id = node.leaf.surface_id; - const focused = focusedId === id; - const card = (inner: React.ReactNode) => ( -
onFocus(id)} - style={{ - display: "flex", flexDirection: "column", width: "100%", height: "100%", - background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", - border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`, - boxSizing: "border-box", - }} - > - {inner} -
- ); + return ; + } + return ; +} - if (running[id] === false) { - return card( -
-
Process exited
-
- - {zoomed === id && ( - - )} -
-
- ); - } +function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit & { id: string }) { + const focused = focusedId === id; + const dropEdge = drop && drop.id === id ? drop.edge : null; - const spec = surfaces[id]?.spec; - const agent = spec?.agent_label ?? "shell"; - const state = states[id] ?? "idle"; + const card = (inner: React.ReactNode) => ( +
onFocus(id)} + style={{ + position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", + background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", + border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`, + boxSizing: "border-box", + }} + > + {inner} + {dropEdge && } +
+ ); + + if (running[id] === false) { return card( - <> -
- - {agent} - {spec?.cwd && {shortPath(spec.cwd)}} - - - {state} - - {zoomed === id - ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> - : { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />} +
+
Process exited
+
+ + {zoomed === id && ( + + )}
-
- -
- +
); } - const { orient, ratios, children } = node.split; - const dir = orient === "h" ? "row" : "column"; - return ( -
- {children.map((child, i) => ( - { - 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); - }}> - - - ))} -
+ const spec = surfaces[id]?.spec; + const agent = spec?.agent_label ?? "shell"; + const state = states[id] ?? "idle"; + return card( + <> +
{ 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" }} + > + + + {agent} + {spec?.cwd && {shortPath(spec.cwd)}} + + + {state} + + {zoomed === id + ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> + : { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />} +
+
+ +
+ ); } -function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) { - const ref = useRef(null); - const startDrag = (e: React.MouseEvent) => { +function DropIndicator({ edge }: { edge: Edge }) { + const base: React.CSSProperties = { position: "absolute", background: `${COLORS.accent}55`, border: `2px solid ${COLORS.accent}`, pointerEvents: "none", boxSizing: "border-box", zIndex: 5 }; + const map: Record = { + 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
; +} + +function SplitView({ split, path, ...rest }: Omit & { split: Extract["split"] }) { + const { orient, ratios, children } = split; + const { workspaceId } = rest; + const dir = orient === "h" ? "row" : "column"; + const containerRef = useRef(null); + const [live, setLive] = useState(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(); - const parent = ref.current?.parentElement; - if (!parent) return; - const total = orient === "h" ? parent.clientWidth : parent.clientHeight; - let last = orient === "h" ? e.clientX : e.clientY; + e.stopPropagation(); + const container = containerRef.current; + if (!container) return; + 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 cur = orient === "h" ? ev.clientX : ev.clientY; - const delta = (cur - last) / total; - last = cur; - onResize(delta); + // Accumulated delta from drag start — not an incremental step — so the + // panel tracks the pointer 1:1 instead of crawling one echo at a time. + 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 = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); + setLive((cur) => { if (cur) void setRatios(workspaceId, path, cur); return cur; }); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }; + return ( - <> -
- {children} -
- {!isLast && ( -
- )} - +
+ {children.map((child, i) => ( +
+ + {i < children.length - 1 && ( +
startDrag(i, e)} + style={{ + position: "absolute", zIndex: 4, + ...(orient === "h" + ? { top: 0, bottom: 0, right: -5, width: 10, cursor: "col-resize" } + : { left: 0, right: 0, bottom: -5, height: 10, cursor: "row-resize" }), + }} /> + )} +
+ ))} +
); } diff --git a/app/src/SearchBar.tsx b/app/src/SearchBar.tsx index eac81fa..1956a51 100644 --- a/app/src/SearchBar.tsx +++ b/app/src/SearchBar.tsx @@ -45,16 +45,17 @@ export function SearchBar({ }; }, [surfaceId]); - function run(forward: boolean) { + function run(forward: boolean, override?: string) { if (!surfaceId) return; const addon = getSearch(surfaceId); - if (!addon || !term) { + const query = override ?? term; + if (!addon || !query) { addon?.clearDecorations(); setCount({ index: -1, total: 0 }); return; } - if (forward) addon.findNext(term, SEARCH_OPTS); - else addon.findPrevious(term, SEARCH_OPTS); + if (forward) addon.findNext(query, SEARCH_OPTS); + else addon.findPrevious(query, SEARCH_OPTS); } return ( @@ -79,9 +80,14 @@ export function SearchBar({ ref={inputRef} value={term} onChange={(e) => { - setTerm(e.target.value); - setCount({ index: -1, total: 0 }); - if (surfaceId) getSearch(surfaceId)?.clearDecorations(); + const value = e.target.value; + setTerm(value); + if (!value) { + setCount({ index: -1, total: 0 }); + if (surfaceId) getSearch(surfaceId)?.clearDecorations(); + } else { + run(true, value); // search-as-you-type + } }} onKeyDown={(e) => { if (e.key === "Enter") { diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index 52e0116..661ca68 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { WebglAddon } from "@xterm/addon-webgl"; import { SearchAddon } from "@xterm/addon-search"; +import { FitAddon } from "@xterm/addon-fit"; import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; import { registerSearch, unregisterSearch } from "./searchRegistry"; @@ -25,6 +26,27 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { term.loadAddon(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. const inputDisposable = term.onData((data) => { void sendInput(surfaceId, encoder.encode(data)); @@ -38,14 +60,15 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { }).then((res) => { if (disposed) return; if (res.snapshot) term.write(res.snapshot); - if (res.cols && res.rows) { - term.resize(res.cols, res.rows); - void resizeSurface(surfaceId, res.cols, res.rows); - } + // Fit to the actual container rather than the daemon's stored geometry, + // then push the resulting size back so the PTY reflows to match. + scheduleFit(); }); return () => { disposed = true; + if (rafId) cancelAnimationFrame(rafId); + ro.disconnect(); inputDisposable.dispose(); void detachSurface(surfaceId); unregisterSearch(surfaceId); diff --git a/app/src/TopBar.tsx b/app/src/TopBar.tsx index 72e3342..df90abc 100644 --- a/app/src/TopBar.tsx +++ b/app/src/TopBar.tsx @@ -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 type { WorkspaceView } from "./layoutTypes"; import { leafIds } from "./layoutTypes"; @@ -29,11 +29,14 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl } export function TopBar({ - active, eventsOpen, onToggleEvents, unread, + active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, }: { active: WorkspaceView | null; eventsOpen: boolean; onToggleEvents: () => void; + onShowEvents: () => void; + sidebarOpen: boolean; + onToggleSidebar: () => void; unread: number; }) { return ( @@ -44,8 +47,8 @@ export function TopBar({ 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. */} + } onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" /> {/* Workspace breadcrumb */}
@@ -67,10 +70,9 @@ export function TopBar({ {/* Right cluster */}
- } onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> } title="Search (mock)" />
- } title="Notifications (mock)" /> + } onClick={onShowEvents} active={eventsOpen} title="Open activity log" /> {unread > 0 && ( )}
+ } onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> } title="Settings (mock)" /> - +
diff --git a/crates/spaceshd/Cargo.toml b/crates/spaceshd/Cargo.toml index b156157..47b6fdc 100644 --- a/crates/spaceshd/Cargo.toml +++ b/crates/spaceshd/Cargo.toml @@ -22,3 +22,5 @@ thiserror.workspace = true futures.workspace = true fs2.workspace = true dirs.workspace = true +toml.workspace = true +libc.workspace = true diff --git a/crates/spaceshd/src/config.rs b/crates/spaceshd/src/config.rs new file mode 100644 index 0000000..3197e5f --- /dev/null +++ b/crates/spaceshd/src/config.rs @@ -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, +} + +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 { + 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 { 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"); + } +} diff --git a/crates/spaceshd/src/lifecycle.rs b/crates/spaceshd/src/lifecycle.rs index 3412c45..5e46db4 100644 --- a/crates/spaceshd/src/lifecycle.rs +++ b/crates/spaceshd/src/lifecycle.rs @@ -19,6 +19,11 @@ pub fn socket_path() -> Result { } pub fn lock_path() -> Result { + if let Ok(p) = std::env::var("SPACESH_LOCK") { + if !p.is_empty() { + return Ok(PathBuf::from(p)); + } + } Ok(spacesh_dir()?.join("daemon.lock")) } @@ -64,6 +69,11 @@ mod tests { #[test] 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(); assert!(first.is_some(), "first acquire should succeed"); // A second attempt from the same process on the same fd path: @@ -72,6 +82,8 @@ mod tests { let second = acquire_instance_lock().unwrap(); assert!(second.is_none(), "second acquire should be blocked"); drop(first); + std::env::remove_var("SPACESH_LOCK"); + let _ = std::fs::remove_file(&tmp); } #[test] diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index 054364c..e693b90 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -1,3 +1,4 @@ +mod config; mod event_log; mod event_store; mod hooks; diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 8cced31..e5a9c2b 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -298,7 +298,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(|| 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: args.clone(), cwd: ws.path.clone(), agent_label: command, cols, rows, autostart: false, @@ -333,7 +333,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(|| 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 (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 +406,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(|| 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 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);