Compare commits
30 Commits
fcbf4a69a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
50996c929d
|
|||
|
ee845e15b3
|
|||
|
2ee2aaaffb
|
|||
|
333b051e9d
|
|||
|
372dd7123a
|
|||
|
39bb8e5fee
|
|||
|
d62628be8d
|
|||
|
3317b24d18
|
|||
|
0275c64ace
|
|||
|
0a67f401c4
|
|||
|
ce6a8d56be
|
|||
|
5c76493a34
|
|||
|
ff0ad7a648
|
|||
|
375e4c5c92
|
|||
|
31c08b5387
|
|||
|
eecea9c38c
|
|||
|
d00abcd2f6
|
|||
|
60383cd543
|
|||
|
69f2e73832
|
|||
|
0674872c9d
|
|||
|
1a7d04aab0
|
|||
|
bd36a83db2
|
|||
|
bb5edb941c
|
|||
|
4419f5660e
|
|||
|
e37faf49d3
|
|||
|
1f69973606
|
|||
|
3d54d679d3
|
|||
|
95ddf30b8c
|
|||
|
614d7fea06
|
|||
|
74abea5467
|
@@ -6,3 +6,6 @@ app/dist/
|
||||
|
||||
# Generated daemon sidecar for DMG bundling (make dmg)
|
||||
app/src-tauri/bin/
|
||||
|
||||
# Local notarization secrets (Apple ID / app-specific password)
|
||||
.signing.env
|
||||
|
||||
Generated
+6
-5
@@ -869,7 +869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -881,16 +881,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"spacesh-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-proto"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"serde",
|
||||
@@ -902,7 +903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-pty"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -912,7 +913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spaceshd"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
# Session Persistence (resurrect + resume) — Design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Let a workspace survive both GUI loss and full power loss. Closing a tab or
|
||||
the whole GUI already keeps agents running (daemon owns PTYs, reattach via live
|
||||
grid snapshot — M1). This design adds the missing half: after the daemon itself
|
||||
dies (reboot, battery death, `kill -9`), the user can bring panels back —
|
||||
panels show their last on-screen state and offer a one-click **Resume** that
|
||||
restarts the agent with its session-continue flag (e.g. `claude --continue`).
|
||||
|
||||
## Scope decisions (locked)
|
||||
|
||||
- **Reboot behavior:** resurrect + resume. A live process cannot survive a
|
||||
power-off — not even tmux does that. After a daemon restart we respawn the
|
||||
panel from its persisted spec (cwd intact) and, for agents that support it,
|
||||
relaunch with a resume flag so the conversation continues in a *new* process.
|
||||
- **Resurrect trigger:** manual, per-panel. After a daemon restart panels are
|
||||
shown stopped with their last screen; nothing spawns until the user clicks.
|
||||
This avoids surprise token burn from auto-launching many agents.
|
||||
- **Persisted scrollback:** visible screen only. We reuse the existing
|
||||
`snapshot_ansi()` serializer (the same one that powers live reattach) and
|
||||
write its output to disk. No scrollback history beyond the visible grid.
|
||||
- **Resume command source:** a `[resume]` table in `~/.spacesh/config.toml`
|
||||
mapping a command basename to resume args, merged over built-in defaults.
|
||||
- **Snapshot cadence:** periodic + shutdown. A background task dumps changed
|
||||
grids every N seconds (default 5), plus a full pass on graceful shutdown and
|
||||
a final dump when an actor exits. This survives `kill -9` / battery (you lose
|
||||
at most N seconds of the last screen).
|
||||
|
||||
## What already exists (do not rebuild)
|
||||
|
||||
- `state.json` (via `JsonStateStore` + debounced `Persister`) already persists
|
||||
structure: groups, workspaces, layout, zoom, pinned, and per-surface
|
||||
`SurfaceSpec` (`command`, `args`, `cwd`, `cols`, `rows`, `agent_label`,
|
||||
`autostart`). On cold start `Registry::restore()` loads this; the `live`
|
||||
actor map is empty, so every surface is "stopped" (spec present, no process).
|
||||
- `SurfaceView.running: bool` already tells the client a surface is stopped.
|
||||
- `spacesh_core::snapshot::snapshot_ansi(&GridSurface) -> Snapshot` serializes
|
||||
the visible grid to an ANSI dump (`ansi`, `cols`, `rows`, `cursor_row`,
|
||||
`cursor_col`). `Snapshot` currently derives `Serialize` only.
|
||||
- The surface actor already answers `SurfaceMsg::AttachSnapshot` by calling
|
||||
`snapshot_ansi(&grid)`; the grid is the authoritative screen model.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Snapshot store — `crates/spaceshd/src/snapshot_store.rs` (new)
|
||||
|
||||
Per-surface JSON file `~/.spacesh/snapshots/<surface_id>.json` holding the
|
||||
serialized visible-screen snapshot. Atomic write (temp file → `sync_all` →
|
||||
rename), mirroring `state_store::JsonStateStore`.
|
||||
|
||||
```rust
|
||||
pub trait SnapshotStore: Send + Sync {
|
||||
fn save(&self, sid: &SurfaceId, snap: &Snapshot) -> anyhow::Result<()>;
|
||||
fn load(&self, sid: &SurfaceId) -> Option<Snapshot>;
|
||||
fn remove(&self, sid: &SurfaceId);
|
||||
}
|
||||
```
|
||||
|
||||
The store persists the core `spacesh_core::snapshot::Snapshot` directly
|
||||
(`ansi`, `cols`, `rows`, `cursor_row`, `cursor_col`) — `spaceshd` already
|
||||
depends on `spacesh-core`, so no separate daemon record type is introduced. A
|
||||
corrupt/missing file yields `None` (never an error that blocks resurrect).
|
||||
`remove` deletes the file and is called when a surface is closed or removed
|
||||
from the tree.
|
||||
|
||||
### 2. On-demand snapshot from the actor — `crates/spaceshd/src/surface.rs`
|
||||
|
||||
Add a message that returns the current snapshot without subscribing:
|
||||
|
||||
```rust
|
||||
SurfaceMsg::Snapshot { reply: oneshot::Sender<(Snapshot, bool)> } // (snapshot, dirty)
|
||||
```
|
||||
|
||||
The actor tracks a `dirty` flag: set inside `flush` whenever bytes are fed into
|
||||
the grid (`grid.feed`), cleared when a `Snapshot` reply is produced. The bool
|
||||
lets the periodic dumper skip unchanged grids.
|
||||
|
||||
On actor exit (after `pty.wait()`), the actor takes a final `snapshot_ansi`
|
||||
and forwards `(id, snapshot)` to the writer channel (a cloned
|
||||
`mpsc::UnboundedSender<(SurfaceId, Snapshot)>` passed into the actor), so the
|
||||
last screen of a finished process is persisted even between ticker ticks.
|
||||
|
||||
### 3. Writer task + periodic ticker — `crates/spaceshd/src/server.rs` / `main.rs`
|
||||
|
||||
- **Writer task:** the sole owner of `Arc<dyn SnapshotStore>`. Receives
|
||||
`(SurfaceId, Snapshot)` on an unbounded channel and writes to disk. Keeps all
|
||||
snapshot disk I/O off the actor/PTY hot path and serializes writes.
|
||||
- **Periodic ticker:** every `snapshot_interval_secs` (config, default 5) the
|
||||
router iterates live surface handles, sends `SurfaceMsg::Snapshot`, awaits the
|
||||
reply, and forwards to the writer channel only when `dirty` is true.
|
||||
- **Graceful shutdown:** before the daemon exits it does one final synchronous
|
||||
pass over all live surfaces into the writer, then flushes the writer.
|
||||
|
||||
### 4. Resume config — `crates/spaceshd/src/config.rs`
|
||||
|
||||
```toml
|
||||
[resume]
|
||||
commands = { claude = ["--continue"], codex = ["resume"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ResumeConfig {
|
||||
#[serde(default)]
|
||||
pub commands: std::collections::HashMap<String, Vec<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
Added to `Config` as `#[serde(default)] pub resume: ResumeConfig`. A method
|
||||
`resume_args(command: &str) -> Option<Vec<String>>` resolves by command
|
||||
basename: user map first, then a built-in default table
|
||||
(`claude → ["--continue"]`, `codex → ["resume"]`), then `None`. The default
|
||||
table is a `const`/static, not inline literals in branching logic.
|
||||
|
||||
### 5. Protocol — `crates/spacesh-proto/src/message.rs`
|
||||
|
||||
The codebase already has `Cmd::RestartSurface { surface_id }` (starts a stopped
|
||||
surface from its spec, guarded by `is_running`) and an `Attach` response that
|
||||
already carries `{ snapshot, cols, rows, cursor_row, cursor_col, stopped }`.
|
||||
So no new command or wire type is needed beyond one field:
|
||||
|
||||
- Extend `Cmd::RestartSurface` with `#[serde(default)] resume: bool`. `resume =
|
||||
true` builds `command + resume_args(command)` (falling back to the original
|
||||
args when no mapping exists); `resume = false` keeps the original
|
||||
`command + args` (today's behavior). The `#[serde(default)]` keeps old frames
|
||||
decoding to `resume = false`.
|
||||
- No `GetSnapshot`, no `StartSurface`, no `SnapshotView`: a stopped-panel
|
||||
`Attach` returns the **disk** snapshot (see §6) using the existing response
|
||||
shape.
|
||||
|
||||
`spacesh-core::snapshot::Snapshot` gains `Deserialize` (alongside `Serialize`)
|
||||
so the store can load it back from disk.
|
||||
|
||||
### 6. Server handlers — `crates/spaceshd/src/server.rs`
|
||||
|
||||
- `RestartSurface { surface_id, resume }`: unchanged flow (spec lookup,
|
||||
`spawn_from_spec`, `set_live`, `SurfaceRestarted` broadcast). When `resume`,
|
||||
spawn with a spec whose `args` are replaced by `config.resume_args(command)`
|
||||
(when present); otherwise spawn the original spec.
|
||||
- `Attach` for a **stopped** surface: instead of returning the empty
|
||||
`{ snapshot: "", stopped: true }`, load the disk snapshot via the snapshot
|
||||
store and return `{ snapshot: <ansi>, cols, rows, cursor_row, cursor_col,
|
||||
stopped: true }`. Missing file → empty snapshot, still `stopped: true`.
|
||||
- Surface close/remove (`Close`, `CloseWorkspace`, `remove_surface` paths):
|
||||
send a remove to the snapshot writer so stale `<sid>.json` files do not
|
||||
accumulate.
|
||||
|
||||
### 7. App — `app/src` and `app/src-tauri`
|
||||
|
||||
- `socketBridge.ts`: `restartSurface(id, resume = false)` gains the `resume`
|
||||
arg; `AttachResult` gains optional `cursor_row`/`cursor_col`/`stopped`.
|
||||
- `app/src-tauri/src/bridge.rs`: `restart_surface` forwards a `resume: bool`
|
||||
arg into `Cmd::RestartSurface`.
|
||||
- `LayoutEngine.tsx` stopped branch (`running[id] === false`): paint the disk
|
||||
snapshot into a dimmed, read-only `xterm` behind the controls, and offer two
|
||||
buttons — **Resume** → `restartSurface(id, true)` and **Restart fresh** →
|
||||
`restartSurface(id, false)`. On success the daemon's `workspace_changed`
|
||||
flips `running` to true, the overlay unmounts, and the live `TerminalView`
|
||||
mounts.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
running surface ──(every 5s, if dirty)──▶ ticker ──▶ writer task ──▶ <sid>.json
|
||||
running surface ──(on exit)─────────────────────────▶ writer task ──▶ <sid>.json
|
||||
daemon shutdown ──(final pass over live)────────────▶ writer task ──▶ <sid>.json
|
||||
|
||||
reboot ▶ daemon cold start ▶ Registry::restore(state.json) ▶ all surfaces stopped
|
||||
client ▶ Attach(sid) [stopped] ▶ disk snapshot ▶ paint dimmed read-only screen
|
||||
user clicks Resume ▶ RestartSurface{resume:true} ▶ spawn(command + resume_args, cwd)
|
||||
▶ SurfaceRestarted + running=true ▶ live TerminalView mounts
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- Missing/corrupt snapshot file → stopped `Attach` returns an empty snapshot;
|
||||
the overlay shows an empty dimmed panel with the Resume/Restart controls.
|
||||
- `RestartSurface` on an already-running surface → no-op ok (existing
|
||||
`is_running` guard); unknown surface → `NOT_FOUND`.
|
||||
- Resume command for an agent without a mapping → falls back to the original
|
||||
spec args (plain restart), never fails the spawn.
|
||||
- Writer task failure to write one file is logged and dropped; it must not stall
|
||||
the daemon or other surfaces.
|
||||
|
||||
## Performance
|
||||
|
||||
- A visible-screen snapshot is ≈ rows × cols bytes of ANSI; at a 5s cadence with
|
||||
the `dirty` debounce, idle panels write nothing. All disk writes happen in the
|
||||
single writer task, off the PTY/actor hot path, so the keypress→echo (<16 ms)
|
||||
and output-batching budgets are untouched.
|
||||
|
||||
## Testing
|
||||
|
||||
- **snapshot_store:** save→load round-trip; atomic write; missing file → `None`;
|
||||
corrupt file → `None`; `remove` deletes the file.
|
||||
- **config:** parse `[resume]` table; `resume_args` returns user override, then
|
||||
built-in default, then `None`; missing section defaults cleanly.
|
||||
- **surface actor:** `SurfaceMsg::Snapshot` returns the current grid contents;
|
||||
`dirty` is true after output and false immediately after a snapshot.
|
||||
- **server:** `RestartSurface{resume:true}` spawns with `command + resume_args`;
|
||||
`{resume:false}` spawns with `command + args`; stopped `Attach` returns the
|
||||
saved disk snapshot; `is_running` guard prevents a second actor.
|
||||
- **registry:** starting a stopped surface re-populates the live map and the
|
||||
view flips `running` to true.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Resuming the literal in-flight process across power loss (impossible).
|
||||
- Scrollback history beyond the visible screen.
|
||||
- Auto-resume on daemon start (manual trigger chosen).
|
||||
- Per-surface resume command stored in the spec/wizard (config map chosen).
|
||||
@@ -7,9 +7,10 @@ DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dm
|
||||
NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
|
||||
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
|
||||
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
|
||||
ENTITLEMENTS := $(APP_DIR)/src-tauri/Entitlements.plist
|
||||
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
|
||||
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spacesh.app
|
||||
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spacesh.app
|
||||
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spaceshell.app
|
||||
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spaceshell.app
|
||||
APP_VERSION := $(shell node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version" 2>/dev/null || echo 0.0.0)
|
||||
|
||||
LANDING_IMAGE := spacesh-landing
|
||||
@@ -17,6 +18,40 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
|
||||
REGISTRY ?= git.realmanual.ru
|
||||
REPO ?= spacesh
|
||||
|
||||
# Stable code-signing identity. Without a STABLE signature the app is ad-hoc
|
||||
# signed and its code identity changes every build, so macOS attributes child
|
||||
# processes (the daemon → Claude Code) to a different "responsible app" each time:
|
||||
# TCC permissions reset and agents lose their Keychain login on every rebuild.
|
||||
# Defaults to the Developer ID (Team 3PNKDC6L42) — a stable designated requirement
|
||||
# (anchor apple generic + TeamID) that Keychain/TCC trust survives across rebuilds.
|
||||
# Override with `SIGN_IDENTITY="<cert name>" make reinstall`, or `SIGN_IDENTITY=`
|
||||
# to fall back to ad-hoc. Tauri reads APPLE_SIGNING_IDENTITY for the bundle + sidecar.
|
||||
SIGN_IDENTITY ?= Developer ID Application: Vassiliy Yegorov (3PNKDC6L42)
|
||||
ifneq ($(strip $(SIGN_IDENTITY)),)
|
||||
export APPLE_SIGNING_IDENTITY := $(SIGN_IDENTITY)
|
||||
endif
|
||||
|
||||
# Notarization (required to distribute the DMG — Gatekeeper blocks un-notarized apps
|
||||
# on other Macs). Secrets: put them in a gitignored `.signing.env` (make syntax,
|
||||
# e.g. `APPLE_ID := you@example.com`) or pass on the CLI. NEVER commit them.
|
||||
# APPLE_ID — your Apple ID email
|
||||
# APPLE_PASSWORD — an app-specific password (appleid.apple.com → App-Specific Passwords)
|
||||
# APPLE_TEAM_ID — 3PNKDC6L42 (defaulted below)
|
||||
# When all three are present, `tauri build` auto-notarizes + staples the bundle.
|
||||
-include .signing.env
|
||||
APPLE_ID ?=
|
||||
APPLE_PASSWORD ?=
|
||||
APPLE_TEAM_ID ?= 3PNKDC6L42
|
||||
ifneq ($(strip $(APPLE_ID)),)
|
||||
export APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID
|
||||
endif
|
||||
|
||||
# ---- Gitea generic package registry (versioned .dmg downloads) ----
|
||||
GITEA_URL ?= https://git.realmanual.ru
|
||||
GITEA_OWNER ?= pub
|
||||
GITEA_PKG ?= spacesh
|
||||
GITEA_TOKEN ?= # token with package:write; pass via env/CLI, never commit
|
||||
|
||||
# ---- Prod deploy (SSH) ----
|
||||
SSH_HOST ?= 192.168.8.5
|
||||
SSH_USER ?= root
|
||||
@@ -43,11 +78,11 @@ targets: ## add rust targets for the universal build
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
.PHONY: bump
|
||||
bump: ## increment the patch version in tauri.conf.json (single source of truth)
|
||||
@node -e "const f='$(APP_DIR)/src-tauri/tauri.conf.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f));const p=j.version.split('.').map(Number);p[2]=(p[2]||0)+1;j.version=p.join('.');fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');console.log('version → '+j.version)"
|
||||
bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the daemon (workspace Cargo.toml)
|
||||
@node scripts/bump_version.mjs
|
||||
|
||||
.PHONY: dmg
|
||||
dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
|
||||
dmg: bump targets ## bump version + build universal .dmg (signed; notarized if .signing.env set)
|
||||
# Tauri's universal build needs BOTH the per-arch sidecars (resolved during each
|
||||
# arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final
|
||||
# bundle — Tauri does not lipo sidecars itself). spaceshd ships inside
|
||||
@@ -93,16 +128,25 @@ kill-daemon: ## stop a running spaceshd so a freshly-built one takes over
|
||||
|
||||
.PHONY: install
|
||||
install: kill-daemon ## install the native .app to /Applications, restart daemon, clear quarantine
|
||||
rm -rf /Applications/spacesh.app
|
||||
rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too
|
||||
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
|
||||
xattr -dr com.apple.quarantine /Applications/spacesh.app
|
||||
@echo "Installed (native). Quit & relaunch spacesh; the bundled daemon restarts."
|
||||
xattr -dr com.apple.quarantine /Applications/spaceshell.app
|
||||
ifneq ($(strip $(SIGN_IDENTITY)),)
|
||||
# Belt-and-suspenders: re-sign inside-out with the stable identity so neither the
|
||||
# embedded daemon nor the app is left ad-hoc if Tauri skipped the sidecar.
|
||||
codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app/Contents/MacOS/spaceshd
|
||||
codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app
|
||||
@codesign -dvv /Applications/spaceshell.app 2>&1 | grep -E "TeamIdentifier|Signature" || true
|
||||
endif
|
||||
@echo "Installed (native). Quit & relaunch spaceshell; the bundled daemon restarts."
|
||||
@echo "Tip: on first launch grant Full Disk Access (System Settings → Privacy & Security)"
|
||||
@echo " so terminals inside the app can run tmutil / reach protected folders."
|
||||
|
||||
.PHONY: install-universal
|
||||
install-universal: kill-daemon ## install the universal .app to /Applications
|
||||
rm -rf /Applications/spacesh.app
|
||||
rm -rf /Applications/spacesh.app /Applications/spaceshell.app
|
||||
cp -R "$(APP_BUNDLE)" /Applications/
|
||||
xattr -dr com.apple.quarantine /Applications/spacesh.app
|
||||
xattr -dr com.apple.quarantine /Applications/spaceshell.app
|
||||
|
||||
.PHONY: reinstall
|
||||
reinstall: app-bundle install ## fast self-update: build .app (no dmg), reinstall, restart daemon
|
||||
@@ -134,7 +178,7 @@ landing-push: landing-image ## tag & push the landing image to the registry
|
||||
# ---- Prod deploy ----
|
||||
|
||||
.PHONY: deploy-dmg
|
||||
deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod download dir
|
||||
deploy-dmg: dmg ## upload .dmg + manifest to prod, and publish the versioned .dmg to Gitea Packages
|
||||
@VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
|
||||
printf '{"version":"%s","url":"https://spaceshell.ru/download/spacesh.dmg"}\n' "$$VER" > /tmp/spacesh-latest.json; \
|
||||
echo "manifest version → $$VER"
|
||||
@@ -142,6 +186,24 @@ deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod d
|
||||
scp $(SSH_OPTS) $(DMG_DIR)/*.dmg "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/spacesh.dmg"
|
||||
scp $(SSH_OPTS) /tmp/spacesh-latest.json "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/latest.json"
|
||||
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg + latest.json"
|
||||
@$(MAKE) --no-print-directory _publish-dmg
|
||||
|
||||
.PHONY: publish-dmg
|
||||
publish-dmg: dmg _publish-dmg ## build + publish the versioned .dmg to the Gitea package registry
|
||||
|
||||
# Internal: upload the most recently built .dmg to Gitea's generic registry under
|
||||
# the current version. No build dependency, so deploy-dmg can call it without a
|
||||
# second bump/rebuild. Skips (doesn't fail) when GITEA_TOKEN is unset.
|
||||
.PHONY: _publish-dmg
|
||||
_publish-dmg:
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then echo "GITEA_TOKEN unset — skipping Gitea Packages publish"; exit 0; fi; \
|
||||
VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
|
||||
DMG=$$(ls -t $(DMG_DIR)/*.dmg 2>/dev/null | head -1); \
|
||||
if [ -z "$$DMG" ]; then echo "no .dmg in $(DMG_DIR) — run make dmg first"; exit 1; fi; \
|
||||
URL="$(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic/$(GITEA_PKG)/$$VER/spaceshell-$$VER.dmg"; \
|
||||
echo "Publishing $$DMG → $$URL"; \
|
||||
curl --fail-with-body -sS -H "Authorization: token $(GITEA_TOKEN)" --upload-file "$$DMG" "$$URL" && \
|
||||
echo "Published spaceshell-$$VER.dmg to Gitea Packages ($(GITEA_OWNER)/$(GITEA_PKG)@$$VER)"
|
||||
|
||||
.PHONY: deploy-stack
|
||||
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
|
||||
|
||||
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
@@ -1354,6 +1355,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
|
||||
Generated
+156
-2
@@ -540,6 +540,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-text"
|
||||
version = "22.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -2944,6 +2955,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3413,6 +3448,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"core-foundation",
|
||||
"core-text",
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -3420,6 +3457,7 @@ dependencies = [
|
||||
"spacesh-proto",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
@@ -3427,7 +3465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spacesh-proto"
|
||||
version = "0.1.0"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"serde",
|
||||
@@ -3730,6 +3768,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
@@ -4926,6 +5006,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4974,13 +5063,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -5017,6 +5123,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5035,6 +5147,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5053,12 +5171,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5077,6 +5207,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5095,6 +5231,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -5113,6 +5255,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5131,6 +5279,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
|
||||
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
spacesh-proto = { path = "../../crates/spacesh-proto" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -25,3 +26,5 @@ anyhow = "1"
|
||||
dirs = "5"
|
||||
# rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
core-text = "22"
|
||||
core-foundation = "0.10"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- WKWebView's JavaScriptCore needs JIT + writable/executable memory under the
|
||||
hardened runtime required for notarization. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- The bundle embeds the spaceshd sidecar (same Team) and loads system/webview
|
||||
components; relax library validation so loading never trips the hardened runtime. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -7,11 +7,14 @@
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:app:default",
|
||||
"core:resources:default",
|
||||
"core:menu:default",
|
||||
"core:tray:default",
|
||||
"notification:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:default","window-state:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-start-dragging","core:window:allow-start-resize-dragging","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:default","window-state:default","dialog:allow-open"]}}
|
||||
@@ -2192,6 +2192,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2192,6 +2192,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
+141
-26
@@ -78,6 +78,13 @@ fn find_daemon() -> PathBuf {
|
||||
PathBuf::from("spaceshd") // last resort: rely on PATH
|
||||
}
|
||||
|
||||
/// The installed `spaceshd` binary's mtime as ms since the epoch (for staleness check).
|
||||
fn daemon_bin_mtime_ms() -> Option<u64> {
|
||||
let meta = std::fs::metadata(find_daemon()).ok()?;
|
||||
let mtime = meta.modified().ok()?;
|
||||
Some(mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64)
|
||||
}
|
||||
|
||||
async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
|
||||
if let Ok(s) = UnixStream::connect(sock).await {
|
||||
return Ok(s);
|
||||
@@ -120,7 +127,7 @@ impl Bridge {
|
||||
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
||||
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
|
||||
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
|
||||
Ok(Self {
|
||||
let bridge = Self {
|
||||
next_id: AtomicU64::new(1),
|
||||
app,
|
||||
sock,
|
||||
@@ -130,7 +137,48 @@ impl Bridge {
|
||||
reader: Mutex::new(reader),
|
||||
pending,
|
||||
out_channels,
|
||||
})
|
||||
};
|
||||
// The daemon outlives the GUI by design, so after an update the GUI may
|
||||
// attach to a stale daemon — new features that need new daemon code then
|
||||
// silently don't work. Restart it if it's out of date.
|
||||
bridge.ensure_matching_daemon().await;
|
||||
Ok(bridge)
|
||||
}
|
||||
|
||||
/// Restart the running daemon if it predates the installed `spaceshd` binary
|
||||
/// (or was built from a different commit). The bundled binary's mtime vs the
|
||||
/// daemon's `started_at_ms` is the reliable signal: it catches every reinstall
|
||||
/// even while developing dirty, where the git build id doesn't change.
|
||||
async fn ensure_matching_daemon(&self) {
|
||||
let Ok(reply) = self.request(Cmd::Health).await else { return };
|
||||
let (daemon_build, started_at_ms) = match &reply {
|
||||
Envelope::Res { data, .. } => (
|
||||
data.get("build").and_then(|v| v.as_str()).map(str::to_string),
|
||||
data.get("started_at_ms").and_then(|v| v.as_u64()),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
let gui_build = option_env!("SPACESH_BUILD").unwrap_or("dev");
|
||||
let build_mismatch = gui_build != "dev"
|
||||
&& daemon_build.as_deref().map(|b| b != gui_build).unwrap_or(false);
|
||||
let binary_newer = match (daemon_bin_mtime_ms(), started_at_ms) {
|
||||
(Some(bin_ms), Some(start_ms)) => bin_ms > start_ms,
|
||||
_ => false,
|
||||
};
|
||||
if !build_mismatch && !binary_newer {
|
||||
return;
|
||||
}
|
||||
// Ask the stale daemon to exit, wait for its socket to clear, then reconnect
|
||||
// — which lazily spawns the fresh bundled daemon.
|
||||
self.fire(Cmd::Shutdown).await;
|
||||
for _ in 0..100 {
|
||||
if UnixStream::connect(&self.sock).await.is_err() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
}
|
||||
let seen = self.gen.load(Ordering::Acquire);
|
||||
let _ = self.reconnect(seen).await;
|
||||
}
|
||||
|
||||
/// Send a command without awaiting a reply or retrying. Used for Shutdown:
|
||||
@@ -348,8 +396,8 @@ pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_i
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||||
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String, resume: bool) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -422,6 +470,11 @@ pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn which_agents(state: BridgeState<'_>, candidates: Vec<String>) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::WhichAgents { candidates }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
// ---- Update check ----
|
||||
|
||||
/// Where the GUI looks for the published app version. Overridable via
|
||||
@@ -449,32 +502,28 @@ fn parse_ver(v: &str) -> (u64, u64, u64) {
|
||||
/// which `make dmg` bumps on every build.
|
||||
#[tauri::command]
|
||||
pub async fn check_update(app: AppHandle) -> Result<UpdateInfo, String> {
|
||||
// The local version is always known; the server may be unreachable (no manifest
|
||||
// yet, offline). Never fail the command for that — return the current version with
|
||||
// an empty `latest` so the UI can show "couldn't check" instead of blanking out.
|
||||
let current = app.package_info().version.to_string();
|
||||
let fallback_url = "https://spaceshell.ru/download/spacesh.dmg".to_string();
|
||||
let manifest_url =
|
||||
std::env::var("SPACESH_UPDATE_URL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_UPDATE_URL.to_string());
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let manifest: Value = client
|
||||
.get(&manifest_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fetch = || async {
|
||||
let client = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
|
||||
client.get(&manifest_url).send().await?.error_for_status()?.json::<Value>().await
|
||||
};
|
||||
|
||||
let latest = manifest.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let url = manifest
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("https://spaceshell.ru/download/spacesh.dmg")
|
||||
.to_string();
|
||||
let has_update = !latest.is_empty() && parse_ver(&latest) > parse_ver(¤t);
|
||||
|
||||
Ok(UpdateInfo { current, latest, has_update, url })
|
||||
match fetch().await {
|
||||
Ok(manifest) => {
|
||||
let latest = manifest.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let url = manifest.get("url").and_then(|v| v.as_str()).unwrap_or(&fallback_url).to_string();
|
||||
let has_update = !latest.is_empty() && parse_ver(&latest) > parse_ver(¤t);
|
||||
Ok(UpdateInfo { current, latest, has_update, url })
|
||||
}
|
||||
Err(_) => Ok(UpdateInfo { current, latest: String::new(), has_update: false, url: fallback_url }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a URL in the default browser (macOS `open`). Used by the update popover's
|
||||
@@ -485,6 +534,51 @@ pub fn open_external(url: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether spaceshell.app has Full Disk Access. Terminals spawned inside the app
|
||||
/// inherit its TCC grants (the app is their "responsible process"), so without FDA
|
||||
/// commands like `tmutil` fail. Probe: reading the user's TCC database requires FDA —
|
||||
/// a permission error means we lack it; success (or the file being absent) means we
|
||||
/// have it.
|
||||
#[tauri::command]
|
||||
pub fn has_full_disk_access() -> bool {
|
||||
let Some(home) = std::env::var_os("HOME") else { return false };
|
||||
let probe = std::path::Path::new(&home).join("Library/Application Support/com.apple.TCC/TCC.db");
|
||||
match std::fs::File::open(&probe) {
|
||||
Ok(_) => true,
|
||||
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open System Settings → Privacy & Security → Full Disk Access so the user can add
|
||||
/// spaceshell. macOS cannot grant this programmatically; we only deep-link the pane.
|
||||
#[tauri::command]
|
||||
pub fn open_full_disk_access_settings() -> Result<(), String> {
|
||||
std::process::Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List the user's installed font families (CoreText) so Settings can offer any of
|
||||
/// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the
|
||||
/// result is de-duplicated and sorted case-insensitively.
|
||||
#[tauri::command]
|
||||
pub fn list_fonts() -> Vec<String> {
|
||||
use std::collections::BTreeSet;
|
||||
let names = core_text::font_collection::get_family_names();
|
||||
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||
for name in names.iter() {
|
||||
let s = name.to_string();
|
||||
if !s.is_empty() && !s.starts_with('.') {
|
||||
set.insert(s);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<String> = set.into_iter().collect();
|
||||
v.sort_by_key(|s| s.to_lowercase());
|
||||
v
|
||||
}
|
||||
|
||||
// ---- Settings commands ----
|
||||
|
||||
#[tauri::command]
|
||||
@@ -500,8 +594,29 @@ pub async fn set_config(
|
||||
font_size: Option<u16>,
|
||||
theme: Option<String>,
|
||||
accent: Option<String>,
|
||||
background: Option<String>,
|
||||
background_image: Option<String>,
|
||||
log_shell_commands: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?)
|
||||
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
/// Read a local image file and return it as a `data:` URL for use as a CSS
|
||||
/// background. Kept in the GUI bridge (not the daemon) so the bytes load straight
|
||||
/// into the webview without crossing the socket.
|
||||
#[tauri::command]
|
||||
pub async fn read_image_data_url(path: String) -> Result<String, String> {
|
||||
use base64::Engine as _;
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
|
||||
let mime = match std::path::Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
Some("gif") => "image/gif",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(format!("data:{mime};base64,{b64}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -8,6 +8,7 @@ pub fn run() {
|
||||
// Persist + restore the window's size, position and maximized state across restarts.
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
// Connect the bridge on a tokio runtime, then manage it.
|
||||
@@ -54,10 +55,15 @@ pub fn run() {
|
||||
bridge::mark_read,
|
||||
bridge::clear_events,
|
||||
bridge::health,
|
||||
bridge::which_agents,
|
||||
bridge::check_update,
|
||||
bridge::open_external,
|
||||
bridge::has_full_disk_access,
|
||||
bridge::open_full_disk_access_settings,
|
||||
bridge::list_fonts,
|
||||
bridge::get_config,
|
||||
bridge::set_config,
|
||||
bridge::read_image_data_url,
|
||||
bridge::shutdown_daemon,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"externalBin": ["bin/spaceshd"]
|
||||
"externalBin": ["bin/spaceshd"],
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "spacesh",
|
||||
"version": "0.1.0",
|
||||
"productName": "spaceshell",
|
||||
"version": "0.1.30",
|
||||
"identifier": "xyz.spacesh.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -10,8 +10,18 @@
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
|
||||
"security": { "csp": null }
|
||||
"windows": [
|
||||
{
|
||||
"title": "spaceshell",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
+146
-19
@@ -4,15 +4,19 @@ import { Sidebar } from "./Sidebar";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { CenterToolbar } from "./CenterToolbar";
|
||||
import { Wizard } from "./Wizard";
|
||||
import { SurfacePicker } from "./SurfacePicker";
|
||||
import { PRESETS } from "./PresetPicker";
|
||||
import { ConfirmDelete } from "./ConfirmDelete";
|
||||
import { Settings } from "./Settings";
|
||||
import { EventCenter } from "./EventCenter";
|
||||
import { maybeNotify } from "./notify";
|
||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge";
|
||||
import { COLORS, FONT, applyTheme, resolvePalette } from "./theme";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate, splitSurface, closeSurfaceCmd, setZoom, readImageDataUrl, hasFullDiskAccess, openFullDiskAccessSettings } from "./socketBridge";
|
||||
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||
import { HOTKEYS, loadBindings, saveBindings, matches, hasModifier } from "./hotkeys";
|
||||
import type { Bindings, HotkeyId } from "./hotkeys";
|
||||
|
||||
/** Read a boolean UI flag from localStorage, falling back to `def`. */
|
||||
function loadFlag(key: string, def: boolean): boolean {
|
||||
@@ -31,6 +35,8 @@ export function App() {
|
||||
const [states, setStates] = useState<Record<string, SurfaceState>>({});
|
||||
const [events, setEvents] = useState<EventRecord[]>([]);
|
||||
const [wizard, setWizard] = useState(false);
|
||||
// Pending additive preset awaiting the per-panel "what to open" choice.
|
||||
const [pendingPreset, setPendingPreset] = useState<{ id: string; delta: number; base: number } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
|
||||
@@ -46,11 +52,20 @@ export function App() {
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
|
||||
const [searchNonce, setSearchNonce] = useState(0);
|
||||
const [bindings, setBindings] = useState<Bindings>(loadBindings);
|
||||
// Full Disk Access: terminals inherit spaceshell's TCC grants, so without FDA
|
||||
// tools like tmutil fail inside panels. Default true to avoid a flash before the probe.
|
||||
const [fdaOk, setFdaOk] = useState(true);
|
||||
const [fdaDismissed, setFdaDismissed] = useState(() => loadFlag("spacesh.fdaDismissed", false));
|
||||
const activeRef = useRef<string | null>(null);
|
||||
const effectiveFocusRef = useRef<string | null>(null);
|
||||
const wsRef = useRef<WorkspaceView[]>([]);
|
||||
const leavesRef = useRef<string[]>([]);
|
||||
const modalOpenRef = useRef(false);
|
||||
const bindingsRef = useRef<Bindings>(bindings);
|
||||
activeRef.current = activeId;
|
||||
wsRef.current = workspaces;
|
||||
bindingsRef.current = bindings;
|
||||
|
||||
const seedEvents = useCallback(async () => {
|
||||
const log = await getEventLog();
|
||||
@@ -93,7 +108,7 @@ export function App() {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
void loadHealth();
|
||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
||||
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
|
||||
const unlisten = onDaemonEvent((evt) => {
|
||||
if (evt.evt === "event") {
|
||||
const rec = evt.data.record;
|
||||
@@ -112,9 +127,7 @@ export function App() {
|
||||
} else if (evt.evt === "exit") {
|
||||
void refresh();
|
||||
} else if (evt.evt === "config_changed") {
|
||||
const c = evt.data.config;
|
||||
setConfigState(c);
|
||||
applyTheme(c.theme, c.accent);
|
||||
setConfigState(evt.data.config);
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
@@ -124,7 +137,7 @@ export function App() {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
void loadHealth();
|
||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
||||
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
|
||||
});
|
||||
const reconnected = onDaemonRawEvent("spacesh:reconnected", () => {
|
||||
setConnected(true);
|
||||
@@ -132,23 +145,85 @@ export function App() {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
void loadHealth();
|
||||
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
|
||||
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
|
||||
});
|
||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); };
|
||||
}, [refresh, seedEvents, loadHealth]);
|
||||
|
||||
// Cycle keyboard focus through the active workspace's panels.
|
||||
const cycleFocus = useCallback((dir: 1 | -1) => {
|
||||
const ls = leavesRef.current;
|
||||
if (ls.length < 2) return;
|
||||
const cur = effectiveFocusRef.current;
|
||||
const i = Math.max(0, ls.indexOf(cur ?? ls[0]));
|
||||
const next = ls[(i + dir + ls.length) % ls.length];
|
||||
setFocusedId(next);
|
||||
void focusSurface(next);
|
||||
}, []);
|
||||
|
||||
// Scrollback search is meaningless on an agent surface: claude/codex/… run a
|
||||
// full-screen TUI in the alternate buffer that repaints every frame, so match
|
||||
// decorations are drawn and immediately clobbered (the counter updates but no
|
||||
// highlight shows). Gate the search affordance off for agent panels.
|
||||
const isAgentSurface = useCallback((id: string | null): boolean => {
|
||||
if (!id) return false;
|
||||
const w = wsRef.current.find((ws) => id in ws.surfaces);
|
||||
return !!w?.surfaces[id]?.spec?.agent_label;
|
||||
}, []);
|
||||
|
||||
// Probe Full Disk Access on launch and again whenever the window regains focus
|
||||
// (so granting it in System Settings and returning clears the banner immediately).
|
||||
useEffect(() => {
|
||||
const recheck = () => { void hasFullDiskAccess().then(setFdaOk); };
|
||||
recheck();
|
||||
window.addEventListener("focus", recheck);
|
||||
return () => window.removeEventListener("focus", recheck);
|
||||
}, []);
|
||||
|
||||
// Action dispatch table for hotkeys. Reads live state through refs so the
|
||||
// window listener can mount once.
|
||||
const actions = useMemo<Record<HotkeyId, () => void>>(() => ({
|
||||
newWorkspace: () => setWizard(true),
|
||||
openSettings: () => { if (config) setSettingsOpen(true); },
|
||||
toggleSidebar: () => setSidebarOpen((v) => !v),
|
||||
toggleEvents: () => setEventsOpen((v) => !v),
|
||||
splitRight: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "right"); },
|
||||
splitDown: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "down"); },
|
||||
closePanel: () => { const f = effectiveFocusRef.current; if (f) void closeSurfaceCmd(f); },
|
||||
focusNext: () => cycleFocus(1),
|
||||
focusPrev: () => cycleFocus(-1),
|
||||
zoomToggle: () => {
|
||||
const a = wsRef.current.find((w) => w.id === activeRef.current);
|
||||
if (!a) return;
|
||||
void setZoom(a.id, a.zoomed ? null : effectiveFocusRef.current);
|
||||
},
|
||||
search: () => {
|
||||
const f = effectiveFocusRef.current;
|
||||
if (f && !isAgentSurface(f)) { setSearchSurfaceId(f); setSearchNonce((n) => n + 1); }
|
||||
},
|
||||
}), [cycleFocus, config, isAgentSurface]);
|
||||
|
||||
const actionsRef = useRef(actions);
|
||||
actionsRef.current = actions;
|
||||
|
||||
// Central hotkey listener (mounted once). Skips while a modal is open, and only
|
||||
// ever swallows keys that carry a modifier so terminal input is never stolen.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
||||
if (activeRef.current && effectiveFocusRef.current) {
|
||||
if (modalOpenRef.current) return;
|
||||
for (const h of HOTKEYS) {
|
||||
const b = bindingsRef.current[h.id];
|
||||
if (hasModifier(b) && matches(b, e)) {
|
||||
e.preventDefault();
|
||||
setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel
|
||||
setSearchNonce((n) => n + 1);
|
||||
e.stopPropagation(); // capture phase: keep the chord out of the focused terminal
|
||||
actionsRef.current[h.id]();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// Capture phase so the chord is seen even when xterm has keyboard focus.
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, []);
|
||||
|
||||
// Update check: once on launch, then every 6h.
|
||||
@@ -166,8 +241,29 @@ export function App() {
|
||||
const leaves = active ? leafIds(active.layout) : [];
|
||||
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
|
||||
effectiveFocusRef.current = effectiveFocus;
|
||||
leavesRef.current = leaves;
|
||||
modalOpenRef.current = wizard || settingsOpen || !!pendingPreset || !!deleteTarget;
|
||||
|
||||
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]);
|
||||
// Apply theme + background fill whenever appearance changes. A custom image is
|
||||
// loaded (file → data URL) before re-applying so the panel glass and root fill
|
||||
// paint together. Centralized here so every config source (initial load,
|
||||
// reconnect, config_changed) flows through one place.
|
||||
const [bgImage, setBgImage] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
let cancelled = false;
|
||||
if (config.background === "custom" && config.background_image) {
|
||||
void readImageDataUrl(config.background_image)
|
||||
.then((url) => { if (!cancelled) { setBgImage(url); applyTheme(config.theme, config.accent, config.background, url); } })
|
||||
.catch(() => { if (!cancelled) { setBgImage(null); applyTheme(config.theme, config.accent, "none", null); } });
|
||||
} else {
|
||||
setBgImage(null);
|
||||
applyTheme(config.theme, config.accent, config.background, null);
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [config?.theme, config?.accent, config?.background, config?.background_image]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent, config.background) : null), [config?.theme, config?.accent, config?.background, bgImage]);
|
||||
const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]);
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
@@ -177,17 +273,36 @@ export function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.appBg }}>
|
||||
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
|
||||
{!fdaOk && !fdaDismissed && (
|
||||
<div style={{ position: "relative", zIndex: 25, display: "flex", alignItems: "center", gap: 12, padding: "8px 14px", background: "rgba(242,184,75,0.12)", borderBottom: `1px solid ${COLORS.stWait}`, fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>
|
||||
<span style={{ flex: 1 }}>
|
||||
<b>Нет Full Disk Access.</b> Терминалы внутри spaceshell наследуют права приложения — без него команды вроде <code style={{ fontFamily: FONT.mono }}>tmutil</code> и доступ к защищённым папкам падают. Добавьте spaceshell в System Settings → Privacy & Security → Full Disk Access.
|
||||
</span>
|
||||
<button onClick={() => void openFullDiskAccessSettings()}
|
||||
style={{ flex: "0 0 auto", padding: "5px 12px", background: COLORS.accent, color: COLORS.bgApp, border: "none", borderRadius: 7, fontSize: 12, fontWeight: 600, cursor: "pointer" }}>Открыть настройки</button>
|
||||
<button onClick={() => void hasFullDiskAccess().then(setFdaOk)}
|
||||
style={{ flex: "0 0 auto", padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12, cursor: "pointer" }}>Проверить снова</button>
|
||||
<button onClick={() => { setFdaDismissed(true); saveFlag("spacesh.fdaDismissed", true); }} aria-label="Скрыть"
|
||||
style={{ flex: "0 0 auto", padding: "5px 8px", background: "transparent", color: COLORS.textMuted, border: "none", borderRadius: 7, fontSize: 12, cursor: "pointer" }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{active && (
|
||||
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
|
||||
<CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => {
|
||||
if (!active) return;
|
||||
const target = PRESETS.find((x) => x.id === p)?.slots ?? leaves.length;
|
||||
const delta = target - leaves.length;
|
||||
if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels
|
||||
setPendingPreset({ id: p, delta, base: leaves.length });
|
||||
}} onOpenSearch={() => { if (effectiveFocus && !isAgentSurface(effectiveFocus)) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} searchDisabled={isAgentSurface(effectiveFocus)} />
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
{active
|
||||
? <LayoutEngine key={connEpoch} workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
|
||||
? <LayoutEngine key={connEpoch} workspaceId={active.id} workspaceName={active.name} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
|
||||
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace — create one to begin.</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,8 +315,20 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
|
||||
{settingsOpen && config && <Settings config={config} health={health} bindings={bindings} onBindingsChange={(b) => { setBindings(b); saveBindings(b); }} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
|
||||
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||
{pendingPreset && active && (
|
||||
<SurfacePicker
|
||||
count={pendingPreset.delta}
|
||||
onCancel={() => setPendingPreset(null)}
|
||||
onConfirm={(specs) => {
|
||||
const padded = [...Array(pendingPreset.base).fill({}), ...specs]; // align to daemon's slots.get(existing.len()+j)
|
||||
const wsId = active.id;
|
||||
setPendingPreset(null);
|
||||
void applyPreset(wsId, pendingPreset.id, padded).then(() => void refresh());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<ConfirmDelete
|
||||
name={deleteTarget.name}
|
||||
|
||||
@@ -3,17 +3,18 @@ import { COLORS, FONT } from "./theme";
|
||||
import { PresetPicker } from "./PresetPicker";
|
||||
|
||||
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
|
||||
export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) {
|
||||
export function CenterToolbar({ selected, onSelect, onOpenSearch, paneCount, searchDisabled = false }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number; searchDisabled?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<PresetPicker selected={selected} onSelect={onSelect} />
|
||||
<div style={{ position: "relative", zIndex: 20, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, background: COLORS.elevatedGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<PresetPicker selected={selected} onSelect={onSelect} minSlots={paneCount} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
title="Search scrollback"
|
||||
onClick={onOpenSearch}
|
||||
title={searchDisabled ? "Поиск недоступен в панели с агентом (полноэкранный TUI)" : "Search scrollback"}
|
||||
onClick={searchDisabled ? undefined : onOpenSearch}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
|
||||
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer",
|
||||
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`,
|
||||
cursor: searchDisabled ? "not-allowed" : "pointer", opacity: searchDisabled ? 0.4 : 1,
|
||||
}}>
|
||||
<Search size={12} color={COLORS.textMuted} />
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.textMuted }}>Search scrollback</span>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function EventCenter({
|
||||
: events;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
||||
<span
|
||||
|
||||
+87
-29
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
|
||||
import { Maximize2, Minimize2, RotateCw, GripVertical, Play, X } from "lucide-react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { StatusRing } from "./StatusRing";
|
||||
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
||||
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
||||
import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge";
|
||||
import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface, closeSurfaceCmd } from "./socketBridge";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
layout: LayoutNode | null;
|
||||
/** surface_id -> running flag, from the latest status/events. */
|
||||
running: Record<string, boolean>;
|
||||
@@ -36,13 +38,7 @@ function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge {
|
||||
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. */
|
||||
function shortPath(cwd: string): string {
|
||||
const leaf = cwd.split("/").filter(Boolean).pop();
|
||||
return leaf ? `~/${leaf}` : cwd;
|
||||
}
|
||||
|
||||
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
|
||||
export function LayoutEngine({ workspaceId, workspaceName, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
|
||||
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
|
||||
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
|
||||
const [drop, setDrop] = useState<DropTarget | null>(null);
|
||||
@@ -80,7 +76,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
|
||||
if (!layout) {
|
||||
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
||||
}
|
||||
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
|
||||
const shared = { workspaceId, workspaceName, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
|
||||
if (zoomed) {
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||
@@ -96,7 +92,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
|
||||
}
|
||||
|
||||
interface NodeProps {
|
||||
workspaceId: string; node: LayoutNode; path: number[];
|
||||
workspaceId: string; workspaceName: string; node: LayoutNode; path: number[];
|
||||
running: Record<string, boolean>; states: Record<string, SurfaceState>;
|
||||
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
|
||||
zoomed: string | null;
|
||||
@@ -116,7 +112,45 @@ function Node({ node, path, ...rest }: NodeProps) {
|
||||
return <SplitView split={node.split} path={path} {...rest} />;
|
||||
}
|
||||
|
||||
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||
const NERD_FALLBACK_LE = "'Symbols Nerd Font Mono'";
|
||||
const fontStackLE = (family: string | null) =>
|
||||
family ? `'${family}', ${NERD_FALLBACK_LE}, monospace`
|
||||
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK_LE}, monospace`;
|
||||
|
||||
function xtermThemeLE(p: Record<string, string>) {
|
||||
return {
|
||||
background: p["term-bg"] ?? p["bg-panel"],
|
||||
foreground: p["text-primary"],
|
||||
cursor: p["text-primary"],
|
||||
selectionBackground: p["search-match"],
|
||||
};
|
||||
}
|
||||
|
||||
function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const term = new Terminal({
|
||||
fontFamily: fontStackLE(font?.family ?? null),
|
||||
fontSize: font?.size ?? 13,
|
||||
theme: palette ? xtermThemeLE(palette) : undefined,
|
||||
allowTransparency: true, // term-bg may be transparent under a background theme
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
scrollback: 0,
|
||||
});
|
||||
term.open(host);
|
||||
let disposed = false;
|
||||
void attachSurface(surfaceId, () => {}).then((res) => {
|
||||
if (!disposed && res.snapshot) term.write(res.snapshot);
|
||||
});
|
||||
return () => { disposed = true; term.dispose(); void detachSurface(surfaceId); };
|
||||
}, [surfaceId, font, palette]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return <div ref={hostRef} style={{ position: "absolute", inset: 0, opacity: 0.45, pointerEvents: "none" }} />;
|
||||
}
|
||||
|
||||
function Leaf({ id, workspaceId, workspaceName, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||
const focused = focusedId === id;
|
||||
const dropEdge = drop && drop.id === id ? drop.edge : null;
|
||||
|
||||
@@ -126,7 +160,8 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
onMouseDown={() => onFocus(id)}
|
||||
style={{
|
||||
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
||||
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
||||
background: "transparent",
|
||||
borderRadius: 8, overflow: "hidden",
|
||||
// Constant 2px border, color-only on focus. A width change (1px<->2px)
|
||||
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
|
||||
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
|
||||
@@ -135,26 +170,45 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{inner}
|
||||
{/* Glass fill + blur as a layer BEHIND the content. The terminal's transparent
|
||||
cells show this through. Crucially the terminal canvas is NOT a descendant
|
||||
of a backdrop-filter element — under WKWebView that clips/smears the WebGL
|
||||
canvas (first-glyph clip at column 0, smearing on scroll). With "none" the
|
||||
glass is the solid bg-panel so the classic look is unchanged. */}
|
||||
<div style={{ position: "absolute", inset: 0, zIndex: 0, background: COLORS.panelGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, pointerEvents: "none" }} />
|
||||
<div style={{ position: "relative", zIndex: 1, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
{inner}
|
||||
</div>
|
||||
{dropEdge && <DropIndicator edge={dropEdge} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (running[id] === false) {
|
||||
return card(
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => void restartSurface(id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<RotateCw size={13} /> Restart
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<Minimize2 size={13} /> Exit zoom
|
||||
<div style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||
<StoppedSnapshot surfaceId={id} font={font} palette={palette} />
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, color: COLORS.textSecondary, background: "rgba(0,0,0,0.35)" }}>
|
||||
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Stopped</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => void restartSurface(id, true)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.accent, color: COLORS.bgApp, border: "none", borderRadius: 7, fontSize: 12, fontWeight: 600 }}>
|
||||
<Play size={13} /> Resume
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => void restartSurface(id, false)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<RotateCw size={13} /> Restart fresh
|
||||
</button>
|
||||
<button onClick={() => void closeSurfaceCmd(id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<X size={13} /> Close
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<Minimize2 size={13} /> Exit zoom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -168,12 +222,12 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
<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" }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.elevatedGlass, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
|
||||
>
|
||||
<GripVertical size={13} color={COLORS.textMuted} />
|
||||
<StatusRing state={state} running={true} />
|
||||
<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>}
|
||||
{workspaceName && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{workspaceName}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
|
||||
{state}
|
||||
@@ -183,9 +237,13 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
||||
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
|
||||
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
|
||||
<X size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Close panel"
|
||||
onMouseDown={(e) => { e.stopPropagation(); void closeSurfaceCmd(id); }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.stError; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<TerminalView key={id} surfaceId={id} font={font} palette={palette} />
|
||||
<TerminalView key={id} surfaceId={id} font={font} palette={palette} focused={focused} />
|
||||
</div>
|
||||
{searchSurfaceId === id && (
|
||||
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
|
||||
|
||||
@@ -13,10 +13,12 @@ export const PRESETS: { id: string; label: string; slots: number }[] = [
|
||||
|
||||
import { COLORS, FONT } from "./theme";
|
||||
|
||||
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
|
||||
// `minSlots` hides presets smaller than the current pane count — applying a preset
|
||||
// only ever ADDS panes (never destroys running ones); shrink by closing panels.
|
||||
export function PresetPicker({ selected, onSelect, minSlots = 0 }: { selected: string; onSelect: (id: string) => void; minSlots?: number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{PRESETS.map((p) => {
|
||||
{PRESETS.filter((p) => p.slots >= minSlots).map((p) => {
|
||||
const on = p.id === selected;
|
||||
return (
|
||||
<button key={p.id} onClick={() => onSelect(p.id)}
|
||||
|
||||
+169
-10
@@ -1,12 +1,78 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { COLORS, FONT, ACCENTS } from "./theme";
|
||||
import { setConfig, restartDaemon } from "./socketBridge";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { X, Search, Check } from "lucide-react";
|
||||
import { COLORS, FONT, ACCENTS, BACKGROUNDS, CUSTOM_BACKGROUND } from "./theme";
|
||||
import { setConfig, restartDaemon, listFonts } from "./socketBridge";
|
||||
import type { ConfigView, DaemonHealth } from "./socketBridge";
|
||||
import { HOTKEYS, defaultBindings, eventBinding, formatBinding, hasModifier } from "./hotkeys";
|
||||
import type { Bindings, HotkeyId } from "./hotkeys";
|
||||
|
||||
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
||||
// Pinned defaults shown first; the rest are the user's installed families (list_fonts).
|
||||
const DEFAULT_FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
|
||||
|
||||
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
|
||||
/** Searchable font picker: type to filter, click to apply. Defaults pinned on top. */
|
||||
function FontPicker({ value, onPick }: { value: string; onPick: (family: string) => void }) {
|
||||
const [installed, setInstalled] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { void listFonts().then(setInstalled).catch(() => {}); }, []);
|
||||
|
||||
// Close on outside click.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [open]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const f of [...DEFAULT_FONTS, ...installed]) {
|
||||
const k = f.toLowerCase();
|
||||
if (!seen.has(k)) { seen.add(k); merged.push(f); }
|
||||
}
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? merged.filter((f) => f.toLowerCase().includes(q)) : merged;
|
||||
}, [installed, query]);
|
||||
|
||||
return (
|
||||
<div ref={boxRef} style={{ position: "relative", marginBottom: 10 }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search size={14} style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", color: COLORS.textMuted }} />
|
||||
<input
|
||||
value={open ? query : value}
|
||||
placeholder={value}
|
||||
onFocus={() => { setOpen(true); setQuery(""); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
style={{ width: "100%", padding: "8px 8px 8px 30px", background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${open ? COLORS.accent : COLORS.borderStrong}`, borderRadius: 8, fontFamily: FONT.ui }}
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: 10, maxHeight: 240, overflowY: "auto",
|
||||
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
|
||||
{options.length === 0 && <div style={{ padding: 10, fontSize: 12, color: COLORS.textMuted }}>Ничего не найдено</div>}
|
||||
{options.map((f) => {
|
||||
const isDefault = DEFAULT_FONTS.some((d) => d.toLowerCase() === f.toLowerCase());
|
||||
return (
|
||||
<button key={f} onClick={() => { onPick(f); setOpen(false); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left", padding: "7px 10px",
|
||||
background: f === value ? COLORS.bgElevated : "transparent", border: "none", color: COLORS.textPrimary,
|
||||
fontFamily: `'${f}', ${FONT.mono}`, fontSize: 13 }}>
|
||||
<Check size={13} style={{ opacity: f === value ? 1 : 0, color: COLORS.accent, flex: "0 0 auto" }} />
|
||||
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{f}</span>
|
||||
{isDefault && <span style={{ fontFamily: FONT.ui, fontSize: 10, color: COLORS.textMuted }}>default</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Settings({ config, health, bindings, onBindingsChange, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; bindings: Bindings; onBindingsChange: (b: Bindings) => void; onClose: () => void; onReload: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.focus(); }, []);
|
||||
|
||||
@@ -17,6 +83,10 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
||||
// Fix 3: controlled shell input — synced from config, committed on blur.
|
||||
const [shellLocal, setShellLocal] = useState(config.default_shell);
|
||||
useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]);
|
||||
|
||||
// Custom background image path — committed on blur (switches background to "custom").
|
||||
const [bgPathLocal, setBgPathLocal] = useState(config.background_image);
|
||||
useEffect(() => { setBgPathLocal(config.background_image); }, [config.background_image]);
|
||||
return (
|
||||
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
|
||||
@@ -30,10 +100,7 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
|
||||
<select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })}
|
||||
style={{ width: "100%", padding: 8, marginBottom: 10, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }}>
|
||||
{FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
<FontPicker value={config.font_family} onPick={(f) => void setConfig({ font_family: f })} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
|
||||
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
|
||||
<input type="range" min={10} max={20} value={sizeLocal}
|
||||
@@ -60,16 +127,108 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Background</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginBottom: 10 }}>
|
||||
{Object.entries(BACKGROUNDS).map(([id, bg]) => {
|
||||
const selected = config.background === id;
|
||||
return (
|
||||
<button key={id} onClick={() => void setConfig({ background: id })} aria-label={bg.label} title={bg.label}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, cursor: "pointer", background: "transparent", border: "none" }}>
|
||||
<div style={{ height: 40, borderRadius: 6, background: bg.swatch,
|
||||
border: selected ? `2px solid ${COLORS.accent}` : `2px solid ${COLORS.borderSubtle}`, boxSizing: "border-box" }} />
|
||||
<span style={{ fontSize: 10, color: selected ? COLORS.textPrimary : COLORS.textMuted, textAlign: "center", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{bg.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }}
|
||||
aria-label="Custom image" title="Custom image"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, cursor: "pointer", background: "transparent", border: "none" }}>
|
||||
<div style={{ height: 40, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: COLORS.textMuted, fontSize: 10, background: COLORS.bgPanel,
|
||||
border: config.background === CUSTOM_BACKGROUND ? `2px solid ${COLORS.accent}` : `2px solid ${COLORS.borderSubtle}`, boxSizing: "border-box" }}>Image</div>
|
||||
<span style={{ fontSize: 10, color: config.background === CUSTOM_BACKGROUND ? COLORS.textPrimary : COLORS.textMuted, textAlign: "center" }}>Custom</span>
|
||||
</button>
|
||||
</div>
|
||||
<input value={bgPathLocal} onChange={(e) => setBgPathLocal(e.target.value)}
|
||||
onBlur={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }}
|
||||
placeholder="/path/to/wallpaper.png — image for the Custom theme"
|
||||
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, fontSize: 12 }} />
|
||||
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
|
||||
<input value={shellLocal} onChange={(e) => setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })}
|
||||
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
|
||||
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Events</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18, cursor: "pointer" }}>
|
||||
<button role="switch" aria-checked={config.log_shell_commands} onClick={() => void setConfig({ log_shell_commands: !config.log_shell_commands })}
|
||||
style={{ position: "relative", width: 38, height: 22, flex: "0 0 auto", borderRadius: 11, border: "none", cursor: "pointer",
|
||||
background: config.log_shell_commands ? COLORS.accent : COLORS.bgElevated, transition: "background 0.15s" }}>
|
||||
<span style={{ position: "absolute", top: 2, left: config.log_shell_commands ? 18 : 2, width: 18, height: 18, borderRadius: "50%", background: "#fff", transition: "left 0.15s" }} />
|
||||
</button>
|
||||
<span style={{ fontSize: 13, color: COLORS.textPrimary }}>
|
||||
Log shell commands
|
||||
<span style={{ display: "block", fontSize: 11, color: COLORS.textMuted }}>Off: only agent activity is logged & notified. Status rings still update.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<HotkeysSection bindings={bindings} onChange={onBindingsChange} />
|
||||
|
||||
<DaemonSection health={health} onReload={onReload} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hotkeys list with click-to-record rebinding. */
|
||||
function HotkeysSection({ bindings, onChange }: { bindings: Bindings; onChange: (b: Bindings) => void }) {
|
||||
const [recordingId, setRecordingId] = useState<HotkeyId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordingId) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") { setRecordingId(null); return; }
|
||||
const b = eventBinding(e);
|
||||
if (!b || !hasModifier(b)) return; // wait for a real chord with a modifier
|
||||
onChange({ ...bindings, [recordingId]: b });
|
||||
setRecordingId(null);
|
||||
};
|
||||
// Capture phase so we intercept before the modal's own key handling.
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [recordingId, bindings, onChange]);
|
||||
|
||||
const groups = ["Workspace", "Panel"] as const;
|
||||
return (
|
||||
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 12, color: COLORS.textSecondary, flex: 1 }}>Hotkeys</span>
|
||||
<button onClick={() => onChange(defaultBindings())}
|
||||
style={{ fontSize: 11, color: COLORS.textMuted, background: "transparent", border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, padding: "3px 8px", cursor: "pointer" }}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<div key={g} style={{ marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: COLORS.textMuted, margin: "6px 0 2px" }}>{g}</div>
|
||||
{HOTKEYS.filter((h) => h.group === g).map((h) => (
|
||||
<div key={h.id} style={{ display: "flex", alignItems: "center", height: 28 }}>
|
||||
<span style={{ flex: 1, fontSize: 13, color: COLORS.textPrimary }}>{h.label}</span>
|
||||
<button onClick={() => setRecordingId(h.id)}
|
||||
style={{ minWidth: 70, fontFamily: FONT.mono, fontSize: 12, padding: "3px 10px", borderRadius: 6, cursor: "pointer",
|
||||
background: recordingId === h.id ? COLORS.accent : COLORS.bgPanel,
|
||||
color: recordingId === h.id ? COLORS.bgApp : COLORS.textPrimary,
|
||||
border: `1px solid ${recordingId === h.id ? COLORS.accent : COLORS.borderStrong}` }}>
|
||||
{recordingId === h.id ? "Press keys…" : formatBinding(bindings[h.id])}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtUptime(ms: number): string {
|
||||
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
|
||||
+2
-2
@@ -192,7 +192,7 @@ export function Sidebar({
|
||||
...ungrouped,
|
||||
];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.bgSidebar, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
|
||||
<div style={{ position: "relative", zIndex: 20, display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
|
||||
<button onClick={onNew} title="New workspace"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textPrimary, cursor: "pointer" }}>
|
||||
<Plus size={15} />
|
||||
@@ -216,7 +216,7 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: 14, boxSizing: "border-box" }}>
|
||||
<button onClick={onNew}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { whichAgents } from "./socketBridge";
|
||||
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
|
||||
|
||||
type SlotSpec = { command?: string; args?: string[] };
|
||||
|
||||
/**
|
||||
* Asks what to open in each new panel before a preset spawns it: Terminal
|
||||
* (shell), one of the installed CLIs (claude/codex/gemini/deepseek), or a
|
||||
* custom command. `count` is the number of new panels the preset will add.
|
||||
*/
|
||||
export function SurfacePicker({ count, onConfirm, onCancel }: { count: number; onConfirm: (specs: SlotSpec[]) => void; onCancel: () => void }) {
|
||||
const [installed, setInstalled] = useState<string[]>([]);
|
||||
const [choices, setChoices] = useState<string[]>([]);
|
||||
const [customCmds, setCustomCmds] = useState<string[]>([]);
|
||||
const choiceList = [SHELL, ...installed, CUSTOM];
|
||||
|
||||
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
||||
|
||||
function confirm() {
|
||||
const specs = Array.from({ length: count }, (_, i) => specForChoice(choices[i] ?? SHELL, customCmds[i] ?? ""));
|
||||
onConfirm(specs);
|
||||
}
|
||||
|
||||
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(); confirm(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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: 420, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>{count > 1 ? `Open ${count} new panels` : "Open new panel"}</div>
|
||||
<div style={{ fontSize: 12, color: "#8B97A6", marginBottom: 16 }}>Choose what to run in each new panel.</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: count > 1 ? "1fr 1fr" : "1fr", gap: 8, marginBottom: 20 }}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const val = choices[i] ?? SHELL;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<select value={val} onChange={(e) => setChoices((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{choiceList.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
|
||||
</select>
|
||||
{val === CUSTOM && (
|
||||
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev" autoFocus
|
||||
onChange={(e) => setCustomCmds((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #4C8DFF", borderRadius: 6, fontFamily: "monospace", fontSize: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
|
||||
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
|
||||
<button onClick={confirm} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,20 +9,55 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// xterm.js auto-answers device queries (Device Attributes, cursor/status reports,
|
||||
// OSC color queries, DECRPM mode reports) by emitting the reply through onData.
|
||||
// But the daemon's alacritty grid is the authoritative emulator and already
|
||||
// answers these on the PTY (see spacesh-core grid.rs `take_replies` →
|
||||
// `write_input`). Forwarding xterm's duplicate — which arrives a full IPC
|
||||
// roundtrip late — lands in the shell's input buffer after it stopped reading
|
||||
// the reply, so the shell echoes it as literal escape gibberish and the prompt
|
||||
// shifts. Drop these standalone reports; never the user's keystrokes/paste/mouse
|
||||
// (mouse reports end in M/m and are real user input the program asked for).
|
||||
function isDeviceReport(data: string): boolean {
|
||||
if (data.charCodeAt(0) !== 0x1b) return false;
|
||||
return (
|
||||
/^\x1b\[[?>=]?[0-9;]*[cntR]$/.test(data) || // DA1/DA2 (c), DSR status (n), text-area/cell-size (t), cursor position (R)
|
||||
/^\x1b\[\?[0-9;]*u$/.test(data) || // kitty keyboard QUERY reply (\x1b[?flags u) — NOT key input \x1b[<code>u
|
||||
/^\x1b\[\?[0-9;]*\$[py]$/.test(data) || // DECRPM mode report
|
||||
/^\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(data) || // OSC color / query reply (BEL- or ST-terminated)
|
||||
/^\x1bP[\s\S]*\x1b\\$/.test(data) || // any DCS report (XTVERSION / DECRQSS / status string)
|
||||
/^\x1b_[\s\S]*\x1b\\$/.test(data) // any APC report (kitty graphics)
|
||||
);
|
||||
}
|
||||
|
||||
// Appended after the user font so Nerd Font icon glyphs (Private Use Area) render
|
||||
// via fallback instead of blank boxes, without changing the base monospace font.
|
||||
const NERD_FALLBACK = "'Symbols Nerd Font Mono'";
|
||||
const fontStack = (family: string | null) =>
|
||||
family ? `'${family}', ${NERD_FALLBACK}, monospace`
|
||||
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK}, monospace`;
|
||||
|
||||
function xtermTheme(p: Record<string, string>) {
|
||||
return {
|
||||
background: p["bg-panel"],
|
||||
background: p["term-bg"] ?? p["bg-panel"],
|
||||
foreground: p["text-primary"],
|
||||
cursor: p["text-primary"],
|
||||
selectionBackground: p["search-match"],
|
||||
};
|
||||
}
|
||||
|
||||
export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
|
||||
export function TerminalView({ surfaceId, font, palette, focused }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null; focused?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
// A background theme makes term-bg fully transparent so the panel's glass fill
|
||||
// shows through. allowTransparency is construction-time only, so it's part of the
|
||||
// effect key to force a remount when it flips. WebGL stays on in both modes — the
|
||||
// glass/blur lives on a sibling layer (see LayoutEngine), not an ancestor, so the
|
||||
// WebGL canvas composites its transparent background without the WKWebView
|
||||
// clipping/smearing artifacts that backdrop-filter ancestors cause.
|
||||
const transparent = palette?.["term-bg"] === "rgba(0,0,0,0)";
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -30,11 +65,12 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
// call registerMarker/registerDecoration (proposed API). Without it findNext
|
||||
// throws and the scrollback search counter never updates.
|
||||
const term = new Terminal({
|
||||
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
|
||||
fontFamily: fontStack(font?.family ?? null),
|
||||
fontSize: font?.size ?? 13,
|
||||
convertEol: false,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
allowTransparency: transparent,
|
||||
theme: palette ? xtermTheme(palette) : undefined,
|
||||
});
|
||||
termRef.current = term;
|
||||
@@ -76,11 +112,18 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
|
||||
// Input → daemon.
|
||||
const inputDisposable = term.onData((data) => {
|
||||
if (isDeviceReport(data)) return; // daemon answers the PTY authoritatively; xterm's dup arrives late and echoes
|
||||
void sendInput(surfaceId, encoder.encode(data));
|
||||
});
|
||||
|
||||
let disposed = false;
|
||||
|
||||
// The Nerd Font fallback may finish loading after the first paint; once it's
|
||||
// ready, drop the WebGL glyph atlas so cached blank cells re-rasterize with icons.
|
||||
void document.fonts.load("16px 'Symbols Nerd Font Mono'").then(() => {
|
||||
if (!disposed) webglRef.current?.clearTextureAtlas();
|
||||
}).catch(() => {});
|
||||
|
||||
// Attach: fresh xterm instance, write snapshot, then stream live output.
|
||||
void attachSurface(surfaceId, (bytes) => {
|
||||
if (!disposed) term.write(decoder.decode(bytes));
|
||||
@@ -104,7 +147,15 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
fitRef.current = null;
|
||||
webglRef.current = null;
|
||||
};
|
||||
}, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [surfaceId, transparent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Keyboard focus cycling (cmd+]/[) only changes the focusedId state — it never
|
||||
// touches the DOM, so the new panel's xterm textarea stays unfocused and keys
|
||||
// keep flowing to the old terminal. Mouse clicks don't hit this because the
|
||||
// click lands on the textarea directly. Drive xterm focus from the prop.
|
||||
useEffect(() => {
|
||||
if (focused) termRef.current?.focus();
|
||||
}, [focused]);
|
||||
|
||||
// Live re-apply font and theme when config changes without remounting.
|
||||
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
|
||||
@@ -112,7 +163,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
|
||||
const t = termRef.current;
|
||||
if (!t) return;
|
||||
if (font) {
|
||||
t.options.fontFamily = `'${font.family}', monospace`;
|
||||
t.options.fontFamily = fontStack(font.family);
|
||||
t.options.fontSize = font.size;
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
|
||||
// the old font/size; without clearing it the grid keeps rendering stale
|
||||
|
||||
+21
-9
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown, RefreshCw, Download } from "lucide-react";
|
||||
import { FolderGit2, PanelLeft, PanelRight, Bell, Settings, ChevronDown, CloudDownload, Download } from "lucide-react";
|
||||
import { COLORS, FONT } from "./theme";
|
||||
import type { WorkspaceView } from "./layoutTypes";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
@@ -51,7 +51,7 @@ function UpdateControl({ update, checking, onCheck }: { update: UpdateInfo | nul
|
||||
animation: hasUpdate ? "spaceshPulse 2s ease-in-out infinite" : "none",
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} style={{ animation: checking ? "spaceshSpin 0.8s linear infinite" : "none" }} />
|
||||
<CloudDownload size={15} style={{ animation: checking ? "spaceshBlink 1s ease-in-out infinite" : "none" }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
@@ -83,8 +83,10 @@ function UpdateControl({ update, checking, onCheck }: { update: UpdateInfo | nul
|
||||
>
|
||||
<Download size={14} /> Скачать {update?.latest}
|
||||
</button>
|
||||
) : (
|
||||
) : update?.latest ? (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.stDone }}>Установлена последняя версия</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.textMuted }}>Не удалось проверить сервер</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -123,20 +125,31 @@ export function TopBar({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
// Native titlebar is a transparent Overlay (see tauri.conf.json), so the
|
||||
// theme fill flows up to the very top and this bar IS the titlebar. Make it
|
||||
// the OS drag region; child buttons keep their own clicks (Tauri only starts
|
||||
// a drag on mousedown landing on the drag-region element itself).
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
display: "flex", alignItems: "center", height: 40, flex: "0 0 40px",
|
||||
padding: "0 14px", gap: 12, background: COLORS.bgApp,
|
||||
// Left pad clears the macOS traffic lights overlaid in this strip.
|
||||
padding: "0 14px 0 78px", gap: 12, background: COLORS.elevatedGlass,
|
||||
backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur,
|
||||
borderBottom: `1px solid ${COLORS.borderSubtle}`,
|
||||
// The glass panels below use backdrop-filter, which creates stacking
|
||||
// contexts that otherwise paint over this bar's popovers (update/bell).
|
||||
// Lift the whole bar into its own context above the panel grid.
|
||||
position: "relative", zIndex: 30,
|
||||
}}
|
||||
>
|
||||
{/* Left: sidebar toggle, flush to the left edge. */}
|
||||
<IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
|
||||
|
||||
{/* Workspace breadcrumb */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<div data-tauri-drag-region style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<FolderGit2 size={15} color={COLORS.textSecondary} />
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{active?.name ?? "spacesh"}
|
||||
{active?.name ?? "spaceshell"}
|
||||
</span>
|
||||
{active && (
|
||||
<>
|
||||
@@ -148,16 +161,15 @@ export function TopBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<div data-tauri-drag-region style={{ flex: 1, alignSelf: "stretch" }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes spaceshSpin { to { transform: rotate(360deg); } }
|
||||
@keyframes spaceshBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
@keyframes spaceshPulse { 0%,100% { box-shadow: 0 0 6px rgba(52,211,194,0.35); } 50% { box-shadow: 0 0 14px rgba(52,211,194,0.7); } }
|
||||
`}</style>
|
||||
|
||||
{/* Right cluster */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
|
||||
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
|
||||
<div style={{ position: "relative", display: "flex" }}>
|
||||
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
|
||||
|
||||
+46
-13
@@ -1,16 +1,20 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { PresetPicker, PRESETS } from "./PresetPicker";
|
||||
import { openWorkspace, applyPreset } from "./socketBridge";
|
||||
import { openWorkspace, applyPreset, whichAgents } from "./socketBridge";
|
||||
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
|
||||
|
||||
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
|
||||
const [path, setPath] = useState(".");
|
||||
const [preset, setPreset] = useState("2x2");
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const [customCmds, setCustomCmds] = useState<string[]>([]);
|
||||
const [installed, setInstalled] = 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 agentChoices = ["shell", "claude", "codex", "gemini"];
|
||||
const agentChoices = [SHELL, ...installed, CUSTOM];
|
||||
|
||||
// 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).
|
||||
@@ -19,16 +23,32 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
||||
pathRef.current?.select();
|
||||
}, []);
|
||||
|
||||
// Only offer agents the user actually has installed.
|
||||
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
|
||||
|
||||
// Native folder picker — fills the path field with the chosen absolute directory.
|
||||
async function browse() {
|
||||
try {
|
||||
const picked = await openDialog({ directory: true, multiple: false, title: "Select project folder" });
|
||||
if (typeof picked === "string") setPath(picked);
|
||||
} catch { /* user cancelled or dialog unavailable */ }
|
||||
}
|
||||
|
||||
// Close on Escape regardless of which element holds focus (the inner div's
|
||||
// onKeyDown misses it once focus moves to a preset button / select).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
async function create() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ws = await openWorkspace(path);
|
||||
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
||||
const a = agents[i] ?? "shell";
|
||||
return a === "shell" ? {} : { command: a };
|
||||
});
|
||||
const slotSpecs = Array.from({ length: slots }, (_, i) => specForChoice(agents[i] ?? SHELL, customCmds[i] ?? ""));
|
||||
await applyPreset(ws, preset, slotSpecs);
|
||||
onDone(ws);
|
||||
} catch (e) {
|
||||
@@ -56,17 +76,30 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
|
||||
<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 }} />
|
||||
<div style={{ display: "flex", gap: 8, margin: "6px 0 16px" }}>
|
||||
<input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ flex: 1, minWidth: 0, padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
||||
<button onClick={() => void browse()} style={{ flex: "0 0 auto", padding: "8px 14px", background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8, fontWeight: 600 }}>Browse…</button>
|
||||
</div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
|
||||
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
|
||||
{Array.from({ length: slots }, (_, i) => (
|
||||
<select key={i} value={agents[i] ?? "shell"} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
))}
|
||||
{Array.from({ length: slots }, (_, i) => {
|
||||
const val = agents[i] ?? SHELL;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<select value={val} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{agentChoices.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
|
||||
</select>
|
||||
{val === CUSTOM && (
|
||||
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev"
|
||||
onChange={(e) => setCustomCmds((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #4C8DFF", borderRadius: 6, fontFamily: "monospace", fontSize: 12 }} />
|
||||
)}
|
||||
</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 }}>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Launchable agents/CLIs offered when opening a new panel. Only the installed
|
||||
// ones are surfaced (probed via whichAgents); "shell" and "custom…" are always
|
||||
// available. Keep this list as the single source of truth — Wizard and
|
||||
// SurfacePicker both consume it.
|
||||
export const KNOWN_AGENTS = ["claude", "codex", "gemini", "deepseek", "opencode"];
|
||||
export const SHELL = "shell";
|
||||
export const CUSTOM = "custom…";
|
||||
|
||||
/** Human label for an agent choice (the shell is presented as "Terminal"). */
|
||||
export function agentLabel(choice: string): string {
|
||||
return choice === SHELL ? "Terminal" : choice;
|
||||
}
|
||||
|
||||
/** Map a picker choice (+ optional custom command line) to an applyPreset slot spec. */
|
||||
export function specForChoice(choice: string, custom: string): { command?: string; args?: string[] } {
|
||||
if (choice === SHELL) return {};
|
||||
if (choice === CUSTOM) {
|
||||
const parts = (custom ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
return parts.length ? { command: parts[0], args: parts.slice(1) } : {};
|
||||
}
|
||||
return { command: choice };
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,118 @@
|
||||
// GUI keyboard shortcuts. Pure front-end concern (the daemon/CLI don't use these),
|
||||
// so bindings live in localStorage and are user-rebindable from Settings.
|
||||
//
|
||||
// All defaults use ⌘ so they never collide with characters typed into a terminal
|
||||
// (xterm receives plain keys; ⌘-combos are app shortcuts). The central handler in
|
||||
// App.tsx requires at least one modifier before it will swallow a key.
|
||||
|
||||
export type HotkeyId =
|
||||
| "newWorkspace"
|
||||
| "openSettings"
|
||||
| "toggleSidebar"
|
||||
| "toggleEvents"
|
||||
| "splitRight"
|
||||
| "splitDown"
|
||||
| "closePanel"
|
||||
| "focusNext"
|
||||
| "focusPrev"
|
||||
| "zoomToggle"
|
||||
| "search";
|
||||
|
||||
export interface Binding {
|
||||
meta?: boolean;
|
||||
ctrl?: boolean;
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
key: string; // normalized via normKey()
|
||||
}
|
||||
|
||||
export interface HotkeyDef {
|
||||
id: HotkeyId;
|
||||
label: string;
|
||||
group: "Workspace" | "Panel";
|
||||
def: Binding;
|
||||
}
|
||||
|
||||
export const HOTKEYS: HotkeyDef[] = [
|
||||
{ id: "newWorkspace", label: "New workspace", group: "Workspace", def: { meta: true, key: "n" } },
|
||||
{ id: "openSettings", label: "Open settings", group: "Workspace", def: { meta: true, key: "," } },
|
||||
{ id: "toggleSidebar", label: "Toggle sidebar", group: "Workspace", def: { meta: true, key: "b" } },
|
||||
{ id: "toggleEvents", label: "Toggle event center", group: "Workspace", def: { meta: true, key: "e" } },
|
||||
{ id: "splitRight", label: "Split right", group: "Panel", def: { meta: true, key: "d" } },
|
||||
{ id: "splitDown", label: "Split down", group: "Panel", def: { meta: true, shift: true, key: "d" } },
|
||||
{ id: "closePanel", label: "Close panel", group: "Panel", def: { meta: true, key: "w" } },
|
||||
{ id: "focusNext", label: "Focus next panel", group: "Panel", def: { meta: true, key: "]" } },
|
||||
{ id: "focusPrev", label: "Focus previous panel", group: "Panel", def: { meta: true, key: "[" } },
|
||||
{ id: "zoomToggle", label: "Toggle zoom", group: "Panel", def: { meta: true, key: "Enter" } },
|
||||
{ id: "search", label: "Search scrollback", group: "Panel", def: { meta: true, key: "f" } },
|
||||
];
|
||||
|
||||
const STORE_KEY = "spacesh.hotkeys";
|
||||
|
||||
export type Bindings = Record<HotkeyId, Binding>;
|
||||
|
||||
export function defaultBindings(): Bindings {
|
||||
const out = {} as Bindings;
|
||||
for (const h of HOTKEYS) out[h.id] = h.def;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function loadBindings(): Bindings {
|
||||
const out = defaultBindings();
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_KEY);
|
||||
if (raw) {
|
||||
const o = JSON.parse(raw) as Partial<Bindings>;
|
||||
for (const h of HOTKEYS) if (o[h.id]) out[h.id] = o[h.id]!;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function saveBindings(b: Bindings): void {
|
||||
try { localStorage.setItem(STORE_KEY, JSON.stringify(b)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Stable key token: single chars lowercased, named keys kept verbatim. */
|
||||
export function normKey(k: string): string {
|
||||
if (k === " ") return "Space";
|
||||
return k.length === 1 ? k.toLowerCase() : k;
|
||||
}
|
||||
|
||||
const MOD_KEYS = new Set(["Meta", "Control", "Alt", "Shift"]);
|
||||
|
||||
/** Capture a binding from a keydown event (null while only modifiers are held). */
|
||||
export function eventBinding(e: KeyboardEvent): Binding | null {
|
||||
if (MOD_KEYS.has(e.key)) return null;
|
||||
return { meta: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, key: normKey(e.key) };
|
||||
}
|
||||
|
||||
export function matches(b: Binding, e: KeyboardEvent): boolean {
|
||||
return (
|
||||
!!b.meta === e.metaKey &&
|
||||
!!b.ctrl === e.ctrlKey &&
|
||||
!!b.alt === e.altKey &&
|
||||
!!b.shift === e.shiftKey &&
|
||||
b.key === normKey(e.key)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasModifier(b: Binding): boolean {
|
||||
return !!(b.meta || b.ctrl || b.alt);
|
||||
}
|
||||
|
||||
/** Human-readable chord, e.g. "⌘⇧D". */
|
||||
export function formatBinding(b: Binding): string {
|
||||
const parts: string[] = [];
|
||||
if (b.ctrl) parts.push("⌃");
|
||||
if (b.alt) parts.push("⌥");
|
||||
if (b.shift) parts.push("⇧");
|
||||
if (b.meta) parts.push("⌘");
|
||||
const key =
|
||||
b.key === "Enter" ? "⏎" :
|
||||
b.key === "Space" ? "␣" :
|
||||
b.key === "ArrowUp" ? "↑" : b.key === "ArrowDown" ? "↓" :
|
||||
b.key === "ArrowLeft" ? "←" : b.key === "ArrowRight" ? "→" :
|
||||
b.key.length === 1 ? b.key.toUpperCase() : b.key;
|
||||
return parts.join("") + key;
|
||||
}
|
||||
+37
-3
@@ -42,6 +42,9 @@ export interface AttachResult {
|
||||
snapshot: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursor_row?: number;
|
||||
cursor_col?: number;
|
||||
stopped?: boolean;
|
||||
}
|
||||
|
||||
export async function attachSurface(
|
||||
@@ -147,8 +150,8 @@ export async function applyPreset(workspaceId: string, presetId: string, slots:
|
||||
return data.surface_ids;
|
||||
}
|
||||
|
||||
export async function restartSurface(surfaceId: string): Promise<void> {
|
||||
await invoke("restart_surface", { surfaceId });
|
||||
export async function restartSurface(surfaceId: string, resume = false): Promise<void> {
|
||||
await invoke("restart_surface", { surfaceId, resume });
|
||||
}
|
||||
|
||||
export async function closeWorkspaceCmd(workspaceId: string): Promise<void> {
|
||||
@@ -199,6 +202,26 @@ export async function openExternal(url: string): Promise<void> {
|
||||
await invoke("open_external", { url });
|
||||
}
|
||||
|
||||
export async function listFonts(): Promise<string[]> {
|
||||
return await invoke<string[]>("list_fonts");
|
||||
}
|
||||
|
||||
/** Whether spaceshell.app has Full Disk Access (terminals inherit its TCC grants). */
|
||||
export async function hasFullDiskAccess(): Promise<boolean> {
|
||||
try { return await invoke<boolean>("has_full_disk_access"); } catch { return true; }
|
||||
}
|
||||
|
||||
/** Deep-link System Settings → Privacy & Security → Full Disk Access. */
|
||||
export async function openFullDiskAccessSettings(): Promise<void> {
|
||||
try { await invoke("open_full_disk_access_settings"); } catch { /* settings pane unavailable */ }
|
||||
}
|
||||
|
||||
/** Which of the given CLI candidates are actually installed on the daemon's spawn PATH. */
|
||||
export async function whichAgents(candidates: string[]): Promise<string[]> {
|
||||
const data = await invoke<{ available: string[] }>("which_agents", { candidates });
|
||||
return data.available;
|
||||
}
|
||||
|
||||
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
|
||||
await invoke("set_zoom", { workspaceId, surfaceId });
|
||||
}
|
||||
@@ -211,22 +234,33 @@ export interface ConfigView {
|
||||
font_size: number;
|
||||
theme: "dark" | "light";
|
||||
accent: string;
|
||||
background: string;
|
||||
background_image: string;
|
||||
log_shell_commands: boolean;
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<ConfigView> {
|
||||
return await invoke<ConfigView>("get_config");
|
||||
}
|
||||
|
||||
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent">>): Promise<void> {
|
||||
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent" | "background" | "background_image" | "log_shell_commands">>): Promise<void> {
|
||||
await invoke("set_config", {
|
||||
defaultShell: patch.default_shell ?? null,
|
||||
fontFamily: patch.font_family ?? null,
|
||||
fontSize: patch.font_size ?? null,
|
||||
theme: patch.theme ?? null,
|
||||
accent: patch.accent ?? null,
|
||||
background: patch.background ?? null,
|
||||
backgroundImage: patch.background_image ?? null,
|
||||
logShellCommands: patch.log_shell_commands ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a local image file into a `data:` URL for use as a CSS background. */
|
||||
export async function readImageDataUrl(path: string): Promise<string> {
|
||||
return await invoke<string>("read_image_data_url", { path });
|
||||
}
|
||||
|
||||
export async function shutdownDaemon(): Promise<void> {
|
||||
try { await invoke("shutdown_daemon"); } catch { /* connection drops as the daemon exits — expected */ }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/* Nerd Font symbols (icons, powerline, devicons) used as a fallback in the
|
||||
terminal so glyphs in the Private Use Area render instead of blank boxes.
|
||||
Base monospace font is untouched; this only fills missing glyphs. */
|
||||
@font-face {
|
||||
font-family: "Symbols Nerd Font Mono";
|
||||
src: url("./assets/SymbolsNerdFontMono-Regular.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
+95
-7
@@ -7,6 +7,11 @@ export const COLORS = {
|
||||
bgElevated: "var(--c-bg-elevated)",
|
||||
bgHover: "var(--c-bg-hover)",
|
||||
bgPanel: "var(--c-bg-panel)",
|
||||
panelGlass: "var(--c-panel-glass, var(--c-bg-panel))",
|
||||
panelBlur: "var(--c-panel-blur, none)",
|
||||
appBg: "var(--app-bg, var(--c-bg-app))",
|
||||
elevatedGlass: "var(--c-elevated-glass, var(--c-bg-elevated))",
|
||||
sidebarGlass: "var(--c-sidebar-glass, var(--c-bg-sidebar))",
|
||||
bgSidebar: "var(--c-bg-sidebar)",
|
||||
borderStrong: "var(--c-border-strong)",
|
||||
borderSubtle: "var(--c-border-subtle)",
|
||||
@@ -92,15 +97,98 @@ export const ACCENTS: Record<string, string> = {
|
||||
|
||||
export type ThemeName = "dark" | "light";
|
||||
|
||||
/** Real color values for consumers that can't use var() (xterm). Keys are the kebab tokens plus "accent". */
|
||||
export function resolvePalette(theme: ThemeName, accent: string): Record<string, string> {
|
||||
const base = theme === "light" ? LIGHT : DARK;
|
||||
return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background themes (Warp-style full-window fills)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A background theme paints the WHOLE window behind every panel, instead of each
|
||||
* terminal owning an opaque tile. Panels become semi-transparent glass (rgba over
|
||||
* a backdrop blur) so the shared fill shows through uniformly across the grid.
|
||||
*
|
||||
* `css` is the CSS `background` value for the app root: a gradient, or `""` for
|
||||
* the classic solid look ("none"), or the sentinel "custom" handled at apply time
|
||||
* via a user-supplied image. `panelAlpha`/`blur` tune the glass over the fill.
|
||||
*/
|
||||
export interface BackgroundTheme {
|
||||
label: string;
|
||||
css: string; // app-root `background` value ("" = solid bg-app)
|
||||
swatch: string; // gallery preview (gradient/color)
|
||||
panelAlpha: number; // 0..1 — panel glass opacity over the fill
|
||||
blur: number; // px — panel backdrop blur
|
||||
}
|
||||
|
||||
/** Write the active palette to :root as --c-* custom properties. */
|
||||
export function applyTheme(theme: ThemeName, accent: string): void {
|
||||
const p = resolvePalette(theme, accent);
|
||||
export const CUSTOM_BACKGROUND = "custom";
|
||||
|
||||
export const BACKGROUNDS: Record<string, BackgroundTheme> = {
|
||||
none: { label: "None", css: "", swatch: DARK["bg-panel"], panelAlpha: 1, blur: 0 },
|
||||
cyberwave: { label: "Cyber Wave", css: "linear-gradient(135deg,#06121f 0%,#0a2a3f 45%,#10183a 100%)", swatch: "linear-gradient(135deg,#06121f,#0a2a3f,#10183a)", panelAlpha: 0.46, blur: 9 },
|
||||
phenomenon: { label: "Phenomenon", css: "radial-gradient(120% 120% at 80% 0%,#241a2e 0%,#15121d 45%,#0a0910 100%)", swatch: "radial-gradient(120% 120% at 80% 0%,#241a2e,#15121d,#0a0910)", panelAlpha: 0.5, blur: 8 },
|
||||
dracula: { label: "Dracula", css: "linear-gradient(160deg,#282a36 0%,#21222c 60%,#1a1b23 100%)", swatch: "linear-gradient(160deg,#282a36,#21222c,#1a1b23)", panelAlpha: 0.58, blur: 6 },
|
||||
aurora: { label: "Aurora", css: "linear-gradient(135deg,#0b3d2e 0%,#0a2c3a 40%,#241147 100%)", swatch: "linear-gradient(135deg,#0b3d2e,#0a2c3a,#241147)", panelAlpha: 0.44, blur: 10 },
|
||||
ember: { label: "Ember", css: "linear-gradient(135deg,#2a0f12 0%,#3a1410 45%,#160a14 100%)", swatch: "linear-gradient(135deg,#2a0f12,#3a1410,#160a14)", panelAlpha: 0.5, blur: 8 },
|
||||
referred: { label: "Referred", css: "linear-gradient(120deg,#b9c6ff 0%,#cdb6ff 40%,#ffd6e7 100%)", swatch: "linear-gradient(120deg,#b9c6ff,#cdb6ff,#ffd6e7)", panelAlpha: 0.34, blur: 12 },
|
||||
};
|
||||
|
||||
/** Resolve a background name to its theme, falling back to "none". */
|
||||
export function backgroundFor(name: string): BackgroundTheme {
|
||||
return BACKGROUNDS[name] ?? BACKGROUNDS.none;
|
||||
}
|
||||
|
||||
/** Hex (#rrggbb) → rgba() string at the given alpha. */
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const h = hex.replace("#", "");
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real color values for consumers that can't use var() (xterm). Keys are the
|
||||
* kebab tokens plus "accent" and "term-bg". When a background theme is active the
|
||||
* terminal renders on transparent glass, so "term-bg" is fully transparent and
|
||||
* the panel container supplies the visible tint.
|
||||
*/
|
||||
export function resolvePalette(theme: ThemeName, accent: string, background: string = "none"): Record<string, string> {
|
||||
const base = theme === "light" ? LIGHT : DARK;
|
||||
const active = background !== "none";
|
||||
return {
|
||||
...base,
|
||||
accent: ACCENTS[accent] ?? ACCENTS.blue,
|
||||
"term-bg": active ? "rgba(0,0,0,0)" : base["bg-panel"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the active palette + background fill to :root as --c-* custom properties.
|
||||
* `imageDataUrl` is only consulted when `background === "custom"`.
|
||||
*/
|
||||
export function applyTheme(theme: ThemeName, accent: string, background: string = "none", imageDataUrl: string | null = null): void {
|
||||
const p = resolvePalette(theme, accent, background);
|
||||
const root = document.documentElement.style;
|
||||
for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
|
||||
|
||||
const bg = backgroundFor(background);
|
||||
const base = theme === "light" ? LIGHT : DARK;
|
||||
const active = background !== "none";
|
||||
|
||||
// App-root fill: custom image (cover) > gradient > solid bg-app.
|
||||
const appBg = background === CUSTOM_BACKGROUND && imageDataUrl
|
||||
? `center / cover no-repeat url("${imageDataUrl}")`
|
||||
: bg.css || base["bg-app"];
|
||||
root.setProperty("--app-bg", appBg);
|
||||
|
||||
// Panel glass: rgba(bg-panel, alpha) over the fill, plus optional backdrop blur.
|
||||
// When inactive this is the solid bg-panel so the classic look is byte-identical.
|
||||
const alpha = active ? (background === CUSTOM_BACKGROUND ? 0.5 : bg.panelAlpha) : 1;
|
||||
const blur = active ? (background === CUSTOM_BACKGROUND ? 8 : bg.blur) : 0;
|
||||
root.setProperty("--c-panel-glass", alpha < 1 ? hexToRgba(base["bg-panel"], alpha) : base["bg-panel"]);
|
||||
root.setProperty("--c-panel-blur", blur > 0 ? `blur(${blur}px)` : "none");
|
||||
|
||||
// Chrome glass (TopBar / toolbar / sidebar / panel headers) — a touch more
|
||||
// opaque than the panels so labels and controls stay legible over the fill.
|
||||
const chromeAlpha = active ? Math.min(alpha + 0.22, 0.92) : 1;
|
||||
root.setProperty("--c-elevated-glass", chromeAlpha < 1 ? hexToRgba(base["bg-elevated"], chromeAlpha) : base["bg-elevated"]);
|
||||
root.setProperty("--c-sidebar-glass", chromeAlpha < 1 ? hexToRgba(base["bg-sidebar"], chromeAlpha) : base["bg-sidebar"]);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ pub enum Sub {
|
||||
},
|
||||
Close { surface_id: String },
|
||||
Focus { surface_id: String },
|
||||
Restart { surface_id: String },
|
||||
Restart {
|
||||
surface_id: String,
|
||||
/// Relaunch the agent with its session-continue flag (e.g. claude --continue).
|
||||
#[arg(long)] resume: bool,
|
||||
},
|
||||
Notify {
|
||||
#[arg(long)] surface: String,
|
||||
#[arg(long, value_enum)] state: StateArg,
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn to_cmd(sub: Sub) -> Cmd {
|
||||
},
|
||||
Sub::Close { surface_id } => Cmd::Close { surface_id: SurfaceId(surface_id) },
|
||||
Sub::Focus { surface_id } => Cmd::Focus { surface_id: SurfaceId(surface_id) },
|
||||
Sub::Restart { surface_id } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id) },
|
||||
Sub::Restart { surface_id, resume } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume },
|
||||
Sub::Notify { surface, state } => Cmd::SetState { surface_id: SurfaceId(surface), state: state_of(state) },
|
||||
Sub::ApplyPreset { workspace_id, preset, agents } => Cmd::ApplyPreset {
|
||||
workspace_id: WorkspaceId(workspace_id),
|
||||
|
||||
@@ -7,3 +7,6 @@ version.workspace = true
|
||||
alacritty_terminal.workspace = true
|
||||
serde.workspace = true
|
||||
spacesh-proto = { path = "../spacesh-proto" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use alacritty_terminal::event::VoidListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use alacritty_terminal::event::{Event, EventListener};
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Column, Line, Point};
|
||||
use alacritty_terminal::term::{Config, Term};
|
||||
use alacritty_terminal::vte::ansi::Processor;
|
||||
use alacritty_terminal::vte::ansi::{NamedColor, Processor, Rgb};
|
||||
|
||||
/// Fixed-size terminal dimensions for the daemon-side grid.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -23,24 +25,94 @@ impl Dimensions for GridSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// One escape sequence the terminal model wants written back to the PTY in
|
||||
/// response to a query: either a ready-made byte reply (DA/DSR/etc.) or a color
|
||||
/// report whose value must be resolved from the term's palette at drain time.
|
||||
enum Reply {
|
||||
Bytes(Vec<u8>),
|
||||
Color(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
|
||||
}
|
||||
|
||||
/// Collects the escape sequences the terminal model wants written back to the PTY
|
||||
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, OSC color
|
||||
/// queries, etc.). Programs like fish, yazi and vim block on these replies at
|
||||
/// startup; with a void listener they hang ~2s and then warn ("could not read
|
||||
/// response to Primary Device Attribute query") or render with the wrong theme.
|
||||
///
|
||||
/// The daemon is the authoritative responder for the PTY — the GUI's xterm.js is
|
||||
/// display-only and must NOT echo its own replies back (its duplicate arrives an
|
||||
/// IPC roundtrip late and gets typed into the shell as literal gibberish).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ReplyCollector {
|
||||
buf: Arc<Mutex<Vec<Reply>>>,
|
||||
}
|
||||
|
||||
impl EventListener for ReplyCollector {
|
||||
fn send_event(&self, event: Event) {
|
||||
let reply = match event {
|
||||
Event::PtyWrite(text) => Reply::Bytes(text.into_bytes()),
|
||||
// OSC 10/11/12 color query — alacritty defers the value to the embedder
|
||||
// (us) via a formatter; resolve it against the palette in take_replies.
|
||||
Event::ColorRequest(index, fmt) => Reply::Color(index, fmt),
|
||||
_ => return,
|
||||
};
|
||||
if let Ok(mut b) = self.buf.lock() {
|
||||
b.push(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback palette colors when a program queries one the term has not had set
|
||||
/// explicitly. Matches the GUI's default theme so OSC 11 (background) reports a
|
||||
/// dark color and light/dark detection in TUIs stays correct.
|
||||
fn default_color(index: usize) -> Rgb {
|
||||
if index == NamedColor::Background as usize { Rgb { r: 0x0a, g: 0x0d, b: 0x12 } }
|
||||
else if index == NamedColor::Foreground as usize { Rgb { r: 0xe6, g: 0xed, b: 0xf3 } }
|
||||
else if index == NamedColor::Cursor as usize { Rgb { r: 0xe6, g: 0xed, b: 0xf3 } }
|
||||
else { Rgb { r: 0x80, g: 0x80, b: 0x80 } }
|
||||
}
|
||||
|
||||
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
||||
pub struct GridSurface {
|
||||
term: Term<VoidListener>,
|
||||
term: Term<ReplyCollector>,
|
||||
parser: Processor,
|
||||
size: GridSize,
|
||||
replies: ReplyCollector,
|
||||
}
|
||||
|
||||
impl GridSurface {
|
||||
pub fn new(cols: u16, rows: u16) -> Self {
|
||||
let size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||
let term = Term::new(Config::default(), &size, VoidListener);
|
||||
Self { term, parser: Processor::new(), size }
|
||||
let replies = ReplyCollector::default();
|
||||
let term = Term::new(Config::default(), &size, replies.clone());
|
||||
Self { term, parser: Processor::new(), size, replies }
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.parser.advance(&mut self.term, bytes);
|
||||
}
|
||||
|
||||
/// Drain any escape sequences the model produced in response to queries fed so
|
||||
/// far. The caller must write these back to the PTY for query-driven programs
|
||||
/// (fish, vim, etc.) to proceed without timing out.
|
||||
pub fn take_replies(&mut self) -> Vec<u8> {
|
||||
let replies = {
|
||||
let Ok(mut b) = self.replies.buf.lock() else { return Vec::new(); };
|
||||
std::mem::take(&mut *b)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for reply in replies {
|
||||
match reply {
|
||||
Reply::Bytes(bytes) => out.extend_from_slice(&bytes),
|
||||
Reply::Color(index, fmt) => {
|
||||
let rgb = self.term.colors()[index].unwrap_or_else(|| default_color(index));
|
||||
out.extend_from_slice(fmt(rgb).as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||
self.term.resize(self.size);
|
||||
@@ -56,7 +128,7 @@ impl GridSurface {
|
||||
self.term.grid()[point].c
|
||||
}
|
||||
|
||||
pub fn term(&self) -> &Term<VoidListener> {
|
||||
pub fn term(&self) -> &Term<ReplyCollector> {
|
||||
&self.term
|
||||
}
|
||||
|
||||
@@ -97,4 +169,26 @@ mod tests {
|
||||
assert_eq!(g.char_at(0, 0), 'a');
|
||||
assert_eq!(g.char_at(1, 0), 'c');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_device_attribute_query_gets_a_reply() {
|
||||
// fish (and friends) send ESC[c at startup and block on the response.
|
||||
let mut g = GridSurface::new(20, 5);
|
||||
g.feed(b"\x1b[c");
|
||||
let reply = g.take_replies();
|
||||
assert!(reply.starts_with(b"\x1b[?"), "expected a DA1 reply, got {reply:?}");
|
||||
// Replies are drained, not duplicated.
|
||||
assert!(g.take_replies().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_background_color_query_gets_a_reply() {
|
||||
// yazi/vim send OSC 11 ("\x1b]11;?\x07") to detect the background color and
|
||||
// block on the reply; the daemon must answer it authoritatively.
|
||||
let mut g = GridSurface::new(20, 5);
|
||||
g.feed(b"\x1b]11;?\x07");
|
||||
let reply = String::from_utf8(g.take_replies()).unwrap();
|
||||
assert!(reply.starts_with("\x1b]11;rgb:"), "expected an OSC 11 reply, got {reply:?}");
|
||||
assert!(g.take_replies().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,32 @@ pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option<LayoutNode> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop duplicate leaves, keeping the first (left-to-right) occurrence of each
|
||||
/// surface id; collapses now-single-child splits. Returns None if empty.
|
||||
///
|
||||
/// Heals a tree corrupted by a duplicate surface id (e.g. an id re-minted after
|
||||
/// a daemon restart before the counter fix), which otherwise renders the same
|
||||
/// panel twice and confuses focus/search/output routing keyed by surface id.
|
||||
pub fn dedupe_leaves(root: LayoutNode) -> Option<LayoutNode> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dedupe(root, &mut seen)
|
||||
}
|
||||
fn dedupe(node: LayoutNode, seen: &mut std::collections::HashSet<SurfaceId>) -> Option<LayoutNode> {
|
||||
match node {
|
||||
LayoutNode::Leaf { surface_id } => {
|
||||
if seen.insert(surface_id.clone()) { Some(LayoutNode::Leaf { surface_id }) } else { None }
|
||||
}
|
||||
LayoutNode::Split { orient, children, .. } => {
|
||||
let kept: Vec<LayoutNode> = children.into_iter().filter_map(|c| dedupe(c, seen)).collect();
|
||||
match kept.len() {
|
||||
0 => None,
|
||||
1 => Some(kept.into_iter().next().unwrap()),
|
||||
n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set ratios on the split node addressed by `path` (child indices from root).
|
||||
/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the
|
||||
/// path is invalid or the length does not match the node's child count.
|
||||
@@ -247,6 +273,27 @@ mod tests {
|
||||
assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_removes_duplicate_leaf_keeping_first() {
|
||||
// s_1 appears twice (the production corruption): heal to one occurrence.
|
||||
let root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![1.0/3.0; 3],
|
||||
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_1"))],
|
||||
};
|
||||
let healed = dedupe_leaves(root).unwrap();
|
||||
assert_eq!(leaves(&healed), vec![sid("s_0"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_clean_tree_is_unchanged() {
|
||||
let root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1"))],
|
||||
};
|
||||
let out = dedupe_leaves(root.clone()).unwrap();
|
||||
assert_eq!(leaves(&out), vec![sid("s_0"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_onto_self_is_noop() {
|
||||
let root = LayoutNode::leaf(sid("s_1"));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use alacritty_terminal::index::Point;
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use alacritty_terminal::vte::ansi::Color;
|
||||
use crate::grid::GridSurface;
|
||||
|
||||
/// Serializable snapshot returned by `attach`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
/// ANSI byte dump suitable for `xterm.write()`.
|
||||
pub ansi: String,
|
||||
@@ -120,6 +120,18 @@ mod tests {
|
||||
assert_eq!(a.rows, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trips_through_json() {
|
||||
let mut g = GridSurface::new(20, 4);
|
||||
g.feed(b"hello");
|
||||
let snap = snapshot_ansi(&g);
|
||||
let json = serde_json::to_string(&snap).unwrap();
|
||||
let back: Snapshot = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.ansi, snap.ansi);
|
||||
assert_eq!((back.cols, back.rows), (snap.cols, snap.rows));
|
||||
assert_eq!((back.cursor_row, back.cursor_col), (snap.cursor_row, snap.cursor_col));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_is_one_based_after_input() {
|
||||
let mut g = GridSurface::new(10, 3);
|
||||
|
||||
@@ -8,6 +8,19 @@ pub struct ConfigView {
|
||||
pub font_size: u16,
|
||||
pub theme: String,
|
||||
pub accent: String,
|
||||
/// Background-theme name (Warp-style full-window fill). "none" = solid.
|
||||
#[serde(default = "default_background")]
|
||||
pub background: String,
|
||||
/// Absolute path to a custom background image (used when background == "custom").
|
||||
#[serde(default)]
|
||||
pub background_image: String,
|
||||
/// Whether shell-command status (OSC 133) is logged; agent activity always is.
|
||||
#[serde(default)]
|
||||
pub log_shell_commands: bool,
|
||||
}
|
||||
|
||||
fn default_background() -> String {
|
||||
"none".into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -18,6 +31,8 @@ mod tests {
|
||||
let c = ConfigView {
|
||||
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
|
||||
font_size: 13, theme: "dark".into(), accent: "blue".into(),
|
||||
background: "none".into(), background_image: String::new(),
|
||||
log_shell_commands: false,
|
||||
};
|
||||
let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
||||
assert_eq!(back, c);
|
||||
|
||||
@@ -92,7 +92,11 @@ pub enum Cmd {
|
||||
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
|
||||
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
|
||||
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
|
||||
RestartSurface { surface_id: SurfaceId },
|
||||
RestartSurface {
|
||||
surface_id: SurfaceId,
|
||||
#[serde(default)]
|
||||
resume: bool,
|
||||
},
|
||||
CloseWorkspace { workspace_id: WorkspaceId },
|
||||
SetWorkspaceMeta {
|
||||
workspace_id: WorkspaceId,
|
||||
@@ -131,6 +135,8 @@ pub enum Cmd {
|
||||
surface_id: Option<SurfaceId>,
|
||||
},
|
||||
Health,
|
||||
/// Which of the given CLI candidates are actually installed on the spawn PATH.
|
||||
WhichAgents { candidates: Vec<String> },
|
||||
Status,
|
||||
Shutdown,
|
||||
GetConfig,
|
||||
@@ -145,6 +151,12 @@ pub enum Cmd {
|
||||
theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
accent: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
background: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
background_image: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
log_shell_commands: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -366,6 +378,21 @@ mod tests {
|
||||
assert_eq!(back, env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_surface_resume_defaults_false_and_round_trips() {
|
||||
// Legacy frame without `resume` decodes to false.
|
||||
let legacy = r#"{"kind":"req","id":5,"cmd":{"cmd":"restart_surface","args":{"surface_id":"s_1"}}}"#;
|
||||
let env: Envelope = serde_json::from_str(legacy).unwrap();
|
||||
match env {
|
||||
Envelope::Req { cmd: Cmd::RestartSurface { resume, .. }, .. } => assert!(!resume),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
// resume=true round-trips.
|
||||
let e = Envelope::Req { id: 6, cmd: Cmd::RestartSurface { surface_id: SurfaceId("s_1".into()), resume: true } };
|
||||
let back: Envelope = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
|
||||
assert_eq!(back, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_log_cmd_no_limit_round_trips() {
|
||||
let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } };
|
||||
|
||||
@@ -48,6 +48,14 @@ impl PtyHandle {
|
||||
if !spec.env.iter().any(|(k, _)| k == "COLORTERM") {
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
}
|
||||
// Guarantee a UTF-8 locale. A GUI/launchd-launched daemon often has no LANG,
|
||||
// so a directly-spawned agent (e.g. `claude`, no shell to set it) renders
|
||||
// wide/box-drawing glyphs as mojibake — visible in Claude Code's usage bar.
|
||||
// An interactive shell sets LANG in its init, which is why it looks fine there.
|
||||
// Respect any inherited or caller-provided value.
|
||||
if !spec.env.iter().any(|(k, _)| k == "LANG") && std::env::var_os("LANG").is_none() {
|
||||
cmd.env("LANG", "en_US.UTF-8");
|
||||
}
|
||||
for (k, v) in &spec.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,26 @@ pub struct AppearanceConfig {
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub accent: Option<String>,
|
||||
/// Background-theme name (Warp-style full-window fill). "none" = solid.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
/// Absolute path to a custom background image (used when background == "custom").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_image: Option<String>,
|
||||
}
|
||||
|
||||
/// Built-in resume args for known agents, used when config has no override.
|
||||
/// (command basename, resume args)
|
||||
const DEFAULT_RESUME: &[(&str, &[&str])] = &[
|
||||
("claude", &["--continue"]),
|
||||
("codex", &["resume"]),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ResumeConfig {
|
||||
/// command basename -> args that continue its previous session.
|
||||
#[serde(default)]
|
||||
pub commands: std::collections::HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -32,6 +52,15 @@ pub struct Config {
|
||||
pub terminal: TerminalConfig,
|
||||
#[serde(default)]
|
||||
pub appearance: AppearanceConfig,
|
||||
#[serde(default)]
|
||||
pub resume: ResumeConfig,
|
||||
/// How often (seconds) the daemon dumps changed grids to disk.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot_interval_secs: Option<u64>,
|
||||
/// Log/notify shell-command status (OSC 133 / fallback) in plain panels.
|
||||
/// Off by default — only agent activity (claude/codex/… hooks) is logged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_shell_commands: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -43,9 +72,17 @@ impl Config {
|
||||
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
|
||||
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
|
||||
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
|
||||
background: self.appearance.background.clone().unwrap_or_else(|| "none".into()),
|
||||
background_image: self.appearance.background_image.clone().unwrap_or_default(),
|
||||
log_shell_commands: self.log_shell_commands(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether shell-command status events are logged (default false).
|
||||
pub fn log_shell_commands(&self) -> bool {
|
||||
self.log_shell_commands.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shell for a plain panel using THIS in-memory config
|
||||
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
|
||||
pub fn resolved_shell(&self) -> String {
|
||||
@@ -85,6 +122,25 @@ impl Config {
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
std::fs::write(path, s)
|
||||
}
|
||||
|
||||
/// Resume args for a command, by basename: user map → built-in default → None.
|
||||
pub fn resume_args(&self, command: &str) -> Option<Vec<String>> {
|
||||
let base = std::path::Path::new(command)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| command.to_string());
|
||||
if let Some(args) = self.resume.commands.get(&base) {
|
||||
return Some(args.clone());
|
||||
}
|
||||
DEFAULT_RESUME.iter()
|
||||
.find(|(name, _)| *name == base)
|
||||
.map(|(_, args)| args.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
/// Snapshot dump cadence in seconds (config → default 5, clamped to [1, 3600]).
|
||||
pub fn snapshot_interval_secs(&self) -> u64 {
|
||||
self.snapshot_interval_secs.unwrap_or(5).clamp(1, 3600)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the shell to spawn for a plain panel.
|
||||
@@ -110,6 +166,98 @@ pub fn default_shell() -> String {
|
||||
"/bin/sh".into()
|
||||
}
|
||||
|
||||
/// The user's full PATH for spawning panels: their login-shell PATH (which sources
|
||||
/// `.zprofile`/`.zshrc`), merged with the daemon's current PATH and common install
|
||||
/// dirs. Cached for the daemon's lifetime.
|
||||
///
|
||||
/// Why: when the GUI launches the daemon (Finder/launchd), the inherited PATH is
|
||||
/// minimal (`/usr/bin:/bin:…`), so agents like `claude`, `codex`, `gemini` — installed
|
||||
/// in `~/.local/bin`, npm-global, or Homebrew — aren't found and the panel exits
|
||||
/// immediately with "Process exited". A bare `/bin/zsh` still works, which is why
|
||||
/// shells launched fine but agents didn't.
|
||||
pub fn enriched_path() -> String {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
static CACHE: OnceLock<String> = OnceLock::new();
|
||||
CACHE
|
||||
.get_or_init(|| {
|
||||
let mut dirs: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut merge = |src: &str| {
|
||||
for d in src.split(':') {
|
||||
if !d.is_empty() && seen.insert(d.to_string()) {
|
||||
dirs.push(d.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(p) = login_shell_path() {
|
||||
merge(&p);
|
||||
}
|
||||
if let Ok(p) = std::env::var("PATH") {
|
||||
merge(&p);
|
||||
}
|
||||
merge(&fallback_path_dirs());
|
||||
dirs.join(":")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Whether `cmd` is an executable resolvable on the spawn PATH (or an existing path
|
||||
/// if it contains a slash). Used to only offer agents the user actually has installed.
|
||||
pub fn is_installed(cmd: &str) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let is_exec = |p: &std::path::Path| {
|
||||
p.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
|
||||
};
|
||||
if cmd.contains('/') {
|
||||
return is_exec(std::path::Path::new(cmd));
|
||||
}
|
||||
enriched_path().split(':').any(|dir| !dir.is_empty() && is_exec(&std::path::Path::new(dir).join(cmd)))
|
||||
}
|
||||
|
||||
/// Common locations user-installed CLIs land in, as a colon-joined fallback.
|
||||
fn fallback_path_dirs() -> String {
|
||||
let mut v = vec![
|
||||
"/opt/homebrew/bin".to_string(),
|
||||
"/usr/local/bin".to_string(),
|
||||
"/usr/bin".to_string(),
|
||||
"/bin".to_string(),
|
||||
"/usr/sbin".to_string(),
|
||||
"/sbin".to_string(),
|
||||
];
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.is_empty() {
|
||||
for d in [".local/bin", ".npm-global/bin", ".cargo/bin", ".bun/bin", ".deno/bin", ".volta/bin", "go/bin"] {
|
||||
v.push(format!("{home}/{d}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
v.join(":")
|
||||
}
|
||||
|
||||
/// Capture PATH from the user's login+interactive shell so rc-file PATH edits apply.
|
||||
/// Parses `env` output (the exported PATH is colon-joined regardless of shell, so this
|
||||
/// works for fish too, where `$PATH` would otherwise print space-separated).
|
||||
#[cfg(unix)]
|
||||
fn login_shell_path() -> Option<String> {
|
||||
let shell = login_shell().or_else(|| std::env::var("SHELL").ok())?;
|
||||
let out = std::process::Command::new(&shell)
|
||||
.args(["-lic", "env"])
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("PATH="))
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn login_shell_path() -> Option<String> { None }
|
||||
|
||||
/// The current user's login shell from the passwd database (`getpwuid`).
|
||||
#[cfg(unix)]
|
||||
fn login_shell() -> Option<String> {
|
||||
@@ -206,4 +354,38 @@ mod tests {
|
||||
assert_eq!(back.appearance.accent.as_deref(), Some("purple"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_args_user_then_default_then_none() {
|
||||
let mut c = Config::default();
|
||||
// built-in defaults present without any config
|
||||
assert_eq!(c.resume_args("claude").as_deref(), Some(&["--continue".to_string()][..]));
|
||||
assert_eq!(c.resume_args("codex").as_deref(), Some(&["resume".to_string()][..]));
|
||||
// a path is reduced to its basename before lookup
|
||||
assert_eq!(c.resume_args("/usr/local/bin/claude").as_deref(), Some(&["--continue".to_string()][..]));
|
||||
// unknown command → None
|
||||
assert_eq!(c.resume_args("bash"), None);
|
||||
// user override wins over the default
|
||||
c.resume.commands.insert("claude".into(), vec!["--resume".into(), "last".into()]);
|
||||
assert_eq!(c.resume_args("claude"), Some(vec!["--resume".into(), "last".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_interval_defaults_to_5s() {
|
||||
let c = Config::default();
|
||||
assert_eq!(c.snapshot_interval_secs(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_resume_table_and_interval() {
|
||||
let dir = std::env::temp_dir().join(format!("spacesh-cfg-resume-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(&path,
|
||||
"snapshot_interval_secs = 10\n[resume.commands]\ngemini = [\"--resume\"]\n").unwrap();
|
||||
let c = Config::from_path(&path);
|
||||
assert_eq!(c.snapshot_interval_secs(), 10);
|
||||
assert_eq!(c.resume_args("gemini"), Some(vec!["--resume".into()]));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,35 +18,39 @@ fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf {
|
||||
home.join(".spacesh").join("hooks").join(&sid.0)
|
||||
}
|
||||
|
||||
/// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit
|
||||
/// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI.
|
||||
/// Our Stop/Notification/UserPromptSubmit hooks as a JSON value (the `hooks` object).
|
||||
fn our_hooks(spacesh_bin: &str) -> serde_json::Value {
|
||||
let entry = |state: &str| serde_json::json!({
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": format!("{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}")
|
||||
}]
|
||||
});
|
||||
serde_json::json!({
|
||||
"Stop": [entry("done")],
|
||||
"Notification": [entry("wait")],
|
||||
"UserPromptSubmit": [entry("work")],
|
||||
})
|
||||
}
|
||||
|
||||
/// Our hooks as a standalone settings JSON string. Passed to `claude` via the
|
||||
/// `--settings` flag so they layer ON TOP of the user's real config WITHOUT
|
||||
/// relocating CLAUDE_CONFIG_DIR — which is the whole point: claude only reads the
|
||||
/// macOS Keychain login (and onboarding state) for its DEFAULT config dir, so any
|
||||
/// override left the agent "Not logged in". `--settings` keeps the default dir.
|
||||
pub fn settings_json(spacesh_bin: &str) -> String {
|
||||
let line = |state: &str| {
|
||||
format!(
|
||||
"{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}",
|
||||
line("done"), line("wait"), line("work")
|
||||
)
|
||||
serde_json::to_string(&serde_json::json!({ "hooks": our_hooks(spacesh_bin) }))
|
||||
.unwrap_or_else(|_| "{\"hooks\":{}}".to_string())
|
||||
}
|
||||
|
||||
/// Prepare the per-surface hook config; return env pairs to merge into the spawn.
|
||||
/// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks).
|
||||
pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> {
|
||||
let Some(home) = dirs::home_dir() else { return vec![] };
|
||||
let dir = dir_for(&home, sid);
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
if std::fs::write(dir.join("settings.json"), settings_json(spacesh_bin)).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
vec![("CLAUDE_CONFIG_DIR".to_string(), dir.to_string_lossy().to_string())]
|
||||
/// Extra CLI args injecting our notify hooks into a spawned `claude`, leaving its
|
||||
/// default config dir (Keychain auth + onboarding) untouched.
|
||||
pub fn claude_settings_args(spacesh_bin: &str) -> Vec<String> {
|
||||
vec!["--settings".to_string(), settings_json(spacesh_bin)]
|
||||
}
|
||||
|
||||
/// Remove the per-surface hook dir (best-effort) on close.
|
||||
/// Remove the legacy per-surface hook dir (best-effort) on close. No longer
|
||||
/// written, but cleans up dirs left by older builds that used CLAUDE_CONFIG_DIR.
|
||||
pub fn cleanup(sid: &SurfaceId) {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let _ = std::fs::remove_dir_all(dir_for(&home, sid));
|
||||
@@ -129,15 +133,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_writes_config_and_cleanup_removes_it() {
|
||||
let sid = SurfaceId(format!("s_test_{}", std::process::id()));
|
||||
let env = prepare(&sid, "/abs/spacesh");
|
||||
assert_eq!(env.len(), 1);
|
||||
assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR");
|
||||
let dir = std::path::PathBuf::from(&env[0].1);
|
||||
assert!(dir.join("settings.json").exists());
|
||||
cleanup(&sid);
|
||||
assert!(!dir.exists());
|
||||
fn claude_settings_args_pass_hooks_via_settings_flag() {
|
||||
let args = claude_settings_args("/abs/spacesh");
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], "--settings");
|
||||
// The second arg is valid JSON carrying all three hook events.
|
||||
let v: serde_json::Value = serde_json::from_str(&args[1]).unwrap();
|
||||
assert!(v["hooks"]["Stop"].is_array());
|
||||
assert!(args[1].contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done"));
|
||||
assert!(args[1].contains("--state wait"));
|
||||
assert!(args[1].contains("--state work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,6 +7,7 @@ mod lifecycle;
|
||||
mod persist;
|
||||
mod registry;
|
||||
mod server;
|
||||
mod snapshot_store;
|
||||
mod state_store;
|
||||
mod surface;
|
||||
|
||||
@@ -60,6 +61,9 @@ async fn run_daemon() -> Result<()> {
|
||||
let events_path = lifecycle::spacesh_dir()?.join("events.json");
|
||||
let event_store: std::sync::Arc<dyn event_store::EventStore> =
|
||||
std::sync::Arc::new(event_store::JsonEventStore::new(events_path));
|
||||
let snapshots_dir = lifecycle::spacesh_dir()?.join("snapshots");
|
||||
let snapshot_store: std::sync::Arc<dyn snapshot_store::SnapshotStore> =
|
||||
std::sync::Arc::new(snapshot_store::JsonSnapshotStore::new(snapshots_dir));
|
||||
eprintln!("spaceshd listening on {}", sock.display());
|
||||
server::serve(&sock, store, event_store).await
|
||||
server::serve(&sock, store, event_store, snapshot_store).await
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct Registry {
|
||||
states: HashMap<SurfaceId, SurfaceState>,
|
||||
}
|
||||
|
||||
/// Parse the hex numeric suffix of an id (`"s_1f"` → `0x1f`). None if malformed.
|
||||
/// All ids are minted as `format!("{prefix}_{n:x}")`, so the suffix is hex.
|
||||
fn id_num(id: &str) -> Option<u64> {
|
||||
id.rsplit_once('_').and_then(|(_, hex)| u64::from_str_radix(hex, 16).ok())
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -114,6 +120,10 @@ impl Registry {
|
||||
pub fn is_running(&self, sid: &SurfaceId) -> bool {
|
||||
self.live.contains_key(sid)
|
||||
}
|
||||
/// Ids of all currently-live surfaces.
|
||||
pub fn live_ids(&self) -> Vec<SurfaceId> {
|
||||
self.live.keys().cloned().collect()
|
||||
}
|
||||
|
||||
// ---- surface state ----
|
||||
|
||||
@@ -189,8 +199,29 @@ impl Registry {
|
||||
self.by_path.clear();
|
||||
self.live.clear();
|
||||
self.states.clear();
|
||||
|
||||
// Advance the id counter past every restored id. The in-memory counter
|
||||
// resets to 0 on each daemon start; without this reseed, after a restart
|
||||
// `new_surface_id()` re-mints ids that already exist — producing duplicate
|
||||
// leaves in a workspace tree (same panel rendered twice, focus/search/
|
||||
// output routing keyed by surface id all break) and cross-workspace id
|
||||
// collisions.
|
||||
let mut max_id = 0u64;
|
||||
for gid in self.groups.keys() {
|
||||
if let Some(n) = id_num(&gid.0) { max_id = max_id.max(n + 1); }
|
||||
}
|
||||
for w in &state.workspaces {
|
||||
if let Some(n) = id_num(&w.id.0) { max_id = max_id.max(n + 1); }
|
||||
for sid in w.surfaces.keys() {
|
||||
if let Some(n) = id_num(&sid.0) { max_id = max_id.max(n + 1); }
|
||||
}
|
||||
}
|
||||
self.counter.store(max_id, Ordering::Relaxed);
|
||||
|
||||
for w in state.workspaces {
|
||||
let mut w = w;
|
||||
// Heal a tree already corrupted by a duplicate leaf (pre-fix state).
|
||||
w.layout = w.layout.take().and_then(spacesh_core::ops::dedupe_leaves);
|
||||
if let Some(z) = &w.zoomed {
|
||||
if !w.surfaces.contains_key(z) { w.zoomed = None; }
|
||||
}
|
||||
@@ -248,6 +279,52 @@ mod tests {
|
||||
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_advances_counter_past_existing_ids() {
|
||||
// After a daemon restart the counter must not re-mint a restored id.
|
||||
let mut r = Registry::new();
|
||||
let mut surfaces = HashMap::new();
|
||||
surfaces.insert(SurfaceId("s_5".into()), spec());
|
||||
let st = PersistState {
|
||||
version: 1, groups: vec![],
|
||||
workspaces: vec![Workspace {
|
||||
id: WorkspaceId("w_2".into()), path: "/p".into(), name: "p".into(),
|
||||
group_id: None, order: 0, unread: false, pinned: false,
|
||||
layout: Some(LN::leaf(SurfaceId("s_5".into()))), zoomed: None, surfaces,
|
||||
}],
|
||||
};
|
||||
r.restore(st);
|
||||
// max restored id is s_5 (hex 5) → next minted must be s_6, no collision.
|
||||
assert_eq!(r.new_surface_id(), SurfaceId("s_6".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_heals_duplicate_leaf() {
|
||||
// A persisted tree with s_1 twice (the production corruption) heals to one.
|
||||
let mut r = Registry::new();
|
||||
let mut surfaces = HashMap::new();
|
||||
surfaces.insert(SurfaceId("s_0".into()), spec());
|
||||
surfaces.insert(SurfaceId("s_1".into()), spec());
|
||||
let tree = LN::Split {
|
||||
orient: Orient::H, ratios: vec![1.0 / 3.0; 3],
|
||||
children: vec![LN::leaf(SurfaceId("s_0".into())), LN::leaf(SurfaceId("s_1".into())), LN::leaf(SurfaceId("s_1".into()))],
|
||||
};
|
||||
let st = PersistState {
|
||||
version: 1, groups: vec![],
|
||||
workspaces: vec![Workspace {
|
||||
id: WorkspaceId("w_0".into()), path: "/p".into(), name: "p".into(),
|
||||
group_id: None, order: 0, unread: false, pinned: false,
|
||||
layout: Some(tree), zoomed: None, surfaces,
|
||||
}],
|
||||
};
|
||||
r.restore(st);
|
||||
let w = r.workspace(&WorkspaceId("w_0".into())).unwrap();
|
||||
assert_eq!(
|
||||
spacesh_core::ops::leaves(w.layout.as_ref().unwrap()),
|
||||
vec![SurfaceId("s_0".into()), SurfaceId("s_1".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_round_trips_through_persist_state() {
|
||||
let mut r = Registry::new();
|
||||
|
||||
+239
-62
@@ -13,6 +13,7 @@ use crate::event_log::EventLog;
|
||||
use crate::event_store::{self, EventPersister, EventStore};
|
||||
use crate::persist::{self, Persister};
|
||||
use crate::registry::Registry;
|
||||
use crate::snapshot_store::{SnapshotStore, SnapshotMsg, spawn_writer};
|
||||
use crate::state_store::StateStore;
|
||||
use crate::surface::{SurfaceMsg};
|
||||
|
||||
@@ -33,11 +34,13 @@ enum ServerMsg {
|
||||
ClientDisconnected { client: ClientId },
|
||||
/// A status change detected internally (OSC 133 / fallback) by a surface actor.
|
||||
StateDetected { surface_id: SurfaceId, state: SurfaceState },
|
||||
/// Periodic snapshot tick: ask all live surfaces for a snapshot and persist dirty ones.
|
||||
SnapshotTick,
|
||||
}
|
||||
|
||||
type ClientId = u64;
|
||||
|
||||
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>) -> Result<()> {
|
||||
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>, snapshot_store: Arc<dyn SnapshotStore>) -> Result<()> {
|
||||
let listener = UnixListener::bind(socket)?;
|
||||
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
|
||||
|
||||
@@ -58,6 +61,20 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
|
||||
}
|
||||
});
|
||||
|
||||
let snapshot_tx = spawn_writer(snapshot_store.clone());
|
||||
|
||||
// Periodic snapshot tick → router.
|
||||
let tick_router = router_tx.clone();
|
||||
let interval_secs = crate::config::Config::load().snapshot_interval_secs();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(interval_secs));
|
||||
tick.tick().await; // consume the immediate first tick
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if tick_router.send(ServerMsg::SnapshotTick).await.is_err() { break; }
|
||||
}
|
||||
});
|
||||
|
||||
let persister = persist::spawn(store.clone(), Duration::from_millis(500));
|
||||
let initial = store.load().unwrap_or_default();
|
||||
let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500));
|
||||
@@ -66,7 +83,7 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
|
||||
let shutdown = tokio::spawn(router(
|
||||
router_rx, router_tx.clone(), exit_tx, state_tx,
|
||||
persister, initial, event_persister, event_initial,
|
||||
started_at_ms,
|
||||
started_at_ms, snapshot_store, snapshot_tx,
|
||||
));
|
||||
|
||||
let mut next_client: ClientId = 0;
|
||||
@@ -128,6 +145,8 @@ async fn router(
|
||||
event_persister: EventPersister,
|
||||
event_initial: crate::event_log::EventLogState,
|
||||
started_at_ms: u64,
|
||||
snapshot_store: Arc<dyn SnapshotStore>,
|
||||
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
|
||||
) {
|
||||
let mut reg = Registry::new();
|
||||
reg.restore(initial);
|
||||
@@ -170,15 +189,34 @@ async fn router(
|
||||
if reg.is_running(&surface_id) {
|
||||
reg.set_state(&surface_id, state);
|
||||
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
|
||||
if let Some(kind) = kind_for_state(state) {
|
||||
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
||||
// StateDetected is the shell path (OSC 133 / fallback scanner). Off by
|
||||
// default it stays a live status ring only — no log entry, no notification.
|
||||
// Agent activity flows through Cmd::SetState (hooks) and is always logged.
|
||||
if config.log_shell_commands() {
|
||||
if let Some(kind) = kind_for_state(state) {
|
||||
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerMsg::SnapshotTick => {
|
||||
let ids = reg.live_ids();
|
||||
for sid in ids {
|
||||
let Some(handle) = reg.live(&sid) else { continue };
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
if handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.is_err() { continue; }
|
||||
if let Ok((snap, dirty)) = reply_rx.await {
|
||||
if dirty {
|
||||
let _ = snapshot_tx.send(SnapshotMsg::Save(sid.clone(), snap));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerMsg::Request { id, cmd, client, out } => {
|
||||
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients,
|
||||
&router_tx, &exit_tx, &state_tx, &persister,
|
||||
&mut event_log, &event_persister, started_at_ms, &mut config).await;
|
||||
&mut event_log, &event_persister, started_at_ms, &mut config,
|
||||
&snapshot_store, &snapshot_tx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,15 +279,39 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
|
||||
/// Compute spawn env (hooks for claude agents, zsh integration for zsh shells)
|
||||
/// and whether a deterministic hook source is active.
|
||||
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
|
||||
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
||||
let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
|
||||
let active = !env.is_empty();
|
||||
(env, active)
|
||||
let (mut env, active) = if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
||||
// Hooks are injected as `--settings` CLI args at spawn (see spawn_from_spec),
|
||||
// not via env — that keeps claude on its default config dir so Keychain login
|
||||
// and onboarding survive. The agent still has a deterministic hook source.
|
||||
(vec![], true)
|
||||
} else if crate::hooks::is_zsh(&spec.command) {
|
||||
(crate::hooks::shell_env(sid), false)
|
||||
} else {
|
||||
(vec![], false)
|
||||
};
|
||||
// Ensure the child sees the user's full PATH; the GUI/launchd-launched daemon
|
||||
// otherwise can't find agents (claude/codex/gemini) and the panel exits at once.
|
||||
if !env.iter().any(|(k, _)| k == "PATH") {
|
||||
env.push(("PATH".to_string(), crate::config::enriched_path()));
|
||||
}
|
||||
(env, active)
|
||||
}
|
||||
|
||||
/// Build the spawn spec for a (re)start. When `resume` and the command has a
|
||||
/// resume mapping, its args are replaced with the resume args; otherwise the
|
||||
/// original spec args are kept.
|
||||
fn resume_spec(
|
||||
spec: &spacesh_proto::workspace::SurfaceSpec,
|
||||
resume: bool,
|
||||
cfg: &crate::config::Config,
|
||||
) -> spacesh_proto::workspace::SurfaceSpec {
|
||||
let mut out = spec.clone();
|
||||
if resume {
|
||||
if let Some(args) = cfg.resume_args(&spec.command) {
|
||||
out.args = args;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit a `layout_changed` event for a workspace's current tree.
|
||||
@@ -278,6 +340,8 @@ async fn handle_request(
|
||||
event_persister: &EventPersister,
|
||||
started_at_ms: u64,
|
||||
config: &mut crate::config::Config,
|
||||
snapshot_store: &Arc<dyn SnapshotStore>,
|
||||
snapshot_tx: &mpsc::UnboundedSender<SnapshotMsg>,
|
||||
) {
|
||||
use spacesh_proto::message::SplitDir;
|
||||
use spacesh_proto::layout::{LayoutNode, Orient};
|
||||
@@ -306,7 +370,7 @@ async fn handle_request(
|
||||
agent_label: command, cols, rows, autostart: false,
|
||||
};
|
||||
let (env, hooks_active) = spawn_env(&sid, &spec);
|
||||
match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
|
||||
match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
@@ -338,7 +402,7 @@ async fn handle_request(
|
||||
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
|
||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
||||
let (env, hooks_active) = spawn_env(&new_sid, &spec);
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
@@ -395,55 +459,56 @@ async fn handle_request(
|
||||
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
||||
};
|
||||
// Kill current panels of this workspace.
|
||||
let existing: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
|
||||
for sid in &existing {
|
||||
if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; }
|
||||
reg.remove_surface(sid);
|
||||
subs.remove(sid);
|
||||
}
|
||||
// Spawn `count` panels (slots padded/truncated to count).
|
||||
let mut new_ids = Vec::new();
|
||||
for i in 0..count {
|
||||
let slot = slots.get(i);
|
||||
// Additive: keep existing panels (and their live processes) in their
|
||||
// current visual order, spawn only the delta needed to reach `count`,
|
||||
// then rebuild the tree to the preset shape. Presets never destroy
|
||||
// running panels — shrinking is done by closing panels via the X. The
|
||||
// GUI only offers presets whose count >= the current pane count, so
|
||||
// `count >= existing.len()` and `ids.len() == count` after the loop.
|
||||
let existing: Vec<SurfaceId> = ws.layout.as_ref()
|
||||
.map(spacesh_core::ops::leaves)
|
||||
.unwrap_or_else(|| ws.surfaces.keys().cloned().collect());
|
||||
let mut ids = existing.clone();
|
||||
let to_spawn = count.saturating_sub(existing.len());
|
||||
for j in 0..to_spawn {
|
||||
let slot = slots.get(existing.len() + j);
|
||||
let new_sid = reg.new_surface_id();
|
||||
let command = slot.and_then(|s| s.command.clone());
|
||||
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
|
||||
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
|
||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
||||
let (env, hooks_active) = spawn_env(&new_sid, &spec);
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle);
|
||||
reg.add_surface_spec(&workspace_id, new_sid.clone(), spec);
|
||||
new_ids.push(new_sid);
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: workspace_id.clone() }));
|
||||
ids.push(new_sid);
|
||||
}
|
||||
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; }
|
||||
}
|
||||
}
|
||||
if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) {
|
||||
if let Some(tree) = spacesh_core::presets::build(&preset_id, &ids) {
|
||||
if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); }
|
||||
}
|
||||
for sid in &new_ids {
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() }));
|
||||
}
|
||||
emit_layout(reg, &workspace_id, clients);
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::<Vec<_>>() }))).await;
|
||||
let _ = out.send(ok(id, serde_json::json!({ "surface_ids": ids.iter().map(|s| s.0.clone()).collect::<Vec<_>>() }))).await;
|
||||
}
|
||||
|
||||
Cmd::RestartSurface { surface_id } => {
|
||||
Cmd::RestartSurface { surface_id, resume } => {
|
||||
if reg.is_running(&surface_id) {
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running
|
||||
}
|
||||
let Some(spec) = reg.surface_spec(&surface_id) else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
|
||||
};
|
||||
let spec = resume_spec(&spec, resume, config);
|
||||
let ws_id = reg.workspace_of(&surface_id).unwrap();
|
||||
let (env, hooks_active) = spawn_env(&surface_id, &spec);
|
||||
match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
|
||||
match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
@@ -458,6 +523,7 @@ async fn handle_request(
|
||||
Cmd::CloseWorkspace { workspace_id } => {
|
||||
let ids = reg.close_workspace(&workspace_id);
|
||||
for sid in &ids { crate::hooks::cleanup(sid); crate::hooks::cleanup_shell(sid); subs.remove(sid); }
|
||||
for sid in &ids { let _ = snapshot_tx.send(SnapshotMsg::Remove(sid.clone())); }
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceClosed { workspace_id: workspace_id.clone() }));
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
@@ -547,8 +613,18 @@ async fn handle_request(
|
||||
}
|
||||
let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
|
||||
} else {
|
||||
// stopped panel: no live stream, return an empty snapshot so the GUI shows the restart overlay.
|
||||
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await;
|
||||
// stopped panel: no live stream. Paint the last on-disk screen if we have one.
|
||||
match snapshot_store.load(&surface_id) {
|
||||
Some(snap) => {
|
||||
let _ = out.send(ok(id, serde_json::json!({
|
||||
"snapshot": snap.ansi, "cols": snap.cols, "rows": snap.rows,
|
||||
"cursor_row": snap.cursor_row, "cursor_col": snap.cursor_col, "stopped": true,
|
||||
}))).await;
|
||||
}
|
||||
None => {
|
||||
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +650,7 @@ async fn handle_request(
|
||||
subs.remove(&surface_id);
|
||||
crate::hooks::cleanup(&surface_id);
|
||||
crate::hooks::cleanup_shell(&surface_id);
|
||||
let _ = snapshot_tx.send(SnapshotMsg::Remove(surface_id.clone()));
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() }));
|
||||
if let Some(ws_id) = ws_id {
|
||||
emit_layout(reg, &ws_id, clients);
|
||||
@@ -628,6 +705,11 @@ async fn handle_request(
|
||||
}))).await;
|
||||
}
|
||||
|
||||
Cmd::WhichAgents { candidates } => {
|
||||
let available: Vec<String> = candidates.into_iter().filter(|c| crate::config::is_installed(c)).collect();
|
||||
let _ = out.send(ok(id, serde_json::json!({ "available": available }))).await;
|
||||
}
|
||||
|
||||
Cmd::Status => {
|
||||
let (groups, workspaces) = reg.status();
|
||||
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
|
||||
@@ -656,6 +738,19 @@ async fn handle_request(
|
||||
}
|
||||
|
||||
Cmd::Shutdown => {
|
||||
// Final snapshot pass: capture each live surface's visible screen so a
|
||||
// clean restart (e.g. Settings → Restart daemon) repaints last screens.
|
||||
// Written synchronously through the store (the async writer task would
|
||||
// not drain before process::exit).
|
||||
for sid in reg.live_ids() {
|
||||
let Some(handle) = reg.live(&sid) else { continue };
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
if handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.is_ok() {
|
||||
if let Ok((snap, _dirty)) = reply_rx.await {
|
||||
snapshot_store.save(&sid, &snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
std::process::exit(0);
|
||||
}
|
||||
@@ -667,7 +762,7 @@ async fn handle_request(
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
|
||||
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands } => {
|
||||
if let Some(v) = &theme {
|
||||
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
|
||||
}
|
||||
@@ -675,12 +770,19 @@ async fn handle_request(
|
||||
const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
|
||||
if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
|
||||
}
|
||||
if let Some(v) = &background {
|
||||
const BACKGROUNDS: [&str; 8] = ["none", "cyberwave", "phenomenon", "dracula", "aurora", "ember", "referred", "custom"];
|
||||
if !BACKGROUNDS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "background")).await; return; }
|
||||
}
|
||||
let mut next = config.clone();
|
||||
if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; }
|
||||
if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; }
|
||||
if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); }
|
||||
if let Some(v) = theme { next.appearance.theme = Some(v); }
|
||||
if let Some(v) = accent { next.appearance.accent = Some(v); }
|
||||
if let Some(v) = background { next.appearance.background = if v == "none" { None } else { Some(v) }; }
|
||||
if let Some(v) = background_image { next.appearance.background_image = if v.is_empty() { None } else { Some(v) }; }
|
||||
if let Some(v) = log_shell_commands { next.log_shell_commands = Some(v); }
|
||||
let to_save = next.clone();
|
||||
match tokio::task::spawn_blocking(move || to_save.save()).await {
|
||||
Ok(Ok(())) => {
|
||||
@@ -728,6 +830,7 @@ fn spawn_output_bridge(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use crate::snapshot_store::NullSnapshotStore;
|
||||
|
||||
async fn req(stream: &mut UnixStream, id: u64, cmd: Cmd) -> Envelope {
|
||||
write_frame(stream, &Envelope::Req { id, cmd }).await.unwrap();
|
||||
@@ -776,7 +879,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
@@ -818,7 +921,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
let r = req(&mut s, 1, Cmd::Input {
|
||||
@@ -844,7 +947,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// First client: open, new surface that prints a marker, attach, then disconnect.
|
||||
@@ -883,7 +986,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
@@ -913,7 +1016,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
@@ -957,7 +1060,7 @@ mod tests {
|
||||
// per-test dir so instance B reads from disk what instance A persisted.
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
|
||||
@@ -974,7 +1077,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
|
||||
let event_store_b = make_event_store(&dir);
|
||||
let sb2 = sock_b.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sb2).await;
|
||||
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
|
||||
let r = req(&mut s2, 1, Cmd::Status).await;
|
||||
@@ -996,7 +1099,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
@@ -1034,7 +1137,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Control connection: open workspace and spawn surface.
|
||||
@@ -1087,7 +1190,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Control connection: open workspace and spawn surface.
|
||||
@@ -1139,7 +1242,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Control connection: open workspace and spawn a surface that emits OSC 133.
|
||||
@@ -1154,8 +1257,7 @@ mod tests {
|
||||
}).await;
|
||||
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Observer connection: receives all broadcast events (the detected-state path
|
||||
// flows through ServerMsg::StateDetected → record_event → Evt::Event).
|
||||
// Observer connection: receives all broadcast events.
|
||||
let mut observer = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
// Drive the PTY output by attaching the control connection.
|
||||
@@ -1163,21 +1265,27 @@ mod tests {
|
||||
surface_id: spacesh_proto::SurfaceId(sid.clone()),
|
||||
}).await;
|
||||
|
||||
// Expect an Evt::Event (kind=done) for this surface from the OSC 133 Done detection.
|
||||
let mut found = None;
|
||||
// Shell-command status (OSC 133) updates the live status ring (Evt::State) but is
|
||||
// NOT logged by default — log_shell_commands defaults to false, so no Evt::Event
|
||||
// is recorded for plain shell panels. (Agent activity flows through Cmd::SetState.)
|
||||
let mut saw_state_done = false;
|
||||
let mut saw_event = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Ok(Ok(Some(env))) =
|
||||
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
|
||||
if let Envelope::Evt(Evt::Event { record }) = env {
|
||||
if record.surface_id.0 == sid { found = Some(record); break; }
|
||||
match env {
|
||||
Envelope::Evt(Evt::State { surface_id, state })
|
||||
if surface_id.0 == sid && state == spacesh_proto::status::SurfaceState::Done => { saw_state_done = true; }
|
||||
// Exit (process end) is always logged; only command-status events are gated.
|
||||
Envelope::Evt(Evt::Event { record })
|
||||
if record.surface_id.0 == sid && record.kind != spacesh_proto::event::EventKind::Exit => { saw_event = true; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rec = found.expect("expected an Evt::Event from the OSC 133 detected state");
|
||||
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
|
||||
assert!(!rec.read);
|
||||
assert_eq!(rec.workspace_id.0, ws);
|
||||
assert!(saw_state_done, "expected an Evt::State(done) from the OSC 133 detection");
|
||||
assert!(!saw_event, "shell-command events must not be logged when log_shell_commands is off");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -1190,7 +1298,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Observer connection to catch the EventsRead broadcast.
|
||||
@@ -1258,7 +1366,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
@@ -1300,7 +1408,7 @@ mod tests {
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
|
||||
let event_store_b = make_event_store(&dir);
|
||||
let sb2 = sock_b.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sb2).await;
|
||||
|
||||
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
|
||||
@@ -1328,7 +1436,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Observer connection.
|
||||
@@ -1390,7 +1498,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
@@ -1413,7 +1521,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
@@ -1460,7 +1568,7 @@ mod tests {
|
||||
let event_store = make_event_store(&dir);
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Control connection: open, spawn, zoom.
|
||||
@@ -1500,4 +1608,73 @@ mod tests {
|
||||
}
|
||||
assert!(saw_cleared, "expected a WorkspaceChanged broadcast with cleared zoom after closing the zoomed surface");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stopped_attach_returns_disk_snapshot() {
|
||||
let _serial = crate::test_support::serial();
|
||||
let dir = tempdir_path();
|
||||
let sock = dir.join("sock");
|
||||
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||
let event_store = make_event_store(&dir);
|
||||
// Use a real JsonSnapshotStore so the on-exit dump actually lands on disk.
|
||||
let snap_dir = dir.join("snapshots");
|
||||
let snapshot_store: std::sync::Arc<dyn crate::snapshot_store::SnapshotStore> =
|
||||
std::sync::Arc::new(crate::snapshot_store::JsonSnapshotStore::new(snap_dir.clone()));
|
||||
let sock_for_task = sock.clone();
|
||||
let store2 = store.clone();
|
||||
let snap_store2 = snapshot_store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, snap_store2).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Open workspace, spawn a surface that prints a unique marker then exits quickly.
|
||||
let surface_id;
|
||||
{
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
|
||||
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
|
||||
let r = req(&mut s, 2, Cmd::NewSurface {
|
||||
workspace_id: spacesh_proto::WorkspaceId(ws),
|
||||
command: Some("/bin/sh".into()),
|
||||
args: vec!["-c".into(), "printf SNAPDISK; sleep 0.2".into()],
|
||||
cols: 80, rows: 24,
|
||||
}).await;
|
||||
surface_id = spacesh_proto::SurfaceId(res_data(&r)["surface_id"].as_str().unwrap().to_string());
|
||||
// Give the process time to run, exit, and the actor to dump its snapshot to the writer.
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
|
||||
// s drops here
|
||||
}
|
||||
|
||||
// Re-verify the socket is still alive.
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// Fresh client: attach to the now-stopped surface.
|
||||
let mut s2 = UnixStream::connect(&sock).await.unwrap();
|
||||
let r = req(&mut s2, 1, Cmd::Attach { surface_id: surface_id.clone() }).await;
|
||||
let data = res_data(&r);
|
||||
assert_eq!(data["stopped"].as_bool(), Some(true), "surface should be stopped");
|
||||
let snap = data["snapshot"].as_str().unwrap_or("");
|
||||
assert!(snap.contains("SNAPDISK"), "on-disk snapshot should contain SNAPDISK, got: {snap:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_spec_swaps_args_when_mapped() {
|
||||
use spacesh_proto::workspace::SurfaceSpec;
|
||||
let spec = SurfaceSpec {
|
||||
command: "claude".into(), args: vec!["--foo".into()], cwd: "/tmp".into(),
|
||||
agent_label: Some("claude".into()), cols: 80, rows: 24, autostart: false,
|
||||
};
|
||||
let cfg = crate::config::Config::default();
|
||||
// resume=false → original args
|
||||
let plain = resume_spec(&spec, false, &cfg);
|
||||
assert_eq!(plain.args, vec!["--foo".to_string()]);
|
||||
// resume=true with a default mapping → resume args
|
||||
let resumed = resume_spec(&spec, true, &cfg);
|
||||
assert_eq!(resumed.args, vec!["--continue".to_string()]);
|
||||
// resume=true for an unmapped command → original args (graceful fallback)
|
||||
let mut shell = spec.clone();
|
||||
shell.command = "bash".into();
|
||||
let resumed_shell = resume_spec(&shell, true, &cfg);
|
||||
assert_eq!(resumed_shell.args, shell.args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
use std::path::PathBuf;
|
||||
use spacesh_core::snapshot::Snapshot;
|
||||
use spacesh_proto::SurfaceId;
|
||||
|
||||
/// Stores one visible-screen snapshot per surface as `<dir>/<surface_id>.json`.
|
||||
pub trait SnapshotStore: Send + Sync {
|
||||
fn save(&self, sid: &SurfaceId, snap: &Snapshot);
|
||||
fn load(&self, sid: &SurfaceId) -> Option<Snapshot>;
|
||||
fn remove(&self, sid: &SurfaceId);
|
||||
}
|
||||
|
||||
/// Writer command: persist or delete a surface's snapshot. Shared by the
|
||||
/// router ticker, the close/remove paths, and each actor's on-exit dump, so a
|
||||
/// single channel type flows everywhere.
|
||||
pub enum SnapshotMsg {
|
||||
Save(SurfaceId, Snapshot),
|
||||
Remove(SurfaceId),
|
||||
}
|
||||
|
||||
/// A no-op store for tests that do not persist snapshots. Test-only — gated so
|
||||
/// release builds don't warn about an unconstructed struct.
|
||||
#[cfg(test)]
|
||||
pub struct NullSnapshotStore;
|
||||
#[cfg(test)]
|
||||
impl SnapshotStore for NullSnapshotStore {
|
||||
fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {}
|
||||
fn load(&self, _sid: &SurfaceId) -> Option<Snapshot> { None }
|
||||
fn remove(&self, _sid: &SurfaceId) {}
|
||||
}
|
||||
|
||||
/// JSON file store. Filenames are the surface id (e.g. `s_1f.json`); ids are
|
||||
/// `^[a-z]_[0-9a-f]+$` so they are always safe path components.
|
||||
pub struct JsonSnapshotStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl JsonSnapshotStore {
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
Self { dir }
|
||||
}
|
||||
fn path(&self, sid: &SurfaceId) -> PathBuf {
|
||||
self.dir.join(format!("{}.json", sid.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotStore for JsonSnapshotStore {
|
||||
fn save(&self, sid: &SurfaceId, snap: &Snapshot) {
|
||||
let path = self.path(sid);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let Ok(bytes) = serde_json::to_vec(snap) else { return };
|
||||
if std::fs::write(&tmp, &bytes).is_err() { return; }
|
||||
if std::fs::File::open(&tmp).and_then(|f| f.sync_all()).is_err() { return; }
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
fn load(&self, sid: &SurfaceId) -> Option<Snapshot> {
|
||||
let bytes = std::fs::read(self.path(sid)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
fn remove(&self, sid: &SurfaceId) {
|
||||
let _ = std::fs::remove_file(self.path(sid));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the writer task; returns the sender used by the router and actors.
|
||||
pub fn spawn_writer(store: std::sync::Arc<dyn SnapshotStore>) -> tokio::sync::mpsc::UnboundedSender<SnapshotMsg> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<SnapshotMsg>();
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
SnapshotMsg::Save(sid, snap) => store.save(&sid, &snap),
|
||||
SnapshotMsg::Remove(sid) => store.remove(&sid),
|
||||
}
|
||||
}
|
||||
});
|
||||
tx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let p = std::env::temp_dir().join(format!("spacesh-snap-{name}-{n}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn sample() -> Snapshot {
|
||||
Snapshot { ansi: "\u{1b}[mhello".into(), cols: 80, rows: 24, cursor_row: 1, cursor_col: 6 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let dir = tmp_dir("roundtrip");
|
||||
let store = JsonSnapshotStore::new(dir.clone());
|
||||
let sid = SurfaceId("s_1".into());
|
||||
store.save(&sid, &sample());
|
||||
assert_eq!(store.load(&sid), Some(sample()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_loads_none() {
|
||||
let store = JsonSnapshotStore::new(tmp_dir("missing"));
|
||||
assert_eq!(store.load(&SurfaceId("s_none".into())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_loads_none() {
|
||||
let dir = tmp_dir("corrupt");
|
||||
let store = JsonSnapshotStore::new(dir.clone());
|
||||
let sid = SurfaceId("s_2".into());
|
||||
std::fs::write(dir.join("s_2.json"), b"{ not json").unwrap();
|
||||
assert_eq!(store.load(&sid), None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_file() {
|
||||
let dir = tmp_dir("remove");
|
||||
let store = JsonSnapshotStore::new(dir.clone());
|
||||
let sid = SurfaceId("s_3".into());
|
||||
store.save(&sid, &sample());
|
||||
assert!(store.load(&sid).is_some());
|
||||
store.remove(&sid);
|
||||
assert_eq!(store.load(&sid), None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_store_is_inert() {
|
||||
let store = NullSnapshotStore;
|
||||
let sid = SurfaceId("s_4".into());
|
||||
store.save(&sid, &sample());
|
||||
assert_eq!(store.load(&sid), None);
|
||||
store.remove(&sid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writer_saves_and_removes() {
|
||||
let dir = tmp_dir("writer");
|
||||
let store: std::sync::Arc<dyn SnapshotStore> = std::sync::Arc::new(JsonSnapshotStore::new(dir.clone()));
|
||||
let tx = spawn_writer(store.clone());
|
||||
let sid = SurfaceId("s_w".into());
|
||||
|
||||
tx.send(SnapshotMsg::Save(sid.clone(), sample())).unwrap();
|
||||
let mut saved = None;
|
||||
for _ in 0..50 {
|
||||
if let Some(s) = store.load(&sid) { saved = Some(s); break; }
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(saved, Some(sample()));
|
||||
|
||||
tx.send(SnapshotMsg::Remove(sid.clone())).unwrap();
|
||||
let mut gone = false;
|
||||
for _ in 0..50 {
|
||||
if store.load(&sid).is_none() { gone = true; break; }
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(gone, "writer should have removed the snapshot file");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
+101
-16
@@ -7,6 +7,7 @@ use spacesh_proto::workspace::SurfaceSpec;
|
||||
use spacesh_pty::{PtyHandle, SpawnSpec};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tokio::time::{Duration, Instant};
|
||||
use crate::snapshot_store::SnapshotMsg;
|
||||
|
||||
/// Spawn (or restart) a surface actor from a persisted spec. Injects
|
||||
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
|
||||
@@ -24,18 +25,26 @@ pub fn spawn_from_spec(
|
||||
hooks_active: bool,
|
||||
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
|
||||
) -> std::io::Result<SurfaceHandle> {
|
||||
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
|
||||
env.extend(extra_env);
|
||||
// For a Claude agent, inject our notify hooks via `--settings` so they layer on
|
||||
// top of the user's real config without relocating CLAUDE_CONFIG_DIR (which would
|
||||
// hide the Keychain login). Surface id reaches the hook through SPACESH_SURFACE_ID.
|
||||
let mut args = spec.args.clone();
|
||||
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
|
||||
args.extend(crate::hooks::claude_settings_args(&crate::hooks::spacesh_bin()));
|
||||
}
|
||||
let spawn_spec = SpawnSpec {
|
||||
command: spec.command.clone(),
|
||||
args: spec.args.clone(),
|
||||
args,
|
||||
cwd: std::path::PathBuf::from(&spec.cwd),
|
||||
cols: spec.cols,
|
||||
rows: spec.rows,
|
||||
env,
|
||||
};
|
||||
Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx))
|
||||
Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx, snapshot_tx))
|
||||
}
|
||||
|
||||
const BROADCAST_CAP: usize = 1024;
|
||||
@@ -53,6 +62,8 @@ pub enum SurfaceMsg {
|
||||
Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
|
||||
/// Attach with snapshot: subscribe AND capture the grid in one actor turn.
|
||||
AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver<Vec<u8>>)> },
|
||||
/// On-demand snapshot without subscribing; bool = dirty since last snapshot.
|
||||
Snapshot { reply: oneshot::Sender<(Snapshot, bool)> },
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -76,10 +87,11 @@ pub fn spawn_surface(
|
||||
hooks_active: bool,
|
||||
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
|
||||
) -> SurfaceHandle {
|
||||
let (tx, rx) = mpsc::channel::<SurfaceMsg>(64);
|
||||
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
||||
tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, Vec::new()));
|
||||
tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, snapshot_tx, Vec::new()));
|
||||
SurfaceHandle { id, workspace_id, tx }
|
||||
}
|
||||
|
||||
@@ -97,6 +109,7 @@ pub fn spawn_surface_deferred(
|
||||
hooks_active: bool,
|
||||
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
|
||||
) -> SurfaceHandle {
|
||||
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
|
||||
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
||||
@@ -122,6 +135,10 @@ pub fn spawn_surface_deferred(
|
||||
let snap = snapshot_ansi(&GridSurface::new(cols, rows));
|
||||
let _ = reply.send((snap, sub));
|
||||
}
|
||||
Some(SurfaceMsg::Snapshot { reply }) => {
|
||||
let snap = snapshot_ansi(&GridSurface::new(cols, rows));
|
||||
let _ = reply.send((snap, false));
|
||||
}
|
||||
Some(SurfaceMsg::Close) | None => break false,
|
||||
}
|
||||
}
|
||||
@@ -135,7 +152,7 @@ pub fn spawn_surface_deferred(
|
||||
spec.cols = cols;
|
||||
spec.rows = rows;
|
||||
match PtyHandle::spawn(spec) {
|
||||
Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, prebuf).await,
|
||||
Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, snapshot_tx, prebuf).await,
|
||||
Err(_) => { let _ = exit_tx.send((actor_id, 127)); }
|
||||
}
|
||||
});
|
||||
@@ -156,6 +173,7 @@ async fn run_actor(
|
||||
mut rx: mpsc::Receiver<SurfaceMsg>,
|
||||
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
|
||||
prebuffered_input: Vec<u8>,
|
||||
) {
|
||||
let actor_id = id.clone();
|
||||
@@ -173,6 +191,7 @@ async fn run_actor(
|
||||
// (hooks active, or any OSC 133 marker observed).
|
||||
let mut deterministic = hooks_active;
|
||||
let mut last_state = SurfaceState::Idle;
|
||||
let mut dirty = false;
|
||||
|
||||
loop {
|
||||
// Copy the deadline into an owned local so the timer future doesn't
|
||||
@@ -202,8 +221,15 @@ async fn run_actor(
|
||||
// this snapshot. Broadcasting here would double-render on reattach.
|
||||
let sub = bcast.subscribe();
|
||||
let snap = snapshot_ansi(&grid);
|
||||
dirty = false;
|
||||
let _ = reply.send((snap, sub));
|
||||
}
|
||||
Some(SurfaceMsg::Snapshot { reply }) => {
|
||||
let snap = snapshot_ansi(&grid);
|
||||
let was_dirty = dirty;
|
||||
dirty = false;
|
||||
let _ = reply.send((snap, was_dirty));
|
||||
}
|
||||
Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
|
||||
}
|
||||
}
|
||||
@@ -211,26 +237,31 @@ async fn run_actor(
|
||||
match chunk {
|
||||
Some(bytes) => {
|
||||
pending.extend_from_slice(&bytes);
|
||||
dirty = true;
|
||||
if flush_deadline.is_none() {
|
||||
flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
|
||||
}
|
||||
if pending.len() >= FLUSH_BYTES {
|
||||
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||
let replies = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||
if !replies.is_empty() { let _ = pty.write_input(&replies); }
|
||||
flush_deadline = None;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||
let _ = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = timer => {
|
||||
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||
let replies = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
|
||||
if !replies.is_empty() { let _ = pty.write_input(&replies); }
|
||||
flush_deadline = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let final_snap = snapshot_ansi(&grid);
|
||||
let _ = snapshot_tx.send(SnapshotMsg::Save(actor_id.clone(), final_snap));
|
||||
let code = pty.wait();
|
||||
let _ = exit_tx.send((actor_id, code));
|
||||
}
|
||||
@@ -238,6 +269,8 @@ async fn run_actor(
|
||||
|
||||
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
|
||||
/// state change (if any). No-op when pending is empty.
|
||||
/// Returns escape-sequence replies the terminal model produced (DA/DSR answers) that
|
||||
/// the caller must write back to the PTY. Empty when there's nothing to feed or reply.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn flush(
|
||||
pending: &mut Vec<u8>,
|
||||
@@ -248,9 +281,9 @@ fn flush(
|
||||
id: &SurfaceId,
|
||||
bcast: &broadcast::Sender<Vec<u8>>,
|
||||
state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||
) {
|
||||
) -> Vec<u8> {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
// Deterministic source: OSC 133 markers in this chunk.
|
||||
// Emit each distinct state transition immediately so no marker is dropped
|
||||
@@ -265,6 +298,9 @@ fn flush(
|
||||
}
|
||||
}
|
||||
grid.feed(&pending[..]);
|
||||
// Answers to device-attribute / status queries the model just parsed; the actor
|
||||
// writes these back to the PTY so query-blocking programs (fish) don't time out.
|
||||
let replies = grid.take_replies();
|
||||
// Best-effort fallback only when no deterministic source is active.
|
||||
if !had_osc && !*deterministic {
|
||||
if let Some(st) = FallbackScanner::scan(&grid.tail_text(6)) {
|
||||
@@ -275,6 +311,7 @@ fn flush(
|
||||
}
|
||||
}
|
||||
let _ = bcast.send(std::mem::take(pending));
|
||||
replies
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -299,7 +336,8 @@ mod tests {
|
||||
let pty = PtyHandle::spawn(spec("printf HELLO; sleep 0.3")).unwrap();
|
||||
let (state_tx, _state_rx) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
|
||||
@@ -324,7 +362,8 @@ mod tests {
|
||||
let pty = PtyHandle::spawn(spec("exit 7")).unwrap();
|
||||
let (state_tx, _state_rx) = mpsc::unbounded_channel();
|
||||
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel();
|
||||
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
let (sid, code) = tokio::time::timeout(tokio::time::Duration::from_secs(3), exit_rx.recv())
|
||||
.await.unwrap().unwrap();
|
||||
assert_eq!(sid, SurfaceId("s_2".into()));
|
||||
@@ -337,7 +376,8 @@ mod tests {
|
||||
let pty = PtyHandle::spawn(spec("printf SNAPME; sleep 0.5")).unwrap();
|
||||
let (state_tx, _state_rx) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
|
||||
// Give the child time to write and the actor time to flush into the grid.
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
@@ -359,7 +399,8 @@ mod tests {
|
||||
};
|
||||
let (state_tx, _state_rx) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, vec![], false, state_tx, exit_tx).unwrap();
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, vec![], false, state_tx, exit_tx, snap_tx).unwrap();
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
|
||||
let mut sub = reply_rx.await.unwrap();
|
||||
@@ -404,7 +445,8 @@ mod tests {
|
||||
let _serial = crate::test_support::serial();
|
||||
let (state_tx, _s) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _e) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface_deferred(SurfaceId("s_d".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx);
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface_deferred(SurfaceId("s_d".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
|
||||
let (rtx, rrx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap();
|
||||
@@ -421,7 +463,8 @@ mod tests {
|
||||
let _serial = crate::test_support::serial();
|
||||
let (state_tx, _s) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _e) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface_deferred(SurfaceId("s_f".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx);
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface_deferred(SurfaceId("s_f".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
|
||||
let (rtx, rrx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap();
|
||||
@@ -437,7 +480,8 @@ mod tests {
|
||||
let pty = PtyHandle::spawn(spec("printf '\\033]133;C\\007'; printf working; printf '\\033]133;D;0\\007'; sleep 0.3")).unwrap();
|
||||
let (state_tx, mut state_rx) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
|
||||
let _h = spawn_surface(SurfaceId("s_o".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let _h = spawn_surface(SurfaceId("s_o".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
let mut seen = Vec::new();
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
@@ -449,4 +493,45 @@ mod tests {
|
||||
assert!(seen.contains(&SurfaceState::Work), "states: {seen:?}");
|
||||
assert!(seen.contains(&SurfaceState::Done), "states: {seen:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_msg_returns_grid_and_tracks_dirty() {
|
||||
let _serial = crate::test_support::serial();
|
||||
let pty = PtyHandle::spawn(spec("printf DIRTYME; sleep 0.4")).unwrap();
|
||||
let (state_tx, _s) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _e) = mpsc::unbounded_channel();
|
||||
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.unwrap();
|
||||
let (snap, dirty) = reply_rx.await.unwrap();
|
||||
assert!(snap.ansi.contains("DIRTYME"), "snapshot: {:?}", snap.ansi);
|
||||
assert!(dirty, "first snapshot after output should be dirty");
|
||||
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.unwrap();
|
||||
let (_snap2, dirty2) = reply_rx.await.unwrap();
|
||||
assert!(!dirty2, "second snapshot with no new output should be clean");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_snapshot_sent_on_exit() {
|
||||
let _serial = crate::test_support::serial();
|
||||
let pty = PtyHandle::spawn(spec("printf BYE")).unwrap(); // exits immediately
|
||||
let (state_tx, _s) = mpsc::unbounded_channel();
|
||||
let (exit_tx, _e) = mpsc::unbounded_channel();
|
||||
let (snap_tx, mut snap_rx) = mpsc::unbounded_channel();
|
||||
let _handle = spawn_surface(SurfaceId("s_x".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
|
||||
|
||||
let msg = tokio::time::timeout(Duration::from_secs(2), snap_rx.recv()).await.unwrap().unwrap();
|
||||
match msg {
|
||||
crate::snapshot_store::SnapshotMsg::Save(sid, snap) => {
|
||||
assert_eq!(sid.0, "s_x");
|
||||
assert!(snap.ansi.contains("BYE"), "final snapshot: {:?}", snap.ansi);
|
||||
}
|
||||
_ => panic!("expected a Save message on exit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ mkdir -p $SSH_REMOTE_DIR/download
|
||||
server, then `docker compose pull && up -d`.
|
||||
- **DMG** → built on macOS, uploaded by hand: `make deploy-dmg`
|
||||
(sets the stable `download/spacesh.dmg`). Tauri can't build a macOS bundle in CI.
|
||||
The same command also publishes a **versioned** copy to the Gitea package
|
||||
registry (`Packages` tab) when `GITEA_TOKEN` is set:
|
||||
|
||||
```bash
|
||||
GITEA_TOKEN=<token-with-package:write> make deploy-dmg # server + Gitea Packages
|
||||
make publish-dmg GITEA_TOKEN=<token> # build + Gitea Packages only
|
||||
```
|
||||
|
||||
Published at `{GITEA_URL}/api/packages/{GITEA_OWNER}/generic/spacesh/<version>/spacesh-<version>.dmg`
|
||||
(override `GITEA_URL`/`GITEA_OWNER`/`GITEA_PKG` if needed). Version comes from
|
||||
`tauri.conf.json`, bumped on every `make dmg`. Without `GITEA_TOKEN` the publish
|
||||
step is skipped (server copy still happens).
|
||||
|
||||
## Gitea secrets required
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
services:
|
||||
landing:
|
||||
# LANDING_IMAGE is written to .env by the CI deploy job (exact registry path + tag).
|
||||
image: ${LANDING_IMAGE:-git.realmanual.ru/spacesh/spacesh-landing:latest}
|
||||
image: ${LANDING_IMAGE:-git.realmanual.ru/pub/spacesh-landing:latest}
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
@@ -14,6 +14,9 @@ services:
|
||||
|
||||
proxy:
|
||||
image: nginx:1.27-alpine
|
||||
# Stable name so NPM can forward by name (spacesh-proxy:80) instead of a
|
||||
# pinned IP that another webproxy container could grab.
|
||||
container_name: spacesh-proxy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- landing
|
||||
|
||||
+12
-11
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>spacesh — терминал-воркспейс для AI-агентов на macOS</title>
|
||||
<title>spaceshell — терминал-воркспейс для AI-агентов на macOS</title>
|
||||
<meta name="description" content="Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS.">
|
||||
<link rel="canonical" href="https://spaceshell.ru">
|
||||
<meta property="og:title" content="spacesh — терминал-воркспейс для AI-агентов">
|
||||
<meta property="og:title" content="spaceshell — терминал-воркспейс для AI-агентов">
|
||||
<meta property="og:description" content="Десяток AI-агентов параллельно. Демон держит сессии живыми — закрой окно, агенты работают.">
|
||||
<meta property="og:url" content="https://spaceshell.ru">
|
||||
<meta property="og:image" content="https://spaceshell.ru/og.png">
|
||||
@@ -812,13 +812,13 @@
|
||||
<div class="header-inner">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-icon">>_</span>
|
||||
spacesh
|
||||
spaceshell
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#how">Как работает</a>
|
||||
<a href="#cli">CLI</a>
|
||||
<a href="https://github.com/spacesh" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>
|
||||
</nav>
|
||||
<a href="https://spaceshell.ru/download/spacesh.dmg" download class="btn btn-primary">Скачать для macOS</a>
|
||||
<button class="mobile-nav-toggle" aria-label="Меню">☰</button>
|
||||
@@ -836,7 +836,7 @@
|
||||
Гоняй десяток AI-агентов параллельно. <span class="accent">Не теряй ни одного.</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
spacesh держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
|
||||
spaceshell держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
|
||||
Закрыл окно, обновил приложение, словил краш — агенты продолжают работать.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
@@ -860,7 +860,7 @@
|
||||
<span class="terminal-dot"></span>
|
||||
<span class="terminal-dot"></span>
|
||||
</div>
|
||||
<span class="terminal-title">spacesh — workspace</span>
|
||||
<span class="terminal-title">spaceshell — workspace</span>
|
||||
</div>
|
||||
<div class="terminal-grid">
|
||||
<div class="terminal-pane">
|
||||
@@ -928,6 +928,7 @@
|
||||
<span class="agent-tag">Codex</span>
|
||||
<span class="agent-tag">Gemini</span>
|
||||
<span class="agent-tag">opencode</span>
|
||||
<span class="agent-tag">deepseek</span>
|
||||
<span class="agent-tag">shell</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -946,7 +947,7 @@
|
||||
</div>
|
||||
<div class="solution-card">
|
||||
<p class="card-label">Решение</p>
|
||||
<h3 class="card-title">spacesh разрывает эту связь.</h3>
|
||||
<h3 class="card-title">spaceshell разрывает эту связь.</h3>
|
||||
<p class="card-text">
|
||||
Сессиями владеет фоновый демон, а не окно. Интерфейс — всего лишь вид поверх него.
|
||||
</p>
|
||||
@@ -999,7 +1000,7 @@
|
||||
<h3 class="feature-title">CLI как первый класс</h3>
|
||||
<p class="feature-text">
|
||||
spacesh status --json, focus, new-surface, notify — те же команды, что и в интерфейсе, плюс shell-completions.
|
||||
Встраивай spacesh в свои пайплайны.
|
||||
Встраивай spaceshell в свои пайплайны.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card reveal">
|
||||
@@ -1105,7 +1106,7 @@
|
||||
Скачать для macOS
|
||||
</a>
|
||||
</div>
|
||||
<a href="https://github.com/spacesh" target="_blank" rel="noopener" class="cta-github">
|
||||
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener" class="cta-github">
|
||||
Исходники на GitHub →
|
||||
</a>
|
||||
</div>
|
||||
@@ -1117,10 +1118,10 @@
|
||||
<div class="footer-inner">
|
||||
<div class="footer-left">
|
||||
<span class="footer-logo">spaceshell.ru</span>
|
||||
<span class="footer-copy">© 2026 spacesh</span>
|
||||
<span class="footer-copy">© 2026 spaceshell</span>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/spacesh" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="#">Документация</a>
|
||||
<a href="#">Лицензия</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
// Bump the patch version in lockstep for the GUI (app/src-tauri/tauri.conf.json)
|
||||
// and the daemon/CLI (root Cargo.toml [workspace.package].version), so the app's
|
||||
// update check and the daemon's reported version never drift apart.
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const TAURI_CONF = "app/src-tauri/tauri.conf.json";
|
||||
const CARGO_TOML = "Cargo.toml";
|
||||
|
||||
const conf = JSON.parse(readFileSync(TAURI_CONF, "utf8"));
|
||||
const parts = conf.version.split(".").map((n) => parseInt(n, 10) || 0);
|
||||
parts[2] = (parts[2] || 0) + 1;
|
||||
const next = parts.join(".");
|
||||
|
||||
conf.version = next;
|
||||
writeFileSync(TAURI_CONF, JSON.stringify(conf, null, 2) + "\n");
|
||||
|
||||
let cargo = readFileSync(CARGO_TOML, "utf8");
|
||||
// Replace only the version inside [workspace.package], not dependency versions.
|
||||
cargo = cargo.replace(
|
||||
/(\[workspace\.package\][\s\S]*?version\s*=\s*")[^"]+(")/,
|
||||
`$1${next}$2`
|
||||
);
|
||||
writeFileSync(CARGO_TOML, cargo);
|
||||
|
||||
console.log(`version → ${next} (GUI + daemon)`);
|
||||
Reference in New Issue
Block a user