diff --git a/.claude/skills/duplicate-detection/SKILL.md b/.claude/skills/duplicate-detection/SKILL.md new file mode 100644 index 000000000..a16fa8f98 --- /dev/null +++ b/.claude/skills/duplicate-detection/SKILL.md @@ -0,0 +1,114 @@ +--- +name: duplicate-detection +description: Detect duplicated code and choose safe deduplication targets in Rust or general codebases. Use when the user asks to find code clones, dedupe a PR or branch, run duplicate-code tools, reduce repeated logic, identify dangerous duplication that may drift, or continue incremental refactors driven by duplicate-detection reports. +--- + +# Duplicate Detection + +Use this skill to turn clone reports into small, safe refactors. Prefer removing duplication that can cause behavior drift over reducing a metric for its own sake. + +## Workflow + +1. Establish scope. + - Inspect the current branch, worktree state, language mix, and test commands. + - For PR work, determine the correct merge base before judging whether duplication is new. + - Check whether CI already runs linting such as `cargo clippy`; clippy is useful but is not a duplicate-code detector. + +2. Generate duplicate reports. + - Use at least one structural or token-based detector. + - Exclude generated output, vendored dependencies, build artifacts, lockfiles, snapshots, and large fixtures unless the user explicitly wants them included. + - Save reports under `/tmp` or another disposable path; do not commit report output unless asked. + +3. Triage the reports before editing. + - Prioritize logic that can drift: policy checks, wire protocol construction, persistence, filesystem/process/network behavior, lifecycle transitions, cache invalidation, notifications, and cross-entrypoint behavior. + - Treat tests as lower priority unless duplicated setup hides product behavior, makes assertions diverge, or blocks future maintenance. + - Ignore harmless symmetry when abstraction would obscure intent: simple DTO conversions, repeated enum arms, tiny wrappers, trivial UI layout, or generated-looking glue. + +4. Refactor in small slices. + - Extract the smallest shared helper that matches an existing module boundary. + - Preserve public APIs, FFI boundaries, serialized formats, and observable error text unless the user asked to change them. + - Avoid macro layers, broad architecture moves, or generic frameworks unless the payoff is obvious; ask first when unsure. + - Commit each coherent refactor separately when the user asks for incremental commits. + +5. Verify and report. + - Run targeted tests or checks for touched code; run broader checks when shared behavior moved. + - Re-run the duplicate detector after meaningful refactors and report before/after counts. + - Explain which duplicate classes remain and why they are lower priority. + +## Rust Commands + +Use `cargo-dupes` for Rust-focused clone reports when available: + +```bash +cargo dupes --path . \ + --exclude target \ + --exclude node_modules \ + --exclude-tests \ + --min-lines 8 \ + --min-nodes 20 \ + --threshold 0.85 \ + --format json report > /tmp/duplicate-detection-cargo-dupes.json +``` + +If it is missing and installing tools is acceptable in the environment: + +```bash +cargo install cargo-dupes --locked +``` + +`cargo-dupes` may emit multiple JSON values. Read the first report object with `jq -s`: + +```bash +jq -s '.[0] | { + exact_groups: .exact_duplicate_groups, + exact_lines: .exact_duplicate_lines, + near_groups: .near_duplicate_groups, + near_lines: .near_duplicate_lines +}' /tmp/duplicate-detection-cargo-dupes.json +``` + +Run normal Rust quality gates for changed behavior: + +```bash +cargo test +cargo clippy --all-targets --all-features -- -D warnings +``` + +Adjust those commands to match the repo's documented CI. + +## General Commands + +Use `jscpd` for broad token/text clone detection across mixed-language repos: + +```bash +npx --yes jscpd@5 . \ + --min-lines 8 \ + --min-tokens 80 \ + --ignore "**/target/**,**/.git/**,**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/Cargo.lock,**/package-lock.json,**/pnpm-lock.yaml,**/yarn.lock" \ + --reporters json,silent \ + --output /tmp/duplicate-detection-jscpd +``` + +When the repo is mostly Rust, add `--format rust` to reduce noise. For mixed repos, let `jscpd` auto-detect formats or pass a comma-separated format list. + +Summarize the JSON report: + +```bash +jq '.statistics.total | { + clones: .clones, + duplicated_lines: .duplicatedLines, + duplicated_percentage: .percentage +}' /tmp/duplicate-detection-jscpd/jscpd-report.json +``` + +## Review Heuristics + +Ask these questions for each candidate: + +- Would a future change likely need to update both places? +- Is one copy already subtly different in a way that looks accidental? +- Does the duplication cross a boundary where behavior should stay aligned, such as local versus remote, daemon versus client, or API versus UI? +- Can the shared helper be named after a real domain concept rather than after the duplicated syntax? +- Will the refactor reduce branching and tests, or just hide two readable blocks behind indirection? + +Proceed when the answers point to drift risk and the extraction is local. Leave the duplication alone when a shared abstraction would be more fragile than the repeated code. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eba4981da..4ce5b961b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,6 +64,14 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings + - name: Daemon/client split gates + run: | + scripts/audit-daemon-client-gaps.sh + if cargo tree -p okena-daemon --edges no-dev | grep -E 'gpui|gpui-component'; then + echo "okena-daemon must stay GPUI-free" + exit 1 + fi + - name: Run tests run: cargo test @@ -163,7 +171,7 @@ jobs: run: cd web && bun install --frozen-lockfile && bun run build - name: Build - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --release --target ${{ matrix.target }} -p okena -p okena-daemon - name: Prepare artifact (Linux) if: runner.os == 'Linux' @@ -171,6 +179,10 @@ jobs: mkdir -p dist/icons cp target/${{ matrix.target }}/release/okena dist/ chmod +x dist/okena + # Ship the GPUI-free daemon sibling so the app runs the real daemon + # binary instead of falling back to `okena --headless`. + cp target/${{ matrix.target }}/release/okena-daemon dist/ + chmod +x dist/okena-daemon # Bundle icons for Linux installation cp assets/app-icon-16.png assets/app-icon-32.png assets/app-icon-48.png \ assets/app-icon-64.png assets/app-icon-128.png assets/app-icon-256.png \ @@ -188,6 +200,8 @@ jobs: run: | mkdir dist copy target\${{ matrix.target }}\release\okena.exe dist\ + rem Ship the GPUI-free daemon sibling (see Linux step). + copy target\${{ matrix.target }}\release\okena-daemon.exe dist\ - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index feef9e642..e931ab525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -6058,6 +6083,7 @@ dependencies = [ "okena-ext-updater", "okena-extensions", "okena-files", + "okena-remote-server", "okena-terminal", "okena-ui", "okena-views-git", @@ -6071,7 +6097,7 @@ dependencies = [ [[package]] name = "okena-app" -version = "0.1.0" +version = "0.27.0" dependencies = [ "anyhow", "arc-swap", @@ -6140,7 +6166,6 @@ dependencies = [ "base64", "clap", "dirs 5.0.1", - "libc", "okena-core", "okena-remote-server", "okena-transport", @@ -6167,6 +6192,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "okena-daemon" +version = "0.27.0" +dependencies = [ + "anyhow", + "env_logger", + "log", + "okena-core", + "okena-daemon-core", + "okena-remote-server", + "okena-workspace", +] + +[[package]] +name = "okena-daemon-core" +version = "0.27.0" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "log", + "okena-app-core", + "okena-core", + "okena-git", + "okena-hooks", + "okena-remote-server", + "okena-services", + "okena-state", + "okena-terminal", + "okena-theme", + "okena-workspace", + "parking_lot", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "okena-ext-claude" version = "0.1.0" @@ -6238,7 +6299,9 @@ dependencies = [ "okena-transport", "okena-ui", "parking_lot", + "reqwest", "semver", + "serde", "serde_json", "sha2", "smol", @@ -6372,7 +6435,7 @@ dependencies = [ [[package]] name = "okena-remote-server" -version = "0.1.0" +version = "0.27.0" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -6383,9 +6446,9 @@ dependencies = [ "hmac", "hyper", "hyper-util", - "libc", "log", "okena-core", + "okena-ext-updater", "okena-terminal", "okena-transport", "okena-workspace", @@ -6400,6 +6463,7 @@ dependencies = [ "serde_json", "sha2", "subtle", + "sysinfo 0.33.1", "tempfile", "tokio", "tokio-rustls", @@ -6413,7 +6477,6 @@ version = "0.1.0" dependencies = [ "async-channel 2.5.0", "gpui", - "libc", "log", "okena-core", "okena-terminal", @@ -6487,6 +6550,25 @@ dependencies = [ "tokio-tungstenite 0.24.0", ] +[[package]] +name = "okena-tui" +version = "0.27.0" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "clap", + "crossterm", + "env_logger", + "log", + "okena-core", + "okena-terminal", + "okena-transport", + "parking_lot", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "okena-ui" version = "0.1.0" @@ -6583,6 +6665,7 @@ dependencies = [ "okena-views-services", "okena-views-terminal", "okena-workspace", + "serde_json", "smol", ] @@ -8495,6 +8578,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -9275,6 +9369,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index ce300ec71..fbeb11778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,13 @@ [workspace] -members = [".", "crates/okena-mobile-ffi", "crates/okena-core", "crates/okena-transport", "crates/okena-git", "crates/okena-views-git", "crates/okena-views-services", "crates/okena-views-sidebar", "crates/okena-views-terminal", "crates/okena-terminal", "crates/okena-layout", "crates/okena-state", "crates/okena-hooks", "crates/okena-workspace", "crates/okena-ui", "crates/okena-usage", "crates/okena-files", "crates/okena-markdown", "crates/okena-extensions", "crates/okena-ext-claude", "crates/okena-ext-codex", "crates/okena-ext-github", "crates/okena-ext-updater", "crates/okena-services", "crates/okena-remote-client", "crates/okena-remote-server", "crates/okena-views-remote", "crates/okena-theme", "crates/okena-cli", "crates/okena-app-core", "crates/okena-app"] +members = [".", "crates/okena-mobile-ffi", "crates/okena-core", "crates/okena-transport", "crates/okena-git", "crates/okena-views-git", "crates/okena-views-services", "crates/okena-views-sidebar", "crates/okena-views-terminal", "crates/okena-terminal", "crates/okena-layout", "crates/okena-state", "crates/okena-hooks", "crates/okena-workspace", "crates/okena-ui", "crates/okena-usage", "crates/okena-files", "crates/okena-markdown", "crates/okena-extensions", "crates/okena-ext-claude", "crates/okena-ext-codex", "crates/okena-ext-github", "crates/okena-ext-updater", "crates/okena-services", "crates/okena-remote-client", "crates/okena-remote-server", "crates/okena-views-remote", "crates/okena-theme", "crates/okena-cli", "crates/okena-app-core", "crates/okena-app", "crates/okena-daemon-core", "crates/okena-daemon", "crates/okena-tui"] resolver = "2" +[workspace.package] +version = "0.27.0" + [package] name = "okena" -version = "0.27.0" +version.workspace = true edition = "2024" license = "MIT" @@ -26,6 +29,11 @@ okena-views-terminal = { path = "crates/okena-views-terminal" } okena-views-git = { path = "crates/okena-views-git" } okena-ui = { path = "crates/okena-ui" } +# `--daemon-client` startup: ensure_local_daemon() (discover-or-spawn the local +# headless daemon + mint a loopback token). Already a transitive dep via +# okena-app, so this adds no new linkage — only a direct path to the `local` API. +okena-remote-server = { path = "crates/okena-remote-server" } + # Extension system — registered at startup in main.rs. okena-extensions = { path = "crates/okena-extensions" } okena-ext-claude = { path = "crates/okena-ext-claude" } diff --git a/crates/okena-app-core/Cargo.toml b/crates/okena-app-core/Cargo.toml index ceb7487e9..6048a2477 100644 --- a/crates/okena-app-core/Cargo.toml +++ b/crates/okena-app-core/Cargo.toml @@ -4,15 +4,19 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui"] +gpui = ["dep:gpui", "okena-workspace/gpui", "okena-files/gpui", "okena-theme/gpui"] + [dependencies] okena-core = { path = "../okena-core" } okena-terminal = { path = "../okena-terminal" } -okena-workspace = { path = "../okena-workspace" } +okena-workspace = { path = "../okena-workspace", default-features = false } okena-git = { path = "../okena-git" } -okena-files = { path = "../okena-files" } -okena-theme = { path = "../okena-theme" } +okena-files = { path = "../okena-files", default-features = false } +okena-theme = { path = "../okena-theme", default-features = false } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } # Terminal emulation (grid/index types used by action execution) alacritty_terminal = "0.25" @@ -25,3 +29,15 @@ serde_json = "1.0" log = "0.4" uuid = { version = "1.10", features = ["v4"] } base64 = "0.22" + +# `gpui` here is the *same* optional dependency declared in `[dependencies]`; +# this entry only layers on the `test-support` feature so the gpui-gated test +# modules can use `#[gpui::test]` / `TestAppContext`. As a dev-dependency it is +# never part of a production (non-test) build, so the shipped binary's gpui +# feature set is unchanged. It is intentionally NOT routed through the `gpui` +# feature: doing so would enable `test-support` in consumers' production builds +# (a real feature-set change). A `--no-default-features` build never compiles +# gpui (verified: zero `Compiling gpui`); the dependency only appears in the +# `cargo tree` metadata graph, not in any compiled artifact. +[dev-dependencies] +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", features = ["test-support"] } diff --git a/crates/okena-app-core/src/lib.rs b/crates/okena-app-core/src/lib.rs index 61224f696..e30e52dff 100644 --- a/crates/okena-app-core/src/lib.rs +++ b/crates/okena-app-core/src/lib.rs @@ -2,5 +2,7 @@ //! action-execution glue over the workspace. Decoupled from the UI views and //! the app coordinator (which still live in the `okena` binary for now). +pub mod remote_config; +pub mod remote_snapshot; pub mod settings; pub mod workspace; diff --git a/crates/okena-app-core/src/remote_config.rs b/crates/okena-app-core/src/remote_config.rs new file mode 100644 index 000000000..febe49d34 --- /dev/null +++ b/crates/okena-app-core/src/remote_config.rs @@ -0,0 +1,447 @@ +//! Shared settings & theme handlers for the remote-control API, used by both +//! the GPUI desktop app and the headless daemon. +//! +//! The two front-ends serve the same app-scoped remote actions (`GetSettings`, +//! `SetSettings`, `GetThemes`, `GetTheme`, `SetTheme`, `SaveCustomTheme`, +//! `GetSettingsSchema`) but differ in exactly three places: +//! +//! 1. how they read/write the backing `AppSettings` store, +//! 2. whether they also apply the active theme to a live surface (the GUI has +//! an `AppTheme` global; the daemon is headless and has none), and +//! 3. the source of the "active theme" colors for `get_theme(None)` (the GUI +//! reads the live `AppTheme.display_colors()`; the daemon derives them from +//! the persisted `theme_mode`). +//! +//! Those three divergent pieces are abstracted behind [`ConfigBackend`]; the +//! rest of the logic (deep-merge, validation, theme-list assembly, id +//! normalization, JSON shapes and error strings) lives here once and is shared +//! verbatim. +//! +//! This module is GPUI-free so it compiles under `--no-default-features`; the +//! GUI supplies a `&mut App`-holding backend, the daemon an +//! `Arc>`-holding one. + +use okena_core::api::CommandResult; +use okena_theme::custom::{get_themes_dir, load_custom_themes}; +use okena_theme::{ + CustomThemeColors, CustomThemeConfig, ThemeColors, ThemeMode, DARK_THEME, HIGH_CONTRAST_THEME, + LIGHT_THEME, PASTEL_DARK_THEME, +}; +use okena_workspace::persistence::AppSettings; +use serde_json::{json, Value}; + +/// Abstracts the three front-end-specific pieces of the settings/theme handlers +/// (backing store, live-theme application, active-theme color source) so the +/// rest of the logic can be shared between the GPUI app and the headless daemon. +pub trait ConfigBackend { + /// Read the current settings snapshot. + fn load_settings(&mut self) -> AppSettings; + + /// Persist the new settings (and, on the GUI, notify observers). Returns an + /// error string on failure so the caller can surface it verbatim. + fn store_settings(&mut self, new: &AppSettings) -> Result<(), String>; + + /// Apply the active theme to any live surface. The daemon is headless and + /// implements this as a no-op; the GUI updates the `AppTheme` global. + /// + /// `custom_colors` is `Some` when a custom theme is being activated (in + /// which case `mode` is [`ThemeMode::Custom`]) and `None` for built-ins. + fn apply_active_theme(&mut self, mode: ThemeMode, custom_colors: Option); + + /// Colors for the "active theme" editable blob returned by + /// [`get_theme`] with `id == None`. The GUI reads the live + /// `AppTheme.display_colors()`; the daemon derives them from `mode`. + fn active_theme_colors(&mut self, mode: ThemeMode, custom_id: Option<&str>) -> ThemeColors; +} + +// ── Settings ───────────────────────────────────────────────────────────────── + +/// Return the full current settings as JSON. +pub fn get_settings(b: &mut B) -> CommandResult { + let current = b.load_settings(); + match serde_json::to_value(¤t) { + Ok(v) => CommandResult::Ok(Some(v)), + Err(e) => CommandResult::Err(format!("failed to serialize settings: {e}")), + } +} + +/// Return a defaults instance of the settings — every key with its default +/// value, as a de-facto schema agents can read to discover available keys. +pub fn get_settings_schema() -> CommandResult { + // Every field has a serde default, so an empty object yields all defaults. + match serde_json::from_value::(json!({})) { + Ok(defaults) => match serde_json::to_value(&defaults) { + Ok(v) => CommandResult::Ok(Some(v)), + Err(e) => CommandResult::Err(format!("failed to serialize schema: {e}")), + }, + Err(e) => CommandResult::Err(format!("failed to build schema: {e}")), + } +} + +/// Deep-merge `patch` into the current settings, validate by re-deserializing, +/// then replace and persist via the backend. +pub fn set_settings(b: &mut B, patch: Value) -> CommandResult { + let current = b.load_settings(); + let mut value = match serde_json::to_value(¤t) { + Ok(v) => v, + Err(e) => return CommandResult::Err(format!("failed to read settings: {e}")), + }; + merge_json(&mut value, patch); + let new: AppSettings = match serde_json::from_value(value) { + Ok(s) => s, + Err(e) => return CommandResult::Err(format!("invalid settings: {e}")), + }; + let out = match serde_json::to_value(&new) { + Ok(v) => v, + Err(e) => return CommandResult::Err(format!("failed to serialize settings: {e}")), + }; + if let Err(e) = b.store_settings(&new) { + return CommandResult::Err(format!("failed to save settings: {e}")); + } + CommandResult::Ok(Some(out)) +} + +/// Recursively merge `patch` into `base`: objects merge key-by-key, everything +/// else (scalars, arrays) is overwritten wholesale. +fn merge_json(base: &mut Value, patch: Value) { + match (base, patch) { + (Value::Object(b), Value::Object(p)) => { + for (k, v) in p { + merge_json(b.entry(k).or_insert(Value::Null), v); + } + } + (b, p) => *b = p, + } +} + +// ── Theme ──────────────────────────────────────────────────────────────────── + +/// List built-in + custom themes, flagging the active one. +pub fn get_themes(b: &mut B) -> CommandResult { + let settings = b.load_settings(); + let mode = settings.theme_mode; + let active_custom = settings.custom_theme_id.clone(); + + let custom = load_custom_themes(); + let custom_refs: Vec<(String, String, bool)> = custom + .iter() + .map(|(info, _colors)| { + let cid = info.id.strip_prefix("custom:").unwrap_or(&info.id).to_string(); + (cid, info.name.clone(), info.is_dark) + }) + .collect(); + let themes = build_themes_list(mode, active_custom.as_deref(), &custom_refs); + CommandResult::Ok(Some(json!({ "themes": themes }))) +} + +/// Return a theme as an editable custom-theme blob (the active theme when +/// `id` is None). +pub fn get_theme(b: &mut B, id: Option) -> CommandResult { + let (name, is_dark, colors) = match id.as_deref() { + None => { + let settings = b.load_settings(); + let mode = settings.theme_mode; + let custom_id = settings.custom_theme_id.clone(); + let colors = b.active_theme_colors(mode, custom_id.as_deref()); + ( + format!("Active ({})", mode_label(mode)), + mode != ThemeMode::Light, + colors, + ) + } + Some(raw) => match builtin_colors(raw) { + Some((name, is_dark, colors)) => (name.to_string(), is_dark, colors), + None => { + let cid = raw.strip_prefix("custom:").unwrap_or(raw); + let target = format!("custom:{cid}"); + match load_custom_themes().into_iter().find(|(i, _)| i.id == target) { + Some((info, colors)) => (info.name, info.is_dark, colors), + None => return CommandResult::Err(format!("theme not found: {raw}")), + } + } + }, + }; + let blob = CustomThemeConfig { + name, + description: String::new(), + is_dark, + colors: CustomThemeColors::from_theme_colors(&colors), + }; + match serde_json::to_value(&blob) { + Ok(v) => CommandResult::Ok(Some(v)), + Err(e) => CommandResult::Err(format!("failed to serialize theme: {e}")), + } +} + +/// Activate a theme: a built-in mode or a custom theme id. Persists the +/// preference and applies it to any live surface. +pub fn set_theme(b: &mut B, id: String) -> CommandResult { + if let Some(mode) = builtin_mode(&id) { + let mut new = b.load_settings(); + new.theme_mode = mode; + new.custom_theme_id = None; + if let Err(e) = b.store_settings(&new) { + return CommandResult::Err(format!("failed to save settings: {e}")); + } + b.apply_active_theme(mode, None); + CommandResult::Ok(Some(json!({ "active": mode_label(mode) }))) + } else { + let cid = id.strip_prefix("custom:").unwrap_or(&id).to_string(); + let target = format!("custom:{cid}"); + match load_custom_themes().into_iter().find(|(i, _)| i.id == target) { + Some((_, colors)) => apply_custom(b, cid, colors), + None => CommandResult::Err(format!("theme not found: {id}")), + } + } +} + +/// Write a custom theme JSON file (a full `CustomThemeConfig`) and, when +/// `activate`, switch to it. +pub fn save_custom_theme( + b: &mut B, + id: String, + config: Value, + activate: bool, +) -> CommandResult { + let cid = id.strip_prefix("custom:").unwrap_or(&id).to_string(); + if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return CommandResult::Err(format!( + "invalid theme id '{cid}' (use letters, digits, '-' or '_')" + )); + } + // Validate by deserializing into the typed config (serde fills any missing + // colors with defaults). + let parsed: CustomThemeConfig = match serde_json::from_value(config) { + Ok(c) => c, + Err(e) => return CommandResult::Err(format!("invalid theme config: {e}")), + }; + let dir = get_themes_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + return CommandResult::Err(format!("failed to create themes dir: {e}")); + } + let path = dir.join(format!("{cid}.json")); + let pretty = match serde_json::to_string_pretty(&parsed) { + Ok(s) => s, + Err(e) => return CommandResult::Err(format!("failed to serialize theme: {e}")), + }; + if let Err(e) = std::fs::write(&path, pretty) { + return CommandResult::Err(format!("failed to write {}: {e}", path.display())); + } + if activate { + let colors = parsed.colors.to_theme_colors(); + return apply_custom(b, cid, colors); + } + CommandResult::Ok(Some(json!({ "id": cid, "path": path.display().to_string() }))) +} + +/// Persist a custom theme preference and apply it to any live surface. +fn apply_custom(b: &mut B, cid: String, colors: ThemeColors) -> CommandResult { + let mut new = b.load_settings(); + new.theme_mode = ThemeMode::Custom; + new.custom_theme_id = Some(cid.clone()); + if let Err(e) = b.store_settings(&new) { + return CommandResult::Err(format!("failed to save settings: {e}")); + } + b.apply_active_theme(ThemeMode::Custom, Some(colors)); + CommandResult::Ok(Some(json!({ "active": format!("custom:{cid}") }))) +} + +/// Build the themes list (built-ins + custom) flagging the active one. Pure: +/// the FS read (`load_custom_themes`) happens in the caller and is passed in as +/// `custom` triples of `(id, name, is_dark)` (the `custom:` prefix already +/// stripped from the id). +fn build_themes_list( + mode: ThemeMode, + active_custom: Option<&str>, + custom: &[(String, String, bool)], +) -> Vec { + let mut themes = Vec::new(); + for (id, name, is_dark) in [ + ("auto", "Auto", Value::Null), + ("dark", "Dark", json!(true)), + ("light", "Light", json!(false)), + ("pastel-dark", "Pastel Dark", json!(true)), + ("high-contrast", "High Contrast", json!(true)), + ] { + let active = mode != ThemeMode::Custom && builtin_mode(id) == Some(mode); + themes.push(json!({ + "id": id, "name": name, "kind": "builtin", "is_dark": is_dark, "active": active, + })); + } + for (cid, name, is_dark) in custom { + let active = mode == ThemeMode::Custom && active_custom == Some(cid.as_str()); + themes.push(json!({ + "id": cid, "name": name, "kind": "custom", + "is_dark": is_dark, "active": active, + })); + } + themes +} + +/// Normalize a theme id ("Pastel Dark" / "pastel-dark" → "pasteldark") and map +/// to a built-in [`ThemeMode`]. Returns None for custom ids. +pub fn builtin_mode(id: &str) -> Option { + let n = id.to_ascii_lowercase().replace(['-', '_', ' '], ""); + match n.as_str() { + "auto" => Some(ThemeMode::Auto), + "dark" => Some(ThemeMode::Dark), + "light" => Some(ThemeMode::Light), + "pasteldark" => Some(ThemeMode::PastelDark), + "highcontrast" => Some(ThemeMode::HighContrast), + _ => None, + } +} + +/// Concrete colors for a built-in theme id (None for "auto" and custom ids). +pub fn builtin_colors(id: &str) -> Option<(&'static str, bool, ThemeColors)> { + let n = id.to_ascii_lowercase().replace(['-', '_', ' '], ""); + match n.as_str() { + "dark" => Some(("Dark", true, DARK_THEME)), + "light" => Some(("Light", false, LIGHT_THEME)), + "pasteldark" => Some(("Pastel Dark", true, PASTEL_DARK_THEME)), + "highcontrast" => Some(("High Contrast", true, HIGH_CONTRAST_THEME)), + _ => None, + } +} + +pub fn mode_label(mode: ThemeMode) -> &'static str { + match mode { + ThemeMode::Auto => "auto", + ThemeMode::Dark => "dark", + ThemeMode::Light => "light", + ThemeMode::PastelDark => "pastel-dark", + ThemeMode::HighContrast => "high-contrast", + ThemeMode::Custom => "custom", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_json_deep_merges_objects() { + let mut base = json!({ + "a": 1, + "nested": { "x": 1, "y": 2 }, + }); + merge_json( + &mut base, + json!({ + "b": 2, + "nested": { "y": 20, "z": 30 }, + }), + ); + assert_eq!( + base, + json!({ + "a": 1, + "b": 2, + "nested": { "x": 1, "y": 20, "z": 30 }, + }) + ); + } + + #[test] + fn merge_json_overwrites_scalars_and_arrays_wholesale() { + let mut base = json!({ "n": 1, "list": [1, 2, 3] }); + merge_json(&mut base, json!({ "n": 99, "list": [4] })); + assert_eq!(base, json!({ "n": 99, "list": [4] })); + + // A scalar replaced by an object is overwritten wholesale, not merged. + let mut base = json!({ "k": 5 }); + merge_json(&mut base, json!({ "k": { "deep": true } })); + assert_eq!(base, json!({ "k": { "deep": true } })); + } + + #[test] + fn builtin_mode_normalizes_variants() { + assert_eq!(builtin_mode("auto"), Some(ThemeMode::Auto)); + assert_eq!(builtin_mode("dark"), Some(ThemeMode::Dark)); + assert_eq!(builtin_mode("light"), Some(ThemeMode::Light)); + assert_eq!(builtin_mode("high-contrast"), Some(ThemeMode::HighContrast)); + // All three spellings of pastel dark normalize to the same mode. + assert_eq!(builtin_mode("Pastel Dark"), Some(ThemeMode::PastelDark)); + assert_eq!(builtin_mode("pastel-dark"), Some(ThemeMode::PastelDark)); + assert_eq!(builtin_mode("pasteldark"), Some(ThemeMode::PastelDark)); + // Unknown / custom ids are not built-ins. + assert_eq!(builtin_mode("custom:mine"), None); + assert_eq!(builtin_mode("nonsense"), None); + } + + #[test] + fn builtin_colors_resolves_only_concrete_builtins() { + assert!(builtin_colors("dark").is_some()); + assert!(builtin_colors("light").is_some()); + assert!(builtin_colors("Pastel Dark").is_some()); + assert!(builtin_colors("high-contrast").is_some()); + // "auto" has no concrete colors (it follows the system), and custom + // ids are resolved elsewhere. + assert!(builtin_colors("auto").is_none()); + assert!(builtin_colors("custom:mine").is_none()); + } + + #[test] + fn get_settings_schema_contains_expected_keys() { + match get_settings_schema() { + CommandResult::Ok(Some(v)) => { + let obj = v.as_object().expect("schema is an object"); + assert!(obj.contains_key("font_size")); + assert!(obj.contains_key("theme_mode")); + assert!(obj.contains_key("font_family")); + // The schema deserializes back into AppSettings (it IS the + // defaults instance). + serde_json::from_value::(v).expect("schema round-trips"); + } + other => panic!("expected Ok(Some), got {other:?}"), + } + } + + #[test] + fn build_themes_list_flags_active_builtin() { + let custom = vec![("mine".to_string(), "Mine".to_string(), true)]; + let themes = build_themes_list(ThemeMode::PastelDark, None, &custom); + + // Exactly the pastel-dark built-in is active; no custom theme is active + // because mode != Custom. + for t in &themes { + let id = t["id"].as_str().unwrap(); + let active = t["active"].as_bool().unwrap(); + if id == "pastel-dark" { + assert!(active, "pastel-dark should be active"); + } else { + assert!(!active, "{id} should be inactive"); + } + } + } + + #[test] + fn build_themes_list_flags_active_custom() { + let custom = vec![ + ("mine".to_string(), "Mine".to_string(), true), + ("other".to_string(), "Other".to_string(), false), + ]; + let themes = build_themes_list(ThemeMode::Custom, Some("mine"), &custom); + + // No built-in is active (mode == Custom), and only the matching custom + // id is flagged. + for t in &themes { + let id = t["id"].as_str().unwrap(); + let kind = t["kind"].as_str().unwrap(); + let active = t["active"].as_bool().unwrap(); + match (kind, id) { + ("custom", "mine") => assert!(active, "active custom should be flagged"), + _ => assert!(!active, "{kind}/{id} should be inactive"), + } + } + } + + #[test] + fn build_themes_list_no_custom_active_when_id_mismatches() { + let custom = vec![("mine".to_string(), "Mine".to_string(), true)]; + let themes = build_themes_list(ThemeMode::Custom, Some("missing"), &custom); + // Custom mode but the active id doesn't match any present custom theme: + // nothing is active. + assert!(themes.iter().all(|t| !t["active"].as_bool().unwrap())); + } +} diff --git a/crates/okena-app-core/src/remote_snapshot.rs b/crates/okena-app-core/src/remote_snapshot.rs new file mode 100644 index 000000000..46616e7eb --- /dev/null +++ b/crates/okena-app-core/src/remote_snapshot.rs @@ -0,0 +1,191 @@ +//! Shared, gpui-free assembly of the remote `GetState` snapshot. +//! +//! Both remote command loops answer `RemoteCommand::GetState` by projecting the +//! same [`WorkspaceData`] onto the same wire DTOs: +//! +//! * GUI: `okena-app`'s `app/remote_commands.rs` `remote_command_loop` +//! (reads an `Entity` / `Entity`). +//! * Headless: `okena-daemon-core`'s `command_loop.rs` `daemon_command_loop` +//! (reads `Arc>` / `Arc>`). +//! +//! The two differ only in how they *gather* the inputs (entity reads vs. lock +//! guards) and in how they enumerate windows (GUI enumerates real OS windows; +//! the daemon serves a single synthetic `"main"` window). The pure projection — +//! ordered projects (following `project_order` + folder expansion + orphans), +//! folders, and the final [`StateResponse`] — is identical, so it lives here. +//! +//! Each caller pre-builds `services_by_project` (project id → wire +//! [`ApiServiceInfo`] list) from its own `ServiceManager` (via +//! `ServiceInstance::to_api`), keeping the `okena-services` dependency out of +//! `okena-app-core`, then hands plain data to these builders. + +use std::collections::{HashMap, HashSet}; + +use okena_core::api::{ + ApiFolder, ApiFullscreen, ApiGitStatus, ApiProject, ApiServiceInfo, ApiWindow, ApiWorktreeMetadata, + StateResponse, +}; +use okena_workspace::state::{FolderData, ProjectData, WorkspaceData}; + +/// Pure visibility projection for the remote `ApiProject.show_in_overview` wire +/// flag: a project is "shown in overview" iff it is absent from the per-window +/// hidden set (today: `main_window.hidden_project_ids`). +pub fn api_project_visibility(project_id: &str, hidden_project_ids: &HashSet) -> bool { + !hidden_project_ids.contains(project_id) +} + +/// Project a single [`ProjectData`] onto its wire [`ApiProject`]. +/// +/// Inputs are already gathered by the caller: +/// * `git_statuses` — project id → latest [`ApiGitStatus`]. +/// * `services_by_project` — project id → wire service list (built from the +/// caller's `ServiceManager`; absent ⇒ no services). +/// * `hidden_project_ids` — per-window hidden set driving `show_in_overview`. +/// * `size_map` — terminal id → `(cols, rows)` for `layout.to_api_with_sizes`. +pub fn build_api_project( + p: &ProjectData, + git_statuses: &HashMap, + services_by_project: &HashMap>, + hidden_project_ids: &HashSet, + size_map: &HashMap, +) -> ApiProject { + ApiProject { + id: p.id.clone(), + name: p.name.clone(), + path: p.path.clone(), + show_in_overview: api_project_visibility(&p.id, hidden_project_ids), + layout: p.layout.as_ref().map(|l| l.to_api_with_sizes(size_map)), + terminal_names: p.terminal_names.clone(), + git_status: git_statuses.get(&p.id).cloned(), + folder_color: p.folder_color, + services: services_by_project.get(&p.id).cloned().unwrap_or_default(), + worktree_info: p.worktree_info.as_ref().map(|wt| ApiWorktreeMetadata { + parent_project_id: wt.parent_project_id.clone(), + color_override: wt.color_override, + }), + worktree_ids: p.worktree_ids.clone(), + pinned: p.pinned, + last_activity_at: p.last_activity_at, + default_shell: p.default_shell.clone(), + hook_terminals: p + .hook_terminals + .iter() + .map(|(tid, e)| e.to_api(tid.clone())) + .collect(), + hooks: p.hooks.to_api(), + } +} + +/// Build the ordered wire project list following `project_order` + folder +/// expansion, then appending orphan projects not referenced by any order entry. +/// +/// Mirrors the historical inline loop shared by both command loops: for each id +/// in `project_order`, if it names a folder expand its `project_ids` in order, +/// otherwise treat it as a project id; a `seen` set dedupes so a project listed +/// both directly and via a folder appears once. +pub fn build_api_projects( + data: &WorkspaceData, + git_statuses: &HashMap, + services_by_project: &HashMap>, + hidden_project_ids: &HashSet, + size_map: &HashMap, +) -> Vec { + let project_map: HashMap<&str, &ProjectData> = + data.projects.iter().map(|p| (p.id.as_str(), p)).collect(); + + let mut projects: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + let push = |projects: &mut Vec, p: &ProjectData| { + projects.push(build_api_project( + p, + git_statuses, + services_by_project, + hidden_project_ids, + size_map, + )); + }; + + for id in &data.project_order { + if let Some(folder) = data.folders.iter().find(|f| &f.id == id) { + for pid in &folder.project_ids { + if seen.insert(pid.clone()) + && let Some(p) = project_map.get(pid.as_str()) + { + push(&mut projects, p); + } + } + } else if seen.insert(id.clone()) + && let Some(p) = project_map.get(id.as_str()) + { + push(&mut projects, p); + } + } + + // Append orphan projects not in any order. + for p in &data.projects { + if seen.insert(p.id.clone()) { + push(&mut projects, p); + } + } + + projects +} + +/// Project the workspace folder list onto the wire [`ApiFolder`] list. +pub fn build_folders(folders: &[FolderData]) -> Vec { + folders + .iter() + .map(|f| ApiFolder { + id: f.id.clone(), + name: f.name.clone(), + project_ids: f.project_ids.clone(), + folder_color: f.folder_color, + }) + .collect() +} + +/// Assemble the final [`StateResponse`] from already-gathered inputs. +/// +/// `windows` is supplied by the caller (GUI enumerates real OS windows; the +/// daemon passes a single synthetic `"main"` window). The back-compat flat +/// fields (`focused_project_id`, `fullscreen_terminal`) are derived here from +/// the active window so old clients keep a sensible focused project / +/// fullscreen. +pub fn build_state_response( + state_version: u64, + data: &WorkspaceData, + git_statuses: &HashMap, + services_by_project: &HashMap>, + hidden_project_ids: &HashSet, + size_map: &HashMap, + windows: Vec, +) -> StateResponse { + let projects = build_api_projects( + data, + git_statuses, + services_by_project, + hidden_project_ids, + size_map, + ); + let folders = build_folders(&data.folders); + + let focused_project_id: Option = windows + .iter() + .find(|w| w.active) + .and_then(|w| w.focused_project_id.clone()); + let fullscreen: Option = windows + .iter() + .find(|w| w.active) + .and_then(|w| w.fullscreen.clone()); + + StateResponse { + state_version, + projects, + focused_project_id, + fullscreen_terminal: fullscreen, + project_order: data.project_order.clone(), + folders, + windows, + } +} diff --git a/crates/okena-app-core/src/settings.rs b/crates/okena-app-core/src/settings.rs index 0fc769d66..7c2f9c36f 100644 --- a/crates/okena-app-core/src/settings.rs +++ b/crates/okena-app-core/src/settings.rs @@ -3,22 +3,34 @@ //! Provides app-wide access to settings through the GlobalSettings global. //! Settings are automatically persisted to disk with debouncing. +#[cfg(feature = "gpui")] use okena_terminal::session_backend::SessionBackend; +#[cfg(feature = "gpui")] use okena_terminal::shell_config::ShellType; +#[cfg(feature = "gpui")] use okena_theme::ThemeMode; +#[cfg(feature = "gpui")] use okena_workspace::toast::ToastManager; -use crate::workspace::persistence::{load_settings, save_settings, get_settings_path, AppSettings}; +use crate::workspace::persistence::{load_settings, save_settings, get_settings_path}; +#[cfg(feature = "gpui")] +use crate::workspace::persistence::AppSettings; +#[cfg(feature = "gpui")] use gpui::*; +#[cfg(feature = "gpui")] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(feature = "gpui")] use std::sync::Arc; /// Global settings wrapper for app-wide access +#[cfg(feature = "gpui")] #[derive(Clone)] pub struct GlobalSettings(pub Entity); +#[cfg(feature = "gpui")] impl Global for GlobalSettings {} /// Settings state that can be observed and updated +#[cfg(feature = "gpui")] pub struct SettingsState { pub settings: AppSettings, save_pending: Arc, @@ -30,6 +42,7 @@ pub struct SettingsState { } /// Macro to generate setter methods with clamping and auto-save +#[cfg(feature = "gpui")] macro_rules! setting_setter { // For f32 values with min/max clamping ($fn_name:ident, $field:ident, f32, $min:expr, $max:expr) => { @@ -61,6 +74,7 @@ macro_rules! setting_setter { }; } +#[cfg(feature = "gpui")] impl SettingsState { pub fn new(settings: AppSettings) -> Self { let baseline = settings.worktree.path_template.clone(); @@ -391,11 +405,13 @@ impl SettingsState { } /// Get the global settings entity +#[cfg(feature = "gpui")] pub fn settings_entity(cx: &App) -> Entity { cx.global::().0.clone() } /// Get a copy of the current settings +#[cfg(feature = "gpui")] pub fn settings(cx: &App) -> AppSettings { settings_entity(cx).read(cx).settings.clone() } @@ -430,6 +446,7 @@ pub fn open_settings_file() { } /// Initialize global settings - call this at app startup +#[cfg(feature = "gpui")] pub fn init_settings(cx: &mut App) -> Entity { let settings = load_settings(); let entity = cx.new(|_cx| SettingsState::new(settings)); diff --git a/crates/okena-app-core/src/workspace/actions/execute/files.rs b/crates/okena-app-core/src/workspace/actions/execute/files.rs index 3f4aafb18..01dd672f4 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/files.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/files.rs @@ -11,7 +11,7 @@ pub(super) fn list_files(ws: &Workspace, project_id: String, show_ignored: bool) Ok(c) => c, Err(e) => return ActionResult::Err(format!("Cannot resolve project path: {}", e)), }; - let files = okena_files::file_search::FileSearchDialog::scan_files(&path, show_ignored); + let files = okena_files::file_scan::scan_files(&path, show_ignored); ActionResult::Ok(Some(serde_json::to_value(files).expect("BUG: FileEntry must serialize"))) } None => ActionResult::Err(format!("project not found: {}", project_id)), diff --git a/crates/okena-app-core/src/workspace/actions/execute/git.rs b/crates/okena-app-core/src/workspace/actions/execute/git.rs index eaf84e77a..0f17173a2 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/git.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/git.rs @@ -6,27 +6,45 @@ use super::{ActionResult, Workspace}; use okena_core::types::DiffMode; +use std::path::Path; -pub(super) fn status(ws: &Workspace, project_id: String) -> ActionResult { +fn with_project_path( + ws: &Workspace, + project_id: String, + f: impl FnOnce(&Path) -> ActionResult, +) -> ActionResult { match ws.project(&project_id) { - Some(p) => { - let path = p.path.clone(); - let status = okena_git::get_git_status(std::path::Path::new(&path)); - ActionResult::Ok(Some(serde_json::to_value(status).expect("BUG: GitStatus must serialize"))) - } + Some(p) => f(Path::new(&p.path)), None => ActionResult::Err(format!("project not found: {}", project_id)), } } +fn run_project_git_command( + ws: &Workspace, + project_id: String, + f: impl FnOnce(&Path) -> Result<(), E>, +) -> ActionResult +where + E: std::fmt::Display, +{ + with_project_path(ws, project_id, |path| match f(path) { + Ok(()) => ActionResult::Ok(None), + Err(e) => ActionResult::Err(e.to_string()), + }) +} + +pub(super) fn status(ws: &Workspace, project_id: String) -> ActionResult { + with_project_path(ws, project_id, |path| { + let status = okena_git::get_git_status(path); + ActionResult::Ok(Some(serde_json::to_value(status).expect("BUG: GitStatus must serialize"))) + }) +} + pub(super) fn diff_summary(ws: &Workspace, project_id: String) -> ActionResult { - match ws.project(&project_id) { - Some(p) => { - let path = p.path.clone(); - let summary = okena_git::get_diff_file_summary(std::path::Path::new(&path)); - ActionResult::Ok(Some(serde_json::to_value(summary).expect("BUG: FileDiffSummary must serialize"))) - } - None => ActionResult::Err(format!("project not found: {}", project_id)), - } + with_project_path(ws, project_id, |path| { + let summary = okena_git::get_diff_file_summary(path); + ActionResult::Ok(Some(serde_json::to_value(summary).expect("BUG: FileDiffSummary must serialize"))) + }) } pub(super) fn diff(ws: &Workspace, project_id: String, mode: DiffMode, ignore_whitespace: bool) -> ActionResult { @@ -43,14 +61,10 @@ pub(super) fn diff(ws: &Workspace, project_id: String, mode: DiffMode, ignore_wh } pub(super) fn branches(ws: &Workspace, project_id: String) -> ActionResult { - match ws.project(&project_id) { - Some(p) => { - let path = p.path.clone(); - let branches = okena_git::get_available_branches_for_worktree(std::path::Path::new(&path)); - ActionResult::Ok(Some(serde_json::to_value(branches).expect("BUG: branches must serialize"))) - } - None => ActionResult::Err(format!("project not found: {}", project_id)), - } + with_project_path(ws, project_id, |path| { + let branches = okena_git::get_available_branches_for_worktree(path); + ActionResult::Ok(Some(serde_json::to_value(branches).expect("BUG: branches must serialize"))) + }) } pub(super) fn file_contents(ws: &Workspace, project_id: String, file_path: String, mode: DiffMode) -> ActionResult { @@ -72,62 +86,104 @@ pub(super) fn file_contents(ws: &Workspace, project_id: String, file_path: Strin } pub(super) fn commit_graph(ws: &Workspace, project_id: String, count: usize, branch: Option) -> ActionResult { - match ws.project(&project_id) { - Some(p) => { - let path = p.path.clone(); - let entries = okena_git::fetch_commit_log( - std::path::Path::new(&path), - count, - branch.as_deref(), - ); - ActionResult::Ok(Some(serde_json::to_value(entries).expect("BUG: CommitLogEntry must serialize"))) - } - None => ActionResult::Err(format!("project not found: {}", project_id)), - } + with_project_path(ws, project_id, |path| { + let entries = okena_git::fetch_commit_log( + path, + count, + branch.as_deref(), + ); + ActionResult::Ok(Some(serde_json::to_value(entries).expect("BUG: CommitLogEntry must serialize"))) + }) } pub(super) fn list_branches(ws: &Workspace, project_id: String) -> ActionResult { + with_project_path(ws, project_id, |path| { + let branches = okena_git::list_branches(path); + ActionResult::Ok(Some(serde_json::to_value(branches).expect("BUG: branches must serialize"))) + }) +} + +pub(super) fn list_worktrees(ws: &Workspace, project_id: String) -> ActionResult { match ws.project(&project_id) { Some(p) => { let path = p.path.clone(); - let branches = okena_git::list_branches(std::path::Path::new(&path)); - ActionResult::Ok(Some(serde_json::to_value(branches).expect("BUG: branches must serialize"))) + let (git_root, subdir) = okena_git::resolve_git_root_and_subdir(std::path::Path::new(&path)); + let norm_git_root = okena_git::repository::normalize_path(&git_root); + let worktrees = okena_git::repository::list_git_worktrees(&git_root); + ActionResult::Ok(Some(serde_json::json!({ + "git_root": norm_git_root.to_string_lossy(), + "subdir": subdir.to_string_lossy(), + "worktrees": worktrees, + }))) } None => ActionResult::Err(format!("project not found: {}", project_id)), } } -pub(super) fn stage_file(ws: &Workspace, project_id: String, file_path: String) -> ActionResult { +pub(super) fn worktree_close_info(ws: &Workspace, project_id: String) -> ActionResult { match ws.project(&project_id) { Some(p) => { - let path = p.path.clone(); - match okena_git::stage_file(std::path::Path::new(&path), &file_path) { - Ok(()) => ActionResult::Ok(None), - Err(e) => ActionResult::Err(e.to_string()), - } + let project_path = p.path.clone(); + let main_repo_path = ws.worktree_parent_path(&project_id); + let path = std::path::Path::new(&project_path); + let is_dirty = okena_git::has_uncommitted_changes(path); + let branch = okena_git::get_current_branch(path); + let default_branch = main_repo_path + .as_ref() + .and_then(|p| okena_git::get_default_branch(std::path::Path::new(p))); + let unpushed_count = okena_git::count_unpushed_commits(path).unwrap_or(0); + ActionResult::Ok(Some(serde_json::json!({ + "is_dirty": is_dirty, + "branch": branch, + "default_branch": default_branch, + "unpushed_count": unpushed_count, + }))) } None => ActionResult::Err(format!("project not found: {}", project_id)), } } -pub(super) fn unstage_file(ws: &Workspace, project_id: String, file_path: String) -> ActionResult { +pub(super) fn generate_worktree_branch_name(ws: &Workspace, project_id: String) -> ActionResult { match ws.project(&project_id) { Some(p) => { let path = p.path.clone(); - match okena_git::unstage_file(std::path::Path::new(&path), &file_path) { - Ok(()) => ActionResult::Ok(None), - Err(e) => ActionResult::Err(e.to_string()), - } + let (git_root, _subdir) = okena_git::resolve_git_root_and_subdir(std::path::Path::new(&path)); + let branch = okena_git::branch_names::generate_branch_name(&git_root); + ActionResult::Ok(Some(serde_json::json!({ "branch": branch }))) } None => ActionResult::Err(format!("project not found: {}", project_id)), } } -pub(super) fn discard_file(ws: &Workspace, project_id: String, file_path: String) -> ActionResult { +pub(super) fn list_branches_classified(ws: &Workspace, project_id: String) -> ActionResult { + with_project_path(ws, project_id, |path| { + let branches = okena_git::list_branches_classified(path); + ActionResult::Ok(Some(serde_json::to_value(branches).expect("BUG: BranchList must serialize"))) + }) +} + +pub(super) fn checkout_local_branch(ws: &Workspace, project_id: String, branch: String) -> ActionResult { + run_project_git_command(ws, project_id, |path| okena_git::checkout_local_branch(path, &branch)) +} + +pub(super) fn checkout_remote_branch(ws: &Workspace, project_id: String, remote_branch: String) -> ActionResult { + run_project_git_command(ws, project_id, |path| okena_git::checkout_remote_branch(path, &remote_branch)) +} + +pub(super) fn create_and_checkout_branch( + ws: &Workspace, + project_id: String, + new_name: String, + start_point: Option, +) -> ActionResult { match ws.project(&project_id) { Some(p) => { let path = p.path.clone(); - match okena_git::discard_file_changes(std::path::Path::new(&path), &file_path) { + match okena_git::create_and_checkout_branch( + std::path::Path::new(&path), + &new_name, + start_point.as_deref(), + ) { Ok(()) => ActionResult::Ok(None), Err(e) => ActionResult::Err(e.to_string()), } @@ -136,6 +192,18 @@ pub(super) fn discard_file(ws: &Workspace, project_id: String, file_path: String } } +pub(super) fn stage_file(ws: &Workspace, project_id: String, file_path: String) -> ActionResult { + run_project_git_command(ws, project_id, |path| okena_git::stage_file(path, &file_path)) +} + +pub(super) fn unstage_file(ws: &Workspace, project_id: String, file_path: String) -> ActionResult { + run_project_git_command(ws, project_id, |path| okena_git::unstage_file(path, &file_path)) +} + +pub(super) fn discard_file(ws: &Workspace, project_id: String, file_path: String) -> ActionResult { + run_project_git_command(ws, project_id, |path| okena_git::discard_file_changes(path, &file_path)) +} + pub(super) fn blame(ws: &Workspace, project_id: String, relative_path: String) -> ActionResult { match ws.project(&project_id) { Some(p) => { diff --git a/crates/okena-app-core/src/workspace/actions/execute/mod.rs b/crates/okena-app-core/src/workspace/actions/execute/mod.rs index 9f3810fda..b43e50bd5 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/mod.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/mod.rs @@ -12,11 +12,12 @@ mod files; mod git; mod project; +mod session; mod tab; mod terminal; use okena_core::api::{ActionRequest, CommandResult}; -use crate::settings::settings; +use crate::workspace::persistence::AppSettings; use okena_terminal::backend::TerminalBackend; use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::{Terminal, TerminalSize}; @@ -24,7 +25,7 @@ use okena_terminal::TerminalsRegistry; use crate::workspace::focus::FocusManager; use crate::workspace::hooks; use crate::workspace::state::{LayoutNode, WindowId, Workspace}; -use gpui::*; +use okena_workspace::context::WorkspaceCx; use std::sync::Arc; /// Result of executing an action. @@ -48,6 +49,10 @@ impl ActionResult { /// /// This is the single source of truth for all client-facing actions. /// Both desktop UI handlers and the remote API delegate here. +// Takes the workspace, focus manager, backend, terminals registry, settings +// and cx as distinct dependencies; bundling them into a context struct would +// obscure more than it clarifies here (matching the sub-handler modules). +#[allow(clippy::too_many_arguments)] pub fn execute_action( action: ActionRequest, ws: &mut Workspace, @@ -55,15 +60,16 @@ pub fn execute_action( focus_manager: &mut FocusManager, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { match action { // ── Terminal ops ───────────────────────────────────────────── ActionRequest::CreateTerminal { project_id } => { - terminal::create(ws, focus_manager, project_id, backend, terminals, cx) + terminal::create(ws, focus_manager, project_id, backend, terminals, settings, cx) } ActionRequest::SplitTerminal { project_id, path, direction } => { - terminal::split(ws, focus_manager, project_id, path, direction, backend, terminals, cx) + terminal::split(ws, focus_manager, project_id, path, direction, backend, terminals, settings, cx) } ActionRequest::CloseTerminal { project_id, terminal_id } => { terminal::close(ws, focus_manager, project_id, terminal_id, backend, terminals, cx) @@ -101,13 +107,25 @@ pub fn execute_action( ActionRequest::RenameTerminal { project_id, terminal_id, name } => { terminal::rename(ws, project_id, terminal_id, name, cx) } + ActionRequest::SwitchTerminalShell { project_id, terminal_id, shell } => { + terminal::switch_shell(ws, project_id, terminal_id, shell, backend, terminals, settings, cx) + } + ActionRequest::AddDiscoveredWorktree { parent_project_id, worktree_path, branch } => { + project::add_discovered_worktree(ws, window_id, parent_project_id, worktree_path, branch, backend, terminals, settings, cx) + } + ActionRequest::RerunHook { project_id, terminal_id } => { + project::rerun_hook(ws, project_id, terminal_id, backend, terminals, cx) + } ActionRequest::ReadContent { terminal_id } => { terminal::read_content(ws, terminal_id, backend, terminals) } + ActionRequest::ExportBuffer { terminal_id } => { + terminal::export_buffer(terminal_id, backend) + } // ── Tab / pane-move ops ────────────────────────────────────── ActionRequest::AddTab { project_id, path, in_group } => { - tab::add_tab(ws, focus_manager, project_id, path, in_group, backend, terminals, cx) + tab::add_tab(ws, focus_manager, project_id, path, in_group, backend, terminals, settings, cx) } ActionRequest::SetActiveTab { project_id, path, index } => { tab::set_active_tab(ws, project_id, path, index, cx) @@ -136,6 +154,21 @@ pub fn execute_action( git::commit_graph(ws, project_id, count, branch) } ActionRequest::GitListBranches { project_id } => git::list_branches(ws, project_id), + ActionRequest::GitListWorktrees { project_id } => git::list_worktrees(ws, project_id), + ActionRequest::WorktreeCloseInfo { project_id } => git::worktree_close_info(ws, project_id), + ActionRequest::GenerateWorktreeBranchName { project_id } => git::generate_worktree_branch_name(ws, project_id), + ActionRequest::GitListBranchesClassified { project_id } => { + git::list_branches_classified(ws, project_id) + } + ActionRequest::GitCheckoutLocalBranch { project_id, branch } => { + git::checkout_local_branch(ws, project_id, branch) + } + ActionRequest::GitCheckoutRemoteBranch { project_id, remote_branch } => { + git::checkout_remote_branch(ws, project_id, remote_branch) + } + ActionRequest::GitCreateAndCheckoutBranch { project_id, new_name, start_point } => { + git::create_and_checkout_branch(ws, project_id, new_name, start_point) + } ActionRequest::GitStageFile { project_id, file_path } => { git::stage_file(ws, project_id, file_path) } @@ -183,7 +216,7 @@ pub fn execute_action( // ── Project / folder / worktree ops ────────────────────────── ActionRequest::AddProject { name, path } => { - project::add_project(ws, window_id, name, path, backend, terminals, cx) + project::add_project(ws, window_id, name, path, backend, terminals, settings, cx) } ActionRequest::ReorderProjectInFolder { folder_id, project_id, new_index } => { project::reorder_in_folder(ws, folder_id, project_id, new_index, cx) @@ -197,17 +230,23 @@ pub fn execute_action( ActionRequest::RenameProject { project_id, name } => { project::rename_project(ws, project_id, name, cx) } + ActionRequest::UpdateProjectHooks { project_id, hooks } => { + project::update_project_hooks(ws, project_id, *hooks, cx) + } ActionRequest::RenameProjectDirectory { project_id, new_name } => { project::rename_project_directory(ws, project_id, new_name, cx) } ActionRequest::DeleteProject { project_id } => { - project::delete_project(ws, focus_manager, project_id, cx) + project::delete_project(ws, focus_manager, project_id, settings, cx) } ActionRequest::SetProjectShowInOverview { project_id, show, window: _ } => { project::set_show_in_overview(ws, focus_manager, window_id, project_id, show, cx) } ActionRequest::RemoveWorktreeProject { project_id, force } => { - project::remove_worktree_project(ws, focus_manager, project_id, force, cx) + project::remove_worktree_project(ws, focus_manager, project_id, force, settings, cx) + } + ActionRequest::CloseWorktree { project_id, merge, stash, fetch, push, delete_branch } => { + project::close_worktree(ws, focus_manager, project_id, merge, stash, fetch, push, delete_branch, settings, cx) } ActionRequest::CreateFolder { name } => project::create_folder(ws, name, cx), ActionRequest::DeleteFolder { folder_id } => project::delete_folder(ws, folder_id, cx), @@ -220,8 +259,40 @@ pub fn execute_action( ActionRequest::MoveProjectOutOfFolder { project_id, top_level_index } => { project::move_out_of_folder(ws, project_id, top_level_index, cx) } + ActionRequest::MoveProject { project_id, new_index } => { + project::move_project(ws, project_id, new_index, cx) + } + ActionRequest::MoveItemInOrder { item_id, new_index } => { + project::move_item_in_order(ws, item_id, new_index, cx) + } + ActionRequest::ToggleProjectPinned { project_id } => { + project::toggle_project_pinned(ws, project_id, cx) + } + ActionRequest::ReorderWorktree { parent_id, worktree_id, new_index } => { + project::reorder_worktree(ws, parent_id, worktree_id, new_index, cx) + } + ActionRequest::SetWorktreeColorOverride { project_id, color } => { + project::set_worktree_color_override(ws, project_id, color, cx) + } ActionRequest::CreateWorktree { project_id, branch, create_branch } => { - project::create_worktree(ws, window_id, project_id, branch, create_branch, backend, terminals, cx) + project::create_worktree(ws, window_id, project_id, branch, create_branch, backend, terminals, settings, cx) + } + + // ── Sessions (whole-workspace; daemon owns session files + state) ── + ActionRequest::LoadSession { name } => { + session::load_session_action(ws, focus_manager, name, backend, terminals, settings, cx) + } + ActionRequest::SaveSession { name } => session::save_session_action(ws, name), + ActionRequest::ImportWorkspace { path } => { + session::import_workspace_action(ws, focus_manager, path, backend, terminals, settings, cx) + } + ActionRequest::ExportWorkspace { path } => session::export_workspace_action(ws, path), + + // Soft-close undo / finalize are handled by the daemon command loop + // directly (it owns the grace deadlines + kept-alive PTYs). + ActionRequest::UndoSoftClose { .. } + | ActionRequest::CloseTerminalNow { .. } => { + ActionResult::Err("soft-close undo/finalize must be handled by the daemon command loop".to_string()) } // Service actions are handled by the remote command loop directly @@ -301,12 +372,41 @@ pub fn ensure_terminal( /// /// Used after `CreateTerminal` / `SplitTerminal` to eagerly create PTYs for /// remote clients that don't have a rendering layer to trigger lazy spawning. +/// The cwd a *new* terminal should inherit when it's created next to an +/// existing one (split / add-tab): the live working directory of the terminal +/// the user acted on — the node at `path`, or the visible terminal under it +/// when `path` is a group. Resolved from the action's `path` (client-independent, +/// so it holds in the daemon model). Uses `Terminal::current_cwd` (the OSC 7 +/// shell cwd), so it follows wherever the source shell has `cd`-ed. `None` when +/// there's no live source terminal — callers then fall back to the project path. +pub(super) fn inherited_cwd( + ws: &Workspace, + terminals: &TerminalsRegistry, + project_id: &str, + path: &[usize], +) -> Option { + let layout = ws.project(project_id)?.layout.as_ref()?; + let node = layout.get_at_path(path)?; + let rel = node.find_visible_terminal_path(); + let LayoutNode::Terminal { terminal_id: Some(terminal_id), .. } = node.get_at_path(&rel)? + else { + return None; + }; + let cwd = terminals.lock().get(terminal_id)?.current_cwd(); + (!cwd.is_empty()).then_some(cwd) +} + pub fn spawn_uninitialized_terminals( ws: &mut Workspace, project_id: &str, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + // When a new terminal is created next to an existing one (split / add-tab), + // the caller passes the source terminal's live cwd so the new one opens + // "here". `None` → the project path (fresh projects, worktrees, sessions). + inherit_cwd: Option, + cx: &mut impl WorkspaceCx, ) -> ActionResult { // Don't spawn terminals for projects whose worktree is still being created if ws.is_creating_project(project_id) { @@ -319,6 +419,9 @@ pub fn spawn_uninitialized_terminals( }; let project_path = project.path.clone(); + // The directory new PTYs actually spawn in: the inherited (source-terminal) + // cwd when provided, else the project path. + let spawn_cwd = inherit_cwd.unwrap_or_else(|| project_path.clone()); let project_name = project.name.clone(); let project_hooks = project.hooks.clone(); let is_worktree = project.worktree_info.is_some(); @@ -332,13 +435,12 @@ pub fn spawn_uninitialized_terminals( } log::info!("spawn_uninitialized_terminals: project={}, uninitialized_count={}", project_id, uninitialized.len()); - let app_settings = settings(cx); - let global_default = app_settings.default_shell.clone(); - let global_hooks = app_settings.hooks; + let global_default = settings.default_shell.clone(); + let global_hooks = settings.hooks.clone(); // Resolve shell_wrapper and on_create once for all terminals in this project let shell_wrapper = hooks::resolve_shell_wrapper(&project_hooks, parent_hooks.as_ref(), &global_hooks); - let on_create_cmd = hooks::resolve_terminal_on_create(&project_hooks, parent_hooks.as_ref(), &global_hooks, cx); + let on_create_cmd = hooks::resolve_terminal_on_create_simple(&project_hooks, parent_hooks.as_ref(), &global_hooks); let folder = ws.folder_for_project_or_parent(project_id); let folder_id = folder.map(|f| f.id.as_str()); let folder_name = folder.map(|f| f.name.as_str()); @@ -363,14 +465,14 @@ pub fn spawn_uninitialized_terminals( shell = hooks::apply_on_create(&shell, cmd, &env); } - match backend.create_terminal(&project_path, Some(&shell)) { + match backend.create_terminal(&spawn_cwd, Some(&shell)) { Ok(terminal_id) => { ws.set_terminal_id(project_id, &path, terminal_id.clone(), cx); let terminal = Arc::new(Terminal::new( terminal_id.clone(), TerminalSize::default(), backend.transport(), - project_path.clone(), + spawn_cwd.clone(), )); terminals.lock().insert(terminal_id.clone(), terminal); diff --git a/crates/okena-app-core/src/workspace/actions/execute/project.rs b/crates/okena-app-core/src/workspace/actions/execute/project.rs index a609eedae..eca2fef93 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/project.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/project.rs @@ -6,13 +6,45 @@ #![allow(clippy::too_many_arguments)] use super::{ActionResult, find_first_terminal_id, spawn_uninitialized_terminals}; -use crate::settings::settings; +use crate::workspace::persistence::AppSettings; use okena_terminal::backend::TerminalBackend; +use okena_terminal::terminal::{Terminal, TerminalSize}; use crate::workspace::focus::FocusManager; use crate::workspace::state::{WindowId, Workspace}; -use gpui::*; +use okena_workspace::context::WorkspaceCx; use okena_core::theme::FolderColor; use okena_terminal::TerminalsRegistry; +use std::sync::Arc; + +fn project_not_found(project_id: &str) -> ActionResult { + ActionResult::Err(format!("project not found: {}", project_id)) +} + +fn with_existing_project( + ws: &mut Workspace, + project_id: &str, + f: impl FnOnce(&mut Workspace), +) -> ActionResult { + if ws.project(project_id).is_none() { + return project_not_found(project_id); + } + f(ws); + ActionResult::Ok(None) +} + +fn with_existing_project_result( + ws: &mut Workspace, + project_id: &str, + f: impl FnOnce(&mut Workspace) -> Result<(), String>, +) -> ActionResult { + if ws.project(project_id).is_none() { + return project_not_found(project_id); + } + match f(ws) { + Ok(()) => ActionResult::Ok(None), + Err(e) => ActionResult::Err(e), + } +} pub(super) fn add_project( ws: &mut Workspace, @@ -21,15 +53,16 @@ pub(super) fn add_project( path: String, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - let project_id = ws.add_project(name, path, true, &settings(cx).hooks, window_id, cx); + let project_id = ws.add_project(name, path, true, &settings.hooks, window_id, cx); // Surface the newly-created project's id alongside the spawned terminal // ids so callers (e.g. the CLI `add-project` verb) can address the project // they just created without re-fetching state. `spawn_uninitialized_terminals` // returns `{ "terminal_ids": [...] }`; we merge `project_id` into that // object, leaving its terminal-spawning behavior unchanged. - match spawn_uninitialized_terminals(ws, &project_id, backend, terminals, cx) { + match spawn_uninitialized_terminals(ws, &project_id, backend, terminals, settings, None, cx) { ActionResult::Ok(Some(serde_json::Value::Object(mut map))) => { map.insert( "project_id".to_string(), @@ -50,7 +83,7 @@ pub(super) fn reorder_in_folder( folder_id: String, project_id: String, new_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.reorder_project_in_folder(&folder_id, &project_id, new_index, cx); ActionResult::Ok(None) @@ -60,7 +93,7 @@ pub(super) fn set_project_color( ws: &mut Workspace, project_id: String, color: FolderColor, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.set_folder_color(&project_id, color, cx); ActionResult::Ok(None) @@ -70,7 +103,7 @@ pub(super) fn set_folder_color( ws: &mut Workspace, folder_id: String, color: FolderColor, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.set_folder_item_color(&folder_id, color, cx); ActionResult::Ok(None) @@ -80,12 +113,34 @@ pub(super) fn rename_project( ws: &mut Workspace, project_id: String, name: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + with_existing_project(ws, &project_id, |ws| ws.rename_project(&project_id, name, cx)) +} + +pub(super) fn update_project_hooks( + ws: &mut Workspace, + project_id: String, + hooks: okena_core::api::ApiHooksConfig, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - if ws.project(&project_id).is_none() { + let new_hooks = crate::workspace::settings::HooksConfig::from_api(&hooks); + // Dirty-check inside the closure: clients flush hooks on every panel + // close/project-switch even when unchanged, so skip the notify (→ state bump + // → full-snapshot churn to all clients) when the hooks are identical. + let mut existed = false; + ws.with_project(&project_id, cx, |p| { + existed = true; + if p.hooks == new_hooks { + false + } else { + p.hooks = new_hooks; + true + } + }); + if !existed { return ActionResult::Err(format!("project not found: {}", project_id)); } - ws.rename_project(&project_id, name, cx); ActionResult::Ok(None) } @@ -93,7 +148,7 @@ pub(super) fn rename_project_directory( ws: &mut Workspace, project_id: String, new_name: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { if let Err(e) = super::validate_leaf_name(&new_name) { return ActionResult::Err(e); @@ -123,14 +178,13 @@ pub(super) fn delete_project( ws: &mut Workspace, focus_manager: &mut FocusManager, project_id: String, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - if ws.project(&project_id).is_none() { - return ActionResult::Err(format!("project not found: {}", project_id)); - } - let global_hooks = settings(cx).hooks.clone(); - ws.delete_project(focus_manager, &project_id, &global_hooks, cx); - ActionResult::Ok(None) + let global_hooks = settings.hooks.clone(); + with_existing_project(ws, &project_id, |ws| { + ws.delete_project(focus_manager, &project_id, &global_hooks, cx); + }) } pub(super) fn set_show_in_overview( @@ -139,7 +193,7 @@ pub(super) fn set_show_in_overview( window_id: WindowId, project_id: String, show: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { apply_set_project_show_in_overview(ws, focus_manager, window_id, &project_id, show, cx) } @@ -158,7 +212,7 @@ fn apply_set_project_show_in_overview( window_id: WindowId, project_id: &str, show: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { if ws.project(project_id).is_none() { return ActionResult::Err(format!("project not found: {}", project_id)); @@ -180,29 +234,53 @@ pub(super) fn remove_worktree_project( focus_manager: &mut FocusManager, project_id: String, force: bool, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - if ws.project(&project_id).is_none() { - return ActionResult::Err(format!("project not found: {}", project_id)); - } - let global_hooks = settings(cx).hooks.clone(); - match ws.remove_worktree_project(focus_manager, &project_id, force, &global_hooks, cx) { - Ok(()) => ActionResult::Ok(None), - Err(e) => ActionResult::Err(e), - } + let global_hooks = settings.hooks.clone(); + with_existing_project_result(ws, &project_id, |ws| { + ws.remove_worktree_project(focus_manager, &project_id, force, &global_hooks, cx) + }) +} + +pub(super) fn close_worktree( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + project_id: String, + merge: bool, + stash: bool, + fetch: bool, + push: bool, + delete_branch: bool, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + with_existing_project_result(ws, &project_id, |ws| { + ws.close_worktree( + focus_manager, + &project_id, + merge, + stash, + fetch, + push, + delete_branch, + &settings.hooks, + cx, + ) + }) } -pub(super) fn create_folder(ws: &mut Workspace, name: String, cx: &mut Context) -> ActionResult { +pub(super) fn create_folder(ws: &mut Workspace, name: String, cx: &mut impl WorkspaceCx) -> ActionResult { let id = ws.create_folder(name, cx); ActionResult::Ok(Some(serde_json::json!({ "folder_id": id }))) } -pub(super) fn delete_folder(ws: &mut Workspace, folder_id: String, cx: &mut Context) -> ActionResult { +pub(super) fn delete_folder(ws: &mut Workspace, folder_id: String, cx: &mut impl WorkspaceCx) -> ActionResult { ws.delete_folder(&folder_id, cx); ActionResult::Ok(None) } -pub(super) fn rename_folder(ws: &mut Workspace, folder_id: String, name: String, cx: &mut Context) -> ActionResult { +pub(super) fn rename_folder(ws: &mut Workspace, folder_id: String, name: String, cx: &mut impl WorkspaceCx) -> ActionResult { ws.rename_folder(&folder_id, name, cx); ActionResult::Ok(None) } @@ -212,28 +290,80 @@ pub(super) fn move_to_folder( project_id: String, folder_id: String, position: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - if ws.project(&project_id).is_none() { - return ActionResult::Err(format!("project not found: {}", project_id)); - } - ws.move_project_to_folder(&project_id, &folder_id, position, cx); - ActionResult::Ok(None) + with_existing_project(ws, &project_id, |ws| { + ws.move_project_to_folder(&project_id, &folder_id, position, cx); + }) } pub(super) fn move_out_of_folder( ws: &mut Workspace, project_id: String, top_level_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - if ws.project(&project_id).is_none() { - return ActionResult::Err(format!("project not found: {}", project_id)); - } - ws.move_project_out_of_folder(&project_id, top_level_index, cx); + with_existing_project(ws, &project_id, |ws| { + ws.move_project_out_of_folder(&project_id, top_level_index, cx); + }) +} + +pub(super) fn move_project( + ws: &mut Workspace, + project_id: String, + new_index: usize, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + with_existing_project(ws, &project_id, |ws| { + ws.move_project(&project_id, new_index, cx); + }) +} + +pub(super) fn move_item_in_order( + ws: &mut Workspace, + item_id: String, + new_index: usize, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + // `item_id` may be a folder id or a top-level project id; the workspace + // method is a no-op when the id isn't in `project_order`. + ws.move_item_in_order(&item_id, new_index, cx); ActionResult::Ok(None) } +pub(super) fn toggle_project_pinned( + ws: &mut Workspace, + project_id: String, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + with_existing_project(ws, &project_id, |ws| { + ws.toggle_project_pinned(&project_id, cx); + }) +} + +pub(super) fn reorder_worktree( + ws: &mut Workspace, + parent_id: String, + worktree_id: String, + new_index: usize, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + with_existing_project(ws, &parent_id, |ws| { + ws.reorder_worktree(&parent_id, &worktree_id, new_index, cx); + }) +} + +pub(super) fn set_worktree_color_override( + ws: &mut Workspace, + project_id: String, + color: Option, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + with_existing_project(ws, &project_id, |ws| { + ws.set_worktree_color_override(&project_id, color, cx); + }) +} + pub(super) fn create_worktree( ws: &mut Workspace, window_id: WindowId, @@ -242,7 +372,8 @@ pub(super) fn create_worktree( create_branch: bool, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { let project = match ws.project(&project_id) { Some(p) => p, @@ -250,13 +381,13 @@ pub(super) fn create_worktree( }; let project_path = std::path::PathBuf::from(&project.path); let (git_root, subdir) = okena_git::resolve_git_root_and_subdir(&project_path); - let path_template = settings(cx).worktree.path_template.clone(); + let path_template = settings.worktree.path_template.clone(); let (worktree_path, wt_project_path) = okena_git::compute_target_paths(&git_root, &subdir, &path_template, &branch); - let global_hooks = settings(cx).hooks.clone(); + let global_hooks = settings.hooks.clone(); match ws.create_worktree_project(&project_id, &branch, &git_root, &worktree_path, &wt_project_path, create_branch, &global_hooks, window_id, cx) { Ok(new_project_id) => { - let result = spawn_uninitialized_terminals(ws, &new_project_id, backend, terminals, cx); + let result = spawn_uninitialized_terminals(ws, &new_project_id, backend, terminals, settings, None, cx); let terminal_id = ws.project(&new_project_id) .and_then(|p| p.layout.as_ref()) .and_then(find_first_terminal_id); @@ -273,7 +404,96 @@ pub(super) fn create_worktree( } } -#[cfg(test)] +/// Register an already-on-disk git worktree as a tracked project under its +/// parent. The worktree path is discovered client-side from a local git scan +/// (same filesystem for a local daemon) and passed verbatim — the daemon +/// creates the project, links it to the parent's worktree list, and spawns its +/// terminal. Mirrors the old in-process `add_discovered_worktree` + +/// `add_to_worktree_ids` GUI path. +pub(super) fn add_discovered_worktree( + ws: &mut Workspace, + window_id: WindowId, + parent_project_id: String, + worktree_path: String, + branch: String, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + if ws.project(&parent_project_id).is_none() { + return ActionResult::Err(format!("project not found: {}", parent_project_id)); + } + let new_id = match ws.add_discovered_worktree(&worktree_path, &branch, &parent_project_id, window_id) { + Some(id) => id, + None => { + return ActionResult::Err(format!( + "worktree already tracked or not addable: {}", + worktree_path + )); + } + }; + ws.add_to_worktree_ids(&parent_project_id, &new_id); + // `add_discovered_worktree` deliberately doesn't notify (caller's job). + ws.notify_data(cx); + let result = spawn_uninitialized_terminals(ws, &new_id, backend, terminals, settings, None, cx); + let terminal_id = ws + .project(&new_id) + .and_then(|p| p.layout.as_ref()) + .and_then(find_first_terminal_id); + match result { + ActionResult::Ok(_) => ActionResult::Ok(Some(serde_json::json!({ + "project_id": new_id, + "terminal_id": terminal_id, + }))), + err => err, + } +} + +/// Rerun a lifecycle-hook terminal: kill the old PTY, spawn a fresh shell at the +/// same cwd, swap the id in state (status back to Running), and type the stored +/// command into the new shell. The daemon is authoritative for the hook's +/// command + cwd (read from `hook_terminals`), so the action only carries the +/// project + terminal ids. Mirrors the old in-process `rerun_hook` GUI path. +pub(super) fn rerun_hook( + ws: &mut Workspace, + project_id: String, + terminal_id: String, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + let (command, cwd) = match ws + .project(&project_id) + .and_then(|p| p.hook_terminals.get(&terminal_id)) + { + Some(e) => (e.command.clone(), e.cwd.clone()), + None => return ActionResult::Err(format!("hook terminal not found: {}", terminal_id)), + }; + backend.kill(&terminal_id); + let new_id = match backend.create_terminal(&cwd, None) { + Ok(id) => id, + Err(e) => return ActionResult::Err(format!("failed to spawn hook terminal: {}", e)), + }; + let transport = backend.transport(); + let terminal = Arc::new(Terminal::new( + new_id.clone(), + TerminalSize::default(), + transport.clone(), + cwd.clone(), + )); + { + let mut guard = terminals.lock(); + guard.remove(&terminal_id); + guard.insert(new_id.clone(), terminal); + } + ws.swap_hook_terminal_id(&project_id, &terminal_id, &new_id, cx); + let cmd_with_newline = format!("{}\n", command); + transport.send_input(&new_id, cmd_with_newline.as_bytes()); + ActionResult::Ok(Some(serde_json::json!({ "terminal_id": new_id }))) +} + +#[cfg(all(test, feature = "gpui"))] mod set_show_in_overview_tests { use super::{apply_set_project_show_in_overview, ActionResult}; use crate::workspace::state::{ProjectData, Workspace, WindowId, WindowState, WorkspaceData}; diff --git a/crates/okena-app-core/src/workspace/actions/execute/session.rs b/crates/okena-app-core/src/workspace/actions/execute/session.rs new file mode 100644 index 000000000..99d97a6d2 --- /dev/null +++ b/crates/okena-app-core/src/workspace/actions/execute/session.rs @@ -0,0 +1,105 @@ +//! Session / whole-workspace action handlers (load / save / import / export). +//! +//! The daemon owns session files (under the profile's `sessions/` dir) and the +//! authoritative workspace (local, non-prefixed ids). The thin GUI client must +//! NOT save/load sessions from its read-only mirror — its ids are +//! `remote::…` prefixed, which would round-trip into garbage. So these run +//! daemon-side: save/export read the daemon's real data; load/import replace the +//! daemon's state and respawn its terminals (the loaded data already had its +//! stale terminal ids cleared by `validate_workspace_data`). + +// Handlers take the workspace, focus manager, terminals registry and cx as +// distinct dependencies; bundling them into a context struct would obscure +// more than it clarifies here. +#![allow(clippy::too_many_arguments)] + +use super::{ActionResult, spawn_uninitialized_terminals}; +use crate::workspace::focus::FocusManager; +use crate::workspace::persistence::{ + export_workspace, import_workspace, load_session, save_session, +}; +use crate::workspace::persistence::AppSettings; +use crate::workspace::state::{Workspace, WorkspaceData}; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::TerminalsRegistry; +use okena_workspace::context::WorkspaceCx; + +/// Kill every live PTY, swap the workspace to `data`, then respawn terminals for +/// every project in the new workspace. Shared by `load_session` + `import`. +fn replace_workspace_with( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + data: WorkspaceData, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + // Kill every live PTY (collect ids first so we don't hold the registry lock + // across backend.kill). + let ids: Vec = terminals.lock().keys().cloned().collect(); + for id in &ids { + backend.kill(id); + } + terminals.lock().clear(); + + ws.replace_data(focus_manager, data, cx); + + // The loaded data had its stale terminal ids cleared, so every layout node is + // uninitialized — respawn a PTY for each project. + let project_ids: Vec = ws.projects().iter().map(|p| p.id.clone()).collect(); + for pid in &project_ids { + if let ActionResult::Err(e) = + spawn_uninitialized_terminals(ws, pid, backend, terminals, settings, None, cx) + { + return ActionResult::Err(e); + } + } + ActionResult::Ok(None) +} + +pub(super) fn load_session_action( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + name: String, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + let data = match load_session(&name, settings.session_backend) { + Ok(d) => d, + Err(e) => return ActionResult::Err(format!("failed to load session '{name}': {e}")), + }; + replace_workspace_with(ws, focus_manager, data, backend, terminals, settings, cx) +} + +pub(super) fn save_session_action(ws: &Workspace, name: String) -> ActionResult { + match save_session(&name, &ws.data().without_remote_projects()) { + Ok(()) => ActionResult::Ok(None), + Err(e) => ActionResult::Err(format!("failed to save session '{name}': {e}")), + } +} + +pub(super) fn import_workspace_action( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + path: String, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + let data = match import_workspace(std::path::Path::new(&path)) { + Ok(d) => d, + Err(e) => return ActionResult::Err(format!("failed to import '{path}': {e}")), + }; + replace_workspace_with(ws, focus_manager, data, backend, terminals, settings, cx) +} + +pub(super) fn export_workspace_action(ws: &Workspace, path: String) -> ActionResult { + match export_workspace(&ws.data().without_remote_projects(), std::path::Path::new(&path)) { + Ok(()) => ActionResult::Ok(None), + Err(e) => ActionResult::Err(format!("failed to export to '{path}': {e}")), + } +} diff --git a/crates/okena-app-core/src/workspace/actions/execute/tab.rs b/crates/okena-app-core/src/workspace/actions/execute/tab.rs index f75632ec9..2c6750a22 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/tab.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/tab.rs @@ -6,10 +6,11 @@ #![allow(clippy::too_many_arguments)] use super::{ActionResult, spawn_uninitialized_terminals}; +use crate::workspace::persistence::AppSettings; use okena_terminal::backend::TerminalBackend; use crate::workspace::focus::FocusManager; use crate::workspace::state::{DropZone, Workspace}; -use gpui::*; +use okena_workspace::context::WorkspaceCx; use okena_terminal::TerminalsRegistry; pub(super) fn add_tab( @@ -20,14 +21,18 @@ pub(super) fn add_tab( in_group: bool, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { + // Inherit the source group/terminal's live cwd (captured before the layout + // mutation) so the new tab opens in the same directory. + let inherit_cwd = super::inherited_cwd(ws, terminals, &project_id, &path); if in_group { ws.add_tab_to_group(focus_manager, &project_id, &path, cx); } else { ws.add_tab(focus_manager, &project_id, &path, cx); } - spawn_uninitialized_terminals(ws, &project_id, backend, terminals, cx) + spawn_uninitialized_terminals(ws, &project_id, backend, terminals, settings, inherit_cwd, cx) } pub(super) fn set_active_tab( @@ -35,7 +40,7 @@ pub(super) fn set_active_tab( project_id: String, path: Vec, index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.set_active_tab(&project_id, &path, index, cx); ActionResult::Ok(None) @@ -47,7 +52,7 @@ pub(super) fn move_tab( path: Vec, from_index: usize, to_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.move_tab(&project_id, &path, from_index, to_index, cx); ActionResult::Ok(None) @@ -61,7 +66,7 @@ pub(super) fn move_terminal_to_tab_group( target_path: Vec, position: Option, target_project_id: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { let target_pid = target_project_id.as_deref().unwrap_or(&project_id); ws.move_terminal_to_tab_group(focus_manager, &project_id, &terminal_id, target_pid, &target_path, position, cx); @@ -76,7 +81,7 @@ pub(super) fn move_pane_to( target_project_id: String, target_terminal_id: String, zone: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { let drop_zone = match zone.as_str() { "top" => DropZone::Top, diff --git a/crates/okena-app-core/src/workspace/actions/execute/terminal.rs b/crates/okena-app-core/src/workspace/actions/execute/terminal.rs index fa3530cbd..eb3df790b 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/terminal.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/terminal.rs @@ -8,16 +8,32 @@ use super::{ ActionResult, ensure_terminal, find_terminal_path, spawn_uninitialized_terminals, }; +use crate::workspace::persistence::AppSettings; use okena_terminal::backend::TerminalBackend; +use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::TerminalSize; use crate::workspace::focus::FocusManager; use crate::workspace::state::Workspace; use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line, Point}; -use gpui::*; +use okena_workspace::context::WorkspaceCx; use okena_core::keys::SpecialKey; use okena_core::types::SplitDirection; use okena_terminal::TerminalsRegistry; +use okena_terminal::terminal::Terminal; + +fn with_ensured_terminal( + ws: &Workspace, + terminal_id: &str, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + f: impl FnOnce(&Terminal) -> ActionResult, +) -> ActionResult { + match ensure_terminal(terminal_id, terminals, backend, ws) { + Some(term) => f(&term), + None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), + } +} pub(super) fn create( ws: &mut Workspace, @@ -25,10 +41,17 @@ pub(super) fn create( project_id: String, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { + // Open in the focused terminal's cwd (when one is focused in this project), + // else the project path. + let inherit_cwd = focus_manager + .focused_terminal_state() + .filter(|f| f.project_id == project_id) + .and_then(|f| super::inherited_cwd(ws, terminals, &project_id, &f.layout_path)); ws.add_terminal(focus_manager, &project_id, cx); - spawn_uninitialized_terminals(ws, &project_id, backend, terminals, cx) + spawn_uninitialized_terminals(ws, &project_id, backend, terminals, settings, inherit_cwd, cx) } pub(super) fn split( @@ -39,10 +62,47 @@ pub(super) fn split( direction: SplitDirection, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, ) -> ActionResult { + // Inherit the split source terminal's live cwd (captured before the layout + // mutation invalidates `path`) so the new pane opens in the same directory. + let inherit_cwd = super::inherited_cwd(ws, terminals, &project_id, &path); ws.split_terminal(focus_manager, &project_id, &path, direction, cx); - spawn_uninitialized_terminals(ws, &project_id, backend, terminals, cx) + spawn_uninitialized_terminals(ws, &project_id, backend, terminals, settings, inherit_cwd, cx) +} + +/// Switch a terminal's shell: kill the old PTY, reset the layout node to +/// uninitialized with the requested shell, then respawn it. Reuses +/// `spawn_uninitialized_terminals` so the new PTY goes through the same +/// shell-default resolution + shell-wrapper/on_create hook application as any +/// freshly created terminal — keeping daemon shell-switch behavior identical to +/// the old in-process GUI path. +pub(super) fn switch_shell( + ws: &mut Workspace, + project_id: String, + terminal_id: String, + shell: ShellType, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + let path = match find_terminal_path(ws, &project_id, &terminal_id) { + Some(p) => p, + None => return ActionResult::Err(format!("terminal not found: {}", terminal_id)), + }; + // No-op if the shell is unchanged (mirrors the old GUI guard). + if ws.get_terminal_shell(&project_id, &path).as_ref() == Some(&shell) { + return ActionResult::Ok(None); + } + backend.kill(&terminal_id); + terminals.lock().remove(&terminal_id); + ws.set_terminal_shell(&project_id, &path, shell, cx); + ws.clear_terminal_id(&project_id, &path, cx); + // Shell-switch respawns the pane in place; keep the project path (the old + // terminal's cwd is gone with its PTY). + spawn_uninitialized_terminals(ws, &project_id, backend, terminals, settings, None, cx) } pub(super) fn close( @@ -52,7 +112,7 @@ pub(super) fn close( terminal_id: String, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { let path = find_terminal_path(ws, &project_id, &terminal_id); match path { @@ -73,7 +133,7 @@ pub(super) fn close_many( terminal_ids: Vec, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { let mut last_err = None; for terminal_id in &terminal_ids { @@ -100,7 +160,7 @@ pub(super) fn focus( focus_manager: &mut FocusManager, project_id: String, terminal_id: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { let path = find_terminal_path(ws, &project_id, &terminal_id); match path { @@ -119,14 +179,10 @@ pub(super) fn send_text( backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, ) -> ActionResult { - match ensure_terminal(&terminal_id, terminals, backend, ws) { - Some(term) => { - term.claim_resize_remote(); - term.send_input(&text); - ActionResult::Ok(None) - } - None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), - } + with_ensured_terminal(ws, &terminal_id, backend, terminals, |term| { + term.send_input(&text); + ActionResult::Ok(None) + }) } pub(super) fn run_command( @@ -136,14 +192,10 @@ pub(super) fn run_command( backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, ) -> ActionResult { - match ensure_terminal(&terminal_id, terminals, backend, ws) { - Some(term) => { - term.claim_resize_remote(); - term.send_input(&format!("{}\r", command)); - ActionResult::Ok(None) - } - None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), - } + with_ensured_terminal(ws, &terminal_id, backend, terminals, |term| { + term.send_input(&format!("{}\r", command)); + ActionResult::Ok(None) + }) } pub(super) fn send_special_key( @@ -153,14 +205,10 @@ pub(super) fn send_special_key( backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, ) -> ActionResult { - match ensure_terminal(&terminal_id, terminals, backend, ws) { - Some(term) => { - term.claim_resize_remote(); - term.send_bytes(&key.to_bytes()); - ActionResult::Ok(None) - } - None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), - } + with_ensured_terminal(ws, &terminal_id, backend, terminals, |term| { + term.send_bytes(&key.to_bytes()); + ActionResult::Ok(None) + }) } pub(super) fn resize( @@ -171,20 +219,16 @@ pub(super) fn resize( backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, ) -> ActionResult { - match ensure_terminal(&terminal_id, terminals, backend, ws) { - Some(term) => { - term.claim_resize_remote(); - let size = TerminalSize { - cols, - rows, - cell_width: 8.0, - cell_height: 16.0, - }; - term.resize(size); - ActionResult::Ok(None) - } - None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), - } + with_ensured_terminal(ws, &terminal_id, backend, terminals, |term| { + let size = TerminalSize { + cols, + rows, + cell_width: 8.0, + cell_height: 16.0, + }; + term.resize(size); + ActionResult::Ok(None) + }) } pub(super) fn update_split_sizes( @@ -192,7 +236,7 @@ pub(super) fn update_split_sizes( project_id: String, path: Vec, sizes: Vec, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.update_split_sizes(&project_id, &path, sizes, cx); ActionResult::Ok(None) @@ -202,7 +246,7 @@ pub(super) fn toggle_minimized( ws: &mut Workspace, project_id: String, terminal_id: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.toggle_terminal_minimized_by_id(&project_id, &terminal_id, cx); ActionResult::Ok(None) @@ -213,7 +257,7 @@ pub(super) fn set_fullscreen( focus_manager: &mut FocusManager, project_id: String, terminal_id: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { match terminal_id { Some(tid) => ws.set_fullscreen_terminal(focus_manager, project_id, tid, cx), @@ -227,7 +271,7 @@ pub(super) fn rename( project_id: String, terminal_id: String, name: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { ws.rename_terminal(&project_id, &terminal_id, name, cx); ActionResult::Ok(None) @@ -239,32 +283,48 @@ pub(super) fn read_content( backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, ) -> ActionResult { - match ensure_terminal(&terminal_id, terminals, backend, ws) { - Some(term) => { - let content = term.with_content(|term| { - let grid = term.grid(); - let screen_lines = grid.screen_lines(); - let cols = grid.columns(); - let mut lines = Vec::with_capacity(screen_lines); + with_ensured_terminal(ws, &terminal_id, backend, terminals, |term| { + let content = term.with_content(|term| { + let grid = term.grid(); + let screen_lines = grid.screen_lines(); + let cols = grid.columns(); + let mut lines = Vec::with_capacity(screen_lines); - for row in 0..screen_lines as i32 { - let mut line = String::with_capacity(cols); - for col in 0..cols { - let cell = &grid[Point::new(Line(row), Column(col))]; - line.push(cell.c); - } - let trimmed = line.trim_end().to_string(); - lines.push(trimmed); + for row in 0..screen_lines as i32 { + let mut line = String::with_capacity(cols); + for col in 0..cols { + let cell = &grid[Point::new(Line(row), Column(col))]; + line.push(cell.c); } + let trimmed = line.trim_end().to_string(); + lines.push(trimmed); + } - while lines.last().is_some_and(|l| l.is_empty()) { - lines.pop(); - } + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + + lines.join("\n") + }); + ActionResult::Ok(Some(serde_json::json!({"content": content}))) + }) +} - lines.join("\n") - }); - ActionResult::Ok(Some(serde_json::json!({"content": content}))) +pub(super) fn export_buffer( + terminal_id: String, + backend: &dyn TerminalBackend, +) -> ActionResult { + match backend.capture_buffer(&terminal_id) { + Some(path) => { + // capture_buffer wrote a daemon-side temp file; read it back and + // drop it — the real export is the client's own copy. + let bytes = std::fs::read(&path).unwrap_or_default(); + let _ = std::fs::remove_file(&path); + let content = String::from_utf8_lossy(&bytes).to_string(); + ActionResult::Ok(Some(serde_json::json!({ "content": content }))) } - None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), + None => ActionResult::Err( + "buffer capture unavailable (requires a tmux session backend)".into(), + ), } } diff --git a/crates/okena-app-core/src/workspace/mod.rs b/crates/okena-app-core/src/workspace/mod.rs index 80018fbc5..e6da34890 100644 --- a/crates/okena-app-core/src/workspace/mod.rs +++ b/crates/okena-app-core/src/workspace/mod.rs @@ -1,4 +1,9 @@ -pub use okena_workspace::{hook_monitor, hooks, persistence, request_broker, requests, settings, state, toast, worktree_sync}; +pub use okena_workspace::{hook_monitor, hooks, persistence, settings, state, toast}; + +// request_broker / requests / worktree_sync are gpui-gated in okena-workspace, +// so re-export them only when the gpui feature is enabled. +#[cfg(feature = "gpui")] +pub use okena_workspace::{request_broker, requests, worktree_sync}; // focus is re-exported implicitly (no types used directly from main app) // sessions is re-exported implicitly (accessed through persistence re-exports) diff --git a/crates/okena-app/Cargo.toml b/crates/okena-app/Cargo.toml index 57d53579b..148bbd614 100644 --- a/crates/okena-app/Cargo.toml +++ b/crates/okena-app/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "okena-app" -version = "0.1.0" +version.workspace = true edition = "2024" license = "MIT" [dependencies] # Shared core types + lower-level crates the UI/app layer builds on. okena-core = { path = "../okena-core" } -okena-transport = { path = "../okena-transport", features = ["client"] } +okena-transport = { path = "../okena-transport", features = ["client", "blocking-http"] } okena-app-core = { path = "../okena-app-core" } okena-remote-server = { path = "../okena-remote-server" } okena-remote-client = { path = "../okena-remote-client" } diff --git a/crates/okena-app/src/CLAUDE.md b/crates/okena-app/src/CLAUDE.md index 5ee30a954..618ee05a4 100644 --- a/crates/okena-app/src/CLAUDE.md +++ b/crates/okena-app/src/CLAUDE.md @@ -21,7 +21,7 @@ crates/okena-app/src/ ├── action_dispatch.rs # Action → workspace dispatch glue ├── logging.rs # In-app log console (ring buffer + reloadable filter) ├── simple_root.rs # Linux Wayland maximize workaround (cfg(target_os = "linux")) -├── soft_close.rs # Confirm-before-close helpers +├── soft_close.rs # Soft-close + restart-daemon toast-action ids (grace logic lives on the daemon) ├── app/ # Main app entity — real code (see app/CLAUDE.md) ├── views/ # UI views — real code (overlays, chrome, panels, components) ├── keybindings/ # Keyboard actions — real code (see keybindings/CLAUDE.md) diff --git a/crates/okena-app/src/action_dispatch.rs b/crates/okena-app/src/action_dispatch.rs index a1d1fe73e..c4baafdae 100644 --- a/crates/okena-app/src/action_dispatch.rs +++ b/crates/okena-app/src/action_dispatch.rs @@ -1,70 +1,74 @@ -//! Unified action dispatch — routes terminal actions to local or remote execution. +//! Unified action dispatch — routes terminal actions to the local daemon. //! -//! The `ActionDispatcher` enum encapsulates the local-vs-remote routing decision. -//! Callers simply call `dispatcher.dispatch(action, cx)` without any conditionals. +//! Every project is a remote project of the local daemon, so `ActionDispatcher` +//! carries a single `Remote` variant. Callers simply call +//! `dispatcher.dispatch(action, cx)` without any conditionals. use crate::remote_client::manager::RemoteConnectionManager; -use crate::services::manager::ServiceManager; -use crate::terminal::backend::TerminalBackend; -use crate::views::window::TerminalsRegistry; -use crate::workspace::actions::execute::execute_action; use crate::workspace::focus::FocusManager; use crate::workspace::state::{WindowId, Workspace}; use okena_core::api::ActionRequest; -use okena_transport::client::strip_prefix; +use okena_transport::client::{strip_prefix, RemoteConnectionConfig}; use gpui::{AppContext, Entity}; -use std::sync::Arc; /// Build an ActionDispatcher for the given project. /// -/// Returns `Remote` variant for remote projects, `Local` for local ones. -/// Returns `None` if required dependencies (backend, remote manager) are unavailable. +/// Every project is a remote project of the local daemon, so this always +/// returns the `Remote` variant. Returns `None` if the project is unknown or +/// the connection/remote manager required to reach it is unavailable. /// /// `window_id` carries the originating `WindowView`'s window id so per-window -/// state mutations triggered by local UI actions (e.g. hide/show via the -/// sidebar context menu routed through `SetProjectShowInOverview`) land on -/// the right window's slot. Remote projects also carry `window_id` so a UI -/// action issued in W2 against a remote project mutates W2's per-window -/// state on the local mirror, not main's. -// Threads the workspace, focus manager, terminals, service/remote managers and -// cx as distinct dependencies; a context struct would obscure more than help. -#[allow(clippy::too_many_arguments)] +/// state mutations triggered by UI actions (e.g. hide/show via the sidebar +/// context menu routed through `SetProjectShowInOverview`) land on the right +/// window's slot. A UI action issued in W2 against a project mutates W2's +/// per-window state on the local mirror, not main's. pub fn dispatcher_for_project( project_id: &str, window_id: WindowId, workspace: &Entity, focus_manager: &Entity, - backend: &Option>, - terminals: &TerminalsRegistry, - service_manager: &Option>, remote_manager: &Option>, cx: &gpui::App, ) -> Option { let ws = workspace.read(cx); let project = ws.project(project_id)?; - if project.is_remote { - let connection_id = project.connection_id.as_ref()?; - let manager = remote_manager.as_ref()?; - Some(ActionDispatcher::Remote { - connection_id: connection_id.clone(), - manager: manager.clone(), - workspace: workspace.clone(), - focus_manager: focus_manager.clone(), - window_id, - }) - } else { - let backend = backend.as_ref()?; - Some(ActionDispatcher::Local { - workspace: workspace.clone(), - focus_manager: focus_manager.clone(), - backend: backend.clone(), - terminals: terminals.clone(), - service_manager: service_manager.clone(), - window_id, - }) - } + let connection_id = project.connection_id.as_ref()?; + let manager = remote_manager.as_ref()?; + Some(ActionDispatcher::Remote { + connection_id: connection_id.clone(), + manager: manager.clone(), + workspace: workspace.clone(), + focus_manager: focus_manager.clone(), + window_id, + }) +} + +/// Build an ActionDispatcher targeting a specific connection by id. +/// +/// Unlike [`dispatcher_for_project`], this needs no project — it's for +/// folder-scoped and workspace-global actions, which carry no project to +/// resolve a connection from. The caller supplies the connection id (e.g. +/// extracted from a `remote::` folder id, or +/// `LOCAL_DAEMON_CONNECTION_ID` for a brand-new folder). The returned +/// dispatcher's `dispatch` still runs id-stripping against this connection id. +/// Returns `None` if the remote manager is unavailable. +pub fn dispatcher_for_connection( + connection_id: &str, + window_id: WindowId, + workspace: &Entity, + focus_manager: &Entity, + remote_manager: &Option>, +) -> Option { + let manager = remote_manager.as_ref()?; + Some(ActionDispatcher::Remote { + connection_id: connection_id.to_string(), + manager: manager.clone(), + workspace: workspace.clone(), + focus_manager: focus_manager.clone(), + window_id, + }) } /// Routes terminal and service actions to either local execution or remote HTTP. @@ -74,18 +78,6 @@ pub fn dispatcher_for_project( /// local or remote. #[derive(Clone)] pub enum ActionDispatcher { - /// Local project — execute actions directly in the workspace. - Local { - workspace: Entity, - focus_manager: Entity, - backend: Arc, - terminals: TerminalsRegistry, - service_manager: Option>, - /// Originating window's id (PRD cri 13). Per-window state mutations - /// inside `execute_action` (e.g. `SetProjectShowInOverview`) target - /// this slot. - window_id: WindowId, - }, /// Remote project — send actions via HTTP to the remote server. /// Visual/presentation actions (split sizes, minimize, fullscreen, active tab, focus) /// are executed locally on the client workspace to avoid server round-trips @@ -106,238 +98,173 @@ impl ActionDispatcher { matches!(self, Self::Remote { .. }) } + fn queue_focus_for_next_remote_terminal( + workspace: &Entity, + window_id: WindowId, + project_id: &str, + cx: &mut impl AppContext, + ) { + let pid = project_id.to_string(); + workspace.update(cx, |ws, _cx| { + let old_terminal_ids = ws + .project(&pid) + .and_then(|p| p.layout.as_ref()) + .map(|layout| layout.collect_terminal_ids()) + .unwrap_or_default(); + ws.queue_pending_remote_focus(window_id, &pid, old_terminal_ids); + }); + } + /// Dispatch a standard action (split, close, create terminal, service action, etc.). pub fn dispatch(&self, action: ActionRequest, cx: &mut impl AppContext) { - match self { - Self::Local { - workspace, - focus_manager, - backend, - terminals, - service_manager, - window_id, - } => { - // Intercept service actions — these need ServiceManager, not execute_action - if let Some(sm) = service_manager { - match &action { - ActionRequest::StartService { project_id, service_name } => { - sm.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(project_id).cloned() { - sm.start_service(project_id, service_name, &path, cx); - } - }); - return; - } - ActionRequest::StopService { project_id, service_name } => { - sm.update(cx, |sm, cx| sm.stop_service(project_id, service_name, cx)); - return; - } - ActionRequest::RestartService { project_id, service_name } => { - sm.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(project_id).cloned() { - sm.restart_service(project_id, service_name, &path, cx); - } - }); - return; - } - ActionRequest::StartAllServices { project_id } => { - sm.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(project_id).cloned() { - sm.start_all(project_id, &path, cx); - } - }); - return; - } - ActionRequest::StopAllServices { project_id } => { - sm.update(cx, |sm, cx| sm.stop_all(project_id, cx)); - return; - } - ActionRequest::ReloadServices { project_id } => { - sm.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(project_id).cloned() { - sm.reload_project_services(project_id, &path, cx); - } - }); - return; - } - _ => {} - } - } + let Self::Remote { + connection_id, + manager, + workspace, + focus_manager, + window_id, + } = self; - let backend = backend.clone(); - let terminals = terminals.clone(); + // Visual/presentation actions are executed locally on the client + // workspace. They never reach the server, so each client has + // independent visual state that survives state syncs. + match &action { + ActionRequest::UpdateSplitSizes { project_id, path, sizes } => { + let pid = project_id.clone(); + let p = path.clone(); + let s = sizes.clone(); + // Use UI-only notify during drag to avoid auto-save spam; + // final sizes are persisted on mouse-up. + workspace.update(cx, |ws, cx| { + ws.update_split_sizes_ui_only(&pid, &p, s, cx); + }); + return; + } + ActionRequest::ToggleMinimized { project_id, terminal_id } => { + let pid = project_id.clone(); + let tid = terminal_id.clone(); + workspace.update(cx, |ws, cx| { + ws.toggle_terminal_minimized_by_id(&pid, &tid, cx); + }); + return; + } + ActionRequest::SetFullscreen { project_id, terminal_id, .. } => { + let pid = project_id.clone(); + let tid = terminal_id.clone(); let focus_manager = focus_manager.clone(); - let window_id = *window_id; focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| { - // Interactive closes go through the optimistic soft - // close: the pane is ejected immediately and the PTY's - // fate (kill now vs. keep for undo) is decided off the - // GPUI thread. Both the single and multi-terminal close - // actions are gated; whatever isn't handled there - // (feature off / terminal not in layout) falls through - // to the immediate close. - match &action { - ActionRequest::CloseTerminal { project_id, terminal_id } - if crate::soft_close::begin( - ws, fm, &backend, &terminals, project_id, terminal_id, cx, - ) => { - return; - } - ActionRequest::CloseTerminals { project_id, terminal_ids } => { - // Optimistically close each terminal (eject now, - // decide kill-vs-undo off-thread); whatever isn't - // handled here (feature off / not in layout) - // hard-closes in a single batched action. - let mut remaining = Vec::new(); - for terminal_id in terminal_ids { - if !crate::soft_close::begin( - ws, fm, &backend, &terminals, project_id, terminal_id, cx, - ) { - remaining.push(terminal_id.clone()); - } - } - if remaining.is_empty() { - return; - } - execute_action( - ActionRequest::CloseTerminals { - project_id: project_id.clone(), - terminal_ids: remaining, - }, - ws, window_id, fm, &*backend, &terminals, cx, - ); - return; - } - _ => {} + match tid { + Some(tid) => ws.set_fullscreen_terminal(fm, pid, tid, cx), + None => ws.exit_fullscreen(fm, cx), } - execute_action(action, ws, window_id, fm, &*backend, &terminals, cx); }); cx.notify(); }); + return; } - Self::Remote { - connection_id, - manager, - workspace, - focus_manager, - window_id, - } => { - // Visual/presentation actions are executed locally on the client - // workspace. They never reach the server, so each client has - // independent visual state that survives state syncs. - match &action { - ActionRequest::UpdateSplitSizes { project_id, path, sizes } => { - let pid = project_id.clone(); - let p = path.clone(); - let s = sizes.clone(); - // Use UI-only notify during drag to avoid auto-save spam; - // final sizes are persisted on mouse-up. - workspace.update(cx, |ws, cx| { - ws.update_split_sizes_ui_only(&pid, &p, s, cx); - }); - return; - } - ActionRequest::ToggleMinimized { project_id, terminal_id } => { - let pid = project_id.clone(); - let tid = terminal_id.clone(); - workspace.update(cx, |ws, cx| { - ws.toggle_terminal_minimized_by_id(&pid, &tid, cx); - }); - return; - } - ActionRequest::SetFullscreen { project_id, terminal_id, .. } => { - let pid = project_id.clone(); - let tid = terminal_id.clone(); - let focus_manager = focus_manager.clone(); - focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - match tid { - Some(tid) => ws.set_fullscreen_terminal(fm, pid, tid, cx), - None => ws.exit_fullscreen(fm, cx), - } - }); - cx.notify(); - }); - return; - } - ActionRequest::SetActiveTab { project_id, path, index } => { - let pid = project_id.clone(); - let p = path.clone(); - let idx = *index; - workspace.update(cx, |ws, cx| { - ws.set_active_tab(&pid, &p, idx, cx); - }); - return; - } - ActionRequest::FocusTerminal { project_id, terminal_id, .. } => { - let pid = project_id.clone(); - let tid = terminal_id.clone(); - let focus_manager = focus_manager.clone(); - focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - if let Some(project) = ws.project(&pid) - && let Some(ref layout) = project.layout - && let Some(path) = layout.find_terminal_path(&tid) { - ws.set_focused_terminal(fm, pid, path, cx); - } - }); - cx.notify(); - }); - return; - } - ActionRequest::CreateTerminal { project_id } => { - // Record pending focus — the actual focus will happen when - // the next state sync brings the new terminal into the - // client's layout (see sync_remote_projects_into_workspace). - let pid = project_id.clone(); - let window_id = *window_id; - workspace.update(cx, |ws, _cx| { - let old_terminal_ids = ws - .project(&pid) - .and_then(|p| p.layout.as_ref()) - .map(|layout| layout.collect_terminal_ids()) - .unwrap_or_default(); - ws.queue_pending_remote_focus(window_id, &pid, old_terminal_ids); - }); - // Don't return — action proceeds to be sent to server below - } - ActionRequest::CreateWorktree { branch, .. } => { - // Record pending project visibility — the server assigns - // the new worktree project ID, so the next state sync - // applies the spawning-window rule when the branch-named - // project first appears. - let window_id = *window_id; - let cid = connection_id.clone(); - let branch = branch.clone(); - workspace.update(cx, |ws, _cx| { - ws.queue_pending_remote_project_visibility( - window_id, - &cid, - &branch, - None, - ); - }); - // Don't return — action proceeds to be sent to server below - } - _ => {} - } - - let action = strip_remote_ids(action, connection_id); + ActionRequest::SetActiveTab { project_id, path, index } => { + let pid = project_id.clone(); + let p = path.clone(); + let idx = *index; + workspace.update(cx, |ws, cx| { + ws.set_active_tab(&pid, &p, idx, cx); + }); + return; + } + ActionRequest::FocusTerminal { project_id, terminal_id, .. } => { + let pid = project_id.clone(); + let tid = terminal_id.clone(); + let focus_manager = focus_manager.clone(); + focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + if let Some(project) = ws.project(&pid) + && let Some(ref layout) = project.layout + && let Some(path) = layout.find_terminal_path(&tid) { + ws.set_focused_terminal(fm, pid, path, cx); + } + }); + cx.notify(); + }); + return; + } + ActionRequest::CreateTerminal { project_id } => { + // Record pending focus — the actual focus will happen when + // the next state sync brings the new terminal into the + // client's layout (see sync_remote_projects_into_workspace). + Self::queue_focus_for_next_remote_terminal(workspace, *window_id, project_id, cx); + // Don't return — action proceeds to be sent to server below + } + ActionRequest::SplitTerminal { project_id, .. } + | ActionRequest::AddTab { project_id, .. } => { + // Split/tab creation also happens on the daemon now. Defer + // terminal focus until the synced layout contains the new PTY. + Self::queue_focus_for_next_remote_terminal(workspace, *window_id, project_id, cx); + // Don't return — action proceeds to be sent to server below + } + ActionRequest::CreateWorktree { branch, .. } => { + // Record pending project visibility — the server assigns + // the new worktree project ID, so the next state sync + // applies the spawning-window rule when the branch-named + // project first appears. + let window_id = *window_id; let cid = connection_id.clone(); - manager.update(cx, |rm, cx| { - rm.send_action(&cid, action, cx); + let branch = branch.clone(); + workspace.update(cx, |ws, _cx| { + ws.queue_pending_remote_project_visibility( + window_id, + &cid, + &branch, + None, + ); }); + // Don't return — action proceeds to be sent to server below } + _ => {} } + + let action = strip_remote_ids(action, connection_id); + let cid = connection_id.clone(); + manager.update(cx, |rm, cx| { + rm.send_action(&cid, action, cx); + }); } - /// Split a terminal (local: workspace layout operation; remote: via server). + /// Persist the final split sizes to the daemon after an interactive drag. /// - /// For local projects this only modifies the layout — the UI will lazily - /// spawn the PTY with the correct shell. Going through `execute_action` - /// would eagerly call `spawn_uninitialized_terminals` with `None` shell, - /// ignoring the project / global default shell (e.g. WSL). + /// During a drag, `dispatch(UpdateSplitSizes)` only updates the local mirror + /// (`update_split_sizes_ui_only`) to avoid per-frame server round-trips. On + /// mouse-up this sends the final ratios to the daemon so they're persisted to + /// `workspace.json` and survive reconnect / restart and reach other clients. + /// The mirror already holds these sizes; the daemon's state sync preserves + /// them on this client via `LayoutNode::merge_visual_state`. + pub fn commit_split_sizes( + &self, + project_id: &str, + layout_path: &[usize], + sizes: Vec, + cx: &mut impl AppContext, + ) { + let Self::Remote { + connection_id, + manager, + .. + } = self; + let action = strip_remote_ids( + ActionRequest::UpdateSplitSizes { + project_id: project_id.to_string(), + path: layout_path.to_vec(), + sizes, + }, + connection_id, + ); + let cid = connection_id.clone(); + manager.update(cx, |rm, cx| { + rm.send_action(&cid, action, cx); + }); + } + + /// Split a terminal via the server. pub fn split_terminal( &self, project_id: &str, @@ -345,32 +272,17 @@ impl ActionDispatcher { direction: crate::workspace::state::SplitDirection, cx: &mut impl AppContext, ) { - match self { - Self::Local { workspace, focus_manager, .. } => { - let pid = project_id.to_string(); - let lp = layout_path.to_vec(); - let focus_manager = focus_manager.clone(); - focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.split_terminal(fm, &pid, &lp, direction, cx); - }); - cx.notify(); - }); - } - Self::Remote { .. } => { - self.dispatch( - ActionRequest::SplitTerminal { - project_id: project_id.to_string(), - path: layout_path.to_vec(), - direction, - }, - cx, - ); - } - } + self.dispatch( + ActionRequest::SplitTerminal { + project_id: project_id.to_string(), + path: layout_path.to_vec(), + direction, + }, + cx, + ); } - /// Add a tab (local: workspace layout operation; remote: create terminal). + /// Add a tab via the server. pub fn add_tab( &self, project_id: &str, @@ -378,38 +290,19 @@ impl ActionDispatcher { in_group: bool, cx: &mut impl AppContext, ) { - match self { - Self::Local { workspace, focus_manager, .. } => { - let pid = project_id.to_string(); - let lp = layout_path.to_vec(); - let focus_manager = focus_manager.clone(); - focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - if in_group { - ws.add_tab_to_group(fm, &pid, &lp, cx); - } else { - ws.add_tab(fm, &pid, &lp, cx); - } - }); - cx.notify(); - }); - } - Self::Remote { .. } => { - self.dispatch( - ActionRequest::AddTab { - project_id: project_id.to_string(), - path: layout_path.to_vec(), - in_group, - }, - cx, - ); - } - } + self.dispatch( + ActionRequest::AddTab { + project_id: project_id.to_string(), + path: layout_path.to_vec(), + in_group, + }, + cx, + ); } } impl ActionDispatcher { - /// Upload a pasted clipboard image to the remote server (no-op for local). + /// Upload a pasted clipboard image to the remote server. /// /// The server writes the bytes to a temp file on its own filesystem and /// bracketed-pastes that path into the terminal, so a server-side TUI like @@ -422,9 +315,7 @@ impl ActionDispatcher { bytes: Vec, cx: &mut impl AppContext, ) { - let Self::Remote { connection_id, manager, .. } = self else { - return; - }; + let Self::Remote { connection_id, manager, .. } = self; let remote_terminal_id = strip_prefix(terminal_id, connection_id); let cid = connection_id.clone(); let mime = mime.to_string(); @@ -472,6 +363,33 @@ impl okena_views_terminal::ActionDispatch for ActionDispatcher { ) { self.upload_remote_paste_image(terminal_id, mime, bytes, cx); } + + fn export_buffer(&self, terminal_id: &str, cx: &mut gpui::App) -> Option { + let Self::Remote { connection_id, manager, .. } = self; + let remote_terminal_id = strip_prefix(terminal_id, connection_id); + // Resolve the connection's HTTP params, then drop the borrow before the + // blocking request. + let (config, token): (RemoteConnectionConfig, String) = { + let rm = manager.read(cx); + let (config, _, _) = rm + .connections() + .into_iter() + .find(|(c, _, _)| &c.id == connection_id)?; + (config.clone(), config.effective_auth_token()?) + }; + let action = okena_core::api::ActionRequest::ExportBuffer { + terminal_id: remote_terminal_id, + }; + let value = okena_transport::remote_action::post_action_with_config(&config, &token, action) + .ok()??; + let content = value.get("content").and_then(|v| v.as_str())?; + // Write the client-side copy (same naming as the in-process capture). + let short: String = terminal_id.chars().take(8).collect(); + let mut path = std::env::temp_dir(); + path.push(format!("terminal-{}.txt", short)); + std::fs::write(&path, content).ok()?; + Some(path) + } } /// Strip the `remote:{connection_id}:` prefix from terminal and project IDs before sending to server. @@ -528,6 +446,15 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest ActionRequest::ReadContent { terminal_id } => ActionRequest::ReadContent { terminal_id: s(&terminal_id), }, + ActionRequest::UndoSoftClose { terminal_id } => ActionRequest::UndoSoftClose { + terminal_id: s(&terminal_id), + }, + ActionRequest::CloseTerminalNow { terminal_id } => ActionRequest::CloseTerminalNow { + terminal_id: s(&terminal_id), + }, + ActionRequest::ExportBuffer { terminal_id } => ActionRequest::ExportBuffer { + terminal_id: s(&terminal_id), + }, ActionRequest::Resize { terminal_id, cols, @@ -574,6 +501,15 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest terminal_id: s(&terminal_id), name, }, + ActionRequest::SwitchTerminalShell { + project_id, + terminal_id, + shell, + } => ActionRequest::SwitchTerminalShell { + project_id: s(&project_id), + terminal_id: s(&terminal_id), + shell, + }, ActionRequest::AddTab { project_id, path, @@ -717,6 +653,22 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest branch, create_branch, }, + ActionRequest::AddDiscoveredWorktree { + parent_project_id, + worktree_path, + branch, + } => ActionRequest::AddDiscoveredWorktree { + parent_project_id: s(&parent_project_id), + worktree_path, + branch, + }, + ActionRequest::RerunHook { + project_id, + terminal_id, + } => ActionRequest::RerunHook { + project_id: s(&project_id), + terminal_id: s(&terminal_id), + }, ActionRequest::GitCommitGraph { project_id, count, branch } => ActionRequest::GitCommitGraph { project_id: s(&project_id), count, @@ -725,6 +677,31 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest ActionRequest::GitListBranches { project_id } => ActionRequest::GitListBranches { project_id: s(&project_id), }, + ActionRequest::GitListWorktrees { project_id } => ActionRequest::GitListWorktrees { + project_id: s(&project_id), + }, + ActionRequest::WorktreeCloseInfo { project_id } => ActionRequest::WorktreeCloseInfo { + project_id: s(&project_id), + }, + ActionRequest::GenerateWorktreeBranchName { project_id } => ActionRequest::GenerateWorktreeBranchName { + project_id: s(&project_id), + }, + ActionRequest::GitListBranchesClassified { project_id } => ActionRequest::GitListBranchesClassified { + project_id: s(&project_id), + }, + ActionRequest::GitCheckoutLocalBranch { project_id, branch } => ActionRequest::GitCheckoutLocalBranch { + project_id: s(&project_id), + branch, + }, + ActionRequest::GitCheckoutRemoteBranch { project_id, remote_branch } => ActionRequest::GitCheckoutRemoteBranch { + project_id: s(&project_id), + remote_branch, + }, + ActionRequest::GitCreateAndCheckoutBranch { project_id, new_name, start_point } => ActionRequest::GitCreateAndCheckoutBranch { + project_id: s(&project_id), + new_name, + start_point, + }, ActionRequest::GitStageFile { project_id, file_path } => ActionRequest::GitStageFile { project_id: s(&project_id), file_path, @@ -796,6 +773,10 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest project_id: s(&project_id), name, }, + ActionRequest::UpdateProjectHooks { project_id, hooks } => ActionRequest::UpdateProjectHooks { + project_id: s(&project_id), + hooks, + }, ActionRequest::RenameProjectDirectory { project_id, new_name } => ActionRequest::RenameProjectDirectory { project_id: s(&project_id), new_name, @@ -812,20 +793,54 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest project_id: s(&project_id), force, }, + ActionRequest::CloseWorktree { project_id, merge, stash, fetch, push, delete_branch } => ActionRequest::CloseWorktree { + project_id: s(&project_id), + merge, + stash, + fetch, + push, + delete_branch, + }, ActionRequest::CreateFolder { name } => ActionRequest::CreateFolder { name }, - ActionRequest::DeleteFolder { folder_id } => ActionRequest::DeleteFolder { folder_id }, - ActionRequest::RenameFolder { folder_id, name } => ActionRequest::RenameFolder { folder_id, name }, + ActionRequest::DeleteFolder { folder_id } => ActionRequest::DeleteFolder { folder_id: s(&folder_id) }, + ActionRequest::RenameFolder { folder_id, name } => ActionRequest::RenameFolder { folder_id: s(&folder_id), name }, ActionRequest::MoveProjectToFolder { project_id, folder_id, position } => ActionRequest::MoveProjectToFolder { project_id: s(&project_id), - folder_id, + folder_id: s(&folder_id), position, }, ActionRequest::MoveProjectOutOfFolder { project_id, top_level_index } => ActionRequest::MoveProjectOutOfFolder { project_id: s(&project_id), top_level_index, }, - // App-scoped actions carry no project/terminal ids to remap. - a @ (ActionRequest::GetSettings + ActionRequest::MoveProject { project_id, new_index } => ActionRequest::MoveProject { + project_id: s(&project_id), + new_index, + }, + ActionRequest::MoveItemInOrder { item_id, new_index } => ActionRequest::MoveItemInOrder { + // `item_id` is a folder or top-level project id; strip_prefix is a + // no-op on an already-local id. + item_id: s(&item_id), + new_index, + }, + ActionRequest::ToggleProjectPinned { project_id } => ActionRequest::ToggleProjectPinned { + project_id: s(&project_id), + }, + ActionRequest::ReorderWorktree { parent_id, worktree_id, new_index } => ActionRequest::ReorderWorktree { + parent_id: s(&parent_id), + worktree_id: s(&worktree_id), + new_index, + }, + ActionRequest::SetWorktreeColorOverride { project_id, color } => ActionRequest::SetWorktreeColorOverride { + project_id: s(&project_id), + color, + }, + // Session + app-scoped actions carry no project/terminal ids to remap. + a @ (ActionRequest::LoadSession { .. } + | ActionRequest::SaveSession { .. } + | ActionRequest::ImportWorkspace { .. } + | ActionRequest::ExportWorkspace { .. } + | ActionRequest::GetSettings | ActionRequest::GetSettingsSchema | ActionRequest::SetSettings { .. } | ActionRequest::GetThemes diff --git a/crates/okena-app/src/app/detached_terminals.rs b/crates/okena-app/src/app/detached_terminals.rs index 0025a908a..f14e05c50 100644 --- a/crates/okena-app/src/app/detached_terminals.rs +++ b/crates/okena-app/src/app/detached_terminals.rs @@ -1,4 +1,5 @@ use crate::views::overlays::detached_terminal::DetachedTerminalView; +use crate::terminal::terminal::TerminalTransport; use crate::workspace::state::Workspace; use gpui::*; #[cfg(not(target_os = "linux"))] @@ -6,6 +7,7 @@ use gpui_component::Root; #[cfg(target_os = "linux")] use crate::simple_root::SimpleRoot as Root; use std::collections::HashSet; +use std::sync::Arc; use super::Okena; @@ -15,29 +17,63 @@ impl Okena { workspace: Entity, cx: &mut Context, ) { - let ws = workspace.read(cx); - let current_detached: HashSet = ws + // Keep each detached terminal's owning project so we can resolve the + // daemon connection that carries its PTY (every terminal is remote). + let current: Vec<(String, String)> = workspace + .read(cx) .collect_all_detached_terminals() .into_iter() - .map(|(terminal_id, _, _)| terminal_id) + .map(|(terminal_id, project_id, _)| (terminal_id, project_id)) .collect(); - let new_ids: Vec<_> = current_detached + let current_ids: HashSet = + current.iter().map(|(terminal_id, _)| terminal_id.clone()).collect(); + + let new: Vec<(String, String)> = current .iter() - .filter(|id| !self.opened_detached_windows.contains(*id)) + .filter(|(terminal_id, _)| !self.opened_detached_windows.contains(terminal_id)) .cloned() .collect(); - self.opened_detached_windows = current_detached; + self.opened_detached_windows = current_ids; - for terminal_id in new_ids { - self.open_detached_window(&terminal_id, cx); + for (terminal_id, project_id) in new { + self.open_detached_window(&terminal_id, &project_id, cx); } } - fn open_detached_window(&self, terminal_id: &str, cx: &mut Context) { + /// Resolve the `TerminalTransport` that carries a project's terminals. + /// Every project is daemon-served, so its terminal bytes and input ride the + /// connection's `RemoteTransport`. Returns `None` if the project is unknown + /// or its connection isn't currently established. + fn transport_for_project( + &self, + project_id: &str, + cx: &App, + ) -> Option> { + let connection_id = self + .workspace + .read(cx) + .project(project_id)? + .connection_id + .clone()?; + let backend = self.remote_manager.read(cx).backend_for(&connection_id)?; + Some(backend.transport()) + } + + fn open_detached_window(&self, terminal_id: &str, project_id: &str, cx: &mut Context) { let workspace = self.workspace.clone(); - let transport: std::sync::Arc = self.pty_manager.clone(); + // A detached terminal reuses the live `Arc` already in the + // registry; the transport only matters on the re-create fallback. Route + // it over the project's daemon connection so input/recreation never + // touch a local PTY (the thin client owns none). + let Some(transport) = self.transport_for_project(project_id, cx) else { + log::warn!( + "Cannot open detached window for terminal {terminal_id}: no remote \ + transport for its connection (project {project_id})" + ); + return; + }; let terminals = self.terminals.clone(); let terminal_id_owned = terminal_id.to_string(); diff --git a/crates/okena-app/src/app/extras.rs b/crates/okena-app/src/app/extras.rs index 2f3d6117a..14540f861 100644 --- a/crates/okena-app/src/app/extras.rs +++ b/crates/okena-app/src/app/extras.rs @@ -1,5 +1,4 @@ -//! Extra window observer + spawn-side OS window creation + CLI/remote -//! focused-window routing. +//! Extra window observer + spawn-side OS window creation. //! //! Slice 05 keystone. The `Workspace::spawn_extra_window` data-layer mutation //! pushes a fresh `WindowState` onto `WorkspaceData.extra_windows`; this module @@ -16,16 +15,7 @@ //! `WindowOptions::window_bounds`. When `os_bounds` is `None` (e.g. a future //! caller spawns without bounds, or a slice 07 restore loads an entry with no //! recorded bounds), the OS picks a default position. -//! -//! Beyond spawn, this module also hosts the focused-window routing helper -//! `resolve_focused_window_id` used by the remote/CLI bridge to send actions -//! to the per-window `FocusManager` of whichever Okena window currently has -//! OS focus (PRD user story 27 / acceptance criterion 13). When no Okena -//! window is focused (another app is in front, or focus is unknown), the -//! helper falls back to `WindowId::Main`. - -use crate::remote::types::{ApiFullscreen, ApiWindow, ApiWindowBounds}; -use crate::workspace::focus::FocusManager; + use crate::workspace::state::{WindowBounds as PersistedWindowBounds, WindowId, WorkspaceData}; use crate::views::window::{WindowView, WindowViewEvent}; use gpui::*; @@ -113,37 +103,6 @@ pub(super) fn resolve_extra_window_bounds( }) } -/// Resolve the `WindowId` that the currently focused OS window corresponds to, -/// or fall back to `WindowId::Main` if no Okena window is focused (e.g. another -/// application has focus, or the active window isn't tracked). -/// -/// Pure function — generic over the handle type so the routing rule can be -/// exercised without standing up real `gpui::AnyWindowHandle` values (which -/// have private fields and can only be constructed via `cx.open_window`). The -/// production caller passes `gpui::AnyWindowHandle`; tests use a trivial -/// stand-in. -/// -/// PRD ref: `plans/multi-window.md` user story 27 ("CLI lands its action in -/// the focused window if any, falling back to main otherwise") + -/// `plans/issues/multi-window/05-spawn-extra-window.md` acceptance criterion -/// 13 (CLI fallback). Used by `Okena::focus_manager_for_active_window` to -/// route remote-bridge actions (the existing `okena action` CLI verb + -/// future `okena open `-style verbs) to the correct per-window -/// `FocusManager`. -pub(super) fn resolve_focused_window_id( - active: Option, - window_handles: &[(WindowId, H)], -) -> WindowId { - match active { - Some(a) => window_handles - .iter() - .find(|(_, h)| *h == a) - .map(|(id, _)| *id) - .unwrap_or(WindowId::Main), - None => WindowId::Main, - } -} - impl Okena { /// Workspace observer body: walk `extra_windows`, open an OS window for /// each entry not yet tracked in `Okena.extra_windows`. Idempotent — @@ -166,192 +125,6 @@ impl Okena { } } - /// Resolve the `(WindowId, Entity)` of whichever Okena - /// window currently has OS focus, falling back to - /// `(WindowId::Main, main_window.focus_manager())` if no Okena window is - /// focused (e.g. another app is in front, or the active window isn't - /// tracked). Used by the remote-bridge command loop so CLI/remote-driven - /// actions land in the focused window's per-window state per PRD user - /// story 27 + slice 05 cri 13. The `WindowId` flows into `execute_action` - /// so per-window data mutations (e.g. `SetProjectShowInOverview`) - /// also target the focused window, not just focus state. - pub(super) fn focus_manager_for_active_window( - &self, - cx: &App, - ) -> (WindowId, Entity) { - let active = cx.active_window(); - let mut handles: Vec<(WindowId, AnyWindowHandle)> = Vec::with_capacity(1 + self.extra_window_handles.len()); - handles.push((WindowId::Main, self.main_window_handle)); - handles.extend(self.extra_window_handles.iter().map(|(id, h)| (*id, *h))); - let resolved = resolve_focused_window_id(active, &handles); - match resolved { - WindowId::Main => (WindowId::Main, self.main_window.read(cx).focus_manager()), - extra_id @ WindowId::Extra(_) => match self.extra_windows.get(&extra_id) { - // Drop-race fallback: the resolver matched on a tracked extra - // handle but the corresponding `WindowView` entity has been - // dropped between handle-tracking and resolution. Fall back - // to main's `(WindowId, FocusManager)` so per-window data - // mutations target a slot that exists. - Some(view) => (extra_id, view.read(cx).focus_manager()), - None => (WindowId::Main, self.main_window.read(cx).focus_manager()), - }, - } - } - - /// Resolve the `(WindowId, Entity)` of a remote-bridge - /// action's target window. - /// - /// `target == None` reuses the focused-window default - /// (`focus_manager_for_active_window`, which always resolves to Some). - /// `Some(WindowId::Main)` always resolves to the main window. - /// `Some(WindowId::Extra(_))` resolves to that extra's `WindowView` if it - /// is currently open, or `None` if no such extra exists (so the caller can - /// report "window not found"). The `WindowId` flows into `execute_action` - /// so per-window data mutations target the addressed window. - pub(super) fn focus_manager_for_window( - &self, - cx: &App, - target: Option, - ) -> Option<(WindowId, Entity)> { - match target { - None => Some(self.focus_manager_for_active_window(cx)), - Some(WindowId::Main) => { - Some((WindowId::Main, self.main_window.read(cx).focus_manager())) - } - Some(extra @ WindowId::Extra(_)) => self - .extra_windows - .get(&extra) - .map(|view| (extra, view.read(cx).focus_manager())), - } - } - - /// Resolve a target window to its OS handle, for dispatching GUI actions - /// (the command palette) from the remote bridge. `None` → the focused - /// window (falling back to main); `Some(id)` → that window, or `None` if it - /// doesn't exist. - pub(super) fn window_handle_for( - &self, - cx: &App, - target: Option, - ) -> Option { - match target { - None => cx.active_window().or(Some(self.main_window_handle)), - Some(WindowId::Main) => Some(self.main_window_handle), - Some(extra @ WindowId::Extra(_)) => self.extra_window_handles.get(&extra).copied(), - } - } - - /// Enumerate the open OS windows for `GET /v1/state`. - /// - /// Stable order: main first, then extras in `WorkspaceData.extra_windows` - /// Vec order (NOT `Okena.extra_windows` HashMap order) so a client sees a - /// deterministic list that matches persistence. For each window the result - /// reports its OS-focus flag, per-window focus (project + terminal), - /// fullscreen target, the persistent visible-project set (after - /// `hidden_project_ids` + `folder_filter`), the active folder filter, OS - /// bounds, and sidebar state. - pub(super) fn build_api_windows(&self, cx: &App) -> Vec { - let active = cx.active_window(); - let workspace = self.workspace.read(cx); - - // Drive enumeration from the persisted `extra_windows` Vec so the - // ordering is stable, regardless of the `Okena.extra_windows` HashMap - // iteration order. Main is always first. - let mut order: Vec = Vec::with_capacity(1 + workspace.data().extra_windows.len()); - order.push(WindowId::Main); - order.extend( - workspace - .data() - .extra_windows - .iter() - .map(|w| WindowId::Extra(w.id)), - ); - - order - .into_iter() - .filter_map(|window_id| { - // Resolve the per-window view + OS handle. An extra present in - // persistence but not yet tracked on `Okena` (spawn observer - // hasn't run, or a close race) is skipped — it has no live - // window to report. - let (view, handle) = match window_id { - WindowId::Main => (&self.main_window, Some(self.main_window_handle)), - extra @ WindowId::Extra(_) => match self.extra_windows.get(&extra) { - Some(v) => (v, self.extra_window_handles.get(&extra).copied()), - None => return None, - }, - }; - - let (id, kind) = match window_id { - WindowId::Main => ("main".to_string(), "main"), - WindowId::Extra(uuid) => (uuid.to_string(), "extra"), - }; - - let is_active = match (active, handle) { - (Some(a), Some(h)) => a == h, - _ => false, - }; - - let fm = view.read(cx).focus_manager().read(cx); - - // Resolve the focused terminal id: the FocusManager tracks a - // (project_id, layout_path) target; map the path to the - // terminal id at that path in the project's current layout. - let focused_terminal_id = fm.focused_terminal_state().and_then(|ft| { - workspace - .project(&ft.project_id) - .and_then(|p| p.layout.as_ref()) - .and_then(|layout| layout.get_at_path(&ft.layout_path)) - .and_then(|node| match node { - crate::workspace::state::LayoutNode::Terminal { terminal_id, .. } => { - terminal_id.clone() - } - _ => None, - }) - }); - - let fullscreen = fm.fullscreen_state().map(|(pid, tid)| ApiFullscreen { - project_id: pid.to_string(), - terminal_id: tid.to_string(), - }); - - let focused_project_id = fm.focused_project_id().cloned(); - - // Persistent visible set: hidden_project_ids + folder_filter - // only (NOT the transient focus narrowing), matching what the - // sidebar persists rather than the momentary zoom state. - let visible_project_ids: Vec = workspace - .visible_projects(window_id, None, false) - .iter() - .map(|p| p.id.clone()) - .collect(); - - let window_state = workspace.data().window(window_id); - let folder_filter = - window_state.and_then(|w| w.folder_filter.clone()); - let bounds = window_state.and_then(|w| w.os_bounds).map(|b| ApiWindowBounds { - x: b.origin_x, - y: b.origin_y, - width: b.width, - height: b.height, - }); - let sidebar_open = window_state.and_then(|w| w.sidebar_open); - - Some(ApiWindow { - id, - kind: kind.to_string(), - active: is_active, - focused_project_id, - focused_terminal_id, - fullscreen, - visible_project_ids, - folder_filter, - bounds, - sidebar_open, - }) - }) - .collect() - } /// Route a [`WindowViewEvent`] raised by any window to the right handler. pub(super) fn handle_window_view_event( @@ -458,7 +231,6 @@ impl Okena { /// `extra_window_handles` maps. fn open_extra_window(&mut self, window_id: WindowId, cx: &mut Context) { let workspace = self.workspace.clone(); - let pty_manager = self.pty_manager.clone(); let terminals = self.terminals.clone(); let okena = cx.entity().clone(); @@ -525,7 +297,7 @@ impl Okena { }, move |window, cx| { let view = cx.new(|cx| { - WindowView::new(window_id, workspace.clone(), pty_manager.clone(), terminals.clone(), window, cx) + WindowView::new(window_id, workspace.clone(), terminals.clone(), window, cx) }); let view_for_okena = view.clone(); let handle = window.window_handle(); @@ -550,18 +322,14 @@ impl Okena { // a "jump into project" can target this window too. cx.subscribe(&view_for_okena, Okena::handle_window_view_event).detach(); - // Wire the per-window UI to the shared singletons + // Wire the per-window UI to the shared remote manager // main was wired with at startup. Without this, the - // extra's ProjectColumns have no git_watcher (no +/- - // diff badges), no service_manager (service panel - // dead), and no remote_manager (remote actions don't - // route). - let git_watcher = this.git_watcher.clone(); - let service_manager = this.service_manager.clone(); + // extra's ProjectColumns have no remote_manager (remote + // actions don't route). Git status + services arrive via + // the daemon snapshot, so there is nothing in-process to + // wire here. let remote_manager = this.remote_manager.clone(); view_for_okena.update(cx, |rv, cx| { - rv.set_git_watcher(git_watcher, cx); - rv.set_service_manager(service_manager, cx); rv.set_remote_manager(remote_manager, cx); }); }); @@ -604,7 +372,7 @@ impl Okena { #[cfg(test)] mod tests { - use super::{extras_to_close, extras_to_open, resolve_extra_window_bounds, resolve_focused_window_id}; + use super::{extras_to_close, extras_to_open, resolve_extra_window_bounds}; use crate::workspace::state::{WindowBounds as PersistedWindowBounds, WindowId, WindowState, WorkspaceData}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; @@ -817,67 +585,6 @@ mod tests { assert_eq!(extras_to_open(&data, &opened), ids, "helper must surface every pending entry"); } - // ── Focused-window routing ─────────────────────────────────────────── - - #[test] - fn no_active_window_falls_back_to_main() { - // Another OS app is in front (or focus is unknown). The CLI/remote - // action must still land somewhere — main is the fallback per PRD - // user story 27 ("falling back to main otherwise"). - let main_handle: u32 = 1; - let extra_id = WindowId::Extra(Uuid::new_v4()); - let handles = vec![(WindowId::Main, main_handle), (extra_id, 2)]; - assert_eq!(resolve_focused_window_id::(None, &handles), WindowId::Main); - } - - #[test] - fn active_main_resolves_to_main() { - let main_handle: u32 = 1; - let extra_id = WindowId::Extra(Uuid::new_v4()); - let handles = vec![(WindowId::Main, main_handle), (extra_id, 2)]; - assert_eq!( - resolve_focused_window_id(Some(main_handle), &handles), - WindowId::Main, - ); - } - - #[test] - fn active_extra_resolves_to_that_extra() { - // PRD cri 13's W2-focused branch: the focused window is an extra; - // routing must land on that extra's WindowId so the remote bridge - // mutates that extra's per-window FocusManager. - let main_handle: u32 = 1; - let extra_a = WindowId::Extra(Uuid::new_v4()); - let extra_b = WindowId::Extra(Uuid::new_v4()); - let handles = vec![ - (WindowId::Main, main_handle), - (extra_a, 2), - (extra_b, 3), - ]; - assert_eq!(resolve_focused_window_id(Some(2), &handles), extra_a); - assert_eq!(resolve_focused_window_id(Some(3), &handles), extra_b); - } - - #[test] - fn unknown_active_window_falls_back_to_main() { - // The active window isn't tracked (e.g. detached terminal popup, or - // a window opened by a future feature that doesn't register here). - // Fall back to main rather than dropping the action. - let main_handle: u32 = 1; - let handles = vec![(WindowId::Main, main_handle)]; - assert_eq!(resolve_focused_window_id(Some(99), &handles), WindowId::Main); - } - - #[test] - fn empty_handles_falls_back_to_main() { - // Defensive — should never happen in practice (main is always tracked) - // but the helper stays total: any input shape produces a valid - // WindowId, never panics. - let handles: Vec<(WindowId, u32)> = Vec::new(); - assert_eq!(resolve_focused_window_id(Some(1), &handles), WindowId::Main); - assert_eq!(resolve_focused_window_id::(None, &handles), WindowId::Main); - } - // ── Restore-bounds resolver ────────────────────────────────────────── #[test] @@ -951,16 +658,4 @@ mod tests { .expect("persisted alone is sufficient"); assert_eq!(resolved, persisted); } - - #[test] - fn first_match_wins_on_duplicate_handles() { - // Pathological input — two entries point at the same handle. The - // helper picks the first match (Vec order). In production, handles - // are unique per OS window, but pinning the rule keeps the helper - // deterministic if a future bug duplicates an entry. - let extra_a = WindowId::Extra(Uuid::new_v4()); - let extra_b = WindowId::Extra(Uuid::new_v4()); - let handles = vec![(WindowId::Main, 1u32), (extra_a, 2), (extra_b, 2)]; - assert_eq!(resolve_focused_window_id(Some(2), &handles), extra_a); - } } diff --git a/crates/okena-app/src/app/headless.rs b/crates/okena-app/src/app/headless.rs index 82d77988a..0b81aa47a 100644 --- a/crates/okena-app/src/app/headless.rs +++ b/crates/okena-app/src/app/headless.rs @@ -13,7 +13,8 @@ use crate::workspace::persistence; use crate::workspace::state::{GlobalWorkspace, WindowId, Workspace, WorkspaceData}; use async_channel::Receiver; use gpui::*; -use okena_core::api::ApiGitStatus; +use okena_core::api::{ApiGitStatus, ApiToast}; +use okena_core::git_poll::GitPollTrigger; use parking_lot::Mutex; use std::collections::{HashMap, HashSet}; use std::net::IpAddr; @@ -43,6 +44,10 @@ pub struct HeadlessApp { pty_broadcaster: Arc, state_version: Arc>, git_status_tx: Arc>>, + /// Broadcast of daemon-originated toasts forwarded to thin clients. Fed by + /// the toast-drain loop, which forwards the `HookMonitor`'s soft-close / hook + /// toasts onto this channel. + toast_tx: Arc>, remote_subscribed_terminals: Arc>>>, next_remote_connection_id: Arc, #[allow(dead_code)] @@ -58,7 +63,7 @@ impl HeadlessApp { workspace_data: WorkspaceData, pty_manager: Arc, pty_events: Receiver, - listen_addr: IpAddr, + listen_addrs: Vec, tls_enabled: bool, cx: &mut Context, ) -> Self { @@ -132,6 +137,10 @@ impl HeadlessApp { // Git status watcher let (git_status_tx, _) = tokio_watch::channel(HashMap::new()); let git_status_tx = Arc::new(git_status_tx); + // Toast broadcast (capacity matches a small backlog; lagging clients just + // drop non-critical notifications). Fed by the toast-drain loop below. + let (toast_tx, _) = tokio::sync::broadcast::channel::(64); + let toast_tx = Arc::new(toast_tx); let remote_subscribed_terminals: Arc>>> = Arc::new(std::sync::RwLock::new(HashMap::new())); let next_remote_connection_id = Arc::new(AtomicU64::new(0)); @@ -142,6 +151,27 @@ impl HeadlessApp { |cx| GitStatusWatcher::new(workspace, git_status_tx, remote_subscribed_terminals, cx) }); + // Git-poll wake-ups: the same channel the dedicated `okena-daemon` uses. + // The remote server sends `visibility_changed` when a client subscribes + // to terminals, and the command loop sends a per-action trigger (git + // status requested, project shown, branch checked out). The consumer + // task below drives the `GitStatusWatcher` so a project refreshes the + // moment it becomes visible instead of on the next poll cycle. + let (git_poll_trigger_tx, mut git_poll_trigger_rx) = + tokio::sync::mpsc::unbounded_channel::(); + cx.spawn({ + let git_watcher = git_watcher.downgrade(); + async move |_this: WeakEntity, cx: &mut AsyncApp| { + while let Some(trigger) = git_poll_trigger_rx.recv().await { + let Some(watcher) = git_watcher.upgrade() else { break }; + cx.update(|cx| { + watcher.update(cx, |w, cx| w.apply_poll_trigger(trigger, cx)); + }); + } + } + }) + .detach(); + // Create service manager for project-scoped background processes let local_backend_for_services: Arc = Arc::new(LocalBackend::new(pty_manager.clone())); @@ -199,6 +229,7 @@ impl HeadlessApp { pty_broadcaster: pty_broadcaster.clone(), state_version: state_version.clone(), git_status_tx: git_status_tx.clone(), + toast_tx: toast_tx.clone(), remote_subscribed_terminals: remote_subscribed_terminals.clone(), next_remote_connection_id: next_remote_connection_id.clone(), git_watcher, @@ -212,6 +243,10 @@ impl HeadlessApp { // Start remote command bridge loop (shared with GUI) let local_backend: Arc = Arc::new(LocalBackend::new(pty_manager)); + // A second backend handle for the soft-close finalizer tick (kills + // grace-expired PTYs). `LocalBackend` is a thin `Arc` wrapper, + // so cloning the `Arc` shares the same PtyManager. + let finalizer_backend: Arc = local_backend.clone(); // Headless mode has no GUI window. Provide a standalone FocusManager // so remote action methods that take `&mut FocusManager` still // compile -- in headless the focus state never drives a render so it @@ -259,23 +294,87 @@ impl HeadlessApp { let action_dispatcher: ActionDispatcher = Arc::new(|_cx, _target, _name| { Err("command palette unavailable in headless mode".to_string()) }); + + // Soft-close plumbing (parity with the daemon-core loop): a HookMonitor + // to receive the Undo/Close-now toasts and the shared grace-deadline + // map. The finalizer + toast-drain spawn loops below realize them. + let hook_monitor = crate::workspace::hook_monitor::HookMonitor::new(); + let deadlines: okena_workspace::actions::soft_close::SoftCloseDeadlines = + Arc::new(Mutex::new(HashMap::new())); + cx.spawn({ let workspace = workspace.clone(); let terminals = terminals.clone(); let state_version = state_version.clone(); let git_status_tx = git_status_tx.clone(); let service_manager = service_manager.clone(); + let hook_monitor = hook_monitor.clone(); + let deadlines = deadlines.clone(); + let git_poll_trigger_tx = git_poll_trigger_tx.clone(); async move |_this: WeakEntity, cx: &mut AsyncApp| { remote_command_loop( bridge_rx, local_backend, workspace, focus_manager_resolver, windows_resolver, - terminals, state_version, git_status_tx, service_manager, action_dispatcher, cx, + terminals, state_version, git_status_tx, service_manager, action_dispatcher, + hook_monitor, deadlines, Some(git_poll_trigger_tx), cx, ).await; } }) .detach(); + // Grace-period finalizer tick: kill soft-closed PTYs whose grace elapsed. + // Mirrors the daemon-core `run_soft_close_poll` loop, driven off a gpui + // timer instead of tokio. + cx.spawn({ + let workspace = workspace.clone(); + let terminals = terminals.clone(); + let deadlines = deadlines.clone(); + let backend = finalizer_backend.clone(); + async move |_this: WeakEntity, cx: &mut AsyncApp| { + loop { + smol::Timer::after(std::time::Duration::from_millis(200)).await; + // `cx.update` returns the closure result directly here; the + // detached task is cancelled when the app executor is torn + // down at shutdown. + cx.update(|cx| { + workspace.update(cx, |ws, cx| { + okena_workspace::actions::soft_close::finalize_expired( + &deadlines, ws, &*backend, &terminals, cx, + ); + }); + }); + } + } + }) + .detach(); + + // Toast drain: forward the HookMonitor's pending soft-close (and hook) + // toasts onto the remote broadcast the server fans out to thin clients. + // Mirrors the daemon-core `run_toast_poll` loop; realizes the previously + // producer-less `toast_tx`. + cx.spawn({ + let hook_monitor = hook_monitor.clone(); + let toast_tx = toast_tx.clone(); + async move |_this: WeakEntity, _cx: &mut AsyncApp| { + loop { + smol::Timer::after(std::time::Duration::from_millis(200)).await; + for toast in hook_monitor.drain_pending_toasts() { + // Fire-and-forget: a send with no receivers is expected + // (clients come and go) and ignored. + let _ = toast_tx.send(toast.to_api()); + } + } + } + }) + .detach(); + // Start remote server - app.start_remote_server(bridge_tx, listen_addr, tls_enabled, &remote_info); + app.start_remote_server( + bridge_tx, + listen_addrs, + tls_enabled, + git_poll_trigger_tx, + &remote_info, + ); app } @@ -284,8 +383,9 @@ impl HeadlessApp { fn start_remote_server( &mut self, bridge_tx: bridge::BridgeSender, - listen_addr: IpAddr, + listen_addrs: Vec, tls_enabled: bool, + git_poll_trigger_tx: tokio::sync::mpsc::UnboundedSender, remote_info: &RemoteInfo, ) { match RemoteServer::start( @@ -293,11 +393,20 @@ impl HeadlessApp { self.auth_store.clone(), self.pty_broadcaster.clone(), self.state_version.clone(), - listen_addr, + listen_addrs, self.git_status_tx.clone(), + self.toast_tx.clone(), self.remote_subscribed_terminals.clone(), + Some(git_poll_trigger_tx), self.next_remote_connection_id.clone(), + // Live-WS-connection count for `/v1/shutdown`; headless has no other + // reader so it's created inline. `None` shutdown signal: the + // single-binary `okena --headless` daemon has no graceful run-loop to + // wake, so the endpoint hard-exits instead (see routes/shutdown.rs). + Arc::new(AtomicU64::new(0)), + None, tls_enabled, + env!("CARGO_PKG_VERSION"), ) { Ok(server) => { let port = server.port(); diff --git a/crates/okena-app/src/app/mod.rs b/crates/okena-app/src/app/mod.rs index 4c0bbaf1a..69cd831db 100644 --- a/crates/okena-app/src/app/mod.rs +++ b/crates/okena-app/src/app/mod.rs @@ -8,94 +8,61 @@ mod remote_config; pub use detached_overlays::open_detached_overlay; -use crate::git::watcher::GitStatusWatcher; -use crate::workspace::worktree_sync::WorktreeSyncWatcher; -use crate::remote::auth::AuthStore; -use crate::remote::bridge; -use crate::remote::pty_broadcaster::PtyBroadcaster; -use crate::remote::server::RemoteServer; -use crate::remote::{GlobalRemoteInfo, RemoteInfo}; -use crate::remote_client::manager::RemoteConnectionManager; +use crate::remote_client::manager::{RemoteConnectionManager, RemoteManagerEvent}; use crate::services::manager::ServiceManager; -use crate::settings::{GlobalSettings, settings}; -use crate::views::panels::toast::ToastManager; -use crate::terminal::pty_manager::{PtyEvent, PtyManager}; -use okena_ext_claude::resolve_claude_dir; use crate::views::window::{TerminalsRegistry, WindowView}; -use crate::workspace::persistence; use crate::workspace::state::{GlobalWorkspace, WindowId, Workspace, WorkspaceData}; -use async_channel::Receiver; use gpui::*; -use okena_core::api::ApiGitStatus; use std::collections::{HashMap, HashSet}; -use std::net::IpAddr; -use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use tokio::sync::watch as tokio_watch; - -fn is_default_claude_dir(claude_dir: &Path) -> bool { - let Some(home) = dirs::home_dir() else { - return false; - }; - let default_dir = home.join(".claude"); - let canonical_default = default_dir.canonicalize().unwrap_or(default_dir); - let canonical_dir = claude_dir - .canonicalize() - .unwrap_or_else(|_| claude_dir.to_path_buf()); - canonical_dir == canonical_default +use std::time::Duration; + +/// Identity guard for [`kill_process_by_pid`]: OS pids recycle, so a pid taken +/// from a possibly-stale `remote.json` may now belong to an unrelated process. +/// Only a process whose name or executable file name starts with "okena" (the +/// `okena`/`okena-daemon` binaries) may be killed. Pure so it's unit-testable. +fn is_okena_process(name: Option<&str>, exe_file_name: Option<&str>) -> bool { + let is_okena = |s: &str| s.starts_with("okena"); + name.is_some_and(is_okena) || exe_file_name.is_some_and(is_okena) } -fn claude_pty_extra_env( - claude_dir: &Path, - multi_profile: bool, - parent_has_claude_config_dir: bool, -) -> Vec<(String, Option)> { - // Default `~/.claude`: actively remove CLAUDE_CONFIG_DIR from the PTY rather - // than just leaving it unset. This keeps Claude Code on its canonical Keychain - // service (an explicit CLAUDE_CONFIG_DIR=~/.claude makes it create a suffixed - // duplicate) *and* prevents a stale value — e.g. one exported in the shell - // that launched Okena and inherited by our process — from leaking into the - // terminal and silently pointing `claude` at the wrong account. - if is_default_claude_dir(claude_dir) { - return vec![("CLAUDE_CONFIG_DIR".to_string(), None)]; +/// Best-effort kill a process by pid — SIGKILL on Unix, `TerminateProcess` on +/// Windows, matching `std::process::Child::kill`. Used by the UI-owned daemon +/// lifecycle to reap a daemon we own but hold no `Child` for: a restart spawns a +/// *detached* successor, known to us only by the pid it advertises in +/// `remote.json`. A pid of 0 (unknown) or an already-dead process is a no-op. +/// Refuses (warn + skip) when the process at that pid doesn't look like an okena +/// binary — see [`is_okena_process`]. +fn kill_process_by_pid(pid: u32) { + if pid == 0 { + return; } - - // Single-profile user who manages CLAUDE_CONFIG_DIR themselves: there's no - // profile boundary to enforce, so leave their exported value untouched. - if !multi_profile && parent_has_claude_config_dir { - return Vec::new(); + use sysinfo::{Pid, ProcessesToUpdate, System}; + let spid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true); + if let Some(proc) = sys.process(spid) { + let name = proc.name().to_str(); + let exe_file_name = proc + .exe() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()); + if !is_okena_process(name, exe_file_name) { + log::warn!( + "Refusing to kill pid {pid}: process {name:?} (exe {exe_file_name:?}) is not an okena binary — the pid was likely recycled" + ); + return; + } + proc.kill(); } - - vec![( - "CLAUDE_CONFIG_DIR".to_string(), - Some(claude_dir.to_string_lossy().into_owned()), - )] -} - -/// Push the resolved Claude config directory into the PTY manager as -/// CLAUDE_CONFIG_DIR so `claude` invocations inside Okena terminals read the -/// per-profile account. -/// -/// Non-default dirs get an unconditional override for multi-profile users -/// (otherwise account isolation would silently break for anyone with -/// `CLAUDE_CONFIG_DIR` exported in their shell rc). The default `~/.claude` is -/// actively *unset* instead so Claude Code uses its canonical Keychain service -/// rather than creating a suffixed duplicate for the same path. -fn sync_claude_pty_env(pty_manager: &Arc, cx: &App) { - let multi_profile = okena_core::profiles::all_profiles() - .map(|p| p.len() > 1) - .unwrap_or(false); - let claude_dir = resolve_claude_dir(cx); - pty_manager.set_extra_env(claude_pty_extra_env( - &claude_dir, - multi_profile, - std::env::var("CLAUDE_CONFIG_DIR").is_ok(), - )); } /// Set up an observer that loads/unloads service configs when projects change. /// Handles deferred worktrees by skipping projects whose directory doesn't exist yet. +/// +/// Used by the headless daemon (`HeadlessApp`), which is the real service owner. +/// The GUI is a thin client and never runs services in-process. pub(crate) fn observe_project_services( workspace: &Entity, service_manager: &Entity, @@ -175,36 +142,9 @@ pub struct Okena { /// remote-bridge boundary (PRD cri 13). pub(super) extra_window_handles: HashMap, pub(crate) workspace: Entity, - pub(crate) pty_manager: Arc, pub(crate) terminals: TerminalsRegistry, /// Track which detached windows we've already opened pub(crate) opened_detached_windows: HashSet, - /// Flag indicating workspace needs to be saved (for debouncing) - /// Note: Field is read by spawned tasks, not directly - #[allow(dead_code)] - save_pending: Arc, - // ── Git status watcher ──────────────────────────────────────────── - #[allow(dead_code)] - git_watcher: Entity, - // ── Worktree sync watcher ──────────────────────────────────────── - #[allow(dead_code)] - worktree_sync: Entity, - git_status_tx: Arc>>, - remote_subscribed_terminals: Arc>>>, - next_remote_connection_id: Arc, - // ── Remote control fields ─────────────────────────────────────────── - remote_server: Option, - pub auth_store: Arc, - pub(crate) pty_broadcaster: Arc, - pub(crate) state_version: Arc>, - remote_info: RemoteInfo, - listen_addr: IpAddr, - /// Serve the remote server over TLS (mirrors settings.remote_tls_enabled). - remote_tls_enabled: bool, - /// Whether the listen address was forced via CLI --listen flag - force_remote: bool, - /// Service manager for project-scoped background processes - service_manager: Entity, /// Remote connection manager. Held so extras spawned at runtime can /// be wired with the same singleton main was wired with at startup /// (`open_extra_window` calls `set_remote_manager` on the new view). @@ -213,84 +153,35 @@ pub struct Okena { /// XDG notification, the thread sends a `NotificationJump` here and the /// click loop focuses the originating pane. See `app/notifications.rs`. notification_jump_tx: async_channel::Sender, + /// Child of a daemon WE spawned in `--daemon-client` mode; killed on app + /// quit. `None` if we attached to an existing daemon or in classic mode. + /// Replaced (old handle reaped) by `recover_local_daemon` when self-healing + /// spawns a fresh daemon. + spawned_daemon: Option, + /// Single-flight guard for the local-daemon recovery task: set while a + /// recovery loop runs so repeat `LocalConnectionFailed` events don't stack + /// up parallel recoveries (each would re-run `ensure_local_daemon`). + recovering: Arc, + /// Set at the start of the quit handler so an in-flight (or newly triggered) + /// recovery bails instead of resurrecting the connection or spawning a + /// daemon we'd immediately orphan. Guards the part-B quit path's + /// `remove_connection` from being mistaken for a recoverable failure. + quitting: Arc, } impl Okena { pub fn new( workspace_data: WorkspaceData, - pty_manager: Arc, - pty_events: Receiver, - listen_addr: Option, + local_daemon: okena_remote_server::local::EnsuredDaemon, window: &mut Window, cx: &mut Context, ) -> Self { - let force_remote = listen_addr.is_some(); - let listen_addr = listen_addr.unwrap_or_else(|| { - cx.global::().0.read(cx).get() - .remote_listen_address.parse::() - .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) - }); - let remote_tls_enabled = cx - .global::() - .0 - .read(cx) - .get() - .remote_tls_enabled; - // Create workspace entity + // Create workspace entity. The GUI is always a thin daemon client: the + // daemon owns persistence + the instance lock and is the single writer, + // so the GUI's `Workspace` is a pure mirror with no autosave. let workspace = cx.new(|_cx| Workspace::new(workspace_data)); cx.set_global(GlobalWorkspace(workspace.clone())); - // Shared flag for debounced save - let save_pending = Arc::new(AtomicBool::new(false)); - // Track last saved data_version to skip saves for UI-only changes - let last_saved_version = Arc::new(AtomicU64::new(0)); - - // Set up debounced auto-save on workspace changes - let save_pending_for_observer = save_pending.clone(); - let last_saved_version_for_observer = last_saved_version.clone(); - let workspace_for_save = workspace.clone(); - cx.observe(&workspace, move |_this, _workspace, cx| { - // Check if persistent data actually changed - let current_version = _workspace.read(cx).data_version(); - if current_version == last_saved_version_for_observer.load(Ordering::Relaxed) { - return; // UI-only change, skip save - } - - save_pending_for_observer.store(true, Ordering::Relaxed); - - let save_pending = save_pending_for_observer.clone(); - let last_saved = last_saved_version_for_observer.clone(); - let workspace = workspace_for_save.clone(); - cx.spawn(async move |_, cx| { - smol::Timer::after(std::time::Duration::from_millis(500)).await; - - if save_pending.swap(false, Ordering::Relaxed) { - let (data, version) = cx.update(|cx| { - let _slow = okena_core::timing::SlowGuard::new("workspace_save_clone"); - let ws = workspace.read(cx); - (ws.data().clone(), ws.data_version()) - }); - // Run blocking fs IO off the GPUI main thread — on Windows - // an AV scan or OneDrive sync of workspace.json can stall - // for seconds and would otherwise freeze the UI. - let save_result = smol::unblock(move || persistence::save_workspace(&data)).await; - match save_result { - Ok(()) => { - last_saved.store(version, Ordering::Relaxed); - } - Err(e) => { - log::error!("Failed to save workspace: {}", e); - cx.update(|cx| { - ToastManager::error(format!("Failed to save workspace: {}", e), cx); - }); - // Don't update last_saved — next mutation will retry the save - } - } - } - }).detach(); - }) - .detach(); - // Shared terminals registry — one per Okena instance, threaded into // every WindowView (main + extras). Each TerminalPane looks up the // existing Arc for its terminal_id from this registry; if @@ -301,10 +192,9 @@ impl Okena { let terminals: TerminalsRegistry = Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())); // Create the main window's per-window view, sharing the registry. - let pty_manager_clone = pty_manager.clone(); let terminals_for_main = terminals.clone(); let main_window = cx.new(|cx| { - WindowView::new(WindowId::Main, workspace.clone(), pty_manager_clone, terminals_for_main, window, cx) + WindowView::new(WindowId::Main, workspace.clone(), terminals_for_main, window, cx) }); // Listen for cross-window requests (e.g. "jump into a project's terminal" @@ -312,22 +202,6 @@ impl Okena { // every window's view + OS handle, so it executes these. cx.subscribe(&main_window, Self::handle_window_view_event).detach(); - // Create service manager for project-scoped background processes - let local_backend_for_services: Arc = - Arc::new(crate::terminal::backend::LocalBackend::new(pty_manager.clone())); - let service_manager = cx.new(|_cx| { - ServiceManager::new(local_backend_for_services.clone(), terminals.clone()) - }); - main_window.update(cx, |rv, cx| { - rv.set_service_manager(service_manager.clone(), cx); - }); - - // Create HookRunner for PTY-backed hook execution - cx.set_global(crate::workspace::hooks::HookRunner::new( - local_backend_for_services.clone(), - terminals.clone(), - )); - // Create remote connection manager and wire to main window let remote_manager = cx.new(|cx| { RemoteConnectionManager::new(terminals.clone(), cx) @@ -335,7 +209,38 @@ impl Okena { main_window.update(cx, |rv, cx| { rv.set_remote_manager(remote_manager.clone(), cx); }); - // Auto-connect to saved connections with valid tokens + + // Register the implicit, trusted loopback connection to our local + // daemon so its projects mirror into the GUI. We own the spawned child + // (if any) and kill it on quit; an attached daemon (`spawned == None`) + // is left alone (§ risk: only the spawner kills). The connection uses a + // fixed id so it's recognizable and dedup-safe, and is never written to + // settings — `add_connection` does not persist, and the only insertion + // site (`OverlayManagerEvent::RemoteConnected`) is never fired for it. + let spawned_daemon = { + let ensured = local_daemon; + let cfg = okena_transport::client::RemoteConnectionConfig { + id: okena_transport::client::LOCAL_DAEMON_CONNECTION_ID.to_string(), + name: "Local".to_string(), + host: ensured.daemon.host().to_string(), + port: ensured.daemon.port, + saved_token: ensured.token.clone(), + token_obtained_at: None, + tls: false, + pinned_cert_sha256: None, + local_endpoint: ensured.daemon.local_endpoint.clone(), + }; + if let Err(e) = remote_manager.update(cx, |rm, cx| rm.add_connection(cfg, cx)) { + eprintln!("Failed to register local-daemon loopback connection: {e}"); + std::process::exit(1); + } + ensured.spawned + }; + + // Auto-connect to saved connections with valid tokens after the + // reserved local-daemon connection is present. Saved user-managed + // remotes that point at the same endpoint are skipped by the manager; + // the implicit local connection is the authoritative one. remote_manager.update(cx, |rm, cx| { rm.auto_connect_all(cx); rm.start_token_refresh_task(cx); @@ -347,50 +252,6 @@ impl Okena { }) .detach(); - // ── Git status watcher ───────────────────────────────────────── - let (git_status_tx, _) = tokio_watch::channel(HashMap::new()); - let git_status_tx = Arc::new(git_status_tx); - let remote_subscribed_terminals: Arc>>> = - Arc::new(std::sync::RwLock::new(HashMap::new())); - let next_remote_connection_id = Arc::new(AtomicU64::new(0)); - let git_watcher = cx.new({ - let workspace = workspace.clone(); - let git_status_tx = git_status_tx.clone(); - let remote_subscribed_terminals = remote_subscribed_terminals.clone(); - |cx| GitStatusWatcher::new(workspace, git_status_tx, remote_subscribed_terminals, cx) - }); - - // ── Worktree sync watcher ───────────────────────────────────── - let worktree_sync = cx.new({ - let workspace = workspace.clone(); - |cx| WorktreeSyncWatcher::new(workspace, cx) - }); - - // Pass git_watcher to main window so ProjectColumns can observe it - main_window.update(cx, |rv, cx| { - rv.set_git_watcher(git_watcher.clone(), cx); - }); - - // ── Remote control setup ──────────────────────────────────────── - let auth_store = Arc::new(AuthStore::new()); - let pty_broadcaster = Arc::new(PtyBroadcaster::new()); - // Publish PTY output directly from reader threads (bypasses GPUI event loop latency) - pty_manager.set_output_sink(pty_broadcaster.clone()); - let (state_version_tx, _) = tokio_watch::channel(0u64); - let state_version = Arc::new(state_version_tx); - let remote_info = RemoteInfo::new(); - cx.set_global(GlobalRemoteInfo(remote_info.clone())); - - // Bump state_version on workspace changes - let sv = state_version.clone(); - cx.observe(&workspace, move |_this, _workspace, _cx| { - sv.send_modify(|v| *v += 1); - }) - .detach(); - - // Create bridge channel and start command loop - let (bridge_tx, bridge_rx) = bridge::bridge_channel(); - // Channel for clicked desktop notifications → "jump to that pane". let (notification_jump_tx, notification_jump_rx) = async_channel::unbounded(); @@ -402,47 +263,51 @@ impl Okena { extra_windows: HashMap::new(), extra_window_handles: HashMap::new(), workspace: workspace.clone(), - pty_manager, terminals, opened_detached_windows: HashSet::new(), - save_pending, - git_watcher, - worktree_sync, - git_status_tx: git_status_tx.clone(), - remote_subscribed_terminals: remote_subscribed_terminals.clone(), - next_remote_connection_id: next_remote_connection_id.clone(), - remote_server: None, - auth_store: auth_store.clone(), - pty_broadcaster: pty_broadcaster.clone(), - state_version: state_version.clone(), - remote_info: remote_info.clone(), - listen_addr, - remote_tls_enabled, - force_remote, - service_manager: service_manager.clone(), remote_manager: remote_manager.clone(), notification_jump_tx, + spawned_daemon, + recovering: Arc::new(AtomicBool::new(false)), + quitting: Arc::new(AtomicBool::new(false)), }; - // Propagate claude config dir to spawned PTYs so `claude` CLI invocations inside - // Okena terminals pick the same install as the status-bar widget. - sync_claude_pty_env(&manager.pty_manager, cx); - let settings_entity = cx.global::().0.clone(); - cx.observe(&settings_entity, move |this, _settings, cx| { - sync_claude_pty_env(&this.pty_manager, cx); - }) - .detach(); - - // Start PTY event loop (centralized for all windows) - manager.start_pty_event_loop(pty_events, cx); - // Route clicked desktop notifications back to their originating pane. manager.start_notification_click_loop(notification_jump_rx, cx); - // Start remote command bridge loop - let local_backend: Arc = - Arc::new(crate::terminal::backend::LocalBackend::new(manager.pty_manager.clone())); - manager.start_remote_command_loop(bridge_rx, local_backend, cx); + // Fire OS notifications for remote (daemon-served) terminals. Their PTY + // output never reaches the local PTY event loop above — it arrives over + // the WS and is only parsed by the remote activity pump, which drains + // each terminal's pending bytes (populating the OSC 9/777/99 + bell + // queues) but doesn't fire OS bubbles. The pump emits the advanced + // terminal ids here so we reuse the exact same focus-suppressed, + // settings-gated notification path the local loop uses. Without this, + // notifications from real (remote) terminals would be parsed and then + // silently dropped in the daemon-client model. + cx.subscribe( + &remote_manager, + |this, _rm, event, cx| match event { + RemoteManagerEvent::TerminalActivity(terminal_ids) => { + if !terminal_ids.is_empty() { + this.process_terminal_notifications(terminal_ids, cx); + // Answer (or, when disabled, drop) OSC 52 clipboard *read* + // requests for remote terminals. The clipboard physically + // lives on this client machine, so the reply must be + // produced here and written back over the terminal's + // RemoteTransport to the daemon PTY. Without this the dead + // local PTY loop's clipboard-read handling no longer runs, + // leaving remote OSC 52 reads unanswered. + this.process_clipboard_reads(terminal_ids, cx); + } + } + // Local daemon connection dead-ended — re-run discovery/ensure so + // the GUI recovers instead of staying wedged on a dead socket. + RemoteManagerEvent::LocalConnectionFailed => { + this.recover_local_daemon(cx); + } + }, + ) + .detach(); // Kill orphaned terminals when projects are deleted cx.observe(&workspace, move |this, workspace, cx| { @@ -450,7 +315,6 @@ impl Okena { if !kills.is_empty() { let mut reg = this.terminals.lock(); for tid in &kills { - this.pty_manager.kill(tid); reg.remove(tid); } } @@ -468,7 +332,6 @@ impl Okena { if !ids.is_empty() { let mut reg = this.terminals.lock(); for tid in &ids { - this.pty_manager.kill(tid); reg.remove(tid); } } @@ -476,6 +339,83 @@ impl Okena { }) .detach(); + // UI-owned daemon lifecycle: when the app quits, ask the daemon WE + // spawned in `--daemon-client` mode to stop — gracefully, and only if no + // other client still depends on it. A daemon we merely attached to + // (`spawned_daemon == None`) is left running for other UIs. + // + // This replaces the old SIGKILL-at-quit, which raced a quickly-restarted + // GUI: the new GUI attaches to our daemon just as our quit kills it, + // leaving it dialing a dead socket. Now we disconnect our own loopback + // connection first, then POST `/v1/shutdown` to the CURRENT daemon + // discovered from `remote.json` (its pid reflects any UI-triggered + // restart successor): + // • accepted → the daemon tears itself down cleanly; we briefly wait + // for its pid to exit and SIGKILL only as a last resort. + // • refused → another client is still attached; leave it running + // (the fix — never kill a daemon others are using). + // • unreachable → fall back to the old best-effort kill. + // + // Runs synchronously in this callback body (not the returned future): + // GPUI polls on_app_quit futures for only ~200ms (`SHUTDOWN_TIMEOUT`), + // but the body runs before that budget applies, so the bounded (a few + // seconds worst case) shutdown handshake completes reliably. + cx.on_app_quit(move |this: &mut Self, cx| { + // Stop any recovery from resurrecting the connection or spawning a + // daemon while we tear down (esp. the remove_connection just below). + this.quitting.store(true, Ordering::SeqCst); + if let Some(mut child) = this.spawned_daemon.take() { + // Disconnect our own loopback connection first so the daemon does + // not count us among the clients that would block its shutdown. + this.remote_manager.update(cx, |rm, cx| { + rm.remove_connection( + okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, + cx, + ); + }); + + match okena_remote_server::local::running_daemon() { + Some(daemon) => { + match okena_remote_server::local::request_local_shutdown(&daemon) { + Ok(outcome) if outcome.accepted => { + // Daemon is exiting cleanly; SIGKILL only if it wedges. + if !okena_remote_server::local::wait_for_pid_exit( + daemon.pid, + Duration::from_secs(3), + ) { + kill_process_by_pid(daemon.pid); + } + let _ = child.wait(); + } + Ok(outcome) => { + log::info!( + "Local daemon shutdown refused ({} other client(s)); leaving it running", + outcome.active_clients + ); + // Do NOT kill: another client is using it. Dropping + // the Child handle does not terminate it on Unix. + } + Err(e) => { + log::info!( + "Local daemon shutdown request failed ({e}); falling back to kill" + ); + let _ = child.kill(); + let _ = child.wait(); + kill_process_by_pid(daemon.pid); + } + } + } + None => { + // No live daemon advertised — it already exited. Reap our Child. + let _ = child.kill(); + let _ = child.wait(); + } + } + } + async {} + }) + .detach(); + // Set up observer for detached terminals cx.observe(&workspace, move |this, workspace, cx| { this.handle_detached_terminals_changed(workspace, cx); @@ -491,6 +431,51 @@ impl Okena { }) .detach(); + // Client-owned window-layout autosave. The GUI (not the daemon) owns its + // window PRESENTATION — which windows are open, their OS bounds, and + // per-window viewport. The `observe_window_bounds → set_os_bounds` wiring + // in `WindowView::new` and the spawn/close mutations all bump + // `data_version`; this debounced observer persists the window layout to + // window-layout.json (NEVER workspace.json — the daemon is its single + // writer). Mirrors the daemon's workspace autosave. Without it, the + // captured bounds + extra-window set are lost on exit and only one window + // reopens next launch. + { + let save_pending = Arc::new(AtomicBool::new(false)); + let last_saved_version = Arc::new(AtomicU64::new(0)); + let workspace_for_save = workspace.clone(); + cx.observe(&workspace, move |_this, ws_entity, cx| { + let current_version = ws_entity.read(cx).data_version(); + if current_version == last_saved_version.load(Ordering::Relaxed) { + return; + } + save_pending.store(true, Ordering::Relaxed); + + let save_pending = save_pending.clone(); + let last_saved = last_saved_version.clone(); + let workspace = workspace_for_save.clone(); + cx.spawn(async move |_, cx| { + smol::Timer::after(Duration::from_millis(500)).await; + if save_pending.swap(false, Ordering::Relaxed) { + let (data, version) = cx.update(|cx| { + let ws = workspace.read(cx); + (ws.data().clone(), ws.data_version()) + }); + let save_result = smol::unblock(move || { + crate::workspace::persistence::save_window_layout(&data) + }) + .await; + match save_result { + Ok(()) => last_saved.store(version, Ordering::Relaxed), + Err(e) => log::error!("Failed to save window layout: {}", e), + } + } + }) + .detach(); + }) + .detach(); + } + // Scrub stale focus across every window's FocusManager on each // workspace change. Deleting a project from one window can leave // another window's focus pointing at a now-gone project; without @@ -535,626 +520,324 @@ impl Okena { }) .detach(); - // Observe workspace to load/unload service configs when projects change - observe_project_services(&workspace, &service_manager, cx); - - // Observe service manager to sync terminal IDs back to workspace for persistence - { - let workspace_for_svc = workspace.clone(); - cx.observe(&service_manager, move |_this, service_manager, cx| { - let sm = service_manager.read(cx); - // Collect project IDs that have services - let project_ids: Vec = sm.instances().keys() - .map(|(pid, _)| pid.clone()) - .collect::>() - .into_iter() - .collect(); - - let terminal_maps: Vec<(String, HashMap)> = project_ids - .into_iter() - .map(|pid| { - let ids = sm.service_terminal_ids(&pid); - (pid, ids) - }) - .collect(); - - workspace_for_svc.update(cx, |ws, cx| { - for (project_id, terminals) in terminal_maps { - ws.sync_service_terminals(&project_id, terminals, cx); - } - }); - }) - .detach(); - } - - // Auto-start remote server if enabled in settings or forced via --remote - let settings = cx.global::().0.clone(); - if settings.read(cx).get().remote_server_enabled || force_remote { - manager.start_remote_server(bridge_tx.clone()); - } - - // Observe settings changes to start/stop server dynamically - let bridge_tx_for_observer = bridge_tx.clone(); - cx.observe(&settings, move |this, settings, cx| { - let s = settings.read(cx).get(); - let enabled = s.remote_server_enabled; - let running = this.remote_server.is_some(); - - let tls_enabled = s.remote_tls_enabled; - - if enabled && !running { - // Update listen_addr from settings if not forced via CLI - if !this.force_remote - && let Ok(addr) = s.remote_listen_address.parse::() { - this.listen_addr = addr; - } - this.remote_tls_enabled = tls_enabled; - this.start_remote_server(bridge_tx_for_observer.clone()); - } else if !enabled && running { - this.stop_remote_server(); - } else if enabled && running && !this.force_remote { - // Restart if the listen address OR the TLS toggle changed. - let new_addr = s.remote_listen_address.parse::().ok(); - let addr_changed = new_addr.is_some_and(|a| a != this.listen_addr); - let tls_changed = tls_enabled != this.remote_tls_enabled; - if addr_changed || tls_changed { - if let Some(a) = new_addr { - this.listen_addr = a; - } - this.remote_tls_enabled = tls_enabled; - this.stop_remote_server(); - this.start_remote_server(bridge_tx_for_observer.clone()); - } - } - }) - .detach(); - // Note: updater is now handled by the okena-ext-updater extension. // GlobalUpdateInfo is set in main.rs via okena_ext_updater::init(). manager } +} - /// Start the remote HTTP/WS server. - fn start_remote_server(&mut self, bridge_tx: bridge::BridgeSender) { - match RemoteServer::start( - bridge_tx, - self.auth_store.clone(), - self.pty_broadcaster.clone(), - self.state_version.clone(), - self.listen_addr, - self.git_status_tx.clone(), - self.remote_subscribed_terminals.clone(), - self.next_remote_connection_id.clone(), - self.remote_tls_enabled, - ) { - Ok(server) => { - let port = server.port(); - let fingerprint = server.cert_fingerprint(); - self.remote_info - .set_active(port, self.auth_store.clone(), fingerprint); - log::info!("Remote server started on port {}", port); - - let code = self.auth_store.get_or_create_code(); - println!("Remote server listening on port {port}"); - println!("Pairing code: {code} (expires in 60s)"); - println!("Run `okena pair` anytime for a fresh code."); - - self.remote_server = Some(server); - } - Err(e) => { - log::error!("Failed to start remote server: {}", e); - } - } +impl Render for Okena { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.main_window.clone()) } +} - /// Stop the remote server. - fn stop_remote_server(&mut self) { - if let Some(mut server) = self.remote_server.take() { - server.stop(); - } - self.remote_info.set_inactive(); - } +/// After this many consecutive failed recovery attempts, surface one error +/// toast. We do NOT stop retrying afterwards: the local daemon backs the whole +/// GUI, so giving up would leave the app dead until a manual restart — exactly +/// the bug this heals. Instead we keep retrying at the 30s cap (see +/// [`recovery_backoff_delay`]) and toast only once, so we never spam. +const RECOVERY_TOAST_AFTER_ATTEMPTS: u32 = 5; + +/// Attach patience for the recovery path's `ensure` calls. Shorter than the 30s +/// startup default so a live-but-unreachable daemon (which makes `ensure` error +/// only after the attach timeout) is escalated on sooner. +const RECOVERY_ATTACH_TIMEOUT: Duration = Duration::from_secs(8); + +/// Spawn budget for the recovery path — the full startup patience, NOT the short +/// attach patience: daemon boot loads the workspace before the server binds and +/// can take many seconds under load, and `ensure` SIGKILLs its own child on this +/// deadline — a short one would kill every mid-boot respawn forever. +const RECOVERY_SPAWN_TIMEOUT: Duration = Duration::from_secs(30); + +/// After this many consecutive failed recovery attempts, escalate: if a live but +/// unreachable local daemon is what's blocking us, kill it so the next attempt +/// takes the spawn path instead of forever re-hitting the attach timeout. +const RECOVERY_ESCALATE_AFTER_ATTEMPTS: u32 = 2; + +/// Confirm-probe timeout before an escalation kill. Deliberately much longer +/// than the 300ms probe `ensure` uses internally: under a system-wide stall a +/// slow-but-healthy daemon must not be misread as dead and killed, while a truly +/// dead socket still fails the connect instantly, so the extra patience is free. +const RECOVERY_HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +/// Whether recovery should escalate to killing the local daemon. Escalate only +/// once we've failed enough consecutive times AND a live-but-unreachable daemon +/// is the thing blocking us (so we never kill a daemon that's merely absent, nor +/// one that's actually healthy). Pure so the decision is unit-testable. +fn should_escalate_recovery(failed_attempts: u32, live_unreachable_daemon: bool) -> bool { + failed_attempts >= RECOVERY_ESCALATE_AFTER_ATTEMPTS && live_unreachable_daemon +} - /// Centralized PTY event loop - notifies all windows (main and detached) - fn start_pty_event_loop( - &mut self, - pty_events: Receiver, - cx: &mut Context, - ) { - let terminals = self.terminals.clone(); - let pty_manager = self.pty_manager.clone(); +/// Backoff before the next local-daemon recovery attempt, given how many have +/// already failed. Ramps 1 → 2 → 5 → 10s then caps at 30s: quick enough to heal +/// a brief daemon gap (the common fast-restart race) yet without a spawn storm +/// when the daemon stays down. Pure so the schedule is unit-testable. +fn recovery_backoff_delay(failed_attempts: u32) -> Duration { + let secs = match failed_attempts { + 0 | 1 => 1, + 2 => 2, + 3 => 5, + 4 => 10, + _ => 30, + }; + Duration::from_secs(secs) +} - // Per-turn work budget. A single high-bandwidth terminal (cat hugefile, - // `yes`, a runaway build log) can keep this loop draining the channel - // forever, starving input/render/resize for ALL terminals (they all - // funnel through this one loop on the GPUI thread). Once we've parsed - // this many bytes in one drain pass we stop, yield to the executor so - // input/render get scheduled, then loop back — the remaining events - // stay in the bounded channel and are picked up next turn (nothing is - // dropped). 256 KiB is a few render frames' worth of throughput while - // staying small enough to keep the UI responsive under sustained load. - const MAX_BYTES_PER_TURN: usize = 256 * 1024; +impl Okena { + /// Self-heal the implicit local-daemon loopback connection after it hit a + /// terminal failure (both dead-end client paths surface `Error`). Re-runs + /// `ensure_local_daemon` — attach to a live daemon or spawn a fresh one — + /// then re-points the loopback connection at the new endpoint/token. + /// Single-flight and quit-aware; mirrors `perform_restart_daemon`'s pattern + /// of running the blocking remote-server call on the background pool. + fn recover_local_daemon(&mut self, cx: &mut Context) { + if self.quitting.load(Ordering::SeqCst) { + return; + } + // Single-flight: a recovery already running will re-point the connection + // when it succeeds, so further failure events until then are redundant. + if self.recovering.swap(true, Ordering::SeqCst) { + return; + } - cx.spawn(async move |this: WeakEntity, cx| { + let recovering = self.recovering.clone(); + let quitting = self.quitting.clone(); + cx.spawn(async move |this: WeakEntity, cx| { + let mut failed_attempts: u32 = 0; loop { - let event = match pty_events.recv().await { - Ok(event) => event, - Err(_) => break, - }; - - let _slow = okena_core::timing::SlowGuard::new("Okena::pty_event_batch"); - - // Collect exit events and track which terminals received data - let mut exit_events: Vec<(String, Option)> = Vec::new(); - let mut dirty_terminal_ids: Vec = Vec::new(); - - // Bytes parsed so far in this drain pass (across batched events). - let mut bytes_this_turn: usize = 0; - - // Process first event (broadcasting handled by PtyOutputSink in reader threads) - match &event { - PtyEvent::Data { terminal_id, data } => { - // Hold the registry lock only for the HashMap lookup — - // clone the Arc out and drop the guard before - // the (potentially long) ANSI parse, so send_input / - // resize / kill on OTHER terminals don't block behind it. - let term = terminals.lock().get(terminal_id).cloned(); - if let Some(term) = term { - bytes_this_turn += data.len(); - term.process_output(data); - } - dirty_terminal_ids.push(terminal_id.clone()); - } - PtyEvent::Exit { terminal_id, exit_code } => { - // Clean up the PtyHandle (reader/writer threads) but don't - // remove the UI Terminal yet — service manager may keep it - // so users can see crash output. - pty_manager.cleanup_exited(terminal_id); - exit_events.push((terminal_id.clone(), *exit_code)); - } + if quitting.load(Ordering::SeqCst) { + break; } - // Drain any additional pending events (batch processing), but - // stop once we exceed the per-turn byte budget so we yield back - // to the executor instead of monopolizing the GPUI thread. - while bytes_this_turn < MAX_BYTES_PER_TURN { - let event = match pty_events.try_recv() { - Ok(event) => event, - Err(_) => break, - }; - match &event { - PtyEvent::Data { terminal_id, data } => { - // Clone the Arc out and drop the registry guard - // before parsing (see note above). - let term = terminals.lock().get(terminal_id).cloned(); - if let Some(term) = term { - bytes_this_turn += data.len(); - term.process_output(data); + // `ensure_local_daemon_with_timeouts` blocks (short attach + // patience, full spawn budget); run it on the blocking pool + // like `perform_restart_daemon` does with restart. + let outcome = cx + .background_executor() + .spawn(async move { + okena_remote_server::local::ensure_local_daemon_with_timeouts( + RECOVERY_ATTACH_TIMEOUT, + RECOVERY_SPAWN_TIMEOUT, + ) + }) + .await; + + match outcome { + Ok(ensured) => { + // Hold `ensured` outside the closure: if the entity is + // gone the closure never runs, and dropping a Child does + // not kill it — we'd orphan a daemon we just spawned. + let mut ensured = Some(ensured); + let applied = this.update(cx, |this, cx| { + if let Some(ensured) = ensured.take() { + this.apply_recovered_daemon(ensured, cx); } - dirty_terminal_ids.push(terminal_id.clone()); - } - PtyEvent::Exit { terminal_id, exit_code } => { - pty_manager.cleanup_exited(terminal_id); - exit_events.push((terminal_id.clone(), *exit_code)); + }); + // Entity dropped mid-recovery: best-effort reap the child. + if applied.is_err() + && let Some(mut child) = ensured.and_then(|e| e.spawned) + { + let _ = child.kill(); + let _ = child.wait(); } + break; } - } - - // Notify main window after processing the batch - let _ = this.update(cx, |this, cx| { - if !exit_events.is_empty() { - // Two-phase hook exit handling: - // Phase 1 (here): notify_exit unblocks any sync hook threads - // waiting on a PTY terminal via mpsc::Receiver. This MUST happen - // before handle_hook_terminal_exits (phase 2) which updates - // workspace status and may trigger project removal. - if let Some(monitor) = crate::workspace::hooks::try_monitor(cx) { - for (terminal_id, exit_code) in &exit_events { - monitor.notify_exit(terminal_id, *exit_code); - } - } - - // Let service manager handle service terminals (may keep - // their UI Terminal for viewing crash output) - let service_tids: std::collections::HashSet = - this.service_manager.update(cx, |sm, cx| { - let mut handled = std::collections::HashSet::new(); - for (terminal_id, exit_code) in &exit_events { - if sm.handle_service_exit(terminal_id, *exit_code, cx) { - handled.insert(terminal_id.clone()); - } - } - handled - }); - - // Handle hook terminal exits (status updates, pending close, cleanup) - let hook_tids = this.handle_hook_terminal_exits(&exit_events, &service_tids, cx); - - // Fire terminal.on_close hook for user terminals (not service, not hook) - let terminal_close_infos: Vec<_> = { - let global_on_close = crate::settings::settings(cx).hooks.terminal.on_close.is_some(); - let ws = this.workspace.read(cx); - exit_events.iter() - .filter(|(tid, _)| !service_tids.contains(tid) && !hook_tids.contains(tid)) - .filter_map(|(tid, exit_code)| { - ws.find_project_for_terminal(tid).and_then(|p| { - let parent_on_close = p.worktree_info.as_ref() - .and_then(|wt| ws.project(&wt.parent_project_id)) - .and_then(|pp| pp.hooks.terminal.on_close.as_ref()) - .is_some(); - if global_on_close || p.hooks.terminal.on_close.is_some() || parent_on_close { - let parent_hooks = p.worktree_info.as_ref() - .and_then(|wt| ws.project(&wt.parent_project_id)) - .map(|pp| pp.hooks.clone()); - let terminal_name = p.terminal_names.get(tid).cloned(); - let is_worktree = p.worktree_info.is_some(); - let folder = ws.folder_for_project_or_parent(&p.id); - let fid = folder.map(|f| f.id.clone()); - let fname = folder.map(|f| f.name.clone()); - Some((p.hooks.clone(), parent_hooks, p.id.clone(), p.name.clone(), p.path.clone(), tid.clone(), terminal_name, is_worktree, *exit_code, fid, fname)) - } else { - None - } + Err(msg) => { + failed_attempts += 1; + log::warn!("Local daemon recovery attempt {failed_attempts} failed: {msg}"); + + // Escalation: a live-but-unreachable daemon makes every + // `ensure` error at the attach timeout, forever. Once we've + // failed enough times, kill that wedged daemon so the next + // `ensure` takes the spawn path. Never while quitting; this + // loop only ever handles the local daemon. + if failed_attempts >= RECOVERY_ESCALATE_AFTER_ATTEMPTS + && !quitting.load(Ordering::SeqCst) + { + let stuck_daemon = cx + .background_executor() + .spawn(async { + okena_remote_server::local::running_daemon().filter(|d| { + !okena_remote_server::local::daemon_endpoint_responds( + d, + RECOVERY_HEALTH_PROBE_TIMEOUT, + ) }) }) - .collect() - }; - for (project_hooks, parent_hooks, project_id, project_name, project_path, terminal_id, terminal_name, is_worktree, exit_code, folder_id, folder_name) in terminal_close_infos { - crate::workspace::hooks::fire_terminal_on_close( - &project_hooks, parent_hooks.as_ref(), &project_id, &project_name, - &project_path, &terminal_id, terminal_name.as_deref(), is_worktree, exit_code, - folder_id.as_deref(), folder_name.as_deref(), &crate::settings::settings(cx).hooks, cx, - ); - } - - // Kill session backends and remove UI Terminals for non-service, non-hook terminals. - // This is critical for dtach: the PTY exit only means the client disconnected, - // but the dtach daemon keeps running. kill() ensures kill_session() is called - // to SIGTERM the daemon and remove the socket file. - { - let mut reg = this.terminals.lock(); - for (terminal_id, _) in &exit_events { - if !service_tids.contains(terminal_id) && !hook_tids.contains(terminal_id) { - this.pty_manager.kill(terminal_id); - reg.remove(terminal_id); - } - } - } - - // If any exited terminal was mid soft-close, its undo toast - // is now useless (the PTY is gone) and the pending record - // would otherwise linger until the grace timer fired a - // redundant kill — drop both now. - let stale_toasts: Vec = this.workspace.update(cx, |ws, _| { - exit_events - .iter() - .filter_map(|(tid, _)| ws.cancel_pending_close(tid)) - .collect() - }); - for toast_id in &stale_toasts { - crate::workspace::toast::ToastManager::dismiss(toast_id, cx); - } - - // If an exited terminal had just been *restored* by a - // soft-close undo that raced this exit, its PTY is dead - // now — the registry-based `alive` check let undo bring - // back a doomed pane. Tear it back out so it doesn't - // linger (and respawn a fresh shell on next render). - this.workspace.update(cx, |ws, cx| { - for (tid, _) in &exit_events { - ws.reap_restored_close(tid, cx); - } - }); - } - // Notify dirty terminal content panes directly (batched in one update). - // All notifications happen in the same GPUI update → single layout pass. - // Each terminal_id may be rendered by multiple panes (one per window - // whose visible set includes its host project), so iterate the vec - // and prune dead weaks lazily. - if !dirty_terminal_ids.is_empty() { - dirty_terminal_ids.dedup(); - let mut registry = crate::views::window::content_pane_registry().lock(); - let mut any_local_pane = false; - for tid in &dirty_terminal_ids { - let now_empty = if let Some(weaks) = registry.get_mut(tid) { - if crate::views::window::notify_pane_weaks(weaks, cx) { - any_local_pane = true; - } - weaks.is_empty() - } else { - false - }; - if now_empty { - registry.remove(tid); + .await; + if should_escalate_recovery(failed_attempts, stuck_daemon.is_some()) + && let Some(daemon) = stuck_daemon + { + log::warn!( + "Local daemon pid {} is live but unreachable after {failed_attempts} failed recovery attempts; killing it so the next attempt respawns", + daemon.pid + ); + kill_process_by_pid(daemon.pid); + // If the killed daemon was our own spawned child, + // wait() it now — otherwise the zombie would linger + // for the app lifetime if the next ensure ATTACHES + // (which never adopts a new Child handle). + let _ = this.update(cx, |this, _cx| { + if let Some(child) = this.spawned_daemon.as_mut() + && child.id() == daemon.pid + { + let _ = child.wait(); + this.spawned_daemon = None; + } + }); } } - drop(registry); - // Remote-only terminals have no local content pane. Without - // cx.notify(), GPUI's draw cycle won't run and the event loop - // effectively stalls. Notify main_window to keep GPUI responsive - // for bridge commands, state queries, and other remote work. - if !any_local_pane { - this.main_window.update(cx, |_, cx| cx.notify()); - } - } - // Check if any hook terminal reported its exit code via - // OSC title (__okena_hook_exit:). This happens when - // keep_alive hooks finish their command but the PTY stays - // alive as an interactive shell. - if !dirty_terminal_ids.is_empty() { - let terminals_guard = this.terminals.lock(); - let ws = this.workspace.read(cx); - let mut status_updates: Vec<(String, crate::workspace::state::HookTerminalStatus)> = Vec::new(); - for tid in &dirty_terminal_ids { - if ws.is_hook_terminal(tid).is_none() { - continue; - } - if let Some(terminal) = terminals_guard.get(tid) - && let Some(title) = terminal.title() - && let Some(code_str) = title.strip_prefix("__okena_hook_exit:") { - let exit_code = code_str.parse::().unwrap_or(-1); - let status = if exit_code == 0 { - crate::workspace::state::HookTerminalStatus::Succeeded - } else { - crate::workspace::state::HookTerminalStatus::Failed { exit_code } - }; - status_updates.push((tid.clone(), status)); - } - } - drop(terminals_guard); - if !status_updates.is_empty() { - this.workspace.update(cx, |ws, cx| { - for (tid, status) in status_updates { - ws.update_hook_terminal_status(&tid, status, cx); + // Update every attempt so a dropped entity ends the loop + // (and the app isn't left spawning daemons post-quit). + let should_toast = failed_attempts == RECOVERY_TOAST_AFTER_ATTEMPTS; + let alive = this + .update(cx, |_this, cx| { + if should_toast { + crate::workspace::toast::ToastManager::error( + "Local daemon unreachable; still retrying in the background…" + .to_string(), + cx, + ); } - }); + }) + .is_ok(); + if !alive { + break; } + cx.background_executor() + .timer(recovery_backoff_delay(failed_attempts)) + .await; } - - // Drain OSC 9 / OSC 777 notifications for terminals that - // produced output this batch and raise OS notifications - // for background panes. Runs here (not in a pane's render) - // so background tabs and detached windows are covered too. - if !dirty_terminal_ids.is_empty() { - this.process_terminal_notifications(&dirty_terminal_ids, cx); - // Stamp project activity for any command that finished - // this batch (OSC 133 ;D), independent of bell/OSC alerts. - this.process_command_finished_activity(&dirty_terminal_ids, cx); - // Answer (or, when disabled, drop) OSC 52 clipboard - // read requests queued by these terminals. - this.process_clipboard_reads(&dirty_terminal_ids, cx); - } - - if !exit_events.is_empty() { - // A terminal exited — every window rendering its - // project column needs to re-render so the layout - // reflects the removal. Fan out to all live windows. - this.main_window.update(cx, |_, cx| cx.notify()); - for view in this.extra_windows.values() { - view.update(cx, |_, cx| cx.notify()); - } - } - }); - - // Cooperatively yield to the executor between drain passes so - // input, rendering, resize, and other terminals' parsing get - // scheduled even under a sustained high-bandwidth stream. The - // next recv().await picks up any events left in the channel, so - // the loop always makes progress and nothing is dropped. - smol::future::yield_now().await; + } } + recovering.store(false, Ordering::SeqCst); }) .detach(); } - // ── Hook terminal exit handling ────────────────────────────────────── - - /// Process hook terminal exit events: update status, resolve pending worktree closes, - /// and schedule cleanup. Returns the set of terminal IDs that were hook terminals. - fn handle_hook_terminal_exits( + /// Apply a freshly-ensured daemon on the GPUI thread: re-point the loopback + /// connection at its endpoint/token and adopt any child we spawned. Bails + /// (reaping a just-spawned daemon) if a quit began while we were ensuring. + fn apply_recovered_daemon( &mut self, - exit_events: &[(String, Option)], - service_tids: &std::collections::HashSet, - cx: &mut Context, - ) -> std::collections::HashSet { - let hook_tids: std::collections::HashSet = { - let ws = self.workspace.read(cx); - exit_events.iter() - .filter(|(tid, _)| !service_tids.contains(tid)) - .filter(|(tid, _)| ws.is_hook_terminal(tid).is_some()) - .map(|(tid, _)| tid.clone()) - .collect() - }; - - for (terminal_id, exit_code) in exit_events { - if !hook_tids.contains(terminal_id) { - continue; - } - - let success = *exit_code == Some(0); - let tid = terminal_id.clone(); - - // Update HookMonitor so the hook log shows correct status - if let Some(monitor) = crate::workspace::hooks::try_monitor(cx) { - monitor.finish_by_terminal_id(&tid, *exit_code); - } - - // Single workspace.update: set hook status, then handle pending close atomically. - // Pull the focus_manager from main_window so the delete_project call - // scrubs focus state on the main window's per-window manager. - let focus_manager = self.main_window.read(cx).focus_manager(); - let pending_data = focus_manager.update(cx, |fm, cx| { - let pending_data = self.workspace.update(cx, |ws, cx| { - // Update hook terminal status - let status = if success { - crate::workspace::state::HookTerminalStatus::Succeeded - } else { - let code = exit_code.map(|c| c as i32).unwrap_or(-1); - crate::workspace::state::HookTerminalStatus::Failed { exit_code: code } - }; - ws.update_hook_terminal_status(&tid, status, cx); - - // Check for pending worktree close tied to this hook terminal - let pending = ws.take_pending_worktree_close(&tid)?; - let folder = ws.folder_for_project_or_parent(&pending.project_id); - let hook_folder_id = folder.map(|f| f.id.clone()); - let hook_folder_name = folder.map(|f| f.name.clone()); - let (project_path_for_git, hook_info) = ws.project(&pending.project_id) - .map(|p| (Some(p.path.clone()), Some((p.hooks.clone(), p.name.clone(), p.path.clone())))) - .unwrap_or((None, None)); - if success { - ws.remove_hook_terminal(&tid, cx); - // Collect remaining hook terminal IDs before deleting the project - let remaining_hook_tids = ws.hook_terminal_ids_for_project(&pending.project_id); - ws.delete_project(fm, &pending.project_id, &settings(cx).hooks, cx); - Some((pending, project_path_for_git, hook_info, remaining_hook_tids, hook_folder_id, hook_folder_name)) - } else { - ws.finish_closing_project(&pending.project_id); - None - } - }); - cx.notify(); - pending_data - }); - - if let Some((pending, project_path_for_git, hook_info, remaining_hook_tids, folder_id, folder_name)) = pending_data { - self.handle_pending_close_result(&tid, pending, project_path_for_git, hook_info, remaining_hook_tids, folder_id, folder_name, cx); - } - // Hook terminal persists — no auto-cleanup. User can dismiss manually or rerun. - } - - hook_tids - } - - /// Handle the result of a pending worktree close after hook exit (success path only). - // Threads the cohesive set of close-result params; no reusable grouping. - #[allow(clippy::too_many_arguments)] - fn handle_pending_close_result( - &mut self, - tid: &str, - pending: crate::workspace::state::PendingWorktreeClose, - project_path_for_git: Option, - hook_info: Option<(crate::workspace::persistence::HooksConfig, String, String)>, - remaining_hook_tids: Vec, - folder_id: Option, - folder_name: Option, + ensured: okena_remote_server::local::EnsuredDaemon, cx: &mut Context, ) { - log::info!("Pending worktree close: hook succeeded, removing project {}", pending.project_id); - - let global_hooks = crate::settings::settings(cx).hooks; - let monitor = crate::workspace::hooks::try_monitor(cx); - let runner = crate::workspace::hooks::try_runner(cx); - // Clean up primary and any other persisted hook terminals in a single lock - { - let mut guard = self.terminals.lock(); - guard.remove(tid); - for hook_tid in &remaining_hook_tids { - guard.remove(hook_tid); + // Raced a quit: don't resurrect the connection, and don't orphan a daemon + // we just spawned (dropping the Child doesn't kill it on Unix). + if self.quitting.load(Ordering::SeqCst) { + if let Some(mut child) = ensured.spawned { + let _ = child.kill(); + let _ = child.wait(); } + return; } - // Fire lifecycle hooks - if let Some((project_hooks, project_name, project_path)) = hook_info { - crate::workspace::hooks::fire_on_worktree_close( - &project_hooks, - &pending.project_id, - &project_name, - &project_path, - &pending.branch, - folder_id.as_deref(), - folder_name.as_deref(), - &global_hooks, + let cfg = okena_transport::client::RemoteConnectionConfig { + id: okena_transport::client::LOCAL_DAEMON_CONNECTION_ID.to_string(), + name: "Local".to_string(), + host: ensured.daemon.host().to_string(), + port: ensured.daemon.port, + saved_token: ensured.token.clone(), + token_obtained_at: None, + tls: ensured.daemon.tls, + pinned_cert_sha256: None, + local_endpoint: ensured.daemon.local_endpoint.clone(), + }; + self.remote_manager.update(cx, |rm, cx| { + rm.redirect_and_reconnect( + okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, + cfg, + ensured.token.clone(), cx, ); - let _ = crate::workspace::hooks::fire_worktree_removed( - &project_hooks, - &global_hooks, - &pending.project_id, - &project_name, - &project_path, - &pending.branch, - &pending.main_repo_path, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - runner.as_ref(), - ); - } + }); - // Git worktree remove in the background - let pending_clone = pending.clone(); - let workspace = self.workspace.clone(); - if let Some(ref path) = project_path_for_git { - workspace.update(cx, |ws, _| { - ws.mark_worktree_removing(path); - }); - } - cx.spawn(async move |_this, cx| { - if let Some(path) = project_path_for_git { - let main_repo = pending_clone.main_repo_path.clone(); - let path_clone = path.clone(); - let result = smol::unblock(move || { - crate::git::remove_worktree_fast( - &std::path::PathBuf::from(&path_clone), - &std::path::PathBuf::from(&main_repo), - ) - }).await; - if let Err(e) = result { - log::error!("Background worktree remove failed: {}", e); + // If we spawned a fresh daemon, we now own it. Reap the old (dead) child + // handle first so we don't leak a zombie, then take over the new one. + match ensured.spawned { + Some(child) => { + if let Some(mut old) = self.spawned_daemon.take() { + let _ = old.kill(); + let _ = old.wait(); } - cx.update(|cx| { - workspace.update(cx, |ws, _| { - ws.finish_worktree_removing(&path); - }); - }); + self.spawned_daemon = Some(child); } - }).detach(); - } - -} + None => { + // Attach path: reap our previous child only if it ALREADY exited + // (e.g. after an escalation kill) so no zombie outlives recovery. + // try_wait never touches a live child — the daemon we attached to + // may well BE that child. + if let Some(child) = self.spawned_daemon.as_mut() + && matches!(child.try_wait(), Ok(Some(_))) + { + self.spawned_daemon = None; + } + } + } -impl Render for Okena { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().size_full().child(self.main_window.clone()) + crate::workspace::toast::ToastManager::info("Local daemon reconnected".to_string(), cx); } } #[cfg(test)] mod tests { - use super::claude_pty_extra_env; + use super::{ + is_okena_process, recovery_backoff_delay, should_escalate_recovery, + RECOVERY_ESCALATE_AFTER_ATTEMPTS, RECOVERY_TOAST_AFTER_ATTEMPTS, + }; + use std::time::Duration; #[test] - fn default_claude_dir_unsets_pty_env() { - let default_dir = dirs::home_dir().unwrap().join(".claude"); - - // The default dir must produce an explicit removal so a stale inherited - // CLAUDE_CONFIG_DIR can't leak in — regardless of profile count or whether - // the parent process happened to have the var set. - for &(multi, parent) in &[(false, false), (true, false), (false, true), (true, true)] { - let env = claude_pty_extra_env(&default_dir, multi, parent); - assert_eq!(env.len(), 1, "multi={multi} parent={parent}"); - assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR"); - assert_eq!(env[0].1, None, "default dir must unset, not set"); - } + fn okena_process_identity_guard() { + // Our binaries — killable. + assert!(is_okena_process(Some("okena"), None)); + assert!(is_okena_process(Some("okena-daemon"), None)); + assert!(is_okena_process(None, Some("okena-daemon"))); + // Exe name matches even when the reported name doesn't (truncation etc.). + assert!(is_okena_process(Some("some-thread"), Some("okena"))); + // Recycled pid pointing at an unrelated process — never kill. + assert!(!is_okena_process(Some("cargo"), Some("cargo"))); + assert!(!is_okena_process(Some("firefox"), None)); + assert!(!is_okena_process(None, None)); } #[test] - fn single_profile_keeps_parent_claude_config_dir() { - let custom_dir = std::env::temp_dir().join("okena-custom-claude-dir"); - - assert!(claude_pty_extra_env(&custom_dir, false, true).is_empty()); + fn escalates_only_after_threshold_and_when_stuck() { + // Below the threshold: never escalate, even if a daemon is stuck. + assert!(!should_escalate_recovery(0, true)); + assert!(!should_escalate_recovery(RECOVERY_ESCALATE_AFTER_ATTEMPTS - 1, true)); + // At/after the threshold WITH a live-unreachable daemon: escalate. + assert!(should_escalate_recovery(RECOVERY_ESCALATE_AFTER_ATTEMPTS, true)); + assert!(should_escalate_recovery(RECOVERY_ESCALATE_AFTER_ATTEMPTS + 5, true)); + // No live-unreachable daemon (absent or healthy): never kill. + assert!(!should_escalate_recovery(RECOVERY_ESCALATE_AFTER_ATTEMPTS + 5, false)); } #[test] - fn custom_claude_dir_is_exported_to_pty() { - let custom_dir = std::env::temp_dir().join("okena-custom-claude-dir"); - let env = claude_pty_extra_env(&custom_dir, true, true); + fn backoff_ramps_then_caps_at_30s() { + assert_eq!(recovery_backoff_delay(0), Duration::from_secs(1)); + assert_eq!(recovery_backoff_delay(1), Duration::from_secs(1)); + assert_eq!(recovery_backoff_delay(2), Duration::from_secs(2)); + assert_eq!(recovery_backoff_delay(3), Duration::from_secs(5)); + assert_eq!(recovery_backoff_delay(4), Duration::from_secs(10)); + assert_eq!(recovery_backoff_delay(5), Duration::from_secs(30)); + assert_eq!(recovery_backoff_delay(50), Duration::from_secs(30)); + } - assert_eq!(env.len(), 1); - assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR"); - assert_eq!(env[0].1.as_deref(), Some(custom_dir.to_string_lossy().as_ref())); + #[test] + fn backoff_is_monotonic_nondecreasing() { + // Never speeds up as failures accumulate — guards against a spawn storm. + let mut prev = Duration::ZERO; + for n in 0..12 { + let d = recovery_backoff_delay(n); + assert!(d >= prev, "delay must not decrease at attempt {n}"); + prev = d; + } + // The one-shot give-up toast fires during the ramp, not only at the cap. + const { assert!(RECOVERY_TOAST_AFTER_ATTEMPTS >= 4) }; } } diff --git a/crates/okena-app/src/app/notifications.rs b/crates/okena-app/src/app/notifications.rs index d7508c6e3..aca4374d7 100644 --- a/crates/okena-app/src/app/notifications.rs +++ b/crates/okena-app/src/app/notifications.rs @@ -124,11 +124,11 @@ impl Okena { return; } - // A bell or OSC alert is "activity" for the owning project regardless - // of the notification settings below — stamp it before any settings - // bail so the activity-sorted sidebar surfaces the project even when - // desktop notifications are disabled. - self.bump_activity_for_terminals(drained.iter().map(|(tid, _, _)| tid.as_str()), cx); + // Activity stamping for bell/OSC alerts now happens on the DAEMON + // (`pty_loop::process_activity_edges`), which owns the authoritative + // `last_activity_at` and persists it. Bumping the client mirror here would + // only be overwritten on the next state sync, so we don't — we keep + // draining the edges above purely to fire OS notification bubbles below. // Read the (small) notification settings; bail if the feature is off. // Draining above already cleared the queues, so nothing accumulates @@ -201,34 +201,6 @@ impl Okena { } } - /// Drain the one-shot command-finished (OSC 133 ;D) edge for each dirty - /// terminal and stamp activity on the owning project. Called from the PTY - /// event loop alongside notification draining so a finished command floats - /// its project up in the activity-sorted sidebar — even with no bell. - pub(super) fn process_command_finished_activity( - &mut self, - dirty_terminal_ids: &[String], - cx: &mut Context, - ) { - // Drain edges first (cheap atomic swap); collect the terminals that - // actually saw a command finish. Almost every batch drains nothing. - let finished: Vec = { - let reg = self.terminals.lock(); - dirty_terminal_ids - .iter() - .filter(|tid| { - reg.get(*tid) - .is_some_and(|t| t.take_pending_command_finished()) - }) - .cloned() - .collect() - }; - if finished.is_empty() { - return; - } - self.bump_activity_for_terminals(finished.iter().map(|s| s.as_str()), cx); - } - /// Answer (or silently deny) OSC 52 clipboard *read* requests /// (`OSC 52 ; c ; ?`) queued by terminals that produced output this batch. /// Runs here, in the PTY event loop, because this is where the opt-in @@ -290,30 +262,6 @@ impl Okena { } } - /// Resolve each terminal id to its owning project and bump that project's - /// activity timestamp once. Deduplicates projects so a batch touching - /// several terminals of the same project only notifies/persists once. - fn bump_activity_for_terminals<'a>( - &mut self, - terminal_ids: impl Iterator, - cx: &mut Context, - ) { - let project_ids: std::collections::HashSet = { - let ws = self.workspace.read(cx); - terminal_ids - .filter_map(|tid| ws.find_project_for_terminal(tid).map(|p| p.id.clone())) - .collect() - }; - if project_ids.is_empty() { - return; - } - self.workspace.update(cx, |ws, cx| { - for pid in project_ids { - ws.bump_activity(&pid, cx); - } - }); - } - /// True when `(project_id, path)` is the focused pane in a window that /// currently holds OS focus. Background tabs, inactive detached windows, /// and "no Okena window focused" all return false, so they notify. diff --git a/crates/okena-app/src/app/remote_commands.rs b/crates/okena-app/src/app/remote_commands.rs index 43699fb79..a8e0cc84f 100644 --- a/crates/okena-app/src/app/remote_commands.rs +++ b/crates/okena-app/src/app/remote_commands.rs @@ -3,21 +3,24 @@ #![allow(clippy::expect_used)] use crate::remote::bridge::{BridgeMessage, BridgeReceiver, CommandResult, RemoteCommand}; -use crate::remote::types::{ActionRequest, ApiFolder, ApiFullscreen, ApiProject, ApiServiceInfo, ApiWindow, StateResponse}; -use crate::services::manager::{ServiceManager, ServiceStatus}; +use crate::remote::types::{ActionRequest, ApiServiceInfo, ApiWindow}; +use crate::services::manager::ServiceManager; use crate::terminal::backend::TerminalBackend; use crate::views::window::TerminalsRegistry; use crate::workspace::actions::execute::{ensure_terminal, execute_action}; +use crate::workspace::hook_monitor::HookMonitor; use crate::workspace::state::{WindowId, Workspace}; use gpui::*; use okena_core::api::ApiGitStatus; -use std::collections::{HashMap, HashSet}; +use okena_core::git_poll::{git_poll_trigger_for_action, GitPollTrigger}; +use okena_workspace::actions::soft_close::{ + begin_soft_close_flow, close_now_flow, probe_busy, undo_soft_close_flow, SoftCloseDeadlines, +}; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::watch as tokio_watch; use uuid::Uuid; -use super::Okena; - /// Parse a wire-format window id into a [`WindowId`]. /// /// `"main"` maps to [`WindowId::Main`]; any other string is parsed as a UUID @@ -32,6 +35,19 @@ pub(crate) fn parse_window_id(s: &str) -> Option { } } +fn claim_input_resize_owner(action: &ActionRequest, owner_id: &str) { + let terminal_id = match action { + ActionRequest::SendText { terminal_id, .. } + | ActionRequest::RunCommand { terminal_id, .. } + | ActionRequest::SendSpecialKey { terminal_id, .. } => Some(terminal_id.as_str()), + _ => None, + }; + + if let Some(terminal_id) = terminal_id { + crate::terminal::terminal::claim_resize_authority_remote_owner(terminal_id, owner_id); + } +} + /// Resolver returning a window's `(WindowId, FocusManager)` for a /// remote-bridge action. /// @@ -66,17 +82,21 @@ pub(crate) type WindowsResolver = Arc Vec + Send + Sy pub(crate) type ActionDispatcher = Arc, &str) -> Result<(), String> + Send + Sync>; -/// Shared remote command loop used by both GUI (`Okena`) and headless (`HeadlessApp`). +/// Remote command loop for the single-binary `okena --headless` daemon +/// (`HeadlessApp`). /// /// Processes commands from the remote API bridge on the GPUI main thread. /// Callers are responsible for spawning this via `cx.spawn()`. /// /// `focus_manager_resolver` is consulted per-action so the focused-window /// scope (PRD user story 27 / slice 05 cri 13) is honored at the moment the -/// action lands, not at loop startup. GUI callers pass a closure that reads -/// `cx.active_window()` and looks the corresponding `WindowView` up on -/// `Okena`; headless callers pass a constant closure returning the synthetic -/// dormant `FocusManager` paired with `WindowId::Main`. +/// action lands, not at loop startup. Headless passes a constant closure +/// returning the synthetic dormant `FocusManager` paired with `WindowId::Main`. +/// +/// `git_poll_trigger_tx`, when set, receives a wake-up after each successful +/// action that should refresh git status (git status requested, project shown, +/// branch checked out) — a consumer drives the `GitStatusWatcher` so the badge +/// updates without waiting for the next poll cycle. // Bridge loop: each param is a distinct channel/entity dependency. #[allow(clippy::too_many_arguments)] pub(crate) async fn remote_command_loop( @@ -90,6 +110,9 @@ pub(crate) async fn remote_command_loop( git_status_tx: Arc>>, service_manager: Entity, action_dispatcher: ActionDispatcher, + hook_monitor: HookMonitor, + deadlines: SoftCloseDeadlines, + git_poll_trigger_tx: Option>, cx: &mut AsyncApp, ) { loop { @@ -100,77 +123,71 @@ pub(crate) async fn remote_command_loop( let _slow = okena_core::timing::SlowGuard::new("remote_command_loop::iter"); - let result = match msg.command { + let command = match msg.command { + // Identityless actions (HTTP /v1/actions: CLI, agents) do NOT + // touch resize authority — nulling the owner here handed the next + // arriving resize to a random client. Only input from an + // identified WS connection ("someone typed at that window") + // transfers ownership. + RemoteCommand::ActionFromConnection { + action, + connection_id, + } => { + claim_input_resize_owner(&action, &connection_id); + RemoteCommand::Action(action) + } + command => command, + }; + + let result = match command { RemoteCommand::Action(action) => { match action { ActionRequest::StartService { project_id, service_name } => { cx.update(|cx| { service_manager.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(&project_id).cloned() { - sm.start_service(&project_id, &service_name, &path, cx); - CommandResult::Ok(None) - } else { - CommandResult::Err(format!("project not found: {}", project_id)) - } + sm.start_service_action(&project_id, &service_name, cx) }) }) } ActionRequest::StopService { project_id, service_name } => { cx.update(|cx| { service_manager.update(cx, |sm, cx| { - sm.stop_service(&project_id, &service_name, cx); - CommandResult::Ok(None) + sm.stop_service_action(&project_id, &service_name, cx) }) }) } ActionRequest::RestartService { project_id, service_name } => { cx.update(|cx| { service_manager.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(&project_id).cloned() { - sm.restart_service(&project_id, &service_name, &path, cx); - CommandResult::Ok(None) - } else { - CommandResult::Err(format!("project not found: {}", project_id)) - } + sm.restart_service_action(&project_id, &service_name, cx) }) }) } ActionRequest::StartAllServices { project_id } => { cx.update(|cx| { service_manager.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(&project_id).cloned() { - sm.start_all(&project_id, &path, cx); - CommandResult::Ok(None) - } else { - CommandResult::Err(format!("project not found: {}", project_id)) - } + sm.start_all_action(&project_id, cx) }) }) } ActionRequest::StopAllServices { project_id } => { cx.update(|cx| { service_manager.update(cx, |sm, cx| { - sm.stop_all(&project_id, cx); - CommandResult::Ok(None) + sm.stop_all_action(&project_id, cx) }) }) } ActionRequest::ReloadServices { project_id } => { cx.update(|cx| { service_manager.update(cx, |sm, cx| { - if let Some(path) = sm.project_path(&project_id).cloned() { - sm.reload_project_services(&project_id, &path, cx); - CommandResult::Ok(None) - } else { - CommandResult::Err(format!("project not found: {}", project_id)) - } + sm.reload_services_action(&project_id, cx) }) }) } // ── App-scoped: settings / theme / command palette ──── ActionRequest::GetSettings => { - cx.update(|cx| super::remote_config::get_settings(cx)) + cx.update(super::remote_config::get_settings) } ActionRequest::GetSettingsSchema => { cx.update(|_cx| super::remote_config::get_settings_schema()) @@ -179,7 +196,7 @@ pub(crate) async fn remote_command_loop( cx.update(|cx| super::remote_config::set_settings(cx, patch)) } ActionRequest::GetThemes => { - cx.update(|cx| super::remote_config::get_themes(cx)) + cx.update(super::remote_config::get_themes) } ActionRequest::GetTheme { id } => { cx.update(|cx| super::remote_config::get_theme(cx, id)) @@ -215,8 +232,127 @@ pub(crate) async fn remote_command_loop( }) } - action => { + // ── Soft-close: undo (restore the ejected pane) ────────── + ActionRequest::UndoSoftClose { terminal_id } => { + // Resolve the dormant main FocusManager (headless has a + // single synthetic window). Then clear the deadline + + // restore the pane through the shared flow. + cx.update(|cx| { + match focus_manager_resolver(cx, None) { + None => CommandResult::Err("window not found: main".to_string()), + Some((_window_id, focus_manager)) => { + focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + undo_soft_close_flow( + &deadlines, ws, fm, &terminals, &terminal_id, cx, + ); + }); + cx.notify(); + }); + CommandResult::Ok(None) + } + } + }) + } + + // ── Soft-close: finalize now ("Close now") ─────────────── + ActionRequest::CloseTerminalNow { terminal_id } => { cx.update(|cx| { + workspace.update(cx, |ws, cx| { + close_now_flow( + &deadlines, ws, &*backend, &terminals, &terminal_id, cx, + ); + }); + CommandResult::Ok(None) + }) + } + + // ── Close terminal: grace-aware soft close ─────────────── + // Mirrors the daemon-core loop: a busy terminal is ejected + // from the layout but its PTY is kept alive for the grace + // period (the finalizer tick kills it on expiry); idle + // terminals and `grace == 0` keep the immediate close. + ActionRequest::CloseTerminal { project_id, terminal_id } => { + let grace = cx.update(|cx| crate::settings::settings(cx).terminal_close_grace_secs); + + if grace == 0 { + // Feature off → immediate close (unchanged behavior). + cx.update(|cx| { + let app_settings = crate::settings::settings(cx); + match focus_manager_resolver(cx, None) { + None => CommandResult::Err("window not found: main".to_string()), + Some((window_id, focus_manager)) => { + focus_manager.update(cx, |fm, cx| { + let result = workspace.update(cx, |ws, cx| { + execute_action( + ActionRequest::CloseTerminal { project_id, terminal_id }, + ws, window_id, fm, &*backend, &terminals, &app_settings, cx, + ) + .into_command_result() + }); + cx.notify(); + result + }) + } + } + }) + } else { + // Probe busy-ness OFF the gpui thread (forks + // tmux/lsof/pgrep). Hold NO gpui lock across it. + let (busy, command) = smol::unblock({ + let backend = backend.clone(); + let tid = terminal_id.clone(); + move || probe_busy(&*backend, &tid) + }) + .await; + + cx.update(|cx| { + let app_settings = crate::settings::settings(cx); + match focus_manager_resolver(cx, None) { + None => CommandResult::Err("window not found: main".to_string()), + Some((window_id, focus_manager)) => { + focus_manager.update(cx, |fm, cx| { + // Try the soft close first when busy; + // `None` means the terminal wasn't in + // the layout, so fall through to the + // immediate close (same as idle). + if busy { + let toast = workspace.update(cx, |ws, cx| { + begin_soft_close_flow( + &deadlines, ws, fm, &terminals, + &project_id, &terminal_id, grace, command, cx, + ) + }); + if let Some(toast) = toast { + hook_monitor.push_toast(toast); + cx.notify(); + return CommandResult::Ok(None); + } + } + // Idle, or busy-but-not-in-layout → + // immediate close. + let result = workspace.update(cx, |ws, cx| { + execute_action( + ActionRequest::CloseTerminal { project_id, terminal_id }, + ws, window_id, fm, &*backend, &terminals, &app_settings, cx, + ) + .into_command_result() + }); + cx.notify(); + result + }) + } + } + }) + } + } + + action => { + // Compute the git-poll wake-up before `action` is moved + // into `execute_action`; it's sent only after the action + // succeeds (mirrors the dedicated daemon's loop). + let git_poll_trigger = git_poll_trigger_for_action(&action); + let result = cx.update(|cx| { // Resolve the action's explicit target window (if // any) BEFORE moving `action` into `execute_action`. // An action that carries `window: Some(s)` must land @@ -242,6 +378,11 @@ pub(crate) async fn remote_command_loop( return CommandResult::Err(format!("invalid window id: {bad}")); } }; + // Snapshot app settings to thread into the gpui-free + // `execute_action` (hooks / worktree template / default + // shell). Read here on the gpui thread before the + // nested entity updates borrow `cx`. + let app_settings = crate::settings::settings(cx); // Resolve focus_manager + window_id so the targeted // (or focused) window's per-window state is the // target for both focus mutations and per-window @@ -258,7 +399,7 @@ pub(crate) async fn remote_command_loop( Some((window_id, focus_manager)) => { focus_manager.update(cx, |fm, cx| { let result = workspace.update(cx, |ws, cx| { - execute_action(action, ws, window_id, fm, &*backend, &terminals, cx) + execute_action(action, ws, window_id, fm, &*backend, &terminals, &app_settings, cx) .into_command_result() }); cx.notify(); @@ -266,10 +407,78 @@ pub(crate) async fn remote_command_loop( }) } } - }) + }); + if matches!(result, CommandResult::Ok(_)) + && let (Some(trigger), Some(tx)) = + (git_poll_trigger, &git_poll_trigger_tx) + { + let _ = tx.send(trigger); + } + result } } } + RemoteCommand::ResizeFromConnection { + terminal_id, + cols, + rows, + connection_id, + } => { + cx.update(|cx| { + if !crate::terminal::terminal::claim_remote_resize_if_allowed( + &terminal_id, + &connection_id, + ) { + // Denied: reply with the authoritative size so the + // stream handler corrects the client's optimistic grid + // and makes it cede (server_owns) instead of leaving it + // silently diverged from the PTY. + let denied_size = terminals + .lock() + .get(&terminal_id) + .map(|term| term.resize_state.lock().size); + return match denied_size { + Some(size) => CommandResult::Ok(Some(serde_json::json!({ + "denied": true, + "cols": size.cols, + "rows": size.rows, + }))), + None => CommandResult::Ok(None), + }; + } + + let app_settings = crate::settings::settings(cx); + match focus_manager_resolver(cx, None) { + None => CommandResult::Err("window not found: main".to_string()), + Some((window_id, focus_manager)) => { + focus_manager.update(cx, |fm, cx| { + let result = workspace.update(cx, |ws, cx| { + execute_action( + ActionRequest::Resize { + terminal_id, + cols, + rows, + }, + ws, + window_id, + fm, + &*backend, + &terminals, + &app_settings, + cx, + ) + .into_command_result() + }); + cx.notify(); + result + }) + } + } + }) + } + RemoteCommand::ActionFromConnection { .. } => { + CommandResult::Err("internal action normalization error".to_string()) + } RemoteCommand::GetState => { cx.update(|cx| { let ws = workspace.read(cx); @@ -287,121 +496,47 @@ pub(crate) async fn remote_command_loop( }).collect() }; - // Build a lookup map for projects - let project_map: std::collections::HashMap<&str, &crate::workspace::state::ProjectData> = - data.projects.iter().map(|p| (p.id.as_str(), p)).collect(); - // Source of truth for runtime visibility (per-window // viewport model). let hidden_project_ids = &data.main_window.hidden_project_ids; - // Build ordered projects following project_order + folder expansion - let mut projects: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - let build_api_project = |p: &crate::workspace::state::ProjectData| -> ApiProject { - let git_status = git_statuses.get(&p.id).cloned(); - let services: Vec = sm.services_for_project(&p.id) - .into_iter() - .map(|inst| { - let (status, exit_code) = match &inst.status { - ServiceStatus::Stopped => ("stopped", None), - ServiceStatus::Starting => ("starting", None), - ServiceStatus::Running => ("running", None), - ServiceStatus::Crashed { exit_code } => ("crashed", *exit_code), - ServiceStatus::Restarting => ("restarting", None), - }; - let kind = match &inst.kind { - crate::services::manager::ServiceKind::Okena => "okena", - crate::services::manager::ServiceKind::DockerCompose { .. } => "docker_compose", - }; - ApiServiceInfo { - name: inst.definition.name.clone(), - status: status.to_string(), - terminal_id: inst.terminal_id.clone(), - ports: inst.detected_ports.clone(), - exit_code, - kind: kind.to_string(), - is_extra: inst.is_extra, - } - }) - .collect(); - ApiProject { - id: p.id.clone(), - name: p.name.clone(), - path: p.path.clone(), - show_in_overview: api_project_visibility(&p.id, hidden_project_ids), - layout: p.layout.as_ref().map(|l| l.to_api_with_sizes(&size_map)), - terminal_names: p.terminal_names.clone(), - git_status, - folder_color: p.folder_color, - services, - worktree_info: p.worktree_info.as_ref().map(|wt| { - okena_core::api::ApiWorktreeMetadata { - parent_project_id: wt.parent_project_id.clone(), - color_override: wt.color_override, - } - }), - worktree_ids: p.worktree_ids.clone(), - } - }; - - for id in &data.project_order { - if let Some(folder) = data.folders.iter().find(|f| &f.id == id) { - for pid in &folder.project_ids { - if seen.insert(pid.clone()) - && let Some(p) = project_map.get(pid.as_str()) { - projects.push(build_api_project(p)); - } - } - } else if seen.insert(id.clone()) - && let Some(p) = project_map.get(id.as_str()) { - projects.push(build_api_project(p)); - } - } - - // Append orphan projects not in any order - for p in &data.projects { - if seen.insert(p.id.clone()) { - projects.push(build_api_project(p)); - } - } - - // Build folders for response - let folders: Vec = data.folders.iter().map(|f| { - ApiFolder { - id: f.id.clone(), - name: f.name.clone(), - project_ids: f.project_ids.clone(), - folder_color: f.folder_color, - } - }).collect(); + // Pre-build the per-project wire service lists from THIS + // caller's `ServiceManager` (keeps `okena-services` out of the + // shared `okena-app-core` builder). The + // `ServiceInstance -> ApiServiceInfo` mapping is + // `ServiceInstance::to_api`, shared with the daemon loop. + let services_by_project: HashMap> = data + .projects + .iter() + .map(|p| { + let services = sm + .services_for_project(&p.id) + .into_iter() + .map(|inst| inst.to_api()) + .collect(); + (p.id.clone(), services) + }) + .collect(); // Enumerate the open OS windows (main first, then extras in // persistence order) so the client sees exactly what the // user sees per-window. The back-compat flat fields // (`focused_project_id`, `fullscreen_terminal`) are derived - // from the ACTIVE window so old clients still get a sensible - // focused project / fullscreen. + // from the ACTIVE window inside `build_state_response`. let windows = windows_resolver(cx); - let focused_project_id = windows - .iter() - .find(|w| w.active) - .and_then(|w| w.focused_project_id.clone()); - let fullscreen: Option = windows - .iter() - .find(|w| w.active) - .and_then(|w| w.fullscreen.clone()); - - let resp = StateResponse { - state_version: sv, - projects, - focused_project_id, - fullscreen_terminal: fullscreen, - project_order: data.project_order.clone(), - folders, + + // Shared projection: ordered projects + folders + flat + // back-compat fields → `StateResponse` (identical to the + // daemon loop). + let resp = okena_app_core::remote_snapshot::build_state_response( + sv, + data, + &git_statuses, + &services_by_project, + hidden_project_ids, + &size_map, windows, - }; + ); CommandResult::Ok(Some(serde_json::to_value(resp).expect("BUG: StateResponse must serialize"))) }) @@ -455,92 +590,12 @@ pub(crate) async fn remote_command_loop( } } -impl Okena { - /// Process commands from the remote API bridge. - /// Thin wrapper that spawns the shared `remote_command_loop`. - /// - /// Builds a focus-manager resolver that, per-action, asks the live `Okena` - /// entity which OS window currently has focus and returns that window's - /// `(WindowId, FocusManager)` (PRD cri 13). When the `Okena` weak handle - /// has been dropped (loop racing app shutdown) the resolver falls back to - /// `(WindowId::Main, captured main FocusManager)`. - pub(super) fn start_remote_command_loop( - &mut self, - bridge_rx: BridgeReceiver, - backend: Arc, - cx: &mut Context, - ) { - let workspace = self.workspace.clone(); - let main_focus_manager = self.main_window.read(cx).focus_manager(); - let okena_weak = cx.entity().downgrade(); - let focus_okena_weak = okena_weak.clone(); - let focus_manager_resolver: FocusManagerResolver = - Arc::new(move |cx: &App, target: Option| { - match focus_okena_weak.upgrade() { - Some(okena) => okena.read(cx).focus_manager_for_window(cx, target), - // Drop-race fallback (loop racing app shutdown): the live - // Okena is gone, so honor only the focused/main default. - None => match target { - None | Some(WindowId::Main) => { - Some((WindowId::Main, main_focus_manager.clone())) - } - Some(WindowId::Extra(_)) => None, - }, - } - }); - let windows_okena_weak = okena_weak.clone(); - let windows_resolver: WindowsResolver = Arc::new(move |cx: &App| { - windows_okena_weak - .upgrade() - .map(|okena| okena.read(cx).build_api_windows(cx)) - .unwrap_or_default() - }); - // Command-palette dispatch: resolve the target window and the named - // GUI action, then dispatch it into that window. - let dispatch_okena_weak = okena_weak; - let action_dispatcher: ActionDispatcher = - Arc::new(move |cx: &mut App, target: Option, name: &str| { - let okena = dispatch_okena_weak - .upgrade() - .ok_or_else(|| "app is shutting down".to_string())?; - let handle = okena - .read(cx) - .window_handle_for(cx, target) - .ok_or_else(|| "window not found".to_string())?; - let descriptions = crate::keybindings::get_action_descriptions(); - let desc = descriptions - .get(name) - .ok_or_else(|| format!("unknown command: {name}"))?; - let action = (desc.factory)(); - handle - .update(cx, |_view, window, cx| window.dispatch_action(action, cx)) - .map_err(|e| format!("dispatch failed: {e}")) - }); - let terminals = self.terminals.clone(); - let state_version = self.state_version.clone(); - let git_status_tx = self.git_status_tx.clone(); - let service_manager = self.service_manager.clone(); - - cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { - remote_command_loop( - bridge_rx, backend, workspace, focus_manager_resolver, windows_resolver, terminals, - state_version, git_status_tx, service_manager, action_dispatcher, cx, - ).await; - }) - .detach(); - } -} - -/// Pure visibility projection for the remote `ApiProject.show_in_overview` -/// wire flag. A project is "shown in overview" iff it is absent from the -/// per-window hidden set (today: `main_window.hidden_project_ids`). -fn api_project_visibility(project_id: &str, hidden_project_ids: &HashSet) -> bool { - !hidden_project_ids.contains(project_id) -} - #[cfg(test)] mod api_project_visibility_tests { - use super::api_project_visibility; + // The visibility projection now lives in the shared `okena-app-core` + // snapshot builder (`build_state_response` uses it internally); this test + // pins its contract from the GUI-crate side. + use okena_app_core::remote_snapshot::api_project_visibility; use std::collections::HashSet; /// Regression: the wire-format visibility flag must derive from the diff --git a/crates/okena-app/src/app/remote_config.rs b/crates/okena-app/src/app/remote_config.rs index f047b18ce..4de89c023 100644 --- a/crates/okena-app/src/app/remote_config.rs +++ b/crates/okena-app/src/app/remote_config.rs @@ -3,171 +3,134 @@ //! `GlobalTheme`) and the filesystem, so they live here rather than in the //! Workspace-scoped `execute_action`. The command-palette *invoke* needs a //! window handle and is wired separately (see `remote_commands` + `mod.rs`). +//! +//! The settings/theme logic itself is shared with the headless daemon via +//! [`okena_app_core::remote_config`]; this module only supplies a GPUI-backed +//! [`ConfigBackend`] wrapper (over `GlobalSettings` / `GlobalTheme`) plus thin +//! public wrappers that preserve the GUI's "unavailable" error strings. use crate::keybindings::get_action_descriptions; use crate::remote::bridge::CommandResult; use crate::settings::{GlobalSettings, SettingsState}; -use crate::theme::{ - AppTheme, CustomThemeColors, CustomThemeConfig, GlobalTheme, ThemeColors, ThemeMode, - DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, get_themes_dir, - load_custom_themes, -}; +use crate::theme::{AppTheme, GlobalTheme, ThemeColors, ThemeMode, DARK_THEME}; use crate::workspace::persistence::AppSettings; +use okena_app_core::remote_config::{self, ConfigBackend}; use gpui::*; use serde_json::{json, Value}; +/// Short-lived GPUI-backed [`ConfigBackend`]: reads/writes the `GlobalSettings` +/// entity and applies the active theme to the live `GlobalTheme` entity. +struct GuiConfigBackend<'a> { + cx: &'a mut App, +} + +impl ConfigBackend for GuiConfigBackend<'_> { + fn load_settings(&mut self) -> AppSettings { + // The global always exists in the running GUI; if it were absent we + // fall back to defaults (the public wrappers below still return the + // "settings unavailable" error before ever reaching this path). + self.cx + .try_global::() + .map(|g| g.0.read(self.cx).settings.clone()) + // Unreachable in practice (the public wrappers guard on the + // global's presence); fall back to the serde defaults instance. + .unwrap_or_else(|| { + serde_json::from_value::(json!({})).expect("settings defaults") + }) + } + + fn store_settings(&mut self, new: &AppSettings) -> Result<(), String> { + let Some(entity) = self.cx.try_global::().map(|g| g.0.clone()) else { + return Err("settings unavailable".into()); + }; + let new = new.clone(); + entity.update(self.cx, |st: &mut SettingsState, cx| { + st.settings = new; + st.save_and_notify(cx); + }); + Ok(()) + } + + fn apply_active_theme(&mut self, mode: ThemeMode, custom_colors: Option) { + let Some(theme) = self.cx.try_global::().map(|g| g.0.clone()) else { + return; + }; + theme.update(self.cx, |t: &mut AppTheme, cx| { + match custom_colors { + Some(colors) => { + t.set_custom_colors(colors); + t.set_mode(ThemeMode::Custom); + } + None => t.set_mode(mode), + } + cx.notify(); + }); + } + + fn active_theme_colors(&mut self, _mode: ThemeMode, _custom_id: Option<&str>) -> ThemeColors { + // The GUI has a live theme surface: read the colors it is actually + // displaying rather than deriving from the persisted mode. + self.cx + .try_global::() + .map(|g| g.0.read(self.cx).display_colors()) + // Unreachable in practice (get_theme(None) guards on GlobalTheme). + .unwrap_or(DARK_THEME) + } +} + // ── Settings ───────────────────────────────────────────────────────────────── /// Return the full current settings as JSON. -pub(super) fn get_settings(cx: &App) -> CommandResult { - match current_settings(cx) { - Some(s) => match serde_json::to_value(&s) { - Ok(v) => CommandResult::Ok(Some(v)), - Err(e) => CommandResult::Err(format!("failed to serialize settings: {e}")), - }, - None => CommandResult::Err("settings unavailable".into()), +pub(super) fn get_settings(cx: &mut App) -> CommandResult { + if cx.try_global::().is_none() { + return CommandResult::Err("settings unavailable".into()); } + remote_config::get_settings(&mut GuiConfigBackend { cx }) } /// Return a defaults instance of the settings — every key with its default /// value, as a de-facto schema agents can read to discover available keys. pub(super) fn get_settings_schema() -> CommandResult { - // Every field has a serde default, so an empty object yields all defaults. - match serde_json::from_value::(json!({})) { - Ok(defaults) => match serde_json::to_value(&defaults) { - Ok(v) => CommandResult::Ok(Some(v)), - Err(e) => CommandResult::Err(format!("failed to serialize schema: {e}")), - }, - Err(e) => CommandResult::Err(format!("failed to build schema: {e}")), - } + remote_config::get_settings_schema() } /// Deep-merge `patch` into the current settings, validate by re-deserializing, /// then replace and persist. The app's settings observer reacts to the change /// (e.g. restarting the remote server when remote_* fields change). pub(super) fn set_settings(cx: &mut App, patch: Value) -> CommandResult { - let Some(entity) = cx.try_global::().map(|g| g.0.clone()) else { + if cx.try_global::().is_none() { return CommandResult::Err("settings unavailable".into()); - }; - let current = entity.read(cx).settings.clone(); - let mut json = match serde_json::to_value(¤t) { - Ok(v) => v, - Err(e) => return CommandResult::Err(format!("failed to read settings: {e}")), - }; - merge_json(&mut json, patch); - let new: AppSettings = match serde_json::from_value(json) { - Ok(s) => s, - Err(e) => return CommandResult::Err(format!("invalid settings: {e}")), - }; - let out = serde_json::to_value(&new).unwrap_or(Value::Null); - entity.update(cx, |st, cx| { - st.settings = new; - st.save_and_notify(cx); - }); - CommandResult::Ok(Some(out)) -} - -fn current_settings(cx: &App) -> Option { - cx.try_global::() - .map(|g| g.0.read(cx).settings.clone()) -} - -/// Recursively merge `patch` into `base`: objects merge key-by-key, everything -/// else (scalars, arrays) is overwritten wholesale. -fn merge_json(base: &mut Value, patch: Value) { - match (base, patch) { - (Value::Object(b), Value::Object(p)) => { - for (k, v) in p { - merge_json(b.entry(k).or_insert(Value::Null), v); - } - } - (b, p) => *b = p, } + remote_config::set_settings(&mut GuiConfigBackend { cx }, patch) } // ── Theme ──────────────────────────────────────────────────────────────────── /// List built-in + custom themes, flagging the active one. -pub(super) fn get_themes(cx: &App) -> CommandResult { - let settings = current_settings(cx); - let mode = settings.as_ref().map(|s| s.theme_mode).unwrap_or_default(); - let active_custom = settings.and_then(|s| s.custom_theme_id); - - let mut themes = Vec::new(); - for (id, name, is_dark) in [ - ("auto", "Auto", Value::Null), - ("dark", "Dark", json!(true)), - ("light", "Light", json!(false)), - ("pastel-dark", "Pastel Dark", json!(true)), - ("high-contrast", "High Contrast", json!(true)), - ] { - let active = mode != ThemeMode::Custom && builtin_mode(id) == Some(mode); - themes.push(json!({ - "id": id, "name": name, "kind": "builtin", "is_dark": is_dark, "active": active, - })); - } - for (info, _colors) in load_custom_themes() { - let cid = info.id.strip_prefix("custom:").unwrap_or(&info.id).to_string(); - let active = mode == ThemeMode::Custom && active_custom.as_deref() == Some(cid.as_str()); - themes.push(json!({ - "id": cid, "name": info.name, "kind": "custom", - "is_dark": info.is_dark, "active": active, - })); +pub(super) fn get_themes(cx: &mut App) -> CommandResult { + if cx.try_global::().is_none() { + return CommandResult::Err("settings unavailable".into()); } - CommandResult::Ok(Some(json!({ "themes": themes }))) + remote_config::get_themes(&mut GuiConfigBackend { cx }) } /// Return a theme as an editable custom-theme blob (the active theme when /// `id` is None). -pub(super) fn get_theme(cx: &App, id: Option) -> CommandResult { - let (name, is_dark, colors) = match id.as_deref() { - None => { - let Some(theme) = cx.try_global::().map(|g| g.0.clone()) else { - return CommandResult::Err("theme unavailable".into()); - }; - let mode = current_settings(cx).map(|s| s.theme_mode).unwrap_or_default(); - ( - format!("Active ({})", mode_label(mode)), - mode != ThemeMode::Light, - theme.read(cx).display_colors(), - ) - } - Some(raw) => match builtin_colors(raw) { - Some((name, is_dark, colors)) => (name.to_string(), is_dark, colors), - None => { - let cid = raw.strip_prefix("custom:").unwrap_or(raw); - let target = format!("custom:{cid}"); - match load_custom_themes().into_iter().find(|(i, _)| i.id == target) { - Some((info, colors)) => (info.name, info.is_dark, colors), - None => return CommandResult::Err(format!("theme not found: {raw}")), - } - } - }, - }; - let blob = CustomThemeConfig { - name, - description: String::new(), - is_dark, - colors: CustomThemeColors::from_theme_colors(&colors), - }; - match serde_json::to_value(&blob) { - Ok(v) => CommandResult::Ok(Some(v)), - Err(e) => CommandResult::Err(format!("failed to serialize theme: {e}")), +pub(super) fn get_theme(cx: &mut App, id: Option) -> CommandResult { + // The active-theme (`None`) blob reads the live `GlobalTheme`; preserve the + // original "theme unavailable" error when it is absent. + if id.is_none() && cx.try_global::().is_none() { + return CommandResult::Err("theme unavailable".into()); } + remote_config::get_theme(&mut GuiConfigBackend { cx }, id) } /// Activate a theme: a built-in mode or a custom theme id. pub(super) fn set_theme(cx: &mut App, id: String) -> CommandResult { - if let Some(mode) = builtin_mode(&id) { - apply_builtin(cx, mode) - } else { - let cid = id.strip_prefix("custom:").unwrap_or(&id).to_string(); - let target = format!("custom:{cid}"); - match load_custom_themes().into_iter().find(|(i, _)| i.id == target) { - Some((_, colors)) => apply_custom(cx, cid, colors), - None => CommandResult::Err(format!("theme not found: {id}")), - } + if cx.try_global::().is_none() { + return CommandResult::Err("theme unavailable".into()); } + remote_config::set_theme(&mut GuiConfigBackend { cx }, id) } /// Write a custom theme JSON file (a full `CustomThemeConfig`) and, when @@ -178,110 +141,10 @@ pub(super) fn save_custom_theme( config: Value, activate: bool, ) -> CommandResult { - let cid = id.strip_prefix("custom:").unwrap_or(&id).to_string(); - if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { - return CommandResult::Err(format!( - "invalid theme id '{cid}' (use letters, digits, '-' or '_')" - )); - } - // Validate by deserializing into the typed config (serde fills any missing - // colors with defaults). - let parsed: CustomThemeConfig = match serde_json::from_value(config) { - Ok(c) => c, - Err(e) => return CommandResult::Err(format!("invalid theme config: {e}")), - }; - let dir = get_themes_dir(); - if let Err(e) = std::fs::create_dir_all(&dir) { - return CommandResult::Err(format!("failed to create themes dir: {e}")); - } - let path = dir.join(format!("{cid}.json")); - let pretty = match serde_json::to_string_pretty(&parsed) { - Ok(s) => s, - Err(e) => return CommandResult::Err(format!("failed to serialize theme: {e}")), - }; - if let Err(e) = std::fs::write(&path, pretty) { - return CommandResult::Err(format!("failed to write {}: {e}", path.display())); - } - if activate { - let colors = parsed.colors.to_theme_colors(); - return apply_custom(cx, cid, colors); - } - CommandResult::Ok(Some(json!({ "id": cid, "path": path.display().to_string() }))) -} - -fn apply_builtin(cx: &mut App, mode: ThemeMode) -> CommandResult { - let Some(theme) = cx.try_global::().map(|g| g.0.clone()) else { - return CommandResult::Err("theme unavailable".into()); - }; - theme.update(cx, |t: &mut AppTheme, cx| { - t.set_mode(mode); - cx.notify(); - }); - if let Some(settings) = cx.try_global::().map(|g| g.0.clone()) { - settings.update(cx, |s: &mut SettingsState, cx| { - s.settings.theme_mode = mode; - s.settings.custom_theme_id = None; - s.save_and_notify(cx); - }); - } - CommandResult::Ok(Some(json!({ "active": mode_label(mode) }))) -} - -fn apply_custom(cx: &mut App, cid: String, colors: ThemeColors) -> CommandResult { - let Some(theme) = cx.try_global::().map(|g| g.0.clone()) else { + if activate && cx.try_global::().is_none() { return CommandResult::Err("theme unavailable".into()); - }; - theme.update(cx, |t: &mut AppTheme, cx| { - t.set_custom_colors(colors); - t.set_mode(ThemeMode::Custom); - cx.notify(); - }); - if let Some(settings) = cx.try_global::().map(|g| g.0.clone()) { - let id = cid.clone(); - settings.update(cx, |s: &mut SettingsState, cx| { - s.settings.theme_mode = ThemeMode::Custom; - s.settings.custom_theme_id = Some(id); - s.save_and_notify(cx); - }); - } - CommandResult::Ok(Some(json!({ "active": format!("custom:{cid}") }))) -} - -/// Normalize a theme id ("Pastel Dark" / "pastel-dark" → "pasteldark") and map -/// to a built-in [`ThemeMode`]. Returns None for custom ids. -fn builtin_mode(id: &str) -> Option { - let n = id.to_ascii_lowercase().replace(['-', '_', ' '], ""); - match n.as_str() { - "auto" => Some(ThemeMode::Auto), - "dark" => Some(ThemeMode::Dark), - "light" => Some(ThemeMode::Light), - "pasteldark" => Some(ThemeMode::PastelDark), - "highcontrast" => Some(ThemeMode::HighContrast), - _ => None, - } -} - -/// Concrete colors for a built-in theme id (None for "auto" and custom ids). -fn builtin_colors(id: &str) -> Option<(&'static str, bool, ThemeColors)> { - let n = id.to_ascii_lowercase().replace(['-', '_', ' '], ""); - match n.as_str() { - "dark" => Some(("Dark", true, DARK_THEME)), - "light" => Some(("Light", false, LIGHT_THEME)), - "pasteldark" => Some(("Pastel Dark", true, PASTEL_DARK_THEME)), - "highcontrast" => Some(("High Contrast", true, HIGH_CONTRAST_THEME)), - _ => None, - } -} - -fn mode_label(mode: ThemeMode) -> &'static str { - match mode { - ThemeMode::Auto => "auto", - ThemeMode::Dark => "dark", - ThemeMode::Light => "light", - ThemeMode::PastelDark => "pastel-dark", - ThemeMode::HighContrast => "high-contrast", - ThemeMode::Custom => "custom", } + remote_config::save_custom_theme(&mut GuiConfigBackend { cx }, id, config, activate) } // ── Command palette ────────────────────────────────────────────────────────── diff --git a/crates/okena-app/src/keybindings/descriptions.rs b/crates/okena-app/src/keybindings/descriptions.rs index 486f118ff..7dc145af7 100644 --- a/crates/okena-app/src/keybindings/descriptions.rs +++ b/crates/okena-app/src/keybindings/descriptions.rs @@ -12,6 +12,7 @@ use super::{ ShowSettings, ShowThemeSelector, SplitHorizontal, SplitVertical, StartAllServices, StopAllServices, ToggleFullscreen, TogglePaneSwitcher, ToggleSidebar, ToggleSidebarAutoHide, ZoomIn, ZoomOut, EqualizeLayout, ToggleProjectLayout, ShowBranchSwitcher, ShowProfileManager, + RestartDaemon, }; /// Get human-readable descriptions for all actions @@ -596,5 +597,15 @@ pub fn get_action_descriptions() -> HashMap<&'static str, ActionDescription> { }, ); + map.insert( + "RestartDaemon", + ActionDescription { + name: "Restart Daemon", + description: "Restart the local daemon (ends all terminal sessions) and reconnect", + category: "Global", + factory: || Box::new(RestartDaemon), + }, + ); + map } diff --git a/crates/okena-app/src/keybindings/mod.rs b/crates/okena-app/src/keybindings/mod.rs index 69afd23ea..e9f660911 100644 --- a/crates/okena-app/src/keybindings/mod.rs +++ b/crates/okena-app/src/keybindings/mod.rs @@ -56,6 +56,7 @@ actions!( ShowBranchSwitcher, ShowProfileManager, NewWindow, + RestartDaemon, ] ); diff --git a/crates/okena-app/src/soft_close.rs b/crates/okena-app/src/soft_close.rs index b248e5d5a..3efb3a93b 100644 --- a/crates/okena-app/src/soft_close.rs +++ b/crates/okena-app/src/soft_close.rs @@ -1,301 +1,29 @@ -//! Desktop orchestration for the grace-period "soft close". +//! Client-side soft-close + restart-daemon toast-action ids. //! -//! Every interactive close is handled *optimistically*: the pane is ejected -//! from the layout immediately (the PTY stays alive), so the UI updates with no -//! lag, and only then — off the GPUI thread — do we probe whether the terminal -//! was busy. That probe can fork `tmux` / `lsof` / `pgrep` (slow on macOS), -//! which is why it must not sit on the close keypath. Once it returns: -//! * idle terminal → kill the PTY now (no toast), -//! * busy terminal → show an "Undo" / "Close now" toast with a countdown and -//! schedule the real teardown after the grace period. +//! The grace-period "soft close" orchestration now lives entirely on the daemon +//! (it owns the PTYs and the authoritative layout): the daemon ejects a busy +//! terminal, keeps its PTY alive for the grace period, builds the Undo / +//! Close-now toast, and finalizes on expiry. See `okena_daemon_core::soft_close` +//! and the daemon command loop. The client just routes a clicked toast button +//! back to the daemon (`WindowView::handle_toast_action`). //! -//! The layout bookkeeping + restore live on `Workspace` (`begin_soft_close`, -//! `decide_pending_close`, `undo_soft_close`, `finalize_soft_close`); this -//! module wires the off-thread probe, the toast, and the timer. Only the -//! interactive desktop close path goes through here — the remote API keeps -//! immediate-close semantics. - -use crate::terminal::backend::TerminalBackend; -use crate::views::window::TerminalsRegistry; -use crate::workspace::actions::soft_close::PendingDecision; -use crate::workspace::focus::FocusManager; -use crate::workspace::state::Workspace; -use crate::workspace::toast::{Toast, ToastAction, ToastActionStyle, ToastManager}; -use gpui::*; -use std::sync::Arc; -use std::time::Duration; - -/// Prefix for the "undo" toast action id; payload is `::`. -pub const UNDO_PREFIX: &str = "soft_close_undo"; -/// Prefix for the "close now" toast action id; payload is `::`. -pub const KILL_PREFIX: &str = "soft_close_kill"; - -/// Decode a toast action id of the form `::`. -/// Returns `(project_id, terminal_id)` when the prefix matches. -pub fn decode_action(id: &str, prefix: &str) -> Option<(String, String)> { - let rest = id.strip_prefix(prefix)?.strip_prefix(':')?; - let (project_id, terminal_id) = rest.split_once(':')?; - Some((project_id.to_string(), terminal_id.to_string())) -} - -/// Cap a terminal label so the toast stays tidy (TOAST_WIDTH is ~320px). OSC -/// titles can be arbitrarily long; truncate on a char boundary with an ellipsis. -fn truncate_label(label: &str) -> String { - const MAX_CHARS: usize = 42; - if label.chars().count() <= MAX_CHARS { - return label.to_string(); - } - let mut out: String = label.chars().take(MAX_CHARS - 1).collect(); - out.push('\u{2026}'); - out -} - -/// Home-relative, tail-preserving working directory for the toast detail line. -/// `~`-collapses the home dir and keeps the *end* of long paths (the directory -/// the user is actually in), since that's the useful part. -fn shorten_cwd(path: &str) -> String { - let shown = match std::env::var("HOME") { - Ok(home) if !home.is_empty() && path == home => return "~".to_string(), - Ok(home) if !home.is_empty() && path.starts_with(&format!("{home}/")) => { - format!("~{}", &path[home.len()..]) - } - _ => path.to_string(), - }; - const MAX_CHARS: usize = 30; - if shown.chars().count() <= MAX_CHARS { - return shown; - } - let tail: String = shown - .chars() - .rev() - .take(MAX_CHARS - 1) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("\u{2026}{tail}") -} - -/// Begin an optimistic close of a terminal. Returns `true` if the close was -/// taken over here (the pane was ejected from the layout and the PTY's fate is -/// being decided in the background); `false` if the feature is off or the -/// terminal isn't in the layout, in which case the caller should fall through -/// to the normal immediate close. -/// -/// The "is this terminal busy?" probe — which can fork `tmux` / `lsof` / -/// `pgrep` and is slow on macOS — runs *after* the pane is ejected, off the -/// GPUI thread, so the close is perceived as instant. Idle terminals are then -/// killed immediately; busy ones get the undo toast + grace timer. -pub fn begin( - ws: &mut Workspace, - focus_manager: &mut FocusManager, - backend: &Arc, - terminals: &TerminalsRegistry, - project_id: &str, - terminal_id: &str, - cx: &mut Context, -) -> bool { - let grace_secs = crate::settings::settings(cx).terminal_close_grace_secs; - if grace_secs == 0 { - return false; // feature disabled — caller does an immediate close - } - - let path = match ws - .project(project_id) - .and_then(|p| p.layout.as_ref()) - .and_then(|l| l.find_terminal_path(terminal_id)) - { - Some(p) => p, - None => return false, - }; - - // Stable toast id reserved up front. The pending record references it now, - // but the toast is only actually posted later *if* the terminal turns out - // to be busy. Only one pending close per terminal exists at a time, so a - // deterministic id is safe. - let toast_id = format!("soft-close:{terminal_id}"); - - // Eject the pane from the layout immediately (PTY stays alive). This is the - // instant, blocking-free part the user perceives as "the terminal closed". - ws.begin_soft_close(focus_manager, project_id, &path, terminal_id, &toast_id, cx); - - // Probe busy-ness off the GPUI thread, then resolve on the next tick. - let backend = backend.clone(); - let terminals = terminals.clone(); - let project_id = project_id.to_string(); - let terminal_id = terminal_id.to_string(); - let grace = Duration::from_secs(grace_secs as u64); - - cx.spawn(async move |ws_weak, cx| { - let probe_backend = backend.clone(); - let probe_tid = terminal_id.clone(); - // The foreground shell pid resolves through session backends (dtach / - // tmux); "has a child" means the user actually has a command running, - // not just the persistence wrapper. Both calls can spawn subprocesses. - let (fg_pid, busy) = smol::unblock(move || { - let fg_pid = probe_backend.get_foreground_shell_pid(&probe_tid); - let busy = fg_pid - .map(okena_terminal::terminal::has_child_processes) - .unwrap_or(false); - (fg_pid, busy) - }) - .await; - - let _ = ws_weak.update(cx, |ws, cx| { - if ws.decide_pending_close(&terminal_id, busy, cx) != PendingDecision::KeepForUndo { - // Raced (the PTY already exited) or Finalized (idle → killed). - // Either way there's nothing to surface. - return; - } - - // Busy: surface the undo toast and schedule the real teardown. - let toast = build_soft_close_toast( - ws, &terminals, &project_id, &terminal_id, fg_pid, &toast_id, grace, - ); - ToastManager::post(toast, cx); - - // Schedule the real teardown once the grace period elapses. If the - // user already undid or force-closed, `finalize_soft_close` returns - // false and the toast was already dismissed by the action handler. - let tid = terminal_id.clone(); - let toast_id_timer = toast_id.clone(); - cx.spawn(async move |ws_weak, cx| { - smol::Timer::after(grace).await; - let _ = ws_weak.update(cx, |ws, cx| { - if ws.finalize_soft_close(&tid, cx) { - ToastManager::dismiss(&toast_id_timer, cx); - } - }); - }) - .detach(); - }); - }) - .detach(); - - true -} - -/// Build the two-line undo toast for a busy soft-close: -/// -/// title: Closed “make” — what's closing -/// detail: okena · ~/projects/okena — project · working directory (muted) -fn build_soft_close_toast( - ws: &Workspace, - terminals: &TerminalsRegistry, - project_id: &str, - terminal_id: &str, - fg_pid: Option, - toast_id: &str, - grace: Duration, -) -> Toast { - // Read the live OSC title + cwd under a single registry lock. - let (osc_title, cwd) = { - let reg = terminals.lock(); - let term = reg.get(terminal_id); - (term.and_then(|t| t.title()), term.map(|t| t.current_cwd())) - }; - let command = fg_pid.and_then(okena_terminal::terminal::foreground_command); - - let (title, detail) = ws - .project(project_id) - .map(|p| { - // Title label precedence: a meaningful display name (user-set custom - // name or non-prompt OSC title) wins; else the live foreground - // command; else a generic "Terminal closed". - let display = p.terminal_display_name(terminal_id, osc_title); - let label = if display == p.directory_name() { command } else { Some(display) }; - let title = match label { - Some(l) => format!("Closed \u{201c}{}\u{201d}", truncate_label(&l)), - None => "Terminal closed".to_string(), - }; - // Detail line: project name, plus the cwd when we have one. - let mut detail = p.name.clone(); - if let Some(cwd) = &cwd { - detail.push_str(" \u{00b7} "); - detail.push_str(&shorten_cwd(cwd)); - } - (title, detail) - }) - .unwrap_or_else(|| ("Terminal closed".to_string(), String::new())); - - let actions = vec![ - ToastAction::new( - format!("{UNDO_PREFIX}:{project_id}:{terminal_id}"), - "Undo", - ToastActionStyle::Primary, - ), - ToastAction::new( - format!("{KILL_PREFIX}:{project_id}:{terminal_id}"), - "Close now", - ToastActionStyle::Danger, - ), - ]; - let base = Toast::info(title) - .with_id(toast_id) - .with_ttl(grace) - .with_actions(actions); - if detail.is_empty() { base } else { base.with_detail(detail) } -} - -#[cfg(test)] -mod tests { - use super::{decode_action, shorten_cwd, truncate_label, KILL_PREFIX, UNDO_PREFIX}; - - #[test] - fn decode_action_round_trips() { - let id = format!("{UNDO_PREFIX}:proj-1:term-9"); - assert_eq!( - decode_action(&id, UNDO_PREFIX), - Some(("proj-1".to_string(), "term-9".to_string())) - ); - } - - #[test] - fn decode_action_rejects_wrong_prefix() { - let id = format!("{KILL_PREFIX}:proj-1:term-9"); - assert_eq!(decode_action(&id, UNDO_PREFIX), None); - } - - #[test] - fn decode_action_rejects_malformed() { - assert_eq!(decode_action("soft_close_undo:onlyone", UNDO_PREFIX), None); - assert_eq!(decode_action("garbage", UNDO_PREFIX), None); - } - - #[test] - fn truncate_label_leaves_short_labels_untouched() { - assert_eq!(truncate_label("vim main.rs"), "vim main.rs"); - } - - #[test] - fn truncate_label_caps_long_labels_with_ellipsis() { - let long = "a".repeat(60); - let out = truncate_label(&long); - assert_eq!(out.chars().count(), 42); - assert!(out.ends_with('\u{2026}')); - } - - #[test] - fn truncate_label_respects_char_boundaries() { - // Multi-byte chars must not be split mid-codepoint. - let long = "é".repeat(60); - let out = truncate_label(&long); - assert_eq!(out.chars().count(), 42); - assert!(out.ends_with('\u{2026}')); - } - - #[test] - fn shorten_cwd_passes_through_short_paths() { - // A path not under $HOME stays as-is when short enough. - assert_eq!(shorten_cwd("/opt/app"), "/opt/app"); - } - - #[test] - fn shorten_cwd_keeps_tail_of_long_paths() { - let long = format!("/opt/{}/leaf", "deep/".repeat(20)); - let out = shorten_cwd(&long); - assert_eq!(out.chars().count(), 30); - assert!(out.starts_with('\u{2026}'), "leading ellipsis"); - assert!(out.ends_with("leaf"), "tail preserved"); - } -} +//! What remains here is purely the client-side id vocabulary: +//! * the soft-close toast-action prefixes + decoder, re-exported from +//! `okena-core` so the daemon (which builds the toast) and this client +//! (which decodes a clicked button) share one source of truth, and +//! * the restart-daemon confirm-toast action ids (a local, non-soft-close +//! toast), kept here so all toast-action ids live in one place. + +// Re-exported under the historical local names so existing callers +// (`crate::soft_close::{UNDO_PREFIX, KILL_PREFIX, decode_action}`) keep working. +pub use okena_core::soft_close::{ + decode_action, SOFT_CLOSE_KILL_PREFIX as KILL_PREFIX, SOFT_CLOSE_UNDO_PREFIX as UNDO_PREFIX, +}; + +/// Stable id for the "Restart the daemon?" confirmation toast (so it can be +/// dismissed by id once the user picks an action). +pub const RESTART_DAEMON_TOAST_ID: &str = "restart-daemon-confirm"; +/// Action id for the "Restart" button on the restart-daemon confirm toast. +pub const RESTART_DAEMON_CONFIRM_PREFIX: &str = "restart_daemon_confirm"; +/// Action id for the "Cancel" button on the restart-daemon confirm toast. +pub const RESTART_DAEMON_CANCEL_PREFIX: &str = "restart_daemon_cancel"; diff --git a/crates/okena-app/src/views/overlay_manager.rs b/crates/okena-app/src/views/overlay_manager.rs index cffd2d0f8..62108f0d4 100644 --- a/crates/okena-app/src/views/overlay_manager.rs +++ b/crates/okena-app/src/views/overlay_manager.rs @@ -21,7 +21,7 @@ use crate::views::overlays::session_manager::{SessionManager, SessionManagerEven use crate::views::overlays::profile_manager::{ProfileManager, ProfileManagerEvent}; use crate::views::overlays::settings_panel::{SettingsPanel, SettingsPanelEvent}; use crate::views::overlays::theme_selector::{ThemeSelector, ThemeSelectorEvent}; -use crate::views::overlays::pairing_dialog::{PairingDialog, PairingDialogEvent}; +use crate::views::overlays::pairing_dialog::{PairingDialog, PairingDialogEvent, PairingEndpoint}; use crate::views::overlays::remote_connect_dialog::{RemoteConnectDialog, RemoteConnectDialogEvent}; use crate::views::overlays::remote_pair_dialog::{RemotePairDialog, RemotePairDialogEvent}; use crate::views::overlays::remote_context_menu::{RemoteContextMenu, RemoteContextMenuEvent}; @@ -35,11 +35,10 @@ use crate::views::overlays::worktree_dialog::{WorktreeDialog, WorktreeDialogEven use okena_views_sidebar::{WorktreeListPopover, WorktreeListPopoverEvent}; use okena_views_sidebar::{ColorPickerPopover, ColorPickerPopoverEvent, ColorPickerTarget}; use okena_transport::client::RemoteConnectionConfig; -use crate::remote::GlobalRemoteInfo; use crate::remote_client::manager::RemoteConnectionManager; use crate::workspace::request_broker::RequestBroker; use crate::workspace::requests::{ContextMenuRequest, FolderContextMenuRequest, OverlayRequest, ProjectOverlay, ProjectOverlayKind, SidebarRequest}; -use crate::workspace::state::{WindowId, Workspace, WorkspaceData}; +use crate::workspace::state::{WindowId, Workspace}; // Re-export generic overlay utilities from okena-ui pub use okena_ui::overlay::{CloseEvent, OverlaySlot}; @@ -76,12 +75,27 @@ impl CloseEvent for PairingDialogEvent { /// actions that need access to WindowView's state (terminals, PTY manager, etc.) #[derive(Clone)] pub enum OverlayManagerEvent { - /// Session manager requested workspace switch - // Boxed: WorkspaceData is large and would bloat every event otherwise. - SwitchWorkspace(Box), + /// Session manager requested a session/workspace action (load/save/import/ + /// export). The host dispatches it to the local daemon, which owns session + /// files + the authoritative workspace. + SessionAction(okena_core::api::ActionRequest), + + /// Settings panel edited a project's per-project hooks. The host strips the + /// remote prefix and dispatches `UpdateProjectHooks` to the daemon (which + /// owns the authoritative `ProjectData.hooks`). + ProjectHooksChanged { + project_id: String, + hooks: okena_core::api::ApiHooksConfig, + }, - /// Worktree dialog created a new project - WorktreeCreated(String), + /// Worktree dialog confirmed: create a worktree on the parent project. + /// The host dispatches `ActionRequest::CreateWorktree`; the daemon creates + /// the worktree + its terminals, which mirror back. + WorktreeCreateRequested { + project_id: String, + branch: String, + create_branch: bool, + }, /// Shell selector selected a shell for a terminal ShellSelected { @@ -102,12 +116,31 @@ pub enum OverlayManagerEvent { /// Context menu: Rename directory on disk RenameDirectory { project_id: String, project_path: String }, - /// Context menu: Close worktree project + /// Rename-directory dialog confirmed: the host dispatches + /// `ActionRequest::RenameProjectDirectory`; the daemon performs the rename, + /// updates the record, and mirrors the new path+name back. + RenameDirectoryConfirmed { project_id: String, new_name: String }, + + /// Context menu: Close worktree project (opens the confirm dialog) CloseWorktree { project_id: String }, + /// Worktree list: track an already-on-disk worktree. The host dispatches + /// `ActionRequest::AddDiscoveredWorktree`; the new project mirrors back. + AddDiscoveredWorktree { + parent_project_id: String, + worktree_path: String, + branch: String, + }, + /// Context menu: Delete project DeleteProject { project_id: String }, + /// Context menu: Toggle a project's pinned flag + ToggleProjectPinned { project_id: String }, + + /// Folder context menu: Delete folder + DeleteFolder { folder_id: String }, + /// Context menu: Configure hooks for a project ConfigureHooks { project_id: String }, @@ -117,6 +150,12 @@ pub enum OverlayManagerEvent { /// Color picker: project color was changed (for remote sync) ProjectColorChanged { project_id: String, color: okena_core::theme::FolderColor }, + /// Color picker: a worktree project's color override was reset to its parent + WorktreeColorReset { project_id: String }, + + /// Color picker: folder color was changed + FolderColorChanged { folder_id: String, color: okena_core::theme::FolderColor }, + /// Context menu: Reload services (okena.yaml) for a project ReloadServices { project_id: String }, @@ -470,10 +509,33 @@ impl OverlayManager { /// Toggle settings panel overlay. pub fn toggle_settings_panel(&mut self, cx: &mut Context) { - let workspace = self.workspace.clone(); - toggle_overlay!(self, cx, SettingsPanel, SettingsPanelEvent, |cx| { - SettingsPanel::new(workspace, cx) - }); + if self.is_modal::() { + self.close_modal(cx); + } else { + let workspace = self.workspace.clone(); + let entity = cx.new(|cx| SettingsPanel::new(workspace, cx)); + self.subscribe_settings_panel(&entity, cx); + self.open_modal(entity, cx); + } + } + + /// Subscribe to a settings panel: close on `Close`, forward per-project hook + /// edits as an `OverlayManagerEvent` the host dispatches to the daemon. + fn subscribe_settings_panel( + &mut self, + entity: &Entity, + cx: &mut Context, + ) { + cx.subscribe(entity, |this, _, event: &SettingsPanelEvent, cx| match event { + SettingsPanelEvent::Close => this.close_modal(cx), + SettingsPanelEvent::ProjectHooksChanged { project_id, hooks } => { + cx.emit(OverlayManagerEvent::ProjectHooksChanged { + project_id: project_id.clone(), + hooks: (**hooks).clone(), + }); + } + }) + .detach(); } /// Toggle hook log overlay. @@ -487,12 +549,11 @@ impl OverlayManager { } /// Toggle pairing dialog overlay. - pub fn toggle_pairing_dialog(&mut self, cx: &mut Context) { + pub fn toggle_pairing_dialog(&mut self, endpoint: Option, cx: &mut Context) { if self.is_modal::() { self.close_modal(cx); - } else if let Some(remote_info) = cx.try_global::() - && let Some(auth_store) = remote_info.0.auth_store() { - let entity = cx.new(|cx| PairingDialog::new(auth_store, cx)); + } else { + let entity = cx.new(|cx| PairingDialog::new(endpoint, cx)); cx.subscribe(&entity, |this, _, event: &PairingDialogEvent, cx| { if event.is_close() { this.close_modal(cx); @@ -505,9 +566,9 @@ impl OverlayManager { /// Show settings panel opened to Hooks category for a specific project. pub fn show_settings_for_project(&mut self, project_id: String, cx: &mut Context) { let workspace = self.workspace.clone(); - open_overlay!(self, cx, SettingsPanelEvent, |cx| { - SettingsPanel::new_for_project(workspace, project_id, cx) - }); + let entity = cx.new(|cx| SettingsPanel::new_for_project(workspace, project_id, cx)); + self.subscribe_settings_panel(&entity, cx); + self.open_modal(entity, cx); } /// Toggle project switcher overlay. @@ -551,15 +612,16 @@ impl OverlayManager { if self.is_modal::() { self.close_modal(cx); } else { - let workspace = self.workspace.clone(); - let manager = cx.new(|cx| SessionManager::new(workspace, cx)); + let manager = cx.new(SessionManager::new); cx.subscribe(&manager, |this, _, event: &SessionManagerEvent, cx| { match event { SessionManagerEvent::Close => { this.close_modal(cx); } - SessionManagerEvent::SwitchWorkspace(data) => { - cx.emit(OverlayManagerEvent::SwitchWorkspace(data.clone())); + SessionManagerEvent::Action(action) => { + cx.emit(OverlayManagerEvent::SessionAction(action.clone())); + // Load/import close the manager (state swaps); save/export + // are quick fire-and-forget — close in all cases. this.close_modal(cx); } } @@ -651,8 +713,12 @@ impl OverlayManager { WorktreeDialogEvent::Close => { this.close_modal(cx); } - WorktreeDialogEvent::Created(new_project_id) => { - cx.emit(OverlayManagerEvent::WorktreeCreated(new_project_id.clone())); + WorktreeDialogEvent::RequestCreate { project_id, branch, create_branch } => { + cx.emit(OverlayManagerEvent::WorktreeCreateRequested { + project_id: project_id.clone(), + branch: branch.clone(), + create_branch: *create_branch, + }); this.close_modal(cx); } } @@ -669,14 +735,25 @@ impl OverlayManager { pub fn show_close_worktree_dialog( &mut self, project_id: String, + params: Option<(String, u16, String, Option, String)>, cx: &mut Context, ) { + let (host, port, token, local_endpoint, daemon_id) = params.unwrap_or_else(|| (String::new(), 0, String::new(), None, project_id.clone())); let workspace = self.workspace.clone(); let focus_manager = self.focus_manager.clone(); let app_settings = crate::settings::settings(cx); - open_overlay!(self, cx, CloseWorktreeDialogEvent, |cx| { - CloseWorktreeDialog::new(workspace, focus_manager, project_id, app_settings.worktree, app_settings.hooks, cx) + let entity = cx.new(|cx| { + CloseWorktreeDialog::new(host, port, token, local_endpoint, daemon_id, workspace, focus_manager, project_id, app_settings.worktree, app_settings.hooks, cx) }); + cx.subscribe(&entity, |this, _, event: &CloseWorktreeDialogEvent, cx| { + match event { + CloseWorktreeDialogEvent::Closed => { + this.close_modal(cx); + } + } + }) + .detach(); + self.open_modal(entity, cx); } // ======================================================================== @@ -690,10 +767,20 @@ impl OverlayManager { project_path: String, cx: &mut Context, ) { - let workspace = self.workspace.clone(); - open_overlay!(self, cx, RenameDirectoryDialogEvent, |cx| { - RenameDirectoryDialog::new(workspace, project_id, project_path, cx) - }); + let entity = cx.new(|cx| RenameDirectoryDialog::new(project_id, project_path, cx)); + cx.subscribe(&entity, |this, _, event: &RenameDirectoryDialogEvent, cx| { + if let RenameDirectoryDialogEvent::Confirmed { project_id, new_name } = event { + cx.emit(OverlayManagerEvent::RenameDirectoryConfirmed { + project_id: project_id.clone(), + new_name: new_name.clone(), + }); + } + if event.is_close() { + this.close_modal(cx); + } + }) + .detach(); + self.open_modal(entity, cx); } // ======================================================================== @@ -753,6 +840,12 @@ impl OverlayManager { project_id: project_id.clone(), }); } + ContextMenuEvent::ToggleProjectPinned { project_id } => { + this.hide_context_menu(cx); + cx.emit(OverlayManagerEvent::ToggleProjectPinned { + project_id: project_id.clone(), + }); + } ContextMenuEvent::ConfigureHooks { project_id } => { this.hide_context_menu(cx); cx.emit(OverlayManagerEvent::ConfigureHooks { @@ -767,7 +860,7 @@ impl OverlayManager { } ContextMenuEvent::ManageWorktrees { project_id, position } => { this.hide_context_menu(cx); - this.show_worktree_list(project_id.clone(), *position, cx); + this.show_worktree_list(project_id.clone(), *position, None, cx); } ContextMenuEvent::ReloadServices { project_id } => { this.hide_context_menu(cx); @@ -862,8 +955,8 @@ impl OverlayManager { } FolderContextMenuEvent::DeleteFolder { folder_id } => { this.hide_folder_context_menu(cx); - this.workspace.update(cx, |ws, cx| { - ws.delete_folder(folder_id, cx); + cx.emit(OverlayManagerEvent::DeleteFolder { + folder_id: folder_id.clone(), }); } FolderContextMenuEvent::FilterToFolder { folder_id } => { @@ -1135,18 +1228,36 @@ impl OverlayManager { } /// Show worktree list popover. - pub fn show_worktree_list(&mut self, project_id: String, position: Point, cx: &mut Context) { + pub fn show_worktree_list(&mut self, project_id: String, position: Point, params: Option<(String, u16, String, Option, String)>, cx: &mut Context) { self.close_all_context_menus(); + let (host, port, token, local_endpoint, daemon_id) = params.unwrap_or_else(|| (String::new(), 0, String::new(), None, project_id.clone())); let workspace = self.workspace.clone(); - let focus_manager = self.focus_manager.clone(); - let window_id = self.window_id; - let hooks = crate::settings::settings(cx).hooks.clone(); - let popover = cx.new(|cx| WorktreeListPopover::new(workspace, focus_manager, project_id, position, hooks, window_id, cx)); + let popover = cx.new(|cx| WorktreeListPopover::new(host, port, token, local_endpoint, daemon_id, workspace, project_id, position, cx)); cx.subscribe(&popover, |this, _, event: &WorktreeListPopoverEvent, cx| { - if event.is_close() { - this.hide_worktree_list(cx); + match event { + WorktreeListPopoverEvent::Close => { + this.hide_worktree_list(cx); + } + WorktreeListPopoverEvent::DeleteProject { project_id } => { + this.hide_worktree_list(cx); + cx.emit(OverlayManagerEvent::DeleteProject { + project_id: project_id.clone(), + }); + } + WorktreeListPopoverEvent::AddDiscoveredWorktree { + parent_project_id, + worktree_path, + branch, + } => { + this.hide_worktree_list(cx); + cx.emit(OverlayManagerEvent::AddDiscoveredWorktree { + parent_project_id: parent_project_id.clone(), + worktree_path: worktree_path.clone(), + branch: branch.clone(), + }); + } } }).detach(); @@ -1193,6 +1304,17 @@ impl OverlayManager { color: *color, }); } + ColorPickerPopoverEvent::WorktreeColorReset { project_id } => { + cx.emit(OverlayManagerEvent::WorktreeColorReset { + project_id: project_id.clone(), + }); + } + ColorPickerPopoverEvent::FolderColorChanged { folder_id, color } => { + cx.emit(OverlayManagerEvent::FolderColorChanged { + folder_id: folder_id.clone(), + color: *color, + }); + } } }).detach(); diff --git a/crates/okena-app/src/views/overlays/add_project_dialog.rs b/crates/okena-app/src/views/overlays/add_project_dialog.rs index a942b898c..6ed5f60d3 100644 --- a/crates/okena-app/src/views/overlays/add_project_dialog.rs +++ b/crates/okena-app/src/views/overlays/add_project_dialog.rs @@ -2,7 +2,6 @@ use crate::keybindings::Cancel; use crate::remote_client::manager::RemoteConnectionManager; -use crate::settings::settings; use crate::theme::theme; use crate::views::components::{ button, input_container, labeled_input, modal_backdrop, modal_content, @@ -15,7 +14,7 @@ use gpui::prelude::*; use gpui::*; use gpui_component::v_flex; use okena_core::api::ActionRequest; -use okena_transport::client::ConnectionStatus; +use okena_transport::client::{ConnectionStatus, LOCAL_DAEMON_CONNECTION_ID}; enum AddProjectTarget { Local, @@ -59,11 +58,17 @@ impl AddProjectDialog { let name_input = cx.new(|cx| SimpleInputState::new(cx).placeholder("Enter project name...")); let path_input = cx.new(PathAutoCompleteState::new); - // Build targets list: Local + connected remote connections + // Build targets list: Local (the implicit loopback local-daemon + // connection) + connected remote connections. The local-daemon + // connection itself is hidden from the remote list — "Local" already + // represents it. let mut targets = vec![AddProjectTarget::Local]; if let Some(ref rm) = remote_manager { let rm = rm.read(cx); for (config, status, _state) in rm.connections() { + if config.id == LOCAL_DAEMON_CONNECTION_ID { + continue; + } if matches!(status, ConnectionStatus::Connected) { targets.push(AddProjectTarget::Remote { connection_id: config.id.clone(), @@ -107,42 +112,38 @@ impl AddProjectDialog { return; } - match self.targets.get(self.selected_target) { - Some(AddProjectTarget::Local) | None => { + // Resolve the target connection. "Local" is just the implicit loopback + // local-daemon connection; every project (local or remote) is added by + // dispatching `AddProject` to a daemon over the same mechanism — the GUI + // never mutates its read-only mirror directly. + let connection_id = match self.targets.get(self.selected_target) { + Some(AddProjectTarget::Local) | None => LOCAL_DAEMON_CONNECTION_ID.to_string(), + Some(AddProjectTarget::Remote { connection_id, .. }) => connection_id.clone(), + }; + + if let Some(ref rm) = self.remote_manager { + let connection_available = rm + .read(cx) + .connections() + .iter() + .any(|(config, _, _)| config.id == connection_id); + if connection_available { let window_id = self.window_id; - self.workspace.update(cx, |ws, cx| { - ws.add_project(name, path, true, &settings(cx).hooks, window_id, cx); + self.workspace.update(cx, |ws, _cx| { + ws.queue_pending_remote_project_visibility( + window_id, + &connection_id, + &name, + Some(&path), + ); + }); + rm.update(cx, |rm, cx| { + rm.send_action( + &connection_id, + ActionRequest::AddProject { name, path }, + cx, + ); }); - } - Some(AddProjectTarget::Remote { - connection_id, .. - }) => { - if let Some(ref rm) = self.remote_manager { - let cid = connection_id.clone(); - let connection_available = rm - .read(cx) - .connections() - .iter() - .any(|(config, _, _)| config.id == cid); - if connection_available { - let window_id = self.window_id; - self.workspace.update(cx, |ws, _cx| { - ws.queue_pending_remote_project_visibility( - window_id, - &cid, - &name, - Some(&path), - ); - }); - rm.update(cx, |rm, cx| { - rm.send_action( - &cid, - ActionRequest::AddProject { name, path }, - cx, - ); - }); - } - } } } diff --git a/crates/okena-app/src/views/overlays/pairing_dialog.rs b/crates/okena-app/src/views/overlays/pairing_dialog.rs index 9a0807f75..aba652d92 100644 --- a/crates/okena-app/src/views/overlays/pairing_dialog.rs +++ b/crates/okena-app/src/views/overlays/pairing_dialog.rs @@ -1,20 +1,28 @@ use crate::keybindings::Cancel; -use crate::remote::auth::AuthStore; -use crate::remote::GlobalRemoteInfo; use crate::theme::theme; -use crate::views::components::{modal_backdrop, modal_content, modal_header}; use crate::ui::tokens::{ui_text, ui_text_md, ui_text_sm}; -use gpui::*; +use crate::views::components::{modal_backdrop, modal_content, modal_header}; use gpui::prelude::*; +use gpui::*; use okena_transport::client::tls::format_fingerprint; -use std::sync::Arc; +use std::time::Instant; + +#[derive(Clone)] +pub struct PairingEndpoint { + pub host: String, + pub port: u16, + pub token: String, + pub local_endpoint: Option, +} pub struct PairingDialog { focus_handle: FocusHandle, - auth_store: Arc, + endpoint: Option, code: String, + code_created_at: Instant, remaining_secs: u64, expired: bool, + error: Option, } pub enum PairingDialogEvent { @@ -24,21 +32,19 @@ pub enum PairingDialogEvent { impl EventEmitter for PairingDialog {} impl PairingDialog { - pub fn new(auth_store: Arc, cx: &mut Context) -> Self { - let code = auth_store.generate_fresh_code(); - let remaining_secs = auth_store.code_remaining_secs(); - + pub fn new(endpoint: Option, cx: &mut Context) -> Self { // Start countdown timer cx.spawn(async move |this: WeakEntity, cx| { loop { smol::Timer::after(std::time::Duration::from_secs(1)).await; let should_continue = this.update(cx, |this, cx| { - let remaining = this.auth_store.code_remaining_secs(); - this.remaining_secs = remaining; - if remaining == 0 { - this.expired = true; + if !this.code.is_empty() { + this.remaining_secs = seconds_remaining(this.code_created_at); + if this.remaining_secs == 0 { + this.expired = true; + } + cx.notify(); } - cx.notify(); true }); if should_continue.is_err() { @@ -48,28 +54,113 @@ impl PairingDialog { }) .detach(); - Self { + let dialog = Self { focus_handle: cx.focus_handle(), - auth_store, - code, - remaining_secs, + endpoint, + code: String::new(), + code_created_at: Instant::now(), + remaining_secs: 0, expired: false, - } + error: None, + }; + + dialog.request_new_code(cx); + dialog + } + + fn request_new_code(&self, cx: &mut Context) { + let Some(endpoint) = self.endpoint.clone() else { + cx.spawn(async move |this: WeakEntity, cx| { + let _ = this.update(cx, |this, cx| { + this.code.clear(); + this.remaining_secs = 0; + this.expired = true; + this.error = Some("Local daemon connection is not ready".to_string()); + cx.notify(); + }); + }) + .detach(); + return; + }; + + cx.spawn(async move |this: WeakEntity, cx| { + let outcome = cx + .background_executor() + .spawn(async move { + okena_remote_server::local::request_pair_code( + &endpoint.host, + endpoint.port, + &endpoint.token, + endpoint.local_endpoint.as_ref(), + ) + }) + .await; + + let _ = this.update(cx, |this, cx| { + match outcome { + Ok(code) => { + this.code = code.code; + this.code_created_at = Instant::now(); + this.remaining_secs = code.expires_in; + this.expired = false; + this.error = None; + } + Err(e) => { + this.code.clear(); + this.remaining_secs = 0; + this.expired = true; + this.error = Some(e); + } + } + cx.notify(); + }); + }) + .detach(); + } + + fn invalidate_code(&self, cx: &mut Context) { + let Some(endpoint) = self.endpoint.clone() else { + return; + }; + + cx.spawn(async move |_this: WeakEntity, cx| { + cx.background_executor() + .spawn(async move { + okena_remote_server::local::invalidate_pair_code( + &endpoint.host, + endpoint.port, + &endpoint.token, + endpoint.local_endpoint.as_ref(), + ); + }) + .await; + }) + .detach(); + } + + fn clear_current_code(&mut self) { + self.code.clear(); + self.remaining_secs = 0; + self.expired = false; + self.error = None; } fn close(&self, cx: &mut Context) { - self.auth_store.invalidate_code(); + self.invalidate_code(cx); cx.emit(PairingDialogEvent::Close); } fn generate_new_code(&mut self, cx: &mut Context) { - self.code = self.auth_store.generate_fresh_code(); - self.remaining_secs = self.auth_store.code_remaining_secs(); - self.expired = false; + self.clear_current_code(); + self.request_new_code(cx); cx.notify(); } } +fn seconds_remaining(created_at: Instant) -> u64 { + 60u64.saturating_sub(created_at.elapsed().as_secs()) +} + impl Render for PairingDialog { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let t = theme(cx); @@ -83,11 +174,12 @@ impl Render for PairingDialog { let code_for_copy = code.clone(); let expired = self.expired; let remaining = self.remaining_secs; + let error = self.error.clone(); + let loading = code.is_empty() && error.is_none() && !expired; // TLS cert fingerprint, shown so the host can read it out for the client // to verify during pairing. `None` when the server runs without TLS. - let fingerprint = cx - .try_global::() - .and_then(|info| info.0.cert_fingerprint()); + let fingerprint = + crate::remote::tls::read_fingerprint(&crate::workspace::persistence::config_dir()); modal_backdrop("pairing-dialog-backdrop", &t) .track_focus(&focus_handle) @@ -117,8 +209,24 @@ impl Render for PairingDialog { .flex_col() .items_center() .gap(px(16.0)) + .when(loading, |d| { + d.child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_muted)) + .child("Generating code..."), + ) + }) + .when_some(error, |d, error| { + d.child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.term_red)) + .child(error), + ) + }) // Code display - .when(!expired, |d| { + .when(!expired && !code.is_empty(), |d| { d.child( div() .text_size(ui_text(32.0, cx)) @@ -133,11 +241,11 @@ impl Render for PairingDialog { div() .text_size(ui_text(18.0, cx)) .text_color(rgb(t.text_muted)) - .child("Code expired"), + .child(if code.is_empty() { "No code available" } else { "Code expired" }), ) }) // Countdown or generate button - .when(!expired, |d| { + .when(!expired && !code.is_empty(), |d| { d.child( div() .text_size(ui_text_md(cx)) @@ -179,7 +287,7 @@ impl Render for PairingDialog { div() .flex() .gap(px(8.0)) - .when(!expired, |d| { + .when(!expired && !code_for_copy.is_empty(), |d| { d.child( div() .id("copy-code-btn") diff --git a/crates/okena-app/src/views/overlays/project_switcher.rs b/crates/okena-app/src/views/overlays/project_switcher.rs index bb30c2bcc..c4a7c1cc2 100644 --- a/crates/okena-app/src/views/overlays/project_switcher.rs +++ b/crates/okena-app/src/views/overlays/project_switcher.rs @@ -55,6 +55,8 @@ pub struct ProjectSwitcher { /// Per-project open state across every window, snapshotted at construction. /// Drives the row's open/closed treatment and the multi-window marker. open_states: HashMap, + /// Kept so rows can read the daemon-mirrored git status (branch label). + workspace: Entity, } impl ProjectSwitcher { @@ -105,6 +107,7 @@ impl ProjectSwitcher { focus_handle, state, open_states, + workspace, } } @@ -156,8 +159,12 @@ impl ProjectSwitcher { let open = self.open_states.get(&project.id).copied().unwrap_or_default(); let is_worktree = project.worktree_info.is_some(); let folder_color = t.get_folder_color(project.folder_color); - let branch = crate::git::get_git_status(std::path::Path::new(&project.path)) - .and_then(|s| s.branch); + let branch = self + .workspace + .read(cx) + .remote_snapshot(&project.id) + .and_then(|s| s.git_status.as_ref()) + .and_then(|g| g.branch.clone()); // Variant C treatment: a project open in *this* window gets a tinted // background + bold name + a bright accent eye; one closed everywhere is diff --git a/crates/okena-app/src/views/overlays/session_manager/actions.rs b/crates/okena-app/src/views/overlays/session_manager/actions.rs index 98454cb22..ea8cd7aea 100644 --- a/crates/okena-app/src/views/overlays/session_manager/actions.rs +++ b/crates/okena-app/src/views/overlays/session_manager/actions.rs @@ -1,8 +1,5 @@ -use crate::settings::settings_entity; -use crate::workspace::persistence::{ - delete_session, export_workspace, import_workspace, load_session, rename_session, save_session, - session_exists, -}; +use crate::workspace::persistence::{delete_session, rename_session, session_exists}; +use okena_core::api::ActionRequest; use gpui::*; use super::{SessionManager, SessionManagerEvent}; @@ -31,35 +28,25 @@ impl SessionManager { return; } - let data = self.workspace.read(cx).data().clone(); - match save_session(&name, &data) { - Ok(()) => { - self.new_session_input.update(cx, |input, cx| { - input.set_value("", cx); - }); - self.refresh_sessions(); - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Failed to save session: {}", e)); - } - } + // The daemon owns the authoritative workspace (local ids) + session + // files; saving from the client mirror would persist prefixed-id garbage. + // Dispatch SaveSession and let the daemon write its own data. + cx.emit(SessionManagerEvent::Action(ActionRequest::SaveSession { name })); + self.new_session_input.update(cx, |input, cx| { + input.set_value("", cx); + }); + self.refresh_sessions(); + self.error_message = None; cx.notify(); } pub(super) fn load_session(&mut self, name: &str, cx: &mut Context) { - let backend = settings_entity(cx).read(cx).settings.session_backend; - match load_session(name, backend) { - Ok(data) => { - // Emit event to notify parent to switch workspace - cx.emit(SessionManagerEvent::SwitchWorkspace(Box::new(data))); - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Failed to load session: {}", e)); - cx.notify(); - } - } + // The daemon loads its own session file + swaps state; the new workspace + // mirrors back via snapshot. + cx.emit(SessionManagerEvent::Action(ActionRequest::LoadSession { + name: name.to_string(), + })); + self.error_message = None; } pub(super) fn start_rename(&mut self, name: &str, window: &mut Window, cx: &mut Context) { @@ -151,17 +138,9 @@ impl SessionManager { return; } - let data = self.workspace.read(cx).data().clone(); - match export_workspace(&data, std::path::Path::new(&path)) { - Ok(()) => { - self.error_message = None; - // Show success message briefly - log::info!("Workspace exported to {}", path); - } - Err(e) => { - self.error_message = Some(format!("Failed to export: {}", e)); - } - } + // Export the DAEMON's authoritative workspace (not the client mirror). + cx.emit(SessionManagerEvent::Action(ActionRequest::ExportWorkspace { path })); + self.error_message = None; cx.notify(); } @@ -173,15 +152,9 @@ impl SessionManager { return; } - match import_workspace(std::path::Path::new(&path)) { - Ok(data) => { - cx.emit(SessionManagerEvent::SwitchWorkspace(Box::new(data))); - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Failed to import: {}", e)); - cx.notify(); - } - } + // The daemon imports the file + swaps state; the result mirrors back. + cx.emit(SessionManagerEvent::Action(ActionRequest::ImportWorkspace { path })); + self.error_message = None; + cx.notify(); } } diff --git a/crates/okena-app/src/views/overlays/session_manager/mod.rs b/crates/okena-app/src/views/overlays/session_manager/mod.rs index c76e696ca..1686502e0 100644 --- a/crates/okena-app/src/views/overlays/session_manager/mod.rs +++ b/crates/okena-app/src/views/overlays/session_manager/mod.rs @@ -3,12 +3,14 @@ mod render; use crate::views::components::SimpleInputState; use crate::workspace::persistence::{list_sessions, SessionInfo}; -use crate::workspace::state::{Workspace, WorkspaceData}; use gpui::*; -/// Session Manager overlay for managing multiple workspaces +/// Session Manager overlay for managing multiple workspaces. +/// +/// Holds no workspace handle: sessions are daemon-owned, so every mutating +/// action is dispatched (via `SessionManagerEvent::Action`) to the local daemon +/// rather than read/written from the client mirror. pub struct SessionManager { - pub(crate) workspace: Entity, pub(crate) focus_handle: FocusHandle, pub(crate) sessions: Vec, /// Input for new session name @@ -32,7 +34,7 @@ pub(crate) enum SessionManagerTab { } impl SessionManager { - pub fn new(workspace: Entity, cx: &mut Context) -> Self { + pub fn new(cx: &mut Context) -> Self { let sessions = list_sessions().unwrap_or_default(); let focus_handle = cx.focus_handle(); @@ -56,7 +58,6 @@ impl SessionManager { }); Self { - workspace, focus_handle, sessions, new_session_input, @@ -73,8 +74,10 @@ impl SessionManager { pub enum SessionManagerEvent { Close, - // Boxed: WorkspaceData is large and would bloat every event otherwise. - SwitchWorkspace(Box), + /// A ready-to-dispatch session/workspace action for the host to route to the + /// local daemon (load/save/import/export). The daemon owns session files and + /// the authoritative workspace, so these never touch the client's mirror. + Action(okena_core::api::ActionRequest), } impl EventEmitter for SessionManager {} diff --git a/crates/okena-app/src/views/overlays/settings_panel/mod.rs b/crates/okena-app/src/views/overlays/settings_panel/mod.rs index 9fdc46f5e..d51087311 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/mod.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/mod.rs @@ -595,6 +595,8 @@ impl SettingsPanel { } fn close(&self, cx: &mut Context) { + // Flush any pending per-project hook edits to the daemon before closing. + self.flush_project_hooks(cx); cx.emit(SettingsPanelEvent::Close); } @@ -624,6 +626,8 @@ impl SettingsPanel { /// Switch to a different project (or "User" if None) pub(super) fn select_project(&mut self, project_id: Option, cx: &mut Context) { + // Flush the outgoing project's hook edits before switching away. + self.flush_project_hooks(cx); self.selected_project_id = project_id.clone(); self.project_dropdown_open = false; @@ -715,6 +719,54 @@ impl SettingsPanel { }); } + /// Read the per-project hook input widgets and emit `ProjectHooksChanged` + /// so the host dispatches `UpdateProjectHooks` to the daemon. Called on panel + /// close and on project switch (not per keystroke — that would churn a full + /// snapshot per character). Reading the input widgets (not the mirror) makes + /// this robust against snapshots overwriting the mirror mid-edit; the daemon + /// dirty-checks so an unchanged flush is a no-op. + fn flush_project_hooks(&self, cx: &mut Context) { + let Some(project_id) = self.selected_project_id.clone() else { + return; + }; + let on_open = opt_string(self.project_hook_project_open.read(cx).value()); + let on_close = opt_string(self.project_hook_project_close.read(cx).value()); + let wt_create = opt_string(self.project_hook_worktree_create.read(cx).value()); + let wt_close = opt_string(self.project_hook_worktree_close.read(cx).value()); + let pre_merge = opt_string(self.project_hook_pre_merge.read(cx).value()); + let post_merge = opt_string(self.project_hook_post_merge.read(cx).value()); + let before_remove = opt_string(self.project_hook_before_worktree_remove.read(cx).value()); + let after_remove = opt_string(self.project_hook_worktree_removed.read(cx).value()); + let on_rebase_conflict = opt_string(self.project_hook_on_rebase_conflict.read(cx).value()); + let on_dirty_close = opt_string(self.project_hook_on_dirty_worktree_close.read(cx).value()); + let term_on_create = opt_string(self.project_hook_terminal_on_create.read(cx).value()); + let term_on_close = opt_string(self.project_hook_terminal_on_close.read(cx).value()); + let shell_wrapper = opt_string(self.project_hook_terminal_shell_wrapper.read(cx).value()); + + let hooks = okena_core::api::ApiHooksConfig { + project: okena_core::api::ApiProjectHooks { on_open, on_close }, + terminal: okena_core::api::ApiTerminalHooks { + on_create: term_on_create, + on_close: term_on_close, + shell_wrapper, + }, + worktree: okena_core::api::ApiWorktreeHooks { + on_create: wt_create, + on_close: wt_close, + pre_merge, + post_merge, + before_remove, + after_remove, + on_rebase_conflict, + on_dirty_close, + }, + }; + cx.emit(SettingsPanelEvent::ProjectHooksChanged { + project_id, + hooks: Box::new(hooks), + }); + } + fn render_content(&mut self, cx: &mut Context) -> impl IntoElement { let content = match &self.active_category { SettingsCategory::General => self.render_general(cx).into_any_element(), @@ -766,6 +818,15 @@ impl SettingsPanel { pub enum SettingsPanelEvent { Close, + /// The user edited a project's per-project hooks. Carries the (prefixed) + /// project id + the full hook set; the host dispatches `UpdateProjectHooks` + /// to the daemon, which owns the authoritative `ProjectData.hooks`. + ProjectHooksChanged { + project_id: String, + // Boxed: the full hook set dwarfs the other variants and trips + // `clippy::large_enum_variant` otherwise. + hooks: Box, + }, } impl EventEmitter for SettingsPanel {} diff --git a/crates/okena-app/src/views/panels/hook_panel.rs b/crates/okena-app/src/views/panels/hook_panel.rs index 8ccd14a47..55bead332 100644 --- a/crates/okena-app/src/views/panels/hook_panel.rs +++ b/crates/okena-app/src/views/panels/hook_panel.rs @@ -46,6 +46,9 @@ pub struct HookPanel { panel_height: f32, /// Number of hook terminals last observed (for auto-open on new hooks). last_hook_count: usize, + /// Action dispatcher for routing hook actions (e.g. rerun) to the daemon. + /// Synced from the owning `ProjectColumn` (mirrors the ServicePanel wiring). + action_dispatcher: Option, } impl HookPanel { @@ -116,9 +119,15 @@ impl HookPanel { terminal_pane: None, panel_height: initial_height, last_hook_count: initial_count, + action_dispatcher: None, } } + /// Set the action dispatcher used to route hook actions to the daemon. + pub fn set_action_dispatcher(&mut self, dispatcher: Option) { + self.action_dispatcher = dispatcher; + } + /// Whether the hook panel is currently open. #[allow(dead_code)] pub fn is_open(&self) -> bool { @@ -214,50 +223,21 @@ impl HookPanel { .unwrap_or_default() } - /// Rerun a hook by killing the old PTY and creating a new one. - fn rerun_hook(&mut self, terminal_id: &str, command: &str, cwd: &str, cx: &mut Context) { - let Some(runner) = cx.try_global::().cloned() else { - log::warn!("Cannot rerun hook: no HookRunner available"); - return; - }; - - // Kill old PTY - runner.backend.kill(terminal_id); - - match runner.backend.create_terminal(cwd, None) { - Ok(new_terminal_id) => { - let transport = runner.backend.transport(); - let terminal = Arc::new(okena_terminal::terminal::Terminal::new( - new_terminal_id.clone(), - okena_terminal::terminal::TerminalSize::default(), - transport.clone(), - cwd.to_string(), - )); - - // Replace in TerminalsRegistry - { - let mut guard = self.terminals.lock(); - guard.remove(terminal_id); - guard.insert(new_terminal_id.clone(), terminal); - } - - // Swap terminal ID in workspace - self.workspace.update(cx, |ws, cx| { - ws.swap_hook_terminal_id(&self.project_id, terminal_id, &new_terminal_id, cx); - }); - - // Type the command into the new shell - let cmd_with_newline = format!("{}\n", command); - transport.send_input(&new_terminal_id, cmd_with_newline.as_bytes()); - - // Switch the panel to the new terminal - self.show_hook(&new_terminal_id, cx); - - log::info!("Hook rerun: replaced {} with {}", terminal_id, new_terminal_id); - } - Err(e) => { - log::error!("Failed to rerun hook terminal: {}", e); - } + /// Rerun a hook terminal: dispatch to the daemon, which kills the old PTY, + /// spawns a fresh shell at the hook's cwd, and re-types the stored command. + /// The daemon owns the hook's command + cwd, so the action carries only the + /// ids; the swapped terminal id arrives via the next state snapshot. + fn rerun_hook(&mut self, terminal_id: &str, cx: &mut Context) { + if let Some(ref dispatcher) = self.action_dispatcher { + dispatcher.dispatch( + okena_core::api::ActionRequest::RerunHook { + project_id: self.project_id.clone(), + terminal_id: terminal_id.to_string(), + }, + cx, + ); + } else { + log::warn!("Cannot rerun hook: no action dispatcher wired"); } } @@ -563,8 +543,6 @@ impl HookPanel { // Rerun button (always visible, dimmed when running) let entity_rerun = entity.clone(); let tid_rerun = tid.clone(); - let command = entry.command.clone(); - let cwd = entry.cwd.clone(); let mut rerun_btn = icon_button_sized( "hook-panel-rerun", "icons/refresh.svg", 22.0, 12.0, t, ); @@ -578,7 +556,7 @@ impl HookPanel { cx.stop_propagation(); if let Some(e) = entity_rerun.upgrade() { e.update(cx, |this, cx| { - this.rerun_hook(&tid_rerun, &command, &cwd, cx); + this.rerun_hook(&tid_rerun, cx); }); } }); diff --git a/crates/okena-app/src/views/panels/project_column.rs b/crates/okena-app/src/views/panels/project_column.rs index dbb04bbc8..1501d626f 100644 --- a/crates/okena-app/src/views/panels/project_column.rs +++ b/crates/okena-app/src/views/panels/project_column.rs @@ -22,6 +22,10 @@ use okena_views_services::service_panel::ServicePanel; use crate::views::panels::hook_panel::HookPanel; use crate::views::window::TerminalsRegistry; +fn project_header_display_name(project: &ProjectData) -> String { + project.name.clone() +} + /// A single project column with header and layout pub struct ProjectColumn { /// Identifies which window-scoped slot on the shared `Workspace` this @@ -84,11 +88,26 @@ impl ProjectColumn { git_provider: Arc, cx: &mut Context, ) -> Self { - // Observe git watcher for re-renders (replaces per-column polling) + // Observe git watcher for re-renders (replaces per-column polling). + // In daemon-client mode this is always `None` (every project is remote, + // so git status arrives via the remote snapshot); the immediate refresh + // for a newly visible project is requested by `WindowView` sending a + // `GitStatus` action to the daemon (see + // `request_git_poll_for_visible_project`). if let Some(ref watcher) = git_watcher { cx.observe(watcher, |_, _, cx| cx.notify()).detach(); } + // Observe the workspace itself. In daemon-client mode there is no local + // git_watcher (it's `None`); the header reads git status from the remote + // snapshot, which is refreshed via `apply_remote_snapshot` + + // `notify_ui_only` on the Workspace. Since ProjectColumn renders inside a + // `.cached()` view, only a notify from an entity it observes repaints it + // — without this observer remote git-status updates (branch, ahead/behind, + // diff stats) never reach the header chip and go stale. Services don't + // hit this because ServicePanel already observes the workspace. + cx.observe(&workspace, |_, _, cx| cx.notify()).detach(); + let initial_service_height = workspace.read(cx).data.service_panel_heights .get(&project_id).copied().unwrap_or(200.0); @@ -189,14 +208,21 @@ impl ProjectColumn { }); } + /// Sync the action dispatcher to the hook panel entity (mirrors the service + /// panel sync — the hook panel needs it to dispatch RerunHook to the daemon). + fn sync_hook_panel_dispatcher(&self, cx: &mut Context) { + let dispatcher = self.action_dispatcher.clone(); + self.hook_panel.update(cx, |hp, _cx| { + hp.set_action_dispatcher(dispatcher); + }); + } + /// Set the service manager and observe it for changes. pub fn set_service_manager(&mut self, manager: Entity, cx: &mut Context) { - // Also update the action dispatcher so it can route service actions locally - if let Some(ActionDispatcher::Local { ref mut service_manager, .. }) = self.action_dispatcher { - *service_manager = Some(manager.clone()); - } - // Sync dispatcher to service panel (may have been set before panel was created) + // Sync dispatcher to service + hook panels (may have been set before the + // panels were created). self.sync_service_panel_dispatcher(cx); + self.sync_hook_panel_dispatcher(cx); self.service_panel.update(cx, |sp, cx| { sp.set_service_manager(manager, cx); }); @@ -259,8 +285,11 @@ impl ProjectColumn { /// Observe workspace for remote service state changes (used for remote project columns). pub fn observe_remote_services(&mut self, workspace: Entity, cx: &mut Context) { - // Sync dispatcher to service panel (may have been set before panel was created) + // Sync dispatcher to service + hook panels (may have been set before the + // panels were created). This is the sync point for daemon-client columns, + // which is how the hook panel gets its dispatcher to route RerunHook. self.sync_service_panel_dispatcher(cx); + self.sync_hook_panel_dispatcher(cx); self.service_panel.update(cx, |sp, cx| { sp.observe_remote_services(workspace, cx); }); @@ -393,6 +422,40 @@ impl ProjectColumn { .into_any_element() } + /// Resolve the project's git status: prefer the local watcher, fall back to + /// the remote snapshot (daemon-client mode, where `git_watcher` is `None`). + /// Both the header badge and the CI-checks popover MUST go through this so + /// they agree on the source — otherwise the badge renders from the snapshot + /// while the popover reads an empty watcher and shows nothing (a pill you + /// can't open). + fn resolve_git_status(&self, cx: &Context) -> Option { + self.git_watcher + .as_ref() + .and_then(|w| w.read(cx).get(&self.project_id).cloned()) + .or_else(|| { + self.workspace + .read(cx) + .remote_snapshot(&self.project_id) + .and_then(|snap| snap.git_status.as_ref()) + .map(|g| git::GitStatus { + branch: g.branch.clone(), + lines_added: g.lines_added, + lines_removed: g.lines_removed, + pr_info: g.pr_info.clone(), + ci_checks: g.ci_checks.clone(), + ahead: g.ahead, + behind: g.behind, + unpushed: g.unpushed, + // Carried over the wire (ApiGitStatus.review_base / + // .default_branch) so the "Review changes" chip renders + // and the base label hides on the default branch for + // daemon-backed projects too. + review_base: g.review_base.clone(), + default_branch: g.default_branch.clone(), + }) + }) + } + fn render_header(&self, project: &ProjectData, cx: &mut Context) -> impl IntoElement { let t = theme(cx); let workspace = self.workspace.clone(); @@ -412,27 +475,10 @@ impl ProjectColumn { let is_comfortable = density == crate::workspace::settings::HeaderDensity::Comfortable && !is_rows; - // Fetch git status once for both header badge and git status area - let git_status = self.git_watcher.as_ref() - .and_then(|w| w.read(cx).get(&self.project_id).cloned()) - .or_else(|| { - self.workspace.read(cx).remote_snapshot(&self.project_id) - .and_then(|snap| snap.git_status.as_ref()) - .map(|g| git::GitStatus { - branch: g.branch.clone(), - lines_added: g.lines_added, - lines_removed: g.lines_removed, - pr_info: g.pr_info.clone(), - ci_checks: g.ci_checks.clone(), - ahead: g.ahead, - behind: g.behind, - unpushed: g.unpushed, - // Not carried over the wire yet; remote projects don't - // surface the "Review changes" chip. - review_base: None, - default_branch: None, - }) - }); + // Fetch git status once for both header badge and git status area. + // Goes through resolve_git_status so the CI popover (below) sees the + // same source — watcher locally, remote snapshot in daemon-client mode. + let git_status = self.resolve_git_status(cx); // Worktree indicator: filled dot for normal project, ring for worktree. let worktree_dot = if project.worktree_info.is_some() { @@ -455,14 +501,7 @@ impl ProjectColumn { }; let project_name_el = { - let display_name = if let Some(ref wt_info) = project.worktree_info { - let ws = self.workspace.read(cx); - ws.project(&wt_info.parent_project_id) - .map(|p| p.name.clone()) - .unwrap_or_else(|| project.name.clone()) - } else { - project.name.clone() - }; + let display_name = project_header_display_name(project); let path_for_tooltip = project.path.clone(); let project_id_for_click = self.project_id.clone(); let request_broker_for_click = self.request_broker.clone(); @@ -973,10 +1012,10 @@ impl Render for ProjectColumn { self.render_empty_state(cx).into_any_element() }; - // Get current branch for commit log popover and update git header - let current_branch = self.git_watcher.as_ref() - .and_then(|w| w.read(cx).get(&self.project_id).cloned()) - .and_then(|s| s.branch); + // Get current branch for commit log popover and update git header. + // Same source as the badge (resolve_git_status) so the branch is + // present in daemon-client mode too, where git_watcher is None. + let current_branch = self.resolve_git_status(cx).and_then(|s| s.branch); self.git_header.update(cx, |gh, _cx| { gh.set_current_branch(current_branch.clone()); }); @@ -992,11 +1031,9 @@ impl Render for ProjectColumn { .child(self.render_header(&project, cx)) .child(content) // Hook panel (delegated to HookPanel entity) - .child({ - self.hook_panel.update(cx, |hp, cx| { - hp.render_panel(&t, cx) - }) - }) + .child(self.hook_panel.update(cx, |hp, cx| { + hp.render_panel(&t, cx) + })) // Service panel (delegated to ServicePanel entity) .child({ self.service_panel.update(cx, |sp, cx| { @@ -1021,10 +1058,13 @@ impl Render for ProjectColumn { gh.render_branch_picker(window, &t, cx) }) }) - // CI checks popover (delegated to GitHeader entity) + // CI checks popover (delegated to GitHeader entity). + // Resolve via the same path as the badge: in daemon-client + // mode git_watcher is None, so the watcher-only fetch left + // ci_checks empty and the popover rendered nothing (the pill + // toggled but never opened). Fall back to the remote snapshot. .child({ - let git_status = self.git_watcher.as_ref() - .and_then(|w| w.read(cx).get(&self.project_id).cloned()); + let git_status = self.resolve_git_status(cx); let ci_checks = git_status.as_ref().and_then(|g| g.ci_checks.clone()); let pr_info = git_status.and_then(|g| g.pr_info); self.git_header.update(cx, |gh, cx| { @@ -1045,3 +1085,48 @@ impl Render for ProjectColumn { } } } + +#[cfg(test)] +mod tests { + use super::project_header_display_name; + use crate::workspace::settings::HooksConfig; + use crate::workspace::state::{ProjectData, WorktreeMetadata}; + use okena_core::theme::FolderColor; + use std::collections::HashMap; + + fn project_with_name(name: &str) -> ProjectData { + ProjectData { + id: "p1".to_string(), + name: name.to_string(), + path: "/tmp/repo-worktree".to_string(), + layout: None, + terminal_names: HashMap::new(), + hidden_terminals: HashMap::new(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: FolderColor::default(), + hooks: HooksConfig::default(), + is_remote: false, + connection_id: None, + service_terminals: HashMap::new(), + default_shell: None, + hook_terminals: HashMap::new(), + pinned: false, + last_activity_at: None, + } + } + + #[test] + fn project_header_uses_worktree_project_name() { + let mut project = project_with_name("feature-login"); + project.worktree_info = Some(WorktreeMetadata { + parent_project_id: "parent".to_string(), + color_override: None, + main_repo_path: "/tmp/repo".to_string(), + worktree_path: "/tmp/repo-worktree".to_string(), + branch_name: "feature/login".to_string(), + }); + + assert_eq!(project_header_display_name(&project), "feature-login"); + } +} diff --git a/crates/okena-app/src/views/panels/status_bar.rs b/crates/okena-app/src/views/panels/status_bar.rs index bfb1613e4..2adcedae1 100644 --- a/crates/okena-app/src/views/panels/status_bar.rs +++ b/crates/okena-app/src/views/panels/status_bar.rs @@ -1,12 +1,15 @@ use crate::keybindings::ToggleSidebar; +use crate::remote_client::manager::RemoteConnectionManager; use crate::settings::settings_entity; use crate::theme::theme; use crate::workspace::state::Workspace; use crate::ui::tokens::{ui_text_ms, ui_text_sm, ui_text_xl}; use gpui::prelude::FluentBuilder; use gpui::*; -use gpui_component::h_flex; +use gpui_component::{h_flex, v_flex}; +use okena_core::api::{ApiLayoutNode, ApiSystemStats}; use okena_extensions::{ExtensionInstance, ExtensionRegistry}; +use okena_transport::client::{ConnectionStatus, LOCAL_DAEMON_CONNECTION_ID}; use parking_lot::Mutex; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -25,6 +28,20 @@ struct SystemStats { memory_total_gb: f32, } +#[derive(Clone)] +struct RemoteStatusSnapshot { + id: String, + name: String, + endpoint: String, + status: ConnectionStatus, + tls: bool, + project_count: usize, + window_count: usize, + terminal_count: usize, + has_state: bool, + system_stats: Option, +} + /// Global system info cache struct SystemInfoCache { system: System, @@ -78,6 +95,9 @@ pub struct StatusBar { /// (cancels background tasks, releases views). active_extensions: HashMap, sidebar_open: bool, + remote_manager: Option>, + remote_status_bounds: Bounds, + remote_popover_visible: bool, } impl StatusBar { @@ -133,7 +153,15 @@ impl StatusBar { cx.observe(&focus_manager, |_, _, cx| cx.notify()).detach(); Self { - workspace, focus_manager, cache, activate_fns, active_extensions, sidebar_open: true, + workspace, + focus_manager, + cache, + activate_fns, + active_extensions, + sidebar_open: true, + remote_manager: None, + remote_status_bounds: Bounds::default(), + remote_popover_visible: false, } } @@ -173,6 +201,11 @@ impl StatusBar { } } + pub fn set_remote_manager(&mut self, manager: Entity, cx: &mut Context) { + self.remote_manager = Some(manager); + cx.notify(); + } + fn format_time() -> String { match OffsetDateTime::now_local() { Ok(now) => format!("{:02}:{:02}", now.hour(), now.minute()), @@ -183,12 +216,342 @@ impl StatusBar { } } } + + fn remote_snapshots(&self, cx: &App) -> Vec { + let Some(manager) = &self.remote_manager else { + return Vec::new(); + }; + + manager.read(cx).connections_with_system_stats().into_iter() + .filter(|(config, _, _, _)| config.id != LOCAL_DAEMON_CONNECTION_ID) + .map(|(config, status, state, system_stats)| { + let (project_count, window_count, terminal_count) = match state { + Some(state) => { + let terminals = state.projects.iter() + .map(|project| Self::layout_terminal_count(project.layout.as_ref())) + .sum(); + (state.projects.len(), state.windows.len(), terminals) + } + None => (0, 0, 0), + }; + + RemoteStatusSnapshot { + id: config.id.clone(), + name: config.name.clone(), + endpoint: config.display_endpoint(), + status: status.clone(), + tls: config.tls, + project_count, + window_count, + terminal_count, + has_state: state.is_some(), + system_stats: system_stats.cloned(), + } + }) + .collect() + } + + fn layout_terminal_count(node: Option<&ApiLayoutNode>) -> usize { + match node { + Some(ApiLayoutNode::Terminal { terminal_id: Some(_), .. }) => 1, + Some(ApiLayoutNode::Terminal { .. }) => 0, + Some(ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. }) => { + children.iter() + .map(|child| Self::layout_terminal_count(Some(child))) + .sum() + } + None => 0, + } + } + + fn count_label(count: usize, singular: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{count} {singular}s") + } + } + + fn status_label(status: &ConnectionStatus) -> String { + match status { + ConnectionStatus::Disconnected => "Disconnected".to_string(), + ConnectionStatus::Connecting => "Connecting".to_string(), + ConnectionStatus::Pairing => "Pairing".to_string(), + ConnectionStatus::Connected => "Connected".to_string(), + ConnectionStatus::Reconnecting { attempt } => format!("Reconnecting #{attempt}"), + ConnectionStatus::Error(message) => { + if message.is_empty() { + "Error".to_string() + } else { + format!("Error: {message}") + } + } + } + } + + fn status_color(status: &ConnectionStatus, t: &okena_core::theme::ThemeColors) -> u32 { + match status { + ConnectionStatus::Connected => t.term_green, + ConnectionStatus::Connecting + | ConnectionStatus::Pairing + | ConnectionStatus::Reconnecting { .. } => t.term_yellow, + ConnectionStatus::Disconnected => t.text_muted, + ConnectionStatus::Error(_) => t.term_red, + } + } + + fn cpu_metric_color(cpu_usage: f32, t: &okena_core::theme::ThemeColors) -> u32 { + if cpu_usage > 80.0 { + t.metric_critical + } else if cpu_usage > 50.0 { + t.metric_warning + } else { + t.metric_normal + } + } + + fn memory_metric_color(memory_percent: u64, t: &okena_core::theme::ThemeColors) -> u32 { + if memory_percent > 80 { + t.metric_critical + } else if memory_percent > 60 { + t.metric_warning + } else { + t.metric_normal + } + } + + fn memory_percent_from_bytes(stats: &ApiSystemStats) -> u64 { + stats + .memory_used_bytes + .saturating_mul(100) + .checked_div(stats.memory_total_bytes) + .unwrap_or(0) + } + + fn format_gib_tenths(bytes: u64) -> String { + const GIB: u64 = 1_073_741_824; + let tenths = bytes.saturating_mul(10).saturating_add(GIB / 2) / GIB; + format!("{}.{}", tenths / 10, tenths % 10) + } + + fn format_memory_bytes(stats: &ApiSystemStats) -> String { + format!( + "{}/{} GB", + Self::format_gib_tenths(stats.memory_used_bytes), + Self::format_gib_tenths(stats.memory_total_bytes), + ) + } + + fn aggregate_remote_color(snapshots: &[RemoteStatusSnapshot], t: &okena_core::theme::ThemeColors) -> u32 { + if snapshots.iter().any(|snap| matches!(snap.status, ConnectionStatus::Error(_))) { + return t.term_red; + } + if snapshots.iter().any(|snap| { + matches!( + snap.status, + ConnectionStatus::Connecting + | ConnectionStatus::Pairing + | ConnectionStatus::Reconnecting { .. } + ) + }) { + return t.term_yellow; + } + if snapshots.iter().all(|snap| matches!(snap.status, ConnectionStatus::Connected)) { + return t.term_green; + } + t.text_muted + } + + fn render_remote_status_popover( + &self, + snapshots: &[RemoteStatusSnapshot], + t: &okena_core::theme::ThemeColors, + cx: &mut Context, + ) -> AnyElement { + if !self.remote_popover_visible || snapshots.is_empty() { + return div().size_0().into_any_element(); + } + + let bounds = self.remote_status_bounds; + let position = point(bounds.origin.x + bounds.size.width, bounds.origin.y - px(6.0)); + + let mut rows = Vec::new(); + for snapshot in snapshots { + let status_label = Self::status_label(&snapshot.status); + let detail = if snapshot.has_state { + format!( + "{} / {} / {}", + Self::count_label(snapshot.project_count, "project"), + Self::count_label(snapshot.terminal_count, "terminal"), + Self::count_label(snapshot.window_count, "window"), + ) + } else { + "Waiting for state".to_string() + }; + let security = if snapshot.tls { "TLS" } else { "no TLS" }; + let status_color = Self::status_color(&snapshot.status, t); + let system_stats = snapshot.system_stats.clone(); + + rows.push( + div() + .id(ElementId::Name(format!("remote-status-row-{}", snapshot.id).into())) + .py(px(6.0)) + .border_t_1() + .border_color(rgb(t.border)) + .flex() + .items_start() + .gap(px(8.0)) + .child( + div() + .mt(px(5.0)) + .w(px(7.0)) + .h(px(7.0)) + .rounded_full() + .bg(rgb(status_color)) + .flex_shrink_0(), + ) + .child( + v_flex() + .gap(px(2.0)) + .min_w_0() + .flex_1() + .child( + h_flex() + .gap(px(6.0)) + .min_w_0() + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_primary)) + .child(snapshot.name.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(if snapshot.tls { t.text_muted } else { t.term_yellow })) + .child(security), + ), + ) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(snapshot.endpoint.clone()), + ) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .text_size(ui_text_sm(cx)) + .text_color(rgb(status_color)) + .child(status_label), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_secondary)) + .child(detail), + ) + .when_some(system_stats, |el, stats| { + let memory_percent = Self::memory_percent_from_bytes(&stats); + el.child( + h_flex() + .gap(px(10.0)) + .text_size(ui_text_sm(cx)) + .child( + h_flex() + .gap(px(4.0)) + .child( + div() + .text_color(rgb(t.text_muted)) + .child("CPU"), + ) + .child( + div() + .text_color(rgb(Self::cpu_metric_color( + stats.cpu_usage, + t, + ))) + .child(format!("{:02.0}%", stats.cpu_usage)), + ), + ) + .child( + h_flex() + .gap(px(4.0)) + .min_w_0() + .child( + div() + .text_color(rgb(t.text_muted)) + .child("MEM"), + ) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .text_color(rgb(Self::memory_metric_color( + memory_percent, + t, + ))) + .child(Self::format_memory_bytes(&stats)), + ), + ), + ) + }), + ) + .into_any_element(), + ); + } + + deferred( + anchored() + .position(position) + .anchor(Anchor::BottomRight) + .snap_to_window() + .child( + okena_ui::popover::popover_panel("remote-status-popover", t) + .w(px(360.0)) + .max_h(px(280.0)) + .overflow_y_scroll() + .child( + h_flex() + .justify_between() + .pb(px(6.0)) + .child( + div() + .text_size(ui_text_sm(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_secondary)) + .child("REMOTE CONNECTIONS"), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(format!("{}", snapshots.len())), + ), + ) + .children(rows), + ), + ) + .into_any_element() + } } impl Render for StatusBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let t = theme(cx); let stats = self.cache.lock().stats(); + let remote_snapshots = self.remote_snapshots(cx); // Get current time using chrono-free approach let time_str = Self::format_time(); @@ -317,41 +680,107 @@ impl Render for StatusBar { } } - // Show remote server status if active - if let Some(remote_info) = cx.try_global::() - && let Some(port) = remote_info.0.port() { - right = right.child( - div() - .id("remote-info") - .flex() - .items_center() - .gap(px(6.0)) - .child( - div() - .text_color(rgb(t.term_cyan)) - .child(format!("REMOTE :{}", port)) - ) - .child( - div() - .id("pair-btn") - .cursor_pointer() - .px(px(6.0)) - .py(px(1.0)) - .rounded(px(3.0)) - .text_color(rgb(t.term_yellow)) - .text_size(ui_text_sm(cx)) - .font_weight(FontWeight::SEMIBOLD) - .hover(|s| s.bg(rgb(t.bg_hover))) - .child("Pair") - .on_click(|_, window, cx| { - window.dispatch_action( - Box::new(crate::keybindings::ShowPairingDialog), - cx, - ); - }) + // Show daemon remote endpoint when active. In thin-client mode + // the server lives in the daemon process, so the GUI no longer + // has an in-process GlobalRemoteInfo/AuthStore to inspect. + if let Some(daemon) = crate::remote::local::running_daemon() { + let port = daemon.port; + right = right.child( + div() + .id("remote-info") + .flex() + .items_center() + .gap(px(6.0)) + .child( + div() + // Neutral footer chrome (matches version/time), + // not a terminal ANSI accent — the accent read + // inconsistent in themes like Pastel. + .text_color(rgb(t.text_secondary)) + .child(format!("REMOTE :{}", port)) + ) + .child( + div() + .id("pair-btn") + .cursor_pointer() + .px(px(6.0)) + .py(px(1.0)) + .rounded(px(3.0)) + // White label + hover bg for the clickable + // affordance, instead of the ANSI yellow accent. + .text_color(rgb(t.text_primary)) + .text_size(ui_text_sm(cx)) + .font_weight(FontWeight::SEMIBOLD) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child("Pair") + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(crate::keybindings::ShowPairingDialog), + cx, + ); + }) + ) + ); + } + + if !remote_snapshots.is_empty() { + let connected = remote_snapshots + .iter() + .filter(|snap| matches!(snap.status, ConnectionStatus::Connected)) + .count(); + let status_color = Self::aggregate_remote_color(&remote_snapshots, &t); + let entity_for_bounds = cx.entity().clone(); + + right = right.child( + div() + .id("remote-status-pill") + .relative() + .cursor_pointer() + .flex() + .items_center() + .gap(px(5.0)) + .px(px(6.0)) + .py(px(1.0)) + .rounded(px(3.0)) + .hover(|s| s.bg(rgb(t.bg_hover))) + .on_click(cx.listener(|this, _, _window, cx| { + this.remote_popover_visible = !this.remote_popover_visible; + cx.notify(); + })) + .child( + div() + .w(px(7.0)) + .h(px(7.0)) + .rounded_full() + .bg(rgb(status_color)), + ) + .child( + div() + .text_color(rgb(t.text_secondary)) + .child("REMOTES"), + ) + .child( + div() + .text_color(rgb(status_color)) + .font_weight(FontWeight::SEMIBOLD) + .child(format!("{}/{}", connected, remote_snapshots.len())), + ) + .child( + canvas( + move |bounds, _window, app| { + entity_for_bounds.update(app, |this: &mut StatusBar, _cx| { + this.remote_status_bounds = bounds; + }); + }, + |_, _, _, _| {}, ) - ); - } + .absolute() + .size_full(), + ), + ); + } else if self.remote_popover_visible { + self.remote_popover_visible = false; + } // Focused project indicator let focused_project = { @@ -419,6 +848,7 @@ impl Render for StatusBar { .text_color(rgb(t.text_secondary)) .child(time_str) ) + .child(self.render_remote_status_popover(&remote_snapshots, &t, cx)) }) } } diff --git a/crates/okena-app/src/views/panels/toast.rs b/crates/okena-app/src/views/panels/toast.rs index eebf61952..d5f811ff4 100644 --- a/crates/okena-app/src/views/panels/toast.rs +++ b/crates/okena-app/src/views/panels/toast.rs @@ -2,7 +2,7 @@ pub use crate::workspace::toast::{Toast, ToastAction, ToastActionStyle, ToastLevel, ToastManager}; use crate::theme::theme; -use crate::ui::tokens::{RADIUS_MD, RADIUS_STD, SPACE_MD, SPACE_SM, SPACE_XS, ICON_SM, ui_text_ms, ui_text_xs}; +use crate::ui::tokens::{RADIUS_STD, SPACE_LG, SPACE_MD, SPACE_SM, SPACE_XS, ICON_SM, ui_text_ms, ui_text_xs}; use gpui::prelude::FluentBuilder; use gpui::*; use std::time::Duration; @@ -24,21 +24,21 @@ const FADE_IN_DURATION: Duration = Duration::from_millis(150); /// Toast width const TOAST_WIDTH: f32 = 320.0; -/// Accent stripe width -const ACCENT_WIDTH: f32 = 3.0; - trait ToastLevelExt { fn icon_char(self) -> &'static str; fn accent_color(self, t: &crate::theme::ThemeColors) -> u32; } impl ToastLevelExt for ToastLevel { + /// The glyph shown inside the level badge. Warning rides its own triangle + /// glyph (text-presentation via U+FE0E so it takes the accent color, not + /// emoji); the others are drawn inside a bordered circle by the renderer. fn icon_char(self) -> &'static str { match self { ToastLevel::Success => "✓", - ToastLevel::Error => "✗", - ToastLevel::Warning => "⚠", - ToastLevel::Info => "ℹ", + ToastLevel::Error => "✕", + ToastLevel::Warning => "⚠\u{fe0e}", + ToastLevel::Info => "i", } } @@ -143,92 +143,101 @@ impl Render for ToastOverlay { let has_countdown = !toast.actions.is_empty(); let remaining = toast.remaining_fraction(); + // Level badge: warning rides its own triangle glyph; the rest + // sit inside a bordered circle. Colored by the level accent. + let icon_el = if toast.level == ToastLevel::Warning { + div() + .flex_shrink_0() + .text_color(rgb(accent_color)) + .text_size(text_size) + .child(icon_char) + .into_any_element() + } else { + div() + .flex_shrink_0() + .size(px(16.0)) + .rounded_full() + .border_1() + .border_color(rgb(accent_color)) + .flex() + .items_center() + .justify_center() + .child( + div() + .text_size(px(10.0)) + .text_color(rgb(accent_color)) + .child(icon_char), + ) + .into_any_element() + }; + div() .id(SharedString::from(format!("toast-{}", toast.id))) .opacity(opacity) .bg(rgb(t.bg_secondary)) .border_1() .border_color(rgb(t.border)) - .rounded(RADIUS_STD) + .rounded(px(10.0)) .shadow_xl() + .relative() .flex() .flex_col() .overflow_hidden() - // Main row: accent stripe + content column + // Grace countdown: a plain bottom line, not a background wash. + .when(has_countdown, |el| { + el.child( + div() + .absolute() + .bottom_0() + .left_0() + .h(px(2.0)) + .w(relative(remaining)) + .bg(rgb(accent_color)) + ) + }) + // Content: level icon + column (title row / subtitle / actions). + // No left accent stripe — the level color rides the icon. .child( div() + .relative() .flex() .flex_row() - // Accent stripe - .child( - div() - .w(px(ACCENT_WIDTH)) - .h_full() - .bg(rgb(accent_color)) - .flex_shrink_0(), - ) - // Content column (message row + optional actions row) + .items_start() + .gap(SPACE_MD) + .px(SPACE_LG) + .py(SPACE_LG) + // Level badge (circle / warning triangle) + .child(icon_el) + // Content column .child( div() + .flex_1() + .min_w(px(0.)) .flex() .flex_col() - .flex_1() - .overflow_x_hidden() - .gap(SPACE_XS) - .px(SPACE_MD) - .py(SPACE_SM) - // Message row + .gap(px(3.0)) + // Title row: bold title + close (top-right) .child( div() .flex() .flex_row() .items_start() .gap(SPACE_SM) - // Icon - .child( - div() - .text_color(rgb(accent_color)) - .text_size(text_size) - .flex_shrink_0() - .mt(px(1.0)) - .child(icon_char), - ) - // Message + optional detail line .child( div() .flex_1() .min_w(px(0.)) - .overflow_x_hidden() - .flex() - .flex_col() - .gap(px(1.0)) - .child( - div() - .whitespace_normal() - .text_size(text_size) - .text_color(rgb(t.text_primary)) - .child(toast.message.clone()), - ) - .when_some( - toast.detail.clone(), - |el, detail| { - el.child( - div() - .whitespace_normal() - .text_size(detail_size) - .text_color(rgb(t.text_muted)) - .child(detail), - ) - }, - ), + .whitespace_normal() + .text_size(text_size) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_primary)) + .child(toast.message.clone()), ) - // Close (dismiss) button — only for - // plain toasts. Action toasts (undo / - // close-now) are resolved via their - // buttons or the countdown, so a third - // "dismiss" affordance would be - // ambiguous (it would hide the undo but - // still let the close go through). + // Close (dismiss) — plain toasts only. + // Action toasts (undo / close-now) are + // resolved via their buttons or the + // countdown, so a third "dismiss" would + // be ambiguous. .when(!has_countdown, |el| { el.child( div() @@ -253,14 +262,28 @@ impl Render for ToastOverlay { ) }), ) - // Actions row (Undo / Close now / …) + // Subtitle / detail + .when_some(toast.detail.clone(), |el, detail| { + el.child( + div() + .whitespace_normal() + .text_size(detail_size) + .text_color(rgb(t.text_secondary)) + .child(detail), + ) + }) + // Actions row (left-aligned text links) .when(has_countdown, |el| { el.child( div() .flex() .flex_row() - .justify_end() .gap(SPACE_XS) + .mt(SPACE_SM) + // Pull left so the first action's + // text (past its hover-pill padding) + // aligns flush with the title above. + .ml(px(-6.0)) .children(toast.actions.iter().map(|action| { let toast_id = toast.id.clone(); let action_id = action.id.clone(); @@ -280,21 +303,6 @@ impl Render for ToastOverlay { }), ), ) - // Countdown progress bar (only for toasts with actions) - .when(has_countdown, |el| { - el.child( - div() - .w_full() - .h(px(2.0)) - .bg(rgb(t.border)) - .child( - div() - .h_full() - .w(relative(remaining)) - .bg(rgb(accent_color)), - ), - ) - }) })) .into_any_element() } @@ -310,8 +318,10 @@ fn action_button( text_size: Pixels, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { + // Plain text-link actions (bottom-left), matching the notification style: + // white primary, error-tinted danger, muted default — with a hover pill. let label_color = match action.style { - ToastActionStyle::Primary => t.term_blue, + ToastActionStyle::Primary => t.text_primary, ToastActionStyle::Danger => t.error, ToastActionStyle::Default => t.text_secondary, }; @@ -321,8 +331,9 @@ fn action_button( .cursor_pointer() .px(SPACE_SM) .py(px(2.0)) - .rounded(RADIUS_MD) + .rounded(RADIUS_STD) .text_size(text_size) + .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(label_color)) .hover(|s| s.bg(rgb(t.bg_hover))) .child(action.label.clone()) diff --git a/crates/okena-app/src/views/window/handlers.rs b/crates/okena-app/src/views/window/handlers.rs index 40aaaca60..5d0301be3 100644 --- a/crates/okena-app/src/views/window/handlers.rs +++ b/crates/okena-app/src/views/window/handlers.rs @@ -1,12 +1,11 @@ use crate::action_dispatch::ActionDispatcher; -use crate::settings::{settings, GlobalSettings}; +use crate::settings::GlobalSettings; use crate::views::overlay_manager::{OverlayManager, OverlayManagerEvent}; -use crate::workspace::persistence; use crate::workspace::requests::{ FolderOverlay, FolderOverlayKind, OverlayRequest, ProjectOverlay, ProjectOverlayKind, SidebarRequest, }; -use crate::workspace::state::{GlobalWorkspace, LayoutNode, Workspace}; +use crate::workspace::state::{LayoutNode, Workspace}; use gpui::*; use okena_core::api::ActionRequest; @@ -14,29 +13,37 @@ use okena_core::api::ActionRequest; use super::WindowView; impl WindowView { - /// Build an ActionDispatcher for the given project. - /// Returns Remote variant if the project is a remote project, - /// otherwise returns Local variant. - fn dispatcher_for_project(&self, project_id: &str, cx: &Context) -> ActionDispatcher { - let backend = Some(self.backend.clone()); + /// Build an ActionDispatcher for the given project. Returns `None` if the + /// project is unknown or its daemon connection is unavailable. + pub(super) fn dispatcher_for_project(&self, project_id: &str, cx: &Context) -> Option { crate::action_dispatch::dispatcher_for_project( project_id, self.window_id, &self.workspace, &self.focus_manager, - &backend, - &self.terminals, - &self.service_manager, &self.remote_manager, cx, - ).unwrap_or_else(|| ActionDispatcher::Local { - workspace: self.workspace.clone(), - focus_manager: self.focus_manager.clone(), - backend: self.backend.clone(), - terminals: self.terminals.clone(), - service_manager: self.service_manager.clone(), - window_id: self.window_id, - }) + ) + } + + /// Build an ActionDispatcher for a folder. Folders carry no project to + /// resolve a connection from, so extract the connection id from the folder + /// id (`remote::` → ``, falling back to the local daemon for + /// an unprefixed id) and target that connection directly. The dispatcher's + /// `dispatch` strips the prefixed folder id automatically. Returns `None` if + /// the remote manager is unavailable. + pub(super) fn dispatcher_for_folder(&self, folder_id: &str, _cx: &Context) -> Option { + let conn_id = folder_id + .strip_prefix("remote:") + .and_then(|r| r.split(':').next()) + .unwrap_or(okena_transport::client::LOCAL_DAEMON_CONNECTION_ID); + crate::action_dispatch::dispatcher_for_connection( + conn_id, + self.window_id, + &self.workspace, + &self.focus_manager, + &self.remote_manager, + ) } /// Resolve remote connection parameters for a remote project. @@ -46,31 +53,33 @@ impl WindowView { project_id: &str, connection_id: &str, cx: &Context, - ) -> Option<(String, u16, String, String)> { + ) -> Option<( + String, + u16, + String, + Option, + String, + )> { let rm = self.remote_manager.as_ref()?.read(cx); let connections = rm.connections(); let (config, _, _) = connections.iter().find(|(c, _, _)| c.id == connection_id)?; - let token = config.saved_token.as_ref()?.clone(); + let token = config.effective_auth_token()?; let actual_id = okena_transport::client::strip_prefix(project_id, connection_id); - Some((config.host.clone(), config.port, token, actual_id)) + Some((config.host.clone(), config.port, token, config.local_endpoint.clone(), actual_id)) } - /// Build a GitProvider for the given project (local or remote). + /// Build a GitProvider for the given project (served by the local daemon). pub(super) fn build_git_provider( &self, project_id: &str, cx: &Context, ) -> Option> { - use crate::views::overlays::diff_viewer::provider::{LocalGitProvider, RemoteGitProvider}; + use crate::views::overlays::diff_viewer::provider::RemoteGitProvider; let ws = self.workspace.read(cx); let project = ws.project(project_id)?; - if project.is_remote { - let conn_id = project.connection_id.as_ref()?; - let (host, port, token, actual_id) = self.remote_params(project_id, conn_id, cx)?; - Some(std::sync::Arc::new(RemoteGitProvider::new(host, port, token, actual_id))) - } else { - Some(std::sync::Arc::new(LocalGitProvider::new(project.path.clone()))) - } + let conn_id = project.connection_id.as_ref()?; + let (host, port, token, local_endpoint, actual_id) = self.remote_params(project_id, conn_id, cx)?; + Some(std::sync::Arc::new(RemoteGitProvider::new(host, port, token, local_endpoint, actual_id, project.path.clone()))) } /// Resolve the focused terminal_id from this window's focus_manager and the @@ -120,7 +129,7 @@ impl WindowView { } } - /// Build a ProjectFs provider for the given project (local or remote). + /// Build a ProjectFs provider for the given project (served by the local daemon). fn build_project_fs( &self, project_id: &str, @@ -128,17 +137,11 @@ impl WindowView { ) -> Option> { let ws = self.workspace.read(cx); let project = ws.project(project_id)?; - if project.is_remote { - let conn_id = project.connection_id.as_ref()?; - let (host, port, token, actual_id) = self.remote_params(project_id, conn_id, cx)?; - Some(std::sync::Arc::new(okena_files::project_fs::RemoteProjectFs::new( - host, port, token, actual_id, project.name.clone(), - ))) - } else { - Some(std::sync::Arc::new(okena_files::project_fs::LocalProjectFs::new( - project.path.clone(), - ))) - } + let conn_id = project.connection_id.as_ref()?; + let (host, port, token, local_endpoint, actual_id) = self.remote_params(project_id, conn_id, cx)?; + Some(std::sync::Arc::new(okena_files::project_fs::RemoteProjectFs::new( + host, port, token, local_endpoint, actual_id, project.name.clone(), project.path.clone(), + ))) } /// Evict cached file viewers for projects that no longer exist. @@ -165,9 +168,9 @@ impl WindowView { }); } - /// Build a BlameProvider for the given project (local or remote). Returns - /// `None` only when project lookup fails — the provider itself surfaces - /// non-git / not-tracked errors at call time. + /// Build a BlameProvider for the given project (served by the local daemon). + /// Returns `None` only when project lookup fails — the provider itself + /// surfaces non-git / not-tracked errors at call time. pub(super) fn build_blame_provider( &self, project_id: &str, @@ -175,17 +178,11 @@ impl WindowView { ) -> Option> { let ws = self.workspace.read(cx); let project = ws.project(project_id)?; - if project.is_remote { - let conn_id = project.connection_id.as_ref()?; - let (host, port, token, actual_id) = self.remote_params(project_id, conn_id, cx)?; - Some(std::sync::Arc::new(okena_views_git::blame::RemoteBlameProvider::new( - host, port, token, actual_id, - ))) - } else { - Some(std::sync::Arc::new(okena_views_git::blame::LocalBlameProvider::new( - project.path.clone(), - ))) - } + let conn_id = project.connection_id.as_ref()?; + let (host, port, token, local_endpoint, actual_id) = self.remote_params(project_id, conn_id, cx)?; + Some(std::sync::Arc::new(okena_views_git::blame::RemoteBlameProvider::new( + host, port, token, local_endpoint, actual_id, + ))) } } @@ -198,30 +195,41 @@ impl WindowView { event: &crate::views::panels::toast::ToastActionEvent, cx: &mut Context, ) { - use crate::soft_close::{decode_action, KILL_PREFIX, UNDO_PREFIX}; + use crate::soft_close::{ + decode_action, KILL_PREFIX, RESTART_DAEMON_CANCEL_PREFIX, + RESTART_DAEMON_CONFIRM_PREFIX, UNDO_PREFIX, + }; use crate::workspace::toast::ToastManager; - if let Some((_project_id, terminal_id)) = decode_action(&event.action_id, UNDO_PREFIX) { - // The PTY is only restorable if it's still in the registry — if the - // shell exited on its own during the grace window there's nothing to - // bring back, and `undo_soft_close` just drops the pending record. - let alive = self.terminals.lock().contains_key(terminal_id.as_str()); - let ws = self.workspace.clone(); - let fm = self.focus_manager.clone(); - fm.update(cx, |fm, cx| { - ws.update(cx, |ws, cx| { - ws.undo_soft_close(fm, &terminal_id, alive, cx); - }); - cx.notify(); - }); + // Restart-daemon confirmation toast: dismiss either way, and run the + // restart only on confirm. Checked first since these action ids carry no + // `:project:terminal` payload (so the soft-close decoders skip them). + if event.action_id == RESTART_DAEMON_CONFIRM_PREFIX { + ToastManager::dismiss(&event.toast_id, cx); + self.perform_restart_daemon(cx); + return; + } + if event.action_id == RESTART_DAEMON_CANCEL_PREFIX { ToastManager::dismiss(&event.toast_id, cx); - } else if let Some((_project_id, terminal_id)) = + return; + } + + // The daemon owns the grace deadlines + kept-alive PTYs, so dispatch the + // undo/finalize to it (the project_id from `decode_action` is the + // connection-prefixed id, so `dispatcher_for_project` resolves it; the + // daemon does the alive-check + kill). The GUI mirror must not mutate + // these directly. + if let Some((project_id, terminal_id)) = decode_action(&event.action_id, UNDO_PREFIX) { + if let Some(dispatcher) = self.dispatcher_for_project(&project_id, cx) { + dispatcher.dispatch(ActionRequest::UndoSoftClose { terminal_id }, cx); + } + ToastManager::dismiss(&event.toast_id, cx); + } else if let Some((project_id, terminal_id)) = decode_action(&event.action_id, KILL_PREFIX) { - let ws = self.workspace.clone(); - ws.update(cx, |ws, cx| { - ws.finalize_soft_close(&terminal_id, cx); - }); + if let Some(dispatcher) = self.dispatcher_for_project(&project_id, cx) { + dispatcher.dispatch(ActionRequest::CloseTerminalNow { terminal_id }, cx); + } ToastManager::dismiss(&event.toast_id, cx); } } @@ -233,20 +241,43 @@ impl WindowView { cx: &mut Context, ) { match event { - OverlayManagerEvent::SwitchWorkspace(data) => { - self.handle_switch_workspace((**data).clone(), cx); + OverlayManagerEvent::SessionAction(action) => { + // Sessions are workspace-global and the daemon owns the session + // files + authoritative state, so route to the local daemon + // connection directly (no project to resolve a dispatcher from). + self.dispatch_to_local_daemon(action.clone(), cx); + } + OverlayManagerEvent::ProjectHooksChanged { project_id, hooks } => { + // The settings panel edited a project's hooks. Route through the + // project's dispatcher so the remote id prefix is stripped before + // the daemon (the authoritative owner) applies them. + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::UpdateProjectHooks { + project_id: project_id.clone(), + hooks: Box::new(hooks.clone()), + }, cx); + } } - OverlayManagerEvent::WorktreeCreated(new_project_id) => { - self.spawn_terminals_for_project(new_project_id.clone(), cx); + OverlayManagerEvent::WorktreeCreateRequested { project_id, branch, create_branch } => { + // The daemon creates the worktree, its project and its terminals; + // they mirror back. No local mirror mutation or PTY spawn here. + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::CreateWorktree { + project_id: project_id.clone(), + branch: branch.clone(), + create_branch: *create_branch, + }, cx); + } } OverlayManagerEvent::ShellSelected { shell_type, project_id, terminal_id } => { self.switch_terminal_shell(project_id, terminal_id, shell_type.clone(), cx); } OverlayManagerEvent::AddTerminal { project_id } => { - let dispatcher = self.dispatcher_for_project(project_id, cx); - dispatcher.dispatch(ActionRequest::CreateTerminal { - project_id: project_id.clone(), - }, cx); + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::CreateTerminal { + project_id: project_id.clone(), + }, cx); + } } OverlayManagerEvent::CreateWorktree { project_id, project_path } => { self.overlay_manager.update(cx, |om, cx| { @@ -267,23 +298,60 @@ impl WindowView { }); } OverlayManagerEvent::CloseWorktree { project_id } => { + let params = self.workspace.read(cx).project(project_id) + .and_then(|p| p.connection_id.clone()) + .and_then(|cid| self.remote_params(project_id, &cid, cx)); self.overlay_manager.update(cx, |om, cx| { - om.show_close_worktree_dialog(project_id.clone(), cx); + om.show_close_worktree_dialog(project_id.clone(), params, cx); }); } OverlayManagerEvent::DeleteProject { project_id } => { - // Collect hook terminal IDs before deleting so we can clean them from the registry - let hook_tids = self.workspace.read(cx).hook_terminal_ids_for_project(project_id); - let workspace = self.workspace.clone(); - let pid = project_id.clone(); - self.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.delete_project(fm, &pid, &settings(cx).hooks, cx); - }); - cx.notify(); - }); - for tid in hook_tids { - self.terminals.lock().remove(&tid); + // The daemon owns the project: dispatch DeleteProject and let the + // removal (incl. hook terminals) mirror back. The GUI must not + // mutate its read-only mirror directly. + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::DeleteProject { + project_id: project_id.clone(), + }, cx); + } + } + OverlayManagerEvent::ToggleProjectPinned { project_id } => { + // The daemon owns the authoritative `pinned` flag: dispatch and + // let the new state mirror back. + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::ToggleProjectPinned { + project_id: project_id.clone(), + }, cx); + } + } + OverlayManagerEvent::DeleteFolder { folder_id } => { + // Folders are owned by the daemon; resolve the connection from + // the folder id and dispatch DeleteFolder. The removal mirrors + // back. + if let Some(dispatcher) = self.dispatcher_for_folder(folder_id, cx) { + dispatcher.dispatch(ActionRequest::DeleteFolder { + folder_id: folder_id.clone(), + }, cx); + } + } + OverlayManagerEvent::RenameDirectoryConfirmed { project_id, new_name } => { + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::RenameProjectDirectory { + project_id: project_id.clone(), + new_name: new_name.clone(), + }, cx); + } + } + OverlayManagerEvent::AddDiscoveredWorktree { parent_project_id, worktree_path, branch } => { + // The daemon owns the project list: dispatch + // AddDiscoveredWorktree (resolving the connection from the + // parent project) and let the new worktree project mirror back. + if let Some(dispatcher) = self.dispatcher_for_project(parent_project_id, cx) { + dispatcher.dispatch(ActionRequest::AddDiscoveredWorktree { + parent_project_id: parent_project_id.clone(), + worktree_path: worktree_path.clone(), + branch: branch.clone(), + }, cx); } } OverlayManagerEvent::ConfigureHooks { project_id } => { @@ -292,10 +360,11 @@ impl WindowView { }); } OverlayManagerEvent::ReloadServices { project_id } => { - let dispatcher = self.dispatcher_for_project(project_id, cx); - dispatcher.dispatch(okena_core::api::ActionRequest::ReloadServices { - project_id: project_id.clone(), - }, cx); + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(okena_core::api::ActionRequest::ReloadServices { + project_id: project_id.clone(), + }, cx); + } } OverlayManagerEvent::QuickCreateWorktree { project_id } => { self.request_broker.update(cx, |broker, cx| { @@ -309,6 +378,26 @@ impl WindowView { sidebar.sync_remote_color(project_id, *color, cx); }); } + OverlayManagerEvent::WorktreeColorReset { project_id } => { + // The daemon owns the worktree color override: dispatch a clear + // and let the reset mirror back. + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::SetWorktreeColorOverride { + project_id: project_id.clone(), + color: None, + }, cx); + } + } + OverlayManagerEvent::FolderColorChanged { folder_id, color } => { + // The daemon owns the folder color: resolve the connection from + // the folder id and dispatch SetFolderColor. + if let Some(dispatcher) = self.dispatcher_for_folder(folder_id, cx) { + dispatcher.dispatch(ActionRequest::SetFolderColor { + folder_id: folder_id.clone(), + color: *color, + }, cx); + } + } OverlayManagerEvent::FocusParent { project_id } => { let parent_id = self.workspace.read(cx) .project(project_id) @@ -423,24 +512,26 @@ impl WindowView { cx.notify(); } OverlayManagerEvent::TerminalSplit { project_id, layout_path, direction } => { - let dispatcher = self.dispatcher_for_project(project_id, cx); - dispatcher.dispatch(ActionRequest::SplitTerminal { - project_id: project_id.clone(), - path: layout_path.clone(), - direction: *direction, - }, cx); + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::SplitTerminal { + project_id: project_id.clone(), + path: layout_path.clone(), + direction: *direction, + }, cx); + } } OverlayManagerEvent::TerminalClose { project_id, terminal_id } => { - let dispatcher = self.dispatcher_for_project(project_id, cx); - dispatcher.dispatch(ActionRequest::CloseTerminal { - project_id: project_id.clone(), - terminal_id: terminal_id.clone(), - }, cx); + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch(ActionRequest::CloseTerminal { + project_id: project_id.clone(), + terminal_id: terminal_id.clone(), + }, cx); + } } OverlayManagerEvent::TabClose { project_id, layout_path, tab_index } => { let terminal_ids = collect_tab_terminal_ids(&self.workspace, project_id, layout_path, cx); - if let Some(tid) = terminal_ids.get(*tab_index).cloned() { - let dispatcher = self.dispatcher_for_project(project_id, cx); + if let Some(tid) = terminal_ids.get(*tab_index).cloned() + && let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { dispatcher.dispatch(ActionRequest::CloseTerminal { project_id: project_id.clone(), terminal_id: tid, @@ -453,8 +544,8 @@ impl WindowView { .filter(|(i, _)| *i != *tab_index) .map(|(_, id)| id) .collect(); - if !to_close.is_empty() { - let dispatcher = self.dispatcher_for_project(project_id, cx); + if !to_close.is_empty() + && let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { dispatcher.dispatch(ActionRequest::CloseTerminals { project_id: project_id.clone(), terminal_ids: to_close, @@ -464,8 +555,8 @@ impl WindowView { OverlayManagerEvent::TabCloseToRight { project_id, layout_path, tab_index } => { let terminal_ids = collect_tab_terminal_ids(&self.workspace, project_id, layout_path, cx); let to_close: Vec = terminal_ids.into_iter().skip(tab_index + 1).collect(); - if !to_close.is_empty() { - let dispatcher = self.dispatcher_for_project(project_id, cx); + if !to_close.is_empty() + && let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { dispatcher.dispatch(ActionRequest::CloseTerminals { project_id: project_id.clone(), terminal_ids: to_close, @@ -512,33 +603,145 @@ impl WindowView { } } - /// Handle workspace switch from session manager. - pub(super) fn handle_switch_workspace(&mut self, data: crate::workspace::state::WorkspaceData, cx: &mut Context) { - // Kill all existing terminals - { - let terminals = self.terminals.lock(); - for terminal in terminals.values() { - self.backend.kill(&terminal.terminal_id); - } + /// Dispatch a workspace-global action (e.g. a session load/save/import/export) + /// to the local daemon connection. Unlike project actions there's no project + /// to resolve a dispatcher from, so it targets `LOCAL_DAEMON_CONNECTION_ID` + /// directly. The daemon owns session files + the authoritative workspace; for + /// load/import the swapped state mirrors back via the next snapshot. + pub(super) fn dispatch_to_local_daemon(&self, action: ActionRequest, cx: &mut Context) { + if let Some(ref rm) = self.remote_manager { + rm.update(cx, |rm, cx| { + rm.send_action(okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, action, cx); + }); } - self.terminals.lock().clear(); + } - // Clear project columns (will be recreated) - self.project_columns.clear(); + /// Show a confirmation toast before restarting the local daemon. Restarting + /// the daemon ends EVERY terminal session (the daemon owns all PTYs), so this + /// is gated behind an explicit, unmissable confirm rather than firing + /// immediately. The actual restart runs in [`Self::perform_restart_daemon`] + /// when the user clicks "Restart" (routed via [`Self::handle_toast_action`]). + pub(super) fn request_restart_daemon(&self, cx: &mut Context) { + use crate::workspace::toast::{Toast, ToastAction, ToastActionStyle, ToastManager}; - // Update workspace with new data - let workspace = self.workspace.clone(); - self.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.replace_data(fm, data, cx); - }); - cx.notify(); - }); + let actions = vec![ + ToastAction::new( + crate::soft_close::RESTART_DAEMON_CONFIRM_PREFIX, + "Restart", + ToastActionStyle::Danger, + ), + ToastAction::new( + crate::soft_close::RESTART_DAEMON_CANCEL_PREFIX, + "Cancel", + ToastActionStyle::Default, + ), + ]; + let toast = Toast::warning("Restart the daemon?") + .with_id(crate::soft_close::RESTART_DAEMON_TOAST_ID) + .with_detail("This ends all terminal sessions in every window.") + .with_ttl(std::time::Duration::from_secs(30)) + .with_actions(actions); + ToastManager::post(toast, cx); + } - // Sync project columns for new data - self.sync_project_columns(cx); + /// Restart the local daemon and reconnect to it (possibly on a new port). + /// + /// 1. POST `/v1/restart` to the current local-daemon endpoint (blocking + /// reqwest, off the GPUI thread). The daemon spawns a replacement (which + /// waits for this one to exit) and exits itself. + /// 2. Wait for the OLD daemon's pid to die, then poll `remote.json` until a + /// LIVE daemon advertises — this is the replacement, which may have bound + /// a DIFFERENT port (the old one can linger in TIME_WAIT). + /// 3. Back on the GPUI thread, re-point the local connection at the new port + /// (keeping the existing token when TCP auth is needed) and reconnect. + /// + /// Failure at any step toasts an error and leaves the connection alone (its + /// own reconnect/backoff still applies), so the GUI is never left wedged. + pub(super) fn perform_restart_daemon(&self, cx: &mut Context) { + use crate::workspace::toast::ToastManager; + use okena_transport::client::LOCAL_DAEMON_CONNECTION_ID; + + let Some(rm) = self.remote_manager.clone() else { + ToastManager::error("No local daemon connection to restart", cx); + return; + }; + + // Resolve the current local-daemon endpoint (host, port, token) so the + // background task can POST the restart and keep the token for reconnect. + let endpoint = { + let manager = rm.read(cx); + manager + .connections() + .into_iter() + .find(|(c, _, _)| c.id == LOCAL_DAEMON_CONNECTION_ID) + .map(|(c, _, _)| { + ( + c.host.clone(), + c.port, + c.saved_token.clone(), + c.local_endpoint.clone(), + ) + }) + }; + let Some((host, old_port, token, local_endpoint)) = endpoint else { + ToastManager::error("Local daemon connection not found", cx); + return; + }; - cx.notify(); + ToastManager::info("Restarting daemon…", cx); + + let rm = rm.downgrade(); + cx.spawn(async move |_this, cx| { + // The restart POST and the pid/discovery polling are blocking I/O, + // so run them on the blocking pool. + let outcome = cx + .background_executor() + .spawn(async move { + okena_remote_server::local::restart_local_daemon( + &host, + old_port, + local_endpoint.as_ref(), + ) + }) + .await; + + match outcome { + Ok(daemon) => { + let _ = rm.update(cx, |rm, cx| { + let next_config = okena_transport::client::RemoteConnectionConfig { + id: LOCAL_DAEMON_CONNECTION_ID.to_string(), + name: "Local".to_string(), + host: daemon.host().to_string(), + port: daemon.port, + saved_token: token.clone(), + token_obtained_at: None, + tls: daemon.tls, + pinned_cert_sha256: None, + local_endpoint: daemon.local_endpoint, + }; + rm.redirect_and_reconnect( + LOCAL_DAEMON_CONNECTION_ID, + next_config, + token, + cx, + ); + crate::workspace::toast::ToastManager::success( + "Daemon restarted; reconnecting…", + cx, + ); + }); + } + Err(msg) => { + let _ = rm.update(cx, |_rm, cx| { + crate::workspace::toast::ToastManager::error( + format!("Daemon restart failed: {msg}"), + cx, + ); + }); + } + } + }) + .detach(); } /// Flush pending saves, spawn a new Okena process for `id`, then quit. @@ -550,13 +753,14 @@ impl WindowView { gs.0.read(cx).flush_pending_save(); } - // 2. Flush workspace - if let Some(gw) = cx.try_global::() - && let Err(e) = persistence::save_workspace(gw.0.read(cx).data()) { - log::error!("Failed to flush workspace before profile switch: {e}"); - } + // NOTE: do NOT flush workspace.json here. The GUI is a daemon client and + // its Workspace is a read-only mirror (prefixed ids) — the daemon is the + // single writer (§5). Writing it would clobber the daemon's file with + // mirror garbage (see the quit handlers in src/main.rs). The current + // profile's daemon owns its own persistence; the relaunch below starts + // the new profile's daemon. - // 3. Spawn current_exe with --profile . Strip any existing --profile arg + // 2. Spawn current_exe with --profile . Strip any existing --profile arg // so we don't double-pass it. match std::env::current_exe() { Ok(exe) => { @@ -656,6 +860,14 @@ impl WindowView { }); } } + ProjectOverlayKind::FileViewer { relative_path } => { + if let Some(fs) = self.build_project_fs(&project_id, cx) { + let blame = self.build_blame_provider(&project_id, cx); + self.overlay_manager.update(cx, |om, cx| { + om.show_file_viewer(relative_path, fs, blame, cx); + }); + } + } ProjectOverlayKind::ColorPicker { position } => { self.overlay_manager.update(cx, |om, cx| { om.show_color_picker( @@ -666,8 +878,11 @@ impl WindowView { }); } ProjectOverlayKind::WorktreeList { position } => { + let params = self.workspace.read(cx).project(&project_id) + .and_then(|p| p.connection_id.clone()) + .and_then(|cid| self.remote_params(&project_id, &cid, cx)); self.overlay_manager.update(cx, |om, cx| { - om.show_worktree_list(project_id, position, cx); + om.show_worktree_list(project_id, position, params, cx); }); } }, diff --git a/crates/okena-app/src/views/window/mod.rs b/crates/okena-app/src/views/window/mod.rs index 205db8303..1705bd1ad 100644 --- a/crates/okena-app/src/views/window/mod.rs +++ b/crates/okena-app/src/views/window/mod.rs @@ -4,11 +4,8 @@ mod render; mod sidebar; mod terminal_actions; -use crate::git::watcher::GitStatusWatcher; use crate::remote_client::manager::{RemoteConnectionManager, RemoteManagerEvent}; use crate::services::manager::ServiceManager; -use crate::terminal::backend::{TerminalBackend, LocalBackend}; -use crate::terminal::pty_manager::PtyManager; use crate::views::overlay_manager::OverlayManager; use crate::views::panels::project_column::ProjectColumn; use crate::views::sidebar_controller::SidebarController; @@ -107,7 +104,6 @@ pub struct WindowView { focus_manager: Entity, workspace: Entity, request_broker: Entity, - backend: Arc, terminals: TerminalsRegistry, sidebar: Entity, /// Sidebar state controller @@ -135,8 +131,6 @@ pub struct WindowView { hscroll_bounds: Rc>>>, /// Remote connection manager (set after creation) remote_manager: Option>, - /// Git status watcher (set by Okena after creation) - git_watcher: Option>, /// Whether the pane switcher overlay is active pane_switch_active: bool, /// Pane switcher overlay entity (separate entity for proper focus handling) @@ -170,7 +164,6 @@ impl WindowView { pub fn new( window_id: WindowId, workspace: Entity, - pty_manager: Arc, terminals: TerminalsRegistry, window: &mut Window, cx: &mut Context, @@ -271,15 +264,10 @@ impl WindowView { let last_data_replacement_epoch = workspace.read(cx).data_replacement_epoch(); - // Wrap PtyManager in LocalBackend for the TerminalBackend trait - let backend: Arc = Arc::new(LocalBackend::new(pty_manager)); - // Wire up sidebar callbacks { let workspace_for_dispatch = workspace.clone(); let focus_manager_for_dispatch = focus_manager.clone(); - let backend_for_dispatch = backend.clone(); - let terminals_for_dispatch = terminals.clone(); sidebar.update(cx, |s, _cx| { // Dispatch action callback s.set_dispatch_action(Box::new(move |project_id, action, cx| { @@ -288,24 +276,12 @@ impl WindowView { window_id, &workspace_for_dispatch, &focus_manager_for_dispatch, - &Some(backend_for_dispatch.clone()), - &terminals_for_dispatch, - &None, // service_manager - wired later &None, // remote_manager - wired later cx, ) { dispatcher.dispatch(action, cx); } })); - - // Settings callback - s.set_settings(Box::new(|cx| { - let app_settings = crate::settings::settings(cx); - okena_views_sidebar::SidebarSettings { - worktree_path_template: app_settings.worktree.path_template.clone(), - hooks: app_settings.hooks.clone(), - } - })); }); } @@ -314,7 +290,6 @@ impl WindowView { focus_manager, workspace, request_broker, - backend, terminals, sidebar, sidebar_ctrl, @@ -334,7 +309,6 @@ impl WindowView { hscroll_bounds: Rc::new(RefCell::new(None)), service_manager: None, remote_manager: None, - git_watcher: None, pane_switch_active: false, pane_switcher_entity: None, last_scroll_project: None, @@ -472,14 +446,6 @@ impl WindowView { self.center_next_navigation = true; } - /// Set the git watcher entity (called by Okena after creation). - pub fn set_git_watcher(&mut self, watcher: Entity, cx: &mut Context) { - self.git_watcher = Some(watcher); - // Drop existing local columns so they get recreated with the watcher - self.project_columns.retain(|id, _| id.starts_with("remote:")); - self.sync_project_columns(cx); - } - /// Set the remote connection manager (called after creation by Okena). pub fn set_remote_manager(&mut self, manager: Entity, cx: &mut Context) { // Observe remote manager and sync remote projects into workspace @@ -534,10 +500,16 @@ impl WindowView { })); }); + self.status_bar.update(cx, |status_bar, cx| { + status_bar.set_remote_manager(manager.clone(), cx); + }); + // Observe remote manager for sidebar updates let sidebar_for_observe = self.sidebar.clone(); + let status_bar_for_observe = self.status_bar.clone(); cx.observe(&manager, move |_this, _rm, cx| { sidebar_for_observe.update(cx, |_, cx| cx.notify()); + status_bar_for_observe.update(cx, |_, cx| cx.notify()); }).detach(); // Repaint the sidebar on remote terminal activity (bell / idle). @@ -549,9 +521,14 @@ impl WindowView { // observer above. let sidebar_for_activity = self.sidebar.clone(); cx.subscribe(&manager, move |_this, _rm, event, cx| match event { - RemoteManagerEvent::TerminalActivity => { + // The payload (advanced terminal ids) is for `Okena`'s + // notification drain; the sidebar re-reads every terminal's + // bell/idle flags, so it just repaints. + RemoteManagerEvent::TerminalActivity(_) => { sidebar_for_activity.update(cx, |_, cx| cx.notify()); } + // Local-daemon self-heal is driven by `Okena`, not the sidebar. + RemoteManagerEvent::LocalConnectionFailed => {} }).detach(); } @@ -584,15 +561,16 @@ impl WindowView { self.service_manager = Some(manager); } - /// Rebuild the sidebar dispatch action callback with current service/remote managers. + /// Rebuild the sidebar dispatch action callback with the current remote manager. fn rebuild_sidebar_dispatch(&self, cx: &mut Context) { let workspace = self.workspace.clone(); let focus_manager = self.focus_manager.clone(); - let backend = self.backend.clone(); - let terminals = self.terminals.clone(); - let service_manager = self.service_manager.clone(); let remote_manager = self.remote_manager.clone(); let window_id = self.window_id; + // Separate clones for the connection-targeted dispatch closure below. + let workspace_conn = self.workspace.clone(); + let focus_manager_conn = self.focus_manager.clone(); + let remote_manager_conn = self.remote_manager.clone(); self.sidebar.update(cx, |s, _cx| { s.set_dispatch_action(Box::new(move |project_id, action, cx| { if let Some(dispatcher) = crate::action_dispatch::dispatcher_for_project( @@ -600,15 +578,26 @@ impl WindowView { window_id, &workspace, &focus_manager, - &Some(backend.clone()), - &terminals, - &service_manager, &remote_manager, cx, ) { dispatcher.dispatch(action, cx); } })); + // Folder-scoped / workspace-global dispatch (no project to resolve a + // connection from). The sidebar resolves the connection id from the + // folder prefix (or uses the local daemon) and calls this. + s.set_dispatch_for_connection(Box::new(move |conn_id, action, cx| { + if let Some(dispatcher) = crate::action_dispatch::dispatcher_for_connection( + conn_id, + window_id, + &workspace_conn, + &focus_manager_conn, + &remote_manager_conn, + ) { + dispatcher.dispatch(action, cx); + } + })); }); } @@ -700,21 +689,43 @@ impl WindowView { .collect(); self.project_columns.retain(|id, _| visible_ids.contains(id.as_str())); - // Create columns for new projects - for (project_id, is_remote, connection_id) in &visible_projects { - if !self.project_columns.contains_key(project_id) { - let entity = if *is_remote { + // Create columns for new projects. Every project is a remote project + // of the local daemon. + for (project_id, _is_remote, connection_id) in &visible_projects { + if !self.project_columns.contains_key(project_id) + && let Some(entity) = self.create_remote_column(project_id, connection_id.as_deref(), cx) - } else { - Some(self.create_local_column(project_id, cx)) - }; - if let Some(entity) = entity { - self.project_columns.insert(project_id.clone(), entity); - } + { + self.project_columns.insert(project_id.clone(), entity); + self.request_git_poll_for_visible_project(project_id, cx); } } } + /// Ask the daemon to refresh a project's git/PR/CI status now that it just + /// became visible in this window. Per-window visibility is client-owned, so + /// the daemon doesn't otherwise know the project is on screen until a + /// terminal subscribes — without this, its badge waits for the next poll + /// cycle. Mirrors the web client's request-on-visible. Fire-and-forget: the + /// fresh status arrives over the git-status stream, not this action's reply. + fn request_git_poll_for_visible_project(&self, project_id: &str, cx: &mut Context) { + if let Some(dispatcher) = crate::action_dispatch::dispatcher_for_project( + project_id, + self.window_id, + &self.workspace, + &self.focus_manager, + &self.remote_manager, + cx, + ) { + dispatcher.dispatch( + okena_core::api::ActionRequest::GitStatus { + project_id: project_id.to_string(), + }, + cx, + ); + } + } + /// Create a ProjectColumn for a remote project. fn create_remote_column( &self, @@ -770,68 +781,6 @@ impl WindowView { })) } - /// Create a ProjectColumn for a local project. - fn create_local_column( - &self, - project_id: &str, - cx: &mut Context, - ) -> Entity { - let workspace_clone = self.workspace.clone(); - let focus_manager_clone = self.focus_manager.clone(); - let request_broker_clone = self.request_broker.clone(); - let terminals_clone = self.terminals.clone(); - let active_drag_clone = self.active_drag.clone(); - let id = project_id.to_string(); - let backend_clone = self.backend.clone(); - let workspace_for_dispatch = self.workspace.clone(); - let focus_manager_for_dispatch = self.focus_manager.clone(); - let backend_for_dispatch = self.backend.clone(); - let terminals_for_dispatch = self.terminals.clone(); - let git_watcher = self.git_watcher.clone(); - - let git_provider = match self.build_git_provider(project_id, cx) { - Some(p) => p, - None => { - log::warn!("Cannot build git provider for project {}", project_id); - let path = self.workspace.read(cx).project(project_id) - .map(|p| p.path.clone()) - .unwrap_or_default(); - Arc::new(okena_views_git::diff_viewer::provider::LocalGitProvider::new(path)) - } - }; - - let window_id = self.window_id; - let entity = cx.new(move |cx| { - let mut col = ProjectColumn::new( - window_id, - workspace_clone, - focus_manager_clone, - request_broker_clone, - id, - backend_clone, - terminals_clone, - active_drag_clone, - git_watcher, - git_provider, - cx, - ); - col.set_action_dispatcher(Some( - crate::action_dispatch::ActionDispatcher::Local { - workspace: workspace_for_dispatch, - focus_manager: focus_manager_for_dispatch, - backend: backend_for_dispatch, - terminals: terminals_for_dispatch, - service_manager: None, // set later via set_service_manager - window_id, - }, - )); - col - }); - if let Some(ref sm) = self.service_manager { - entity.update(cx, |col, cx| col.set_service_manager(sm.clone(), cx)); - } - entity - } } impl_focusable!(WindowView); diff --git a/crates/okena-app/src/views/window/render.rs b/crates/okena-app/src/views/window/render.rs index ec98e828e..4c19fb4cf 100644 --- a/crates/okena-app/src/views/window/render.rs +++ b/crates/okena-app/src/views/window/render.rs @@ -1,8 +1,9 @@ -use crate::keybindings::{ShowKeybindings, ShowSessionManager, ShowThemeSelector, ShowCommandPalette, ShowSettings, OpenSettingsFile, ShowFileSearch, ShowContentSearch, ShowProjectSwitcher, ShowDiffViewer, ReviewChanges, ShowHookLog, ShowLogConsole, NewProject, NewWindow, CloseWindow, ToggleSidebar, ToggleSidebarAutoHide, TogglePaneSwitcher, CreateWorktree, CheckForUpdates, InstallUpdate, FocusSidebar, FocusActiveProject, ShowPairingDialog, StartAllServices, StopAllServices, ClearFocus, EqualizeLayout, ToggleProjectLayout, ShowBranchSwitcher, ShowProfileManager}; +use crate::keybindings::{ShowKeybindings, ShowSessionManager, ShowThemeSelector, ShowCommandPalette, ShowSettings, OpenSettingsFile, ShowFileSearch, ShowContentSearch, ShowProjectSwitcher, ShowDiffViewer, ReviewChanges, ShowHookLog, ShowLogConsole, NewProject, NewWindow, CloseWindow, ToggleSidebar, ToggleSidebarAutoHide, TogglePaneSwitcher, CreateWorktree, CheckForUpdates, InstallUpdate, FocusSidebar, FocusActiveProject, ShowPairingDialog, StartAllServices, StopAllServices, ClearFocus, EqualizeLayout, ToggleProjectLayout, ShowBranchSwitcher, ShowProfileManager, RestartDaemon}; use crate::settings::{open_settings_file, settings_entity}; use crate::theme::theme; use crate::views::layout::navigation::{get_pane_map, prune_pane_map}; use crate::views::layout::split_pane::{compute_resize, render_project_divider, render_sidebar_divider, DragState}; +use crate::views::overlays::pairing_dialog::PairingEndpoint; use crate::workspace::requests::{OverlayRequest, ProjectOverlay, ProjectOverlayKind}; use crate::ui::tokens::{ui_text_md, ui_text_xl}; use gpui::*; @@ -618,16 +619,26 @@ impl Render for WindowView { let active_drag = active_drag.clone(); let terminals = self.terminals.clone(); let workspace = workspace.clone(); + let window_id = self.window_id; + let focus_manager = self.focus_manager.clone(); + let remote_manager = self.remote_manager.clone(); move |_bounds, _prepaint, window, _cx| { let active_drag = active_drag.clone(); let terminals = terminals.clone(); let workspace = workspace.clone(); + let focus_manager = focus_manager.clone(); + let remote_manager = remote_manager.clone(); window.on_mouse_event(move |e: &MouseUpEvent, phase, _window, cx| { if phase == DispatchPhase::Bubble && e.button == MouseButton::Left { - let was_split_drag = matches!( - *active_drag.borrow(), - Some(DragState::Split { .. }) - ); + // Snapshot the dragged split's coordinates before + // clearing the drag, so we can commit its final + // sizes to the daemon below. + let split_drag = match &*active_drag.borrow() { + Some(DragState::Split { project_id, layout_path, .. }) => { + Some((project_id.clone(), layout_path.clone())) + } + _ => None, + }; let was_dragging = active_drag.borrow().is_some(); *active_drag.borrow_mut() = None; @@ -638,11 +649,41 @@ impl Render for WindowView { } } - // Persist final split sizes (drag used ui_only notify) - if was_split_drag { - workspace.update(cx, |ws, cx| { - ws.notify_data(cx); - }); + // Commit the final split sizes to the daemon. The + // drag only updated the local mirror (ui_only + // notify); without this the dragged ratios persist + // nowhere and revert on reconnect / restart / + // second client. + if let Some((project_id, layout_path)) = split_drag { + let final_sizes = workspace + .read(cx) + .project(&project_id) + .and_then(|p| p.layout.as_ref()) + .and_then(|layout| layout.get_at_path(&layout_path)) + .and_then(|node| match node { + okena_workspace::state::LayoutNode::Split { sizes, .. } => { + Some(sizes.clone()) + } + _ => None, + }); + if let Some(sizes) = final_sizes + && let Some(dispatcher) = + crate::action_dispatch::dispatcher_for_project( + &project_id, + window_id, + &workspace, + &focus_manager, + &remote_manager, + cx, + ) + { + dispatcher.commit_split_sizes( + &project_id, + &layout_path, + sizes, + cx, + ); + } } } }); @@ -839,11 +880,34 @@ impl Render for WindowView { overlay_manager.update(cx, |om, cx| om.toggle_profile_manager(cx)); } })) + // Handle restart-daemon action (command palette only; destructive, so + // no default chord). Shows a confirmation toast before restarting — + // restarting the daemon ends every terminal session. + .on_action(cx.listener(|this, _: &RestartDaemon, _window, cx| { + this.request_restart_daemon(cx); + })) // Handle show pairing dialog action .on_action(cx.listener({ let overlay_manager = overlay_manager.clone(); - move |_this, _: &ShowPairingDialog, _window, cx| { - overlay_manager.update(cx, |om, cx| om.toggle_pairing_dialog(cx)); + move |this, _: &ShowPairingDialog, _window, cx| { + let endpoint = this.remote_manager.as_ref().and_then(|rm| { + let manager = rm.read(cx); + manager + .connections() + .into_iter() + .find(|(config, _, _)| { + config.id == okena_transport::client::LOCAL_DAEMON_CONNECTION_ID + }) + .and_then(|(config, _, _)| { + config.effective_auth_token().map(|token| PairingEndpoint { + host: config.host.clone(), + port: config.port, + token, + local_endpoint: config.local_endpoint.clone(), + }) + }) + }); + overlay_manager.update(cx, |om, cx| om.toggle_pairing_dialog(endpoint, cx)); } })) // Handle new project action @@ -862,25 +926,16 @@ impl Render for WindowView { .on_action(cx.listener(|_this, _: &CheckForUpdates, _window, cx| { if let Some(update_info) = cx.try_global::() { let info = update_info.0.clone(); - - // Prevent concurrent manual checks - if !info.try_start_manual() { - return; - } - info.set_status(okena_ext_updater::UpdateStatus::Checking); - let token = info.current_token(); cx.notify(); cx.spawn(async move |this, cx| { - okena_ext_updater::orchestrator::run_manual_check( - info, - token, - cx, - move |cx| { - let _ = this.update(cx, |_, cx| cx.notify()); - }, - ) - .await; + match smol::unblock(okena_ext_updater::daemon_client::request_check).await { + Ok(snapshot) => info.apply_snapshot(snapshot), + Err(e) => info.set_status(okena_ext_updater::UpdateStatus::Failed { + error: e.to_string(), + }), + } + let _ = this.update(cx, |_, cx| cx.notify()); }) .detach(); } @@ -889,24 +944,15 @@ impl Render for WindowView { .on_action(cx.listener(|_this, _: &InstallUpdate, _window, cx| { if let Some(update_info) = cx.try_global::() { let info = update_info.0.clone(); - if let okena_ext_updater::UpdateStatus::Ready { version, path } = info.status() { - info.set_status(okena_ext_updater::UpdateStatus::Installing { - version: version.clone(), - }); - cx.notify(); - cx.spawn(async move |this, cx| { - okena_ext_updater::orchestrator::run_install( - info, - version, - path, - cx, - move |cx| { - let _ = this.update(cx, |_, cx| cx.notify()); - }, - ) - .await; - }).detach(); - } + cx.spawn(async move |this, cx| { + match smol::unblock(okena_ext_updater::daemon_client::request_install).await { + Ok(snapshot) => info.apply_snapshot(snapshot), + Err(e) => info.set_status(okena_ext_updater::UpdateStatus::Failed { + error: e.to_string(), + }), + } + let _ = this.update(cx, |_, cx| cx.notify()); + }).detach(); } })) // Handle toggle pane switcher action @@ -1037,19 +1083,26 @@ impl Render for WindowView { .map(|p| p.id.clone()) }); project_id.and_then(|pid| { + let review_base = ws + .remote_snapshot(&pid) + .and_then(|s| s.git_status.as_ref()) + .and_then(|g| g.review_base.clone()); ws.projects() .iter() .find(|p| p.id == pid) - .map(move |p| (pid, p.path.clone(), p.is_remote)) + .map(move |p| (pid, p.path.clone(), p.is_remote, review_base)) }) }; - let Some((project_id, project_path, is_remote)) = target else { + let Some((project_id, project_path, is_remote, review_base)) = target else { return; }; let mode = if is_remote { - None + review_base.map(|base| crate::git::DiffMode::BranchCompare { + base, + head: "HEAD".to_string(), + }) } else { crate::git::resolve_review_base(std::path::Path::new(&project_path)) .map(|base| crate::git::DiffMode::BranchCompare { diff --git a/crates/okena-app/src/views/window/terminal_actions.rs b/crates/okena-app/src/views/window/terminal_actions.rs index 7d720ac7c..546c5c0f4 100644 --- a/crates/okena-app/src/views/window/terminal_actions.rs +++ b/crates/okena-app/src/views/window/terminal_actions.rs @@ -1,27 +1,12 @@ -use crate::settings::settings; -use crate::terminal::terminal::{Terminal, TerminalSize}; -use crate::views::panels::toast::ToastManager; -use crate::workspace::actions::execute::spawn_uninitialized_terminals; -use crate::workspace::hooks; use gpui::*; -use std::sync::Arc; use super::WindowView; impl WindowView { - /// Spawn terminals for all layout slots in a project that have terminal_id: None - /// Used after creating a worktree project to immediately populate terminals - pub(super) fn spawn_terminals_for_project(&mut self, project_id: String, cx: &mut Context) { - let backend = self.backend.clone(); - let terminals = self.terminals.clone(); - self.workspace.update(cx, |ws, cx| { - spawn_uninitialized_terminals(ws, &project_id, &*backend, &terminals, cx); - }); - self.sync_project_columns(cx); - } - - /// Switch terminal shell - kills old terminal and creates new one with the new shell. - /// Used when user selects a different shell from the shell selector overlay. + /// Switch terminal shell — dispatches to the daemon, which kills the old PTY + /// and respawns at the same layout path with the new shell (resolving the + /// default chain + applying shell-wrapper/on_create hooks). The new terminal + /// id arrives via the next state snapshot. pub(super) fn switch_terminal_shell( &mut self, project_id: &str, @@ -29,102 +14,15 @@ impl WindowView { shell_type: crate::terminal::shell_config::ShellType, cx: &mut Context, ) { - // Get project path and terminal's layout path - let (project_path, layout_path) = { - let ws = self.workspace.read(cx); - let project = match ws.project(project_id) { - Some(p) => p, - None => { - log::error!("switch_terminal_shell: Project {} not found", project_id); - return; - } - }; - let layout_path = match project.layout.as_ref().and_then(|l| l.find_terminal_path(old_terminal_id)) { - Some(p) => p, - None => { - log::error!("switch_terminal_shell: Terminal {} not found in project {}", old_terminal_id, project_id); - return; - } - }; - (project.path.clone(), layout_path) - }; - - // Get current shell to check if it's actually changing - let current_shell = self.workspace.read(cx).get_terminal_shell(project_id, &layout_path); - if current_shell.as_ref() == Some(&shell_type) { - log::info!("switch_terminal_shell: Shell type unchanged, skipping"); - return; - } - - // Kill the old terminal - self.backend.kill(old_terminal_id); - self.terminals.lock().remove(old_terminal_id); - - // Update shell type in workspace state - self.workspace.update(cx, |ws, cx| { - ws.set_terminal_shell(project_id, &layout_path, shell_type.clone(), cx); - }); - - // Determine the actual shell to use (resolve Default → project default → global default) - let mut actual_shell = shell_type.resolve_default( - self.workspace.read(cx).project(project_id).and_then(|p| p.default_shell.as_ref()), - &settings(cx).default_shell, - ); - - // Get project info for hooks - let (project_name, project_hooks, parent_hooks, is_worktree, folder_id, folder_name) = { - let ws = self.workspace.read(cx); - let project = ws.project(project_id); - let name = project.map(|p| p.name.clone()).unwrap_or_default(); - let hooks_cfg = project.map(|p| p.hooks.clone()).unwrap_or_default(); - let parent = project - .and_then(|p| p.worktree_info.as_ref()) - .and_then(|wt| ws.project(&wt.parent_project_id)) - .map(|p| p.hooks.clone()); - let is_wt = project.map(|p| p.worktree_info.is_some()).unwrap_or(false); - let folder = ws.folder_for_project_or_parent(project_id); - let fid = folder.map(|f| f.id.clone()); - let fname = folder.map(|f| f.name.clone()); - (name, hooks_cfg, parent, is_wt, fid, fname) - }; - - let env = hooks::terminal_hook_env(project_id, &project_name, &project_path, is_worktree, folder_id.as_deref(), folder_name.as_deref()); - - // Apply shell_wrapper if configured - let global_hooks = settings(cx).hooks; - if let Some(wrapper) = hooks::resolve_shell_wrapper(&project_hooks, parent_hooks.as_ref(), &global_hooks) { - actual_shell = hooks::apply_shell_wrapper(&actual_shell, &wrapper, &env); - } - - // Apply on_create: wrap shell to run command first, then exec into shell - if let Some(cmd) = hooks::resolve_terminal_on_create(&project_hooks, parent_hooks.as_ref(), &settings(cx).hooks, cx) { - actual_shell = hooks::apply_on_create(&actual_shell, &cmd, &env); - } - - // Create new terminal with the new shell - match self.backend.create_terminal(&project_path, Some(&actual_shell)) { - Ok(new_terminal_id) => { - log::info!("switch_terminal_shell: Switched to {:?}, new terminal_id: {}", actual_shell, new_terminal_id); - - // Update terminal_id in workspace state - self.workspace.update(cx, |ws, cx| { - ws.set_terminal_id(project_id, &layout_path, new_terminal_id.clone(), cx); - }); - - // Create terminal wrapper and register it - let size = TerminalSize::default(); - let terminal = Arc::new(Terminal::new( - new_terminal_id.clone(), - size, - self.backend.transport(), - project_path.clone(), - )); - self.terminals.lock().insert(new_terminal_id, terminal); - } - Err(e) => { - log::error!("switch_terminal_shell: Failed to create terminal with new shell: {}", e); - ToastManager::error(format!("Failed to create terminal: {}", e), cx); - } + if let Some(dispatcher) = self.dispatcher_for_project(project_id, cx) { + dispatcher.dispatch( + okena_core::api::ActionRequest::SwitchTerminalShell { + project_id: project_id.to_string(), + terminal_id: old_terminal_id.to_string(), + shell: shell_type, + }, + cx, + ); } } @@ -147,7 +45,10 @@ impl WindowView { ws.project(&id).map(|p| { let project_path = p.path.clone(); let is_worktree = p.worktree_info.is_some(); - let is_git = crate::git::is_git_repo(std::path::Path::new(&project_path)); + let is_git = ws + .remote_snapshot(&id) + .and_then(|s| s.git_status.as_ref()) + .is_some(); (id, project_path, is_git, is_worktree) }) }) diff --git a/crates/okena-cli/Cargo.toml b/crates/okena-cli/Cargo.toml index 3ccf95c4a..5e7a010e0 100644 --- a/crates/okena-cli/Cargo.toml +++ b/crates/okena-cli/Cargo.toml @@ -23,7 +23,3 @@ base64 = "0.22" rand = "0.8" uuid = { version = "1.10", features = ["v4"] } dirs = "5.0" - -[target.'cfg(unix)'.dependencies] -# Liveness check for the running server's PID (libc::kill on unix). -libc = "0.2" diff --git a/crates/okena-cli/src/commands.rs b/crates/okena-cli/src/commands.rs index 666b38854..c623d6d20 100644 --- a/crates/okena-cli/src/commands.rs +++ b/crates/okena-cli/src/commands.rs @@ -52,7 +52,7 @@ pub fn cli_pair() -> i32 { } pub fn cli_health(json_mode: bool) -> i32 { - let (host, port) = match discover_server() { + let server = match discover_server() { Ok(v) => v, Err(e) => { eprintln!("{e}"); @@ -60,8 +60,14 @@ pub fn cli_health(json_mode: bool) -> i32 { } }; - let url = format!("http://{}:{}/health", host, port); - let resp = match reqwest::blocking::Client::new() + let (client, url) = match server.client_and_url("/health") { + Ok(v) => v, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + let resp = match client .get(&url) .timeout(std::time::Duration::from_secs(5)) .send() @@ -1938,6 +1944,16 @@ pub fn cli_command_list(json_mode: bool) -> i32 { } let v: serde_json::Value = serde_json::from_str(&resp).unwrap_or(serde_json::Value::Null); if let Some(arr) = v.get("actions").and_then(|a| a.as_array()) { + if arr.is_empty() { + // The headless daemon (okena-daemon) has no GUI action registry, so + // it returns an empty list. Explain it on stderr (stdout stays empty + // for clean parsing) so an empty result isn't mistaken for an error + // or a parse failure — mirrors `command run`'s explicit rejection. + eprintln!( + "No actions available — the command palette requires a running GUI window and is unavailable when connected to a headless daemon." + ); + return 0; + } for a in arr { let g = |k: &str| a.get(k).and_then(|x| x.as_str()).unwrap_or(""); // category \t name \t description diff --git a/crates/okena-cli/src/lib.rs b/crates/okena-cli/src/lib.rs index 6658508d0..98973c368 100644 --- a/crates/okena-cli/src/lib.rs +++ b/crates/okena-cli/src/lib.rs @@ -3,6 +3,7 @@ pub mod parser; pub mod register; pub mod resolve; +use okena_core::process::is_process_alive; use okena_workspace::persistence::config_dir; use clap::Parser as _; use parser::{ @@ -11,6 +12,7 @@ use parser::{ }; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use okena_transport::client::LocalEndpoint; /// CLI config stored in `~/.config/okena/cli.json`. #[derive(Serialize, Deserialize)] @@ -229,9 +231,33 @@ fn save_cli_config(config: &CliConfig) -> Result<(), String> { Ok(()) } +/// A running Okena instance discovered from `remote.json`. +pub(crate) struct DiscoveredServer { + pub host: String, + pub port: u16, + local_endpoint: Option, +} + +impl DiscoveredServer { + pub fn client_and_url(&self, path: &str) -> Result<(reqwest::blocking::Client, String), String> { + #[cfg(unix)] + if let Some(LocalEndpoint::UnixSocket { path: socket_path }) = &self.local_endpoint { + let client = reqwest::blocking::Client::builder() + .unix_socket(socket_path.as_str()) + .build() + .map_err(|e| format!("Cannot initialise Unix socket client: {e}"))?; + return Ok((client, format!("http://okena.local{path}"))); + } + + Ok(( + reqwest::blocking::Client::new(), + format!("http://{}:{}{}", self.host, self.port, path), + )) + } +} + /// Discover a running Okena instance by reading `remote.json`. -/// Returns `(host, port)`. -fn discover_server() -> Result<(String, u16), String> { +fn discover_server() -> Result { let path = config_dir().join("remote.json"); let data = std::fs::read_to_string(&path).map_err(|_| "Okena is not running (no remote.json).")?; @@ -241,26 +267,24 @@ fn discover_server() -> Result<(String, u16), String> { let port = json .get("port") .and_then(|v| v.as_u64()) - .ok_or("Missing port in remote.json.")? as u16; + .ok_or("Missing port in remote.json.") + .and_then(|port| u16::try_from(port).map_err(|_| "Invalid port in remote.json."))?; + let host = json + .get("local_host") + .and_then(|v| v.as_str()) + .filter(|host| !host.is_empty()) + .unwrap_or("127.0.0.1") + .to_string(); + let local_endpoint = json + .get("local_endpoint") + .and_then(|value| serde_json::from_value::(value.clone()).ok()); let pid = json.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32; if pid != 0 && !is_process_alive(pid) { return Err("Okena is not running (stale remote.json).".to_string()); } - Ok(("127.0.0.1".to_string(), port)) -} - -fn is_process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(pid as i32, 0) == 0 } - } - #[cfg(not(unix))] - { - let _ = pid; - true - } + Ok(DiscoveredServer { host, port, local_endpoint }) } /// Ensure we have a valid token, auto-registering if needed. @@ -269,17 +293,16 @@ fn ensure_token() -> Result { // Try existing token if let Some(config) = load_cli_config() { // Quick validation: try an authenticated request - if let Ok((host, port)) = discover_server() { - let url = format!("http://{}:{}/v1/tokens", host, port); - let client = reqwest::blocking::Client::new(); - if let Ok(resp) = client + if let Ok(server) = discover_server() + && let Ok((client, url)) = server.client_and_url("/v1/tokens") + && let Ok(resp) = client .get(&url) .header("Authorization", format!("Bearer {}", config.token)) .timeout(std::time::Duration::from_secs(5)) .send() - && resp.status().is_success() { - return Ok(config.token); - } + && resp.status().is_success() + { + return Ok(config.token); } } @@ -288,9 +311,8 @@ fn ensure_token() -> Result { } fn api_get(path: &str, token: &str) -> Result { - let (host, port) = discover_server()?; - let url = format!("http://{}:{}{}", host, port, path); - let client = reqwest::blocking::Client::new(); + let server = discover_server()?; + let (client, url) = server.client_and_url(path)?; let resp = client .get(&url) .header("Authorization", format!("Bearer {}", token)) @@ -309,9 +331,8 @@ fn api_get(path: &str, token: &str) -> Result { } fn api_post(path: &str, token: &str, body: &str) -> Result { - let (host, port) = discover_server()?; - let url = format!("http://{}:{}{}", host, port, path); - let client = reqwest::blocking::Client::new(); + let server = discover_server()?; + let (client, url) = server.client_and_url(path)?; let resp = client .post(&url) .header("Authorization", format!("Bearer {}", token)) diff --git a/crates/okena-cli/src/register.rs b/crates/okena-cli/src/register.rs index e364d561e..47c799a52 100644 --- a/crates/okena-cli/src/register.rs +++ b/crates/okena-cli/src/register.rs @@ -1,90 +1,34 @@ use crate::{CliConfig, discover_server, save_cli_config}; -use okena_remote_server::auth::{self, PersistedToken}; +use okena_remote_server::local; use okena_workspace::persistence::config_dir; -/// Register a CLI token by writing directly to the token store. -/// Returns the plaintext bearer token. +/// Register a CLI token by minting one against the local `remote_secret`, then +/// notifying any running server to reload. Returns the plaintext bearer token. pub fn register() -> Result { - // 1. Read the app secret - let secret_path = auth::secret_path(); - let secret = std::fs::read(&secret_path).map_err(|_| { - "No Okena config found. Has Okena been started at least once?".to_string() - })?; - if secret.len() != 32 { - return Err("Invalid remote_secret (wrong size).".into()); - } - - // 2. Generate a random token (same format as AuthStore::try_pair) - use rand::Rng; - let mut token_bytes = [0u8; 32]; - rand::thread_rng().fill(&mut token_bytes); - let token = base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - token_bytes, - ); - - // 3. Compute HMAC - let token_hmac = auth::compute_hmac(&secret, token.as_bytes()); - let token_hmac_b64 = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &token_hmac, - ); - - // 4. Load existing tokens, append new one - let tokens_path = auth::tokens_path(); - let mut persisted: Vec = if let Ok(data) = std::fs::read_to_string(&tokens_path) - { - serde_json::from_str(&data).unwrap_or_default() - } else { - Vec::new() - }; - - let token_id = uuid::Uuid::new_v4().to_string(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + // Mint a token authorized by read access to the local 0600 `remote_secret`. + let minted = local::mint_local_token()?; - persisted.push(PersistedToken { - id: token_id.clone(), - token_hmac: token_hmac_b64, - created_at: now, - }); - - // 5. Write back - let json = serde_json::to_string_pretty(&persisted) - .map_err(|e| format!("Failed to serialize tokens: {e}"))?; - - if let Some(parent) = tokens_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - std::fs::write(&tokens_path, json.as_bytes()) - .map_err(|e| format!("Failed to write remote_tokens.json: {e}"))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - let _ = std::fs::set_permissions(&tokens_path, perms); - } - - // 6. Save CLI config + // Save CLI config so subsequent invocations reuse the token. save_cli_config(&CliConfig { - token: token.clone(), - token_id, - registered_at: now, + token: minted.token.clone(), + token_id: minted.token_id, + registered_at: minted.created_at, })?; - // 7. Notify running server to reload tokens - if let Ok((host, port)) = discover_server() { - let url = format!("http://{}:{}/v1/auth/reload", host, port); - let _ = reqwest::blocking::Client::new() + // Notify a running server to reload tokens (loopback-only route). A server + // that isn't running yet will pick the token up from disk at startup. + if let Ok(server) = discover_server() + && let Ok((client, url)) = server.client_and_url("/v1/auth/reload") { + let _ = client .post(&url) .timeout(std::time::Duration::from_secs(5)) .send(); } - eprintln!("Registered CLI access. Token saved to {}", config_dir().join("cli.json").display()); + eprintln!( + "Registered CLI access. Token saved to {}", + config_dir().join("cli.json").display() + ); - Ok(token) + Ok(minted.token) } diff --git a/crates/okena-cli/src/resolve.rs b/crates/okena-cli/src/resolve.rs index 54e8e4377..67584637e 100644 --- a/crates/okena-cli/src/resolve.rs +++ b/crates/okena-cli/src/resolve.rs @@ -306,6 +306,11 @@ mod tests { services: vec![], worktree_info: None, worktree_ids: vec![], + pinned: false, + last_activity_at: None, + default_shell: None, + hook_terminals: vec![], + hooks: Default::default(), } } diff --git a/crates/okena-core/src/api.rs b/crates/okena-core/src/api.rs index 76669fcf2..cc9d650b5 100644 --- a/crates/okena-core/src/api.rs +++ b/crates/okena-core/src/api.rs @@ -1,4 +1,5 @@ use crate::keys::SpecialKey; +use crate::shell::ShellType; use crate::theme::FolderColor; use crate::types::{DiffMode, SplitDirection}; use serde::{Deserialize, Serialize}; @@ -13,6 +14,14 @@ pub struct HealthResponse { pub uptime_secs: u64, } +/// Lightweight system metrics for remote status surfaces. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ApiSystemStats { + pub cpu_usage: f32, + pub memory_used_bytes: u64, + pub memory_total_bytes: u64, +} + /// GET /v1/state response #[derive(Clone, Serialize, Deserialize)] pub struct StateResponse { @@ -214,6 +223,52 @@ pub struct ApiGitStatus { /// Commits not yet pushed to `origin/` (`None` if never pushed). #[serde(default, skip_serializing_if = "Option::is_none")] pub unpushed: Option, + /// The base ref (e.g. `origin/main`) this branch is reviewed against, + /// driving the "Review changes" branch-vs-base diff chip. `None` if not + /// resolvable. Carried over the wire so daemon-client projects surface the + /// chip — without it the GUI hard-codes `None` and the chip never renders. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_base: Option, + /// The repository's default branch (e.g. `main`). Used to suppress the + /// redundant base label on the ahead/behind chip when the review base is + /// the default branch (the common case). Carried over the wire so the + /// daemon-client GUI can hide the label too — without it the GUI hard-codes + /// `None` and the label always shows. `None` when unresolved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_branch: Option, +} + +/// Wire projection of a daemon-originated toast, forwarded to thin clients so +/// the daemon-client GUI can surface notifications that the daemon itself has no +/// surface to show (e.g. lifecycle-hook failures from the daemon's +/// `HookMonitor`). +/// +/// Deliberately omits the local-only `Toast` fields: `created: Instant` and +/// `ttl: Duration` are not serde-serializable as-is, so the TTL travels as +/// `ttl_ms` and the client stamps a fresh `created` on receipt. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiToast { + pub id: String, + /// One of "success" | "error" | "warning" | "info". + pub level: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Time-to-live in milliseconds. + pub ttl_ms: u64, + /// Clickable actions (buttons). Empty for ordinary informational toasts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec, +} + +/// A clickable button on a wire toast. `id` is opaque (the client decodes it, +/// e.g. `soft_close_undo::`); `style` is one of +/// "default" | "primary" | "danger". +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiToastAction { + pub id: String, + pub label: String, + pub style: String, } #[derive(Clone, Serialize, Deserialize)] @@ -235,6 +290,120 @@ pub struct ApiProject { pub worktree_info: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub worktree_ids: Vec, + /// Whether this project is pinned to the top of the activity-sorted view. + /// Carried over the wire so daemon-client projects keep their pin marker + /// and stable pinned-tier ordering. + #[serde(default)] + pub pinned: bool, + /// Unix-millis timestamp of last meaningful activity, driving the + /// activity-sorted sidebar order. Without it daemon-client projects would + /// all sort as "no activity". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_activity_at: Option, + /// Per-project default shell override (so the shell picker reflects the + /// current selection for daemon-client projects). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_shell: Option, + /// Lifecycle-hook terminals shown in the service panel. Projected onto the + /// wire so daemon-client projects surface their hook terminals (the domain + /// `HookTerminalEntry` lives in `okena-state` and can't be referenced here + /// without a dependency cycle — see [`ApiHookTerminalEntry`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hook_terminals: Vec, + /// Per-project lifecycle-hook overrides. Carried so daemon-client clients + /// can display + edit the real hooks (the daemon, not the client, applies + /// them on PTY spawn). Empty for projects with no per-project overrides. + #[serde(default, skip_serializing_if = "ApiHooksConfig::is_empty")] + pub hooks: ApiHooksConfig, +} + +/// Wire mirror of `okena_state::HookTerminalStatus` (which can't be referenced +/// from `okena-core` without a dependency cycle). Converted via +/// `HookTerminalStatus::{to_api,from_api}` in `okena-state`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ApiHookTerminalStatus { + Running, + Succeeded, + Failed { exit_code: i32 }, +} + +/// Wire mirror of a hook terminal entry shown in the service panel. The +/// terminal id (the map key in the domain type) is inlined here as `terminal_id` +/// so the wire form is a flat list. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiHookTerminalEntry { + pub terminal_id: String, + pub label: String, + pub status: ApiHookTerminalStatus, + pub hook_type: String, + pub command: String, + pub cwd: String, +} + +/// Wire mirror of `okena_state::ProjectHooks`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ApiProjectHooks { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_open: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_close: Option, +} + +/// Wire mirror of `okena_state::TerminalHooks`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ApiTerminalHooks { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_create: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_close: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell_wrapper: Option, +} + +/// Wire mirror of `okena_state::WorktreeHooks`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ApiWorktreeHooks { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_create: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_close: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_merge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub post_merge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_remove: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_remove: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_rebase_conflict: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_dirty_close: Option, +} + +/// Wire mirror of `okena_state::HooksConfig` — per-project lifecycle hook +/// overrides. Carried so daemon-client clients show and edit the *real* +/// per-project hooks (the daemon applies them when it spawns PTYs). Converted +/// via `HooksConfig::{to_api,from_api}` in `okena-state`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ApiHooksConfig { + #[serde(default, skip_serializing_if = "is_default")] + pub project: ApiProjectHooks, + #[serde(default, skip_serializing_if = "is_default")] + pub terminal: ApiTerminalHooks, + #[serde(default, skip_serializing_if = "is_default")] + pub worktree: ApiWorktreeHooks, +} + +impl ApiHooksConfig { + pub fn is_empty(&self) -> bool { + *self == ApiHooksConfig::default() + } +} + +fn is_default(v: &T) -> bool { + *v == T::default() } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -305,7 +474,7 @@ pub struct ApiFullscreen { } /// POST /v1/actions request body (tagged enum) -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] pub enum ActionRequest { SendText { @@ -333,6 +502,16 @@ pub enum ActionRequest { project_id: String, terminal_ids: Vec, }, + /// Undo an in-flight soft-close: restore the terminal to the layout (the + /// daemon checks whether its PTY is still alive). Terminal-only. + UndoSoftClose { + terminal_id: String, + }, + /// Finalize an in-flight soft-close immediately ("Close now"): kill the + /// kept-alive PTY without waiting out the grace period. Terminal-only. + CloseTerminalNow { + terminal_id: String, + }, FocusTerminal { project_id: String, terminal_id: String, @@ -343,6 +522,13 @@ pub enum ActionRequest { ReadContent { terminal_id: String, }, + /// Capture a terminal's full scrollback buffer (tmux `capture-pane`). The + /// daemon writes it to a temp file, reads it back, and returns the content + /// as `{"content": }`; the client writes its own local copy. + /// Terminal-only, mirroring `ReadContent`. + ExportBuffer { + terminal_id: String, + }, Resize { terminal_id: String, cols: u16, @@ -372,6 +558,15 @@ pub enum ActionRequest { terminal_id: String, name: String, }, + /// Switch the shell of an existing terminal: the daemon kills the old PTY + /// and respawns at the same layout path with `shell` (resolving Default → + /// project default → global default and applying shell-wrapper/on_create + /// hooks, like any uninitialized terminal). + SwitchTerminalShell { + project_id: String, + terminal_id: String, + shell: ShellType, + }, AddTab { project_id: String, path: Vec, @@ -434,6 +629,32 @@ pub enum ActionRequest { GitListBranches { project_id: String, }, + GitListWorktrees { + project_id: String, + }, + WorktreeCloseInfo { + project_id: String, + }, + GenerateWorktreeBranchName { + project_id: String, + }, + GitListBranchesClassified { + project_id: String, + }, + GitCheckoutLocalBranch { + project_id: String, + branch: String, + }, + GitCheckoutRemoteBranch { + project_id: String, + remote_branch: String, + }, + GitCreateAndCheckoutBranch { + project_id: String, + new_name: String, + #[serde(default)] + start_point: Option, + }, GitStageFile { project_id: String, file_path: String, @@ -494,6 +715,21 @@ pub enum ActionRequest { #[serde(default)] create_branch: bool, }, + /// Track an already-on-disk git worktree (discovered by a client-side git + /// scan) as a project under `parent_project_id`. The daemon creates the + /// project, links it to the parent, and spawns its terminal. + AddDiscoveredWorktree { + parent_project_id: String, + worktree_path: String, + branch: String, + }, + /// Rerun a lifecycle-hook terminal. The daemon kills the old PTY, spawns a + /// fresh shell at the hook's cwd, and re-types the stored command — command + /// + cwd are read daemon-side from the hook terminal entry. + RerunHook { + project_id: String, + terminal_id: String, + }, ListFiles { project_id: String, #[serde(default)] @@ -553,6 +789,15 @@ pub enum ActionRequest { project_id: String, name: String, }, + /// Replace a project's per-project lifecycle-hook overrides. Sent by a + /// client whose settings panel edited them; the daemon owns the + /// authoritative `ProjectData.hooks` and applies them on PTY spawn. + UpdateProjectHooks { + project_id: String, + // Boxed: this is by far the largest `ActionRequest` variant, and an + // unboxed `ApiHooksConfig` here trips `clippy::large_enum_variant`. + hooks: Box, + }, RenameProjectDirectory { project_id: String, new_name: String, @@ -572,6 +817,19 @@ pub enum ActionRequest { #[serde(default)] force: bool, }, + CloseWorktree { + project_id: String, + #[serde(default)] + merge: bool, + #[serde(default)] + stash: bool, + #[serde(default)] + fetch: bool, + #[serde(default)] + push: bool, + #[serde(default)] + delete_branch: bool, + }, CreateFolder { name: String, }, @@ -592,6 +850,53 @@ pub enum ActionRequest { project_id: String, top_level_index: usize, }, + /// Reorder a top-level item (project or folder id) within `project_order`. + /// Backs the sidebar drag of a project/folder onto a top-level slot. + MoveProject { + project_id: String, + new_index: usize, + }, + /// Reorder an existing top-level item (folder or already-top-level project) + /// within `project_order` by its id. + MoveItemInOrder { + item_id: String, + new_index: usize, + }, + /// Toggle a project's pinned flag. + ToggleProjectPinned { + project_id: String, + }, + /// Reorder a worktree within its parent project's `worktree_ids`. + ReorderWorktree { + parent_id: String, + worktree_id: String, + new_index: usize, + }, + /// Set or clear a worktree project's color override. + SetWorktreeColorOverride { + project_id: String, + #[serde(default)] + color: Option, + }, + + // ── Sessions (workspace-global; the daemon owns session files + state) ── + /// Load a saved session by name: the daemon reads its own session file + /// (local ids), kills all terminals, replaces its workspace, and respawns. + LoadSession { + name: String, + }, + /// Save the daemon's current workspace as a named session file. + SaveSession { + name: String, + }, + /// Import a workspace file from `path` and switch to it (like LoadSession). + ImportWorkspace { + path: String, + }, + /// Export the daemon's current workspace to a file at `path`. + ExportWorkspace { + path: String, + }, // ── Settings (app-scoped; handled at the remote bridge) ─────────── /// Return the full current settings as JSON. @@ -761,6 +1066,28 @@ mod tests { services: vec![], worktree_info: None, worktree_ids: vec![], + pinned: true, + last_activity_at: Some(1_700_000_000_000), + default_shell: Some(ShellType::Default), + hook_terminals: vec![ApiHookTerminalEntry { + terminal_id: "h1".into(), + label: "on_project_open".into(), + status: ApiHookTerminalStatus::Failed { exit_code: 2 }, + hook_type: "on_project_open".into(), + command: "echo hi".into(), + cwd: "/tmp".into(), + }], + hooks: ApiHooksConfig { + project: ApiProjectHooks { + on_open: Some("echo open".into()), + on_close: None, + }, + terminal: ApiTerminalHooks { + shell_wrapper: Some("devcontainer exec -- {shell}".into()), + ..Default::default() + }, + worktree: ApiWorktreeHooks::default(), + }, }], focused_project_id: Some("p1".into()), fullscreen_terminal: None, @@ -798,6 +1125,25 @@ mod tests { assert_eq!(parsed.projects.len(), 1); assert_eq!(parsed.projects[0].id, "p1"); assert!(matches!(parsed.projects[0].folder_color, FolderColor::Blue)); + assert!(parsed.projects[0].pinned); + assert_eq!(parsed.projects[0].last_activity_at, Some(1_700_000_000_000)); + assert_eq!(parsed.projects[0].default_shell, Some(ShellType::Default)); + assert_eq!(parsed.projects[0].hook_terminals.len(), 1); + assert_eq!(parsed.projects[0].hook_terminals[0].terminal_id, "h1"); + assert!(matches!( + parsed.projects[0].hook_terminals[0].status, + ApiHookTerminalStatus::Failed { exit_code: 2 } + )); + assert_eq!( + parsed.projects[0].hooks.project.on_open.as_deref(), + Some("echo open") + ); + assert_eq!(parsed.projects[0].hooks.project.on_close, None); + assert_eq!( + parsed.projects[0].hooks.terminal.shell_wrapper.as_deref(), + Some("devcontainer exec -- {shell}") + ); + assert_eq!(parsed.projects[0].hooks.worktree, ApiWorktreeHooks::default()); assert!(parsed.fullscreen_terminal.is_none()); assert_eq!(parsed.project_order, vec!["folder1", "p1"]); assert_eq!(parsed.folders.len(), 1); @@ -864,6 +1210,8 @@ mod tests { ahead: Some(3), behind: Some(1), unpushed: Some(2), + review_base: Some("origin/main".into()), + default_branch: Some("main".into()), }; let json = serde_json::to_string(&status).unwrap(); let parsed: ApiGitStatus = serde_json::from_str(&json).unwrap(); @@ -871,6 +1219,8 @@ mod tests { assert_eq!(parsed.ci_checks, status.ci_checks); assert_eq!(parsed.ahead, Some(3)); assert_eq!(parsed.behind, Some(1)); + assert_eq!(parsed.review_base.as_deref(), Some("origin/main")); + assert_eq!(parsed.default_branch.as_deref(), Some("main")); assert_eq!(parsed.unpushed, Some(2)); assert_eq!(parsed.ci_checks.as_ref().unwrap().checks[0].elapsed_label(), "1m5s"); } @@ -955,6 +1305,11 @@ mod tests { terminal_id: "t1".into(), name: "my-term".into(), }, + ActionRequest::SwitchTerminalShell { + project_id: "p1".into(), + terminal_id: "t1".into(), + shell: ShellType::Default, + }, ActionRequest::AddTab { project_id: "p1".into(), path: vec![0, 1], @@ -1095,6 +1450,15 @@ mod tests { project_id: "p1".into(), force: true, }, + ActionRequest::AddDiscoveredWorktree { + parent_project_id: "p1".into(), + worktree_path: "/home/user/projects/my-project-wt".into(), + branch: "feature/x".into(), + }, + ActionRequest::RerunHook { + project_id: "p1".into(), + terminal_id: "h1".into(), + }, ActionRequest::CreateFolder { name: "My Folder".into(), }, @@ -1114,6 +1478,30 @@ mod tests { project_id: "p1".into(), top_level_index: 0, }, + ActionRequest::MoveProject { + project_id: "p1".into(), + new_index: 1, + }, + ActionRequest::MoveItemInOrder { + item_id: "f1".into(), + new_index: 0, + }, + ActionRequest::ToggleProjectPinned { + project_id: "p1".into(), + }, + ActionRequest::ReorderWorktree { + parent_id: "p1".into(), + worktree_id: "w1".into(), + new_index: 0, + }, + ActionRequest::SetWorktreeColorOverride { + project_id: "p1".into(), + color: Some(FolderColor::Blue), + }, + ActionRequest::LoadSession { name: "work".into() }, + ActionRequest::SaveSession { name: "work".into() }, + ActionRequest::ImportWorkspace { path: "/tmp/ws.json".into() }, + ActionRequest::ExportWorkspace { path: "/tmp/ws.json".into() }, ]; for action in actions { let json = serde_json::to_string(&action).unwrap(); diff --git a/crates/okena-core/src/git_poll.rs b/crates/okena-core/src/git_poll.rs new file mode 100644 index 000000000..a837d548a --- /dev/null +++ b/crates/okena-core/src/git_poll.rs @@ -0,0 +1,109 @@ +use crate::api::ActionRequest; + +/// External wake-up request for git / GitHub status polling. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitPollTrigger { + pub project_id: Option, + pub poll_github: bool, + pub invalidate_github: bool, +} + +impl GitPollTrigger { + pub fn branch_change(project_id: String) -> Self { + Self { + project_id: Some(project_id), + poll_github: true, + invalidate_github: true, + } + } + + pub fn project_visible(project_id: String) -> Self { + Self { + project_id: Some(project_id), + poll_github: true, + invalidate_github: false, + } + } + + pub fn visibility_changed() -> Self { + Self { + project_id: None, + poll_github: false, + invalidate_github: false, + } + } +} + +/// Map an action to the git-poll wake-up it should trigger (if any). +/// +/// Shared by both daemons so the same actions always drive the same immediate +/// refresh: the dedicated `okena-daemon` (`DaemonCore`) and the single-binary +/// `okena --headless` (`HeadlessApp`) both call this after a successful action. +/// +/// - Branch checkout invalidates the cached PR/CI (they belong to the old +/// branch) and re-polls `gh`. +/// - Requesting git status, or showing a project in the overview, marks the +/// project visible so `gh` is fetched when it has no cached PR/CI yet. +pub fn git_poll_trigger_for_action(action: &ActionRequest) -> Option { + match action { + ActionRequest::GitCheckoutLocalBranch { project_id, .. } + | ActionRequest::GitCheckoutRemoteBranch { project_id, .. } + | ActionRequest::GitCreateAndCheckoutBranch { project_id, .. } => { + Some(GitPollTrigger::branch_change(project_id.clone())) + } + ActionRequest::GitStatus { project_id } + | ActionRequest::SetProjectShowInOverview { + project_id, + show: true, + .. + } => Some(GitPollTrigger::project_visible(project_id.clone())), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn branch_checkout_creates_invalidating_trigger() { + let trigger = git_poll_trigger_for_action(&ActionRequest::GitCheckoutLocalBranch { + project_id: "p1".to_string(), + branch: "feature".to_string(), + }) + .expect("branch checkout creates trigger"); + assert_eq!(trigger.project_id.as_deref(), Some("p1")); + assert!(trigger.poll_github); + assert!(trigger.invalidate_github); + } + + #[test] + fn visible_project_actions_create_non_invalidating_trigger() { + let trigger = git_poll_trigger_for_action(&ActionRequest::SetProjectShowInOverview { + project_id: "p1".to_string(), + show: true, + window: None, + }) + .expect("showing a project creates trigger"); + assert_eq!(trigger.project_id.as_deref(), Some("p1")); + assert!(trigger.poll_github); + assert!(!trigger.invalidate_github); + + let trigger = git_poll_trigger_for_action(&ActionRequest::GitStatus { + project_id: "p1".to_string(), + }) + .expect("requesting git status creates trigger"); + assert_eq!(trigger.project_id.as_deref(), Some("p1")); + assert!(trigger.poll_github); + assert!(!trigger.invalidate_github); + + assert!( + git_poll_trigger_for_action(&ActionRequest::SetProjectShowInOverview { + project_id: "p1".to_string(), + show: false, + window: None, + }) + .is_none() + ); + } +} diff --git a/crates/okena-core/src/lib.rs b/crates/okena-core/src/lib.rs index 815497c34..a63515a1d 100644 --- a/crates/okena-core/src/lib.rs +++ b/crates/okena-core/src/lib.rs @@ -1,12 +1,14 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod api; +pub mod git_poll; pub mod keys; pub mod process; pub mod profiles; pub mod selection; pub mod send_payload; pub mod shell; +pub mod soft_close; pub mod theme; pub mod timing; pub mod types; diff --git a/crates/okena-core/src/process/mod.rs b/crates/okena-core/src/process/mod.rs index 42b4dc137..a77f36613 100644 --- a/crates/okena-core/src/process/mod.rs +++ b/crates/okena-core/src/process/mod.rs @@ -91,6 +91,40 @@ pub fn open_url(url: &str) { } } +/// Check whether a process with the given PID is still running. +pub fn is_process_alive(pid: u32) -> bool { + #[cfg(unix)] + { + // signal 0 probes for existence without delivering a signal. + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + } + #[cfg(windows)] + { + // A process handle is signaled after exit. This avoids treating an + // actual exit code of 259 (`STILL_ACTIVE`) as forever alive. + use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if handle.is_null() { + return false; + } + let result = WaitForSingleObject(handle, 0); + CloseHandle(handle); + result == WAIT_TIMEOUT + } + } + #[cfg(not(any(unix, windows)))] + { + // Without a cheap liveness probe, assume alive and let callers fall + // back to their existing connection/time-out paths. + let _ = pid; + true + } +} + /// Raise the soft open-file-descriptor limit toward the hard limit at startup. /// /// The command bus caps concurrent child processes (~20), but interactive PTY diff --git a/crates/okena-core/src/profiles.rs b/crates/okena-core/src/profiles.rs index 30cfbd6f5..8e9d18b69 100644 --- a/crates/okena-core/src/profiles.rs +++ b/crates/okena-core/src/profiles.rs @@ -1,3 +1,4 @@ +use crate::process::is_process_alive; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -31,6 +32,10 @@ impl ProfilePaths { pub fn remote_secret(&self) -> PathBuf { self.root.join("remote_secret") } pub fn remote_tokens(&self) -> PathBuf { self.root.join("remote_tokens.json") } pub fn pair_code(&self) -> PathBuf { self.root.join("pair_code") } + /// Pristine pre-upgrade copies of config, one dir per outgoing version. + pub fn config_backups_dir(&self) -> PathBuf { self.root.join("config-backups") } + /// Plain-text marker holding the last app version that ran on this profile. + pub fn app_version_marker(&self) -> PathBuf { self.root.join(".app-version") } } /// Initialize the process-wide active profile. Must be called exactly once before @@ -409,6 +414,252 @@ pub fn migrate_legacy_layout_if_needed(paths: &ProfilePaths) -> Result<()> { Ok(()) } +// ─── Config snapshots (upgrade safety-net) ────────────────────────────────────── + +/// A config file's in-code schema version, used to detect a pending migration +/// (on-disk version older than what this build produces). +pub struct SchemaVersion { + /// File name relative to the profile root, e.g. `"workspace.json"`. + pub file: &'static str, + /// The schema version this build expects/produces. + pub current: u32, +} + +/// Config files copied verbatim into every snapshot. +const SNAPSHOT_FILES: &[&str] = &[ + "workspace.json", + "workspace.json.bak", + "settings.json", + "keybindings.json", + "window-layout.json", +]; + +/// Config directories copied recursively into every snapshot. +const SNAPSHOT_DIRS: &[&str] = &["themes", "sessions"]; + +/// Maximum number of config snapshots to retain per profile. +const MAX_SNAPSHOTS: usize = 3; + +/// Snapshot the profile's config into `config-backups//` when an app +/// upgrade or a pending schema migration is detected, so a downgrade can restore +/// the old-format config the previous binary can read. +/// +/// Idempotent and first-wins: an existing snapshot for the chosen key is left +/// untouched (it is already the pristine pre-upgrade state). Must run at startup +/// BEFORE any config is loaded/migrated. Returns the snapshot key if one was +/// created. +/// +/// Trigger: +/// - app version increased vs the `.app-version` marker (key = old version), or +/// - no marker yet but config exists (key = `pre-`), or +/// - any `schema_versions` entry is behind on disk (dev churn without a version +/// bump; key = `pre-`). +pub fn snapshot_configs_before_upgrade( + paths: &ProfilePaths, + current_app_version: &str, + schema_versions: &[SchemaVersion], +) -> Result> { + // Only meaningful if there is existing config to protect. + if !paths.workspace_json().exists() && !paths.settings_json().exists() { + return Ok(None); + } + + let marker = read_app_version_marker(paths); + + let upgrade = match &marker { + Some(last) => is_upgrade(current_app_version, last), + // First run with this feature on a pre-existing config: protect it once. + None => true, + }; + let schema_pending = schema_versions + .iter() + .any(|sv| schema_is_behind(&paths.root.join(sv.file), sv.current)); + + if !upgrade && !schema_pending { + return Ok(None); + } + + // Key = the version we're leaving (a clean revert target) when we have a real + // upgrade with a recorded previous version; otherwise `pre-` (the + // config as it was before any `current` build touched it). + let key = match &marker { + Some(last) if is_upgrade(current_app_version, last) => sanitize_key(last), + _ => format!("pre-{}", sanitize_key(current_app_version)), + }; + + let backups_dir = paths.config_backups_dir(); + let target = backups_dir.join(&key); + // First-wins: never overwrite an existing pristine snapshot for this key. + if target.exists() { + return Ok(None); + } + + std::fs::create_dir_all(&backups_dir)?; + // Per-process tmp name: the GUI and its daemon may snapshot the same profile + // concurrently, so they must not share a staging dir. + let tmp = backups_dir.join(format!("{key}.{}.tmp", std::process::id())); + // Clear a leftover partial from a previous crash before publishing. + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp)?; + + for name in SNAPSHOT_FILES { + let from = paths.root.join(name); + if from.exists() { + let _ = std::fs::copy(&from, tmp.join(name)); + } + } + for name in SNAPSHOT_DIRS { + let from = paths.root.join(name); + if from.is_dir() { + let _ = copy_dir_recursive(&from, &tmp.join(name)); + } + } + + // Describe the snapshot for humans and a future revert command. + let schema_meta: serde_json::Map = schema_versions + .iter() + .map(|sv| { + let on_disk = read_schema_version(&paths.root.join(sv.file)); + ( + sv.file.to_string(), + serde_json::json!({ "on_disk": on_disk, "code": sv.current }), + ) + }) + .collect(); + let meta = serde_json::json!({ + "from_app_version": marker, + "to_app_version": current_app_version, + "created_at": now_iso8601(), + "schema_versions": schema_meta, + }); + let _ = std::fs::write( + tmp.join("meta.json"), + serde_json::to_string_pretty(&meta).unwrap_or_default(), + ); + + // Atomically publish: a half-written `.tmp` never looks like a real snapshot. + if target.exists() { + // Lost a race with a concurrent snapshot — the other copy is pristine too. + let _ = std::fs::remove_dir_all(&tmp); + return Ok(None); + } + if let Err(e) = std::fs::rename(&tmp, &target) { + let _ = std::fs::remove_dir_all(&tmp); + return Err(e.into()); + } + prune_snapshots(&backups_dir, MAX_SNAPSHOTS); + + log::info!("Config snapshot saved before upgrade: {}", target.display()); + Ok(Some(key)) +} + +/// Record the current app version into the profile's `.app-version` marker. +/// Call once at startup after the snapshot check. Best-effort. +pub fn record_app_version(paths: &ProfilePaths, current_app_version: &str) { + let path = paths.app_version_marker(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, current_app_version); +} + +fn read_app_version_marker(paths: &ProfilePaths) -> Option { + let content = std::fs::read_to_string(paths.app_version_marker()).ok()?; + let trimmed = content.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Read the root `"version"` field from a JSON config file. +fn read_schema_version(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value.get("version").and_then(|v| v.as_u64()).map(|n| n as u32) +} + +/// Whether an existing config file is behind the build's schema version (and so +/// would be migrated on load). A present file with a missing/invalid version is +/// treated as legacy (behind) — erring toward taking a backup is safe. +fn schema_is_behind(path: &Path, current: u32) -> bool { + if !path.exists() { + return false; + } + match read_schema_version(path) { + Some(v) => v < current, + None => true, + } +} + +/// True if `current` is a newer version than `last`. Falls back to string +/// inequality (conservative: take a snapshot) when either side is unparseable. +fn is_upgrade(current: &str, last: &str) -> bool { + match (parse_version(current), parse_version(last)) { + (Some(c), Some(l)) => c > l, + _ => current.trim() != last.trim(), + } +} + +/// Parse a `major.minor.patch` version, ignoring any `-pre`/`+build` suffix. +fn parse_version(v: &str) -> Option<(u32, u32, u32)> { + let core = v.trim().split(['-', '+']).next().unwrap_or(""); + let mut it = core.split('.'); + let major = it.next()?.trim().parse().ok()?; + let minor = it.next().unwrap_or("0").trim().parse().ok()?; + let patch = it.next().unwrap_or("0").trim().parse().ok()?; + Some((major, minor, patch)) +} + +/// Make a version string safe to use as a directory name. +fn sanitize_key(s: &str) -> String { + s.trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }) + .collect() +} + +fn copy_dir_recursive(from: &Path, to: &Path) -> Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let ty = entry.file_type()?; + let dst = to.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&entry.path(), &dst)?; + } else if ty.is_file() { + std::fs::copy(entry.path(), &dst)?; + } + // Symlinks intentionally skipped — config dirs shouldn't contain them. + } + Ok(()) +} + +/// Keep the `keep` most recently created snapshots, removing older ones. +fn prune_snapshots(backups_dir: &Path, keep: usize) { + let Ok(entries) = std::fs::read_dir(backups_dir) else { + return; + }; + let mut dirs: Vec<(std::time::SystemTime, PathBuf)> = entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .filter(|e| !e.file_name().to_string_lossy().ends_with(".tmp")) + .filter_map(|e| { + let mtime = e.metadata().and_then(|m| m.modified()).ok()?; + Some((mtime, e.path())) + }) + .collect(); + if dirs.len() <= keep { + return; + } + dirs.sort_by_key(|(t, _)| *t); // oldest first + let remove_count = dirs.len() - keep; + for (_, path) in dirs.into_iter().take(remove_count) { + let _ = std::fs::remove_dir_all(&path); + } +} + // ─── Helpers ────────────────────────────────────────────────────────────────── fn bootstrap_default_profile(config_root: &Path) -> Result { @@ -451,39 +702,6 @@ fn unique_id(display_name: &str, index: &ProfileIndex) -> String { unreachable!() } -fn is_process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } - } - #[cfg(windows)] - { - // Use WaitForSingleObject with a 0 timeout rather than - // GetExitCodeProcess + STILL_ACTIVE: a process that legitimately exited - // with code 259 (== STILL_ACTIVE) would otherwise be reported alive - // forever (or until its PID is reused). The process handle is signaled - // once the process terminates; WAIT_TIMEOUT means it is still running. - use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT}; - use windows_sys::Win32::System::Threading::{ - OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, - }; - unsafe { - let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); - if handle.is_null() { - return false; - } - let result = WaitForSingleObject(handle, 0); - CloseHandle(handle); - result == WAIT_TIMEOUT - } - } - #[cfg(not(any(unix, windows)))] - { - let _ = pid; - false - } -} - fn now_iso8601() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let s = SystemTime::now() @@ -633,6 +851,158 @@ mod tests { assert!(ts.ends_with('Z')); } + // ─── Config snapshot tests ────────────────────────────────────────────── + + fn snap_paths(dir: &TempDir) -> ProfilePaths { + let root = dir.path().join("profiles").join("default"); + fs::create_dir_all(&root).unwrap(); + ProfilePaths { + id: "default".to_string(), + root, + config_root: dir.path().to_path_buf(), + } + } + + #[test] + fn test_snapshot_on_app_version_upgrade() { + let dir = temp_root(); + let paths = snap_paths(&dir); + fs::write(paths.workspace_json(), r#"{"version":2,"hello":"world"}"#).unwrap(); + record_app_version(&paths, "0.27.0"); + + let sv = [SchemaVersion { file: "workspace.json", current: 2 }]; + let key = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(key.as_deref(), Some("0.27.0")); + + let snap_dir = paths.config_backups_dir().join("0.27.0"); + let content = fs::read_to_string(snap_dir.join("workspace.json")).unwrap(); + assert!(content.contains("\"hello\":\"world\"")); + assert!(snap_dir.join("meta.json").exists()); + } + + #[test] + fn test_snapshot_when_schema_behind_no_marker() { + let dir = temp_root(); + let paths = snap_paths(&dir); + fs::write(paths.workspace_json(), r#"{"version":1}"#).unwrap(); + // No .app-version marker — first run of the feature on an existing config. + let sv = [SchemaVersion { file: "workspace.json", current: 2 }]; + let key = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(key.as_deref(), Some("pre-0.28.0")); + assert!(paths.config_backups_dir().join("pre-0.28.0").join("workspace.json").exists()); + } + + #[test] + fn test_no_snapshot_when_up_to_date() { + let dir = temp_root(); + let paths = snap_paths(&dir); + fs::write(paths.workspace_json(), r#"{"version":2}"#).unwrap(); + record_app_version(&paths, "0.28.0"); + let sv = [SchemaVersion { file: "workspace.json", current: 2 }]; + let key = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(key, None); + assert!(!paths.config_backups_dir().exists()); + } + + #[test] + fn test_no_snapshot_on_fresh_install() { + let dir = temp_root(); + let paths = snap_paths(&dir); + // No config files at all. + let sv = [SchemaVersion { file: "workspace.json", current: 2 }]; + let key = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(key, None); + } + + #[test] + fn test_snapshot_on_schema_churn_same_version() { + let dir = temp_root(); + let paths = snap_paths(&dir); + fs::write(paths.workspace_json(), r#"{"version":2}"#).unwrap(); + record_app_version(&paths, "0.28.0"); + // Code schema bumped without an app-version bump (dev churn on a branch). + let sv = [SchemaVersion { file: "workspace.json", current: 3 }]; + let key = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(key.as_deref(), Some("pre-0.28.0")); + } + + #[test] + fn test_snapshot_idempotent_first_wins() { + let dir = temp_root(); + let paths = snap_paths(&dir); + fs::write(paths.workspace_json(), r#"{"version":2,"n":1}"#).unwrap(); + record_app_version(&paths, "0.27.0"); + let sv = [SchemaVersion { file: "workspace.json", current: 2 }]; + + let k1 = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(k1.as_deref(), Some("0.27.0")); + + // Mutate the live config, then snapshot again with the same key. + fs::write(paths.workspace_json(), r#"{"version":2,"n":2}"#).unwrap(); + let k2 = snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert_eq!(k2, None, "must not re-snapshot an existing key"); + + let snap = + fs::read_to_string(paths.config_backups_dir().join("0.27.0").join("workspace.json")) + .unwrap(); + assert!(snap.contains("\"n\":1"), "first snapshot must stay pristine"); + } + + #[test] + fn test_snapshot_copies_dirs() { + let dir = temp_root(); + let paths = snap_paths(&dir); + fs::write(paths.workspace_json(), r#"{"version":2}"#).unwrap(); + fs::create_dir_all(paths.themes_dir()).unwrap(); + fs::write(paths.themes_dir().join("custom.json"), "{}").unwrap(); + record_app_version(&paths, "0.27.0"); + + let sv = [SchemaVersion { file: "workspace.json", current: 2 }]; + snapshot_configs_before_upgrade(&paths, "0.28.0", &sv).unwrap(); + assert!(paths + .config_backups_dir() + .join("0.27.0") + .join("themes") + .join("custom.json") + .exists()); + } + + #[test] + fn test_prune_keeps_recent() { + let dir = temp_root(); + let backups = dir.path().join("config-backups"); + fs::create_dir_all(&backups).unwrap(); + for name in ["a", "b", "c", "d", "e"] { + fs::create_dir_all(backups.join(name)).unwrap(); + } + // A leftover staging dir must never be counted or removed as a snapshot. + fs::create_dir_all(backups.join("f.123.tmp")).unwrap(); + + prune_snapshots(&backups, 3); + + let snapshot_dirs = fs::read_dir(&backups) + .unwrap() + .flatten() + .filter(|e| !e.file_name().to_string_lossy().ends_with(".tmp")) + .count(); + assert_eq!(snapshot_dirs, 3); + assert!(backups.join("f.123.tmp").exists()); + } + + #[test] + fn test_version_compare() { + assert!(is_upgrade("0.28.0", "0.27.0")); + assert!(is_upgrade("1.0.0", "0.99.99")); + assert!(is_upgrade("0.28.1", "0.28.0")); + assert!(!is_upgrade("0.27.0", "0.28.0")); + assert!(!is_upgrade("0.28.0", "0.28.0")); + // Pre-release / build suffix ignored on the core triple. + assert!(!is_upgrade("0.28.0-beta", "0.28.0")); + // Unparseable on either side → conservative: snapshot iff strings differ. + assert!(is_upgrade("weird", "other")); + assert!(!is_upgrade("weird", "weird")); + } + fn make_test_index_with_two(dir: &TempDir) -> ProfileIndex { let idx = ProfileIndex { version: 1, diff --git a/crates/okena-core/src/soft_close.rs b/crates/okena-core/src/soft_close.rs new file mode 100644 index 000000000..fe14aada9 --- /dev/null +++ b/crates/okena-core/src/soft_close.rs @@ -0,0 +1,126 @@ +//! Shared encoding of soft-close toast-action ids, used by the daemon (which +//! builds the Undo/Close-now toast) and the GUI client (which decodes a clicked +//! action id back into the project/terminal it targets). + +/// Toast-action id prefix for the soft-close "Undo" button. +pub const SOFT_CLOSE_UNDO_PREFIX: &str = "soft_close_undo"; +/// Toast-action id prefix for the soft-close "Close now" button. +pub const SOFT_CLOSE_KILL_PREFIX: &str = "soft_close_kill"; + +/// Encode a toast-action id as `::`. +pub fn encode_action(prefix: &str, project_id: &str, terminal_id: &str) -> String { + format!( + "{prefix}:{}:{}", + encode_component(project_id), + encode_component(terminal_id) + ) +} + +/// Decode `::` → `(project_id, terminal_id)` +/// when `prefix` matches. +pub fn decode_action(id: &str, prefix: &str) -> Option<(String, String)> { + let rest = id.strip_prefix(prefix)?.strip_prefix(':')?; + let (project_id, terminal_id) = rest.split_once(':')?; + Some(( + decode_component(project_id)?, + decode_component(terminal_id)?, + )) +} + +fn encode_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '%' => encoded.push_str("%25"), + ':' => encoded.push_str("%3A"), + _ => encoded.push(ch), + } + } + encoded +} + +fn decode_component(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hi = *bytes.get(i + 1)?; + let lo = *bytes.get(i + 2)?; + decoded.push(hex_value(hi)? << 4 | hex_value(lo)?); + i += 3; + } else { + decoded.push(bytes[i]); + i += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn hex_value(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn round_trips() { + let id = encode_action(SOFT_CLOSE_UNDO_PREFIX, "p", "t"); + assert_eq!(id, "soft_close_undo:p:t"); + assert_eq!( + decode_action(&id, SOFT_CLOSE_UNDO_PREFIX), + Some(("p".into(), "t".into())) + ); + assert_eq!(decode_action(&id, SOFT_CLOSE_KILL_PREFIX), None); + } + #[test] + fn rejects_malformed() { + assert_eq!( + decode_action("soft_close_undo:onlyone", SOFT_CLOSE_UNDO_PREFIX), + None + ); + assert_eq!(decode_action("garbage", SOFT_CLOSE_UNDO_PREFIX), None); + } + + #[test] + fn round_trips_remote_prefixed_ids() { + let project_id = "remote:local-daemon:p"; + let terminal_id = "remote:local-daemon:t"; + let id = encode_action(SOFT_CLOSE_UNDO_PREFIX, project_id, terminal_id); + assert_eq!( + id, + "soft_close_undo:remote%3Alocal-daemon%3Ap:remote%3Alocal-daemon%3At" + ); + assert_eq!( + decode_action(&id, SOFT_CLOSE_UNDO_PREFIX), + Some((project_id.into(), terminal_id.into())) + ); + } + + #[test] + fn round_trips_percent_signs() { + let id = encode_action(SOFT_CLOSE_UNDO_PREFIX, "p%25", "t%3A"); + assert_eq!( + decode_action(&id, SOFT_CLOSE_UNDO_PREFIX), + Some(("p%25".into(), "t%3A".into())) + ); + } + + #[test] + fn rejects_bad_escape_sequences() { + assert_eq!( + decode_action("soft_close_undo:p%:t", SOFT_CLOSE_UNDO_PREFIX), + None + ); + assert_eq!( + decode_action("soft_close_undo:p%xx:t", SOFT_CLOSE_UNDO_PREFIX), + None + ); + } +} diff --git a/crates/okena-core/src/ws.rs b/crates/okena-core/src/ws.rs index cfb45ffe4..22d7565b7 100644 --- a/crates/okena-core/src/ws.rs +++ b/crates/okena-core/src/ws.rs @@ -1,4 +1,4 @@ -use crate::api::ApiGitStatus; +use crate::api::{ApiGitStatus, ApiSystemStats, ApiToast}; use crate::keys::SpecialKey; use serde::{Deserialize, Serialize}; @@ -59,6 +59,14 @@ pub enum WsOutbound { GitStatusChanged { projects: std::collections::HashMap, }, + SystemStatsChanged { + stats: ApiSystemStats, + }, + /// A daemon-originated toast to display on the client. Fire-and-forget event + /// (mirrors `GitStatusChanged`): the daemon has no surface of its own, so + /// notifications it produces (e.g. hook failures) are pushed here and the + /// client's `ToastManager` renders them. + Toast(ApiToast), TerminalResized { terminal_id: String, cols: u16, @@ -183,6 +191,21 @@ mod tests { .into_iter() .collect(), }, + WsOutbound::SystemStatsChanged { + stats: ApiSystemStats { + cpu_usage: 42.5, + memory_used_bytes: 4_294_967_296, + memory_total_bytes: 17_179_869_184, + }, + }, + WsOutbound::Toast(ApiToast { + id: "toast-1".into(), + level: "error".into(), + message: "Hook `pre_merge` failed".into(), + detail: Some("exit code 1".into()), + ttl_ms: 5000, + actions: Vec::new(), + }), WsOutbound::TerminalResized { terminal_id: "t1".into(), cols: 120, diff --git a/crates/okena-daemon-core/Cargo.toml b/crates/okena-daemon-core/Cargo.toml new file mode 100644 index 000000000..074f360c4 --- /dev/null +++ b/crates/okena-daemon-core/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "okena-daemon-core" +version.workspace = true +edition = "2024" +license = "MIT" + +[dependencies] +# Logic crates the daemon drives. All gpui-optional crates are pulled with +# `default-features = false` so NO gpui is linked into the headless daemon. +okena-core = { path = "../okena-core" } +okena-state = { path = "../okena-state" } +okena-terminal = { path = "../okena-terminal" } +okena-git = { path = "../okena-git" } +okena-hooks = { path = "../okena-hooks", default-features = false } +okena-workspace = { path = "../okena-workspace", default-features = false } +okena-services = { path = "../okena-services", default-features = false } +# Theme data types (gpui-free with default-features = false: avoids the +# `default = ["gpui"]` feature so no gpui is linked into the headless daemon). +okena-theme = { path = "../okena-theme", default-features = false } +# Action execution glue (`execute_action`, `ensure_terminal`, `ActionResult`). +# `default-features = false` drops its `gpui` default feature so the headless +# daemon links no gpui. +okena-app-core = { path = "../okena-app-core", default-features = false } +# Remote bridge types (`BridgeReceiver`, `BridgeMessage`, `RemoteCommand`). +# `default-features = false` drops its `gpui` default feature. +okena-remote-server = { path = "../okena-remote-server", default-features = false } + +# Tokio reactor backing the gpui-free trait impls. +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "signal"] } +parking_lot = "0.12" +serde_json = "1.0" +log = "0.4" +# PTY event channel type (`PtyEvent` receiver) shared with `okena-terminal`. +async-channel = "2.3" +# Window-id parsing (the gpui-free `parse_window_id` copy). +uuid = { version = "1.10", features = ["v4"] } +# `DaemonCore::new` / `run` return `anyhow::Result`, and `?` over +# `RemoteServer::start` (which returns `anyhow::Result`) + the tokio runtime +# builder (`io::Result`) coalesce through it. +anyhow = "1.0" diff --git a/crates/okena-daemon-core/src/command_loop.rs b/crates/okena-daemon-core/src/command_loop.rs new file mode 100644 index 000000000..68a854a6a --- /dev/null +++ b/crates/okena-daemon-core/src/command_loop.rs @@ -0,0 +1,1236 @@ +//! GPUI-free remote command loop: the headless daemon's faithful port of the +//! GUI's `remote_command_loop` (in `okena-app`'s `app/remote_commands.rs`). +//! +//! The GUI version runs on the GPUI main thread and dispatches each +//! [`RemoteCommand`] into `Entity` / `Entity` via +//! `cx.update(|cx| …)` / `entity.read(cx)` / `entity.update(cx, …)`. The daemon +//! has no entity graph: it holds the same state behind +//! `Arc>` and drives the identical +//! `okena-app-core` / `okena-services` code paths against the daemon reactor cx +//! types (see [`crate::workspace_cx`] / [`crate::service_cx`]). +//! +//! Each arm reproduces the GUI behavior arm-for-arm: +//! +//! * **Service actions** lock the [`ServiceManager`], mint a +//! [`DaemonServiceCx`](crate::service_cx::DaemonServiceCx) from the shared +//! [`ServiceReactorRef`], and call the same method with the same project-path +//! lookup + "project not found" error as the GUI. +//! * **App-scoped settings/theme** delegate to [`DaemonConfig`] (the GUI's +//! `remote_config` counterpart). +//! * **Command palette** is unavailable in the daemon (no GUI action registry): +//! `ListActions` returns an empty list, `InvokeAction` returns an error. +//! * **Workspace-scoped actions** run through +//! [`execute_action`](okena_app_core::workspace::actions::execute::execute_action) +//! against [`WindowId::Main`] (the daemon serves a single synthetic main +//! window, mirroring headless mode). +//! * **`GetState`** builds the [`StateResponse`](okena_core::api::StateResponse) +//! the same way the GUI does, with the single synthetic `"main"` window. +//! +//! ## Lock discipline +//! +//! Every arm is fully synchronous: it never `.await`s while a state guard is +//! held, so each guard drops at the arm's end before the loop's next +//! `recv().await`. This mirrors the established daemon pattern in +//! [`crate::pty_loop::handle_exits`]. The single `GetState`/service-action arms +//! that touch both the workspace and service-manager locks take the workspace +//! lock first, then the service-manager lock (consistent order), and both drop +//! before looping. + +use std::collections::HashMap; +use std::sync::Arc; + +use okena_app_core::workspace::actions::execute::{ + ensure_terminal, execute_action, spawn_uninitialized_terminals, +}; +use okena_app_core::remote_snapshot::build_state_response; +use okena_core::api::{ + ActionRequest, ApiGitStatus, ApiServiceInfo, ApiWindow, CommandResult, +}; +use okena_core::git_poll::{git_poll_trigger_for_action, GitPollTrigger}; +use okena_remote_server::bridge::{BridgeMessage, BridgeReceiver, RemoteCommand}; +use okena_services::manager::ServiceManager; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::TerminalsRegistry; +use okena_workspace::actions::soft_close::{ + begin_soft_close_flow, close_now_flow, probe_busy, undo_soft_close_flow, +}; +use okena_workspace::focus::FocusManager; +use okena_workspace::persistence::AppSettings; +use okena_workspace::state::{WindowId, Workspace}; +use parking_lot::Mutex; +use tokio::sync::watch; + +use crate::daemon_config::{get_settings_schema, DaemonConfig}; +use crate::service_cx::ServiceReactorRef; +use crate::soft_close::SoftCloseDeadlines; +use crate::workspace_cx::DaemonWorkspaceCx; + +/// Parse a wire-format window id into a [`WindowId`]. +/// +/// GPUI-free copy of the GUI's `remote_commands::parse_window_id`. `"main"` +/// maps to [`WindowId::Main`]; any other string is parsed as a UUID and, on +/// success, wrapped in [`WindowId::Extra`]. A malformed UUID returns `None` so +/// the caller can reject the action with an "invalid window id" error rather +/// than silently routing it to the wrong window. +fn parse_window_id(s: &str) -> Option { + if s == "main" { + Some(WindowId::Main) + } else { + uuid::Uuid::parse_str(s).ok().map(WindowId::Extra) + } +} + +fn claim_input_resize_owner(action: &ActionRequest, owner_id: &str) { + let terminal_id = match action { + ActionRequest::SendText { terminal_id, .. } + | ActionRequest::RunCommand { terminal_id, .. } + | ActionRequest::SendSpecialKey { terminal_id, .. } => Some(terminal_id.as_str()), + _ => None, + }; + + if let Some(terminal_id) = terminal_id { + okena_terminal::terminal::claim_resize_authority_remote_owner(terminal_id, owner_id); + } +} + +fn send_git_poll_trigger_after_success( + result: &CommandResult, + trigger: Option, + tx: &tokio::sync::mpsc::UnboundedSender, +) { + if matches!(result, CommandResult::Ok(_)) + && let Some(trigger) = trigger + { + let _ = tx.send(trigger); + } +} + +/// GPUI-free remote command loop for the headless daemon. +/// +/// Processes [`RemoteCommand`]s off the [`BridgeReceiver`] until every bridge +/// sender is dropped (server shutdown), replying via each message's `oneshot` +/// when present. The single dormant `FocusManager` is owned by the loop (which +/// is single-threaded), mirroring the GUI's per-window focus-manager but with no +/// view to drive. +// Bridge loop: each param is a distinct channel / shared-state dependency. +#[allow(clippy::too_many_arguments)] +pub async fn daemon_command_loop( + bridge_rx: BridgeReceiver, + backend: Arc, + workspace: Arc>, + workspace_tick: watch::Sender, + hook_runner: Option, + hook_monitor: Option, + terminals: TerminalsRegistry, + state_version: Arc>, + git_status_tx: Arc>>, + service_manager: Arc>, + service_tick: watch::Sender, + runtime: tokio::runtime::Handle, + settings: Arc>, + mut daemon_config: DaemonConfig, + deadlines: SoftCloseDeadlines, + git_poll_trigger_tx: tokio::sync::mpsc::UnboundedSender, +) { + // Single dormant "main" FocusManager. The loop is single-threaded, so it + // owns the FM directly instead of resolving a per-window entity like the + // GUI. Focus state never drives a render here, so it is effectively dormant. + let mut focus_manager = FocusManager::new(); + + // Shared service reactor: built once, `cx()` re-borrowed per service arm. + // It re-locks `service_manager` internally on reentry, so the loop locks the + // manager itself only while the cx is alive — never across an await. + let service_reactor = + ServiceReactorRef::new(service_manager.clone(), runtime.clone(), service_tick.clone()); + + loop { + let msg: BridgeMessage = match bridge_rx.recv().await { + Ok(m) => m, + Err(_) => break, + }; + + let command = match msg.command { + // Identityless actions (HTTP /v1/actions: CLI, agents) do NOT + // touch resize authority — nulling the owner here handed the next + // arriving resize to a random client. Only input from an + // identified WS connection ("someone typed at that window") + // transfers ownership. + RemoteCommand::ActionFromConnection { + action, + connection_id, + } => { + claim_input_resize_owner(&action, &connection_id); + RemoteCommand::Action(action) + } + command => command, + }; + + let result: CommandResult = match command { + RemoteCommand::Action(action) => match action { + // ── Service actions ────────────────────────────────────────── + ActionRequest::StartService { project_id, service_name } => { + let mut sm = service_manager.lock(); + let mut cx = service_reactor.cx(); + sm.start_service_action(&project_id, &service_name, &mut cx) + } + ActionRequest::StopService { project_id, service_name } => { + let mut sm = service_manager.lock(); + let mut cx = service_reactor.cx(); + sm.stop_service_action(&project_id, &service_name, &mut cx) + } + ActionRequest::RestartService { project_id, service_name } => { + let mut sm = service_manager.lock(); + let mut cx = service_reactor.cx(); + sm.restart_service_action(&project_id, &service_name, &mut cx) + } + ActionRequest::StartAllServices { project_id } => { + let mut sm = service_manager.lock(); + let mut cx = service_reactor.cx(); + sm.start_all_action(&project_id, &mut cx) + } + ActionRequest::StopAllServices { project_id } => { + let mut sm = service_manager.lock(); + let mut cx = service_reactor.cx(); + sm.stop_all_action(&project_id, &mut cx) + } + ActionRequest::ReloadServices { project_id } => { + let mut sm = service_manager.lock(); + let mut cx = service_reactor.cx(); + sm.reload_services_action(&project_id, &mut cx) + } + + // ── App-scoped: settings / theme ───────────────────────────── + ActionRequest::GetSettings => daemon_config.get_settings(), + ActionRequest::GetSettingsSchema => get_settings_schema(), + ActionRequest::SetSettings { patch } => daemon_config.set_settings(patch), + ActionRequest::GetThemes => daemon_config.get_themes(), + ActionRequest::GetTheme { id } => daemon_config.get_theme(id), + ActionRequest::SetTheme { id } => daemon_config.set_theme(id), + ActionRequest::SaveCustomTheme { id, config, activate } => { + daemon_config.save_custom_theme(id, config, activate) + } + + // ── Command palette ────────────────────────────────────────── + // The daemon has no GUI action registry, so there are no + // invokable commands to list or dispatch (the agreed parity + // decision; the GUI's headless mode rejects these too). + ActionRequest::ListActions => { + CommandResult::Ok(Some(serde_json::json!({ "actions": [] }))) + } + ActionRequest::InvokeAction { .. } => { + CommandResult::Err("command palette unavailable in daemon mode".to_string()) + } + + // ── Soft-close: undo (restore the ejected pane) ────────────── + ActionRequest::UndoSoftClose { terminal_id } => { + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + undo_soft_close_flow( + &deadlines, + &mut ws, + &mut focus_manager, + &terminals, + &terminal_id, + &mut cx, + ); + CommandResult::Ok(None) + } + + // ── Soft-close: finalize now ("Close now") ─────────────────── + ActionRequest::CloseTerminalNow { terminal_id } => { + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + close_now_flow( + &deadlines, + &mut ws, + &*backend, + &terminals, + &terminal_id, + &mut cx, + ); + CommandResult::Ok(None) + } + + // ── Close terminal: grace-aware soft close ─────────────────── + // Faithful daemon-side port of the GUI's optimistic close. A + // busy terminal is ejected from the layout (mirrors to clients) + // but its PTY is kept alive for the grace period; the finalizer + // loop ([`crate::soft_close::run_soft_close_poll`]) kills it on + // expiry. Idle terminals and `grace == 0` keep the immediate + // close. The Undo / Close-now toast buttons are built here but + // are inert until the client wires their actions. + ActionRequest::CloseTerminal { project_id, terminal_id } => { + let grace = settings.lock().terminal_close_grace_secs; + + if grace == 0 { + // Feature off → immediate close (unchanged behavior). + // Snapshot settings BEFORE locking the workspace. + let app_settings = settings.lock().clone(); + let mut ws = workspace.lock(); + run_main_workspace_action( + ActionRequest::CloseTerminal { project_id, terminal_id }, + &mut ws, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + } else { + // Probe busy-ness OFF the loop thread (forks + // tmux/lsof/pgrep). Hold NO locks across the await. Also + // grab the foreground command for the toast label. + let probe = { + let backend = backend.clone(); + let tid = terminal_id.clone(); + runtime.spawn_blocking(move || probe_busy(&*backend, &tid)) + }; + let (busy, command) = probe.await.unwrap_or((false, None)); + + if !busy { + // Idle → immediate close. + let app_settings = settings.lock().clone(); + let mut ws = workspace.lock(); + run_main_workspace_action( + ActionRequest::CloseTerminal { project_id, terminal_id }, + &mut ws, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + } else { + // Soft close: eject the pane (mirrors back), keep the + // PTY, surface an Undo/Close-now toast, and arm the + // grace deadline for the finalizer loop. `None` from + // the flow means the terminal wasn't in the layout — + // fall back to an immediate close. + let toast = { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + let mut ws = workspace.lock(); + begin_soft_close_flow( + &deadlines, + &mut ws, + &mut focus_manager, + &terminals, + &project_id, + &terminal_id, + grace, + command, + &mut cx, + ) + }; + match toast { + Some(toast) => { + if let Some(hm) = &hook_monitor { + hm.push_toast(toast); + } + CommandResult::Ok(None) + } + None => { + // Not in the layout — immediate close. + let app_settings = settings.lock().clone(); + let mut ws = workspace.lock(); + run_main_workspace_action( + ActionRequest::CloseTerminal { + project_id, + terminal_id, + }, + &mut ws, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + } + } + } + } + } + + // ── Default: workspace-scoped action ───────────────────────── + action => { + let git_poll_trigger = git_poll_trigger_for_action(&action); + // Resolve the action's explicit target window (if any) + // BEFORE moving `action` into `execute_action`. The daemon + // serves only the synthetic main window: `None` and + // `Some("main")` are accepted; any other (valid) window id is + // "not found"; a malformed id is "invalid". + let parsed_target = match action.target_window() { + None => Ok(None), + Some(s) => match parse_window_id(s) { + Some(wid) => Ok(Some(wid)), + None => Err(s.to_string()), + }, + }; + match parsed_target { + Err(bad) => { + // Malformed window id: rejected up front. + CommandResult::Err(format!("invalid window id: {bad}")) + } + Ok(None) | Ok(Some(WindowId::Main)) => { + // Snapshot app settings to thread into the gpui-free + // `execute_action` (hooks / worktree template / + // default shell). Read before locking the workspace. + // The daemon always targets `WindowId::Main`; the + // mutators notify via `cx` themselves, so there is no + // separate `cx.notify()` like the GUI's view-refresh. + let app_settings = settings.lock().clone(); + let mut ws = workspace.lock(); + let result = run_main_workspace_action( + action, + &mut ws, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + send_git_poll_trigger_after_success( + &result, + git_poll_trigger, + &git_poll_trigger_tx, + ); + result + } + Ok(Some(WindowId::Extra(uuid))) => { + // The daemon has only the synthetic main window. + CommandResult::Err(format!("window not found: {uuid}")) + } + } + } + }, + + RemoteCommand::ResizeFromConnection { + terminal_id, + cols, + rows, + connection_id, + } => { + if !okena_terminal::terminal::claim_remote_resize_if_allowed( + &terminal_id, + &connection_id, + ) { + // Denied: reply with the authoritative size so the stream + // handler can correct the client's optimistically-resized + // grid and make it cede (server_owns), instead of leaving + // it silently diverged from the PTY. + let denied_size = terminals + .lock() + .get(&terminal_id) + .map(|term| term.resize_state.lock().size); + match denied_size { + Some(size) => CommandResult::Ok(Some(serde_json::json!({ + "denied": true, + "cols": size.cols, + "rows": size.rows, + }))), + None => CommandResult::Ok(None), + } + } else { + let app_settings = settings.lock().clone(); + let mut ws = workspace.lock(); + run_main_workspace_action( + ActionRequest::Resize { + terminal_id, + cols, + rows, + }, + &mut ws, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + } + } + RemoteCommand::ActionFromConnection { .. } => { + CommandResult::Err("internal action normalization error".to_string()) + } + + // ── GetState: full workspace snapshot ──────────────────────────── + RemoteCommand::GetState => { + // Lock order: workspace first, then service manager (kept + // consistent across the loop). The whole arm is synchronous, so + // both guards drop before the next `recv().await`. + let ws = workspace.lock(); + let sm = service_manager.lock(); + let sv = *state_version.borrow(); + let git_statuses = git_status_tx.borrow().clone(); + let data = ws.data(); + + // Build terminal size map from the registry. + let size_map: HashMap = { + let registry = terminals.lock(); + registry + .iter() + .map(|(id, term)| { + let size = term.resize_state.lock().size; + (id.clone(), (size.cols, size.rows)) + }) + .collect() + }; + + // Source of truth for runtime visibility (per-window viewport). + let hidden_project_ids = &data.main_window.hidden_project_ids; + + // Pre-build the per-project wire service lists from THIS caller's + // `ServiceManager` (keeps the `okena-services` dependency in the + // daemon; the shared builder in `okena-app-core` never sees it). + // The `ServiceInstance -> ApiServiceInfo` mapping is + // `ServiceInstance::to_api`, shared with the GUI loop. + let services_by_project: HashMap> = data + .projects + .iter() + .map(|p| { + let services = sm + .services_for_project(&p.id) + .into_iter() + .map(|inst| inst.to_api()) + .collect(); + (p.id.clone(), services) + }) + .collect(); + + // The daemon serves a SINGLE synthetic main window (ported from + // headless.rs's windows_resolver). No GUI, so it's always + // "active", has no per-window focus/fullscreen/bounds, and no + // hidden set — every project in `project_order` is visible. + let visible_project_ids: Vec = ws + .visible_projects(WindowId::Main, None, false) + .iter() + .map(|p| p.id.clone()) + .collect(); + let windows = vec![ApiWindow { + id: "main".into(), + kind: "main".into(), + active: true, + focused_project_id: None, + focused_terminal_id: None, + fullscreen: None, + visible_project_ids, + folder_filter: None, + bounds: None, + sidebar_open: None, + }]; + + // Shared projection: ordered projects + folders + flat back-compat + // fields → `StateResponse` (identical to the GUI loop). + let resp = build_state_response( + sv, + data, + &git_statuses, + &services_by_project, + hidden_project_ids, + &size_map, + windows, + ); + + // `match` (not `.expect`) so the daemon-core crate stays clean + // under `clippy::expect_used` had it been enabled — the serialize + // is unreachable-fail for a well-formed DTO. + match serde_json::to_value(resp) { + Ok(v) => CommandResult::Ok(Some(v)), + Err(e) => CommandResult::Err(format!("failed to serialize state: {e}")), + } + } + + // ── GetTerminalSizes ───────────────────────────────────────────── + RemoteCommand::GetTerminalSizes { terminal_ids } => { + let terms = terminals.lock(); + let mut sizes: HashMap = HashMap::new(); + for id in &terminal_ids { + if let Some(term) = terms.get(id) { + let s = term.resize_state.lock().size; + sizes.insert(id.clone(), (s.cols, s.rows)); + } + } + match serde_json::to_value(sizes) { + Ok(v) => CommandResult::Ok(Some(v)), + Err(e) => CommandResult::Err(format!("failed to serialize sizes: {e}")), + } + } + + // ── RenderSnapshot ─────────────────────────────────────────────── + RemoteCommand::RenderSnapshot { terminal_id } => { + let ws = workspace.lock(); + match ensure_terminal(&terminal_id, &terminals, &*backend, &ws) { + Some(term) => CommandResult::OkBytes(term.render_snapshot()), + None => CommandResult::Err(format!("terminal not found: {terminal_id}")), + } + } + + // ── PasteImage ─────────────────────────────────────────────────── + RemoteCommand::PasteImage { terminal_id, path } => { + let ws = workspace.lock(); + match ensure_terminal(&terminal_id, &terminals, &*backend, &ws) { + Some(term) => { + // Bracketed paste of the server-local image path — same as + // a local image paste, so the focused TUI's own paste + // handler attaches it. + term.send_paste(&path); + CommandResult::Ok(Some(serde_json::json!({ "path": path }))) + } + None => CommandResult::Err(format!("terminal not found: {terminal_id}")), + } + } + }; + + if let Some(reply) = msg.reply { + let _ = reply.send(result); + } + } +} + +/// Materialize the PTYs for every restored project's uninitialized terminal +/// slots at daemon startup. +/// +/// Persisted `workspace.json` layouts carry terminal slots with +/// `terminal_id: None` (the normal saved state). In daemon-client mode nobody +/// ever materializes them: the GUI client cannot self-spawn over a remote +/// backend, and the daemon only calls +/// [`spawn_uninitialized_terminals`](okena_app_core::workspace::actions::execute::spawn_uninitialized_terminals) +/// from the `CreateTerminal` / `SplitTerminal` / `AddProject` action arms — not +/// on boot. A restored slot therefore never gets a PTY and renders blank +/// forever. +/// +/// This is the daemon's boot-time analogue of the GUI's +/// `spawn_terminals_for_project` (fired on project creation): it walks EVERY +/// loaded project and assigns ids + creates PTYs for its uninitialized slots, +/// so `/v1/state` serves real ids and the snapshot/live-PTY path works. +/// +/// All projects (not just the visible ones): the prior in-process GUI eagerly +/// spawned terminals when a project column was created, regardless of overview +/// visibility, and `hidden_project_ids` is a per-window viewport concern, not a +/// "don't run this project" signal. Spawning everything keeps behavior simple +/// and correct; project counts are small (one column per project), so this is +/// not too heavy. +/// +/// Runs on the LocalSet thread (mirroring the command loop's `execute_action`): +/// PTY spawning and hook execution may reach the reactor, and the +/// `WorkspaceCx::notify` bumps the `workspace_tick` whose observer task bumps +/// `state_version`. The freshly-assigned ids bump `data_version`, so the +/// existing autosave observer persists them — this introduces NO second writer. +/// +/// Must be invoked AFTER `spawn_observers` (so the tick reaches them) and BEFORE +/// the command loop starts serving clients (so `/v1/state` never exposes the +/// transient null slots). +pub fn materialize_uninitialized_terminals( + backend: &dyn TerminalBackend, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + terminals: &TerminalsRegistry, + settings: &Arc>, +) { + // Snapshot the project ids under a short lock, then drop it before spawning + // (each `spawn_uninitialized_terminals` call re-locks the workspace itself). + let project_ids: Vec = { + let ws = workspace.lock(); + ws.data().projects.iter().map(|p| p.id.clone()).collect() + }; + + // Snapshot settings once, mirroring the command loop's `execute_action` arm. + let app_settings = settings.lock().clone(); + + for project_id in project_ids { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + match spawn_uninitialized_terminals( + &mut ws, + &project_id, + backend, + terminals, + &app_settings, + None, + &mut cx, + ) { + okena_app_core::workspace::actions::execute::ActionResult::Err(e) => { + log::error!( + "startup: failed to materialize terminals for project {project_id}: {e}" + ); + } + okena_app_core::workspace::actions::execute::ActionResult::Ok(_) => {} + } + } +} + +/// Run a workspace-scoped action against the synthetic main window. +/// +/// Shared by the generic default arm and the `CloseTerminal` immediate-close +/// fallbacks. The caller snapshots `app_settings` and locks the workspace +/// (passed as `&mut Workspace`) BEFORE invoking this, so no lock is held across +/// an `.await`. Mirrors the inline body the default arm uses for +/// `WindowId::Main`. +#[allow(clippy::too_many_arguments)] +fn run_main_workspace_action( + action: ActionRequest, + ws: &mut Workspace, + focus_manager: &mut FocusManager, + backend: &Arc, + terminals: &TerminalsRegistry, + app_settings: &AppSettings, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, +) -> CommandResult { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let result = execute_action( + action, + ws, + WindowId::Main, + focus_manager, + &**backend, + terminals, + app_settings, + &mut cx, + ) + .into_command_result(); + + // Drain any terminal kills the action queued (delete_project, + // remove_worktree_project, the grace==0 immediate close, …) and tear down + // their PTYs + persistent session backends. The GUI client does this via a + // `Workspace` observer; the daemon has no equivalent observer, so without + // this a delete removes the project's state but leaks its PTY / dtach / tmux + // processes. Mirrors the soft-close finalize paths (`CloseTerminalNow`, the + // grace-expiry poll), which drain + kill the same way. + for id in ws.drain_pending_terminal_kills() { + backend.kill(&id); + terminals.lock().remove(&id); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{StubBackend, StubTransport, default_settings, empty_workspace_data}; + use okena_core::api::StateResponse; + use std::collections::HashSet; + + use okena_remote_server::bridge::{BridgeReceiver, BridgeSender, bridge_channel}; + use okena_state::WorkspaceData; + use okena_terminal::backend::TerminalBackend; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use tokio::sync::oneshot; + + /// Bundle of the shared state + channels the loop needs, so each test can + /// spawn the loop and keep handles to inspect afterwards. + struct Harness { + workspace: Arc>, + backend: Arc, + workspace_tick: watch::Sender, + terminals: TerminalsRegistry, + state_version: Arc>, + git_status_tx: Arc>>, + service_manager: Arc>, + service_tick: watch::Sender, + settings: Arc>, + daemon_config: DaemonConfig, + } + + impl Harness { + fn spawn_loop(self, bridge_rx: BridgeReceiver) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_local(daemon_command_loop( + bridge_rx, + self.backend, + self.workspace, + self.workspace_tick, + None, + None, + self.terminals, + self.state_version, + self.git_status_tx, + self.service_manager, + self.service_tick, + tokio::runtime::Handle::current(), + self.settings, + self.daemon_config, + Arc::new(Mutex::new(HashMap::new())), + tokio::sync::mpsc::unbounded_channel().0, + )) + } + } + + fn harness() -> Harness { + let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data()))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let settings = Arc::new(Mutex::new(default_settings())); + let daemon_config = DaemonConfig::new(settings.clone()); + let (workspace_tick, _wtrx) = watch::channel(0u64); + let (service_tick, _strx) = watch::channel(0u64); + let (state_version, _svrx) = watch::channel(0u64); + let (git_status_tx, _gsrx) = watch::channel(HashMap::new()); + Harness { + workspace, + backend, + workspace_tick, + terminals, + state_version: Arc::new(state_version), + git_status_tx: Arc::new(git_status_tx), + service_manager, + service_tick, + settings, + daemon_config, + } + } + + async fn request( + bridge_tx: &BridgeSender, + command: RemoteCommand, + label: &str, + ) -> CommandResult { + let (reply_tx, reply_rx) = oneshot::channel(); + bridge_tx + .send(BridgeMessage { + command, + reply: Some(reply_tx), + }) + .await + .unwrap_or_else(|_| panic!("send {label}")); + reply_rx.await.unwrap_or_else(|_| panic!("{label} reply")) + } + + // ── Pure unit tests ────────────────────────────────────────────────────── + + #[test] + fn parse_window_id_main_maps_to_main() { + assert_eq!(parse_window_id("main"), Some(WindowId::Main)); + } + + #[test] + fn parse_window_id_valid_uuid_maps_to_extra() { + let id = uuid::Uuid::new_v4(); + assert_eq!(parse_window_id(&id.to_string()), Some(WindowId::Extra(id))); + } + + #[test] + fn parse_window_id_garbage_returns_none() { + assert_eq!(parse_window_id("garbage"), None); + assert_eq!(parse_window_id(""), None); + // A near-miss UUID (one char short) is still rejected. + assert_eq!(parse_window_id("550e8400-e29b-41d4-a716-44665544000"), None); + } + + #[test] + fn api_project_visibility_reads_from_hidden_set() { + use okena_app_core::remote_snapshot::api_project_visibility; + let hidden: HashSet = ["p1".to_string()].into_iter().collect(); + assert!(!api_project_visibility("p1", &hidden)); + assert!(api_project_visibility("p2", &hidden)); + } + + #[test] + fn api_project_visibility_empty_hidden_set_is_visible() { + use okena_app_core::remote_snapshot::api_project_visibility; + let hidden: HashSet = HashSet::new(); + assert!(api_project_visibility("p1", &hidden)); + } + + #[test] + fn git_poll_trigger_is_sent_only_after_success() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + send_git_poll_trigger_after_success( + &CommandResult::Err("nope".to_string()), + Some(GitPollTrigger::branch_change("p1".to_string())), + &tx, + ); + assert!(rx.try_recv().is_err()); + + send_git_poll_trigger_after_success( + &CommandResult::Ok(None), + Some(GitPollTrigger::branch_change("p1".to_string())), + &tx, + ); + let trigger = rx.try_recv().expect("success sends trigger"); + assert_eq!(trigger.project_id.as_deref(), Some("p1")); + assert!(trigger.poll_github); + assert!(trigger.invalidate_github); + } + + // ── Loop round-trip tests ───────────────────────────────────────────────── + + /// `GetState` returns `Ok(Some(v))` that deserializes into a `StateResponse` + /// with the single synthetic `"main"` window. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_state_round_trip() { + let h = harness(); + let (bridge_tx, bridge_rx) = bridge_channel(); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let handle = h.spawn_loop(bridge_rx); + let result = request(&bridge_tx, RemoteCommand::GetState, "GetState").await; + let value = match result { + CommandResult::Ok(Some(v)) => v, + other => panic!("expected Ok(Some), got {other:?}"), + }; + let resp: StateResponse = + serde_json::from_value(value).expect("deserialize StateResponse"); + assert_eq!(resp.windows.len(), 1, "single synthetic window"); + assert_eq!(resp.windows[0].id, "main"); + assert_eq!(resp.windows[0].kind, "main"); + assert!(resp.windows[0].active); + + // Drop the sender so `recv` errors and the loop task joins. + drop(bridge_tx); + handle.await.expect("loop task joins"); + }) + .await; + } + + /// App-scoped `GetSettingsSchema` returns `Ok(Some(_))` with settings keys. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_settings_schema_round_trip() { + let h = harness(); + let (bridge_tx, bridge_rx) = bridge_channel(); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let handle = h.spawn_loop(bridge_rx); + let result = request( + &bridge_tx, + RemoteCommand::Action(ActionRequest::GetSettingsSchema), + "GetSettingsSchema", + ) + .await; + match result { + CommandResult::Ok(Some(v)) => { + let obj = v.as_object().expect("schema is an object"); + assert!(obj.contains_key("font_size")); + assert!(obj.contains_key("theme_mode")); + } + other => panic!("expected Ok(Some), got {other:?}"), + } + + drop(bridge_tx); + handle.await.expect("loop task joins"); + }) + .await; + } + + /// A workspace-scoped action (`CreateFolder`) returns `Ok(_)` and mutates the + /// shared workspace. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn create_folder_action_mutates_workspace() { + let h = harness(); + let workspace_for_assert = h.workspace.clone(); + let (bridge_tx, bridge_rx) = bridge_channel(); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let handle = h.spawn_loop(bridge_rx); + let result = request( + &bridge_tx, + RemoteCommand::Action(ActionRequest::CreateFolder { name: "f".into() }), + "CreateFolder", + ) + .await; + assert!( + matches!(result, CommandResult::Ok(_)), + "expected Ok, got {result:?}" + ); + + // The shared workspace now has the folder. + { + let ws = workspace_for_assert.lock(); + assert_eq!(ws.data().folders.len(), 1, "folder was created"); + assert_eq!(ws.data().folders[0].name, "f"); + } + + drop(bridge_tx); + handle.await.expect("loop task joins"); + }) + .await; + } + + // ── Startup terminal materialization ────────────────────────────────────── + + /// A `WorkspaceData` carrying one project whose layout is a single + /// uninitialized terminal slot (`terminal_id: None`) — the normal persisted + /// state for a restored project. `path` is the project cwd the PTY spawns in. + fn workspace_with_uninitialized_terminal(path: &str) -> WorkspaceData { + use okena_state::{LayoutNode, ProjectData}; + let project = ProjectData { + id: "p1".to_string(), + name: "Project p1".to_string(), + path: path.to_string(), + layout: Some(LayoutNode::Terminal { + terminal_id: None, + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }), + terminal_names: Default::default(), + hidden_terminals: Default::default(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: Default::default(), + is_remote: false, + connection_id: None, + service_terminals: Default::default(), + default_shell: None, + hook_terminals: Default::default(), + pinned: false, + last_activity_at: None, + }; + WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["p1".to_string()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + } + } + + /// `materialize_uninitialized_terminals` assigns a real `terminal_id` to a + /// restored `terminal_id: None` slot, creates the backing PTY (so it lands + /// in the registry), bumps `data_version` (so the autosave observer persists + /// the assigned id) and the `workspace_tick` (so the state-version observer + /// fires). This is the boot fix for blank restored terminals in + /// daemon-client mode. + #[test] + fn materialize_assigns_ids_and_spawns_ptys_for_restored_projects() { + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + use okena_workspace::state::LayoutNode; + + // A real, existing cwd for the spawned shell. + let tmp = std::env::temp_dir(); + let tmp_path = tmp.to_str().expect("temp dir is utf-8"); + + let (pty_manager, _pty_events) = PtyManager::new(SessionBackend::None); + let pty_manager = Arc::new(pty_manager); + let backend: Arc = Arc::new(LocalBackend::new(pty_manager.clone())); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(tmp_path), + ))); + let settings = Arc::new(Mutex::new(default_settings())); + let (workspace_tick, _wtrx) = watch::channel(0u64); + + // Preconditions: slot is uninitialized, registry empty, tick at 0. + let version_before = workspace.lock().data_version(); + let tick_before = *workspace_tick.borrow(); + assert!(terminals.lock().is_empty(), "registry starts empty"); + + materialize_uninitialized_terminals( + &*backend, + &workspace, + &workspace_tick, + &None, + &None, + &terminals, + &settings, + ); + + // The slot now has an id, the PTY is in the registry, and both the + // persistent data_version and the notify tick advanced. + let ws = workspace.lock(); + let project = ws.project("p1").expect("project p1 exists"); + let assigned = match project.layout.as_ref().expect("layout present") { + LayoutNode::Terminal { terminal_id, .. } => terminal_id.clone(), + other => panic!("expected a Terminal layout node, got {other:?}"), + }; + let assigned = assigned.expect("terminal slot got a real id"); + assert!( + terminals.lock().contains_key(&assigned), + "spawned PTY is registered under the assigned id" + ); + assert!( + ws.data_version() > version_before, + "data_version advanced so autosave persists the assigned id" + ); + assert!( + *workspace_tick.borrow() > tick_before, + "workspace_tick advanced so the state-version observer fires" + ); + } + + /// On an empty workspace `materialize_uninitialized_terminals` is a no-op: + /// no terminals spawned and the data_version is untouched. + #[test] + fn materialize_is_noop_for_empty_workspace() { + let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data()))); + let backend: Arc = Arc::new(StubBackend); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let settings = Arc::new(Mutex::new(default_settings())); + let (workspace_tick, _wtrx) = watch::channel(0u64); + + let version_before = workspace.lock().data_version(); + materialize_uninitialized_terminals( + &*backend, + &workspace, + &workspace_tick, + &None, + &None, + &terminals, + &settings, + ); + + assert!(terminals.lock().is_empty(), "no terminals spawned"); + assert_eq!( + workspace.lock().data_version(), + version_before, + "data_version untouched on empty workspace" + ); + } + + /// `TerminalBackend` that records every `kill`ed id so a test can assert a + /// deleted project's PTYs are torn down. + struct RecordingBackend { + killed: Arc>>, + } + + impl TerminalBackend for RecordingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + fn create_terminal(&self, _cwd: &str, _shell: Option<&ShellType>) -> anyhow::Result { + anyhow::bail!("recording backend: create_terminal not supported") + } + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("recording backend: reconnect_terminal not supported") + } + fn kill(&self, terminal_id: &str) { + self.killed.lock().push(terminal_id.to_string()); + } + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + fn supports_buffer_capture(&self) -> bool { + false + } + fn is_remote(&self) -> bool { + false + } + fn get_shell_pid(&self, _terminal_id: &str) -> Option { + None + } + fn get_service_pids(&self, _terminal_id: &str) -> Vec { + Vec::new() + } + } + + /// A single-terminal project whose terminal already has a real id. + fn workspace_with_initialized_terminal(terminal_id: &str) -> WorkspaceData { + use okena_state::{LayoutNode, ProjectData}; + let project = ProjectData { + id: "p1".to_string(), + name: "Project p1".to_string(), + path: "/tmp".to_string(), + layout: Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id.to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }), + terminal_names: Default::default(), + hidden_terminals: Default::default(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: Default::default(), + is_remote: false, + connection_id: None, + service_terminals: Default::default(), + default_shell: None, + hook_terminals: Default::default(), + pinned: false, + last_activity_at: None, + }; + WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["p1".to_string()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + } + } + + /// Deleting a project through the generic daemon action path must tear down + /// its terminals' PTYs. `run_main_workspace_action` drains the kill queue + /// `delete_project` fills — the GUI does this via a `Workspace` observer, but + /// the daemon has none, so without the drain the PTY/session would leak. + #[test] + fn delete_project_drains_and_kills_queued_terminals() { + let killed = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = + Arc::new(RecordingBackend { killed: killed.clone() }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let mut workspace = Workspace::new(workspace_with_initialized_terminal("t1")); + let mut focus_manager = FocusManager::new(); + let settings = default_settings(); + let (workspace_tick, _wtrx) = watch::channel(0u64); + + let result = run_main_workspace_action( + ActionRequest::DeleteProject { + project_id: "p1".to_string(), + }, + &mut workspace, + &mut focus_manager, + &backend, + &terminals, + &settings, + &workspace_tick, + &None, + &None, + ); + + assert!( + matches!(result, CommandResult::Ok(_)), + "delete should succeed: {result:?}" + ); + assert!(workspace.project("p1").is_none(), "project removed from state"); + assert_eq!( + &*killed.lock(), + &vec!["t1".to_string()], + "the deleted project's terminal PTY was killed, not leaked" + ); + } +} diff --git a/crates/okena-daemon-core/src/daemon.rs b/crates/okena-daemon-core/src/daemon.rs new file mode 100644 index 000000000..680492b68 --- /dev/null +++ b/crates/okena-daemon-core/src/daemon.rs @@ -0,0 +1,465 @@ +//! Final daemon assembly: the GPUI-free analogue of `okena-app`'s +//! [`HeadlessApp`](okena_app::app::headless::HeadlessApp). +//! +//! [`HeadlessApp`] wires the headless GUI mode: it stands up the workspace +//! entity, PTY manager, service manager, git watcher, the remote command bridge, +//! and the [`RemoteServer`](okena_remote_server::server::RemoteServer), all on +//! GPUI's main-thread reactor. [`DaemonCore`] does the exact same wiring with no +//! GPUI in scope: the shared state lives behind `Arc>` in +//! [`DaemonReactor`](crate::reactor::DaemonReactor), the `cx.observe` closures +//! become the `watch`-channel-driven observer tasks +//! ([`spawn_observers`](crate::reactor::DaemonReactor::spawn_observers)), and the +//! GPUI command loop becomes [`daemon_command_loop`](crate::command_loop). +//! +//! [`DaemonCore::new`] builds everything and starts the remote server (so its +//! port + pairing info are printed before [`run`](DaemonCore::run) blocks); +//! [`DaemonCore::run`] drives the reactor tasks on a +//! [`LocalSet`](tokio::task::LocalSet) until the bridge closes or the process +//! receives ctrl-c. +//! +//! ## Why a `LocalSet` +//! +//! The reactor tasks ([`spawn_observers`](crate::reactor::DaemonReactor::spawn_observers), +//! [`run_pty_loop`](crate::pty_loop::run_pty_loop), the service manager's +//! `spawn_main` restarts, and the service arms of +//! [`daemon_command_loop`](crate::command_loop::daemon_command_loop)) use +//! `tokio::task::spawn_local`, which requires a running `LocalSet`. They are +//! therefore spawned from inside [`LocalSet::block_on`](tokio::task::LocalSet::block_on) +//! on the multi-thread runtime; the blocking subprocess offloads still reach the +//! multi-thread pool via the held [`Handle`](tokio::runtime::Handle). +//! +//! ## Lifecycle +//! +//! The daemon is UI-owned: the spawning UI starts it and kills it. So +//! [`run`](DaemonCore::run) blocking until the bridge closes (the remote server +//! is gone) or ctrl-c arrives is the intended behavior — there is no other +//! shutdown surface. +//! +//! ## Testing +//! +//! This type is integration-verified via the `okena-daemon` binary (the next +//! step), not unit tests: [`new`](DaemonCore::new) binds a real TCP port and +//! writes `remote.json` to the real config dir, which would be flaky and racy +//! with any other running instance. The wired-together pieces each have their +//! own unit tests in their respective modules. +//! +//! ## Lifecycle hooks +//! +//! The reactor is built with a real `HookRunner` / `HookMonitor` (constructed +//! from the daemon's terminal backend + registry). The action layer reaches +//! them through `WorkspaceCx::{hook_runner,hook_monitor}`, so project/worktree +//! lifecycle hooks fire in the daemon and their PTYs reach clients over the +//! normal remote terminal path. (Surfacing the `HookMonitor`'s in-flight/run +//! status into `StateResponse` for a client-side hooks panel is a follow-up.) + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; + +use async_channel::Receiver; +use okena_core::api::{ApiGitStatus, ApiToast}; +use okena_core::git_poll::GitPollTrigger; +use okena_hooks::{HookMonitor, HookRunner}; +use okena_remote_server::auth::AuthStore; +use okena_remote_server::bridge::{self, BridgeReceiver}; +use okena_remote_server::pty_broadcaster::PtyBroadcaster; +use okena_remote_server::server::RemoteServer; +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::{LocalBackend, TerminalBackend}; +use okena_terminal::pty_manager::{PtyEvent, PtyManager}; +use okena_terminal::session_backend::SessionBackend; +use okena_workspace::persistence::{AppSettings, LockGuard, acquire_instance_lock}; +use okena_workspace::state::{Workspace, WorkspaceData}; +use parking_lot::Mutex; +use tokio::sync::{mpsc, watch}; + +use crate::daemon_config::DaemonConfig; +use crate::reactor::DaemonReactor; + +/// Inputs needed to construct a daemon. +pub struct DaemonParams { + /// The persisted workspace state to drive (projects, layouts, windows). + pub workspace_data: WorkspaceData, + /// The app settings (font / theme / shell / session backend), loaded once at + /// startup and shared with [`DaemonConfig`] as the settings write path. + pub settings: AppSettings, + /// The session backend (tmux / dtach / screen / none) the PTY manager uses + /// to spawn terminals. + pub session_backend: SessionBackend, + /// TCP addresses the remote server binds to. The UI-owned daemon always + /// includes loopback for same-host clients; remote mode may add a LAN bind. + pub listen_addrs: Vec, + /// Whether the remote server should serve TLS (dual-stack http+https). + pub tls_enabled: bool, +} + +/// The assembled, GPUI-free daemon: owns the tokio runtime, the shared reactor +/// state, the running remote server, and the channels the reactor tasks use. +/// +/// Built by [`new`](DaemonCore::new); driven by [`run`](DaemonCore::run). See the +/// module docs for the lifecycle and the `LocalSet` requirement. +pub struct DaemonCore { + /// The multi-thread tokio runtime the reactor tasks run on (via a + /// `LocalSet` in [`run`](DaemonCore::run)). + runtime: tokio::runtime::Runtime, + /// Shared, GPUI-free daemon state (workspace + service manager + ticks). + reactor: Arc, + /// The running remote server. Kept alive for the daemon's lifetime; dropping + /// it stops the server and removes `remote.json`. + remote_server: RemoteServer, + /// Receiving end of the command bridge — the remote server sends commands, + /// the command loop consumes them. + bridge_rx: BridgeReceiver, + /// Terminal backend over the PTY manager, threaded into the command loop's + /// `execute_action` / `ensure_terminal`. + backend: Arc, + /// Shared terminal registry: PTY `Data` events route into it, the command + /// loop reads sizes / snapshots from it. + terminals: TerminalsRegistry, + /// The PTY manager, for `cleanup_exited` / `kill` in the PTY loop. + pty_manager: Arc, + /// PTY event receiver, drained by [`run_pty_loop`](crate::pty_loop::run_pty_loop). + pty_events: Receiver, + /// Server-readable view of the reactor's `state_version` (shared channel — + /// see [`new`](DaemonCore::new)). + state_version: Arc>, + /// Git-status channel the poll loop publishes into and the server broadcasts. + git_status_tx: Arc>>, + /// Toast broadcast: a periodic drain task (see [`run`](DaemonCore::run)) + /// pushes the `HookMonitor`'s pending toasts here as [`ApiToast`]s and the + /// server fans them out to clients. The daemon has no surface of its own, so + /// this is how hook-failure notifications reach the GUI. + toast_tx: Arc>, + /// Client terminal subscriptions (connection id -> subscribed terminal ids), + /// shared with the remote server. The git poll reads it to fan out the + /// expensive `gh` PR/CI lookups only for projects a client is viewing. + remote_subscribed_terminals: + Arc>>>, + /// Git poll wake-up sender shared by command handling and the remote server. + git_poll_trigger_tx: mpsc::UnboundedSender, + /// Git poll wake-up receiver consumed by [`run`](DaemonCore::run). + git_poll_trigger_rx: mpsc::UnboundedReceiver, + /// Shared settings cell (the [`DaemonConfig`] write path; also read by the + /// command loop's `execute_action` for hooks / worktree / default shell). + settings: Arc>, + /// GPUI-free settings/theme handler for the app-scoped remote actions. + daemon_config: DaemonConfig, + /// Single-writer instance lock (§5). The daemon is the sole owner of the + /// profile's `workspace.json` + lock; held for the daemon's lifetime so a + /// second instance (or a classic in-process GUI) cannot clobber the profile. + /// Released on drop at the end of [`run`](DaemonCore::run). + _instance_lock: LockGuard, + /// Graceful-shutdown trigger fired by `POST /v1/shutdown` (via the remote + /// server's `AppState`). [`run`](DaemonCore::run) awaits it and returns, + /// which drops the server (socket unlink + remote.json removal) and releases + /// the instance lock on drop — a clean teardown, no SIGKILL. + shutdown_requested: Arc, +} + +impl DaemonCore { + /// Build the daemon and start its remote server. + /// + /// Mirrors [`HeadlessApp::new`](okena_app::app::headless::HeadlessApp) with no + /// GPUI: stands up the PTY manager + broadcaster, the terminal registry + + /// backend, the workspace + reactor, the settings + config, the server wiring + /// channels, then starts the [`RemoteServer`] and prints its pairing info. + /// The reactor tasks are NOT started here — that is [`run`](DaemonCore::run)'s + /// job (they need a `LocalSet`). + pub fn new(params: DaemonParams) -> anyhow::Result { + // ── 0. Acquire the single-writer instance lock FIRST ───────────────── + // §5: exactly one process owns the profile's persistence + lock. The + // daemon is that process; the `--daemon-client` GUI deliberately skips + // the lock. Acquire before binding a port / writing `remote.json` so a + // collision fails fast with no side effects. Held for the daemon's + // lifetime (dropped at the end of `run`). + let instance_lock = acquire_instance_lock()?; + + // ── 1. Multi-thread tokio runtime backing the reactor ──────────────── + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("okena-daemon") + .build()?; + let handle = runtime.handle().clone(); + + // ── 2. PTY manager + broadcaster + registry + backend ──────────────── + let (pty_manager, pty_events) = PtyManager::new(params.session_backend); + let pty_manager = Arc::new(pty_manager); + // Per-profile Claude account isolation: push CLAUDE_CONFIG_DIR (or its + // active removal for the default ~/.claude) into the PTYs the daemon + // spawns, so `claude` invocations inside daemon-served terminals read the + // right account — the same override the GUI's `sync_claude_pty_env` + // applies, computed by the gpui-free `okena_workspace::claude_env` from + // the daemon's own settings (the GUI's `ExtensionSettingsStore` is gpui). + pty_manager.set_extra_env(okena_workspace::claude_env::claude_pty_env_for_settings( + ¶ms.settings, + )); + let broadcaster = Arc::new(PtyBroadcaster::new()); + pty_manager.set_output_sink(broadcaster.clone()); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(LocalBackend::new(pty_manager.clone())); + + // ── 3. Workspace + reactor ─────────────────────────────────────────── + let workspace = Workspace::new(params.workspace_data); + // Lifecycle hooks: construct the same services the GUI sets as globals + // (`HookRunner::new(backend, terminals)` in app/mod.rs, `HookMonitor::new()` + // in main.rs). The action layer already reaches them through + // `WorkspaceCx::{hook_runner,hook_monitor}` (the daemon's + // `DaemonWorkspaceCx` returns these), and hook PTYs register in the same + // `terminals` registry + broadcast over the same `PtyBroadcaster`, so + // hook terminals reach clients via the normal remote terminal path. Both + // ctors are gpui-free (okena-hooks built without the gpui feature here). + let hook_runner = HookRunner::new(backend.clone(), terminals.clone()); + let hook_monitor = HookMonitor::new(); + let reactor = Arc::new(DaemonReactor::new( + workspace, + backend.clone(), + terminals.clone(), + Some(hook_runner), + Some(hook_monitor), + handle.clone(), + )); + + // ── 4. Settings + config ───────────────────────────────────────────── + let settings = Arc::new(Mutex::new(params.settings)); + let mut daemon_config = DaemonConfig::new(settings.clone()); + // Seed the process palette from the active theme so daemon terminals + // answer OSC color queries (no views push per-terminal palettes here; + // client mirrors deliberately don't answer). Kept in sync afterwards by + // `DaemonConfig::apply_active_theme` on theme changes. + { + use okena_app_core::remote_config::ConfigBackend as _; + let (mode, custom_id) = { + let s = settings.lock(); + (s.theme_mode, s.custom_theme_id.clone()) + }; + let colors = daemon_config.active_theme_colors(mode, custom_id.as_deref()); + okena_terminal::terminal::set_process_palette(colors); + } + + // ── 5. Server wiring channels ──────────────────────────────────────── + // Shared-watch trick: `tokio::sync::watch::Sender` is `Clone` and clones + // share one underlying channel. The server + command loop READ this + // `state_version`; the reactor's observers / PTY loop / git poll BUMP + // `reactor.state_version` (the same channel), so reads observe the bumps. + let state_version = Arc::new(reactor.state_version.clone()); + let git_status_tx = Arc::new(watch::Sender::new(HashMap::new())); + // Toast broadcast: the periodic drain task in `run()` is the sole + // producer; each connected client subscribes a receiver. Capacity bounds + // the per-client backlog — a lagging client drops non-critical toasts. + let toast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let auth_store = Arc::new(AuthStore::new()); + let remote_subscribed_terminals = Arc::new(std::sync::RwLock::new(HashMap::new())); + let next_connection_id = Arc::new(AtomicU64::new(0)); + // Live-WS-connection count + graceful-shutdown trigger for `/v1/shutdown`. + let active_connections = Arc::new(AtomicU64::new(0)); + let shutdown_requested = Arc::new(tokio::sync::Notify::new()); + let (bridge_tx, bridge_rx) = bridge::bridge_channel(); + let (git_poll_trigger_tx, git_poll_trigger_rx) = mpsc::unbounded_channel(); + + // ── 6. Start the remote server ─────────────────────────────────────── + // It owns its OWN internal tokio runtime and talks to us only via the + // channels above; that is fine — the daemon's runtime drives the reactor. + let remote_server = RemoteServer::start( + bridge_tx, + auth_store.clone(), + broadcaster.clone(), + state_version.clone(), + params.listen_addrs, + git_status_tx.clone(), + toast_tx.clone(), + remote_subscribed_terminals.clone(), + Some(git_poll_trigger_tx.clone()), + next_connection_id, + active_connections, + Some(shutdown_requested.clone()), + params.tls_enabled, + env!("CARGO_PKG_VERSION"), + )?; + + // ── 7. Print pairing info to stdout (mirror headless.rs) ────────────── + let port = remote_server.port(); + let code = auth_store.get_or_create_code(); + log::info!("Remote server started on port {port}"); + println!("Remote server listening on port {port}"); + println!("Pairing code: {code} (expires in 60s)"); + if let Some(fp) = remote_server.cert_fingerprint() { + // Print the raw fingerprint string rather than pulling in + // okena-transport's formatter, keeping daemon-core's dep set lean. + println!("TLS cert fingerprint (SHA-256): {fp}"); + } + println!("Run `okena pair` for a fresh code."); + + // ── 8. Store exactly what `run()` needs ────────────────────────────── + // `broadcaster` and `auth_store` are now owned by the server; no + // duplicates are kept here. + Ok(Self { + runtime, + reactor, + remote_server, + bridge_rx, + backend, + terminals, + pty_manager, + pty_events, + state_version, + git_status_tx, + toast_tx, + remote_subscribed_terminals, + git_poll_trigger_tx, + git_poll_trigger_rx, + settings, + daemon_config, + _instance_lock: instance_lock, + shutdown_requested, + }) + } + + /// Drive the reactor on a [`LocalSet`](tokio::task::LocalSet) until shutdown. + /// + /// Spawns the observer tasks, the PTY loop, and the git poll, then runs the + /// command loop as the "main" task — racing it against ctrl-c so the daemon + /// can shut down cleanly in dev. Blocks until the bridge closes (the remote + /// server is gone) or ctrl-c arrives, then drops the server (stopping it and + /// removing `remote.json`). See the module docs for why this blocks. + pub fn run(self) -> anyhow::Result<()> { + let DaemonCore { + runtime, + reactor, + remote_server, + bridge_rx, + backend, + terminals, + pty_manager, + pty_events, + state_version, + git_status_tx, + toast_tx, + remote_subscribed_terminals, + git_poll_trigger_tx, + git_poll_trigger_rx, + settings, + daemon_config, + // Bound (not `..`) so the lock is held until the end of `run`, then + // released on drop after the server is stopped. + _instance_lock, + shutdown_requested, + } = self; + let handle = runtime.handle().clone(); + let local = tokio::task::LocalSet::new(); + local.block_on(&runtime, async move { + // Observers MUST be spawned inside the LocalSet (they `spawn_local`). + reactor.spawn_observers(); + tokio::task::spawn_local(crate::pty_loop::run_pty_loop( + pty_events, + terminals.clone(), + pty_manager.clone(), + reactor.service_manager.clone(), + handle.clone(), + reactor.service_tick.clone(), + // Daemon-owned workspace + hooks: the PTY loop runs the full + // terminal-exit lifecycle (hook-terminal exits, terminal.on_close, + // OSC hook-exit, soft-close reap) directly against this state. + crate::pty_loop::PtyLoopReactor { + workspace: reactor.workspace.clone(), + hook_runner: reactor.hook_runner.clone(), + hook_monitor: reactor.hook_monitor.clone(), + workspace_tick: reactor.workspace_tick.clone(), + settings: settings.clone(), + }, + reactor.state_version.clone(), + )); + tokio::task::spawn_local(crate::git_poll::run_git_poll( + reactor.workspace.clone(), + git_status_tx.clone(), + reactor.state_version.clone(), + remote_subscribed_terminals, + git_poll_trigger_rx, + )); + // Forward the daemon's HookMonitor toasts to clients. The daemon has + // no surface; this drains its pending toasts and broadcasts them over + // the same channel the remote server fans out (`WsOutbound::Toast`). + // `(*toast_tx).clone()` clones the inner `broadcast::Sender` (cheap, + // shares the channel) so the task can outlive this `Arc` handle. + tokio::task::spawn_local(crate::toast_poll::run_toast_poll( + reactor.hook_monitor.clone(), + (*toast_tx).clone(), + )); + + // Materialize PTYs for every restored project's uninitialized + // terminal slots BEFORE the command loop starts serving clients. + // Persisted layouts carry `terminal_id: None` slots that nobody + // else spawns in daemon-client mode (the GUI client can't self-spawn + // over a remote backend), so they would render blank forever. This + // assigns ids + creates PTYs for all loaded projects; the assigned + // ids bump `data_version` (the existing autosave observer persists + // them — no second writer) and `workspace_tick` (whose observer, + // spawned above, bumps `state_version`). Runs on the LocalSet thread + // because PTY/hook spawning may reach the reactor. + crate::command_loop::materialize_uninitialized_terminals( + &*backend, + &reactor.workspace, + &reactor.workspace_tick, + &reactor.hook_runner, + &reactor.hook_monitor, + &terminals, + &settings, + ); + + // Shared soft-close deadline map: the command loop arms a deadline + // when it ejects a busy terminal; the finalizer loop kills the PTY + // once it elapses. Spawn the finalizer BEFORE `backend`/`settings` + // are consumed by the command loop, cloning what both tasks need. + let soft_close_deadlines: crate::soft_close::SoftCloseDeadlines = + Arc::new(Mutex::new(HashMap::new())); + tokio::task::spawn_local(crate::soft_close::run_soft_close_poll( + reactor.workspace.clone(), + backend.clone(), + terminals.clone(), + reactor.workspace_tick.clone(), + reactor.hook_runner.clone(), + reactor.hook_monitor.clone(), + soft_close_deadlines.clone(), + )); + + // The command loop is the "main" task; it runs until the bridge + // closes. Race it against ctrl-c so the daemon can shut down cleanly. + let cmd = crate::command_loop::daemon_command_loop( + bridge_rx, + backend, + reactor.workspace.clone(), + reactor.workspace_tick.clone(), + reactor.hook_runner.clone(), + reactor.hook_monitor.clone(), + terminals.clone(), + state_version, + git_status_tx.clone(), + reactor.service_manager.clone(), + reactor.service_tick.clone(), + handle.clone(), + settings, + daemon_config, + soft_close_deadlines, + git_poll_trigger_tx, + ); + tokio::select! { + _ = cmd => log::info!("daemon command loop ended (remote server gone)"), + r = tokio::signal::ctrl_c() => { + if let Err(e) = r { + log::warn!("ctrl-c handler error: {e}"); + } + log::info!("daemon received ctrl-c, shutting down"); + } + // A client-aware `POST /v1/shutdown` accepted: return so the + // teardown below runs cleanly (no successor to hand off to). + _ = shutdown_requested.notified() => { + log::info!("daemon received shutdown request, shutting down"); + } + } + }); + // Keep `remote_server` alive across `block_on`; dropping it here stops + // the server and removes remote.json. + drop(remote_server); + Ok(()) + } +} diff --git a/crates/okena-daemon-core/src/daemon_config.rs b/crates/okena-daemon-core/src/daemon_config.rs new file mode 100644 index 000000000..522e1a7ca --- /dev/null +++ b/crates/okena-daemon-core/src/daemon_config.rs @@ -0,0 +1,190 @@ +//! GPUI-free settings & theme handlers for the headless daemon. +//! +//! This is the headless counterpart to the desktop app's +//! `okena-app/src/app/remote_config.rs`. Both now share the same logic via +//! [`okena_app_core::remote_config`]; this module only supplies the daemon's +//! [`ConfigBackend`] impl (a shared `Arc>` +//! backing store) and thin method wrappers over the shared functions. +//! +//! The data-vs-presentation split this migration follows: the daemon owns the +//! theme **preference** (the data — `theme_mode` + `custom_theme_id`, persisted +//! to settings.json) and the custom-theme files on disk. It does NOT own an +//! `AppTheme` entity, because applying colors to pixels is the client's job. +//! So `apply_active_theme` is a no-op here and `active_theme_colors` derives the +//! editable blob's colors straight from the persisted `theme_mode`. +//! +//! State is shared through a single `Arc>`, +//! loaded once at daemon startup via [`load_settings`]. [`DaemonConfig`] is the +//! write path; other daemon code reads the same `Arc`. +//! +//! [`load_settings`]: okena_workspace::persistence::load_settings + +use std::sync::Arc; + +use okena_app_core::remote_config::{self, ConfigBackend}; +use okena_core::api::CommandResult; +use okena_theme::custom::load_custom_themes; +use okena_theme::{ + ThemeColors, ThemeMode, DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, +}; +use okena_workspace::persistence::AppSettings; +use okena_workspace::settings::save_settings; +use parking_lot::Mutex; +use serde_json::Value; + +/// GPUI-free settings & theme handler backed by a shared +/// `Arc>`. +pub struct DaemonConfig { + settings: Arc>, +} + +impl DaemonConfig { + /// Build the handler over the daemon's single shared settings cell. + /// + /// The daemon loads settings once at startup (via `load_settings()`) into + /// this `Arc>`; this struct is the write path while + /// other daemon code reads the same `Arc`. + pub fn new(settings: Arc>) -> Self { + Self { settings } + } + + /// Return the full current settings as JSON. + pub fn get_settings(&mut self) -> CommandResult { + remote_config::get_settings(self) + } + + /// Deep-merge `patch` into the current settings, validate by + /// re-deserializing, persist, then replace the held value. + /// + /// Unlike the GUI there is no settings observer here, so changes to the + /// `remote_*` fields do NOT hot-restart the remote server — they apply on + /// the next daemon launch. On a save failure the held value is left + /// unchanged. + pub fn set_settings(&mut self, patch: Value) -> CommandResult { + remote_config::set_settings(self, patch) + } + + /// List built-in + custom themes, flagging the active one. + pub fn get_themes(&mut self) -> CommandResult { + remote_config::get_themes(self) + } + + /// Return a theme as an editable custom-theme blob (the active theme when + /// `id` is None). + pub fn get_theme(&mut self, id: Option) -> CommandResult { + remote_config::get_theme(self, id) + } + + /// Activate a theme: a built-in mode or a custom theme id. Persists the + /// preference to settings.json (there is no `AppTheme` to update). + pub fn set_theme(&mut self, id: String) -> CommandResult { + remote_config::set_theme(self, id) + } + + /// Write a custom theme JSON file (a full `CustomThemeConfig`) and, when + /// `activate`, switch the persisted preference to it. + pub fn save_custom_theme(&mut self, id: String, config: Value, activate: bool) -> CommandResult { + remote_config::save_custom_theme(self, id, config, activate) + } +} + +impl ConfigBackend for DaemonConfig { + fn load_settings(&mut self) -> AppSettings { + self.settings.lock().clone() + } + + fn store_settings(&mut self, new: &AppSettings) -> Result<(), String> { + // Persist to disk first; only replace the held value on success so a + // save failure leaves the in-memory settings unchanged. + save_settings(new).map_err(|e| e.to_string())?; + *self.settings.lock() = new.clone(); + Ok(()) + } + + fn apply_active_theme(&mut self, mode: ThemeMode, custom_colors: Option) { + // Headless: no live theme surface to update (the preference is already + // persisted), but the daemon's terminals answer OSC color queries from + // the process palette — keep it in sync with the active theme. + let colors = match custom_colors { + Some(colors) => colors, + None => self.active_theme_colors(mode, None), + }; + okena_terminal::terminal::set_process_palette(colors); + } + + fn active_theme_colors(&mut self, mode: ThemeMode, custom_id: Option<&str>) -> ThemeColors { + // No AppTheme entity here: derive the editable blob's colors straight + // from the held `theme_mode`. The daemon has no windowing system to + // detect light/dark, so Auto defaults to dark for this editable blob. + match mode { + ThemeMode::Dark | ThemeMode::Auto => DARK_THEME, + ThemeMode::Light => LIGHT_THEME, + ThemeMode::PastelDark => PASTEL_DARK_THEME, + ThemeMode::HighContrast => HIGH_CONTRAST_THEME, + ThemeMode::Custom => { + let target = custom_id.map(|cid| format!("custom:{cid}")); + match target + .and_then(|t| load_custom_themes().into_iter().find(|(i, _)| i.id == t)) + { + Some((_, colors)) => colors, + // Custom mode but no resolvable custom theme: fall back to + // dark so we still return an editable blob. + None => DARK_THEME, + } + } + } + } +} + +/// Return a defaults instance of the settings — every key with its default +/// value, as a de-facto schema agents can read to discover available keys. +pub fn get_settings_schema() -> CommandResult { + remote_config::get_settings_schema() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::default_settings; + use serde_json::json; + + fn config_with(settings: AppSettings) -> DaemonConfig { + DaemonConfig::new(Arc::new(Mutex::new(settings))) + } + + #[test] + fn get_settings_returns_held_value_round_trips() { + let mut settings = default_settings(); + settings.font_size = 17.5; + settings.font_family = "Fira Code".to_string(); + let mut cfg = config_with(settings); + + match cfg.get_settings() { + CommandResult::Ok(Some(v)) => { + assert_eq!(v["font_size"], json!(17.5)); + assert_eq!(v["font_family"], json!("Fira Code")); + // Round-trips back into AppSettings. + let back: AppSettings = serde_json::from_value(v).expect("round-trip"); + assert_eq!(back.font_size, 17.5); + assert_eq!(back.font_family, "Fira Code"); + } + other => panic!("expected Ok(Some), got {other:?}"), + } + } + + #[test] + fn get_settings_schema_contains_expected_keys() { + match get_settings_schema() { + CommandResult::Ok(Some(v)) => { + let obj = v.as_object().expect("schema is an object"); + assert!(obj.contains_key("font_size")); + assert!(obj.contains_key("theme_mode")); + assert!(obj.contains_key("font_family")); + // The schema deserializes back into AppSettings (it IS the + // defaults instance). + serde_json::from_value::(v).expect("schema round-trips"); + } + other => panic!("expected Ok(Some), got {other:?}"), + } + } +} diff --git a/crates/okena-daemon-core/src/git_poll.rs b/crates/okena-daemon-core/src/git_poll.rs new file mode 100644 index 000000000..9b1a9b98d --- /dev/null +++ b/crates/okena-daemon-core/src/git_poll.rs @@ -0,0 +1,627 @@ +//! GPUI-free git-status poller: the headless analogue of +//! `okena-views-git`'s `GitStatusWatcher` (its `watcher.rs`), minus the GUI. +//! +//! The GUI's watcher polls git status every ~5s for the set of *visible* +//! non-remote projects, caches per-project [`GitStatus`], and pushes a slimmed +//! [`ApiGitStatus`] map into a `tokio::sync::watch` channel (`remote_tx`) that +//! the remote server broadcasts to clients. The daemon reproduces exactly that +//! `watch`-channel output path with `okena-git` directly (NOT `okena-views-git`, +//! which is a GUI crate). +//! +//! ## What is faithfully ported vs. dropped +//! +//! * **Ported:** the 5s cadence, the visible-non-remote project selection (via +//! [`Workspace::all_visible_project_ids`], which is the union across the main +//! and all extra windows — the same set the GUI uses), running +//! [`okena_git::refresh_git_status`] on a blocking pool under [`Lane::Poll`], +//! building the `HashMap`, and `send_replace`-ing it. +//! Also ported: the `gh` PR/CI fan-out and its adaptive cadence +//! ([`okena_git::repository::get_pr_info`] / [`get_ci_checks`], gated by +//! [`has_github_remote`]), with the across-cycle `pr_infos`/`ci_checks` caches +//! and the two-phase publish — basic gix status is published first so the +//! branch/diff badge never waits on a (possibly hanging) `gh` call, then the +//! richer PR/CI data is merged in and published as a follow-up. +//! * **Dropped (GUI/remote-server concerns not present in daemon-core):** the +//! `cx.notify()` local-UI push, the branch-only warmup, and the +//! remotely-subscribed-terminals augmentation of the poll set (that set lives +//! in the remote server, which wires the daemon — it can extend the poll set +//! there later). The primary output is the `git_status_tx` watch, exactly as +//! in the GUI. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use okena_core::api::ApiGitStatus; +use okena_core::git_poll::GitPollTrigger; +use okena_core::process::{Lane, with_lane}; +use okena_git::{self as git, GitStatus}; +use okena_workspace::state::Workspace; +use parking_lot::Mutex; +use tokio::sync::{mpsc, watch}; + +/// How often to poll git status. Mirrors the GUI watcher's `GIT_POLL_INTERVAL`. +const GIT_POLL_INTERVAL: Duration = Duration::from_secs(5); +/// How many git poll cycles between PR URL checks (~60s). Mirrors the GUI +/// watcher's `PR_POLL_EVERY_N_CYCLES`. +const PR_POLL_EVERY_N_CYCLES: u64 = 12; +/// How many git poll cycles between CI check polls when checks are pending +/// (~15s). Mirrors the GUI watcher's `CI_PENDING_POLL_EVERY_N_CYCLES`. +const CI_PENDING_POLL_EVERY_N_CYCLES: u64 = 3; +/// How many git poll cycles between CI check polls when checks are settled +/// (~60s). Mirrors the GUI watcher's `CI_SETTLED_POLL_EVERY_N_CYCLES`. +const CI_SETTLED_POLL_EVERY_N_CYCLES: u64 = 12; + +/// Project the local [`GitStatus`] onto the slimmer wire type pushed to remote +/// clients. GPUI-free reimplementation of `okena-views-git`'s `to_api`. +fn to_api(s: &GitStatus) -> ApiGitStatus { + ApiGitStatus { + branch: s.branch.clone(), + lines_added: s.lines_added, + lines_removed: s.lines_removed, + pr_info: s.pr_info.clone(), + ci_checks: s.ci_checks.clone(), + ahead: s.ahead, + behind: s.behind, + unpushed: s.unpushed, + review_base: s.review_base.clone(), + default_branch: s.default_branch.clone(), + } +} + +#[derive(Default)] +struct TriggerAccumulator { + /// Unconditional `gh` refreshes. Used when existing PR/CI cache is invalid. + force_gh_ids: HashSet, + /// Conditional refreshes. These become forced only if PR/CI cache is absent. + candidate_gh_ids: HashSet, + /// Projects whose cached PR/CI belongs to a previous branch. + invalidate_gh_ids: HashSet, +} + +impl TriggerAccumulator { + fn record(&mut self, trigger: GitPollTrigger) { + let Some(project_id) = trigger.project_id else { + return; + }; + if trigger.invalidate_github { + self.invalidate_gh_ids.insert(project_id.clone()); + self.force_gh_ids.insert(project_id); + } else if trigger.poll_github { + self.candidate_gh_ids.insert(project_id); + } + } + + fn clear(&mut self) { + self.force_gh_ids.clear(); + self.candidate_gh_ids.clear(); + self.invalidate_gh_ids.clear(); + } +} + +/// Run the daemon git-status poll loop until the `watch` channel is closed (all +/// receivers dropped → the server is gone). +/// +/// Each cycle: +/// 1. Lock the workspace, snapshot the `(id, path)` of the projects to poll +/// (visible, non-remote — see module docs), then DROP the lock. +/// 2. Run [`git::refresh_git_status`] for each on a blocking pool +/// ([`tokio::task::spawn_blocking`], under [`Lane::Poll`]) so the gix dir-walk +/// and diff never stall the reactor thread. Merge in any cached PR/CI so the +/// badges don't blank mid-cycle, then build + `send_replace` the slimmed +/// `HashMap` on change — BEFORE the slow `gh` calls. +/// 3. On the PR/CI cadence (skip cycle 0; first check at cycle 1), fan out the +/// `gh` PR/CI lookups — also on the blocking pool, gated by `has_github_remote` +/// — merge the results into the across-cycle caches, then re-publish the +/// statuses with the richer PR/CI data as a follow-up update. +/// +/// Bumps `state_version` on a real change so a snapshot/broadcast observer can +/// react; the *primary* output is the `git_status_tx` watch. +pub async fn run_git_poll( + workspace: Arc>, + git_status_tx: Arc>>, + state_version: watch::Sender, + remote_subscribed_terminals: Arc>>>, + mut trigger_rx: mpsc::UnboundedReceiver, +) { + // Last-published per-project statuses, kept across cycles so we only + // re-broadcast + bump on real change. Keyed by the richer `GitStatus` + // (which derives `PartialEq`) — the GUI's `commit_statuses` compares the + // same type. `ApiGitStatus` (the wire projection) has no `PartialEq`. + let mut last: HashMap = HashMap::new(); + + // Across-cycle PR/CI caches keyed by project ID, mirroring the GUI watcher's + // `pr_infos` / `ci_checks`. The expensive `gh` fan-out only runs on the + // cadence below; between those cycles the cached values are merged into every + // status so the badges don't blank. Merge (not replace) on update so a + // project that drops out of the visible set keeps its last-known PR/CI. + let mut pr_infos: HashMap> = HashMap::new(); + let mut ci_checks: HashMap> = HashMap::new(); + // Drives the adaptive CI cadence: faster polling while any check is pending. + let mut any_pending_ci = false; + let mut cycle: u64 = 0; + let mut trigger_acc = TriggerAccumulator::default(); + let mut known_gh_ids: HashSet = HashSet::new(); + let mut trigger_rx_closed = false; + + loop { + drain_git_poll_triggers(&mut trigger_rx, &mut trigger_acc, &mut trigger_rx_closed); + if clear_github_cache_for_ids( + &trigger_acc.invalidate_gh_ids, + &mut pr_infos, + &mut ci_checks, + ) { + any_pending_ci = has_pending_ci(&ci_checks); + } + + // ── 1. Snapshot the projects to poll under the workspace lock ──────── + // git status (gix, cheap, local-only) is polled for EVERY non-remote + // project: the daemon's own window visibility is synthetic and must not + // gate it, or a project a CLIENT views (but the daemon's window hides) + // would show no branch/diff/PR badge. The expensive `gh` PR/CI fan-out + // (network) is instead gated to `gh_ids` — projects visible in some + // window PLUS any with a remotely-subscribed terminal (i.e. what a + // client is actually looking at). Mirrors the GUI watcher's split. + let (projects, mut gh_ids): (Vec<(String, String)>, HashSet) = { + let ws = workspace.lock(); + let projects = ws + .projects() + .iter() + .filter(|p| !p.is_remote) + .map(|p| (p.id.clone(), p.path.clone())) + .collect(); + + let mut gh_ids = ws.all_visible_project_ids(); + if let Ok(subscribed) = remote_subscribed_terminals.read() { + for terminal_ids in subscribed.values() { + for tid in terminal_ids { + if let Some(p) = ws.find_project_for_terminal(tid) + && !p.is_remote + { + gh_ids.insert(p.id.clone()); + } + } + } + } + gh_ids.extend(trigger_acc.force_gh_ids.iter().cloned()); + gh_ids.extend(trigger_acc.candidate_gh_ids.iter().cloned()); + (projects, gh_ids) + }; + if cycle != 0 || !known_gh_ids.is_empty() { + trigger_acc.force_gh_ids.extend(missing_github_stats_ids( + gh_ids.difference(&known_gh_ids), + &pr_infos, + &ci_checks, + )); + } + trigger_acc.force_gh_ids.extend(missing_github_stats_ids( + trigger_acc.candidate_gh_ids.iter(), + &pr_infos, + &ci_checks, + )); + gh_ids.extend(trigger_acc.force_gh_ids.iter().cloned()); + known_gh_ids = gh_ids.clone(); + + // ── 2. Refresh each project's git status on the blocking pool ──────── + let mut new_statuses: HashMap = HashMap::new(); + for (id, path) in &projects { + let id = id.clone(); + let path = path.clone(); + let status = tokio::task::spawn_blocking(move || { + with_lane(Lane::Poll, || git::refresh_git_status(Path::new(&path))) + }) + .await; + match status { + Ok(Some(mut status)) => { + // Inject whatever PR/CI we already have cached so a still-fresh + // badge doesn't blank between `gh` cadence cycles. + status.pr_info = pr_infos.get(&id).cloned().flatten(); + status.ci_checks = ci_checks.get(&id).cloned().flatten(); + new_statuses.insert(id, status); + } + // No status (not a repo / transient miss with no cache): omit it, + // matching the GUI's `filter_map` over `Some` statuses. + Ok(None) => {} + Err(e) => { + log::error!("git status poll task panicked for {id}: {e}"); + } + } + } + + let branch_changes = branch_changed_ids(&last, &new_statuses); + if !branch_changes.is_empty() { + if clear_github_cache_for_ids(&branch_changes, &mut pr_infos, &mut ci_checks) { + any_pending_ci = has_pending_ci(&ci_checks); + } + for id in &branch_changes { + if let Some(status) = new_statuses.get_mut(id) { + status.pr_info = None; + status.ci_checks = None; + } + } + trigger_acc + .force_gh_ids + .extend(branch_changes.iter().cloned()); + gh_ids.extend(branch_changes); + } + + // Skip the cycle-0 `gh` fan-out unless something explicitly forced it: + // startup still gets basic gix status immediately without a `gh` herd. + let ci_poll_interval = if any_pending_ci { + CI_PENDING_POLL_EVERY_N_CYCLES + } else { + CI_SETTLED_POLL_EVERY_N_CYCLES + }; + let pr_cadence = cycle == 1 || (cycle != 0 && cycle.is_multiple_of(PR_POLL_EVERY_N_CYCLES)); + let ci_cadence = cycle == 1 || (cycle != 0 && cycle.is_multiple_of(ci_poll_interval)); + let force_gh = !trigger_acc.force_gh_ids.is_empty(); + let check_prs = force_gh || pr_cadence; + let check_ci = force_gh || ci_cadence; + let pr_poll_ids = gh_poll_ids(&gh_ids, &trigger_acc.force_gh_ids, force_gh, pr_cadence); + let ci_poll_ids = gh_poll_ids(&gh_ids, &trigger_acc.force_gh_ids, force_gh, ci_cadence); + + // ── 3. Publish the basic status map on change — BEFORE the slow `gh` ── + // git status comes from gix (fast, in-process); PR/CI come from `gh` + // (network, and can hang). Publishing here means a stuck `gh` can never + // block the branch/diff badge from appearing. + publish(&mut last, &new_statuses, &git_status_tx, &state_version); + + // Stop once every external `watch` receiver is gone (the server is down). + if git_status_tx.is_closed() { + return; + } + + // ── 4. `gh` PR/CI fan-out (network, can hang) on the blocking pool ─── + // Only on the PR/CI cadence. Each `gh` call runs under `spawn_blocking` + // so it never stalls the reactor or the basic-status publish above. A + // failed/missing call leaves that project's cache value untouched (or + // None) — we never panic and never blank existing data on error. + if check_prs { + for (id, path) in &projects { + // Only fan out `gh` for projects a client is actually viewing. + if !pr_poll_ids.contains(id) { + continue; + } + let id = id.clone(); + let path = path.clone(); + let fetched = tokio::task::spawn_blocking(move || { + with_lane(Lane::Poll, || { + // Skip `gh` entirely for non-GitHub repos. + if !git::repository::has_github_remote(Path::new(&path)) { + return None; + } + git::repository::get_pr_info(Path::new(&path)) + }) + }) + .await; + match fetched { + Ok(pr) => { + pr_infos.insert(id, pr); + } + Err(e) => { + log::warn!("gh PR info task failed for {id}: {e}"); + } + } + } + } + + if check_ci { + for (id, path) in &projects { + // Only fan out `gh` for projects a client is actually viewing. + if !ci_poll_ids.contains(id) { + continue; + } + let id = id.clone(); + let path = path.clone(); + // Use the freshest PR number we have (just-fetched or cached) so + // `gh pr checks` targets the right PR; falls back to branch-level + // checks when no PR is known. + let pr_number = pr_infos.get(&id).and_then(|p| p.as_ref()).map(|p| p.number); + let fetched = tokio::task::spawn_blocking(move || { + with_lane(Lane::Poll, || { + // Skip `gh` entirely for non-GitHub repos. + if !git::repository::has_github_remote(Path::new(&path)) { + return None; + } + git::repository::get_ci_checks(Path::new(&path), pr_number) + }) + }) + .await; + match fetched { + Ok(checks) => { + ci_checks.insert(id, checks); + } + Err(e) => { + log::warn!("gh CI checks task failed for {id}: {e}"); + } + } + } + any_pending_ci = has_pending_ci(&ci_checks); + } + + // ── 5. Re-publish with the richer PR/CI merged in (follow-up update) ─ + // `publish` no-ops when nothing changed since the basic publish above, + // so when `gh` was skipped or returned the same data this is free. + if check_prs || check_ci { + for (id, status) in new_statuses.iter_mut() { + status.pr_info = pr_infos.get(id).cloned().flatten(); + status.ci_checks = ci_checks.get(id).cloned().flatten(); + } + publish(&mut last, &new_statuses, &git_status_tx, &state_version); + } + + trigger_acc.clear(); + cycle += 1; + wait_for_next_cycle(&mut trigger_rx, &mut trigger_acc, &mut trigger_rx_closed).await; + } +} + +fn drain_git_poll_triggers( + trigger_rx: &mut mpsc::UnboundedReceiver, + trigger_acc: &mut TriggerAccumulator, + trigger_rx_closed: &mut bool, +) { + if *trigger_rx_closed { + return; + } + loop { + match trigger_rx.try_recv() { + Ok(trigger) => { + trigger_acc.record(trigger); + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + *trigger_rx_closed = true; + break; + } + } + } +} + +fn missing_github_stats( + id: &str, + pr_infos: &HashMap>, + ci_checks: &HashMap>, +) -> bool { + !(pr_infos.contains_key(id) && ci_checks.contains_key(id)) +} + +fn missing_github_stats_ids<'a>( + ids: impl Iterator, + pr_infos: &HashMap>, + ci_checks: &HashMap>, +) -> HashSet { + ids.filter(|id| missing_github_stats(id, pr_infos, ci_checks)) + .cloned() + .collect() +} + +fn branch_changed_ids( + last: &HashMap, + new_statuses: &HashMap, +) -> HashSet { + new_statuses + .iter() + .filter_map(|(id, status)| { + last.get(id) + .filter(|prev| prev.branch != status.branch) + .map(|_| id.clone()) + }) + .collect() +} + +fn clear_github_cache_for_ids( + ids: &HashSet, + pr_infos: &mut HashMap>, + ci_checks: &mut HashMap>, +) -> bool { + let mut changed = false; + for id in ids { + changed |= pr_infos.remove(id).is_some(); + changed |= ci_checks.remove(id).is_some(); + } + changed +} + +fn has_pending_ci(ci_checks: &HashMap>) -> bool { + ci_checks + .values() + .any(|c| c.as_ref().map(|s| s.status.is_pending()).unwrap_or(false)) +} + +fn gh_poll_ids( + gh_ids: &HashSet, + forced_gh_ids: &HashSet, + force_gh: bool, + cadence: bool, +) -> HashSet { + match (force_gh, cadence) { + (true, true) => gh_ids.clone(), + (true, false) => forced_gh_ids.clone(), + (false, true) => gh_ids.clone(), + (false, false) => HashSet::new(), + } +} + +async fn wait_for_next_cycle( + trigger_rx: &mut mpsc::UnboundedReceiver, + trigger_acc: &mut TriggerAccumulator, + trigger_rx_closed: &mut bool, +) { + if *trigger_rx_closed { + tokio::time::sleep(GIT_POLL_INTERVAL).await; + return; + } + + tokio::select! { + _ = tokio::time::sleep(GIT_POLL_INTERVAL) => {} + trigger = trigger_rx.recv() => { + match trigger { + Some(trigger) => { + trigger_acc.record(trigger); + } + None => { + *trigger_rx_closed = true; + } + } + } + } +} + +/// Broadcast the slimmed `ApiGitStatus` map into `git_status_tx` and bump +/// `state_version`, but only on a real change. `last` holds the previously +/// published richer `GitStatus` map (the GUI's `commit_statuses` change check); +/// no-ops when `new_statuses` equals it, so re-committing the same data is free. +fn publish( + last: &mut HashMap, + new_statuses: &HashMap, + git_status_tx: &watch::Sender>, + state_version: &watch::Sender, +) { + if new_statuses == last { + return; + } + *last = new_statuses.clone(); + let api_statuses: HashMap = + last.iter().map(|(id, s)| (id.clone(), to_api(s))).collect(); + git_status_tx.send_replace(api_statuses); + state_version.send_modify(|v| *v += 1); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::empty_workspace_data; + + /// With no projects and no external `watch` receiver, the first cycle does + /// its empty snapshot, publishes nothing (unchanged), detects the closed + /// channel, and the loop ends — without touching any real repository or + /// sleeping. Exercises the snapshot → no-change → channel-closed-detection + /// path. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_git_poll_stops_when_channel_closed() { + let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data()))); + let (tx, rx) = watch::channel(HashMap::::new()); + let git_status_tx = Arc::new(tx); + let (state_version, _svrx) = watch::channel(0u64); + + // Drop the only external receiver up front so the first `is_closed()` + // check returns immediately (no 5s sleep, deterministic). + drop(rx); + + let subscribed = Arc::new(RwLock::new(HashMap::new())); + let (_trigger_tx, trigger_rx) = mpsc::unbounded_channel(); + run_git_poll( + workspace, + git_status_tx.clone(), + state_version, + subscribed, + trigger_rx, + ) + .await; + + // No projects → nothing was published; the channel holds the initial map. + assert!(git_status_tx.borrow().is_empty()); + } + + #[test] + fn forced_gh_poll_ids_are_targeted_between_cadence_cycles() { + let gh_ids: HashSet = ["visible-a".to_string(), "visible-b".to_string()] + .into_iter() + .collect(); + let forced: HashSet = ["switched".to_string()].into_iter().collect(); + + assert_eq!(gh_poll_ids(&gh_ids, &forced, true, false), forced); + } + + #[test] + fn missing_github_stats_requires_both_cache_slots() { + let mut prs = HashMap::new(); + let mut checks = HashMap::new(); + + assert!(missing_github_stats("p1", &prs, &checks)); + + prs.insert("p1".to_string(), None); + assert!(missing_github_stats("p1", &prs, &checks)); + + checks.insert("p1".to_string(), None); + assert!(!missing_github_stats("p1", &prs, &checks)); + } + + #[test] + fn branch_changed_ids_only_reports_existing_branch_changes() { + let mut last = HashMap::new(); + last.insert( + "same".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + ); + last.insert( + "changed".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + ); + + let mut new_statuses = HashMap::new(); + new_statuses.insert( + "same".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + ); + new_statuses.insert( + "changed".to_string(), + GitStatus { + branch: Some("feature".to_string()), + ..GitStatus::default() + }, + ); + new_statuses.insert( + "new".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + ); + + let changed = branch_changed_ids(&last, &new_statuses); + assert_eq!(changed, HashSet::from(["changed".to_string()])); + } + + #[test] + fn clear_github_cache_for_ids_removes_pr_and_ci_entries() { + let mut prs = HashMap::from([("p1".to_string(), None), ("p2".to_string(), None)]); + let mut checks = HashMap::from([("p1".to_string(), None), ("p3".to_string(), None)]); + + let changed = clear_github_cache_for_ids( + &HashSet::from(["p1".to_string(), "missing".to_string()]), + &mut prs, + &mut checks, + ); + + assert!(changed); + assert!(!prs.contains_key("p1")); + assert!(prs.contains_key("p2")); + assert!(!checks.contains_key("p1")); + assert!(checks.contains_key("p3")); + } + + #[test] + fn trigger_accumulator_keeps_visible_projects_conditional() { + let mut acc = TriggerAccumulator::default(); + acc.record(GitPollTrigger::project_visible("visible".to_string())); + acc.record(GitPollTrigger::branch_change("switched".to_string())); + acc.record(GitPollTrigger::visibility_changed()); + + assert!(acc.candidate_gh_ids.contains("visible")); + assert!(acc.force_gh_ids.contains("switched")); + assert!(acc.invalidate_gh_ids.contains("switched")); + assert!(!acc.force_gh_ids.contains("visible")); + } +} diff --git a/crates/okena-daemon-core/src/lib.rs b/crates/okena-daemon-core/src/lib.rs new file mode 100644 index 000000000..8609770a2 --- /dev/null +++ b/crates/okena-daemon-core/src/lib.rs @@ -0,0 +1,47 @@ +//! GPUI-free daemon core for Okena. +//! +//! The desktop app drives the workspace/service logic crates through GPUI's +//! `Context`/`AsyncApp` reactor; this crate provides the second, headless +//! implementer backed by a plain tokio reactor and `Arc` +//! shared state. It exists so a headless daemon can run the exact same +//! `okena-workspace` / `okena-services` code paths with no GPUI in scope. +//! +//! This is the scaffold step: it stands up the shared [`reactor::DaemonReactor`] +//! state and the tokio-backed implementations of the two reactor trait families +//! +//! - [`okena_workspace::context::WorkspaceCx`] (see [`workspace_cx`]) +//! - [`okena_services::manager`]'s `ServiceCx` / `ServiceHandle` / +//! `ServiceAsyncCx` (see [`service_cx`]) +//! +//! This crate also provides the self-contained, gpui-free async tasks the +//! daemon runs on its reactor: +//! +//! - the observer tasks (see [`observers`]), +//! - the PTY event loop ([`pty_loop::run_pty_loop`]), +//! - the git-status poller ([`git_poll::run_git_poll`]), +//! - the remote command loop ([`command_loop::daemon_command_loop`]), and +//! - the gpui-free settings/theme handlers ([`daemon_config`]). +//! +//! Each takes its dependencies as parameters; [`DaemonCore::new`](daemon::DaemonCore::new) +//! wires them onto the tokio `LocalSet` / multi-thread runtime. +//! +//! Finally, [`daemon`] assembles all of the above into [`DaemonCore`] — the +//! complete, GPUI-free headless daemon that owns the runtime + remote server and +//! runs the reactor until shutdown. + +pub mod command_loop; +pub mod daemon; +pub mod daemon_config; +pub mod git_poll; +pub mod observers; +pub mod pty_loop; +pub mod reactor; +pub mod service_cx; +pub mod soft_close; +pub mod toast_poll; +pub mod workspace_cx; + +#[cfg(test)] +mod test_support; + +pub use daemon::{DaemonCore, DaemonParams}; diff --git a/crates/okena-daemon-core/src/observers.rs b/crates/okena-daemon-core/src/observers.rs new file mode 100644 index 000000000..a871afabf --- /dev/null +++ b/crates/okena-daemon-core/src/observers.rs @@ -0,0 +1,488 @@ +//! Observer reactor: the GPUI-free analogue of the app's `cx.observe`-driven +//! autosave / state-version / service-sync wiring. +//! +//! The GUI registers `cx.observe(&workspace, …)` / `cx.observe(&service_manager, +//! …)` closures that fire on every `notify`. The daemon has no entity graph, so +//! it converts each `notify` into a `watch` tick (see [`crate::reactor`]) and +//! drives the same behaviors from two long-lived tokio tasks that `await` those +//! ticks: +//! +//! 1. the **workspace-tick task** — bumps `state_version`, runs the debounced +//! autosave, and runs the project→services load/unload diff +//! ([`observe_project_services`] / `sync_services` in `okena-app`'s `app/mod.rs`). +//! 2. the **service-tick task** — bumps `state_version` and writes the per-project +//! service terminal-id maps back into the workspace +//! (`Workspace::sync_service_terminals`). +//! +//! ## Re-entrancy +//! +//! The write-back notifies the workspace → bumps `workspace_tick` → re-runs the +//! services diff → could bump `service_tick` → storm. Three guards defend against +//! this (all required, see [`spawn_observers`]): +//! +//! * **Coalescing ticks** — a `watch` channel collapses every bump made between +//! two `changed()` polls into a single wakeup, so a burst is one pass. +//! * **Idempotent diffs** — both `sync_services` (guarded by the `known` set) and +//! `Workspace::sync_service_terminals` (guarded by an equality check that only +//! notifies on real change) are no-ops once converged, so the storm terminates. +//! * **Separate lock scopes** — a pass never holds the workspace mutex and the +//! service-manager mutex at the same time: lock → snapshot → drop → lock the +//! other. + +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use okena_services::manager::{ServiceCx, ServiceManager}; +use okena_workspace::persistence; + +use crate::reactor::DaemonReactor; +use crate::service_cx::ServiceReactorRef; +use crate::workspace_cx::DaemonWorkspaceCx; + +/// Debounce window before an autosave is flushed to disk. Mirrors the GUI's +/// 500ms timer in `app/mod.rs`. +const AUTOSAVE_DEBOUNCE: Duration = Duration::from_millis(500); + +/// Per-project snapshot taken under the workspace lock so the services diff can +/// run after the lock is dropped (the separate-lock-scope guard). +struct ProjectSnapshot { + id: String, + path: String, + is_remote: bool, + service_terminals: std::collections::HashMap, +} + +impl DaemonReactor { + /// Spawn the two observer tasks onto the current `tokio::task::LocalSet`. + /// + /// They MUST be `spawn_local` (not `Handle::spawn`): the workspace-tick task + /// drives `ServiceManager::load_project_services`, which can call + /// [`ServiceCx::spawn_main`](okena_services::manager::ServiceCx::spawn_main) + /// — and the daemon's `spawn_main` is `tokio::task::spawn_local`, which + /// panics outside a `LocalSet`. The caller is responsible for running these + /// inside `LocalSet::run_until` / `LocalSet::block_on` on a multi-thread + /// runtime (the `spawn_blocking` offloads in autosave / the service async cx + /// still reach the multi-thread pool via the held [`tokio::runtime::Handle`]). + /// + /// `spawn_local` does not require the futures to be `Send`, which matches the + /// GUI's single-threaded main executor and lets the service tasks stay `!Send`. + pub fn spawn_observers(&self) { + // Subscribe to each tick *here*, synchronously, before spawning. A + // `watch::Receiver` created now treats any bump made after this call as + // "changed" — so a tick fired between `spawn_observers()` returning and + // the spawned task first polling is not lost. (Subscribing inside the + // task would race: `spawn_local` only schedules, so a bump that lands + // before the task runs would be marked already-seen at subscribe time.) + let workspace_rx = self.workspace_tick.subscribe(); + let service_rx = self.service_tick.subscribe(); + + // Clone the shared bits here (synchronously) so the spawned futures own + // them and capture no borrow of `self` — `spawn_local` requires `'static`. + tokio::task::spawn_local(workspace_tick_task( + workspace_rx, + self.workspace.clone(), + self.service_manager.clone(), + self.state_version.clone(), + self.service_tick.clone(), + self.runtime.clone(), + self.hook_runner.clone(), + self.hook_monitor.clone(), + )); + tokio::task::spawn_local(service_tick_task( + service_rx, + self.workspace.clone(), + self.service_manager.clone(), + self.state_version.clone(), + self.workspace_tick.clone(), + self.hook_runner.clone(), + self.hook_monitor.clone(), + )); + } +} + +type SharedWorkspace = Arc>; +type SharedServiceManager = Arc>; + +/// The workspace-tick observer task: bump `state_version`, autosave, and run the +/// project→services load/unload diff on every `workspace_tick` change. +#[allow(clippy::too_many_arguments)] +async fn workspace_tick_task( + mut tick_rx: tokio::sync::watch::Receiver, + workspace: SharedWorkspace, + service_manager: SharedServiceManager, + state_version: tokio::sync::watch::Sender, + service_tick: tokio::sync::watch::Sender, + runtime: tokio::runtime::Handle, + hook_runner: Option, + hook_monitor: Option, +) { + // Tracks the `data_version` last persisted, so UI-only changes skip the + // save — the daemon analogue of the GUI's `last_saved_version`. + let last_saved_version = Arc::new(AtomicU64::new(0)); + // Projects already loaded into the service manager — the GUI's `known` set, + // kept across passes to make the diff idempotent. + let mut known: HashSet = HashSet::new(); + + // Mirror the GUI's initial load: run one diff pass before awaiting ticks so + // persisted projects get their services loaded at startup. + run_services_sync(&workspace, &service_manager, &runtime, &service_tick, &mut known); + + loop { + if tick_rx.changed().await.is_err() { + // All senders dropped — the reactor is gone; stop the task. + return; + } + + // Coarse "persistent state changed" tick. + state_version.send_modify(|v| *v += 1); + + // ── Debounced autosave ─────────────────────────────────────────────── + autosave(&workspace, &runtime, &hook_runner, &hook_monitor, &last_saved_version).await; + + // ── project → services load/unload diff ───────────────────────────── + run_services_sync(&workspace, &service_manager, &runtime, &service_tick, &mut known); + } +} + +/// The service-tick observer task: bump `state_version` and write the per-project +/// service terminal-id maps back into the workspace on every `service_tick` +/// change. +async fn service_tick_task( + mut tick_rx: tokio::sync::watch::Receiver, + workspace: SharedWorkspace, + service_manager: SharedServiceManager, + state_version: tokio::sync::watch::Sender, + workspace_tick: tokio::sync::watch::Sender, + hook_runner: Option, + hook_monitor: Option, +) { + loop { + if tick_rx.changed().await.is_err() { + return; + } + + state_version.send_modify(|v| *v += 1); + + // ── services → workspace terminal-id write-back ───────────────────── + // + // Lock scope 1: snapshot the per-project terminal-id maps under the + // service-manager lock, then DROP it. + let terminal_maps: Vec<(String, std::collections::HashMap)> = { + let sm = service_manager.lock(); + let project_ids: HashSet = + sm.instances().keys().map(|(pid, _)| pid.clone()).collect(); + project_ids + .into_iter() + .map(|pid| { + let ids = sm.service_terminal_ids(&pid); + (pid, ids) + }) + .collect() + }; + + // Lock scope 2: write the maps back under the workspace lock. + // `sync_service_terminals` only notifies when a map actually changes, so + // once converged this stops bumping `workspace_tick` and the cross-tick + // storm terminates. + { + let mut ws = workspace.lock(); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + for (project_id, terminals) in terminal_maps { + ws.sync_service_terminals(&project_id, terminals, &mut cx); + } + } + } +} + +/// Debounced autosave pass. Skips the save when `data_version` is unchanged +/// since the last persisted version (UI-only change); otherwise waits the +/// debounce window, re-snapshots under a short lock, and runs the blocking +/// `save_workspace` on the multi-thread runtime. Mirrors `app/mod.rs`'s +/// 500ms-debounced save observer. +async fn autosave( + workspace: &SharedWorkspace, + runtime: &tokio::runtime::Handle, + _hook_runner: &Option, + _hook_monitor: &Option, + last_saved_version: &Arc, +) { + // Skip UI-only changes: the persistent `data_version` is unchanged. + let current_version = workspace.lock().data_version(); + if current_version == last_saved_version.load(Ordering::Relaxed) { + return; + } + + // Debounce: a burst of mutations collapses into one save after the window. + tokio::time::sleep(AUTOSAVE_DEBOUNCE).await; + + // Re-snapshot after the sleep — the version may have moved again; take the + // latest under a short lock and DROP it before the blocking I/O. + let (data, version) = { + let ws = workspace.lock(); + (ws.data().clone(), ws.data_version()) + }; + + // Blocking fs I/O — offload onto the multi-thread runtime so it never stalls + // the LocalSet thread (Windows AV / OneDrive can stall workspace.json saves). + let save_result = runtime + .spawn_blocking(move || persistence::save_workspace(&data)) + .await; + + match save_result { + Ok(Ok(())) => { + last_saved_version.store(version, Ordering::Relaxed); + } + Ok(Err(e)) => { + log::error!("Failed to save workspace: {}", e); + // Don't update last_saved_version — the next mutation retries. + } + Err(e) => { + log::error!("Workspace save task panicked: {}", e); + } + } +} + +/// Run one project→services load/unload diff pass with separate lock scopes. +/// +/// Lock scope 1: snapshot the project list under the workspace lock, then DROP +/// it. Lock scope 2: lock the service manager, build a +/// [`DaemonServiceCx`](crate::service_cx::DaemonServiceCx), and run +/// [`sync_services`]. +fn run_services_sync( + workspace: &SharedWorkspace, + service_manager: &SharedServiceManager, + runtime: &tokio::runtime::Handle, + service_tick: &tokio::sync::watch::Sender, + known: &mut HashSet, +) { + // Lock scope 1: snapshot the projects, then drop the workspace lock. + let projects: Vec = { + let ws = workspace.lock(); + ws.data() + .projects + .iter() + .map(|p| ProjectSnapshot { + id: p.id.clone(), + path: p.path.clone(), + is_remote: p.is_remote, + service_terminals: p.service_terminals.clone(), + }) + .collect() + }; + + // Lock scope 2: lock the service manager, mint a top-level cx, run the diff. + // `spawn_main` from inside the loaded services lands on the active LocalSet + // (the spawn_observers contract), so this must run on the LocalSet thread. + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut sm = service_manager.lock(); + let mut cx = reactor_ref.cx(); + sync_services(&projects, known, &mut sm, &mut cx); +} + +/// GPUI-free port of `okena-app`'s `app/mod.rs::sync_services`: diff the current +/// non-remote, on-disk project set against `known` and load/unload service +/// configs accordingly. Idempotent — a project already in `known` is skipped, so +/// repeated passes converge to no-ops (the re-entrancy guard). +fn sync_services( + projects: &[ProjectSnapshot], + known: &mut HashSet, + sm: &mut ServiceManager, + cx: &mut impl ServiceCx, +) { + let current_ids: HashSet = projects + .iter() + .filter(|p| !p.is_remote) + .map(|p| p.id.clone()) + .collect(); + + for p in projects { + if p.is_remote || known.contains(&p.id) { + continue; + } + // Skip projects whose directory doesn't exist yet (deferred worktrees). + if !std::path::Path::new(&p.path).exists() { + continue; + } + sm.load_project_services(&p.id, &p.path, &p.service_terminals, cx); + known.insert(p.id.clone()); + } + + let removed: Vec = known.difference(¤t_ids).cloned().collect(); + for id in &removed { + sm.unload_project_services(id, cx); + known.remove(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{StubBackend, empty_workspace_data}; + + /// A `known`-set + project snapshot fixture for the diff logic. + fn project(id: &str, path: &str, is_remote: bool) -> ProjectSnapshot { + ProjectSnapshot { + id: id.to_string(), + path: path.to_string(), + is_remote, + service_terminals: Default::default(), + } + } + + /// The on-disk path used for "exists" projects in the diff tests — the crate + /// dir always exists, so the deferred-worktree skip is not triggered. + fn existing_path() -> String { + env!("CARGO_MANIFEST_DIR").to_string() + } + + /// A `ServiceManager` with a stub backend. Load is a no-op when there is no + /// `okena.yaml` / docker-compose, so the diff's `known`-set bookkeeping is + /// what the tests assert. + fn manager() -> ServiceManager { + let backend = Arc::new(StubBackend); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + ServiceManager::new(backend, terminals) + } + + /// Build a top-level `DaemonServiceCx` over a throwaway reactor for tests + /// that need to pass a `cx` into `sync_services`. The notify just bumps a + /// detached watch channel. + fn reactor_ref(manager: &std::sync::Arc>) -> ServiceReactorRef { + let (tick, _rx) = tokio::sync::watch::channel(0u64); + ServiceReactorRef::new(manager.clone(), tokio::runtime::Handle::current(), tick) + } + + #[tokio::test] + async fn sync_services_loads_new_local_projects_and_tracks_them() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + + let projects = vec![ + project("local", &existing_path(), false), + project("remote", &existing_path(), true), + ]; + let mut known = HashSet::new(); + + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut known, &mut guard, &mut cx); + } + + // Non-remote, on-disk project is tracked; remote project is skipped. + assert!(known.contains("local")); + assert!(!known.contains("remote")); + } + + #[tokio::test] + async fn sync_services_skips_nonexistent_paths() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + + let projects = vec![project("ghost", "/path/that/does/not/exist/okena", false)]; + let mut known = HashSet::new(); + + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut known, &mut guard, &mut cx); + } + + // Deferred worktree (missing dir) is NOT tracked, so a later pass retries. + assert!(!known.contains("ghost")); + } + + #[tokio::test] + async fn sync_services_unloads_removed_projects() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + + // Pass 1: load a local project. + let mut known = HashSet::new(); + { + let projects = vec![project("local", &existing_path(), false)]; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut known, &mut guard, &mut cx); + } + assert!(known.contains("local")); + + // Pass 2: the project is gone from the workspace → it is unloaded. + { + let projects: Vec = vec![]; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut known, &mut guard, &mut cx); + } + assert!(!known.contains("local")); + } + + #[tokio::test] + async fn sync_services_is_idempotent_when_converged() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + + let projects = vec![project("local", &existing_path(), false)]; + let mut known = HashSet::new(); + + // First pass loads and tracks. + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut known, &mut guard, &mut cx); + } + let known_after_first: HashSet = known.clone(); + + // Second pass with the same project set is a no-op (already in `known`). + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut known, &mut guard, &mut cx); + } + assert_eq!(known, known_after_first); + } + + /// End-to-end-ish: spawn the observer tasks on a LocalSet, bump + /// `workspace_tick`, and assert `state_version` advances. Exercises the + /// `spawn_local`/LocalSet wiring and the tick→state_version bump. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn observers_advance_state_version_on_workspace_tick() { + use okena_workspace::state::Workspace; + + let backend = Arc::new(StubBackend); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let workspace = Workspace::new(empty_workspace_data()); + let reactor = Arc::new(DaemonReactor::new( + workspace, + backend, + terminals, + None, + None, + tokio::runtime::Handle::current(), + )); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + reactor.spawn_observers(); + + let mut sv_rx = reactor.state_version.subscribe(); + let before = *sv_rx.borrow_and_update(); + + // Bump the workspace tick — the observer should react. + reactor.workspace_tick.send_modify(|v| *v += 1); + + // Wait for state_version to advance (the workspace-tick task ran). + sv_rx.changed().await.expect("state_version sender alive"); + let after = *sv_rx.borrow(); + assert!(after > before, "state_version should advance on workspace_tick"); + }) + .await; + } +} diff --git a/crates/okena-daemon-core/src/pty_loop.rs b/crates/okena-daemon-core/src/pty_loop.rs new file mode 100644 index 000000000..6d058701d --- /dev/null +++ b/crates/okena-daemon-core/src/pty_loop.rs @@ -0,0 +1,745 @@ +//! GPUI-free PTY event loop: the headless analogue of the GUI's batched +//! `async_channel` drain in `okena-app`'s `app/mod.rs` / `app/headless.rs`. +//! +//! The GUI reads [`PtyEvent`]s off the [`PtyManager`]'s channel on the GPUI +//! thread, feeds `Data` into the per-terminal `process_output`, and on `Exit` +//! cleans up the PTY handle and lets the [`ServiceManager`] decide whether the +//! terminal was a service (so it can restart it or keep its crash output). The +//! daemon does the same against `Arc>` state and a tokio +//! task — but, unlike a thin GUI client, the daemon OWNS the workspace, hooks, +//! and lifecycle state, so it also runs the full terminal-exit lifecycle: +//! +//! * hook-terminal exits → status updates + pending worktree-close resolution +//! (deleting the worktree project DIRECTLY in the workspace — the GUI client +//! instead dispatched a remote `DeleteProject`), +//! * `terminal.on_close` hooks for plain user terminals, +//! * hook-exit-via-OSC-title (`__okena_hook_exit:`), +//! * stale soft-close-record cleanup. +//! +//! The only GUI-only bits dropped are the ones with no daemon surface: window / +//! pane notify and soft-close *toast* dismissal (the daemon still does the +//! soft-close workspace-state cleanup, just without the UI toast). +//! +//! ## Runs inside the [`LocalSet`](tokio::task::LocalSet) +//! +//! [`run_pty_loop`] MUST be driven by `spawn_local` (or directly inside a +//! running `LocalSet`): on `Exit` it calls +//! [`ServiceManager::handle_service_exit`](okena_services::manager::ServiceManager::handle_service_exit), +//! which for a crashed-but-restart service calls +//! [`ServiceCx::spawn_main`](okena_services::manager::ServiceCx::spawn_main) — +//! and the daemon's `spawn_main` is `tokio::task::spawn_local`, which panics +//! outside a `LocalSet`. This is the same constraint the observer tasks document +//! (see [`crate::observers`]). The blocking subprocess offloads still reach the +//! multi-thread pool via the held [`Handle`](tokio::runtime::Handle). + +use std::collections::HashSet; +use std::sync::Arc; + +use async_channel::Receiver; +use okena_hooks::{HookMonitor, HookRunner}; +use okena_services::manager::ServiceManager; +use okena_terminal::pty_manager::{PtyEvent, PtyManager}; +use okena_terminal::TerminalsRegistry; +use okena_workspace::focus::FocusManager; +use okena_workspace::persistence::AppSettings; +use okena_workspace::state::{HookTerminalStatus, Workspace}; +use parking_lot::Mutex; +use tokio::runtime::Handle; +use tokio::sync::watch; + +use crate::service_cx::ServiceReactorRef; +use crate::workspace_cx::DaemonWorkspaceCx; + +/// Per-turn work budget. A single high-bandwidth terminal (`cat hugefile`, +/// `yes`, a runaway build log) can otherwise keep this loop draining the channel +/// forever, starving the other tasks sharing the LocalSet thread. Once we've +/// parsed this many bytes in one drain pass we stop and yield back to the +/// executor; the remaining events stay in the bounded channel and are picked up +/// next turn (nothing is dropped). Mirrors the GUI's `MAX_BYTES_PER_TURN`. +const MAX_BYTES_PER_TURN: usize = 256 * 1024; + +/// The shared reactor handles the PTY loop needs to run terminal-exit lifecycle +/// work directly against the daemon-owned workspace + hooks. Bundled so the loop +/// signature (and the per-batch handlers it calls) stay readable. +/// +/// Everything here is cheaply clonable (`Arc>`, `watch::Sender`, the +/// `Arc`-backed hook services) — the loop holds it for its whole lifetime and +/// re-borrows per batch. +pub struct PtyLoopReactor { + /// The daemon-owned workspace: hook-terminal status, pending worktree close, + /// soft-close records, and project deletion all mutate it directly. + pub workspace: Arc>, + /// Hook runner — threaded into `DaemonWorkspaceCx` so workspace mutators that + /// need it (e.g. project deletion firing lifecycle hooks) can reach it. + pub hook_runner: Option, + /// Hook monitor — `notify_exit` / `finish_by_terminal_id` updates and the + /// `terminal.on_close` hook run reach it directly. + pub hook_monitor: Option, + /// Bumped by `DaemonWorkspaceCx::notify` on each workspace mutation. + pub workspace_tick: watch::Sender, + /// App settings (read for the global `terminal.on_close` hook + the + /// global-hooks arg passed into project deletion / hook firing). + pub settings: Arc>, +} + +impl PtyLoopReactor { + /// Build a fresh [`DaemonWorkspaceCx`] borrowing this reactor's notify channel + /// + hook services, for a single workspace mutation site. + fn workspace_cx(&self) -> DaemonWorkspaceCx<'_> { + DaemonWorkspaceCx::new(&self.workspace_tick, &self.hook_runner, &self.hook_monitor) + } +} + +/// Run the daemon PTY event loop until the channel closes (all PTY senders +/// dropped, i.e. shutdown). +/// +/// Dependencies are passed individually so `DaemonCore::new` wires them +/// explicitly: +/// * `pty_events` — the [`Receiver`] returned by [`PtyManager::new`]. +/// * `terminals` — the shared [`TerminalsRegistry`]; `Data` events look up the +/// `Arc` here and feed `process_output`. +/// * `pty_manager` — for `cleanup_exited` (reap reader/writer threads on EOF) +/// and `kill` (SIGTERM the lingering session for non-service terminals). +/// * `service_manager` + `runtime` + `service_tick` — the same triple +/// [`ServiceReactorRef`] needs to mint a `DaemonServiceCx` so +/// `handle_service_exit` can `notify`/`spawn_main` (the service restart path). +/// * `reactor` — the daemon-owned workspace + hook handles the lifecycle work +/// (hook-terminal exits, `terminal.on_close`, OSC hook-exit, soft-close reap) +/// mutates directly. +/// * `state_version` — bumped once per batch that contained exits, so clients +/// resync after the lifecycle mutations. +#[allow(clippy::too_many_arguments)] +pub async fn run_pty_loop( + pty_events: Receiver, + terminals: TerminalsRegistry, + pty_manager: Arc, + service_manager: Arc>, + runtime: Handle, + service_tick: watch::Sender, + reactor: PtyLoopReactor, + state_version: watch::Sender, +) { + // The reactor bits needed to build a top-level `DaemonServiceCx` for + // `handle_service_exit`. Built once; `cx()` is re-borrowed per exit batch. + // It re-locks `service_manager` internally on reentry, so the loop locks the + // manager itself (below) only while the cx is alive — never across an await. + let reactor_ref = ServiceReactorRef::new(service_manager.clone(), runtime, service_tick); + + loop { + // Block until at least one event arrives. `Err` means every sender was + // dropped — the PtyManager is gone, so the loop is done. + let event = match pty_events.recv().await { + Ok(event) => event, + Err(_) => break, + }; + + // Exits collected across this drain pass, handled together after. + let mut exit_events: Vec<(String, Option)> = Vec::new(); + // Terminals that produced output this pass (for the OSC hook-exit title + // check, mirroring the GUI's `dirty_terminal_ids`). + let mut dirty_terminal_ids: Vec = Vec::new(); + // Bytes parsed so far in this pass (across batched `Data` events). + let mut bytes_this_turn: usize = 0; + + process_event( + &event, + &terminals, + &pty_manager, + &mut exit_events, + &mut dirty_terminal_ids, + &mut bytes_this_turn, + ); + + // Drain additional pending events (batch processing), stopping once we + // exceed the per-turn byte budget so we yield instead of monopolizing + // the LocalSet thread. + while bytes_this_turn < MAX_BYTES_PER_TURN { + let event = match pty_events.try_recv() { + Ok(event) => event, + Err(_) => break, + }; + process_event( + &event, + &terminals, + &pty_manager, + &mut exit_events, + &mut dirty_terminal_ids, + &mut bytes_this_turn, + ); + } + + // Hook terminals can report their exit code via an OSC title + // (`__okena_hook_exit:`) while the interactive shell stays alive — + // independent of any PTY `Exit`. Mirror the GUI's post-batch dirty-title + // scan. (Runs whether or not there were exits.) + if !dirty_terminal_ids.is_empty() { + process_osc_hook_exits(&dirty_terminal_ids, &terminals, &reactor); + // Activity edges — OSC 133 ;D command-finish, bell, and OSC 9/777 + // notification — stamp `last_activity_at` on the owning project so the + // activity-sorted sidebar floats it up. Bump `state_version` if + // anything was stamped so clients resync (the bump is mirrored into + // StateResponse). Stamping on the daemon — not the client mirror, + // which the next sync overwrites — is what makes bell/notification + // recency persist and reach every client. + if process_activity_edges(&dirty_terminal_ids, &terminals, &reactor) { + state_version.send_modify(|v| *v += 1); + } + } + + if !exit_events.is_empty() { + handle_exits( + &exit_events, + &terminals, + &pty_manager, + &service_manager, + &reactor_ref, + &reactor, + ); + // Coarse "something changed" tick: the lifecycle mutations above + // (hook status, project deletion, soft-close cleanup) are now visible + // to clients on their next resync. + state_version.send_modify(|v| *v += 1); + } + } +} + +/// Handle a single [`PtyEvent`]: feed `Data` into the terminal (dropping the +/// registry lock before the parse, as the GUI does) and record it dirty, or +/// reap + record `Exit`. +fn process_event( + event: &PtyEvent, + terminals: &TerminalsRegistry, + pty_manager: &PtyManager, + exit_events: &mut Vec<(String, Option)>, + dirty_terminal_ids: &mut Vec, + bytes_this_turn: &mut usize, +) { + match event { + PtyEvent::Data { terminal_id, data } => { + // Hold the registry lock only for the HashMap lookup — clone the + // `Arc` out and drop the guard before the (potentially + // long) ANSI parse, so input/resize/kill on OTHER terminals don't + // block behind it. + let term = terminals.lock().get(terminal_id).cloned(); + if let Some(term) = term { + *bytes_this_turn += data.len(); + term.process_output(data); + } + dirty_terminal_ids.push(terminal_id.clone()); + } + PtyEvent::Exit { terminal_id, exit_code } => { + // Clean up the PtyHandle (reader/writer threads) but don't remove + // the Terminal yet — the service manager may keep it so users can + // see crash output. + pty_manager.cleanup_exited(terminal_id); + exit_events.push((terminal_id.clone(), *exit_code)); + } + } +} + +/// Hook-exit-via-OSC-title: for any terminal that produced output this batch and +/// IS a hook terminal, if its title is `__okena_hook_exit:`, set the hook +/// status to Succeeded (code 0) / Failed otherwise. +/// +/// Mirrors the GUI's post-batch dirty-title scan (`app/mod.rs`). This happens for +/// keep-alive hooks whose command finished but whose PTY stays alive as an +/// interactive shell, so there is no PTY `Exit` to drive the status. +fn process_osc_hook_exits( + dirty_terminal_ids: &[String], + terminals: &TerminalsRegistry, + reactor: &PtyLoopReactor, +) { + // Collect status updates under the registry + workspace read locks, then + // apply them under a single workspace write lock (matching the GUI's split). + let mut status_updates: Vec<(String, HookTerminalStatus)> = Vec::new(); + { + let terminals_guard = terminals.lock(); + let ws = reactor.workspace.lock(); + for tid in dirty_terminal_ids { + if ws.is_hook_terminal(tid).is_none() { + continue; + } + if let Some(terminal) = terminals_guard.get(tid) + && let Some(title) = terminal.title() + && let Some(code_str) = title.strip_prefix("__okena_hook_exit:") + { + let exit_code = code_str.parse::().unwrap_or(-1); + let status = if exit_code == 0 { + HookTerminalStatus::Succeeded + } else { + HookTerminalStatus::Failed { exit_code } + }; + status_updates.push((tid.clone(), status)); + } + } + } + if !status_updates.is_empty() { + let mut cx = reactor.workspace_cx(); + let mut ws = reactor.workspace.lock(); + for (tid, status) in status_updates { + ws.update_hook_terminal_status(&tid, status, &mut cx); + } + } +} + +/// Drain the one-shot activity edges — command-finished (OSC 133 ;D), bell, and +/// desktop-notification (OSC 9/777) — for each terminal that produced output this +/// batch and stamp `last_activity_at` on the owning project (drives the +/// activity-sorted sidebar). Returns `true` if any activity was stamped, so the +/// caller can bump `state_version` for clients to resync. +/// +/// Mirrors the GUI's activity bump: drain the cheap atomic edges first (almost +/// every batch drains nothing), resolve the active terminals to their owning +/// projects (deduplicated), then `bump_activity` once per project. The daemon's +/// own `Terminal`s parse all three signals in `process_output`, so the edges are +/// available here — the client's bell/notification bump was on its read-only +/// mirror and lost on the next sync. +fn process_activity_edges( + dirty_terminal_ids: &[String], + terminals: &TerminalsRegistry, + reactor: &PtyLoopReactor, +) -> bool { + // Drain edges first (cheap atomic swaps); collect the terminals that saw any + // meaningful activity. The lock is dropped before touching the workspace. + // + // Drain ALL THREE edges with `|=` (never `||`): each is a one-shot that must + // be consumed to clear it, so short-circuiting would leak the notification + // queue (and miss a bell that follows a command-finish in the same batch). + let active: Vec = { + let reg = terminals.lock(); + dirty_terminal_ids + .iter() + .filter(|tid| { + reg.get(*tid).is_some_and(|t| { + let mut a = t.take_pending_command_finished(); + a |= t.take_pending_bell(); + a |= !t.take_pending_notifications().is_empty(); + a + }) + }) + .cloned() + .collect() + }; + if active.is_empty() { + return false; + } + + // Resolve each active terminal to its owning project, deduplicating so a + // batch touching several terminals of the same project bumps it once. + let project_ids: HashSet = { + let ws = reactor.workspace.lock(); + active + .iter() + .filter_map(|tid| ws.find_project_for_terminal(tid).map(|p| p.id.clone())) + .collect() + }; + if project_ids.is_empty() { + return false; + } + + let mut cx = reactor.workspace_cx(); + let mut ws = reactor.workspace.lock(); + for pid in &project_ids { + ws.bump_activity(pid, &mut cx); + } + true +} + +/// Handle the exits collected in one batch: +/// 1. Let the service manager claim its service terminals (restart / +/// keep-crash-output) — yields the `service_tids` set. +/// 2. Resolve hook-terminal exits: notify the monitor, set hook status, and +/// resolve any pending worktree close (run the canonical worktree removal +/// DIRECTLY in the daemon workspace on success; finish-closing on failure) — +/// yields the `hook_tids` set. +/// 3. Fire `terminal.on_close` for plain user terminals (non-service, non-hook). +/// 4. Kill + remove the UI Terminal for every non-service, non-hook terminal. +/// 5. Drop stale soft-close records for any exited terminal. +/// +/// Mirrors the GUI's PTY-exit handling, adapted: the GUI is a thin client and +/// dispatched a remote action + ran the worktree removal locally; the daemon owns +/// the workspace and runs the canonical removal directly. The GUI-only +/// window/pane notify + toast dismissal have no daemon surface and are dropped +/// (the soft-close *state* cleanup still runs). +fn handle_exits( + exit_events: &[(String, Option)], + terminals: &TerminalsRegistry, + pty_manager: &PtyManager, + service_manager: &Arc>, + reactor_ref: &ServiceReactorRef, + reactor: &PtyLoopReactor, +) { + // ── 1. Service terminals ──────────────────────────────────────────────── + // For a crashed service with `restart_on_crash`, `handle_service_exit` calls + // `spawn_main` (lands on this LocalSet) to restart after a delay; otherwise + // it marks the service crashed and keeps the Terminal so the crash output + // stays visible. The returned set is the service-claimed terminal ids — the + // daemon's equivalent of the GUI's (always-empty, since services run here) + // `service_tids`. + let service_tids: HashSet = { + let mut sm = service_manager.lock(); + let mut cx = reactor_ref.cx(); + let mut handled = HashSet::new(); + for (terminal_id, exit_code) in exit_events { + if sm.handle_service_exit(terminal_id, *exit_code, &mut cx) { + handled.insert(terminal_id.clone()); + } + } + handled + }; + + // ── 2. Hook-terminal exits ────────────────────────────────────────────── + // Phase 1 (here): `notify_exit` unblocks any sync hook threads waiting on a + // PTY terminal. This MUST happen before phase 2 (status updates / pending + // worktree-close resolution) which may delete a project. + if let Some(monitor) = reactor.hook_monitor.as_ref() { + for (terminal_id, exit_code) in exit_events { + monitor.notify_exit(terminal_id, *exit_code); + } + } + let hook_tids = + handle_hook_terminal_exits(exit_events, &service_tids, reactor); + + // ── 3. terminal.on_close for plain user terminals ─────────────────────── + // Same gating as the GUI: a global, project, OR parent-worktree on_close + // must be present. Collect the args under a workspace read lock, then fire + // the hooks (which spawn background subprocesses) outside it. + let global_hooks = reactor.settings.lock().hooks.clone(); + let close_infos = collect_terminal_close_infos(exit_events, &service_tids, &hook_tids, reactor, &global_hooks); + let monitor = reactor.hook_monitor.as_ref(); + for info in close_infos { + okena_hooks::fire_terminal_on_close_with_services( + &info.project_hooks, + info.parent_hooks.as_ref(), + &info.project_id, + &info.project_name, + &info.project_path, + &info.terminal_id, + info.terminal_name.as_deref(), + info.is_worktree, + info.exit_code, + info.folder_id.as_deref(), + info.folder_name.as_deref(), + &global_hooks, + monitor, + ); + } + + // ── 4. Kill + remove non-service, non-hook terminals ──────────────────── + // `kill` is critical for dtach: the PTY exit only means the client + // disconnected, but the dtach daemon keeps running — `kill` SIGTERMs it and + // removes the socket file. + { + let mut reg = terminals.lock(); + for (terminal_id, _) in exit_events { + if !service_tids.contains(terminal_id) && !hook_tids.contains(terminal_id) { + pty_manager.kill(terminal_id); + reg.remove(terminal_id); + } + } + } + + // ── 5. Stale soft-close reap ───────────────────────────────────────────── + // If an exited terminal was mid soft-close, its pending record would + // otherwise linger until the grace timer fired a redundant kill — drop it. + // And if undo had just *restored* a now-doomed pane (racing this exit), tear + // it back out. The daemon has no undo toast, so the returned toast id is + // intentionally dropped (no UI dismissal to do). + { + let mut cx = reactor.workspace_cx(); + let mut ws = reactor.workspace.lock(); + for (tid, _) in exit_events { + let _stale_toast = ws.cancel_pending_close(tid); + ws.reap_restored_close(tid, &mut cx); + } + } +} + +/// Phase 2 of hook-terminal exit handling: for each exited terminal that IS a +/// hook terminal, update the `HookMonitor`, set `HookTerminalStatus`, and +/// resolve any pending worktree close. +/// +/// Returns the set of terminal ids that were hook terminals (so the caller skips +/// them in the `terminal.on_close` / kill+remove passes, mirroring the GUI). +/// +/// ## Worktree-close adaptation (direct removal, not remote dispatch) +/// +/// The GUI is a thin client whose `Workspace` mirror is read-only, so on a +/// successful close it dispatched a remote action to the daemon and then ran the +/// git worktree removal + `on_worktree_close` / `worktree_removed` hooks locally +/// in `handle_pending_close_result`. The daemon OWNS the workspace, so it runs +/// the canonical worktree removal DIRECTLY via +/// [`Workspace::remove_worktree_project`] — the SAME path +/// `execute_action(RemoveWorktreeProject)` takes: fire `on_worktree_close`, then +/// `git worktree remove`, then `delete_project` (which fires `on_project_close`). +/// +/// The exited hook terminal that drives this is the `before_worktree_remove` +/// hook (registered alongside the pending close in the close-worktree dialog), +/// NOT `on_worktree_close`, so converging on `remove_worktree_project` does not +/// double-fire any hook. +/// +/// Residual difference vs. the GUI: the daemon does the removal synchronously via +/// `remove_worktree` (`git worktree remove`) rather than the GUI's background +/// `remove_worktree_fast` + `worktree_removed` hook. This matches the normal +/// daemon `RemoveWorktreeProject` action exactly. +fn handle_hook_terminal_exits( + exit_events: &[(String, Option)], + service_tids: &HashSet, + reactor: &PtyLoopReactor, +) -> HashSet { + let hook_tids: HashSet = { + let ws = reactor.workspace.lock(); + exit_events + .iter() + .filter(|(tid, _)| !service_tids.contains(tid)) + .filter(|(tid, _)| ws.is_hook_terminal(tid).is_some()) + .map(|(tid, _)| tid.clone()) + .collect() + }; + + let global_hooks = reactor.settings.lock().hooks.clone(); + + for (terminal_id, exit_code) in exit_events { + if !hook_tids.contains(terminal_id) { + continue; + } + + let success = *exit_code == Some(0); + let tid = terminal_id.clone(); + + // Update HookMonitor so the hook log shows correct status. + if let Some(monitor) = reactor.hook_monitor.as_ref() { + monitor.finish_by_terminal_id(&tid, *exit_code); + } + + // Set hook status + resolve any pending worktree close. + // + // On a successful close the project deletion is done DIRECTLY here (the + // daemon owns the workspace). `delete_project` needs a `&mut + // FocusManager`; daemon focus state is dormant (never drives a render), + // so an ephemeral one is fine. + let mut focus_manager = FocusManager::new(); + let mut cx = reactor.workspace_cx(); + let mut ws = reactor.workspace.lock(); + + let status = if success { + HookTerminalStatus::Succeeded + } else { + let code = exit_code.map(|c| i32::try_from(c).unwrap_or(i32::MAX)).unwrap_or(-1); + HookTerminalStatus::Failed { exit_code: code } + }; + ws.update_hook_terminal_status(&tid, status, &mut cx); + + if let Some(pending) = ws.take_pending_worktree_close(&tid) { + if success { + // Drop the hook terminal record, then run the canonical + // worktree removal — the SAME path the normal + // `ActionRequest::RemoveWorktreeProject` action takes: + // fire `on_worktree_close`, `git worktree remove`, then + // `delete_project` (which fires `on_project_close`). + // + // The exited hook terminal is the `before_worktree_remove` + // hook (the one registered with this pending close), NOT + // `on_worktree_close`, so this does not double-fire any hook. + // + // `force = true`: this close was already gated by the user + // confirming AND the `before_worktree_remove` hook succeeding, + // so it must not be blocked by a dirty/locked working tree — + // matching the GUI's hook-close path, which removed the + // worktree unconditionally (`remove_worktree_fast`). + ws.remove_hook_terminal(&tid, &mut cx); + if let Err(e) = ws.remove_worktree_project( + &mut focus_manager, + &pending.project_id, + true, + &global_hooks, + &mut cx, + ) { + log::error!( + "worktree-close hook succeeded but remove_worktree_project failed for {}: {}", + pending.project_id, + e + ); + } + } else { + // Hook failed → abort the close: unmark the project as closing. + ws.finish_closing_project(&pending.project_id); + } + } + // Hook terminal persists on non-close paths — no auto-cleanup. A client + // can dismiss or rerun it. + } + + hook_tids +} + +/// Args for a single `terminal.on_close` hook firing, collected under the +/// workspace read lock so the (subprocess-spawning) hook run happens outside it. +struct TerminalCloseInfo { + project_hooks: okena_state::HooksConfig, + parent_hooks: Option, + project_id: String, + project_name: String, + project_path: String, + terminal_id: String, + terminal_name: Option, + is_worktree: bool, + exit_code: Option, + folder_id: Option, + folder_name: Option, +} + +/// Collect `terminal.on_close` firing args for exited user terminals (non-service, +/// non-hook), applying the GUI's gating: fire only when a global, project, OR +/// parent-worktree `terminal.on_close` is configured. +fn collect_terminal_close_infos( + exit_events: &[(String, Option)], + service_tids: &HashSet, + hook_tids: &HashSet, + reactor: &PtyLoopReactor, + global_hooks: &okena_state::HooksConfig, +) -> Vec { + let global_on_close = global_hooks.terminal.on_close.is_some(); + let ws = reactor.workspace.lock(); + exit_events + .iter() + .filter(|(tid, _)| !service_tids.contains(tid) && !hook_tids.contains(tid)) + .filter_map(|(tid, exit_code)| { + let p = ws.find_project_for_terminal(tid)?; + let parent_on_close = p + .worktree_info + .as_ref() + .and_then(|wt| ws.project(&wt.parent_project_id)) + .and_then(|pp| pp.hooks.terminal.on_close.as_ref()) + .is_some(); + if !(global_on_close || p.hooks.terminal.on_close.is_some() || parent_on_close) { + return None; + } + let parent_hooks = p + .worktree_info + .as_ref() + .and_then(|wt| ws.project(&wt.parent_project_id)) + .map(|pp| pp.hooks.clone()); + let terminal_name = p.terminal_names.get(tid).cloned(); + let is_worktree = p.worktree_info.is_some(); + let folder = ws.folder_for_project_or_parent(&p.id); + let folder_id = folder.map(|f| f.id.clone()); + let folder_name = folder.map(|f| f.name.clone()); + Some(TerminalCloseInfo { + project_hooks: p.hooks.clone(), + parent_hooks, + project_id: p.id.clone(), + project_name: p.name.clone(), + project_path: p.path.clone(), + terminal_id: tid.clone(), + terminal_name, + is_worktree, + exit_code: *exit_code, + folder_id, + folder_name, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + use okena_terminal::backend::LocalBackend; + use okena_terminal::session_backend::SessionBackend; + use okena_terminal::terminal::{Terminal, TerminalSize}; + use okena_workspace::state::WorkspaceData; + + fn terminal_size() -> TerminalSize { + TerminalSize { + cols: 80, + rows: 24, + cell_width: 8.0, + cell_height: 16.0, + } + } + + fn test_reactor(workspace: Workspace, settings: AppSettings) -> PtyLoopReactor { + let (workspace_tick, _wrx) = watch::channel(0u64); + PtyLoopReactor { + workspace: Arc::new(Mutex::new(workspace)), + hook_runner: None, + hook_monitor: Some(HookMonitor::new()), + workspace_tick, + settings: Arc::new(Mutex::new(settings)), + } + } + + /// `run_pty_loop` routes a synthesized `Data` event into a registered + /// terminal: the terminal's `content_generation` advances, proving the + /// bytes reached `process_output`. Exercises the recv → registry-lookup → + /// `process_output` path (no exits) on a `LocalSet`, and that the loop + /// exits cleanly once every sender is dropped. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_pty_loop_processes_data_into_registered_terminal() { + // Our own event channel — `run_pty_loop` consumes the receiver; we keep + // the sender to inject one event and then drop it so the loop ends. + let (tx, pty_events) = async_channel::bounded::(16); + + // A real `PtyManager` (no terminals spawned) provides the + // `TerminalTransport` for the test terminal and the `cleanup_exited` / + // `kill` no-ops; its own internal channel is unused here. + let (pty_manager, _pty_manager_events) = PtyManager::new(SessionBackend::None); + let pty_manager = Arc::new(pty_manager); + + let terminals: TerminalsRegistry = Arc::new(parking_lot::Mutex::new(Default::default())); + let transport = pty_manager.clone(); // PtyManager: TerminalTransport + let term = Arc::new(Terminal::new( + "t1".to_string(), + terminal_size(), + transport, + "/tmp".to_string(), + )); + let gen_before = term.content_generation(); + terminals.lock().insert("t1".to_string(), term.clone()); + + let backend = Arc::new(LocalBackend::new(pty_manager.clone())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals.clone()))); + + let (service_tick, _srx) = watch::channel(0u64); + let (state_version, _vrx) = watch::channel(0u64); + let reactor = test_reactor(Workspace::new(WorkspaceData::empty()), AppSettings::default()); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let handle = tokio::task::spawn_local(run_pty_loop( + pty_events, + terminals.clone(), + pty_manager.clone(), + service_manager.clone(), + Handle::current(), + service_tick, + reactor, + state_version, + )); + + tx.send(PtyEvent::Data { + terminal_id: "t1".to_string(), + data: b"hello".to_vec(), + }) + .await + .expect("send synthesized data event"); + + // Drop the only sender so `recv` returns `Err`, ending the loop. + drop(tx); + + handle.await.expect("pty loop task joins"); + }) + .await; + + // `process_output` bumped the content generation → the data was routed + // into the registered terminal. + assert!( + term.content_generation() > gen_before, + "process_output should have advanced content_generation (before={gen_before}, after={})", + term.content_generation(), + ); + } +} diff --git a/crates/okena-daemon-core/src/reactor.rs b/crates/okena-daemon-core/src/reactor.rs new file mode 100644 index 000000000..118e612ad --- /dev/null +++ b/crates/okena-daemon-core/src/reactor.rs @@ -0,0 +1,86 @@ +//! Shared daemon state: the GPUI-free analogue of the app's entity graph. +//! +//! The GUI keeps `Workspace` and `ServiceManager` as GPUI entities and reacts to +//! their `notify` via observers. The daemon instead holds them behind +//! `Arc>` and turns each `notify` into a `watch` channel +//! bump, so future observer tasks can `await` a change and re-derive state / +//! broadcast it over the protocol. +//! +//! Three independent `watch` channels mirror the three notify surfaces: +//! - [`state_version`](DaemonReactor::state_version) — coarse "something +//! persistent changed" tick used by the autosave / snapshot observer. +//! - [`workspace_tick`](DaemonReactor::workspace_tick) — bumped by +//! [`WorkspaceCx::notify`](okena_workspace::context::WorkspaceCx::notify). +//! - [`service_tick`](DaemonReactor::service_tick) — bumped by the service +//! reactor's `notify`. + +use std::sync::Arc; + +use okena_hooks::{HookMonitor, HookRunner}; +use okena_services::manager::ServiceManager; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::TerminalsRegistry; +use okena_workspace::state::Workspace; +use parking_lot::Mutex; +use tokio::runtime::Handle; +use tokio::sync::watch; + +/// The shared, GPUI-free daemon state driven by the tokio reactor. +/// +/// Cheaply clonable bits (the `Arc>` handles, the `watch::Sender`s, the +/// tokio [`Handle`], the `Arc`-backed hook services) are what the trait impls +/// capture so spawned tasks can re-enter the managers and signal changes. +pub struct DaemonReactor { + /// The workspace state, shared with reactor contexts. `Workspace::new` is + /// gpui-free, so this is a plain mutex rather than a GPUI entity. + pub workspace: Arc>, + + /// The service manager state, shared with reactor contexts. + pub service_manager: Arc>, + + /// Coarse "persistent state changed" tick (autosave / snapshot observer). + pub state_version: watch::Sender, + + /// Bumped on every `WorkspaceCx::notify`. + pub workspace_tick: watch::Sender, + + /// Bumped on every service-reactor `notify`. + pub service_tick: watch::Sender, + + /// Hook runner (creates PTY-backed hook terminals), if configured. + pub hook_runner: Option, + + /// Hook monitor (tracks in-flight/completed hook runs), if configured. + pub hook_monitor: Option, + + /// Handle to the multi-thread tokio runtime the reactor tasks spawn onto. + pub runtime: Handle, +} + +impl DaemonReactor { + /// Build the shared daemon state. + /// + /// The terminal backend + registry are the same dependencies the GUI hands + /// to `ServiceManager::new` / `HookRunner::new`; the daemon's bootstrap owns + /// them and passes them in. The three `watch` channels start at `0`. + pub fn new( + workspace: Workspace, + backend: Arc, + terminals: TerminalsRegistry, + hook_runner: Option, + hook_monitor: Option, + runtime: Handle, + ) -> Self { + let service_manager = ServiceManager::new(backend, terminals); + Self { + workspace: Arc::new(Mutex::new(workspace)), + service_manager: Arc::new(Mutex::new(service_manager)), + state_version: watch::Sender::new(0), + workspace_tick: watch::Sender::new(0), + service_tick: watch::Sender::new(0), + hook_runner, + hook_monitor, + runtime, + } + } +} diff --git a/crates/okena-daemon-core/src/service_cx.rs b/crates/okena-daemon-core/src/service_cx.rs new file mode 100644 index 000000000..1bd3c1ec2 --- /dev/null +++ b/crates/okena-daemon-core/src/service_cx.rs @@ -0,0 +1,189 @@ +//! GPUI-free implementers of the service-manager reactor traits. +//! +//! Maps the GPUI shapes onto tokio / `Arc`: +//! +//! | trait | GPUI | daemon | +//! |--------------------|-------------------------------|-------------------------------| +//! | `ServiceCx` | `Context<'_, ServiceManager>` | [`DaemonServiceCx`] | +//! | `ServiceHandle` | `WeakEntity` | [`DaemonServiceHandle`] | +//! | `ServiceAsyncCx` | `AsyncApp` | [`DaemonServiceAsyncCx`] | +//! +//! The handle is an `Arc>` plus the bits a re-spawned task +//! needs (the tokio [`Handle`] and the service notify channel). `update` re-locks +//! the mutex instead of upgrading a `WeakEntity` — the manager is never "gone", +//! so it always returns `Some`. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use okena_services::manager::{ServiceAsyncCx, ServiceCx, ServiceHandle, ServiceManager}; +use parking_lot::Mutex; +use tokio::runtime::Handle; +use tokio::sync::watch; + +/// Cloneable bundle of everything a spawned service task / reentry needs: the +/// shared manager, the tokio runtime handle, and the service notify channel. +/// +/// Held by [`DaemonServiceHandle`] and [`DaemonServiceAsyncCx`] (both `'static`) +/// and re-borrowed by the short-lived [`DaemonServiceCx`]. Cloning is cheap +/// (three `Arc`/sender clones). +#[derive(Clone)] +struct ServiceReactor { + manager: Arc>, + runtime: Handle, + service_tick: watch::Sender, +} + +impl ServiceReactor { + fn bump_notify(&self) { + self.service_tick.send_modify(|v| *v += 1); + } +} + +/// Cloneable, `'static` handle a spawned task captures to re-enter the manager. +/// +/// GPUI's `WeakEntity` equivalent. `update` re-locks the shared +/// mutex and runs the callback against the manager plus a fresh +/// [`DaemonServiceCx`]. +#[derive(Clone)] +pub struct DaemonServiceHandle { + reactor: ServiceReactor, +} + +impl DaemonServiceHandle { + /// Build a handle from the shared manager + reactor bits. + pub fn new( + manager: Arc>, + runtime: Handle, + service_tick: watch::Sender, + ) -> Self { + Self { + reactor: ServiceReactor { + manager, + runtime, + service_tick, + }, + } + } +} + +impl ServiceHandle for DaemonServiceHandle { + type AsyncCx = DaemonServiceAsyncCx; + + fn update( + &self, + _cx: &mut Self::AsyncCx, + f: impl FnOnce(&mut ServiceManager, &mut DaemonServiceCx<'_>) -> R, + ) -> Option { + // Re-lock the shared manager (the GPUI `WeakEntity::update` reentry). The + // manager can't be "gone" behind an `Arc`, so this always runs and + // returns `Some` — the `Option` exists only to mirror GPUI's released- + // entity case. + let mut manager = self.reactor.manager.lock(); + let mut reentry = DaemonServiceCx { + reactor: &self.reactor, + }; + Some(f(&mut manager, &mut reentry)) + } +} + +/// Synchronous, main-context reentry context. GPUI's +/// `Context<'_, ServiceManager>` equivalent. Borrows the reactor bits for the +/// duration of one reentry callback. +pub struct DaemonServiceCx<'a> { + reactor: &'a ServiceReactor, +} + +/// Owned, `'static` holder of the reactor bits, so callers outside this module +/// can mint a top-level [`DaemonServiceCx`] without naming the private +/// [`ServiceReactor`]. Construct once at the service-method call site. +pub struct ServiceReactorRef(ServiceReactor); + +impl ServiceReactorRef { + /// Build the holder from the shared manager + reactor bits. + pub fn new( + manager: Arc>, + runtime: Handle, + service_tick: watch::Sender, + ) -> Self { + Self(ServiceReactor { + manager, + runtime, + service_tick, + }) + } + + /// Borrow a top-level [`DaemonServiceCx`] for a `ServiceManager` method call. + pub fn cx(&self) -> DaemonServiceCx<'_> { + DaemonServiceCx { reactor: &self.0 } + } +} + +impl ServiceCx for DaemonServiceCx<'_> { + type Handle = DaemonServiceHandle; + type AsyncCx = DaemonServiceAsyncCx; + + fn notify(&mut self) { + self.reactor.bump_notify(); + } + + fn spawn_main(&self, f: F) + where + F: AsyncFnOnce(Self::Handle, &mut Self::AsyncCx) + 'static, + { + // GPUI spawns onto the *main* (foreground) executor: single-threaded, so + // the captured future need not be `Send` — and the trait signature does + // not bound it `Send`. The faithful tokio analogue of a single-threaded + // foreground executor is `tokio::task::spawn_local`, which keeps the + // (`!Send`) future on the current thread instead of moving it to a worker + // (`Handle::spawn`/`spawn_blocking` would both require `Send`, which we do + // not have, and adding the bound would be a type hack that changes the + // trait contract). + // + // Requirement on the daemon's runtime: the reactor's "main loop" must run + // inside a `tokio::task::LocalSet` on a current-thread runtime, the + // structural mirror of GPUI's single-threaded main loop. `spawn_local` + // panics if no `LocalSet` is active — the wiring step that owns the loop + // is responsible for that, exactly as GPUI owns its main executor. + let handle = DaemonServiceHandle { + reactor: self.reactor.clone(), + }; + let mut async_cx = DaemonServiceAsyncCx { + reactor: self.reactor.clone(), + }; + tokio::task::spawn_local(async move { + f(handle, &mut async_cx).await; + }); + } +} + +/// The async context held across await points inside a spawned service task. +/// GPUI's `AsyncApp` equivalent. `'static` so it survives the spawned future. +pub struct DaemonServiceAsyncCx { + reactor: ServiceReactor, +} + +impl ServiceAsyncCx for DaemonServiceAsyncCx { + type ReentryCx<'a> + = DaemonServiceCx<'a> + where + Self: 'a; + + fn spawn_blocking( + &self, + fut: impl Future + Send + 'static, + ) -> impl Future + where + T: Send + 'static, + { + // Offload onto the multi-thread runtime and await the join handle. The + // future is `Send + 'static`, so `Handle::spawn` accepts it directly. + let join = self.reactor.runtime.spawn(fut); + async move { join.await.expect("daemon spawn_blocking: task panicked") } + } + + fn timer(&self, duration: Duration) -> impl Future { + tokio::time::sleep(duration) + } +} diff --git a/crates/okena-daemon-core/src/soft_close.rs b/crates/okena-daemon-core/src/soft_close.rs new file mode 100644 index 000000000..84531df40 --- /dev/null +++ b/crates/okena-daemon-core/src/soft_close.rs @@ -0,0 +1,51 @@ +//! Daemon-side grace-period finalizer. The command loop ejects a busy terminal +//! and records a deadline; this loop kills the PTY once the grace elapses. +//! Undo / Close-now (handled in the command loop) remove the deadline first. +//! +//! The finalize logic itself lives in the shared, runtime-agnostic engine +//! ([`okena_workspace::actions::soft_close`]); this module only owns the tokio +//! timer that ticks it. The headless loop drives the same engine off a gpui +//! timer instead. + +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use tokio::sync::watch; + +use okena_hooks::{HookMonitor, HookRunner}; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::TerminalsRegistry; +use okena_workspace::actions::soft_close::finalize_expired; +use okena_workspace::state::Workspace; + +use crate::workspace_cx::DaemonWorkspaceCx; + +/// Shared `terminal_id -> grace deadline` map for in-flight soft-closes. +/// +/// Re-exported from the shared engine so daemon-core callers (`daemon.rs`) stay +/// unchanged now that the type lives in `okena-workspace`. +pub use okena_workspace::actions::soft_close::SoftCloseDeadlines; + +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +/// Periodically finalize soft-closes whose grace period elapsed: drop the +/// pending record (workspace), then kill the PTY + drop it from the registry. +/// The client toast TTLs out on its own. +pub async fn run_soft_close_poll( + workspace: Arc>, + backend: Arc, + terminals: TerminalsRegistry, + workspace_tick: watch::Sender, + hook_runner: Option, + hook_monitor: Option, + deadlines: SoftCloseDeadlines, +) { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + finalize_expired(&deadlines, &mut ws, &*backend, &terminals, &mut cx); + } +} diff --git a/crates/okena-daemon-core/src/test_support.rs b/crates/okena-daemon-core/src/test_support.rs new file mode 100644 index 000000000..a0890e0ed --- /dev/null +++ b/crates/okena-daemon-core/src/test_support.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use okena_state::WorkspaceData; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::shell_config::ShellType; +use okena_terminal::terminal::TerminalTransport; +use okena_workspace::persistence::AppSettings; + +pub(crate) struct StubTransport; + +impl TerminalTransport for StubTransport { + fn send_input(&self, _terminal_id: &str, _data: &[u8]) {} + fn resize(&self, _terminal_id: &str, _cols: u16, _rows: u16) {} + fn uses_mouse_backend(&self) -> bool { + false + } +} + +pub(crate) struct StubBackend; + +impl TerminalBackend for StubBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal(&self, _cwd: &str, _shell: Option<&ShellType>) -> anyhow::Result { + anyhow::bail!("stub backend: create_terminal not supported") + } + + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("stub backend: reconnect_terminal not supported") + } + + fn kill(&self, _terminal_id: &str) {} + + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + + fn supports_buffer_capture(&self) -> bool { + false + } + + fn is_remote(&self) -> bool { + false + } + + fn get_shell_pid(&self, _terminal_id: &str) -> Option { + None + } + + fn get_service_pids(&self, _terminal_id: &str) -> Vec { + Vec::new() + } +} + +pub(crate) fn empty_workspace_data() -> WorkspaceData { + WorkspaceData { + version: 1, + projects: Vec::new(), + project_order: Vec::new(), + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + } +} + +pub(crate) fn default_settings() -> AppSettings { + serde_json::from_value::(serde_json::json!({})).expect("defaults") +} diff --git a/crates/okena-daemon-core/src/toast_poll.rs b/crates/okena-daemon-core/src/toast_poll.rs new file mode 100644 index 000000000..03afcd7d1 --- /dev/null +++ b/crates/okena-daemon-core/src/toast_poll.rs @@ -0,0 +1,123 @@ +//! GPUI-free toast forwarder: drains the daemon's [`HookMonitor`] pending toasts +//! and broadcasts them to connected clients as [`ApiToast`]s. +//! +//! ## Why this exists +//! +//! In the in-process GUI, the toast overlay polls `HookMonitor::drain_pending_toasts` +//! every ~50ms and shows the results via `ToastManager`. The daemon runs the +//! lifecycle hooks (so hook failures queue toasts in *its* `HookMonitor`) but has +//! no surface to display them — and the thin-client GUI never sees them because +//! the hooks ran remotely. This loop closes that gap: it periodically drains the +//! daemon's `HookMonitor` and pushes each toast onto the toast broadcast that the +//! remote server fans out over the WebSocket (`WsOutbound::Toast`). +//! +//! ## Draining vs. delivery +//! +//! Draining is unconditional: we drain (and thus clear) the pending queue every +//! cycle even when no client is connected, so the queue can never grow unbounded +//! while the daemon runs unattended. `broadcast::Sender::send` returns +//! `Err(SendError)` when there are no receivers — that is expected and ignored +//! (fire-and-forget, mirroring the git-status broadcast). A toast produced while +//! no client is connected is simply dropped; daemon toasts are non-critical +//! notifications, not durable state. + +use std::time::Duration; + +use okena_core::api::ApiToast; +use okena_hooks::HookMonitor; + +/// How often to drain the `HookMonitor` and forward pending toasts. Roughly +/// matches the GUI toast overlay's 50ms poll, but looser since these toasts are +/// non-critical and a small extra latency is unnoticeable. +const TOAST_POLL_INTERVAL: Duration = Duration::from_millis(200); + +/// Run the daemon toast-forward loop forever (cancelled on daemon shutdown when +/// its `LocalSet`/runtime is torn down). +/// +/// Each cycle drains the `HookMonitor`'s pending toasts and broadcasts each one +/// as an [`ApiToast`]; a send with no receivers is ignored. When the daemon has +/// no `HookMonitor` (hooks disabled) there is nothing to forward, so the loop +/// returns immediately rather than spinning. +pub async fn run_toast_poll( + hook_monitor: Option, + toast_tx: tokio::sync::broadcast::Sender, +) { + let Some(hook_monitor) = hook_monitor else { + // No hook monitor → no toast source. Nothing to do. + return; + }; + + loop { + for toast in hook_monitor.drain_pending_toasts() { + // Ignore the no-receivers error: clients may come and go, and these + // toasts are fire-and-forget. Draining above already cleared the + // queue so it cannot grow unbounded regardless of delivery. + let _ = toast_tx.send(toast.to_api()); + } + tokio::time::sleep(TOAST_POLL_INTERVAL).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use okena_hooks::HookStatus; + + /// A failed hook queues a toast in the monitor; one drain cycle forwards it + /// to a subscribed receiver as an `ApiToast` carrying the error level. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn forwards_pending_hook_toast_to_receiver() { + let monitor = HookMonitor::new(); + let id = monitor.record_start("pre_merge", "exit 1", "proj", None); + monitor.record_finish( + id, + HookStatus::Failed { + duration: Duration::from_millis(1), + exit_code: 1, + stderr: "boom".to_string(), + }, + ); + + let (tx, mut rx) = tokio::sync::broadcast::channel::(8); + + // Manually run a single drain cycle (the public loop sleeps forever). + for toast in monitor.drain_pending_toasts() { + let _ = tx.send(toast.to_api()); + } + + let api = rx.try_recv().expect("a toast should have been forwarded"); + assert_eq!(api.level, "error"); + assert!(api.message.contains("pre_merge")); + // The queue was drained — a second cycle forwards nothing. + assert!(monitor.drain_pending_toasts().is_empty()); + } + + /// Draining still happens with no receivers: the `send` error is swallowed + /// and the queue is cleared so it cannot grow unbounded. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn drains_even_without_receivers() { + let monitor = HookMonitor::new(); + let id = monitor.record_start("on_open", "bad", "proj", None); + monitor.record_finish( + id, + HookStatus::SpawnError { + message: "not found".to_string(), + }, + ); + + // Sender with no live receiver: `send` errors, but draining proceeds. + let (tx, _) = tokio::sync::broadcast::channel::(8); + for toast in monitor.drain_pending_toasts() { + let _ = tx.send(toast.to_api()); + } + + assert!(monitor.drain_pending_toasts().is_empty()); + } + + /// No hook monitor → the loop returns immediately (no panic, no spin). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn returns_immediately_without_hook_monitor() { + let (tx, _) = tokio::sync::broadcast::channel::(8); + run_toast_poll(None, tx).await; + } +} diff --git a/crates/okena-daemon-core/src/workspace_cx.rs b/crates/okena-daemon-core/src/workspace_cx.rs new file mode 100644 index 000000000..e638cd2f0 --- /dev/null +++ b/crates/okena-daemon-core/src/workspace_cx.rs @@ -0,0 +1,62 @@ +//! GPUI-free [`WorkspaceCx`] implementer. +//! +//! The GUI satisfies `WorkspaceCx` with `gpui::Context<'_, Workspace>`; the +//! daemon satisfies it with [`DaemonWorkspaceCx`], a thin borrow of the +//! reactor's notify channel + hook services. It is constructed around a +//! `&mut Workspace` mutation site (e.g. just after locking +//! [`DaemonReactor::workspace`](crate::reactor::DaemonReactor::workspace)) so the +//! workspace action methods — which take `cx: &mut impl WorkspaceCx` — run +//! unchanged. + +use okena_hooks::{HookMonitor, HookRunner}; +use okena_workspace::context::WorkspaceCx; +use tokio::sync::watch; + +/// Daemon-side [`WorkspaceCx`]: `notify` bumps the workspace tick, `refresh_views` +/// is a no-op (no local views), and the hook accessors clone the held services. +/// +/// Borrows the reactor's `workspace_tick` sender and hook-service options for the +/// duration of a single mutation, so it never holds the `Workspace` mutex lock +/// across the borrow. +pub struct DaemonWorkspaceCx<'a> { + workspace_tick: &'a watch::Sender, + hook_runner: &'a Option, + hook_monitor: &'a Option, +} + +impl<'a> DaemonWorkspaceCx<'a> { + /// Construct a context from the reactor's notify channel + hook services. + pub fn new( + workspace_tick: &'a watch::Sender, + hook_runner: &'a Option, + hook_monitor: &'a Option, + ) -> Self { + Self { + workspace_tick, + hook_runner, + hook_monitor, + } + } +} + +impl WorkspaceCx for DaemonWorkspaceCx<'_> { + fn notify(&mut self) { + // Bump the tick; observer tasks `await` the change. `send_modify` always + // notifies receivers even if no value comparison would (it can't return + // an error — there is always the internal receiver), matching GPUI's + // unconditional `Context::notify`. + self.workspace_tick.send_modify(|v| *v += 1); + } + + fn refresh_views(&mut self) { + // No local views in the daemon — nothing to invalidate. + } + + fn hook_runner(&self) -> Option { + self.hook_runner.clone() + } + + fn hook_monitor(&self) -> Option { + self.hook_monitor.clone() + } +} diff --git a/crates/okena-daemon/Cargo.toml b/crates/okena-daemon/Cargo.toml new file mode 100644 index 000000000..c40e4767e --- /dev/null +++ b/crates/okena-daemon/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "okena-daemon" +version.workspace = true +edition = "2024" +license = "MIT" + +[[bin]] +name = "okena-daemon" +path = "src/main.rs" + +[dependencies] +okena-daemon-core = { path = "../okena-daemon-core" } +# gpui-free: provides the profile resolution used to honor OKENA_PROFILE so the +# daemon lands in the same config dir as the spawning desktop. +okena-core = { path = "../okena-core" } +# Pulled gpui-free: okena-workspace defaults to the `gpui` feature, so we must +# opt out of default features to keep this binary 100% GPUI-free. +okena-workspace = { path = "../okena-workspace", default-features = false } +# gpui-free local-daemon helpers: the self-restart handoff waits for the +# outgoing daemon (`--await-pid`) before this one locks/binds. default-features +# off keeps the daemon binary 100% GPUI-free (the `gpui` feature is opt-in). +okena-remote-server = { path = "../okena-remote-server", default-features = false } +anyhow = "1.0" +log = "0.4" +env_logger = "0.11" diff --git a/crates/okena-daemon/src/main.rs b/crates/okena-daemon/src/main.rs new file mode 100644 index 000000000..21edbdc1a --- /dev/null +++ b/crates/okena-daemon/src/main.rs @@ -0,0 +1,222 @@ +//! Standalone, GPUI-free headless daemon binary. +//! +//! This is the runnable counterpart of `okena --headless`: it boots the headless +//! server by constructing and running [`okena_daemon_core::DaemonCore`], with no +//! GPUI anywhere in scope. It mirrors the GUI's `run_headless` bootstrap +//! (`src/main.rs`), but loads settings via the gpui-free +//! [`okena_workspace::settings::load_settings`] instead of GPUI's +//! `settings::init_settings(cx)`. +//! +//! The desktop app's `spawn_daemon` prefers this binary when it exists next to +//! `okena`; the `okena --headless` fallback remains supported for single-binary +//! installs. + +use std::io::Write; +use std::net::IpAddr; + +use anyhow::Context; +use okena_daemon_core::{DaemonCore, DaemonParams}; +use okena_workspace::persistence; +use okena_workspace::settings::load_settings; + +/// Writes to both stderr and a log file simultaneously (mirrors the GUI's +/// `TeeWriter`). The daemon is spawned by the GUI with inherited stdio, so its +/// stderr lands in the GUI's terminal; teeing to a dedicated `okena-daemon.log` +/// keeps a durable record (incl. panics) separate from the GUI's `okena.log`. +struct TeeWriter { + stderr: std::io::Stderr, + file: std::fs::File, +} + +impl Write for TeeWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let _ = self.stderr.write_all(buf); + self.file.write_all(buf)?; + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + let _ = self.stderr.flush(); + self.file.flush() + } +} + +fn main() -> anyhow::Result<()> { + if std::env::args().any(|a| a == "--version") { + println!("okena-daemon {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // 0. Resolve the active profile (env-only: the spawning desktop propagates + // OKENA_PROFILE; a standalone daemon reads the default / last-used). This + // makes get_config_dir() resolve the SAME profile dir the desktop uses, so + // both read/write the same workspace. Must run before logging (which uses + // the profile's paths) and before load_settings()/load_workspace(). + let profile_paths = match okena_core::profiles::resolve_active_profile(None) { + Ok(p) => p, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + // SAFETY: called before any threads are spawned; no concurrent env access. + unsafe { std::env::set_var("OKENA_PROFILE", &profile_paths.id) }; + // Capture the daemon log path BEFORE moving `profile_paths` into the global. + let daemon_log = profile_paths.root.join("okena-daemon.log"); + okena_core::profiles::init_profile(profile_paths); + if let Err(e) = + okena_core::profiles::migrate_legacy_layout_if_needed(okena_core::profiles::current()) + { + eprintln!("Warning: profile migration failed: {e}"); + } + + // Snapshot the existing config BEFORE load_settings()/load_workspace() so an + // upgrade can be reverted to an old-format config the previous binary reads. + // Shares the marker + config-backups dir with the GUI (first-wins, idempotent). + { + use okena_core::profiles::SchemaVersion; + use okena_workspace::persistence::{ + SETTINGS_VERSION, WINDOW_LAYOUT_VERSION, WORKSPACE_VERSION, + }; + let schema_versions = [ + SchemaVersion { + file: "workspace.json", + current: WORKSPACE_VERSION, + }, + SchemaVersion { + file: "settings.json", + current: SETTINGS_VERSION, + }, + SchemaVersion { + file: "window-layout.json", + current: WINDOW_LAYOUT_VERSION, + }, + ]; + if let Err(e) = okena_core::profiles::snapshot_configs_before_upgrade( + okena_core::profiles::current(), + env!("CARGO_PKG_VERSION"), + &schema_versions, + ) { + eprintln!("Warning: config snapshot failed: {e}"); + } + okena_core::profiles::record_app_version( + okena_core::profiles::current(), + env!("CARGO_PKG_VERSION"), + ); + } + + // 1. Logging: env_logger driven by RUST_LOG (default "info"), teed to + // `okena-daemon.log` so the daemon's output + panics survive even though + // its stderr is the GUI's inherited terminal. Best-effort: if the file + // can't be opened we fall back to plain stderr. + let mut builder = + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")); + if let Ok(file) = std::fs::File::create(&daemon_log) { + builder.target(env_logger::fmt::Target::Pipe(Box::new(TeeWriter { + stderr: std::io::stderr(), + file, + }))); + } + builder.init(); + + // Log panics (with a backtrace) to okena-daemon.log. Without this a panic on + // the awaited path (command loop / startup) aborts the process leaving only + // the client's "connection refused" — no cause. The default hook still runs + // after, preserving normal stderr output. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let backtrace = std::backtrace::Backtrace::force_capture(); + log::error!("daemon PANIC: {info}\n{backtrace}"); + default_hook(info); + })); + + // 1b. Self-restart handoff: a daemon restarting itself spawns this process + // with `--await-pid ` (see okena_remote_server::routes::restart). + // Wait for the outgoing daemon to exit BEFORE constructing DaemonCore, + // which acquires the instance lock (fail-fast against a live PID) and + // binds a port (the old one may linger in TIME_WAIT). Bounded so a wedged + // predecessor doesn't hang the new daemon forever; on timeout we proceed + // anyway and let the lock acquisition surface the real error. + if let Some(old_pid) = okena_remote_server::local::parse_await_pid(std::env::args()) { + log::info!("restart: waiting for outgoing daemon (pid {old_pid}) to exit"); + let exited = okena_remote_server::local::wait_for_pid_exit( + old_pid, + std::time::Duration::from_secs(10), + ); + if !exited { + log::warn!( + "restart: outgoing daemon (pid {old_pid}) still alive after 10s; \ + proceeding (lock acquisition will fail if it truly holds the lock)" + ); + } + } + + // 2. Optional `--listen ` override. Parsing is intentionally minimal and + // dependency-free; the error messages mirror the GUI's `src/main.rs`. + let listen_override: Option = parse_listen_override(); + + // 3. Load settings (gpui-free), then the workspace — falling back to the + // default workspace on error, logging like the GUI's `run_headless`. + let settings = load_settings(); + let session_backend = settings.session_backend; // `Copy` + let workspace_data = persistence::load_workspace(session_backend).unwrap_or_else(|e| { + log::error!( + "Failed to load workspace: {}. A backup may have been saved to {:?}. Using default workspace.", + e, + persistence::get_workspace_path().with_extension("json.bak") + ); + persistence::default_workspace() + }); + + // 4. Resolve TCP bind addresses. Same-host access is always available via + // loopback (and Unix socket on Unix). When the user enables remote server + // mode, also bind the configured interface so off-host clients can connect. + // An explicit `--listen` is standalone/headless intent and is honored even + // when the settings toggle is off. + let listen_addrs = + okena_remote_server::local::resolve_daemon_listen_addrs(listen_override, &settings); + + // 5. Build params (read TLS out before moving `settings`) and run. `run` + // blocks until the bridge closes or ctrl-c arrives — that is expected, + // the daemon is UI-owned. + // + // TLS policy by deployment mode (architecture §1): a purely local daemon + // serves plain http; once any non-loopback listener is active, honor the + // user's TLS preference for off-host clients. The server remains dual-stack, + // so same-host plain clients continue to work on the local endpoint. + let tls_enabled = + listen_addrs.iter().any(|addr| !addr.is_loopback()) && settings.remote_tls_enabled; + let params = DaemonParams { + workspace_data, + settings, + session_backend, + listen_addrs, + tls_enabled, + }; + + DaemonCore::new(params) + .context("failed to start daemon")? + .run() +} + +/// Parse an optional `--listen ` override from the process args. +/// +/// Mirrors the GUI's `src/main.rs`: a missing or malformed value prints a helpful +/// message to stderr and exits non-zero. +fn parse_listen_override() -> Option { + let args: Vec = std::env::args().collect(); + let pos = args.iter().position(|a| a == "--listen")?; + match args.get(pos + 1) { + Some(addr_str) => match addr_str.parse::() { + Ok(addr) => Some(addr), + Err(_) => { + eprintln!("Invalid address for --listen: {addr_str}"); + eprintln!("Expected an IP address, e.g. --listen 0.0.0.0"); + std::process::exit(1); + } + }, + None => { + eprintln!("--listen requires an address argument, e.g. --listen 0.0.0.0"); + std::process::exit(1); + } + } +} diff --git a/crates/okena-ext-claude/src/usage.rs b/crates/okena-ext-claude/src/usage.rs index 571c63da0..823a4af13 100644 --- a/crates/okena-ext-claude/src/usage.rs +++ b/crates/okena-ext-claude/src/usage.rs @@ -1,19 +1,19 @@ use crate::ui_helpers::capitalize_first; -use okena_extensions::{ExtensionSettingsStore, ThemeColors}; -use okena_usage::{ - effective_time_pct, read_working_days, render_simple_bar, render_usage_row, - usage_body_container, usage_divider, usage_kv_row, usage_popover_container, - usage_popover_header, usage_trigger_items, SegmentUnit, UsageRow, -}; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::{h_flex, v_flex}; +use okena_extensions::{ExtensionSettingsStore, ThemeColors}; +use okena_usage::{ + SegmentUnit, UsageRow, effective_time_pct, read_working_days, render_simple_bar, + render_usage_row, usage_body_container, usage_divider, usage_kv_row, usage_popover_container, + usage_popover_header, usage_trigger_items, +}; use parking_lot::Mutex; #[cfg(target_os = "macos")] use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; /// Refresh interval for usage data @@ -50,6 +50,14 @@ struct ExtraUsage { utilization: f64, } +/// Usage info for a model-scoped weekly limit, e.g. a dedicated Fable bucket. +#[derive(Clone)] +struct ScopedTierUsage { + label: String, + tier: TierUsage, + active: bool, +} + /// All fetched usage data #[derive(Clone)] struct UsageData { @@ -57,8 +65,7 @@ struct UsageData { plan: Option, five_hour: Option, seven_day: Option, - seven_day_sonnet: Option, - seven_day_opus: Option, + weekly_scoped: Vec, extra_usage: Option, } @@ -72,9 +79,10 @@ fn expand_tilde(path: &str) -> PathBuf { return home.join(rest); } } else if path == "~" - && let Some(home) = dirs::home_dir() { - return home; - } + && let Some(home) = dirs::home_dir() + { + return home; + } PathBuf::from(path) } @@ -102,13 +110,15 @@ fn existing_path(path: &str, source: &str) -> Option { pub fn resolve_claude_dir(cx: &App) -> PathBuf { if let Some(settings) = cx.global::().get("claude-code", cx) && let Some(dir) = settings["config_dir"].as_str() - && let Some(expanded) = existing_path(dir, "settings config_dir") { - return expanded; - } + && let Some(expanded) = existing_path(dir, "settings config_dir") + { + return expanded; + } if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") - && let Some(expanded) = existing_path(&dir, "CLAUDE_CONFIG_DIR") { - return expanded; - } + && let Some(expanded) = existing_path(&dir, "CLAUDE_CONFIG_DIR") + { + return expanded; + } dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".claude") @@ -147,7 +157,9 @@ struct ClaudeUsageData { fn keychain_service_name(claude_dir: &Path) -> String { const BASE: &str = "Claude Code-credentials"; let default_dir = dirs::home_dir().map(|h| h.join(".claude")); - let canonical = claude_dir.canonicalize().unwrap_or_else(|_| claude_dir.to_path_buf()); + let canonical = claude_dir + .canonicalize() + .unwrap_or_else(|_| claude_dir.to_path_buf()); if Some(&canonical) == default_dir.as_ref() { BASE.to_string() } else { @@ -161,7 +173,9 @@ fn keychain_service_name(claude_dir: &Path) -> String { #[cfg(target_os = "macos")] fn suffixed_keychain_service_name(claude_dir: &Path) -> String { const BASE: &str = "Claude Code-credentials"; - let canonical = claude_dir.canonicalize().unwrap_or_else(|_| claude_dir.to_path_buf()); + let canonical = claude_dir + .canonicalize() + .unwrap_or_else(|_| claude_dir.to_path_buf()); let mut h = Sha256::new(); h.update(canonical.to_string_lossy().as_bytes()); let d = h.finalize(); @@ -221,11 +235,16 @@ fn read_access_token(claude_dir: &Path) -> Option { { let user = std::env::var("USER").ok()?; for service in keychain_service_names(claude_dir) { - let output = okena_core::process::safe_output( - okena_core::process::command("security") - .args(["find-generic-password", "-s", &service, "-a", &user, "-w"]), - ) - .ok()?; + let output = + okena_core::process::safe_output(okena_core::process::command("security").args([ + "find-generic-password", + "-s", + &service, + "-a", + &user, + "-w", + ])) + .ok()?; if output.status.success() { let content = String::from_utf8_lossy(&output.stdout).trim().to_string(); if let Some(token) = extract_access_token(&content, now) { @@ -264,11 +283,16 @@ fn read_subscription_type(claude_dir: &Path) -> Option { { let user = std::env::var("USER").ok()?; for service in keychain_service_names(claude_dir) { - let output = okena_core::process::safe_output( - okena_core::process::command("security") - .args(["find-generic-password", "-s", &service, "-a", &user, "-w"]), - ) - .ok()?; + let output = + okena_core::process::safe_output(okena_core::process::command("security").args([ + "find-generic-password", + "-s", + &service, + "-a", + &user, + "-w", + ])) + .ok()?; if output.status.success() { let content = String::from_utf8_lossy(&output.stdout).trim().to_string(); if let Some(plan) = extract_subscription_type(&content) { @@ -282,26 +306,31 @@ fn read_subscription_type(claude_dir: &Path) -> Option { } fn parse_usage(resp: &serde_json::Value) -> UsageData { - let five_hour = parse_tier(resp, "five_hour", FIVE_HOUR_SECS); - let seven_day = parse_tier(resp, "seven_day", SEVEN_DAY_SECS); - let seven_day_sonnet = parse_tier(resp, "seven_day_sonnet", SEVEN_DAY_SECS); - let seven_day_opus = parse_tier(resp, "seven_day_opus", SEVEN_DAY_SECS); - - let extra_usage = resp.get("extra_usage").map(|eu| { - ExtraUsage { - is_enabled: eu["is_enabled"].as_bool().unwrap_or(false), - monthly_limit: eu["monthly_limit"].as_f64().unwrap_or(0.0), - used_credits: eu["used_credits"].as_f64().unwrap_or(0.0), - utilization: eu["utilization"].as_f64().unwrap_or(0.0), - } + let mut five_hour = parse_tier(resp, "five_hour", FIVE_HOUR_SECS); + let mut seven_day = parse_tier(resp, "seven_day", SEVEN_DAY_SECS); + let mut weekly_scoped = Vec::new(); + + if let Some(tier) = parse_tier(resp, "seven_day_sonnet", SEVEN_DAY_SECS) { + upsert_weekly_scoped(&mut weekly_scoped, "Sonnet".to_string(), tier, false); + } + if let Some(tier) = parse_tier(resp, "seven_day_opus", SEVEN_DAY_SECS) { + upsert_weekly_scoped(&mut weekly_scoped, "Opus".to_string(), tier, false); + } + + apply_limits(resp, &mut five_hour, &mut seven_day, &mut weekly_scoped); + + let extra_usage = resp.get("extra_usage").map(|eu| ExtraUsage { + is_enabled: eu["is_enabled"].as_bool().unwrap_or(false), + monthly_limit: eu["monthly_limit"].as_f64().unwrap_or(0.0), + used_credits: eu["used_credits"].as_f64().unwrap_or(0.0), + utilization: eu["utilization"].as_f64().unwrap_or(0.0), }); UsageData { plan: None, five_hour, seven_day, - seven_day_sonnet, - seven_day_opus, + weekly_scoped, extra_usage, } } @@ -311,21 +340,159 @@ const FIVE_HOUR_SECS: f64 = 5.0 * 3600.0; const SEVEN_DAY_SECS: f64 = 7.0 * 86400.0; fn parse_tier(resp: &serde_json::Value, key: &str, period_secs: f64) -> Option { - let tier = resp.get(key)?; - let resets_at_raw = tier["resets_at"].as_str(); + let tier = resp.get(key)?.as_object()?; + let resets_at_raw = tier.get("resets_at").and_then(|v| v.as_str()); let reset_epoch = resets_at_raw.and_then(parse_iso8601_to_epoch); - let time_elapsed_pct = resets_at_raw.and_then(|ts| compute_time_elapsed_pct(ts, period_secs)); Some(TierUsage { - utilization: tier["utilization"].as_f64().unwrap_or(0.0), + utilization: tier + .get("utilization") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), reset_epoch, period_secs, - time_elapsed_pct, + time_elapsed_pct: resets_at_raw.and_then(|ts| compute_time_elapsed_pct(ts, period_secs)), }) } +fn apply_limits( + resp: &serde_json::Value, + five_hour: &mut Option, + seven_day: &mut Option, + weekly_scoped: &mut Vec, +) { + let Some(limits) = resp.get("limits").and_then(|v| v.as_array()) else { + return; + }; + + for limit in limits { + let kind = limit.get("kind").and_then(|v| v.as_str()); + let group = limit.get("group").and_then(|v| v.as_str()); + let scoped_label = scoped_limit_label(limit); + + if kind == Some("session") || group == Some("session") { + let fallback_reset = five_hour.as_ref().and_then(|tier| tier.reset_epoch); + if let Some(tier) = parse_limit_tier(limit, FIVE_HOUR_SECS, fallback_reset) { + *five_hour = Some(tier); + } + continue; + } + + let scope_is_empty = limit.get("scope").is_none_or(|scope| scope.is_null()); + if kind == Some("weekly_all") || (group == Some("weekly") && scope_is_empty) { + let fallback_reset = seven_day.as_ref().and_then(|tier| tier.reset_epoch); + if let Some(tier) = parse_limit_tier(limit, SEVEN_DAY_SECS, fallback_reset) { + *seven_day = Some(tier); + } + continue; + } + + if kind == Some("weekly_scoped") || (group == Some("weekly") && scoped_label.is_some()) { + let Some(label) = scoped_label else { + continue; + }; + let fallback_reset = scoped_reset_epoch(weekly_scoped, &label) + .or_else(|| seven_day.as_ref().and_then(|tier| tier.reset_epoch)); + if let Some(tier) = parse_limit_tier(limit, SEVEN_DAY_SECS, fallback_reset) { + let active = limit + .get("is_active") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + upsert_weekly_scoped(weekly_scoped, label, tier, active); + } + } + } +} + +fn parse_limit_tier( + limit: &serde_json::Value, + period_secs: f64, + fallback_reset_epoch: Option, +) -> Option { + let utilization = limit + .get("percent") + .and_then(|v| v.as_f64()) + .or_else(|| limit.get("utilization").and_then(|v| v.as_f64()))?; + let reset_epoch = limit + .get("resets_at") + .and_then(|v| v.as_str()) + .and_then(parse_iso8601_to_epoch) + .or(fallback_reset_epoch); + + Some(TierUsage { + utilization, + reset_epoch, + period_secs, + time_elapsed_pct: reset_epoch + .and_then(|e| compute_time_elapsed_pct_from_epoch(e, period_secs)), + }) +} + +fn scoped_limit_label(limit: &serde_json::Value) -> Option { + let scope = limit.get("scope")?; + + if let Some(model) = scope.get("model") + && let Some(label) = scope_label(model) + { + return Some(label); + } + + if let Some(surface) = scope.get("surface") + && let Some(label) = scope_label(surface) + { + return Some(label); + } + + None +} + +fn scope_label(scope: &serde_json::Value) -> Option { + scope + .get("display_name") + .and_then(|v| v.as_str()) + .or_else(|| scope.get("name").and_then(|v| v.as_str())) + .or_else(|| scope.get("id").and_then(|v| v.as_str())) + .map(str::trim) + .filter(|label| !label.is_empty()) + .map(ToOwned::to_owned) +} + +fn scoped_reset_epoch(weekly_scoped: &[ScopedTierUsage], label: &str) -> Option { + weekly_scoped + .iter() + .find(|entry| entry.label.eq_ignore_ascii_case(label)) + .and_then(|entry| entry.tier.reset_epoch) +} + +fn upsert_weekly_scoped( + weekly_scoped: &mut Vec, + label: String, + tier: TierUsage, + active: bool, +) { + if let Some(existing) = weekly_scoped + .iter_mut() + .find(|entry| entry.label.eq_ignore_ascii_case(&label)) + { + existing.tier = tier; + existing.active = active; + } else { + weekly_scoped.push(ScopedTierUsage { + label, + tier, + active, + }); + } +} + /// Compute what percentage of the billing period has elapsed. fn compute_time_elapsed_pct(resets_at: &str, period_secs: f64) -> Option { - let reset_epoch = parse_iso8601_to_epoch(resets_at)?; + compute_time_elapsed_pct_from_epoch(parse_iso8601_to_epoch(resets_at)?, period_secs) +} + +fn compute_time_elapsed_pct_from_epoch(reset_epoch: f64, period_secs: f64) -> Option { + if period_secs <= 0.0 { + return None; + } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .ok()? @@ -456,8 +623,8 @@ impl ClaudeUsageData { .header("retry-after") .and_then(|v| v.parse::().ok()) .unwrap_or(USAGE_INTERVAL.as_secs() * 2); - let effective = Duration::from_secs(retry_secs) - .max(MIN_RETRY_DELAY); + let effective = + Duration::from_secs(retry_secs).max(MIN_RETRY_DELAY); log::warn!( "[claude-usage] rate limited (429), retrying in {}s", effective.as_secs() @@ -474,11 +641,10 @@ impl ClaudeUsageData { if !resp.is_success() { return (None, None); } - let parsed: serde_json::Value = - match serde_json::from_str(&body) { - Ok(v) => v, - Err(_) => return (None, None), - }; + let parsed: serde_json::Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return (None, None), + }; let mut usage = parse_usage(&parsed); usage.plan = read_subscription_type(&dir); (Some(usage), None) @@ -523,9 +689,16 @@ impl ClaudeUsageData { log::info!("[claude-usage] next fetch in {}s", delay.as_secs()); // Race: sleep vs wake signal (e.g. when UI becomes visible but has no data) let woken = smol::future::or( - async { smol::Timer::after(delay).await; false }, - async { let _ = wake_rx.recv().await; true }, - ).await; + async { + smol::Timer::after(delay).await; + false + }, + async { + let _ = wake_rx.recv().await; + true + }, + ) + .await; // Drain any extra wake signals while wake_rx.try_recv().is_ok() {} // Don't reset consecutive_failures on wake — preserve backoff @@ -621,11 +794,7 @@ impl ClaudeUsage { .detach(); } - fn render_popover( - &self, - t: &ThemeColors, - cx: &mut Context, - ) -> impl IntoElement { + fn render_popover(&self, t: &ThemeColors, cx: &mut Context) -> impl IntoElement { let shared = self.data.read(cx); let data = shared.data.lock(); let data = match data.as_ref() { @@ -671,7 +840,13 @@ impl ClaudeUsage { el.child(render_usage_row( t, cx, - &tier_row("Session", "5h", tier, "claude-marker-session", SegmentUnit::Hour), + &tier_row( + "Session", + "5h", + tier, + "claude-marker-session", + SegmentUnit::Hour, + ), working, )) }) @@ -679,35 +854,31 @@ impl ClaudeUsage { el.child(render_usage_row( t, cx, - &tier_row("Weekly", "7d", tier, "claude-marker-weekly", SegmentUnit::Day), + &tier_row( + "Weekly", + "7d", + tier, + "claude-marker-weekly", + SegmentUnit::Day, + ), working, )) }) - .when_some( - data.seven_day_sonnet - .as_ref() - .filter(|tier| tier.utilization >= 0.5), - |el, tier| { - el.child(render_usage_row( - t, - cx, - &tier_row("Sonnet", "7d", tier, "claude-marker-sonnet", SegmentUnit::Day), - working, - )) - }, - ) - .when_some( - data.seven_day_opus - .as_ref() - .filter(|tier| tier.utilization >= 0.5), - |el, tier| { - el.child(render_usage_row( - t, - cx, - &tier_row("Opus", "7d", tier, "claude-marker-opus", SegmentUnit::Day), - working, - )) - }, + .children( + data.weekly_scoped + .iter() + .filter(|scoped| should_show_scoped(scoped)) + .map(|scoped| { + let row = tier_row( + scoped.label.as_str(), + "7d", + &scoped.tier, + marker_id_for_scoped(&scoped.label), + SegmentUnit::Day, + ); + render_usage_row(t, cx, &row, working) + .into_any_element() + }), ) .when_some(data.extra_usage.as_ref(), |el, extra| { if !extra.is_enabled { @@ -726,10 +897,10 @@ impl ClaudeUsage { /// Build a shared [`UsageRow`] from a Claude rate-limit tier. fn tier_row( - label: &str, + label: impl Into, period: &str, tier: &TierUsage, - marker_id: &'static str, + marker_id: impl Into, unit: SegmentUnit, ) -> UsageRow { UsageRow { @@ -744,6 +915,28 @@ fn tier_row( } } +fn should_show_scoped(scoped: &ScopedTierUsage) -> bool { + scoped.active || scoped.tier.utilization >= 0.5 +} + +fn should_show_scoped_in_trigger(scoped: &ScopedTierUsage) -> bool { + scoped.active || scoped.tier.utilization >= 80.0 +} + +fn marker_id_for_scoped(label: &str) -> SharedString { + let slug: String = label + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + format!("claude-marker-scoped-{slug}").into() +} + fn render_extra_usage_row(t: &ThemeColors, cx: &App, extra: &ExtraUsage) -> impl IntoElement { v_flex() .gap(px(5.0)) @@ -790,6 +983,21 @@ impl Render for ClaudeUsage { ); items.push(("7d".into(), tier.utilization, et)); } + for scoped in d + .weekly_scoped + .iter() + .filter(|scoped| should_show_scoped_in_trigger(scoped)) + { + let tier = &scoped.tier; + let et = effective_time_pct( + tier.reset_epoch, + tier.period_secs, + Some(SegmentUnit::Day), + working, + tier.time_elapsed_pct, + ); + items.push((scoped.label.clone().into(), tier.utilization, et)); + } } None => { drop(data); @@ -968,6 +1176,158 @@ mod tests { assert!((pct - 50.0).abs() < 5.0, "Expected ~50%, got: {}", pct); } + #[test] + fn test_parse_usage_applies_limits_with_top_level_reset_fallback() { + let resp = serde_json::json!({ + "five_hour": { + "utilization": 10.0, + "resets_at": "2026-07-07T14:00:00.000Z" + }, + "seven_day": { + "utilization": 20.0, + "resets_at": "2026-07-08T21:00:00.000Z" + }, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 46, + "resets_at": null, + "scope": null + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 79, + "resets_at": null, + "scope": null + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 100, + "resets_at": null, + "scope": { + "model": { + "id": null, + "display_name": "Fable" + }, + "surface": null + }, + "is_active": true + } + ], + "extra_usage": { + "is_enabled": false + } + }); + + let usage = parse_usage(&resp); + let five_hour = usage.five_hour.unwrap(); + let seven_day = usage.seven_day.unwrap(); + let fable = usage + .weekly_scoped + .iter() + .find(|entry| entry.label == "Fable") + .unwrap(); + + assert_eq!(five_hour.utilization, 46.0); + assert_eq!( + five_hour.reset_epoch, + parse_iso8601_to_epoch("2026-07-07T14:00:00.000Z") + ); + assert_eq!(seven_day.utilization, 79.0); + assert_eq!( + seven_day.reset_epoch, + parse_iso8601_to_epoch("2026-07-08T21:00:00.000Z") + ); + assert_eq!(fable.tier.utilization, 100.0); + assert_eq!( + fable.tier.reset_epoch, + parse_iso8601_to_epoch("2026-07-08T21:00:00.000Z") + ); + assert!(fable.active); + } + + #[test] + fn test_parse_usage_scoped_limit_uses_own_reset_when_present() { + let resp = serde_json::json!({ + "seven_day": { + "utilization": 20.0, + "resets_at": "2026-07-08T21:00:00.000Z" + }, + "limits": [ + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 50, + "resets_at": "2026-07-09T09:30:00.000Z", + "scope": { + "model": { + "display_name": "Fable" + } + }, + "is_active": false + } + ] + }); + + let usage = parse_usage(&resp); + let fable = usage + .weekly_scoped + .iter() + .find(|entry| entry.label == "Fable") + .unwrap(); + + assert_eq!( + fable.tier.reset_epoch, + parse_iso8601_to_epoch("2026-07-09T09:30:00.000Z") + ); + } + + #[test] + fn test_parse_usage_limits_override_legacy_scoped_bucket() { + let resp = serde_json::json!({ + "seven_day": { + "utilization": 20.0, + "resets_at": "2026-07-08T21:00:00.000Z" + }, + "seven_day_opus": { + "utilization": 12.0, + "resets_at": "2026-07-08T20:00:00.000Z" + }, + "limits": [ + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 88, + "resets_at": null, + "scope": { + "model": { + "display_name": "Opus" + } + }, + "is_active": true + } + ] + }); + + let usage = parse_usage(&resp); + let opus_entries: Vec<&ScopedTierUsage> = usage + .weekly_scoped + .iter() + .filter(|entry| entry.label == "Opus") + .collect(); + + assert_eq!(opus_entries.len(), 1); + assert_eq!(opus_entries[0].tier.utilization, 88.0); + assert_eq!( + opus_entries[0].tier.reset_epoch, + parse_iso8601_to_epoch("2026-07-08T20:00:00.000Z") + ); + assert!(opus_entries[0].active); + } + #[cfg(target_os = "macos")] #[test] fn test_keychain_service_default() { @@ -978,7 +1338,10 @@ mod tests { // a tempdir stand-in only for the non-default branch, and test the default via the // real path (which exists on developer machines). if default_dir.exists() { - assert_eq!(keychain_service_name(&default_dir), "Claude Code-credentials"); + assert_eq!( + keychain_service_name(&default_dir), + "Claude Code-credentials" + ); } } @@ -1004,7 +1367,7 @@ mod tests { let path = dir.path().canonicalize().unwrap(); let service = keychain_service_name(&path); - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(path.to_string_lossy().as_bytes()); let d = h.finalize(); @@ -1013,7 +1376,10 @@ mod tests { d[0], d[1], d[2], d[3] ); assert_eq!(service, expected); - assert_ne!(service, "Claude Code-credentials", "custom dir must get a suffix"); + assert_ne!( + service, "Claude Code-credentials", + "custom dir must get a suffix" + ); } #[cfg(target_os = "macos")] diff --git a/crates/okena-ext-updater/Cargo.toml b/crates/okena-ext-updater/Cargo.toml index 1c6bc1583..7fb868945 100644 --- a/crates/okena-ext-updater/Cargo.toml +++ b/crates/okena-ext-updater/Cargo.toml @@ -4,14 +4,19 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui-ui"] +gpui-ui = ["dep:gpui", "dep:gpui-component", "dep:okena-extensions", "dep:okena-ui"] + [dependencies] okena-core = { path = "../okena-core" } okena-transport = { path = "../okena-transport", features = ["blocking-http"] } -okena-extensions = { path = "../okena-extensions" } -okena-ui = { path = "../okena-ui" } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } -gpui-component = { git = "https://github.com/longbridge/gpui-component", package = "gpui-component" } +okena-extensions = { path = "../okena-extensions", optional = true } +okena-ui = { path = "../okena-ui", optional = true } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } +gpui-component = { git = "https://github.com/longbridge/gpui-component", package = "gpui-component", optional = true } serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } smol = "2.0" parking_lot = "0.12" dirs = "5.0" @@ -19,3 +24,4 @@ log = "0.4" anyhow = "1.0" semver = "1" sha2 = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } diff --git a/crates/okena-ext-updater/src/daemon_client.rs b/crates/okena-ext-updater/src/daemon_client.rs new file mode 100644 index 000000000..f7324f260 --- /dev/null +++ b/crates/okena-ext-updater/src/daemon_client.rs @@ -0,0 +1,97 @@ +use crate::UpdateStatusSnapshot; +use anyhow::{Context, Result}; +use std::time::Duration; + +struct LocalUpdateEndpoint { + client: reqwest::blocking::Client, + url: String, +} + +fn local_update_endpoint(path: &str) -> Result { + let remote_path = okena_core::profiles::try_current() + .map(|p| p.remote_json()) + .unwrap_or_else(|| { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("okena") + .join("remote.json") + }); + let data = std::fs::read_to_string(&remote_path) + .with_context(|| format!("failed to read {}", remote_path.display()))?; + let value: serde_json::Value = + serde_json::from_str(&data).context("failed to parse remote.json")?; + let port_value = value + .get("port") + .and_then(serde_json::Value::as_u64) + .context("remote.json is missing port")?; + let port = u16::try_from(port_value).context("remote.json port is out of range")?; + #[cfg(unix)] + if let Some(socket_path) = value + .get("local_endpoint") + .and_then(|endpoint| { + if endpoint.get("kind").and_then(serde_json::Value::as_str) == Some("unix_socket") { + endpoint.get("path").and_then(serde_json::Value::as_str) + } else { + None + } + }) + { + let client = reqwest::blocking::Client::builder() + .unix_socket(socket_path) + .build() + .with_context(|| format!("failed to build Unix socket client for {socket_path}"))?; + return Ok(LocalUpdateEndpoint { + client, + url: format!("http://okena.local{path}"), + }); + } + + let host = value + .get("local_host") + .and_then(serde_json::Value::as_str) + .filter(|host| !host.is_empty()) + .unwrap_or("127.0.0.1"); + Ok(LocalUpdateEndpoint { + client: reqwest::blocking::Client::new(), + url: format!("http://{host}:{port}{path}"), + }) +} + +pub fn fetch_status() -> Result { + let endpoint = local_update_endpoint("/v1/update/status")?; + let response = endpoint + .client + .get(&endpoint.url) + .timeout(Duration::from_secs(5)) + .send() + .context("failed to fetch update status")? + .error_for_status() + .context("update status request failed")?; + response.json().context("failed to decode update status") +} + +pub fn request_check() -> Result { + post_snapshot("/v1/update/check", "updater.daemon_check") +} + +pub fn request_install() -> Result { + post_snapshot("/v1/update/install", "updater.daemon_install") +} + +pub fn request_dismiss() -> Result { + post_snapshot("/v1/update/dismiss", "updater.daemon_dismiss") +} + +fn post_snapshot(path: &str, label: &'static str) -> Result { + let endpoint = local_update_endpoint(path)?; + let _ = label; + let response = endpoint + .client + .post(&endpoint.url) + .timeout(Duration::from_secs(10)) + .send() + .with_context(|| format!("failed to POST {path}"))? + .error_for_status() + .with_context(|| format!("daemon rejected {path}"))?; + response.json().context("failed to decode update status") +} diff --git a/crates/okena-ext-updater/src/installer.rs b/crates/okena-ext-updater/src/installer.rs index c9e2e6a2c..aa7a79a3b 100644 --- a/crates/okena-ext-updater/src/installer.rs +++ b/crates/okena-ext-updater/src/installer.rs @@ -3,8 +3,7 @@ use std::path::{Path, PathBuf}; /// Extract the archive and replace the current binary. pub fn install_update(archive_path: &Path) -> Result { - let current_exe = std::env::current_exe() - .context("failed to get current exe path")?; + let current_exe = std::env::current_exe().context("failed to get current exe path")?; let extract_dir = archive_path .parent() @@ -12,12 +11,18 @@ pub fn install_update(archive_path: &Path) -> Result { .join("extracted"); let _ = std::fs::remove_dir_all(&extract_dir); - std::fs::create_dir_all(&extract_dir) - .context("failed to create extraction dir")?; + std::fs::create_dir_all(&extract_dir).context("failed to create extraction dir")?; extract_archive(archive_path, &extract_dir)?; - let new_binary = find_binary(&extract_dir)?; + install_sibling_if_present(¤t_exe, &extract_dir, main_binary_name())?; + install_sibling_if_present(¤t_exe, &extract_dir, daemon_binary_name())?; + + let current_name = current_exe + .file_name() + .and_then(|name| name.to_str()) + .context("current executable has no file name")?; + let new_binary = find_binary_named(&extract_dir, current_name)?; replace_binary(¤t_exe, &new_binary)?; @@ -29,11 +34,48 @@ pub fn install_update(archive_path: &Path) -> Result { Ok(current_exe) } +fn install_sibling_if_present( + current_exe: &Path, + extract_dir: &Path, + binary_name: &str, +) -> Result<()> { + let Some(current_name) = current_exe.file_name().and_then(|name| name.to_str()) else { + return Ok(()); + }; + if current_name == binary_name { + return Ok(()); + } + + let Some(parent) = current_exe.parent() else { + return Ok(()); + }; + let target = parent.join(binary_name); + if !target.exists() { + return Ok(()); + } + + match find_binary_named(extract_dir, binary_name) { + Ok(new_binary) => { + replace_binary(&target, &new_binary)?; + validate_binary(&target)?; + } + Err(e) => { + log::warn!("Update archive does not contain sibling binary {binary_name}: {e}"); + } + } + + Ok(()) +} + /// Restart the application by spawning a new process and quitting. +#[cfg(feature = "gpui-ui")] pub fn restart_app(cx: &mut gpui::App) { if let Ok(exe) = std::env::current_exe() { let args: Vec = std::env::args().skip(1).collect(); - match crate::process::command(&exe.to_string_lossy()).args(&args).spawn() { + match crate::process::command(&exe.to_string_lossy()) + .args(&args) + .spawn() + { Ok(_) => { log::info!("Restarting okena..."); cx.quit(); @@ -71,7 +113,10 @@ fn validate_binary(binary: &Path) -> Result<()> { let start = std::time::Instant::now(); let status = loop { - match child.try_wait().context("failed to wait on validation process")? { + match child + .try_wait() + .context("failed to wait on validation process")? + { Some(status) => break status, None => { if start.elapsed() > timeout { @@ -99,14 +144,16 @@ fn validate_binary(binary: &Path) -> Result<()> { } fn extract_archive(archive: &Path, dest: &Path) -> Result<()> { - let name = archive - .file_name() - .unwrap_or_default() - .to_string_lossy(); + let name = archive.file_name().unwrap_or_default().to_string_lossy(); if name.ends_with(".tar.gz") || name.ends_with(".tgz") { let status = crate::process::command("tar") - .args(["xzf", &archive.to_string_lossy(), "-C", &dest.to_string_lossy()]) + .args([ + "xzf", + &archive.to_string_lossy(), + "-C", + &dest.to_string_lossy(), + ]) .status() .context("failed to run tar")?; if !status.success() { @@ -116,7 +163,12 @@ fn extract_archive(archive: &Path, dest: &Path) -> Result<()> { #[cfg(unix)] { let status = crate::process::command("unzip") - .args(["-o", &archive.to_string_lossy(), "-d", &dest.to_string_lossy()]) + .args([ + "-o", + &archive.to_string_lossy(), + "-d", + &dest.to_string_lossy(), + ]) .status() .context("failed to run unzip")?; if !status.success() { @@ -126,7 +178,12 @@ fn extract_archive(archive: &Path, dest: &Path) -> Result<()> { #[cfg(windows)] { let status = crate::process::command("tar") - .args(["-xf", &archive.to_string_lossy(), "-C", &dest.to_string_lossy()]) + .args([ + "-xf", + &archive.to_string_lossy(), + "-C", + &dest.to_string_lossy(), + ]) .status() .context("failed to run tar on Windows")?; if !status.success() { @@ -140,12 +197,19 @@ fn extract_archive(archive: &Path, dest: &Path) -> Result<()> { Ok(()) } -fn find_binary(dir: &Path) -> Result { - #[cfg(unix)] - let binary_name = "okena"; - #[cfg(windows)] - let binary_name = "okena.exe"; +fn main_binary_name() -> &'static str { + if cfg!(windows) { "okena.exe" } else { "okena" } +} +fn daemon_binary_name() -> &'static str { + if cfg!(windows) { + "okena-daemon.exe" + } else { + "okena-daemon" + } +} + +fn find_binary_named(dir: &Path, binary_name: &str) -> Result { find_binary_recursive(dir, binary_name, 3) .with_context(|| format!("could not find '{}' in extracted archive", binary_name)) } @@ -164,9 +228,10 @@ fn find_binary_recursive(dir: &Path, name: &str, depth: u32) -> Result for entry in entries.flatten() { let path = entry.path(); if path.is_dir() - && let Ok(found) = find_binary_recursive(&path, name, depth - 1) { - return Ok(found); - } + && let Ok(found) = find_binary_recursive(&path, name, depth - 1) + { + return Ok(found); + } } } @@ -203,8 +268,7 @@ fn replace_binary(current: &Path, new_binary: &Path) -> Result<()> { } #[cfg(not(windows))] - std::fs::rename(&target, &old_path) - .context("failed to rename current binary")?; + std::fs::rename(&target, &old_path).context("failed to rename current binary")?; if let Err(e) = std::fs::copy(new_binary, &target) { log::error!("Failed to copy new binary, rolling back: {}", e); diff --git a/crates/okena-ext-updater/src/lib.rs b/crates/okena-ext-updater/src/lib.rs index 95af4d97e..0ecfd9c2d 100644 --- a/crates/okena-ext-updater/src/lib.rs +++ b/crates/okena-ext-updater/src/lib.rs @@ -1,21 +1,30 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod checker; +pub mod daemon_client; pub mod downloader; pub mod installer; +pub mod manager; +#[cfg(feature = "gpui-ui")] pub mod orchestrator; mod process; mod status; +#[cfg(feature = "gpui-ui")] mod update_checker; +#[cfg(feature = "gpui-ui")] use gpui::AppContext as _; +#[cfg(feature = "gpui-ui")] use okena_extensions::{ExtensionInstance, ExtensionManifest, ExtensionRegistration}; +#[cfg(feature = "gpui-ui")] use std::sync::Arc; // Re-export public types used by the host app -pub use status::{GlobalUpdateInfo, UpdateInfo, UpdateStatus}; +#[cfg(feature = "gpui-ui")] pub use installer::restart_app; +pub use status::{GlobalUpdateInfo, UpdateInfo, UpdateStatus, UpdateStatusSnapshot}; +#[cfg(feature = "gpui-ui")] pub fn register() -> ExtensionRegistration { ExtensionRegistration { manifest: ExtensionManifest { @@ -37,6 +46,7 @@ pub fn register() -> ExtensionRegistration { /// Initialize the updater: set GlobalUpdateInfo global, clean up old binary, /// start background checker. Called by the host app at startup. /// `app_version` should be the host application's version (from root Cargo.toml). +#[cfg(feature = "gpui-ui")] pub fn init(app_version: &str, cx: &mut gpui::App) { installer::cleanup_old_binary(); diff --git a/crates/okena-ext-updater/src/manager.rs b/crates/okena-ext-updater/src/manager.rs new file mode 100644 index 000000000..785eef1aa --- /dev/null +++ b/crates/okena-ext-updater/src/manager.rs @@ -0,0 +1,88 @@ +use crate::status::{UpdateInfo, UpdateStatus}; + +/// Run one check/download pass. The caller owns concurrency guards +/// (`try_start_manual` for user-initiated checks, `try_start` for background). +pub async fn run_check(info: UpdateInfo, token: u64, finish_manual: bool) { + info.set_status(UpdateStatus::Checking); + + match crate::checker::check_for_update(info.app_version()).await { + Ok(Some(release)) => { + if info.is_homebrew() { + info.set_status(UpdateStatus::BrewUpdate { + version: release.version, + }); + } else { + info.set_status(UpdateStatus::Downloading { + version: release.version.clone(), + progress: 0, + }); + + match crate::downloader::download_asset( + release.asset_url, + release.asset_name, + release.version.clone(), + info.clone(), + token, + release.checksum_url, + ) + .await + { + Ok(path) => { + info.set_status(UpdateStatus::Ready { + version: release.version, + path, + }); + } + Err(e) => { + if !info.is_cancelled(token) { + log::error!("Download failed: {}", e); + info.set_status(UpdateStatus::Failed { + error: e.to_string(), + }); + } + } + } + } + } + Ok(None) => { + info.set_status(UpdateStatus::Idle); + } + Err(e) => { + log::error!("Update check failed: {}", e); + info.set_status(UpdateStatus::Failed { + error: e.to_string(), + }); + } + } + + if finish_manual { + info.finish_manual(); + } else { + info.mark_stopped(token); + } +} + +/// Install the downloaded update currently held in `Ready` status. +pub async fn install_ready_update(info: UpdateInfo) { + let (version, path) = match info.status() { + UpdateStatus::Ready { version, path } => (version, path), + _ => return, + }; + + info.set_status(UpdateStatus::Installing { + version: version.clone(), + }); + + let result = smol::unblock(move || crate::installer::install_update(&path)).await; + match result { + Ok(_) => { + info.set_status(UpdateStatus::ReadyToRestart { version }); + } + Err(e) => { + log::error!("Install failed: {}", e); + info.set_status(UpdateStatus::Failed { + error: e.to_string(), + }); + } + } +} diff --git a/crates/okena-ext-updater/src/status.rs b/crates/okena-ext-updater/src/status.rs index 13c2748a4..3725b8c93 100644 --- a/crates/okena-ext-updater/src/status.rs +++ b/crates/okena-ext-updater/src/status.rs @@ -1,14 +1,19 @@ -use okena_extensions::ThemeColors; -use okena_ui::tokens::ui_text_sm; -use gpui::*; -use gpui_component::h_flex; use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +#[cfg(feature = "gpui-ui")] +use gpui::*; +#[cfg(feature = "gpui-ui")] +use gpui_component::h_flex; +#[cfg(feature = "gpui-ui")] +use okena_extensions::ThemeColors; +#[cfg(feature = "gpui-ui")] +use okena_ui::tokens::ui_text_sm; /// Status of the update process. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum UpdateStatus { Idle, Checking, @@ -40,6 +45,15 @@ pub enum UpdateStatus { }, } +/// Serializable view of the daemon-owned update state. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UpdateStatusSnapshot { + pub app_version: String, + pub status: UpdateStatus, + pub dismissed: bool, + pub is_homebrew: bool, +} + struct UpdateInfoInner { status: UpdateStatus, dismissed: bool, @@ -79,6 +93,23 @@ impl UpdateInfo { self.inner.lock().status.clone() } + pub fn snapshot(&self) -> UpdateStatusSnapshot { + let inner = self.inner.lock(); + UpdateStatusSnapshot { + app_version: self.app_version(), + status: inner.status.clone(), + dismissed: inner.dismissed, + is_homebrew: inner.is_homebrew, + } + } + + pub fn apply_snapshot(&self, snapshot: UpdateStatusSnapshot) { + let mut inner = self.inner.lock(); + inner.status = snapshot.status; + inner.dismissed = snapshot.dismissed; + inner.is_homebrew = snapshot.is_homebrew; + } + pub fn set_status(&self, status: UpdateStatus) { let mut inner = self.inner.lock(); if matches!( @@ -183,33 +214,39 @@ pub fn is_homebrew_install() -> bool { #[derive(Clone)] pub struct GlobalUpdateInfo(pub UpdateInfo); +#[cfg(feature = "gpui-ui")] impl Global for GlobalUpdateInfo {} +#[cfg(feature = "gpui-ui")] fn theme(cx: &App) -> ThemeColors { okena_extensions::theme(cx) } +#[cfg(feature = "gpui-ui")] fn open_url(url: &str) { okena_core::process::open_url(url); } /// Status bar widget that shows update status. +#[cfg(feature = "gpui-ui")] pub struct UpdateStatusWidget { _subscription: Option<()>, } +#[cfg(feature = "gpui-ui")] impl UpdateStatusWidget { pub fn new(cx: &mut Context) -> Self { - // Start the background checker when the widget is created + // Mirror daemon-owned update state into this GPUI process. if let Some(gui) = cx.try_global::() { let info = gui.0.clone(); - crate::update_checker::start_update_checker(info, cx); + crate::update_checker::start_update_status_poll(info, cx); } Self { _subscription: None } } } +#[cfg(feature = "gpui-ui")] impl Render for UpdateStatusWidget { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(update_info) = cx.try_global::() else { @@ -242,24 +279,15 @@ impl Render for UpdateStatusWidget { .on_click(cx.listener(|_this, _, _window, cx| { if let Some(gui) = cx.try_global::() { let info = gui.0.clone(); - if let UpdateStatus::Ready { version, path } = info.status() { - info.set_status(UpdateStatus::Installing { - version: version.clone(), - }); - cx.notify(); - cx.spawn(async move |this, cx| { - crate::orchestrator::run_install( - info, - version, - path, - cx, - move |cx| { - let _ = this.update(cx, |_, cx| cx.notify()); - }, - ) - .await; - }).detach(); - } + cx.spawn(async move |this, cx| { + match smol::unblock(crate::daemon_client::request_install).await { + Ok(snapshot) => info.apply_snapshot(snapshot), + Err(e) => info.set_status(UpdateStatus::Failed { + error: e.to_string(), + }), + } + let _ = this.update(cx, |_, cx| cx.notify()); + }).detach(); } })) ) @@ -341,6 +369,7 @@ impl Render for UpdateStatusWidget { .child("x") .on_click(move |_, _, _cx| { info_dismiss.dismiss(); + let _ = crate::daemon_client::request_dismiss(); }) ) .into_any_element() @@ -367,6 +396,7 @@ impl Render for UpdateStatusWidget { .child("x") .on_click(move |_, _, _cx| { info_dismiss.dismiss(); + let _ = crate::daemon_client::request_dismiss(); }) ) .into_any_element() diff --git a/crates/okena-ext-updater/src/update_checker.rs b/crates/okena-ext-updater/src/update_checker.rs index eed1cdbc8..129f7c4c3 100644 --- a/crates/okena-ext-updater/src/update_checker.rs +++ b/crates/okena-ext-updater/src/update_checker.rs @@ -1,188 +1,25 @@ use crate::status::{UpdateInfo, UpdateStatus, UpdateStatusWidget}; use gpui::*; -/// Spawn the update checker loop. -pub fn start_update_checker(update_info: UpdateInfo, cx: &mut Context) { - let token = match update_info.try_start() { - Some(t) => t, - None => return, - }; - +/// Mirror daemon-owned update state into the status widget. +pub fn start_update_status_poll(update_info: UpdateInfo, cx: &mut Context) { cx.spawn(async move |this: WeakEntity, cx| { - // Initial delay — check cancellation every second - for _ in 0..30 { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } - smol::Timer::after(std::time::Duration::from_secs(1)).await; - } - loop { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; + if let Ok(snapshot) = smol::unblock(crate::daemon_client::fetch_status).await { + update_info.apply_snapshot(snapshot); + let _ = this.update(cx, |_, cx| cx.notify()); } - // Pause while a manual check is in progress - while update_info.is_manual_active() { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } - smol::Timer::after(std::time::Duration::from_secs(1)).await; - } - - // If an update was already found, stop - match update_info.status() { - UpdateStatus::Ready { .. } - | UpdateStatus::ReadyToRestart { .. } - | UpdateStatus::Installing { .. } - | UpdateStatus::BrewUpdate { .. } => { - update_info.mark_stopped(token); - return; - } - _ => {} - } - - update_info.set_status(UpdateStatus::Checking); - let _ = this.update(cx, |_, cx| cx.notify()); - - match crate::checker::check_for_update(update_info.app_version()).await { - Ok(Some(release)) => { - if update_info.is_homebrew() { - update_info.set_status(UpdateStatus::BrewUpdate { - version: release.version, - }); - let _ = this.update(cx, |_, cx| cx.notify()); - update_info.mark_stopped(token); - return; - } - - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } + let delay = match update_info.status() { + UpdateStatus::Checking + | UpdateStatus::Downloading { .. } + | UpdateStatus::Installing { .. } => std::time::Duration::from_millis(500), + _ => std::time::Duration::from_secs(5), + }; + smol::Timer::after(delay).await; - let asset_url = release.asset_url; - let asset_name = release.asset_name; - let version = release.version; - let checksum_url = release.checksum_url; - - update_info.set_status(UpdateStatus::Downloading { - version: version.clone(), - progress: 0, - }); - let _ = this.update(cx, |_, cx| cx.notify()); - - let mut last_err: Option = None; - for attempt in 0..3u32 { - if attempt > 0 { - let delay_secs = 30u64 * (1 << (attempt - 1)); - for _ in 0..delay_secs { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } - smol::Timer::after(std::time::Duration::from_secs(1)).await; - } - update_info.set_status(UpdateStatus::Downloading { - version: version.clone(), - progress: 0, - }); - let _ = this.update(cx, |_, cx| cx.notify()); - } - - let download = crate::downloader::download_asset( - asset_url.clone(), - asset_name.clone(), - version.clone(), - update_info.clone(), - token, - checksum_url.clone(), - ); - let mut download = std::pin::pin!(download); - - let result = loop { - let polled = std::future::poll_fn(|task_cx| { - match download.as_mut().poll(task_cx) { - std::task::Poll::Ready(r) => std::task::Poll::Ready(Some(r)), - std::task::Poll::Pending => std::task::Poll::Ready(None), - } - }).await; - match polled { - Some(r) => break r, - None => { - smol::Timer::after(std::time::Duration::from_millis(250)).await; - let _ = this.update(cx, |_, cx| cx.notify()); - } - } - }; - - match result { - Ok(path) => { - update_info.set_status(UpdateStatus::Ready { - version, - path, - }); - let _ = this.update(cx, |_, cx| cx.notify()); - update_info.mark_stopped(token); - return; - } - Err(e) => { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } - log::warn!("Download attempt {}/3 failed: {}", attempt + 1, e); - last_err = Some(e); - } - } - } - - if let Some(e) = last_err { - log::error!("Download failed after 3 attempts: {}", e); - update_info.set_status(UpdateStatus::Failed { - error: e.to_string(), - }); - let _ = this.update(cx, |_, cx| cx.notify()); - } - } - Ok(None) => { - update_info.set_status(UpdateStatus::Idle); - let _ = this.update(cx, |_, cx| cx.notify()); - } - Err(e) => { - log::error!("Update check failed: {}", e); - update_info.set_status(UpdateStatus::Failed { - error: e.to_string(), - }); - let _ = this.update(cx, |_, cx| cx.notify()); - } - } - - // Keep Failed status visible for 60 seconds before clearing - if matches!(update_info.status(), UpdateStatus::Failed { .. }) { - for _ in 0..60 { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } - smol::Timer::after(std::time::Duration::from_secs(1)).await; - } - if matches!(update_info.status(), UpdateStatus::Failed { .. }) { - update_info.set_status(UpdateStatus::Idle); - let _ = this.update(cx, |_, cx| cx.notify()); - } - } - - // Wait 24 hours, checking cancellation every minute - for _ in 0..(24 * 60) { - if update_info.is_cancelled(token) { - update_info.mark_stopped(token); - return; - } - smol::Timer::after(std::time::Duration::from_secs(60)).await; + if this.update(cx, |_, _| {}).is_err() { + return; } } }) diff --git a/crates/okena-files/Cargo.toml b/crates/okena-files/Cargo.toml index ebe14e68c..ce3df1552 100644 --- a/crates/okena-files/Cargo.toml +++ b/crates/okena-files/Cargo.toml @@ -4,24 +4,41 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui"] +gpui = [ + "dep:gpui", + "dep:gpui-component", + "dep:okena-ui", + "dep:okena-markdown", + "dep:syntect", + "dep:two-face", + "dep:tree-sitter", + "dep:tree-sitter-md", + "dep:streaming-iterator", + "dep:usvg", + "dep:ttf-parser", + "dep:image", +] + [dependencies] okena-core = { path = "../okena-core" } okena-transport = { path = "../okena-transport", features = ["blocking-http"] } -okena-markdown = { path = "../okena-markdown" } -okena-ui = { path = "../okena-ui" } +okena-markdown = { path = "../okena-markdown", optional = true } +okena-ui = { path = "../okena-ui", optional = true } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } -gpui-component = { git = "https://github.com/longbridge/gpui-component", package = "gpui-component" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } +gpui-component = { git = "https://github.com/longbridge/gpui-component", package = "gpui-component", optional = true } -syntect = { version = "5", features = ["default-fancy"] } -two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] } +syntect = { version = "5", features = ["default-fancy"], optional = true } +two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"], optional = true } # Markdown highlighting: tree-sitter is ~20x faster than syntect's Markdown # grammar on large files. tree-sitter (0.26) and streaming-iterator are already # in the workspace via gpui-component, so this only adds the grammar crate. -tree-sitter = "0.26" -tree-sitter-md = { version = "0.5", features = ["parser"] } -streaming-iterator = "0.1" +tree-sitter = { version = "0.26", optional = true } +tree-sitter-md = { version = "0.5", features = ["parser"], optional = true } +streaming-iterator = { version = "0.1", optional = true } grep-searcher = "0.1" grep-regex = "0.1" @@ -32,9 +49,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" log = "0.4" base64 = "0.22" -usvg = { version = "0.45", default-features = false } -ttf-parser = "0.25" -image = { version = "0.25", default-features = false } +usvg = { version = "0.45", default-features = false, optional = true } +ttf-parser = { version = "0.25", optional = true } +image = { version = "0.25", default-features = false, optional = true } [dev-dependencies] tempfile = "3" diff --git a/crates/okena-files/src/file_scan.rs b/crates/okena-files/src/file_scan.rs new file mode 100644 index 000000000..5326049ef --- /dev/null +++ b/crates/okena-files/src/file_scan.rs @@ -0,0 +1,104 @@ +//! GPUI-free file scanning for the fuzzy finder index. +//! +//! Walks a project directory with the `ignore` crate and produces a flat list +//! of [`FileEntry`] values. The scanning logic has no GPUI dependency, so it is +//! usable from a headless daemon (via `ProjectFs` / the remote action handlers) +//! as well as from the GUI's `FileSearchDialog`. + +use crate::content_search::ALWAYS_IGNORE; +use ignore::WalkBuilder; +use std::path::{Path, PathBuf}; + +/// Maximum number of files to keep in the fuzzy finder index. +/// +/// The file viewer tree is now lazy (see `crate::list_directory`), so the cap +/// only constrains the Cmd+P fuzzy finder. 25k covers all but the very +/// largest monorepos while keeping `nucleo` per-keystroke matching snappy. +const MAX_FILES: usize = 25_000; + +/// A file entry in the search list. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct FileEntry { + /// Full path to the file + pub path: PathBuf, + /// Path relative to project root + pub relative_path: String, + /// Just the filename + pub filename: String, +} + +/// Scan files in the project directory using the `ignore` crate. +/// +/// `show_ignored` is additive: regular (non-gitignored) files are scanned +/// first, then gitignored files are appended up to `MAX_FILES`. Without +/// this two-pass split, a single huge gitignored directory (e.g. an +/// Android `build/` tree) can fill the cap alphabetically and crowd out +/// real project files later in the walk. +pub fn scan_files(project_path: &Path, show_ignored: bool) -> Vec { + let mut files = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + collect_files(project_path, false, &mut files, &mut seen); + if show_ignored { + collect_files(project_path, true, &mut files, &mut seen); + } + + files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + files +} + +/// Walk the project, appending entries to `files` until `MAX_FILES` is +/// reached. `seen` tracks already-collected paths so the gitignored pass +/// doesn't duplicate the regular pass. +fn collect_files( + project_path: &Path, + include_ignored: bool, + files: &mut Vec, + seen: &mut std::collections::HashSet, +) { + let mut walk_builder = WalkBuilder::new(project_path); + walk_builder + .hidden(false) + .git_ignore(!include_ignored) + .git_global(!include_ignored) + .git_exclude(!include_ignored) + .max_depth(Some(15)); + + let mut override_builder = ignore::overrides::OverrideBuilder::new(project_path); + for pattern in ALWAYS_IGNORE { + let _ = override_builder.add(pattern); + } + if let Ok(overrides) = override_builder.build() { + walk_builder.overrides(overrides); + } + + for entry in walk_builder.build().flatten() { + if files.len() >= MAX_FILES { + break; + } + + let path = entry.path(); + if !path.is_file() { + continue; + } + if !seen.insert(path.to_path_buf()) { + continue; + } + + let filename = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + let relative_path = path + .strip_prefix(project_path) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| filename.clone()); + + files.push(FileEntry { + path: path.to_path_buf(), + relative_path, + filename, + }); + } +} diff --git a/crates/okena-files/src/file_search.rs b/crates/okena-files/src/file_search.rs index 333f9c52b..8bcf4d8c0 100644 --- a/crates/okena-files/src/file_search.rs +++ b/crates/okena-files/src/file_search.rs @@ -3,12 +3,12 @@ //! Provides a searchable list of files in the active project, //! similar to VS Code's Cmd+P file picker. +use crate::file_scan::scan_files; use crate::list_overlay::ListOverlayConfig; use crate::theme::theme; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::h_flex; -use ignore::WalkBuilder; use okena_ui::badge::keyboard_hint; use okena_ui::tokens::{ui_text_sm, ui_text_ms, ui_text}; use okena_ui::empty_state::empty_state; @@ -16,7 +16,12 @@ use okena_ui::file_icon::file_icon; use okena_ui::modal::{modal_backdrop, modal_content, modal_header}; use okena_ui::selectable_list::selectable_list_item; use okena_ui::simple_input::{InputChangedEvent, SimpleInputState}; -use std::path::PathBuf; +use std::path::Path; + +/// The flat file entry produced by the gpui-free scanner. Re-exported so the +/// dialog and existing `okena_files::file_search::FileEntry` imports keep +/// working after the scan logic moved to [`crate::file_scan`]. +pub use crate::file_scan::FileEntry; // Define Cancel action locally so we don't depend on the main app's keybindings gpui::actions!(okena_files, [Cancel]); @@ -29,24 +34,6 @@ const BINARY_EXTENSIONS: &[&str] = &[ "pdf", "woff", "woff2", "ttf", "eot", "exe", "bin", ]; -/// Maximum number of files to keep in the fuzzy finder index. -/// -/// The file viewer tree is now lazy (see `crate::list_directory`), so the cap -/// only constrains the Cmd+P fuzzy finder. 25k covers all but the very -/// largest monorepos while keeping `nucleo` per-keystroke matching snappy. -const MAX_FILES: usize = 25_000; - -/// A file entry in the search list -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct FileEntry { - /// Full path to the file - pub path: PathBuf, - /// Path relative to project root - pub relative_path: String, - /// Just the filename - pub filename: String, -} - /// Remembered state from the last file search session. /// /// The `show_ignored` filter toggle is NOT stored here — it lives in @@ -164,78 +151,11 @@ impl FileSearchDialog { /// Scan files in the project directory using the `ignore` crate. /// - /// `show_ignored` is additive: regular (non-gitignored) files are scanned - /// first, then gitignored files are appended up to `MAX_FILES`. Without - /// this two-pass split, a single huge gitignored directory (e.g. an - /// Android `build/` tree) can fill the cap alphabetically and crowd out - /// real project files later in the walk. - pub fn scan_files(project_path: &PathBuf, show_ignored: bool) -> Vec { - let mut files = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - - Self::collect_files(project_path, false, &mut files, &mut seen); - if show_ignored { - Self::collect_files(project_path, true, &mut files, &mut seen); - } - - files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); - files - } - - /// Walk the project, appending entries to `files` until `MAX_FILES` is - /// reached. `seen` tracks already-collected paths so the gitignored pass - /// doesn't duplicate the regular pass. - fn collect_files( - project_path: &PathBuf, - include_ignored: bool, - files: &mut Vec, - seen: &mut std::collections::HashSet, - ) { - let mut walk_builder = WalkBuilder::new(project_path); - walk_builder - .hidden(false) - .git_ignore(!include_ignored) - .git_global(!include_ignored) - .git_exclude(!include_ignored) - .max_depth(Some(15)); - - let mut override_builder = ignore::overrides::OverrideBuilder::new(project_path); - for pattern in crate::content_search::ALWAYS_IGNORE { - let _ = override_builder.add(pattern); - } - if let Ok(overrides) = override_builder.build() { - walk_builder.overrides(overrides); - } - - for entry in walk_builder.build().flatten() { - if files.len() >= MAX_FILES { - break; - } - - let path = entry.path(); - if !path.is_file() { - continue; - } - if !seen.insert(path.to_path_buf()) { - continue; - } - - let filename = match path.file_name().and_then(|n| n.to_str()) { - Some(name) => name.to_string(), - None => continue, - }; - - let relative_path = path - .strip_prefix(project_path) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|_| filename.clone()); - - files.push(FileEntry { - path: path.to_path_buf(), - relative_path, - filename, - }); - } + /// Thin delegate to the gpui-free [`crate::file_scan::scan_files`] so the + /// dialog's behavior is unchanged while the scanning logic stays usable + /// from headless (gpui-free) builds. + pub fn scan_files(project_path: &Path, show_ignored: bool) -> Vec { + scan_files(project_path, show_ignored) } /// Save current state for next open. diff --git a/crates/okena-files/src/file_viewer/context_menu.rs b/crates/okena-files/src/file_viewer/context_menu.rs index 044a61d62..511abdb6b 100644 --- a/crates/okena-files/src/file_viewer/context_menu.rs +++ b/crates/okena-files/src/file_viewer/context_menu.rs @@ -185,21 +185,33 @@ impl FileViewer { return; } - if new_path.exists() { - cx.notify(); - return; - } + // Remote projects have no real local root: the tree node's `abs_path` + // is the fake `/`. Recover the relative path + // by stripping the `project_id()` prefix (same convention as + // `render_context_menu` below). + let rel_root = PathBuf::from(self.project_fs.project_id()); + let old_rel = old_path + .strip_prefix(&rel_root) + .unwrap_or(old_path.as_path()) + .to_string_lossy() + .to_string(); + let new_rel = new_path + .strip_prefix(&rel_root) + .unwrap_or(new_path.as_path()) + .to_string_lossy() + .to_string(); - if let Err(_e) = std::fs::rename(&old_path, &new_path) { + if self.project_fs.rename_file(&old_rel, &new_name).is_err() { cx.notify(); return; } - let project_root = self.project_fs.project_root(); - let old_rel = relative_for(&project_root, &old_path); - let new_rel = relative_for(&project_root, &new_path); - - self.update_tabs_after_rename(&old_path, &new_path, old_rel.as_deref(), new_rel.as_deref()); + self.update_tabs_after_rename( + &old_path, + &new_path, + Some(old_rel.as_str()), + Some(new_rel.as_str()), + ); self.update_expanded_after_rename(&old_path, &new_path); self.refresh_file_tree_async(cx); @@ -284,9 +296,9 @@ impl FileViewer { } fn update_expanded_after_rename(&mut self, old_path: &Path, new_path: &Path) { - let Some(project_path) = self.project_fs.project_root() else { - return; - }; + // Remote projects have no local root; the fake tree paths are rooted at + // `project_id()`, so strip that prefix to recover folder-relative paths. + let project_path = PathBuf::from(self.project_fs.project_id()); let old_rel = match old_path.strip_prefix(&project_path) { Ok(p) => p.to_string_lossy().to_string(), Err(_) => return, @@ -332,12 +344,20 @@ impl FileViewer { return; }; - let result = match &confirm.target { - TreeNodeTarget::File { path, .. } => std::fs::remove_file(path), - TreeNodeTarget::Folder { abs_path, .. } => std::fs::remove_dir_all(abs_path), - }; + // Recover the target's project-relative path by stripping the + // `project_id()` prefix from the fake tree path (same convention as + // `render_context_menu`). The daemon's DeleteFile handles both files + // and folders. + let rel_root = PathBuf::from(self.project_fs.project_id()); + let rel = confirm + .target + .abs_path() + .strip_prefix(&rel_root) + .unwrap_or(confirm.target.abs_path()) + .to_string_lossy() + .to_string(); - if let Err(e) = result { + if let Err(e) = self.project_fs.delete_file(&rel) { confirm.error_message = Some(format!("Failed to delete: {}", e)); self.delete_confirm = Some(confirm); cx.notify(); @@ -348,8 +368,11 @@ impl FileViewer { // Invalidate just the parent directory so we don't re-fetch the whole // expanded tree for a single deletion. - let parent_rel = parent_relative_of_target(&confirm.target, &self.project_fs.project_root()) - .unwrap_or_default(); + let parent_rel = parent_relative_of_target( + &confirm.target, + &Some(PathBuf::from(self.project_fs.project_id())), + ) + .unwrap_or_default(); self.invalidate_directory(&parent_rel, cx); cx.notify(); } @@ -406,6 +429,8 @@ impl FileViewer { .to_string_lossy() .to_string(); + let abs_to_copy = self.project_fs.absolute_path(&rel_path).unwrap_or_else(|| abs_path.clone()); + let send_path = if is_folder { None } else { Some(abs_path_buf.clone()) }; Some( @@ -473,7 +498,7 @@ impl FileViewer { ) .on_click(cx.listener(move |this, _, _, cx| { cx.write_to_clipboard(ClipboardItem::new_string( - abs_path.clone(), + abs_to_copy.clone(), )); this.close_context_menu(cx); })), diff --git a/crates/okena-files/src/lib.rs b/crates/okena-files/src/lib.rs index 363abc34b..7b784064f 100644 --- a/crates/okena-files/src/lib.rs +++ b/crates/okena-files/src/lib.rs @@ -1,17 +1,36 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] +// GPUI-free file operations — usable from a headless daemon. pub mod blame; -pub mod code_view; -pub mod project_fs; pub mod content_search; +pub mod file_scan; +pub mod list_directory; +pub mod project_fs; + +// GPUI viewer — depends on gpui / okena-ui / okena-markdown and is only built +// when the `gpui` feature is on (the default for the GUI app). +#[cfg(feature = "gpui")] +pub mod code_view; +#[cfg(feature = "gpui")] pub mod content_search_dialog; +#[cfg(feature = "gpui")] pub mod file_search; +#[cfg(feature = "gpui")] pub mod file_tree; +#[cfg(feature = "gpui")] pub mod file_viewer; +#[cfg(feature = "gpui")] pub mod in_page_search; -pub mod list_directory; +#[cfg(feature = "gpui")] pub mod list_overlay; +#[cfg(feature = "gpui")] pub mod markdown_highlight; +#[cfg(feature = "gpui")] pub mod selection; +#[cfg(feature = "gpui")] pub mod syntax; +// `theme` re-exports gpui theme helpers from `okena-ui` (a gpui crate) and is +// consumed only by gpui code, so it is gated with the rest of the viewer even +// though it holds no rendering itself. +#[cfg(feature = "gpui")] pub mod theme; diff --git a/crates/okena-files/src/list_directory.rs b/crates/okena-files/src/list_directory.rs index 11cdc58fd..ebe1bebde 100644 --- a/crates/okena-files/src/list_directory.rs +++ b/crates/okena-files/src/list_directory.rs @@ -32,8 +32,8 @@ pub fn list_directory( // root — `relative_path` may carry `..` segments or even be absolute // (Path::join with an absolute argument replaces). Without this guard // a caller passing `../../etc` would list arbitrary directories. The - // read_file / read_file_bytes path enforces the same invariant via - // LocalProjectFs::resolve; this is the listing-side mirror. + // read_file / read_file_bytes path enforces the same invariant via the + // server-side `resolve_project_file`; this is the listing-side mirror. let canonical_root = project_root .canonicalize() .map_err(|e| format!("Cannot resolve project path: {}", e))?; diff --git a/crates/okena-files/src/project_fs.rs b/crates/okena-files/src/project_fs.rs index 0ec11d55a..f74b24597 100644 --- a/crates/okena-files/src/project_fs.rs +++ b/crates/okena-files/src/project_fs.rs @@ -1,7 +1,7 @@ -//! ProjectFs trait and implementations for local and remote file operations. +//! ProjectFs trait and the remote-server (HTTP) implementation. use crate::content_search::{ContentSearchConfig, FileSearchResult, SearchMode}; -use crate::file_search::FileEntry; +use crate::file_scan::FileEntry; use crate::list_directory::DirEntry; use std::path::PathBuf; use std::sync::atomic::AtomicBool; @@ -28,6 +28,12 @@ pub trait ProjectFs: Send + Sync + 'static { /// Get file size in bytes. fn file_size(&self, relative_path: &str) -> Result; + /// Rename a file or folder (project-relative path) to `new_name`. + fn rename_file(&self, relative_path: &str, new_name: &str) -> Result<(), String>; + + /// Delete a file or folder (project-relative path). + fn delete_file(&self, relative_path: &str) -> Result<(), String>; + /// Search content across project files. fn search_content( &self, @@ -48,92 +54,11 @@ pub trait ProjectFs: Send + Sync + 'static { /// operations (e.g. context-menu rename/delete). Remote projects return /// `None` because the root only exists on the remote machine. fn project_root(&self) -> Option; -} - -/// Local file system provider — delegates to existing functions. -pub struct LocalProjectFs { - path: PathBuf, -} - -impl LocalProjectFs { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - /// Canonicalize `relative_path` inside the project root and reject any - /// path that escapes via `..` or absolute-path replacement. Mirrors the - /// server-side `resolve_project_file` so the trait has the same - /// containment guarantee on both implementations. - fn resolve(&self, relative_path: &str) -> Result { - let joined = self.path.join(relative_path); - let canonical = joined - .canonicalize() - .map_err(|e| format!("Cannot read file: {}", e))?; - let root = self - .path - .canonicalize() - .map_err(|e| format!("Cannot resolve project path: {}", e))?; - if !canonical.starts_with(&root) { - return Err("path traversal not allowed".to_string()); - } - Ok(canonical) - } -} - -impl ProjectFs for LocalProjectFs { - fn list_files(&self, show_ignored: bool) -> Vec { - crate::file_search::FileSearchDialog::scan_files(&self.path, show_ignored) - } - - fn list_directory( - &self, - relative_path: &str, - show_ignored: bool, - ) -> Result, String> { - crate::list_directory::list_directory(&self.path, relative_path, show_ignored) - } - - fn read_file(&self, relative_path: &str) -> Result { - let full = self.resolve(relative_path)?; - std::fs::read_to_string(&full).map_err(|e| format!("Cannot read file: {}", e)) - } - - fn read_file_bytes(&self, relative_path: &str) -> Result, String> { - let full = self.resolve(relative_path)?; - std::fs::read(&full).map_err(|e| format!("Cannot read file: {}", e)) - } - - fn file_size(&self, relative_path: &str) -> Result { - let full = self.resolve(relative_path)?; - std::fs::metadata(&full) - .map(|m| m.len()) - .map_err(|e| format!("Cannot read file: {}", e)) - } - fn search_content( - &self, - query: &str, - config: &ContentSearchConfig, - cancelled: &AtomicBool, - on_result: &mut (dyn FnMut(FileSearchResult) + Send), - ) { - crate::content_search::search_content(&self.path, query, config, cancelled, on_result); - } - - fn project_name(&self) -> String { - self.path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Project".to_string()) - } - - fn project_id(&self) -> String { - self.path.to_string_lossy().to_string() - } - - fn project_root(&self) -> Option { - Some(self.path.clone()) - } + /// Daemon-side absolute path for a project-relative path, for display/copy + /// (e.g. the "Copy Absolute Path" context-menu action). The path lives on + /// the daemon's filesystem. Returns `None` when the daemon root is unknown. + fn absolute_path(&self, relative_path: &str) -> Option; } /// Remote file system provider — fetches data via HTTP from a remote server. @@ -141,17 +66,33 @@ pub struct RemoteProjectFs { host: String, port: u16, token: String, + local_endpoint: Option, project_id: String, project_name: String, + root: String, } impl RemoteProjectFs { - pub fn new(host: String, port: u16, token: String, project_id: String, project_name: String) -> Self { - Self { host, port, token, project_id, project_name } + pub fn new( + host: String, + port: u16, + token: String, + local_endpoint: Option, + project_id: String, + project_name: String, + root: String, + ) -> Self { + Self { host, port, token, local_endpoint, project_id, project_name, root } } fn post_action(&self, action: okena_core::api::ActionRequest) -> Result, String> { - okena_transport::remote_action::post_action(&self.host, self.port, &self.token, action) + okena_transport::remote_action::post_action_with_endpoint( + &self.host, + self.port, + &self.token, + self.local_endpoint.as_ref(), + action, + ) } } @@ -238,6 +179,23 @@ impl ProjectFs for RemoteProjectFs { } } + fn rename_file(&self, relative_path: &str, new_name: &str) -> Result<(), String> { + let action = okena_core::api::ActionRequest::RenameFile { + project_id: self.project_id.clone(), + relative_path: relative_path.to_string(), + new_name: new_name.to_string(), + }; + self.post_action(action).map(|_| ()) + } + + fn delete_file(&self, relative_path: &str) -> Result<(), String> { + let action = okena_core::api::ActionRequest::DeleteFile { + project_id: self.project_id.clone(), + relative_path: relative_path.to_string(), + }; + self.post_action(action).map(|_| ()) + } + fn search_content( &self, query: &str, @@ -284,4 +242,12 @@ impl ProjectFs for RemoteProjectFs { fn project_root(&self) -> Option { None } + + fn absolute_path(&self, relative_path: &str) -> Option { + if self.root.is_empty() { + return None; + } + let base = self.root.trim_end_matches(['/', '\\']); + Some(format!("{}/{}", base, relative_path)) + } } diff --git a/crates/okena-git/src/repository/branch.rs b/crates/okena-git/src/repository/branch.rs index bb2f52a1d..77b89a915 100644 --- a/crates/okena-git/src/repository/branch.rs +++ b/crates/okena-git/src/repository/branch.rs @@ -4,6 +4,8 @@ use std::path::Path; +use serde::{Deserialize, Serialize}; + use okena_core::process::{command, safe_output}; use super::{head_branch_short, path_str, require_success}; @@ -29,22 +31,29 @@ pub fn get_available_branches_for_worktree(path: &Path) -> Vec { /// Get the default branch of a repository (e.g. "main" or "master"). /// Checks the `origin/HEAD` symref first, then falls back to checking for -/// local `main` / `master` branches. +/// remote/local `main` / `master` branches. pub fn get_default_branch(repo_path: &Path) -> Option { let repo = crate::gix_helpers::open(repo_path)?; // Read refs/remotes/origin/HEAD; it is a symbolic ref whose target points - // at e.g. refs/remotes/origin/main. + // at e.g. refs/remotes/origin/main. Ignore stale/dangling targets left by + // renamed or deleted default branches. if let Ok(head_ref) = repo.find_reference("refs/remotes/origin/HEAD") && let Some(target_name) = head_ref.target().try_name() { let target = target_name.as_bstr().to_string(); if let Some(branch) = target.strip_prefix("refs/remotes/origin/") - && !branch.is_empty() { + && !branch.is_empty() + && repo.find_reference(target.as_str()).is_ok() { return Some(branch.to_string()); } } - // Fallback: check if main or master branch exists locally. + // Fallback: check if main or master exists on origin, then locally. + for candidate in ["main", "master"] { + if repo.find_reference(&format!("refs/remotes/origin/{}", candidate)).is_ok() { + return Some(candidate.to_string()); + } + } for candidate in ["main", "master"] { if repo.find_reference(&format!("refs/heads/{}", candidate)).is_ok() { return Some(candidate.to_string()); @@ -210,7 +219,7 @@ pub fn push_branch(repo_path: &Path, branch: &str) -> GitResult<()> { /// Branch list classified into local and remote, with the current branch name /// (if HEAD points at a branch). -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct BranchList { /// Local branch names. pub local: Vec, @@ -452,6 +461,24 @@ mod tests { assert_eq!(get_default_branch(&repo).as_deref(), Some("main")); } + #[test] + fn get_default_branch_ignores_stale_origin_head() { + let (_tmp, repo) = init_temp_repo(); + git_in(&repo, &["branch", "backup/pre-cleanup"]); + git_in(&repo, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + git_in( + &repo, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/backup/pre-cleanup", + ], + ); + + assert_eq!(get_default_branch(&repo).as_deref(), Some("main")); + assert_eq!(resolve_review_base(&repo), None); + } + #[test] fn resolve_review_base_is_none_on_default_branch() { let (_tmp, repo) = init_temp_repo(); diff --git a/crates/okena-git/src/repository/ci.rs b/crates/okena-git/src/repository/ci.rs index ebdcb4154..06516d1f8 100644 --- a/crates/okena-git/src/repository/ci.rs +++ b/crates/okena-git/src/repository/ci.rs @@ -55,13 +55,15 @@ pub fn has_github_remote(path: &Path) -> bool { pub fn get_pr_info(path: &Path) -> Option { let path_str = path.to_str()?; let branch = super::status::get_current_branch(path)?; + let current_sha = super::status::get_head_sha(path); + let pushed_sha = get_pushed_sha(path); let output = safe_output_with_timeout( command("gh") .args([ "pr", "list", "--head", &branch, "--state", "all", "--limit", "1", - "--json", "url,state,isDraft,number,baseRefName", - "--jq", ".[0] | [.url, .state, .isDraft, .number, .baseRefName] | @tsv", + "--json", "url,state,isDraft,number,baseRefName,headRefOid", + "--jq", ".[0] | [.url, .state, .isDraft, .number, .baseRefName, .headRefOid] | @tsv", ]) .current_dir(path_str), GH_TIMEOUT, @@ -70,28 +72,38 @@ pub fn get_pr_info(path: &Path) -> Option { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); - return parse_pr_list_tsv(stdout.trim()); + return parse_pr_list_tsv(stdout.trim(), current_sha.as_deref(), pushed_sha.as_deref()); } None } /// Parse the single TSV line our `gh pr list --jq ... | @tsv` query emits into a -/// [`PrInfo`]. Fields, in order: url, state, isDraft, number, baseRefName (the -/// last may be absent on older `gh`). Returns `None` for an empty line or one -/// that doesn't start with a URL (i.e. no PR found for the branch). -fn parse_pr_list_tsv(line: &str) -> Option { +/// [`PrInfo`]. Fields, in order: url, state, isDraft, number, baseRefName, +/// headRefOid. Returns `None` for an empty line, no PR, or a closed PR whose +/// head is no longer the current branch head. +fn parse_pr_list_tsv( + line: &str, + current_sha: Option<&str>, + pushed_sha: Option<&str>, +) -> Option { let parts: Vec<&str> = line.split('\t').collect(); if parts.len() < 4 || !parts[0].starts_with("http") { return None; } let url = parts[0].to_string(); + let raw_state = parts[1]; let is_draft = parts[2] == "true"; let number = parts[3].parse::().unwrap_or(0); + let head_oid = parts.get(5).map(|s| s.trim()).filter(|s| !s.is_empty()); + let is_closed_pr = !is_draft && matches!(raw_state, "MERGED" | "CLOSED"); + if is_closed_pr && !closed_pr_head_matches(head_oid, current_sha, pushed_sha) { + return None; + } let state = if is_draft { crate::PrState::Draft } else { - match parts[1] { + match raw_state { "OPEN" => crate::PrState::Open, "MERGED" => crate::PrState::Merged, "CLOSED" => crate::PrState::Closed, @@ -110,6 +122,17 @@ fn parse_pr_list_tsv(line: &str) -> Option { Some(crate::PrInfo { url, state, number, base }) } +fn closed_pr_head_matches( + head_oid: Option<&str>, + current_sha: Option<&str>, + pushed_sha: Option<&str>, +) -> bool { + let Some(head_oid) = head_oid else { + return false; + }; + current_sha == Some(head_oid) || pushed_sha == Some(head_oid) +} + /// Compute elapsed milliseconds between two ISO-8601 timestamps (those /// returned by `gh pr checks --json startedAt,completedAt`). Returns 0 /// when either timestamp is missing or unparseable — interpreted as @@ -471,8 +494,8 @@ mod tests { #[test] fn parse_pr_list_tsv_captures_base_branch() { - let line = "https://github.com/o/r/pull/9\tOPEN\tfalse\t9\tdevelop"; - let pr = super::parse_pr_list_tsv(line).expect("should parse"); + let line = "https://github.com/o/r/pull/9\tOPEN\tfalse\t9\tdevelop\tabc123"; + let pr = super::parse_pr_list_tsv(line, Some("different"), None).expect("should parse"); assert_eq!(pr.url, "https://github.com/o/r/pull/9"); assert_eq!(pr.state, crate::PrState::Open); assert_eq!(pr.number, 9); @@ -482,26 +505,40 @@ mod tests { #[test] fn parse_pr_list_tsv_base_absent_is_none() { // Older `gh` without baseRefName: only four fields. - let line = "https://github.com/o/r/pull/3\tMERGED\tfalse\t3"; - let pr = super::parse_pr_list_tsv(line).expect("should parse"); - assert_eq!(pr.state, crate::PrState::Merged); + let line = "https://github.com/o/r/pull/3\tOPEN\tfalse\t3"; + let pr = super::parse_pr_list_tsv(line, None, None).expect("should parse"); + assert_eq!(pr.state, crate::PrState::Open); assert_eq!(pr.number, 3); assert_eq!(pr.base, None); } #[test] fn parse_pr_list_tsv_draft_overrides_state() { - let line = "https://github.com/o/r/pull/5\tOPEN\ttrue\t5\tmain"; - let pr = super::parse_pr_list_tsv(line).expect("should parse"); + let line = "https://github.com/o/r/pull/5\tOPEN\ttrue\t5\tmain\tabc123"; + let pr = super::parse_pr_list_tsv(line, Some("different"), None).expect("should parse"); assert_eq!(pr.state, crate::PrState::Draft); assert_eq!(pr.base.as_deref(), Some("main")); } + #[test] + fn parse_pr_list_tsv_keeps_closed_pr_at_current_head() { + let line = "https://github.com/o/r/pull/3\tMERGED\tfalse\t3\tmain\tabc123"; + let pr = super::parse_pr_list_tsv(line, Some("abc123"), None).expect("should parse"); + assert_eq!(pr.state, crate::PrState::Merged); + assert_eq!(pr.number, 3); + } + + #[test] + fn parse_pr_list_tsv_ignores_stale_closed_pr() { + let line = "https://github.com/o/r/pull/4\tCLOSED\tfalse\t4\tmain\toldsha"; + assert!(super::parse_pr_list_tsv(line, Some("newsha"), Some("newsha")).is_none()); + } + #[test] fn parse_pr_list_tsv_empty_or_no_pr_is_none() { - assert!(super::parse_pr_list_tsv("").is_none()); + assert!(super::parse_pr_list_tsv("", None, None).is_none()); // A jq null/empty result — no leading URL. - assert!(super::parse_pr_list_tsv("\t\t\t").is_none()); + assert!(super::parse_pr_list_tsv("\t\t\t", None, None).is_none()); } // ─── CI check parsing tests ──────────────────────────────────────── diff --git a/crates/okena-hooks/Cargo.toml b/crates/okena-hooks/Cargo.toml index 02ca63a61..356ac67ce 100644 --- a/crates/okena-hooks/Cargo.toml +++ b/crates/okena-hooks/Cargo.toml @@ -4,13 +4,17 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui"] +gpui = ["dep:gpui"] + [dependencies] okena-core = { path = "../okena-core" } okena-terminal = { path = "../okena-terminal" } okena-git = { path = "../okena-git" } okena-state = { path = "../okena-state" } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } parking_lot = "0.12" log = "0.4" diff --git a/crates/okena-hooks/src/hook_monitor.rs b/crates/okena-hooks/src/hook_monitor.rs index c368b0320..dc6348841 100644 --- a/crates/okena-hooks/src/hook_monitor.rs +++ b/crates/okena-hooks/src/hook_monitor.rs @@ -48,6 +48,7 @@ struct HookMonitorInner { #[derive(Clone)] pub struct HookMonitor(Arc>); +#[cfg(feature = "gpui")] impl gpui::Global for HookMonitor {} impl Default for HookMonitor { @@ -147,6 +148,14 @@ impl HookMonitor { std::mem::take(&mut inner.pending_toasts) } + /// Enqueue an arbitrary toast for the daemon's toast-forward loop to + /// broadcast to clients. Used by daemon flows (e.g. soft-close) that must + /// surface a toast but aren't hook-driven. + pub fn push_toast(&self, toast: Toast) { + let mut inner = self.0.lock(); + inner.pending_toasts.push(toast); + } + /// Get a snapshot of the execution history (newest first). pub fn history(&self) -> Vec { let inner = self.0.lock(); diff --git a/crates/okena-hooks/src/hooks.rs b/crates/okena-hooks/src/hooks.rs index 80421f7f8..4069fbc99 100644 --- a/crates/okena-hooks/src/hooks.rs +++ b/crates/okena-hooks/src/hooks.rs @@ -9,6 +9,7 @@ use okena_terminal::terminal::{Terminal, TerminalSize}; use okena_terminal::TerminalsRegistry; use okena_state::HooksConfig; use crate::hook_monitor::{HookMonitor, HookStatus}; +#[cfg(feature = "gpui")] use gpui::App; use std::collections::HashMap; use std::sync::Arc; @@ -28,6 +29,7 @@ impl HookRunner { } } +#[cfg(feature = "gpui")] impl gpui::Global for HookRunner {} /// Pending terminal-backed hook actions paired with their env vars, returned @@ -351,11 +353,13 @@ fn resolve_hook_with_parent( } /// Try to get the global HookMonitor from GPUI context. +#[cfg(feature = "gpui")] pub fn try_monitor(cx: &App) -> Option { cx.try_global::().cloned() } /// Try to get the global HookRunner from GPUI context. +#[cfg(feature = "gpui")] pub fn try_runner(cx: &App) -> Option { cx.try_global::().cloned() } @@ -581,6 +585,10 @@ fn project_env( } /// Fire the `on_project_open` hook for a project. +/// +/// GPUI-free: takes the `HookRunner`/`HookMonitor` services explicitly so the +/// daemon reactor can drive it without an `&App`. Callers in scope of GPUI pass +/// `try_runner(cx)`/`try_monitor(cx)`. pub fn fire_on_project_open( project_hooks: &HooksConfig, project_id: &str, @@ -589,14 +597,13 @@ pub fn fire_on_project_open( folder_id: Option<&str>, folder_name: Option<&str>, global_hooks: &HooksConfig, - cx: &App, + runner: Option<&HookRunner>, + monitor: Option<&HookMonitor>, ) -> Vec { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.project.on_open) { let env = project_env(project_id, project_name, project_path, folder_id, folder_name); log::info!("Running on_project_open hook for project '{}'", project_name); - let monitor = try_monitor(cx); - let runner = try_runner(cx); - if let Some(result) = run_hook(cmd, env, monitor.as_ref(), "on_project_open", project_name, runner.as_ref(), project_id, true) { + if let Some(result) = run_hook(cmd, env, monitor, "on_project_open", project_name, runner, project_id, true) { return vec![result]; } } @@ -605,6 +612,9 @@ pub fn fire_on_project_open( /// Fire the `on_project_close` hook for a project. /// Runs headlessly (no PTY terminal) since the project is being deleted. +/// +/// GPUI-free: takes the `HookMonitor` service explicitly (no runner — close +/// hooks never spawn a PTY terminal). pub fn fire_on_project_close( project_hooks: &HooksConfig, project_id: &str, @@ -613,17 +623,18 @@ pub fn fire_on_project_close( folder_id: Option<&str>, folder_name: Option<&str>, global_hooks: &HooksConfig, - cx: &App, + monitor: Option<&HookMonitor>, ) { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.project.on_close) { let env = project_env(project_id, project_name, project_path, folder_id, folder_name); log::info!("Running on_project_close hook for project '{}'", project_name); - let monitor = try_monitor(cx); - run_hook(cmd, env, monitor.as_ref(), "on_project_close", project_name, None, project_id, true); + run_hook(cmd, env, monitor, "on_project_close", project_name, None, project_id, true); } } /// Fire the `on_worktree_create` hook after a worktree is successfully created. +/// +/// GPUI-free: takes the `HookRunner`/`HookMonitor` services explicitly. pub fn fire_on_worktree_create( project_hooks: &HooksConfig, project_id: &str, @@ -633,15 +644,14 @@ pub fn fire_on_worktree_create( folder_id: Option<&str>, folder_name: Option<&str>, global_hooks: &HooksConfig, - cx: &App, + runner: Option<&HookRunner>, + monitor: Option<&HookMonitor>, ) -> Vec { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_create) { let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); env.insert("OKENA_BRANCH".into(), branch.into()); log::info!("Running on_worktree_create hook for branch '{}'", branch); - let monitor = try_monitor(cx); - let runner = try_runner(cx); - if let Some(result) = run_hook(cmd, env, monitor.as_ref(), "on_worktree_create", project_name, runner.as_ref(), project_id, true) { + if let Some(result) = run_hook(cmd, env, monitor, "on_worktree_create", project_name, runner, project_id, true) { return vec![result]; } } @@ -650,7 +660,10 @@ pub fn fire_on_worktree_create( /// Fire the `on_worktree_close` hook after a worktree is successfully removed. /// Runs headlessly (no PTY terminal) since the worktree project is being deleted. -pub fn fire_on_worktree_close( +/// +/// GPUI-free: takes the `HookMonitor` service explicitly (no runner — close +/// hooks never spawn a PTY terminal). +pub fn fire_on_worktree_close_with_services( project_hooks: &HooksConfig, project_id: &str, project_name: &str, @@ -659,17 +672,45 @@ pub fn fire_on_worktree_close( folder_id: Option<&str>, folder_name: Option<&str>, global_hooks: &HooksConfig, - cx: &App, + monitor: Option<&HookMonitor>, ) { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_close) { let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); env.insert("OKENA_BRANCH".into(), branch.into()); log::info!("Running on_worktree_close hook for project '{}' (branch: {})", project_name, branch); - let monitor = try_monitor(cx); - run_hook(cmd, env, monitor.as_ref(), "on_worktree_close", project_name, None, project_id, true); + run_hook(cmd, env, monitor, "on_worktree_close", project_name, None, project_id, true); } } +/// GPUI wrapper around [`fire_on_worktree_close_with_services`]: reads the +/// `HookMonitor` global from `&App` and delegates. Kept so existing `&App` +/// callers (e.g. okena-app's pending-worktree-close path) compile unchanged. +#[cfg(feature = "gpui")] +pub fn fire_on_worktree_close( + project_hooks: &HooksConfig, + project_id: &str, + project_name: &str, + project_path: &str, + branch: &str, + folder_id: Option<&str>, + folder_name: Option<&str>, + global_hooks: &HooksConfig, + cx: &App, +) { + let monitor = try_monitor(cx); + fire_on_worktree_close_with_services( + project_hooks, + project_id, + project_name, + project_path, + branch, + folder_id, + folder_name, + global_hooks, + monitor.as_ref(), + ); +} + /// Bare sync hook runner for tests (no monitor, no runner). #[cfg(test)] fn run_hook_sync_bare(command: &str, env_vars: HashMap) -> Result, String> { @@ -873,6 +914,7 @@ pub fn fire_worktree_removed( /// Resolve the `terminal.on_create` hook command. /// Returns the command string if configured at any level (project/parent/global). +#[cfg(feature = "gpui")] pub fn resolve_terminal_on_create( project_hooks: &HooksConfig, parent_hooks: Option<&HooksConfig>, @@ -903,9 +945,14 @@ pub fn apply_on_create(shell: &ShellType, on_create_cmd: &str, env_vars: &HashMa ShellType::for_command(script) } -/// Fire the `terminal.on_close` hook after a terminal PTY exits. -/// Runs headlessly (no PTY runner) since the terminal just exited. -pub fn fire_terminal_on_close( +/// Fire the `terminal.on_close` hook after a terminal PTY exits, taking the +/// `HookMonitor` explicitly (GPUI-free). Runs headlessly (no PTY runner) since +/// the terminal just exited. +/// +/// This is the core; the GPUI [`fire_terminal_on_close`] wrapper just reads the +/// monitor global from `&App` and delegates here. The daemon (no GPUI globals) +/// calls this directly with the monitor it owns. +pub fn fire_terminal_on_close_with_services( project_hooks: &HooksConfig, parent_hooks: Option<&HooksConfig>, project_id: &str, @@ -918,7 +965,7 @@ pub fn fire_terminal_on_close( folder_id: Option<&str>, folder_name: Option<&str>, global_hooks: &HooksConfig, - cx: &App, + monitor: Option<&HookMonitor>, ) { if let Some(cmd) = resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| &h.terminal.on_close) { let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); @@ -939,11 +986,48 @@ pub fn fire_terminal_on_close( } } log::info!("Running terminal.on_close hook for terminal '{}'", terminal_id); - let monitor = try_monitor(cx); - run_hook(cmd, env, monitor.as_ref(), "terminal.on_close", project_name, None, project_id, true); + run_hook(cmd, env, monitor, "terminal.on_close", project_name, None, project_id, true); } } +/// GPUI wrapper around [`fire_terminal_on_close_with_services`]: reads the +/// `HookMonitor` global from `&App` and delegates. Kept so existing `&App` +/// callers (e.g. okena-app's PTY exit loop) compile unchanged. +#[cfg(feature = "gpui")] +#[allow(clippy::too_many_arguments)] +pub fn fire_terminal_on_close( + project_hooks: &HooksConfig, + parent_hooks: Option<&HooksConfig>, + project_id: &str, + project_name: &str, + project_path: &str, + terminal_id: &str, + terminal_name: Option<&str>, + is_worktree: bool, + exit_code: Option, + folder_id: Option<&str>, + folder_name: Option<&str>, + global_hooks: &HooksConfig, + cx: &App, +) { + let monitor = try_monitor(cx); + fire_terminal_on_close_with_services( + project_hooks, + parent_hooks, + project_id, + project_name, + project_path, + terminal_id, + terminal_name, + is_worktree, + exit_code, + folder_id, + folder_name, + global_hooks, + monitor.as_ref(), + ); +} + /// Resolve the shell_wrapper for terminal creation. /// Returns the wrapper command template if configured (project or global level). pub fn resolve_shell_wrapper( diff --git a/crates/okena-hooks/src/lib.rs b/crates/okena-hooks/src/lib.rs index 8c5f9053a..f51ce7d11 100644 --- a/crates/okena-hooks/src/lib.rs +++ b/crates/okena-hooks/src/lib.rs @@ -20,7 +20,12 @@ pub use hooks::{ HookRunner, HookTerminalResult, apply_shell_wrapper, fire_before_worktree_remove, fire_before_worktree_remove_async, fire_on_dirty_worktree_close, fire_on_project_close, fire_on_project_open, - fire_on_rebase_conflict, fire_on_worktree_close, fire_on_worktree_create, - fire_post_merge, fire_pre_merge, fire_terminal_on_close, fire_worktree_removed, - resolve_terminal_on_create, terminal_hook_env, try_monitor, try_runner, + fire_on_rebase_conflict, fire_on_worktree_close_with_services, fire_on_worktree_create, + fire_post_merge, fire_pre_merge, fire_terminal_on_close_with_services, fire_worktree_removed, + terminal_hook_env, +}; +#[cfg(feature = "gpui")] +pub use hooks::{ + fire_on_worktree_close, fire_terminal_on_close, resolve_terminal_on_create, try_monitor, + try_runner, }; diff --git a/crates/okena-mobile-ffi/src/api/state.rs b/crates/okena-mobile-ffi/src/api/state.rs index 699593a18..552df7377 100644 --- a/crates/okena-mobile-ffi/src/api/state.rs +++ b/crates/okena-mobile-ffi/src/api/state.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use crate::client::manager::ConnectionManager; -use okena_core::api::ApiLayoutNode; +use okena_transport::client::collect_layout_terminal_ids; /// Flat FFI-friendly project info. #[derive(Debug, Clone)] @@ -68,9 +68,7 @@ pub fn get_projects(conn_id: String) -> Vec { .iter() .map(|p| { let terminal_ids = if let Some(ref layout) = p.layout { - let mut ids = Vec::new(); - collect_layout_ids_vec(layout, &mut ids); - ids + collect_layout_terminal_ids(layout) } else { Vec::new() }; @@ -154,18 +152,3 @@ pub fn get_fullscreen_terminal(conn_id: String) -> Option { }) }) } - -fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec) { - match node { - ApiLayoutNode::Terminal { terminal_id, .. } => { - if let Some(id) = terminal_id { - ids.push(id.clone()); - } - } - ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { - for child in children { - collect_layout_ids_vec(child, ids); - } - } - } -} diff --git a/crates/okena-mobile-ffi/src/client/manager.rs b/crates/okena-mobile-ffi/src/client/manager.rs index 8131818c5..d438d4543 100644 --- a/crates/okena-mobile-ffi/src/client/manager.rs +++ b/crates/okena-mobile-ffi/src/client/manager.rs @@ -130,6 +130,7 @@ impl ConnectionManager { token_obtained_at: None, tls, pinned_cert_sha256: None, + local_endpoint: None, }; let conn_id = config.id.clone(); @@ -399,9 +400,21 @@ impl ConnectionManager { } } } + ConnectionEvent::SystemStatsChanged { .. } => {} ConnectionEvent::ServerWarning { message, .. } => { log::warn!("Server warning for {}: {}", conn_id, message); } + ConnectionEvent::Toast { toast, .. } => { + // The mobile bridge has no toast surface yet; surfacing these + // in the RN UI would need a dedicated FFI callback. Log for now + // so daemon-originated toasts are at least observable. + log::info!( + "Toast for {} [{}]: {}", + conn_id, + toast.level, + toast.message + ); + } } } } diff --git a/crates/okena-mobile-ffi/src/lib.rs b/crates/okena-mobile-ffi/src/lib.rs index a0ef9a03f..87e9e9ab4 100644 --- a/crates/okena-mobile-ffi/src/lib.rs +++ b/crates/okena-mobile-ffi/src/lib.rs @@ -437,15 +437,20 @@ impl From for MobileFfiError { } } +async fn send_mobile_action( + conn_id: &str, + action: ActionRequest, +) -> Result<(), MobileFfiError> { + ConnectionManager::get().send_action(conn_id, action).await?; + Ok(()) +} + // ── Terminal actions (async — await reqwest) ──────────────────────── /// Create a new terminal in the given project. #[uniffi::export(async_runtime = "tokio")] pub async fn create_terminal(conn_id: String, project_id: String) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action(&conn_id, ActionRequest::CreateTerminal { project_id }) - .await?; - Ok(()) + send_mobile_action(&conn_id, ActionRequest::CreateTerminal { project_id }).await } /// Close a terminal in the given project. @@ -455,16 +460,14 @@ pub async fn close_terminal( project_id: String, terminal_id: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::CloseTerminal { - project_id, - terminal_id, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::CloseTerminal { + project_id, + terminal_id, + }, + ) + .await } /// Close multiple terminals in a project. @@ -474,16 +477,14 @@ pub async fn close_terminals( project_id: String, terminal_ids: Vec, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::CloseTerminals { - project_id, - terminal_ids, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::CloseTerminals { + project_id, + terminal_ids, + }, + ) + .await } /// Rename a terminal. @@ -494,17 +495,15 @@ pub async fn rename_terminal( terminal_id: String, name: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::RenameTerminal { - project_id, - terminal_id, - name, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::RenameTerminal { + project_id, + terminal_id, + name, + }, + ) + .await } /// Focus a terminal. @@ -514,17 +513,15 @@ pub async fn focus_terminal( project_id: String, terminal_id: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::FocusTerminal { - project_id, - terminal_id, - window: None, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::FocusTerminal { + project_id, + terminal_id, + window: None, + }, + ) + .await } /// Toggle minimized state of a terminal. @@ -534,16 +531,14 @@ pub async fn toggle_minimized( project_id: String, terminal_id: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::ToggleMinimized { - project_id, - terminal_id, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::ToggleMinimized { + project_id, + terminal_id, + }, + ) + .await } /// Set/clear fullscreen terminal. @@ -553,17 +548,15 @@ pub async fn set_fullscreen( project_id: String, terminal_id: Option, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::SetFullscreen { - project_id, - terminal_id, - window: None, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::SetFullscreen { + project_id, + terminal_id, + window: None, + }, + ) + .await } /// Split a terminal pane. `direction` is "vertical" or "horizontal". @@ -578,17 +571,15 @@ pub async fn split_terminal( "vertical" => SplitDirection::Vertical, _ => SplitDirection::Horizontal, }; - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::SplitTerminal { - project_id, - path: path.into_iter().map(|v| v as usize).collect(), - direction: dir, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::SplitTerminal { + project_id, + path: path.into_iter().map(|v| v as usize).collect(), + direction: dir, + }, + ) + .await } /// Run a command in a terminal (presses Enter automatically). @@ -598,16 +589,14 @@ pub async fn run_command( terminal_id: String, command: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::RunCommand { - terminal_id, - command, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::RunCommand { + terminal_id, + command, + }, + ) + .await } /// Read terminal content as text. @@ -703,16 +692,14 @@ pub async fn start_service( project_id: String, service_name: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::StartService { - project_id, - service_name, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::StartService { + project_id, + service_name, + }, + ) + .await } /// Stop a service. @@ -722,16 +709,14 @@ pub async fn stop_service( project_id: String, service_name: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::StopService { - project_id, - service_name, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::StopService { + project_id, + service_name, + }, + ) + .await } /// Restart a service. @@ -741,16 +726,14 @@ pub async fn restart_service( project_id: String, service_name: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::RestartService { - project_id, - service_name, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::RestartService { + project_id, + service_name, + }, + ) + .await } /// Start all services in a project. @@ -759,28 +742,19 @@ pub async fn start_all_services( conn_id: String, project_id: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action(&conn_id, ActionRequest::StartAllServices { project_id }) - .await?; - Ok(()) + send_mobile_action(&conn_id, ActionRequest::StartAllServices { project_id }).await } /// Stop all services in a project. #[uniffi::export(async_runtime = "tokio")] pub async fn stop_all_services(conn_id: String, project_id: String) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action(&conn_id, ActionRequest::StopAllServices { project_id }) - .await?; - Ok(()) + send_mobile_action(&conn_id, ActionRequest::StopAllServices { project_id }).await } /// Reload services config for a project. #[uniffi::export(async_runtime = "tokio")] pub async fn reload_services(conn_id: String, project_id: String) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action(&conn_id, ActionRequest::ReloadServices { project_id }) - .await?; - Ok(()) + send_mobile_action(&conn_id, ActionRequest::ReloadServices { project_id }).await } // ── Project management (async) ────────────────────────────────────── @@ -792,10 +766,7 @@ pub async fn add_project( name: String, path: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action(&conn_id, ActionRequest::AddProject { name, path }) - .await?; - Ok(()) + send_mobile_action(&conn_id, ActionRequest::AddProject { name, path }).await } /// Set project color (named color, e.g. "blue"; unknown → default). @@ -807,16 +778,14 @@ pub async fn set_project_color( ) -> Result<(), MobileFfiError> { let folder_color: okena_core::theme::FolderColor = serde_json::from_value(serde_json::Value::String(color)).unwrap_or_default(); - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::SetProjectColor { - project_id, - color: folder_color, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::SetProjectColor { + project_id, + color: folder_color, + }, + ) + .await } /// Set folder color (named color; unknown → default). @@ -828,16 +797,14 @@ pub async fn set_folder_color( ) -> Result<(), MobileFfiError> { let folder_color: okena_core::theme::FolderColor = serde_json::from_value(serde_json::Value::String(color)).unwrap_or_default(); - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::SetFolderColor { - folder_id, - color: folder_color, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::SetFolderColor { + folder_id, + color: folder_color, + }, + ) + .await } /// Reorder a project within a folder. @@ -848,17 +815,15 @@ pub async fn reorder_project_in_folder( project_id: String, new_index: u32, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::ReorderProjectInFolder { - folder_id, - project_id, - new_index: new_index as usize, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::ReorderProjectInFolder { + folder_id, + project_id, + new_index: new_index as usize, + }, + ) + .await } // ── Layout actions (async) ────────────────────────────────────────── @@ -871,17 +836,15 @@ pub async fn update_split_sizes( path: Vec, sizes: Vec, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::UpdateSplitSizes { - project_id, - path: path.into_iter().map(|v| v as usize).collect(), - sizes, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::UpdateSplitSizes { + project_id, + path: path.into_iter().map(|v| v as usize).collect(), + sizes, + }, + ) + .await } /// Add a new tab to a tab group. @@ -892,17 +855,15 @@ pub async fn add_tab( path: Vec, in_group: bool, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::AddTab { - project_id, - path: path.into_iter().map(|v| v as usize).collect(), - in_group, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::AddTab { + project_id, + path: path.into_iter().map(|v| v as usize).collect(), + in_group, + }, + ) + .await } /// Set the active tab in a tab group. @@ -913,17 +874,15 @@ pub async fn set_active_tab( path: Vec, index: u32, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::SetActiveTab { - project_id, - path: path.into_iter().map(|v| v as usize).collect(), - index: index as usize, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::SetActiveTab { + project_id, + path: path.into_iter().map(|v| v as usize).collect(), + index: index as usize, + }, + ) + .await } /// Move a tab within a tab group. @@ -935,18 +894,16 @@ pub async fn move_tab( from_index: u32, to_index: u32, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::MoveTab { - project_id, - path: path.into_iter().map(|v| v as usize).collect(), - from_index: from_index as usize, - to_index: to_index as usize, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::MoveTab { + project_id, + path: path.into_iter().map(|v| v as usize).collect(), + from_index: from_index as usize, + to_index: to_index as usize, + }, + ) + .await } /// Move a terminal into a tab group. @@ -959,19 +916,17 @@ pub async fn move_terminal_to_tab_group( position: Option, target_project_id: Option, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::MoveTerminalToTabGroup { - project_id, - terminal_id, - target_path: target_path.into_iter().map(|v| v as usize).collect(), - position: position.map(|p| p as usize), - target_project_id, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::MoveTerminalToTabGroup { + project_id, + terminal_id, + target_path: target_path.into_iter().map(|v| v as usize).collect(), + position: position.map(|p| p as usize), + target_project_id, + }, + ) + .await } /// Move a pane to a drop zone relative to another terminal. @@ -984,17 +939,15 @@ pub async fn move_pane_to( target_terminal_id: String, zone: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::MovePaneTo { - project_id, - terminal_id, - target_project_id, - target_terminal_id, - zone, - }, - ) - .await?; - Ok(()) + send_mobile_action( + &conn_id, + ActionRequest::MovePaneTo { + project_id, + terminal_id, + target_project_id, + target_terminal_id, + zone, + }, + ) + .await } diff --git a/crates/okena-remote-client/src/backend.rs b/crates/okena-remote-client/src/backend.rs index ac86995d7..00b0f56bc 100644 --- a/crates/okena-remote-client/src/backend.rs +++ b/crates/okena-remote-client/src/backend.rs @@ -1,8 +1,12 @@ +use anyhow::Result; use okena_terminal::backend::TerminalBackend; use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::TerminalTransport; -use anyhow::Result; -use okena_transport::client::{make_prefixed_id, strip_prefix, WsClientMessage}; +use okena_transport::client::{ + close_remote_terminal, make_prefixed_id, resize_remote_terminal, send_remote_terminal_input, + WsClientMessage, REMOTE_TERMINAL_ANSWERS_QUERIES, REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS, + REMOTE_TERMINAL_USES_MOUSE_BACKEND, +}; use std::path::PathBuf; use std::sync::Arc; @@ -18,28 +22,35 @@ pub struct RemoteTransport { impl TerminalTransport for RemoteTransport { fn send_input(&self, terminal_id: &str, data: &[u8]) { - let remote_id = strip_prefix(terminal_id, &self.connection_id); - let _ = self.ws_tx.try_send(WsClientMessage::SendText { - terminal_id: remote_id, - text: String::from_utf8_lossy(data).to_string(), - }); + send_remote_terminal_input(&self.ws_tx, &self.connection_id, terminal_id, data); } + /// No-op: the daemon owns the PTY and is the sole responder to terminal + /// queries (Device Attributes, DSR, DECRQM, …). A remote client is a + /// render-only mirror of the daemon's grid — if it also answered, the reply + /// would be a duplicate that round-trips over the WebSocket and lands at the + /// PTY long after the querying program exited (the stray `6c` at the shell + /// prompt after closing nvim). The default `send_response` routes to + /// `send_input`, so we must explicitly suppress it here. + fn send_response(&self, _terminal_id: &str, _data: &[u8]) {} + fn resize(&self, terminal_id: &str, cols: u16, rows: u16) { - let remote_id = strip_prefix(terminal_id, &self.connection_id); - let _ = self.ws_tx.try_send(WsClientMessage::Resize { - terminal_id: remote_id, - cols, - rows, - }); + resize_remote_terminal(&self.ws_tx, &self.connection_id, terminal_id, cols, rows); } fn uses_mouse_backend(&self) -> bool { - false + REMOTE_TERMINAL_USES_MOUSE_BACKEND } fn resize_debounce_ms(&self) -> u64 { - 150 + REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS + } + + fn answers_terminal_queries(&self) -> bool { + // Mirror of a server-owned PTY: the server's emulator answers DSR/DA/ + // OSC-color queries. Answering here too would duplicate every reply + // and let emulator chatter steal the server's resize ownership. + REMOTE_TERMINAL_ANSWERS_QUERIES } } @@ -80,13 +91,7 @@ impl TerminalBackend for RemoteBackend { } fn kill(&self, terminal_id: &str) { - let remote_id = strip_prefix(terminal_id, &self.connection_id); - let _ = self - .transport - .ws_tx - .try_send(WsClientMessage::CloseTerminal { - terminal_id: remote_id, - }); + close_remote_terminal(&self.transport.ws_tx, &self.connection_id, terminal_id); } fn capture_buffer(&self, _terminal_id: &str) -> Option { @@ -94,7 +99,10 @@ impl TerminalBackend for RemoteBackend { } fn supports_buffer_capture(&self) -> bool { - false + // The daemon performs the actual `capture-pane`; the GUI routes the + // export through the action dispatcher (server-side op over HTTP), so + // the button stays visible for remote terminals. + true } fn is_remote(&self) -> bool { diff --git a/crates/okena-remote-client/src/connection.rs b/crates/okena-remote-client/src/connection.rs index bcf79e684..31fe32a4d 100644 --- a/crates/okena-remote-client/src/connection.rs +++ b/crates/okena-remote-client/src/connection.rs @@ -3,7 +3,7 @@ use okena_terminal::backend::TerminalBackend; use okena_terminal::terminal::{Terminal, TerminalSize}; use okena_terminal::TerminalsRegistry; -use okena_core::api::StateResponse; +use okena_core::api::{ApiSystemStats, StateResponse}; use okena_transport::client::{ is_remote_terminal, ConnectionEvent, ConnectionHandler, ConnectionStatus, RemoteClient, RemoteConnectionConfig, WsClientMessage, @@ -78,6 +78,7 @@ impl ConnectionHandler for DesktopConnectionHandler { } fn resize_terminal(&self, prefixed_id: &str, cols: u16, rows: u16, server_owns: bool) { + log::debug!("client recv resize: terminal={prefixed_id} {cols}x{rows} server_owns={server_owns}"); if let Some(terminal) = self.terminals.lock().get(prefixed_id) { // The origin's local user just reclaimed resize authority. Mark the // remote side as resize owner on this client so its TerminalElement @@ -194,6 +195,14 @@ impl RemoteConnection { self.client.set_remote_state(state); } + pub fn system_stats(&self) -> Option<&ApiSystemStats> { + self.client.system_stats() + } + + pub fn set_system_stats(&mut self, stats: Option) { + self.client.set_system_stats(stats); + } + pub fn update_stream_mappings(&mut self, mappings: HashMap) { self.client.update_stream_mappings(mappings); } diff --git a/crates/okena-remote-client/src/manager.rs b/crates/okena-remote-client/src/manager.rs index cdf8157bb..551bf60c8 100644 --- a/crates/okena-remote-client/src/manager.rs +++ b/crates/okena-remote-client/src/manager.rs @@ -1,13 +1,18 @@ use crate::connection::RemoteConnection; use okena_terminal::backend::TerminalBackend; use okena_terminal::terminal::Terminal; -use okena_workspace::toast::ToastManager; +use okena_workspace::toast::{Toast, ToastManager}; use okena_terminal::TerminalsRegistry; use okena_workspace::settings::{load_settings, update_remote_connections}; -use okena_core::api::{ActionRequest, StateResponse}; +use okena_core::api::{ActionRequest, ApiSystemStats, StateResponse}; +use okena_core::soft_close::{ + decode_action, encode_action, SOFT_CLOSE_KILL_PREFIX, SOFT_CLOSE_UNDO_PREFIX, +}; +use okena_transport::client::LocalEndpoint; use okena_transport::client::{ - ConnectionEvent, ConnectionStatus, RemoteConnectionConfig, + make_prefixed_id, ConnectionEvent, ConnectionStatus, RemoteConnectionConfig, + LOCAL_DAEMON_CONNECTION_ID, }; use okena_transport::client::connection::try_refresh_token; @@ -15,6 +20,22 @@ use gpui::*; use std::collections::HashMap; use std::sync::Arc; +fn http_client_and_url(config: &RemoteConnectionConfig, path: &str) -> (reqwest::Client, String) { + #[cfg(unix)] + if let Some(LocalEndpoint::UnixSocket { path: socket_path }) = &config.local_endpoint { + let client = reqwest::Client::builder() + .unix_socket(socket_path.as_str()) + .build() + .unwrap_or_else(|e| { + log::error!("Failed to build Unix socket HTTP client for {socket_path}: {e}"); + reqwest::Client::new() + }); + return (client, config.http_url(path)); + } + + (reqwest::Client::new(), config.http_url(path)) +} + /// Lightweight events emitted by [`RemoteConnectionManager`] that must NOT go /// through `cx.notify()`. /// @@ -27,7 +48,23 @@ use std::sync::Arc; pub enum RemoteManagerEvent { /// A remote terminal produced output / changed derived state (bell, idle). /// Subscribers should repaint indicators but must not re-sync project state. - TerminalActivity, + /// + /// Carries the ids of the remote terminals whose `content_generation` + /// advanced this wake. The sidebar ignores the payload (it re-reads every + /// terminal's flags), but `Okena` uses it to drain OSC 9/777/99 + bell + /// notifications for exactly those terminals — the daemon-client equivalent + /// of the local PTY loop's `process_terminal_notifications` pass. Remote + /// PTY output never goes through that loop (it arrives over the WS and is + /// only buffered via `enqueue_output`), so without this the per-terminal + /// notification queues would be parsed here but never fire an OS bubble. + TerminalActivity(Vec), + + /// The implicit local-daemon loopback connection reached a terminal failed + /// state (its own connect/reconnect retries are exhausted against a dead + /// endpoint). The manager stays generic — it only reports; the app decides + /// to re-run daemon discovery/ensure and re-point the connection. Emitted + /// ONLY for `LOCAL_DAEMON_CONNECTION_ID`, never for user-managed remotes. + LocalConnectionFailed, } /// GPUI Entity managing all remote connections. @@ -148,12 +185,23 @@ impl RemoteConnectionManager { }; let mut next_generations = HashMap::with_capacity(terminals.len()); + // Terminals whose generation advanced this wake — i.e. ones + // that actually parsed new output. `Okena` drains their + // notification/bell queues; an OSC alert or bell always + // bumps the generation (via `drain_pending_output`), so this + // set is a superset of the terminals that have something to + // fire. + let mut advanced: Vec = Vec::new(); for (id, terminal) in &terminals { // Parse on the GPUI thread so bell/idle flags are // current even for terminals with no mounted pane. // No-op when the pending buffer is empty. terminal.process_pending_output(); - next_generations.insert(id.clone(), terminal.content_generation()); + let generation = terminal.content_generation(); + if last_generations.get(id) != Some(&generation) { + advanced.push(id.clone()); + } + next_generations.insert(id.clone(), generation); } let changed = activity_changed(&last_generations, &next_generations); last_generations = next_generations; @@ -161,8 +209,9 @@ impl RemoteConnectionManager { if changed { // Emit (not notify): repaint the sidebar's bell/idle // indicators without dragging in the heavy project-sync - // observer that fires on `cx.notify()`. - cx.emit(RemoteManagerEvent::TerminalActivity); + // observer that fires on `cx.notify()`, and let `Okena` + // fire OS notifications for the advanced terminals. + cx.emit(RemoteManagerEvent::TerminalActivity(advanced)); } }); if result.is_err() { @@ -217,6 +266,35 @@ impl RemoteConnectionManager { } } + /// Re-point an existing connection at a (possibly new) local daemon + token, then + /// reconnect. Used after a local-daemon restart: the replacement daemon may + /// bind a DIFFERENT port (the old one can linger in TIME_WAIT), so a plain + /// `reconnect` — which reuses the old config — could dial a dead endpoint. + /// The caller re-reads `remote.json` and passes the full fresh config here. + /// + /// `connect()` clones the config at call time, so replacing it first and + /// reconnecting picks up the new endpoint. The token usually survives a + /// restart (the daemon reloads `remote_tokens.json` at startup), so `token` + /// is normally the existing one; it is refreshed here for completeness. Does + /// nothing if the connection id is unknown. + pub fn redirect_and_reconnect( + &mut self, + connection_id: &str, + next_config: RemoteConnectionConfig, + token: Option, + cx: &mut Context, + ) { + if let Some(conn) = self.connections.get_mut(connection_id) { + *conn.config_mut() = next_config; + if let Some(token) = token { + conn.config_mut().saved_token = Some(token); + } + conn.disconnect(); + conn.connect(); + cx.notify(); + } + } + /// Remove a connection (disconnects first). pub fn remove_connection(&mut self, connection_id: &str, cx: &mut Context) { if let Some(mut conn) = self.connections.remove(connection_id) { @@ -281,6 +359,27 @@ impl RemoteConnectionManager { .collect() } + pub fn connections_with_system_stats( + &self, + ) -> Vec<( + &RemoteConnectionConfig, + &ConnectionStatus, + Option<&StateResponse>, + Option<&ApiSystemStats>, + )> { + self.connections + .values() + .map(|conn| { + ( + conn.config(), + conn.status(), + conn.remote_state(), + conn.system_stats(), + ) + }) + .collect() + } + /// Get the backend for a specific connection. pub fn backend_for(&self, connection_id: &str) -> Option> { self.connections @@ -300,7 +399,10 @@ impl RemoteConnectionManager { pub fn auto_connect_all(&mut self, cx: &mut Context) { let settings = load_settings(); for config in settings.remote_connections { - if config.saved_token.is_some() && !self.connections.contains_key(&config.id) { + if config.saved_token.is_some() + && !self.connections.contains_key(&config.id) + && self.find_by_host_port(&config.host, config.port).is_none() + { let id = config.id.clone(); let mut conn = RemoteConnection::new( config, @@ -332,8 +434,8 @@ impl RemoteConnectionManager { return; } }; - let token = match config.saved_token { - Some(ref t) => t.clone(), + let token = match config.effective_auth_token() { + Some(t) => t, None => { log::error!("send_action: no auth token for connection {}", connection_id); ToastManager::error("No auth token for remote connection".to_string(), cx); @@ -341,14 +443,11 @@ impl RemoteConnectionManager { } }; - let host = config.host.clone(); - let port = config.port; let name = config.name.clone(); let event_tx = self.event_tx.clone(); self.runtime.spawn(async move { - let url = format!("http://{}:{}/v1/actions", host, port); - let client = reqwest::Client::new(); + let (client, url) = http_client_and_url(&config, "/v1/actions"); let result = client .post(&url) .header("Authorization", format!("Bearer {}", token)) @@ -404,8 +503,8 @@ impl RemoteConnectionManager { return; } }; - let token = match config.saved_token { - Some(ref t) => t.clone(), + let token = match config.effective_auth_token() { + Some(t) => t, None => { log::error!( "upload_paste_image: no auth token for connection {}", @@ -416,19 +515,16 @@ impl RemoteConnectionManager { } }; - let host = config.host.clone(); - let port = config.port; let name = config.name.clone(); let event_tx = self.event_tx.clone(); let terminal_id = terminal_id.to_string(); let mime = mime.to_string(); self.runtime.spawn(async move { - let url = format!( - "http://{}:{}/v1/terminals/{}/paste-image", - host, port, terminal_id + let (client, url) = http_client_and_url( + &config, + &format!("/v1/terminals/{terminal_id}/paste-image"), ); - let client = reqwest::Client::new(); let result = client .post(&url) .header("Authorization", format!("Bearer {}", token)) @@ -474,6 +570,8 @@ impl RemoteConnectionManager { ConnectionEvent::StateReceived { .. } => "StateReceived", ConnectionEvent::SubscriptionMappings { .. } => "SubscriptionMappings", ConnectionEvent::GitStatusChanged { .. } => "GitStatusChanged", + ConnectionEvent::SystemStatsChanged { .. } => "SystemStatsChanged", + ConnectionEvent::Toast { .. } => "Toast", ConnectionEvent::ServerWarning { .. } => "ServerWarning", ConnectionEvent::TokenRefreshed { .. } => "TokenRefreshed", }; @@ -507,6 +605,11 @@ impl RemoteConnectionManager { _ => {} } } + // The local daemon backs the whole GUI; when its connection + // dead-ends, ask the app to self-heal (re-run discovery/ensure). + if is_local_connection_terminal_failure(&connection_id, &status) { + cx.emit(RemoteManagerEvent::LocalConnectionFailed); + } cx.notify(); } ConnectionEvent::TokenObtained { @@ -593,6 +696,39 @@ impl RemoteConnectionManager { } cx.notify(); } + ConnectionEvent::SystemStatsChanged { + connection_id, + stats, + } => { + if let Some(conn) = self.connections.get_mut(&connection_id) { + conn.set_system_stats(Some(stats)); + } + } + ConnectionEvent::Toast { + connection_id, + mut toast, + } => { + // A daemon-originated toast: reconstruct the local `Toast` (fresh + // `created` timestamp, ttl from `ttl_ms`) and show it the same way + // local toasts are shown. + // + // Daemon toasts carry daemon-side project/terminal ids in their + // soft-close action ids; prefix them with this connection so the + // GUI's dispatcher routing + prefix-strip-on-dispatch line up. + for action in &mut toast.actions { + for prefix in [SOFT_CLOSE_UNDO_PREFIX, SOFT_CLOSE_KILL_PREFIX] { + if let Some((p, t)) = decode_action(&action.id, prefix) { + action.id = encode_action( + prefix, + &make_prefixed_id(&connection_id, &p), + &make_prefixed_id(&connection_id, &t), + ); + break; + } + } + } + ToastManager::post(Toast::from_api(&toast), cx); + } ConnectionEvent::ServerWarning { connection_id, message, @@ -687,6 +823,20 @@ fn activity_changed(last: &HashMap, current: &HashMap) .any(|(id, generation)| last.get(id) != Some(generation)) } +/// Whether a status change should trigger local-daemon recovery. +/// +/// True only for the implicit local-daemon loopback connection reaching the +/// terminal `Error` state — the two dead-end paths in the client engine +/// (initial connect exhausting its attempts, and the WS reconnect loop +/// exhausting its attempts) both land here. User-managed remotes and every +/// non-terminal state (Connecting/Pairing/Reconnecting/Connected/Disconnected) +/// return false, so a normal reconnect — or `remove_connection`, which sets +/// Disconnected without emitting Error — never provokes recovery. Pure so the +/// decision is testable without a live GPUI/tokio stack. +fn is_local_connection_terminal_failure(connection_id: &str, status: &ConnectionStatus) -> bool { + connection_id == LOCAL_DAEMON_CONNECTION_ID && matches!(status, ConnectionStatus::Error(_)) +} + fn now_unix_timestamp() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -696,7 +846,45 @@ fn now_unix_timestamp() -> i64 { #[cfg(test)] mod tests { - use super::{activity_changed, RemoteConnectionManager}; + use super::{activity_changed, is_local_connection_terminal_failure, RemoteConnectionManager}; + use okena_transport::client::{ConnectionStatus, LOCAL_DAEMON_CONNECTION_ID}; + + #[test] + fn local_error_status_triggers_recovery() { + assert!(is_local_connection_terminal_failure( + LOCAL_DAEMON_CONNECTION_ID, + &ConnectionStatus::Error("dead socket".into()), + )); + } + + #[test] + fn local_non_error_states_do_not_trigger_recovery() { + // Reconnecting/Connected/etc. are transient or healthy — not dead-ends. + // Disconnected is what `remove_connection` (on quit) leaves behind, so + // it must never look like a failure. + for status in [ + ConnectionStatus::Disconnected, + ConnectionStatus::Connecting, + ConnectionStatus::Pairing, + ConnectionStatus::Connected, + ConnectionStatus::Reconnecting { attempt: 3 }, + ] { + assert!(!is_local_connection_terminal_failure( + LOCAL_DAEMON_CONNECTION_ID, + &status + )); + } + } + + #[test] + fn user_remote_error_does_not_trigger_recovery() { + // A user-managed remote failing is surfaced as a toast only; recovery is + // reserved for the daemon the GUI depends on. + assert!(!is_local_connection_terminal_failure( + "some-user-remote", + &ConnectionStatus::Error("gone".into()), + )); + } fn gens(pairs: &[(&str, u64)]) -> HashMap { pairs.iter().map(|(id, g)| (id.to_string(), *g)).collect() @@ -748,6 +936,7 @@ mod tests { token_obtained_at: None, tls: false, pinned_cert_sha256: None, + local_endpoint: None, } } diff --git a/crates/okena-remote-server/Cargo.toml b/crates/okena-remote-server/Cargo.toml index 1c795fb6c..dfddb94e1 100644 --- a/crates/okena-remote-server/Cargo.toml +++ b/crates/okena-remote-server/Cargo.toml @@ -1,16 +1,24 @@ [package] name = "okena-remote-server" -version = "0.1.0" +version.workspace = true edition = "2024" license = "MIT" +[features] +default = ["gpui"] +# Forward gpui to okena-workspace so the GUI consumer keeps the gpui-backed +# items (e.g. GlobalRemoteInfo); with the feature off the crate compiles +# gpui-free so the headless daemon can link it. +gpui = ["dep:gpui", "okena-workspace/gpui"] + [dependencies] okena-core = { path = "../okena-core" } okena-transport = { path = "../okena-transport", features = ["client"] } okena-terminal = { path = "../okena-terminal" } -okena-workspace = { path = "../okena-workspace" } +okena-workspace = { path = "../okena-workspace", default-features = false } +okena-ext-updater = { path = "../okena-ext-updater", default-features = false } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } # Async runtime + channels tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "fs"] } @@ -19,6 +27,7 @@ futures = "0.3" parking_lot = "0.12" # HTTP/WS server stack +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } axum = { version = "0.8", features = ["ws"] } hyper = { version = "1", features = ["server", "http1", "http2"] } hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } @@ -43,15 +52,13 @@ serde_json = "1.0" anyhow = "1.0" log = "0.4" uuid = { version = "1.10", features = ["v4"] } +sysinfo = "0.33" # Embed the built web client into the binary. `interpolate-folder-path` lets # the `#[folder]` attribute expand `$CARGO_MANIFEST_DIR` so we can point at the # repo-root `web/dist` from inside this crate. rust-embed = { version = "8.5", features = ["interpolate-folder-path"] } -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } tempfile = "3" diff --git a/crates/okena-remote-server/src/auth.rs b/crates/okena-remote-server/src/auth.rs index 240973d37..de9a14348 100644 --- a/crates/okena-remote-server/src/auth.rs +++ b/crates/okena-remote-server/src/auth.rs @@ -512,40 +512,50 @@ pub fn secret_path() -> std::path::PathBuf { okena_workspace::persistence::config_dir().join("remote_secret") } -/// Load existing app secret or generate a new one. -fn load_or_create_secret() -> Vec { - let path = secret_path(); +fn generate_secret() -> Vec { + let mut secret = vec![0u8; 32]; + rand::thread_rng().fill(&mut secret[..]); + secret +} - // Try to load existing secret - if let Ok(data) = std::fs::read(&path) { +fn load_or_create_secret_at(path: &std::path::Path) -> std::io::Result> { + if let Ok(data) = std::fs::read(path) { if data.len() == 32 { - return data; + return Ok(data); } log::warn!("Invalid remote_secret file (wrong size), regenerating"); } - // Generate new secret - let mut secret = vec![0u8; 32]; - rand::thread_rng().fill(&mut secret[..]); - - // Persist it + let secret = generate_secret(); if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&path, &secret) { - log::error!("Failed to write remote_secret: {}", e); - } else { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - if let Err(e) = std::fs::set_permissions(&path, perms) { - log::warn!("Failed to set remote_secret permissions: {}", e); - } + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, &secret)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + if let Err(e) = std::fs::set_permissions(path, perms) { + log::warn!("Failed to set remote_secret permissions: {}", e); } } - secret + Ok(secret) +} + +pub(crate) fn load_or_create_secret_in(dir: &std::path::Path) -> std::io::Result> { + load_or_create_secret_at(&dir.join("remote_secret")) +} + +/// Load existing app secret or generate a new one. +fn load_or_create_secret() -> Vec { + match load_or_create_secret_at(&secret_path()) { + Ok(secret) => secret, + Err(e) => { + log::error!("Failed to write remote_secret: {}", e); + generate_secret() + } + } } /// Serializable representation of a token record for disk persistence. diff --git a/crates/okena-remote-server/src/bridge.rs b/crates/okena-remote-server/src/bridge.rs index 97b92d126..339c7c3d2 100644 --- a/crates/okena-remote-server/src/bridge.rs +++ b/crates/okena-remote-server/src/bridge.rs @@ -18,6 +18,18 @@ pub struct BridgeMessage { pub enum RemoteCommand { /// All client-facing actions (workspace + I/O). Action(ActionRequest), + /// Client-facing action carrying the WebSocket connection that produced it. + ActionFromConnection { + action: ActionRequest, + connection_id: String, + }, + /// Resize from a WebSocket client. Only the current owner may resize. + ResizeFromConnection { + terminal_id: String, + cols: u16, + rows: u16, + connection_id: String, + }, /// Get the full workspace state snapshot. GetState, /// Render a terminal's visible content as ANSI bytes (for snapshots). diff --git a/crates/okena-remote-server/src/lib.rs b/crates/okena-remote-server/src/lib.rs index e28a52030..e00821b00 100644 --- a/crates/okena-remote-server/src/lib.rs +++ b/crates/okena-remote-server/src/lib.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod bridge; +pub mod local; pub mod pty_broadcaster; pub mod routes; pub mod serve; @@ -80,8 +81,10 @@ impl RemoteInfo { } /// GPUI global wrapper for RemoteInfo. +#[cfg(feature = "gpui")] #[derive(Clone)] pub struct GlobalRemoteInfo(pub RemoteInfo); +#[cfg(feature = "gpui")] impl gpui::Global for GlobalRemoteInfo {} diff --git a/crates/okena-remote-server/src/local.rs b/crates/okena-remote-server/src/local.rs new file mode 100644 index 000000000..351f870b3 --- /dev/null +++ b/crates/okena-remote-server/src/local.rs @@ -0,0 +1,1272 @@ +//! Client-side helpers for discovering and authenticating to a *local* Okena +//! daemon over loopback. +//! +//! Local-trust model: any process that can read the `0600` `remote_secret` in +//! the user's config dir already shares the user's filesystem trust boundary, so +//! it is authorized to mint a bearer token directly (write its HMAC into +//! `remote_tokens.json`) — no interactive pairing-code dance, which exists only +//! to bootstrap trust with *off-host* clients. A same-host UI or CLI uses this +//! to attach to the daemon transparently. +//! +//! The core functions are parameterized by config dir (`*_in`) so they unit-test +//! against a temp directory; thin wrappers bind them to +//! [`okena_workspace::persistence::config_dir`]. + +use crate::auth::{self, PersistedToken}; +use base64::Engine as _; +pub use okena_core::process::is_process_alive; +use okena_transport::client::LocalEndpoint; +use okena_workspace::persistence::config_dir; +use rand::Rng as _; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::net::{IpAddr, Ipv4Addr}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// Default loopback host for local clients. Newer `remote.json` files can +/// override this with `local_host` when the daemon only has an IPv6 local TCP +/// endpoint. +pub const LOCAL_HOST: &str = "127.0.0.1"; + +/// A local daemon discovered from `remote.json`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalDaemon { + pub port: u16, + /// Loopback host to dial for TCP clients. + pub host: String, + /// Daemon process id (0 if the file omitted it). + pub pid: u32, + /// Whether the daemon negotiates TLS (dual-stack) on its port. + pub tls: bool, + pub local_endpoint: Option, +} + +impl LocalDaemon { + /// Loopback host to dial. + pub fn host(&self) -> &str { + &self.host + } +} + +/// Parse `remote.json` from an explicit config dir. Returns `None` when the file +/// is absent, unreadable, malformed, or missing a port. +pub fn discover_in(dir: &Path) -> Option { + let data = std::fs::read_to_string(dir.join("remote.json")).ok()?; + let v: serde_json::Value = serde_json::from_str(&data).ok()?; + let port = u16::try_from(v.get("port")?.as_u64()?).ok()?; + let host = v + .get("local_host") + .and_then(|h| h.as_str()) + .filter(|h| !h.is_empty()) + .unwrap_or(LOCAL_HOST) + .to_string(); + let pid = v.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) as u32; + let tls = v.get("tls").and_then(|t| t.as_bool()).unwrap_or(false); + let local_endpoint = v + .get("local_endpoint") + .and_then(|value| serde_json::from_value::(value.clone()).ok()); + Some(LocalDaemon { port, host, pid, tls, local_endpoint }) +} + +pub fn default_local_endpoint() -> Option { + #[cfg(unix)] + { + Some(LocalEndpoint::UnixSocket { + path: default_unix_socket_path(&config_dir()).to_string_lossy().into_owned(), + }) + } + #[cfg(not(unix))] + { + None + } +} + +/// Resolve daemon TCP bind addresses from an explicit CLI override or persisted +/// remote settings. Same-host access stays available unless the user explicitly +/// requested an unspecified bind such as `0.0.0.0`. +pub fn resolve_daemon_listen_addrs( + listen_override: Option, + settings: &okena_workspace::persistence::AppSettings, +) -> Vec { + let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST); + + if let Some(addr) = listen_override { + return bind_addrs_with_loopback(addr); + } + + if !settings.remote_server_enabled { + return vec![loopback]; + } + + let configured = settings.remote_listen_address.trim(); + match configured.parse::() { + Ok(addr) => bind_addrs_with_loopback(addr), + Err(_) => { + if !configured.is_empty() { + log::warn!( + "Invalid remote_listen_address in settings ({configured:?}); falling back to 127.0.0.1" + ); + } + vec![loopback] + } + } +} + +fn bind_addrs_with_loopback(addr: IpAddr) -> Vec { + let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST); + + if addr == loopback || addr.is_unspecified() { + vec![addr] + } else { + vec![loopback, addr] + } +} + +#[cfg(unix)] +pub fn default_unix_socket_path(dir: &Path) -> PathBuf { + let key = profile_key(dir); + runtime_dir().join("okena").join(format!("{key}.sock")) +} + +#[cfg(unix)] +fn profile_key(dir: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(dir.to_string_lossy().as_bytes()); + let hash = hasher.finalize(); + hash.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +#[cfg(unix)] +fn runtime_dir() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from)) + .unwrap_or_else(std::env::temp_dir) +} + +/// Parse `remote.json` from the user's config dir. +pub fn discover() -> Option { + discover_in(&config_dir()) +} + +/// Discover a live daemon from an explicit config dir (testable core). Confirms +/// the recorded process is actually alive — guards against a stale `remote.json` +/// left by a crashed daemon. A recorded pid of 0 (unknown) is "assume alive". +pub fn running_daemon_in(dir: &Path) -> Option { + let daemon = discover_in(dir)?; + if daemon.pid == 0 || is_process_alive(daemon.pid) { + Some(daemon) + } else { + None + } +} + +/// Discover a local daemon and confirm its process is actually alive — guards +/// against a stale `remote.json` left by a crashed daemon. A recorded pid of 0 +/// (unknown) is treated as "assume alive". +pub fn running_daemon() -> Option { + running_daemon_in(&config_dir()) +} + +/// A freshly minted local bearer token plus the metadata a caller needs to +/// persist its own record of it (e.g. the CLI's `cli.json`). +#[derive(Debug, Clone)] +pub struct MintedToken { + /// Plaintext bearer token — send as `Authorization: Bearer `. + pub token: String, + /// Server-side record id (for later revocation). + pub token_id: String, + /// Unix seconds the token was created. + pub created_at: u64, +} + +/// Mint a local bearer token in an explicit config dir (testable core). +/// +/// Loads or creates `remote_secret`, generates a random token in the same format +/// as `AuthStore::try_pair`, and appends its HMAC to `remote_tokens.json` +/// (`0600`). +/// An already-running daemon must be told to reload (`POST /v1/auth/reload`, +/// loopback-only); a freshly spawned daemon picks it up at startup. +pub fn mint_local_token_in(dir: &Path) -> Result { + let secret = auth::load_or_create_secret_in(dir) + .map_err(|e| format!("Failed to initialize remote_secret: {e}"))?; + + // Random 32-byte token, base64url (no pad) — matches AuthStore::try_pair. + let mut token_bytes = [0u8; 32]; + rand::thread_rng().fill(&mut token_bytes); + let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token_bytes); + + let token_hmac = auth::compute_hmac(&secret, token.as_bytes()); + let token_hmac_b64 = base64::engine::general_purpose::STANDARD.encode(&token_hmac); + + let tokens_path = dir.join("remote_tokens.json"); + let mut persisted: Vec = std::fs::read_to_string(&tokens_path) + .ok() + .and_then(|data| serde_json::from_str(&data).ok()) + .unwrap_or_default(); + + let token_id = uuid::Uuid::new_v4().to_string(); + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + persisted.push(PersistedToken { + id: token_id.clone(), + token_hmac: token_hmac_b64, + created_at, + }); + + let json = serde_json::to_string_pretty(&persisted) + .map_err(|e| format!("Failed to serialize tokens: {e}"))?; + if let Some(parent) = tokens_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&tokens_path, json.as_bytes()) + .map_err(|e| format!("Failed to write remote_tokens.json: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tokens_path, std::fs::Permissions::from_mode(0o600)); + } + + Ok(MintedToken { + token, + token_id, + created_at, + }) +} + +/// Mint a local bearer token in the user's config dir. +pub fn mint_local_token() -> Result { + mint_local_token_in(&config_dir()) +} + +/// Resolve the dedicated `okena-daemon` binary as a sibling of the current +/// executable. `None` if it can't be located (caller falls back to +/// `current_exe --headless`). Honors the platform executable suffix. +fn daemon_binary_path() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let name = if cfg!(windows) { "okena-daemon.exe" } else { "okena-daemon" }; + let path = dir.join(name); + path.exists().then_some(path) +} + +/// Spawn a local daemon and writes `remote.json`. +/// +/// Prefers the dedicated, GPUI-free `okena-daemon` binary (a sibling of the +/// current exe — cargo and shipped installs place it alongside `okena`) as the +/// lighter variant. The dedicated daemon reads settings itself: same-host access +/// is always local (Unix socket + loopback), and remote bind addresses are added +/// only when the remote server setting is enabled. When that binary isn't +/// present, the UI binary runs its own `current_exe --headless` daemon — the +/// standard single-binary path. Either way the child inherits `OKENA_PROFILE` +/// from this process, so it uses the same config dir. +/// +/// The caller owns the returned [`std::process::Child`]. In the UI-owned +/// lifecycle the desktop kills it when the last window closes; mint the token +/// *before* spawning so the fresh daemon loads it at startup (no reload needed). +pub fn spawn_daemon() -> std::io::Result { + match daemon_binary_path() { + Some(daemon) => std::process::Command::new(daemon).spawn(), + None => { + let exe = std::env::current_exe()?; + log::info!( + "No sibling okena-daemon binary; running the built-in `okena --headless` \ + daemon (the single-binary path). The dedicated GPUI-free okena-daemon is \ + the lighter variant and is used when present." + ); + std::process::Command::new(exe).arg("--headless").spawn() + } + } +} + +/// Poll an explicit config dir until a live daemon is discoverable or `timeout` +/// elapses (testable core). Polls every 50ms. +pub fn wait_until_ready_in(dir: &Path, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(daemon) = discover_in(dir) + && (daemon.pid == 0 || is_process_alive(daemon.pid)) + { + return Some(daemon); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Poll the user's config dir until a live daemon appears or `timeout` elapses. +/// Used after [`spawn_daemon`] to wait for the daemon to bind + advertise. +pub fn wait_until_ready(timeout: Duration) -> Option { + wait_until_ready_in(&config_dir(), timeout) +} + +fn wait_until_reachable_in(dir: &Path, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(daemon) = discover_in(dir) + && (daemon.pid == 0 || is_process_alive(daemon.pid)) + && daemon_responds_to_health(&daemon, Duration::from_millis(300)) + { + return Some(daemon); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Probe a discovered local daemon's `/health` over its advertised transport +/// (Unix socket when present, else loopback TCP). Exposed so callers such as the +/// GUI's self-heal loop can distinguish a live-but-unreachable daemon from a +/// dead one without duplicating the transport-selection logic. +pub fn daemon_endpoint_responds(daemon: &LocalDaemon, timeout: Duration) -> bool { + daemon_responds_to_health(daemon, timeout) +} + +fn daemon_responds_to_health(daemon: &LocalDaemon, timeout: Duration) -> bool { + let (client, url) = blocking_client_and_url( + daemon.host(), + daemon.port, + "/health", + daemon.local_endpoint.as_ref(), + ); + matches!( + client.get(&url).timeout(timeout).send(), + Ok(resp) if resp.status().is_success() + ) +} + +/// Result of ensuring a local daemon is available. +pub struct EnsuredDaemon { + pub daemon: LocalDaemon, + /// Plaintext bearer token to authenticate the client connection. `None` + /// means the client connects over a trusted local transport. + pub token: Option, + /// `Some` ONLY when we spawned the daemon in this call. UI-owned lifecycle: + /// the caller kills only what it spawned; never kill a daemon we merely attached to. + pub spawned: Option, +} + +/// Best-effort: tell an already-running daemon to reload its token file. +/// A freshly spawned daemon reads tokens at startup, so this is only needed on the +/// attach path. Failures are ignored — the worst case is the caller's connection +/// attempt fails and retries. +pub fn notify_auth_reload(daemon: &LocalDaemon) { + let (client, url) = blocking_client_and_url( + daemon.host(), + daemon.port, + "/v1/auth/reload", + daemon.local_endpoint.as_ref(), + ); + let _ = client + .post(&url) + .timeout(Duration::from_secs(5)) + .send(); +} + +/// Outcome of asking the local daemon to shut down (`POST /v1/shutdown`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShutdownOutcome { + /// The daemon accepted and is tearing itself down. `false` means it refused + /// because other clients are still connected — the caller must NOT kill it. + pub accepted: bool, + /// Live client connections the daemon counted (excluding the caller, which + /// disconnects its own loopback WS before asking). + pub active_clients: u64, +} + +/// How many times to (re)ask for shutdown, and the pause between tries. The +/// caller has just closed its own WS; a bounded retry lets the daemon notice the +/// close and decrement its live count before we conclude "another client is here". +const SHUTDOWN_MAX_ATTEMPTS: u32 = 4; +const SHUTDOWN_RETRY_DELAY: Duration = Duration::from_millis(150); + +/// Ask the local daemon to shut down (`POST /v1/shutdown`). Returns whether it +/// accepted plus the live-client count it saw. +/// +/// The daemon REFUSES while any *other* client is connected, so this is safe to +/// call on quit even if a second GUI attached during a fast restart — it will +/// simply report `accepted: false` and stay up. +/// +/// Self-exclusion (option **a**): the caller must disconnect its OWN loopback WS +/// before calling; the daemon just counts live WS connections. The bounded retry +/// here absorbs the lag between the caller's socket close and the daemon +/// deregistering it, so "only us" reliably reads as zero. Option (b) — passing +/// the caller's own connection id to exclude — isn't available: the WS protocol +/// never tells a client its server-assigned id (see okena-core `WsOutbound`), so +/// (a) avoids inventing a new protocol message. +/// +/// A transport error on the FIRST attempt is a hard error (the daemon is +/// unreachable — the caller falls back to killing it); a transport error on a +/// later attempt, after we already got a "refused" answer, is swallowed and the +/// last answer returned, so a flaky retry can never escalate into killing a +/// daemon that another client is using. +pub fn request_local_shutdown(daemon: &LocalDaemon) -> Result { + let (client, url) = blocking_client_and_url( + daemon.host(), + daemon.port, + "/v1/shutdown", + daemon.local_endpoint.as_ref(), + ); + + let mut outcome = ShutdownOutcome { + accepted: false, + active_clients: 0, + }; + for attempt in 0..SHUTDOWN_MAX_ATTEMPTS { + if attempt > 0 { + std::thread::sleep(SHUTDOWN_RETRY_DELAY); + } + match client.post(&url).timeout(Duration::from_secs(5)).send() { + Ok(resp) if resp.status().is_success() => { + let body = resp + .json::() + .map_err(|e| format!("failed to parse shutdown response: {e}"))?; + outcome = ShutdownOutcome { + accepted: body.shutting_down, + active_clients: body.active_clients, + }; + if outcome.accepted { + break; + } + } + Ok(resp) => return Err(format!("shutdown endpoint returned {}", resp.status())), + Err(_) if attempt > 0 => { + // Already had a (refused) answer; don't let a flaky retry turn + // into a kill. Report what we last saw. + break; + } + Err(e) => return Err(format!("shutdown request failed: {e}")), + } + } + Ok(outcome) +} + +/// Ask the local daemon at `host:port` to restart itself (`POST /v1/restart`), +/// then block until the replacement daemon advertises a live endpoint. +/// +/// Pure blocking I/O — call it off any UI/async reactor thread. Returns the +/// discovered [`LocalDaemon`] (with its possibly-NEW port: the old one can linger +/// in TIME_WAIT, so the replacement may bind a different one) or an error string. +/// +/// Sequence: +/// 1. Snapshot the outgoing daemon's pid from `remote.json` (so we can wait for +/// its exit — that's what frees the lock + port for the replacement). +/// 2. POST `/v1/restart`. A non-success status is a hard error; a transport +/// error is treated as "it's already going down" (it acks then exits, so the +/// connection can drop mid-response) and we proceed. +/// 3. Wait for the old pid to die (bounded), then poll `remote.json` until a LIVE +/// daemon advertises — [`wait_until_ready`] rejects a stale file whose pid is +/// dead, so it returns only once the replacement has written its own pid+port. +pub fn restart_local_daemon( + host: &str, + port: u16, + local_endpoint: Option<&LocalEndpoint>, +) -> Result { + let old_pid = running_daemon().map(|d| d.pid).unwrap_or(0); + + let (client, url) = blocking_client_and_url(host, port, "/v1/restart", local_endpoint); + match client + .post(&url) + .timeout(Duration::from_secs(10)) + .send() + { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => return Err(format!("restart endpoint returned {}", resp.status())), + // Transport error: the daemon may already be tearing down. The discovery + // poll below is the real readiness signal, so log and keep going. + Err(e) => log::warn!("restart POST error (daemon likely exiting): {e}"), + } + + if old_pid != 0 && !wait_for_pid_exit(old_pid, Duration::from_secs(10)) { + return Err(format!( + "outgoing daemon pid {old_pid} did not exit before timeout" + )); + } + + wait_until_ready_replacing(old_pid, Duration::from_secs(15)) + .ok_or_else(|| "daemon did not come back in time".to_string()) +} + +/// Poll until the replacement daemon appears. Do not accept the stale +/// `remote.json` that the outgoing process leaves behind while it is exiting. +fn wait_until_ready_replacing(old_pid: u32, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(daemon) = discover() + && (old_pid == 0 || daemon.pid != old_pid) + && (daemon.pid == 0 || is_process_alive(daemon.pid)) + && daemon_responds_to_health(&daemon, Duration::from_millis(300)) + { + return Some(daemon); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// CLI flag the restart relauncher passes to the replacement daemon so it waits +/// for the outgoing daemon's process to exit (releasing its port + instance lock) +/// before it tries to bind / acquire the lock. See [`spawn_replacement_daemon`] +/// and [`wait_for_pid_exit`]. +pub const AWAIT_PID_FLAG: &str = "--await-pid"; + +/// Block until the process `pid` is no longer alive, or `timeout` elapses. Polls +/// every 50ms. A `pid` of 0 (unknown) returns immediately. +/// +/// Used by the replacement daemon spawned during a self-restart: the outgoing +/// daemon spawns it and then exits, so the replacement must wait for that exit +/// before [`acquire_instance_lock`](okena_workspace::persistence::acquire_instance_lock) +/// (which fails fast against a live PID) and before its port scan (the old port +/// may linger in TIME_WAIT, but binding succeeds once the old socket is gone). +pub fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool { + if pid == 0 { + return true; + } + let deadline = Instant::now() + timeout; + loop { + if !is_process_alive(pid) { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Parse the `--await-pid ` flag the restart relauncher injects into the +/// replacement daemon's args. Returns `None` when the flag is absent or its +/// value is missing/unparseable (the caller then just boots normally). +pub fn parse_await_pid(args: I) -> Option +where + I: IntoIterator, + S: AsRef, +{ + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + if arg.as_ref() == AWAIT_PID_FLAG { + return iter.next().and_then(|v| v.as_ref().parse::().ok()); + } + } + None +} + +/// Spawn a fresh daemon process to *replace* the current one, then leave it to +/// the caller to exit the current process. +/// +/// Re-launches the current executable with the same args (so a daemon launched +/// as `okena-daemon --listen 127.0.0.1` or the single-binary `okena --headless +/// --listen 127.0.0.1` re-launches identically), with `OKENA_PROFILE` inherited +/// from the environment so the replacement lands in the same config dir. Appends +/// `--await-pid ` so the replacement waits for THIS process to exit +/// — releasing its port + instance lock — before binding / locking. Any existing +/// `--await-pid` pair is stripped first so it isn't passed twice. +/// +/// Mirrors the updater's restart pattern (`installer::restart_app`): spawn the +/// successor, then quit. The child is detached (its handle is dropped); on Unix +/// it survives as an orphan, on Windows as an independent process. +pub fn spawn_replacement_daemon() -> std::io::Result { + let exe = std::env::current_exe()?; + let mut args: Vec = std::env::args().skip(1).collect(); + strip_await_pid_args(&mut args); + let my_pid = std::process::id(); + std::process::Command::new(exe) + .args(&args) + .arg(AWAIT_PID_FLAG) + .arg(my_pid.to_string()) + .spawn() +} + +/// Remove any `--await-pid ` pair from `args` so a chain of restarts never +/// accumulates duplicate flags (the latest restart re-appends the current pid). +fn strip_await_pid_args(args: &mut Vec) { + let mut i = 0; + while i < args.len() { + if args[i] == AWAIT_PID_FLAG { + args.remove(i); + if i < args.len() { + args.remove(i); + } + } else { + i += 1; + } + } +} + +fn daemon_needs_bearer_token(daemon: &LocalDaemon) -> bool { + !matches!(daemon.local_endpoint, Some(LocalEndpoint::UnixSocket { .. })) +} + +fn auth_token_for_daemon(dir: &Path, daemon: &LocalDaemon) -> Result, String> { + if !daemon_needs_bearer_token(daemon) { + return Ok(None); + } + let token = mint_local_token_in(dir)?.token; + notify_auth_reload(daemon); + Ok(Some(token)) +} + +/// Ensure a local daemon is reachable from an explicit config dir (testable +/// core), returning an auth token only when the transport needs bearer auth. +/// +/// The patience is split by path: `attach_timeout` bounds how long an +/// already-advertised daemon may take to answer `/health`; `spawn_timeout` +/// budgets a child we just spawned to boot (workspace load happens before the +/// server binds, so boot can take several seconds under load). Keeping them +/// separate lets a caller give up on a wedged daemon quickly WITHOUT ever +/// SIGKILLing its own mid-boot child on that same short deadline. +/// +/// ATTACH path — a live daemon already runs: wait until it answers `/health`, +/// then mint/reload a token only for TCP fallback. SPAWN path — none runs: +/// spawn it, wait for the advertised transport, then mint/reload only when that +/// transport needs bearer auth. We own the spawned [`std::process::Child`] +/// (`spawned = Some`), killing it on timeout or token setup failure. +pub fn ensure_local_daemon_in( + dir: &Path, + attach_timeout: Duration, + spawn_timeout: Duration, +) -> Result { + if running_daemon_in(dir).is_some() { + // Attach: a live pid is not enough. The daemon may still be binding + // after a restart; do not hand an unreachable endpoint to the UI. + if let Some(daemon) = wait_until_reachable_in(dir, attach_timeout) { + let token = auth_token_for_daemon(dir, &daemon)?; + return Ok(EnsuredDaemon { + daemon, + token, + spawned: None, + }); + } + + if let Some(daemon) = discover_in(dir) + && (daemon.pid == 0 || is_process_alive(daemon.pid)) + { + return Err(format!( + "Local daemon pid {} did not respond to /health in time.", + daemon.pid + )); + } + + // It died while we were waiting; fall through and spawn a fresh daemon. + log::info!("Existing local daemon disappeared before it became reachable"); + } + + let mut child = spawn_daemon().map_err(|e| format!("Failed to spawn daemon: {e}"))?; + match wait_until_reachable_in(dir, spawn_timeout) { + Some(daemon) => match auth_token_for_daemon(dir, &daemon) { + Ok(token) => Ok(EnsuredDaemon { + daemon, + token, + spawned: Some(child), + }), + Err(e) => { + let _ = child.kill(); + Err(e) + } + }, + None => { + let _ = child.kill(); + Err("Daemon did not become ready in time.".into()) + } + } +} + +/// Ensure a local daemon is reachable from the user's config dir, with caller- +/// chosen patience split by path (see [`ensure_local_daemon_in`]). The self-heal +/// recovery loop uses a short attach patience — so a wedged daemon is detected +/// (and escalated on) sooner — but the full spawn budget, so it never kills its +/// own freshly spawned child that is merely slow to boot. +pub fn ensure_local_daemon_with_timeouts( + attach_timeout: Duration, + spawn_timeout: Duration, +) -> Result { + ensure_local_daemon_in(&config_dir(), attach_timeout, spawn_timeout) +} + +/// Ensure a local daemon is reachable from the user's config dir. +pub fn ensure_local_daemon() -> Result { + ensure_local_daemon_with_timeouts(Duration::from_secs(30), Duration::from_secs(30)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalPairCode { + pub code: String, + pub expires_in: u64, +} + +#[derive(Deserialize)] +struct PairCodeResponse { + code: String, + expires_in: u64, +} + +pub fn request_pair_code( + host: &str, + port: u16, + token: &str, + local_endpoint: Option<&LocalEndpoint>, +) -> Result { + let (client, url) = blocking_client_and_url(host, port, "/v1/pair-code", local_endpoint); + let resp = client + .post(&url) + .bearer_auth(token) + .timeout(Duration::from_secs(5)) + .send() + .map_err(|e| format!("Failed to request pairing code: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(format!("Pairing code request returned {status}: {body}")); + } + + let body = resp + .json::() + .map_err(|e| format!("Failed to parse pairing code response: {e}"))?; + Ok(LocalPairCode { + code: body.code, + expires_in: body.expires_in, + }) +} + +pub fn invalidate_pair_code( + host: &str, + port: u16, + token: &str, + local_endpoint: Option<&LocalEndpoint>, +) { + let (client, url) = blocking_client_and_url(host, port, "/v1/pair-code", local_endpoint); + let _ = client + .delete(&url) + .bearer_auth(token) + .timeout(Duration::from_secs(5)) + .send(); +} + +fn blocking_client_and_url( + host: &str, + port: u16, + path: &str, + local_endpoint: Option<&LocalEndpoint>, +) -> (reqwest::blocking::Client, String) { + #[cfg(unix)] + if let Some(LocalEndpoint::UnixSocket { path: socket_path }) = local_endpoint { + let client = reqwest::blocking::Client::builder() + .unix_socket(socket_path.as_str()) + .build() + .unwrap_or_else(|e| { + log::error!("Failed to build Unix socket HTTP client for {socket_path}: {e}"); + reqwest::blocking::Client::new() + }); + return (client, format!("http://okena.local{path}")); + } + + (reqwest::blocking::Client::new(), format!("http://{host}:{port}{path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "okena-local-test-{:?}-{}", + std::thread::current().id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn spawn_health_server() -> u16 { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind((LOCAL_HOST, 0)).expect("bind health server"); + let port = listener.local_addr().expect("health server addr").port(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { + break; + }; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let body = r#"{"status":"ok","version":"test","uptime_secs":0}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + port + } + + /// Stub server that answers every request with a fixed `/v1/shutdown` JSON + /// body. Used to exercise `request_local_shutdown`'s accept/refuse handling + /// without a live daemon (mirrors `spawn_health_server`). + fn spawn_shutdown_server(shutting_down: bool, active_clients: u64) -> u16 { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind((LOCAL_HOST, 0)).expect("bind shutdown server"); + let port = listener.local_addr().expect("shutdown server addr").port(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { + break; + }; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let body = format!( + r#"{{"shutting_down":{shutting_down},"active_clients":{active_clients}}}"# + ); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + port + } + + fn tcp_daemon(port: u16) -> LocalDaemon { + LocalDaemon { + port, + host: LOCAL_HOST.to_string(), + pid: std::process::id(), + tls: false, + local_endpoint: None, + } + } + + fn remote_settings( + enabled: bool, + listen_address: &str, + ) -> okena_workspace::persistence::AppSettings { + let mut settings = okena_workspace::persistence::AppSettings::default(); + settings.remote_server_enabled = enabled; + settings.remote_listen_address = listen_address.to_string(); + settings + } + + #[test] + fn resolve_daemon_listen_addrs_defaults_to_loopback_when_remote_disabled() { + let settings = remote_settings(false, "0.0.0.0"); + assert_eq!( + resolve_daemon_listen_addrs(None, &settings), + vec![IpAddr::V4(Ipv4Addr::LOCALHOST)] + ); + } + + #[test] + fn resolve_daemon_listen_addrs_adds_loopback_for_remote_interface() { + let remote_addr = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)); + let settings = remote_settings(true, "192.0.2.10"); + + assert_eq!( + resolve_daemon_listen_addrs(None, &settings), + vec![IpAddr::V4(Ipv4Addr::LOCALHOST), remote_addr] + ); + } + + #[test] + fn resolve_daemon_listen_addrs_honors_unspecified_override() { + let bind_all = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + let settings = remote_settings(false, "127.0.0.1"); + + assert_eq!(resolve_daemon_listen_addrs(Some(bind_all), &settings), vec![bind_all]); + } + + #[test] + fn resolve_daemon_listen_addrs_falls_back_on_invalid_config() { + let settings = remote_settings(true, "not an ip"); + + assert_eq!( + resolve_daemon_listen_addrs(None, &settings), + vec![IpAddr::V4(Ipv4Addr::LOCALHOST)] + ); + } + + #[test] + fn request_shutdown_accepts_when_no_clients() { + let port = spawn_shutdown_server(true, 0); + let outcome = request_local_shutdown(&tcp_daemon(port)).expect("request should succeed"); + assert!(outcome.accepted); + assert_eq!(outcome.active_clients, 0); + } + + #[test] + fn request_shutdown_refuses_when_clients_connected() { + // The daemon reports a live client on every retry, so the bounded loop + // gives up and reports the refusal instead of ever accepting. + let port = spawn_shutdown_server(false, 2); + let outcome = request_local_shutdown(&tcp_daemon(port)).expect("request should succeed"); + assert!(!outcome.accepted, "must not accept while a client is connected"); + assert_eq!(outcome.active_clients, 2); + } + + #[test] + fn request_shutdown_errors_when_unreachable() { + // Port 9 (discard) refuses fast — the caller treats an unreachable daemon + // as an error and falls back to its own kill path. + let err = request_local_shutdown(&tcp_daemon(9)).expect_err("unreachable must error"); + assert!(err.contains("shutdown request failed"), "unexpected error: {err}"); + } + + #[test] + fn discover_parses_port_pid_tls() { + let dir = temp_dir(); + std::fs::write( + dir.join("remote.json"), + r#"{"port": 19123, "pid": 4242, "tls": true}"#, + ) + .unwrap(); + let d = discover_in(&dir).expect("should parse"); + assert_eq!( + d, + LocalDaemon { + port: 19123, + host: LOCAL_HOST.to_string(), + pid: 4242, + tls: true, + local_endpoint: None, + } + ); + assert_eq!(d.host(), "127.0.0.1"); + } + + #[test] + fn discover_parses_local_host() { + let dir = temp_dir(); + std::fs::write( + dir.join("remote.json"), + r#"{"port": 19123, "local_host": "::1", "pid": 4242, "tls": false}"#, + ) + .unwrap(); + let d = discover_in(&dir).expect("should parse"); + assert_eq!(d.host(), "::1"); + } + + #[test] + fn discover_defaults_missing_pid_and_tls() { + let dir = temp_dir(); + std::fs::write(dir.join("remote.json"), r#"{"port": 19100}"#).unwrap(); + let d = discover_in(&dir).expect("should parse"); + assert_eq!( + d, + LocalDaemon { + port: 19100, + host: LOCAL_HOST.to_string(), + pid: 0, + tls: false, + local_endpoint: None, + } + ); + } + + #[test] + fn discover_parses_local_endpoint() { + let dir = temp_dir(); + std::fs::write( + dir.join("remote.json"), + r#"{"port": 19123, "pid": 4242, "tls": false, "local_endpoint": {"kind": "unix_socket", "path": "/tmp/okena.sock"}}"#, + ) + .unwrap(); + let d = discover_in(&dir).expect("should parse"); + assert_eq!( + d.local_endpoint, + Some(LocalEndpoint::UnixSocket { + path: "/tmp/okena.sock".to_string(), + }) + ); + } + + #[test] + fn discover_none_when_absent_or_malformed() { + let dir = temp_dir(); + assert_eq!(discover_in(&dir), None); + std::fs::write(dir.join("remote.json"), b"{not json").unwrap(); + assert_eq!(discover_in(&dir), None); + std::fs::write(dir.join("remote.json"), r#"{"pid": 1}"#).unwrap(); + assert_eq!(discover_in(&dir), None, "missing port is a miss"); + } + + #[test] + fn mint_writes_validatable_token() { + let dir = temp_dir(); + let secret = vec![7u8; 32]; + std::fs::write(dir.join("remote_secret"), &secret).unwrap(); + + let minted = mint_local_token_in(&dir).expect("mint should succeed"); + assert!(!minted.token.is_empty()); + assert!(!minted.token_id.is_empty()); + + // The persisted HMAC must match HMAC(secret, token) — i.e. a server with + // this secret would validate the token. + let raw = std::fs::read_to_string(dir.join("remote_tokens.json")).unwrap(); + let persisted: Vec = serde_json::from_str(&raw).unwrap(); + assert_eq!(persisted.len(), 1); + let expected = base64::engine::general_purpose::STANDARD + .encode(auth::compute_hmac(&secret, minted.token.as_bytes())); + assert_eq!(persisted[0].token_hmac, expected); + assert_eq!(persisted[0].id, minted.token_id); + } + + #[test] + fn ensure_attaches_to_running_daemon() { + let dir = temp_dir(); + let port = spawn_health_server(); + let secret = vec![3u8; 32]; + std::fs::write(dir.join("remote_secret"), &secret).unwrap(); + // pid = this process so the liveness check treats the daemon as alive. + std::fs::write( + dir.join("remote.json"), + format!(r#"{{"port": {port}, "pid": {}, "tls": false}}"#, std::process::id()), + ) + .unwrap(); + + let ensured = + ensure_local_daemon_in(&dir, Duration::from_millis(200), Duration::from_millis(200)) + .expect("attach should succeed"); + assert!(ensured.spawned.is_none(), "must not spawn when one is running"); + assert_eq!(ensured.daemon.port, port); + + // The returned token must validate against the secret — its HMAC has to + // appear in remote_tokens.json (mirrors mint_writes_validatable_token). + let token = ensured.token.as_ref().expect("TCP transport needs a token"); + let raw = std::fs::read_to_string(dir.join("remote_tokens.json")).unwrap(); + let persisted: Vec = serde_json::from_str(&raw).unwrap(); + let expected = base64::engine::general_purpose::STANDARD + .encode(auth::compute_hmac(&secret, token.as_bytes())); + assert!( + persisted.iter().any(|t| t.token_hmac == expected), + "minted token's HMAC must be persisted" + ); + } + + #[test] + fn unix_socket_daemon_does_not_need_token() { + let dir = temp_dir(); + let daemon = LocalDaemon { + port: 19100, + host: LOCAL_HOST.to_string(), + pid: std::process::id(), + tls: false, + local_endpoint: Some(LocalEndpoint::UnixSocket { + path: "/tmp/okena-test.sock".to_string(), + }), + }; + + let token = auth_token_for_daemon(&dir, &daemon).expect("trusted local transport"); + assert!(token.is_none()); + assert!(!dir.join("remote_secret").exists()); + assert!(!dir.join("remote_tokens.json").exists()); + } + + #[test] + fn ensure_rejects_unreachable_live_daemon() { + let dir = temp_dir(); + std::fs::write(dir.join("remote_secret"), vec![3u8; 32]).unwrap(); + std::fs::write( + dir.join("remote.json"), + format!(r#"{{"port": 9, "pid": {}, "tls": false}}"#, std::process::id()), + ) + .unwrap(); + + // Attach patience is short but the spawn budget is long: the error must + // arrive on the ATTACH deadline, proving the branches use their own + // timeouts (a live-but-unreachable daemon never enters the spawn path). + let start = Instant::now(); + let err = match ensure_local_daemon_in( + &dir, + Duration::from_millis(120), + Duration::from_secs(30), + ) { + Ok(_) => panic!("live but unreachable daemon must not be accepted"), + Err(err) => err, + }; + assert!( + err.contains("did not respond to /health"), + "unexpected error: {err}" + ); + assert!( + start.elapsed() < Duration::from_secs(5), + "attach branch must give up on attach_timeout, not spawn_timeout" + ); + } + + #[test] + fn ensure_attach_succeeds_within_attach_timeout_even_with_short_spawn_budget() { + let dir = temp_dir(); + let port = spawn_health_server(); + std::fs::write(dir.join("remote_secret"), vec![3u8; 32]).unwrap(); + std::fs::write( + dir.join("remote.json"), + format!(r#"{{"port": {port}, "pid": {}, "tls": false}}"#, std::process::id()), + ) + .unwrap(); + + // A healthy advertised daemon attaches under attach_timeout regardless + // of the spawn budget (which only applies to a child we spawn). + let ensured = ensure_local_daemon_in( + &dir, + Duration::from_millis(500), + Duration::from_millis(1), + ) + .expect("attach should succeed"); + assert!(ensured.spawned.is_none()); + assert_eq!(ensured.daemon.port, port); + } + + #[test] + fn mint_appends_to_existing_tokens() { + let dir = temp_dir(); + std::fs::write(dir.join("remote_secret"), vec![9u8; 32]).unwrap(); + let first = mint_local_token_in(&dir).expect("first mint"); + let second = mint_local_token_in(&dir).expect("second mint"); + assert_ne!(first.token, second.token); + + let raw = std::fs::read_to_string(dir.join("remote_tokens.json")).unwrap(); + let persisted: Vec = serde_json::from_str(&raw).unwrap(); + assert_eq!(persisted.len(), 2, "second mint appends, not overwrites"); + } + + #[test] + fn mint_creates_secret_when_absent() { + let dir = temp_dir(); + let minted = mint_local_token_in(&dir).expect("mint should create secret"); + let secret = std::fs::read(dir.join("remote_secret")).unwrap(); + assert_eq!(secret.len(), 32); + + let raw = std::fs::read_to_string(dir.join("remote_tokens.json")).unwrap(); + let persisted: Vec = serde_json::from_str(&raw).unwrap(); + let expected = base64::engine::general_purpose::STANDARD + .encode(auth::compute_hmac(&secret, minted.token.as_bytes())); + assert_eq!(persisted[0].token_hmac, expected); + } + + #[test] + fn mint_regenerates_wrong_secret_size() { + let dir = temp_dir(); + std::fs::write(dir.join("remote_secret"), vec![1u8; 16]).unwrap(); + mint_local_token_in(&dir).expect("mint should regenerate invalid secret"); + let secret = std::fs::read(dir.join("remote_secret")).unwrap(); + assert_eq!(secret.len(), 32); + } + + #[test] + fn current_process_is_alive() { + assert!(is_process_alive(std::process::id())); + } + + #[test] + fn parse_await_pid_reads_flag_value() { + let args = ["--listen", "127.0.0.1", AWAIT_PID_FLAG, "4242"]; + assert_eq!(parse_await_pid(args), Some(4242)); + } + + #[test] + fn parse_await_pid_none_when_absent_or_malformed() { + assert_eq!(parse_await_pid(["--listen", "127.0.0.1"]), None); + assert_eq!(parse_await_pid([AWAIT_PID_FLAG]), None, "flag without value"); + assert_eq!(parse_await_pid([AWAIT_PID_FLAG, "notanum"]), None); + } + + #[test] + fn strip_await_pid_removes_flag_and_value() { + let mut args = vec![ + "--listen".to_string(), + "127.0.0.1".to_string(), + AWAIT_PID_FLAG.to_string(), + "99".to_string(), + ]; + strip_await_pid_args(&mut args); + assert_eq!(args, vec!["--listen".to_string(), "127.0.0.1".to_string()]); + } + + #[test] + fn wait_for_pid_exit_zero_returns_immediately() { + // pid 0 is "unknown"; treat as already gone so the caller doesn't block. + assert!(wait_for_pid_exit(0, Duration::from_millis(10))); + } + + #[test] + fn wait_for_pid_exit_times_out_on_live_pid() { + let start = Instant::now(); + // This very process is alive, so the wait must run to the deadline. + assert!(!wait_for_pid_exit(std::process::id(), Duration::from_millis(120))); + assert!(start.elapsed() < Duration::from_secs(2), "should give up near the timeout"); + } + + #[test] + fn daemon_binary_path_is_total_and_sibling_consistent() { + // The function must never panic and must agree with the path-derivation + // contract: when it returns Some, the path is the sibling of current_exe + // named per the platform suffix and actually exists on disk. + let result = daemon_binary_path(); + + let exe = std::env::current_exe().expect("current_exe in test harness"); + let dir = exe.parent().expect("current_exe has a parent"); + let name = if cfg!(windows) { "okena-daemon.exe" } else { "okena-daemon" }; + let expected = dir.join(name); + + match result { + // Some only when the sibling exists, and it must be that exact path. + Some(path) => { + assert_eq!(path, expected); + assert!(path.exists(), "Some implies the sibling exists"); + } + // None is the correct, non-panicking answer when no sibling exists + // (caller then falls back to current_exe --headless). + None => assert!(!expected.exists(), "None implies no sibling on disk"), + } + } + + #[test] + fn wait_returns_immediately_when_daemon_present() { + let dir = temp_dir(); + std::fs::write( + dir.join("remote.json"), + format!(r#"{{"port": 19100, "pid": {}, "tls": false}}"#, std::process::id()), + ) + .unwrap(); + let found = wait_until_ready_in(&dir, Duration::from_secs(2)); + assert_eq!(found.map(|d| d.port), Some(19100)); + } + + #[test] + fn wait_times_out_when_absent() { + let dir = temp_dir(); + let start = Instant::now(); + assert_eq!(wait_until_ready_in(&dir, Duration::from_millis(120)), None); + assert!(start.elapsed() < Duration::from_secs(2), "should give up near the timeout"); + } + + #[test] + fn wait_skips_stale_dead_pid() { + let dir = temp_dir(); + // pid 0 is treated as "unknown -> assume alive"; use a very high pid that + // is almost certainly dead to exercise the liveness rejection path. + std::fs::write( + dir.join("remote.json"), + r#"{"port": 19100, "pid": 2147483646, "tls": false}"#, + ) + .unwrap(); + assert_eq!(wait_until_ready_in(&dir, Duration::from_millis(120)), None); + } +} diff --git a/crates/okena-remote-server/src/pty_broadcaster.rs b/crates/okena-remote-server/src/pty_broadcaster.rs index 99947e473..16b3e76ff 100644 --- a/crates/okena-remote-server/src/pty_broadcaster.rs +++ b/crates/okena-remote-server/src/pty_broadcaster.rs @@ -8,7 +8,13 @@ pub enum PtyBroadcastEvent { Output { terminal_id: String, data: Vec }, /// Terminal was resized (server-side). `server_owns` is true when the /// origin's local user currently holds resize authority. - Resized { terminal_id: String, cols: u16, rows: u16, server_owns: bool }, + Resized { + terminal_id: String, + cols: u16, + rows: u16, + server_owns: bool, + owner_connection_id: Option, + }, } /// Fan-out PTY events to WebSocket subscribers. @@ -38,8 +44,21 @@ impl PtyBroadcaster { } /// Publish a terminal resize event. Non-blocking; drops if no subscribers. - pub fn publish_resize(&self, terminal_id: String, cols: u16, rows: u16, server_owns: bool) { - let _ = self.tx.send(PtyBroadcastEvent::Resized { terminal_id, cols, rows, server_owns }); + pub fn publish_resize( + &self, + terminal_id: String, + cols: u16, + rows: u16, + server_owns: bool, + owner_connection_id: Option, + ) { + let _ = self.tx.send(PtyBroadcastEvent::Resized { + terminal_id, + cols, + rows, + server_owns, + owner_connection_id, + }); } /// Create a new subscriber receiver. @@ -53,7 +72,14 @@ impl PtyOutputSink for PtyBroadcaster { self.publish(terminal_id, data); } - fn publish_resize(&self, terminal_id: String, cols: u16, rows: u16, server_owns: bool) { - self.publish_resize(terminal_id, cols, rows, server_owns); + fn publish_resize( + &self, + terminal_id: String, + cols: u16, + rows: u16, + server_owns: bool, + owner_connection_id: Option, + ) { + self.publish_resize(terminal_id, cols, rows, server_owns, owner_connection_id); } } diff --git a/crates/okena-remote-server/src/routes/auth_reload.rs b/crates/okena-remote-server/src/routes/auth_reload.rs index be89b5653..c66522985 100644 --- a/crates/okena-remote-server/src/routes/auth_reload.rs +++ b/crates/okena-remote-server/src/routes/auth_reload.rs @@ -1,13 +1,12 @@ -use crate::routes::AppState; -use axum::extract::{ConnectInfo, State}; +use crate::routes::{AppState, PeerInfo}; +use axum::extract::{Extension, State}; use axum::http::StatusCode; -use std::net::{IpAddr, SocketAddr}; pub async fn post_reload( - ConnectInfo(addr): ConnectInfo, + Extension(peer): Extension, State(state): State, ) -> StatusCode { - if !is_trusted_reload_peer(addr) { + if !peer.is_local_trusted() { return StatusCode::FORBIDDEN; } @@ -18,34 +17,22 @@ pub async fn post_reload( } } -fn is_trusted_reload_peer(addr: SocketAddr) -> bool { - match addr.ip() { - IpAddr::V4(v4) => v4.is_loopback(), - // Dual-stack binds can surface an IPv4 loopback peer as the mapped - // form `::ffff:127.0.0.1`. `Ipv6Addr::is_loopback` only matches `::1`, - // so unwrap the mapping first and re-check at the v4 layer. - IpAddr::V6(v6) => match v6.to_ipv4_mapped() { - Some(v4) => v4.is_loopback(), - None => v6.is_loopback(), - }, - } -} - #[cfg(test)] mod tests { - use super::*; - use std::net::Ipv6Addr; + use crate::routes::PeerInfo; + use std::net::{IpAddr, Ipv6Addr, SocketAddr}; #[test] fn loopback_peers_can_reload_tokens() { - assert!(is_trusted_reload_peer(SocketAddr::from(([127, 0, 0, 1], 19100)))); - assert!(is_trusted_reload_peer(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 19100)))); + assert!(PeerInfo::Local.is_local_trusted()); + assert!(PeerInfo::Tcp(SocketAddr::from(([127, 0, 0, 1], 19100))).is_local_trusted()); + assert!(PeerInfo::Tcp(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 19100))).is_local_trusted()); } #[test] fn non_loopback_peers_cannot_reload_tokens() { - assert!(!is_trusted_reload_peer(SocketAddr::from(([192, 168, 1, 50], 19100)))); - assert!(!is_trusted_reload_peer(SocketAddr::from(([10, 0, 0, 2], 19100)))); + assert!(!PeerInfo::Tcp(SocketAddr::from(([192, 168, 1, 50], 19100))).is_local_trusted()); + assert!(!PeerInfo::Tcp(SocketAddr::from(([10, 0, 0, 2], 19100))).is_local_trusted()); } #[test] @@ -54,7 +41,7 @@ mod tests { // socket. Must be accepted: the CLI register flow uses this path on // some hosts and would otherwise hit FORBIDDEN. let mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001); - assert!(is_trusted_reload_peer(SocketAddr::new(IpAddr::V6(mapped), 19100))); + assert!(PeerInfo::Tcp(SocketAddr::new(IpAddr::V6(mapped), 19100)).is_local_trusted()); } #[test] @@ -63,6 +50,6 @@ mod tests { // stay rejected; the unwrap-then-check at the v4 layer should not // be a back door for off-host callers. let mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0132); - assert!(!is_trusted_reload_peer(SocketAddr::new(IpAddr::V6(mapped), 19100))); + assert!(!PeerInfo::Tcp(SocketAddr::new(IpAddr::V6(mapped), 19100)).is_local_trusted()); } } diff --git a/crates/okena-remote-server/src/routes/health.rs b/crates/okena-remote-server/src/routes/health.rs index 2661cb420..41e6aaf1e 100644 --- a/crates/okena-remote-server/src/routes/health.rs +++ b/crates/okena-remote-server/src/routes/health.rs @@ -7,7 +7,7 @@ pub async fn get_health(State(state): State) -> Json { let uptime = state.start_time.elapsed().as_secs(); Json(HealthResponse { status: "ok".into(), - version: env!("CARGO_PKG_VERSION").into(), + version: state.update_info.app_version(), uptime_secs: uptime, }) } diff --git a/crates/okena-remote-server/src/routes/mod.rs b/crates/okena-remote-server/src/routes/mod.rs index 5d4160993..d67d6cfde 100644 --- a/crates/okena-remote-server/src/routes/mod.rs +++ b/crates/okena-remote-server/src/routes/mod.rs @@ -4,22 +4,27 @@ pub mod health; pub mod pair; pub mod paste_image; pub mod refresh; +pub mod restart; +pub mod shutdown; pub mod state; pub mod stream; pub mod tokens; +pub mod update; use crate::auth::AuthStore; use crate::bridge::BridgeSender; use crate::pty_broadcaster::PtyBroadcaster; -use axum::extract::DefaultBodyLimit; use axum::Router; +use axum::extract::DefaultBodyLimit; use axum::extract::Request; use axum::http::StatusCode; use axum::middleware::{self, Next}; use axum::response::Response; -use okena_core::api::ApiGitStatus; +use okena_core::api::{ApiGitStatus, ApiToast}; +use okena_core::git_poll::GitPollTrigger; use rust_embed::RustEmbed; use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, RwLock}; use std::time::Instant; @@ -39,10 +44,56 @@ pub struct AppState { pub state_version: Arc>, pub start_time: Instant, pub git_status: Arc>>, + /// Broadcast of daemon-originated toasts. Each WS connection subscribes a + /// receiver and forwards [`WsOutbound::Toast`] frames; events sent with no + /// receivers are simply dropped (fire-and-forget, like git status). + pub toast_tx: Arc>, /// Per-connection set of subscribed terminal IDs (connection_id → terminal_ids). /// Used by GitStatusWatcher to poll git for projects visible on remote clients. pub remote_subscribed_terminals: Arc>>>, + /// Optional wake-up path for the host git poller when a WS client starts + /// viewing terminals. + pub git_poll_trigger_tx: Option>, pub next_connection_id: Arc, + /// Count of currently-live authenticated WS connections. The stream route + /// increments it on accept and decrements it on close; `/v1/shutdown` reads + /// it to refuse while any client is still connected. (`remote_subscribed_terminals` + /// only tracks connections that have subscribed to a terminal, so it can't + /// stand in for a live-connection registry.) + pub active_connections: Arc, + /// Graceful process-shutdown trigger for `/v1/shutdown`. `Some` on the + /// dedicated daemon, whose `run()` awaits it and then tears down cleanly + /// (socket unlink + remote.json removal + instance-lock release on drop); + /// `None` on the single-binary `okena --headless` daemon, which hard-exits. + pub process_shutdown: Option>, + pub update_info: okena_ext_updater::UpdateInfo, +} + +#[derive(Clone, Copy, Debug)] +pub enum PeerInfo { + Tcp(SocketAddr), + Local, +} + +impl PeerInfo { + pub fn is_local_trusted(self) -> bool { + match self { + Self::Local => true, + Self::Tcp(addr) => match addr.ip() { + IpAddr::V4(v4) => v4.is_loopback(), + // Dual-stack binds can surface an IPv4 loopback peer as the + // mapped form `::ffff:127.0.0.1`. + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => v4.is_loopback(), + None => v6.is_loopback(), + }, + }, + } + } +} + +fn request_is_unix_socket(req: &Request) -> bool { + matches!(req.extensions().get::(), Some(PeerInfo::Local)) } /// Build the complete axum router. @@ -55,8 +106,13 @@ pub fn build_router( state_version: Arc>, start_time: Instant, git_status: Arc>>, + toast_tx: Arc>, remote_subscribed_terminals: Arc>>>, + git_poll_trigger_tx: Option>, next_connection_id: Arc, + active_connections: Arc, + process_shutdown: Option>, + update_info: okena_ext_updater::UpdateInfo, ) -> Router { let state = AppState { bridge_tx, @@ -65,8 +121,13 @@ pub fn build_router( state_version, start_time, git_status, + toast_tx, remote_subscribed_terminals, + git_poll_trigger_tx, next_connection_id, + active_connections, + process_shutdown, + update_info, }; // Routes that require auth @@ -81,7 +142,14 @@ pub fn build_router( .route("/v1/stream", axum::routing::get(stream::ws_handler)) .route("/v1/refresh", axum::routing::post(refresh::post_refresh)) .route("/v1/tokens", axum::routing::get(tokens::list_tokens)) - .route("/v1/tokens/{id}", axum::routing::delete(tokens::revoke_token)) + .route( + "/v1/tokens/{id}", + axum::routing::delete(tokens::revoke_token), + ) + .route( + "/v1/pair-code", + axum::routing::post(pair::post_pair_code).delete(pair::delete_pair_code), + ) .layer(middleware::from_fn_with_state( state.clone(), auth_middleware, @@ -91,12 +159,29 @@ pub fn build_router( // `/v1/auth/reload` is loopback-gated in its handler: the CLI register // flow calls it before the new token is visible in-memory, so bearer auth // would deadlock, but exposed listeners must not accept network callers. + // `/v1/restart` is loopback-gated in the same way: a same-host UI restarts + // the daemon; it is bearer-free so the restart works even if the caller's + // token is in an awkward state, and off-host callers are refused. + // `/v1/shutdown` is loopback-gated identically: a same-host UI asks the + // daemon to stop on quit, but the daemon refuses while other clients remain. let public = Router::new() .route("/health", axum::routing::get(health::get_health)) .route("/v1/pair", axum::routing::post(pair::post_pair)) .route( "/v1/auth/reload", axum::routing::post(auth_reload::post_reload), + ) + .route("/v1/restart", axum::routing::post(restart::post_restart)) + .route("/v1/shutdown", axum::routing::post(shutdown::post_shutdown)) + .route("/v1/update/status", axum::routing::get(update::get_status)) + .route("/v1/update/check", axum::routing::post(update::post_check)) + .route( + "/v1/update/install", + axum::routing::post(update::post_install), + ) + .route( + "/v1/update/dismiss", + axum::routing::post(update::post_dismiss), ); public @@ -146,12 +231,17 @@ fn serve_embedded_file(path: &str, file: rust_embed::EmbeddedFile) -> axum::resp } /// Auth middleware: validates Bearer token on protected routes. +/// Unix socket traffic is already same-user scoped by the local transport. /// Skips validation for WebSocket upgrade requests (WS has its own auth flow). async fn auth_middleware( axum::extract::State(state): axum::extract::State, req: Request, next: Next, ) -> Result { + if request_is_unix_socket(&req) { + return Ok(next.run(req).await); + } + // Allow WebSocket upgrades through — they handle auth via query param or first message let is_websocket = req .headers() @@ -179,3 +269,24 @@ async fn auth_middleware( Ok(next.run(req).await) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix_socket_requests_skip_bearer_auth() { + let mut req = Request::new(axum::body::Body::empty()); + req.extensions_mut().insert(PeerInfo::Local); + + assert!(request_is_unix_socket(&req)); + } + + #[test] + fn tcp_loopback_requests_still_require_bearer_auth() { + let mut req = Request::new(axum::body::Body::empty()); + req.extensions_mut().insert(PeerInfo::Tcp(SocketAddr::from(([127, 0, 0, 1], 19100)))); + + assert!(!request_is_unix_socket(&req)); + } +} diff --git a/crates/okena-remote-server/src/routes/pair.rs b/crates/okena-remote-server/src/routes/pair.rs index 99bc5cdbe..fc4c5163b 100644 --- a/crates/okena-remote-server/src/routes/pair.rs +++ b/crates/okena-remote-server/src/routes/pair.rs @@ -1,8 +1,8 @@ use crate::auth::{PairError, TOKEN_TTL_SECS}; -use crate::routes::AppState; +use crate::routes::{AppState, PeerInfo}; use crate::types::{PairRequest, PairResponse}; use axum::Json; -use axum::extract::{ConnectInfo, State}; +use axum::extract::{ConnectInfo, Extension, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use std::net::SocketAddr; @@ -45,3 +45,31 @@ pub async fn post_pair( } } } + +pub async fn post_pair_code( + Extension(peer): Extension, + State(state): State, +) -> impl IntoResponse { + if !peer.is_local_trusted() { + return StatusCode::FORBIDDEN.into_response(); + } + + let code = state.auth_store.generate_fresh_code(); + Json(serde_json::json!({ + "code": code, + "expires_in": state.auth_store.code_remaining_secs(), + })) + .into_response() +} + +pub async fn delete_pair_code( + Extension(peer): Extension, + State(state): State, +) -> StatusCode { + if !peer.is_local_trusted() { + return StatusCode::FORBIDDEN; + } + + state.auth_store.invalidate_code(); + StatusCode::NO_CONTENT +} diff --git a/crates/okena-remote-server/src/routes/restart.rs b/crates/okena-remote-server/src/routes/restart.rs new file mode 100644 index 000000000..0ffe6163d --- /dev/null +++ b/crates/okena-remote-server/src/routes/restart.rs @@ -0,0 +1,99 @@ +//! `POST /v1/restart` — loopback-only daemon self-restart. +//! +//! Restarting the daemon ends every PTY (the daemon owns them all), so this is a +//! deliberate, user-confirmed action surfaced by the desktop GUI ("pick up a +//! freshly-built daemon binary"). It is loopback-gated exactly like +//! `/v1/auth/reload`: a same-host client triggers it; off-host callers are +//! refused. +//! +//! Mechanism (a self-restart, so it works whether the GUI spawned the daemon or +//! merely attached to it): +//! +//! 1. Spawn a *replacement* daemon process via +//! [`spawn_replacement_daemon`](crate::local::spawn_replacement_daemon). It +//! re-launches the current executable with the same args plus +//! `--await-pid `, so it waits for THIS process to exit before it +//! binds a port / acquires the instance lock. +//! 2. Ack the HTTP request immediately (so the client gets a clean response and +//! can begin polling for the new daemon). +//! 3. Schedule `std::process::exit` on a short timer — *after* the response has +//! been flushed — which drops this daemon's socket, releases its port, and +//! lets the lock file go stale (a dead PID), so the replacement takes over. +//! +//! The replacement's port scan picks the first free port in 19100–19200, which +//! may differ from the outgoing daemon's (the old one can linger in TIME_WAIT). +//! The client re-reads `remote.json` after restart to pick up the new port — see +//! the GUI's restart handler. + +use crate::routes::{AppState, PeerInfo}; +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use std::time::Duration; + +/// Grace before the outgoing daemon exits, so the HTTP ack is fully flushed to +/// the client before its connection drops. +const EXIT_DELAY: Duration = Duration::from_millis(300); + +pub async fn post_restart( + Extension(peer): Extension, + State(_state): State, +) -> StatusCode { + if !peer.is_local_trusted() { + return StatusCode::FORBIDDEN; + } + + // Spawn the replacement BEFORE acking: if we can't even launch it, fail the + // request and stay alive rather than exit into a daemon-less state. + match crate::local::spawn_replacement_daemon() { + Ok(_child) => { + log::info!("Daemon restart requested; spawned replacement, exiting shortly"); + } + Err(e) => { + log::error!("Daemon restart failed to spawn replacement: {e}"); + return StatusCode::INTERNAL_SERVER_ERROR; + } + } + + // Exit after the response is flushed. A detached task on the server runtime + // sleeps briefly, then hard-exits: `std::process::exit` skips destructors, + // so the lock file + remote.json are left with this (now-dead) PID, which the + // replacement treats as stale and takes over. + tokio::spawn(async move { + tokio::time::sleep(EXIT_DELAY).await; + log::info!("Daemon exiting for restart"); + std::process::exit(0); + }); + + StatusCode::OK +} + +#[cfg(test)] +mod tests { + use crate::routes::PeerInfo; + use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + + #[test] + fn loopback_peers_can_restart() { + assert!(PeerInfo::Local.is_local_trusted()); + assert!(PeerInfo::Tcp(SocketAddr::from(([127, 0, 0, 1], 19100))).is_local_trusted()); + assert!(PeerInfo::Tcp(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 19100))).is_local_trusted()); + } + + #[test] + fn non_loopback_peers_cannot_restart() { + assert!(!PeerInfo::Tcp(SocketAddr::from(([192, 168, 1, 50], 19100))).is_local_trusted()); + assert!(!PeerInfo::Tcp(SocketAddr::from(([10, 0, 0, 2], 19100))).is_local_trusted()); + } + + #[test] + fn ipv4_mapped_loopback_is_trusted() { + let mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001); + assert!(PeerInfo::Tcp(SocketAddr::new(IpAddr::V6(mapped), 19100)).is_local_trusted()); + } + + #[test] + fn ipv4_mapped_non_loopback_is_not_trusted() { + let mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0132); + assert!(!PeerInfo::Tcp(SocketAddr::new(IpAddr::V6(mapped), 19100)).is_local_trusted()); + } +} diff --git a/crates/okena-remote-server/src/routes/shutdown.rs b/crates/okena-remote-server/src/routes/shutdown.rs new file mode 100644 index 000000000..93ae5b193 --- /dev/null +++ b/crates/okena-remote-server/src/routes/shutdown.rs @@ -0,0 +1,133 @@ +//! `POST /v1/shutdown` — loopback-only, client-aware daemon shutdown. +//! +//! Replaces the desktop's old SIGKILL-at-quit. The quitting GUI asks the daemon +//! to stop, but the daemon REFUSES while any *other* client is still connected — +//! e.g. a second GUI that attached during a fast restart, or a mobile/web WS. +//! This is the fix for the restart race where the old GUI's quit killed the +//! daemon the new GUI had just attached to. Loopback-gated exactly like +//! `/v1/restart`; refusal is a normal 200 response, not an HTTP error. +//! +//! Self-exclusion: the caller disconnects its OWN loopback WS before calling and +//! the daemon simply counts live WS connections — see `local::request_local_shutdown`. +//! +//! On accept the daemon exits cleanly (unlike restart's hard `process::exit`, +//! since there is no successor to hand the port/lock to): waking the daemon's +//! `run()` loop drops the `RemoteServer` (unlinks the socket + removes +//! remote.json) and releases the instance lock on drop. + +use crate::routes::{AppState, PeerInfo}; +use axum::Json; +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::Ordering; +use std::time::Duration; + +/// Grace before teardown so the HTTP ack is flushed to the client before the +/// connection drops (mirrors the restart route's `EXIT_DELAY`). +const EXIT_DELAY: Duration = Duration::from_millis(300); + +/// Wire response for `POST /v1/shutdown`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShutdownResponse { + /// Whether the daemon accepted and is now tearing itself down. + pub shutting_down: bool, + /// Live client connections the daemon counted at decision time. + pub active_clients: u64, +} + +/// Pure accept/refuse decision: shut down only when no client is connected. +/// +/// The caller disconnects its own loopback WS first (see `request_local_shutdown`), +/// so a live count of zero means "only the quitting UI was here". +pub fn should_shut_down(active_clients: u64) -> bool { + active_clients == 0 +} + +pub async fn post_shutdown( + Extension(peer): Extension, + State(state): State, +) -> Response { + if !peer.is_local_trusted() { + return StatusCode::FORBIDDEN.into_response(); + } + + let active_clients = state.active_connections.load(Ordering::Relaxed); + if !should_shut_down(active_clients) { + log::info!("Shutdown refused: {active_clients} other client(s) connected"); + return Json(ShutdownResponse { + shutting_down: false, + active_clients, + }) + .into_response(); + } + + // Accepted. Tear down AFTER the response has been flushed (like restart). + match state.process_shutdown.clone() { + Some(notify) => { + tokio::spawn(async move { + tokio::time::sleep(EXIT_DELAY).await; + log::info!("Daemon shutting down (no other clients connected)"); + // Wakes the daemon's `run()` loop → drops the RemoteServer + // (socket unlink + pid-guarded remote.json removal) and releases + // the instance lock on drop. A clean teardown, no SIGKILL. + notify.notify_one(); + }); + } + None => { + // Transitional `okena --headless` fallback: no graceful run-loop to + // signal, so hard-exit like the restart route. Because `process::exit` + // skips Drop, clean up our own files first (pid-guarded): remove + // remote.json, unlink the local socket, and drop the instance lock — + // otherwise the leftover socket/lock race the next daemon start. + tokio::spawn(async move { + tokio::time::sleep(EXIT_DELAY).await; + crate::server::cleanup_on_hard_exit(); + log::info!("Headless daemon exiting for shutdown"); + std::process::exit(0); + }); + } + } + + Json(ShutdownResponse { + shutting_down: true, + active_clients, + }) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::should_shut_down; + use crate::routes::PeerInfo; + use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + + #[test] + fn shuts_down_only_when_no_clients_remain() { + assert!(should_shut_down(0), "no other clients → accept"); + assert!(!should_shut_down(1), "one other client → refuse"); + assert!(!should_shut_down(5), "many other clients → refuse"); + } + + #[test] + fn loopback_peers_can_shut_down() { + assert!(PeerInfo::Local.is_local_trusted()); + assert!(PeerInfo::Tcp(SocketAddr::from(([127, 0, 0, 1], 19100))).is_local_trusted()); + assert!( + PeerInfo::Tcp(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 19100))).is_local_trusted() + ); + } + + #[test] + fn non_loopback_peers_cannot_shut_down() { + assert!(!PeerInfo::Tcp(SocketAddr::from(([192, 168, 1, 50], 19100))).is_local_trusted()); + assert!(!PeerInfo::Tcp(SocketAddr::from(([10, 0, 0, 2], 19100))).is_local_trusted()); + } + + #[test] + fn ipv4_mapped_loopback_is_trusted() { + let mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001); + assert!(PeerInfo::Tcp(SocketAddr::new(IpAddr::V6(mapped), 19100)).is_local_trusted()); + } +} diff --git a/crates/okena-remote-server/src/routes/stream.rs b/crates/okena-remote-server/src/routes/stream.rs index 4214e7ec0..b3ee8dcee 100644 --- a/crates/okena-remote-server/src/routes/stream.rs +++ b/crates/okena-remote-server/src/routes/stream.rs @@ -3,19 +3,68 @@ #![allow(clippy::expect_used)] use crate::bridge::{BridgeMessage, CommandResult, RemoteCommand}; -use crate::routes::AppState; +use crate::routes::{AppState, PeerInfo}; use crate::types::{ - ActionRequest, WsInbound, WsOutbound, build_binary_frame, build_pty_frame, parse_binary_frame, - FRAME_TYPE_INPUT, FRAME_TYPE_SNAPSHOT, + ActionRequest, ApiSystemStats, FRAME_TYPE_INPUT, FRAME_TYPE_SNAPSHOT, WsInbound, WsOutbound, + build_binary_frame, build_pty_frame, parse_binary_frame, }; use axum::extract::ws::{Message, WebSocket}; -use axum::extract::{Query, State, WebSocketUpgrade}; +use axum::extract::{Extension, Query, State, WebSocketUpgrade}; use axum::response::IntoResponse; use futures::{SinkExt, StreamExt}; +use okena_core::git_poll::GitPollTrigger; use std::collections::HashMap; use std::sync::atomic::Ordering; +use std::time::Duration; +use sysinfo::System; use tokio::sync::{broadcast, mpsc}; +const SYSTEM_STATS_REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +struct SystemStatsCache { + system: System, + stats: ApiSystemStats, +} + +impl SystemStatsCache { + fn new() -> Self { + let mut system = System::new(); + system.refresh_cpu_usage(); + system.refresh_memory(); + + Self { + system, + stats: ApiSystemStats::default(), + } + } + + fn refresh(&mut self) { + self.system.refresh_cpu_usage(); + self.system.refresh_memory(); + + let mut total_cpu = 0.0; + let mut cpu_count = 0.0; + for cpu in self.system.cpus() { + total_cpu += cpu.cpu_usage(); + cpu_count += 1.0; + } + + self.stats = ApiSystemStats { + cpu_usage: if cpu_count > 0.0 { + total_cpu / cpu_count + } else { + 0.0 + }, + memory_used_bytes: self.system.used_memory(), + memory_total_bytes: self.system.total_memory(), + }; + } + + fn stats(&self) -> ApiSystemStats { + self.stats.clone() + } +} + #[derive(serde::Deserialize)] pub struct WsQuery { pub token: Option, @@ -24,14 +73,23 @@ pub struct WsQuery { pub async fn ws_handler( State(state): State, Query(query): Query, + Extension(peer): Extension, ws: WebSocketUpgrade, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_ws(socket, state, query.token)) + ws.on_upgrade(move |socket| handle_ws(socket, state, query.token, peer)) } -async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option) { +async fn handle_ws( + mut socket: WebSocket, + state: AppState, + query_token: Option, + peer: PeerInfo, +) { // ── Auth phase ────────────────────────────────────────────────────── - let authenticated = if let Some(token) = query_token { + // Unix socket clients are same-user local clients; bearer auth is for TCP. + let authenticated = if matches!(peer, PeerInfo::Local) { + true + } else if let Some(token) = query_token { state.auth_store.validate_token(&token) } else { // Wait for first-message auth (2 second timeout) @@ -76,10 +134,23 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = HashMap::new(); let mut next_stream_id: u32 = 1; let connection_id = state.next_connection_id.fetch_add(1, Ordering::Relaxed); + let connection_owner_id = connection_id.to_string(); + // Register this live connection (deregistered in the cleanup block below). + // `/v1/shutdown` counts these to refuse while any client is still connected. + // We register only after auth so unauthenticated dial attempts don't count. + state.active_connections.fetch_add(1, Ordering::Relaxed); // Subscribe to state_version and git status changes let mut state_rx = state.state_version.subscribe(); let mut git_rx = state.git_status.subscribe(); + // Subscribe to daemon-originated toasts (fire-and-forget broadcast). + let mut toast_rx = state.toast_tx.subscribe(); + // Once the toast sender is gone we disable that select arm, otherwise its + // `recv()` would resolve `Err(Closed)` instantly and busy-spin the loop. + let mut toast_open = true; + let mut system_stats = SystemStatsCache::new(); + let mut system_stats_interval = tokio::time::interval(SYSTEM_STATS_REFRESH_INTERVAL); + system_stats_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); // Pin the writer handle for use in select! tokio::pin!(writer_handle); @@ -105,6 +176,9 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = terminal_ids .iter() .filter_map(|id| { @@ -129,18 +203,59 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = + sizes.keys().cloned().collect(); + let resp = serde_json::to_string(&WsOutbound::Subscribed { mappings, sizes: sizes.clone() }).expect("BUG: WsOutbound must serialize"); if out_tx.send(Message::Text(resp.into())).await.is_err() { break; } + if send_authority_resizes( + &out_tx, + &sizes, + &connection_owner_id, + ) + .await + .is_err() + { + break; + } // Send initial snapshots for all subscribed terminals if send_snapshots(&out_tx, &state, &terminal_ids, &subscribed_ids).await.is_err() { break; } - // Drain PTY events that accumulated before/during snapshot generation. - // The snapshot already contains their effects — replaying would garble the display. - while pty_rx.try_recv().is_ok() {} + // Selectively drain PTY events that accumulated + // before/during snapshot generation. For + // pre-existing terminals the snapshot already + // contains their effects, so drop them. For + // just-spawned terminals (subscribed but absent + // from `pre_existing`) FORWARD the events — the + // shell's first prompt arrives here and the empty + // snapshot did not cover it, so dropping it would + // leave the pane blank until the next keypress. + if drain_or_forward_post_snapshot( + &out_tx, + &mut pty_rx, + &subscribed_ids, + &pre_existing, + &connection_owner_id, + ) + .await + .is_err() + { + break; + } } Ok(WsInbound::Unsubscribe { terminal_ids }) => { for id in &terminal_ids { @@ -159,21 +274,58 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option { let _ = state.bridge_tx.send(BridgeMessage { - command: RemoteCommand::Action(ActionRequest::SendText { terminal_id, text }), + command: RemoteCommand::ActionFromConnection { + action: ActionRequest::SendText { terminal_id, text }, + connection_id: connection_owner_id.clone(), + }, reply: None, }).await; } Ok(WsInbound::SendSpecialKey { terminal_id, key }) => { let _ = state.bridge_tx.send(BridgeMessage { - command: RemoteCommand::Action(ActionRequest::SendSpecialKey { terminal_id, key }), + command: RemoteCommand::ActionFromConnection { + action: ActionRequest::SendSpecialKey { terminal_id, key }, + connection_id: connection_owner_id.clone(), + }, reply: None, }).await; } Ok(WsInbound::Resize { terminal_id, cols, rows }) => { - let _ = state.bridge_tx.send(BridgeMessage { - command: RemoteCommand::Action(ActionRequest::Resize { terminal_id, cols, rows }), - reply: None, - }).await; + // Ask for a reply: a denied resize carries the + // authoritative size, which we bounce back to + // THIS client as a server-owned resize so it + // reverts its optimistic grid and stops + // re-asserting instead of silently diverging. + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let sent = state.bridge_tx.send(BridgeMessage { + command: RemoteCommand::ResizeFromConnection { + terminal_id: terminal_id.clone(), + cols, + rows, + connection_id: connection_owner_id.clone(), + }, + reply: Some(reply_tx), + }).await.is_ok(); + if sent + && let Ok(CommandResult::Ok(Some(value))) = reply_rx.await + && value.get("denied").and_then(|d| d.as_bool()) == Some(true) + && let (Some(cols), Some(rows)) = ( + value.get("cols").and_then(|c| c.as_u64()), + value.get("rows").and_then(|r| r.as_u64()), + ) + { + let msg = WsOutbound::TerminalResized { + terminal_id, + cols: cols as u16, + rows: rows as u16, + server_owns: true, + }; + let resp = serde_json::to_string(&msg) + .expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(resp.into())).await.is_err() { + break; + } + } } Ok(WsInbound::Ping) => { let resp = serde_json::to_string(&WsOutbound::Pong).expect("BUG: WsOutbound must serialize"); @@ -198,10 +350,13 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option { + crate::pty_broadcaster::PtyBroadcastEvent::Resized { + terminal_id, + cols, + rows, + server_owns, + owner_connection_id, + } => { if subscribed_ids.contains_key(terminal_id) { - resize_msgs.push(WsOutbound::TerminalResized { - terminal_id: terminal_id.clone(), - cols: *cols, - rows: *rows, - server_owns: *server_owns, - }); + resize_msgs.push(terminal_resized_for_recipient( + terminal_id.clone(), + *cols, + *rows, + *server_owns, + owner_connection_id.as_deref(), + &connection_owner_id, + )); } } } @@ -247,16 +410,24 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option { + crate::pty_broadcaster::PtyBroadcastEvent::Resized { + terminal_id, + cols, + rows, + server_owns, + owner_connection_id, + } => { if subscribed_ids.contains_key(terminal_id) { // Keep only the latest resize per terminal resize_msgs.retain(|m| !matches!(m, WsOutbound::TerminalResized { terminal_id: id, .. } if id == terminal_id)); - resize_msgs.push(WsOutbound::TerminalResized { - terminal_id: terminal_id.clone(), - cols: *cols, - rows: *rows, - server_owns: *server_owns, - }); + resize_msgs.push(terminal_resized_for_recipient( + terminal_id.clone(), + *cols, + *rows, + *server_owns, + owner_connection_id.as_deref(), + &connection_owner_id, + )); } } }, @@ -360,6 +531,41 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option { + system_stats.refresh(); + let resp = serde_json::to_string(&WsOutbound::SystemStatsChanged { + stats: system_stats.stats(), + }).expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(resp.into())).await.is_err() { + break; + } + } + + // Daemon-originated toast push (fire-and-forget broadcast). + result = toast_rx.recv(), if toast_open => { + match result { + Ok(api_toast) => { + let resp = serde_json::to_string(&WsOutbound::Toast(api_toast)) + .expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(resp.into())).await.is_err() { + break; + } + } + // A slow client missed some toasts — they are non-critical + // notifications, so just keep receiving the newer ones. + Err(broadcast::error::RecvError::Lagged(n)) => { + log::debug!("toast broadcast lagged, dropped {n} toast(s) for a client"); + } + // Sender gone (daemon shutting down) — stop polling this arm so + // it can't busy-spin; the connection lives on until it closes. + Err(broadcast::error::RecvError::Closed) => { + toast_open = false; + } + } + } + // Writer task died (socket write error) — stop the reader too _ = &mut writer_handle => { break; @@ -372,6 +578,13 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option, + recipient_connection_id: &str, +) -> WsOutbound { + let server_owns = + server_owns || owner_connection_id.is_some_and(|owner| owner != recipient_connection_id); + WsOutbound::TerminalResized { + terminal_id, + cols, + rows, + server_owns, + } +} + +async fn send_authority_resizes( + out_tx: &mpsc::Sender, + sizes: &HashMap, + recipient_connection_id: &str, +) -> Result<(), ()> { + let authority = okena_terminal::terminal::resize_authority_snapshot(""); + if !authority.claimed { + return Ok(()); + } + for (terminal_id, (cols, rows)) in sizes { + let msg = terminal_resized_for_recipient( + terminal_id.clone(), + *cols, + *rows, + authority.local, + authority.remote_owner_id.as_deref(), + recipient_connection_id, + ); + let resp = serde_json::to_string(&msg).expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(resp.into())).await.is_err() { + return Err(()); + } + } + Ok(()) +} + +/// Drain the PTY events that accumulated before/during snapshot generation at +/// subscribe time, discarding those already reflected in a terminal's snapshot +/// and forwarding those that are not. +/// +/// The plain blanket drain (`while pty_rx.try_recv().is_ok() {}`) is correct for +/// terminals that already had a live PTY before this subscribe: their snapshot +/// reflects current state, so replaying the queued events would garble the +/// display. But a terminal spawned lazily *during* this subscribe (its +/// `render_snapshot()` was empty because the shell hadn't printed yet) has its +/// first output sitting in the queue — dropping it would leave the pane blank +/// until the next keypress. So: +/// +/// * events for `pre_existing` terminals (snapshot covers them) are dropped; +/// * events for subscribed-but-just-spawned terminals are forwarded as the same +/// resize-then-PTY frames the main loop sends; +/// * events for non-subscribed terminals are dropped (not ours). +/// +/// On `Lagged` the queued backlog is meaningless (we already lost events), so we +/// simply stop draining; the main loop's own lag handling resynchronizes via +/// fresh snapshots. Returns `Err(())` if a client send fails (caller breaks). +async fn drain_or_forward_post_snapshot( + out_tx: &mpsc::Sender, + pty_rx: &mut broadcast::Receiver, + subscribed_ids: &HashMap, + pre_existing: &std::collections::HashSet, + connection_owner_id: &str, +) -> Result<(), ()> { + use crate::pty_broadcaster::PtyBroadcastEvent; + + // Coalesce forwarded output per stream and keep only the latest resize per + // terminal, matching the main loop's batching. + let mut batch: HashMap> = HashMap::new(); + let mut resize_msgs: Vec = Vec::new(); + + while let Ok(event) = pty_rx.try_recv() { + match event { + PtyBroadcastEvent::Output { terminal_id, data } => { + // Forward only for subscribed terminals that were NOT already + // covered by a snapshot (i.e. just spawned this subscribe). + if !pre_existing.contains(&terminal_id) + && let Some(&stream_id) = subscribed_ids.get(&terminal_id) + { + batch.entry(stream_id).or_default().extend_from_slice(&data); + } + } + PtyBroadcastEvent::Resized { + terminal_id, + cols, + rows, + server_owns, + owner_connection_id, + } => { + if !pre_existing.contains(&terminal_id) && subscribed_ids.contains_key(&terminal_id) + { + resize_msgs.retain(|m| { + !matches!(m, WsOutbound::TerminalResized { terminal_id: id, .. } if *id == terminal_id) + }); + resize_msgs.push(terminal_resized_for_recipient( + terminal_id, + cols, + rows, + server_owns, + owner_connection_id.as_deref(), + connection_owner_id, + )); + } + } + } + } + + // Resize first (so the client updates its grid before applying PTY data), + // then the coalesced PTY frames — same ordering as the main broadcast arm. + for msg in resize_msgs { + let resp = serde_json::to_string(&msg).expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(resp.into())).await.is_err() { + return Err(()); + } + } + for (stream_id, data) in batch { + let frame = build_pty_frame(stream_id, &data); + if out_tx.send(Message::Binary(frame.into())).await.is_err() { + return Err(()); + } + } + Ok(()) +} + /// Send snapshot frames for the given terminal IDs via the mpsc channel. /// Returns Err if the channel send fails (caller should break). async fn send_snapshots( @@ -410,13 +754,57 @@ async fn send_snapshots( }) .await .is_ok() - && let Ok(CommandResult::OkBytes(snapshot)) = reply_rx.await { - let frame = build_binary_frame(FRAME_TYPE_SNAPSHOT, stream_id, &snapshot); - if out_tx.send(Message::Binary(frame.into())).await.is_err() { - return Err(()); - } + && let Ok(CommandResult::OkBytes(snapshot)) = reply_rx.await + { + let frame = build_binary_frame(FRAME_TYPE_SNAPSHOT, stream_id, &snapshot); + if out_tx.send(Message::Binary(frame.into())).await.is_err() { + return Err(()); } + } } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn resized_server_owns( + server_owns: bool, + owner_connection_id: Option<&str>, + recipient_connection_id: &str, + ) -> bool { + match terminal_resized_for_recipient( + "t".to_string(), + 120, + 40, + server_owns, + owner_connection_id, + recipient_connection_id, + ) { + WsOutbound::TerminalResized { server_owns, .. } => server_owns, + _ => unreachable!("helper always returns TerminalResized"), + } + } + + #[test] + fn resize_echo_stays_client_owned_for_origin_connection() { + assert!(!resized_server_owns(false, Some("conn-a"), "conn-a")); + } + + #[test] + fn resize_from_other_connection_makes_recipient_defer() { + assert!(resized_server_owns(false, Some("conn-a"), "conn-b")); + } + + #[test] + fn server_owned_resize_makes_every_remote_defer() { + assert!(resized_server_owns(true, None, "conn-a")); + } + + #[test] + fn legacy_unknown_remote_owner_keeps_prior_client_behavior() { + assert!(!resized_server_owns(false, None, "conn-a")); + } +} diff --git a/crates/okena-remote-server/src/routes/update.rs b/crates/okena-remote-server/src/routes/update.rs new file mode 100644 index 000000000..2e40cfd4a --- /dev/null +++ b/crates/okena-remote-server/src/routes/update.rs @@ -0,0 +1,94 @@ +use crate::routes::{AppState, PeerInfo}; +use axum::Json; +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use okena_ext_updater::UpdateStatus; +use std::time::Duration; + +pub async fn get_status( + Extension(peer): Extension, + State(state): State, +) -> Result, StatusCode> { + if !peer.is_local_trusted() { + return Err(StatusCode::FORBIDDEN); + } + Ok(Json(state.update_info.snapshot())) +} + +pub async fn post_check( + Extension(peer): Extension, + State(state): State, +) -> Result, StatusCode> { + if !peer.is_local_trusted() { + return Err(StatusCode::FORBIDDEN); + } + + let info = state.update_info.clone(); + if info.try_start_manual() { + let token = info.current_token(); + tokio::spawn(async move { + okena_ext_updater::manager::run_check(info, token, true).await; + }); + } + + Ok(Json(state.update_info.snapshot())) +} + +pub async fn post_install( + Extension(peer): Extension, + State(state): State, +) -> Result, StatusCode> { + if !peer.is_local_trusted() { + return Err(StatusCode::FORBIDDEN); + } + + let info = state.update_info.clone(); + if matches!(info.status(), UpdateStatus::Ready { .. }) { + tokio::spawn(async move { + okena_ext_updater::manager::install_ready_update(info).await; + }); + } + + Ok(Json(state.update_info.snapshot())) +} + +pub async fn post_dismiss( + Extension(peer): Extension, + State(state): State, +) -> Result, StatusCode> { + if !peer.is_local_trusted() { + return Err(StatusCode::FORBIDDEN); + } + + state.update_info.dismiss(); + Ok(Json(state.update_info.snapshot())) +} + +pub fn spawn_background_checker(update_info: okena_ext_updater::UpdateInfo) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30)).await; + + loop { + if let Some(token) = update_info.try_start() { + okena_ext_updater::manager::run_check(update_info.clone(), token, false).await; + } + + match update_info.status() { + UpdateStatus::Ready { .. } + | UpdateStatus::ReadyToRestart { .. } + | UpdateStatus::Installing { .. } + | UpdateStatus::BrewUpdate { .. } => return, + _ => {} + } + + if matches!(update_info.status(), UpdateStatus::Failed { .. }) { + tokio::time::sleep(Duration::from_secs(60)).await; + if matches!(update_info.status(), UpdateStatus::Failed { .. }) { + update_info.set_status(UpdateStatus::Idle); + } + } + + tokio::time::sleep(Duration::from_secs(24 * 60 * 60)).await; + } + }); +} diff --git a/crates/okena-remote-server/src/serve.rs b/crates/okena-remote-server/src/serve.rs index a6e5af261..36a396cb3 100644 --- a/crates/okena-remote-server/src/serve.rs +++ b/crates/okena-remote-server/src/serve.rs @@ -62,6 +62,7 @@ pub async fn serve_dual_stack( async move { let mut req = req.map(Body::new); req.extensions_mut().insert(ConnectInfo(peer)); + req.extensions_mut().insert(crate::routes::PeerInfo::Tcp(peer)); app.call(req).await } }); @@ -88,3 +89,102 @@ pub async fn serve_dual_stack( }); } } + +pub async fn serve_plain( + listener: TcpListener, + app: Router, + shutdown: impl std::future::Future, +) -> std::io::Result<()> { + tokio::pin!(shutdown); + + loop { + let (stream, peer) = tokio::select! { + res = listener.accept() => match res { + Ok(v) => v, + Err(e) => { + log::warn!("Remote server accept error: {e}"); + continue; + } + }, + _ = &mut shutdown => return Ok(()), + }; + + let app = app.clone(); + tokio::spawn(async move { + let svc = hyper::service::service_fn(move |req: Request| { + let mut app = app.clone(); + async move { + let mut req = req.map(Body::new); + req.extensions_mut().insert(ConnectInfo(peer)); + req.extensions_mut().insert(crate::routes::PeerInfo::Tcp(peer)); + app.call(req).await + } + }); + + if let Err(e) = Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(stream), svc) + .await + { + log::debug!("HTTP connection from {peer} ended: {e}"); + } + }); + } +} + +#[cfg(unix)] +pub fn bind_unix_socket(path: &std::path::Path) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + if path.exists() { + let _ = std::fs::remove_file(path); + } + + tokio::net::UnixListener::bind(path) +} + +#[cfg(unix)] +pub async fn serve_unix_listener( + path: std::path::PathBuf, + listener: tokio::net::UnixListener, + app: Router, + shutdown: impl std::future::Future, +) -> std::io::Result<()> { + log::info!("Local daemon socket listening on {}", path.display()); + tokio::pin!(shutdown); + + loop { + let (stream, _peer) = tokio::select! { + res = listener.accept() => match res { + Ok(v) => v, + Err(e) => { + log::warn!("Local socket accept error: {e}"); + continue; + } + }, + _ = &mut shutdown => { + let _ = tokio::fs::remove_file(&path).await; + return Ok(()); + }, + }; + + let app = app.clone(); + tokio::spawn(async move { + let svc = hyper::service::service_fn(move |req: Request| { + let mut app = app.clone(); + async move { + let mut req = req.map(Body::new); + req.extensions_mut().insert(crate::routes::PeerInfo::Local); + app.call(req).await + } + }); + + if let Err(e) = Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(stream), svc) + .await + { + log::debug!("Local socket connection ended: {e}"); + } + }); + } +} diff --git a/crates/okena-remote-server/src/server.rs b/crates/okena-remote-server/src/server.rs index 8ed429152..6bc3999b9 100644 --- a/crates/okena-remote-server/src/server.rs +++ b/crates/okena-remote-server/src/server.rs @@ -2,7 +2,9 @@ use crate::auth::AuthStore; use crate::bridge::BridgeSender; use crate::pty_broadcaster::PtyBroadcaster; use crate::routes; -use okena_core::api::ApiGitStatus; +use okena_core::api::{ApiGitStatus, ApiToast}; +use okena_core::git_poll::GitPollTrigger; +use okena_transport::client::LocalEndpoint; use std::collections::{HashMap, HashSet}; use std::net::{IpAddr, SocketAddr}; use std::sync::atomic::AtomicU64; @@ -31,11 +33,16 @@ impl RemoteServer { auth_store: Arc, broadcaster: Arc, state_version: Arc>, - bind_addr: IpAddr, + bind_addrs: Vec, git_status: Arc>>, + toast_tx: Arc>, remote_subscribed_terminals: Arc>>>, + git_poll_trigger_tx: Option>, next_connection_id: Arc, + active_connections: Arc, + process_shutdown: Option>, tls_enabled: bool, + app_version: &'static str, ) -> anyhow::Result { let _slow = okena_core::timing::SlowGuard::new("RemoteServer::start"); let runtime = tokio::runtime::Builder::new_multi_thread() @@ -49,21 +56,14 @@ impl RemoteServer { // Clean up stale remote.json from a previous crash cleanup_stale_remote_json(); - // Try to bind to a port - let listener = runtime.block_on(async { - // Try preferred range first - for port in 19100..=19200 { - let addr = SocketAddr::new(bind_addr, port); - if let Ok(listener) = tokio::net::TcpListener::bind(addr).await { - return Ok(listener); - } - } - // Fall back to OS-assigned port - let addr = SocketAddr::new(bind_addr, 0); - tokio::net::TcpListener::bind(addr).await - })?; - - let port = listener.local_addr()?.port(); + let bind_addrs = normalize_bind_addrs(bind_addrs); + let listeners = runtime.block_on(bind_listeners(&bind_addrs))?; + let port = listeners + .first() + .expect("bind_listeners returns at least one listener") + .1 + .local_addr()? + .port(); // Load (or generate) the persisted self-signed cert when TLS is enabled. // If cert setup fails we deliberately do NOT silently fall back to plain @@ -76,6 +76,10 @@ impl RemoteServer { None }; let cert_fingerprint = tls_material.as_ref().map(|m| m.fingerprint.clone()); + let tls_server_config = match tls_material.as_ref() { + Some(material) => Some(crate::tls::server_config(material)?), + None => None, + }; let scheme = if tls_enabled { "http+https dual-stack" @@ -84,7 +88,11 @@ impl RemoteServer { }; log::info!( "Remote control server listening on {}:{} ({})", - bind_addr, + bind_addrs + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), port, scheme ); @@ -97,27 +105,60 @@ impl RemoteServer { // Warn loudly when bound to a non-loopback address WITHOUT TLS: the // pairing token and all terminal I/O would travel the network in cleartext. - if !bind_addr.is_loopback() && !tls_enabled { + if bind_addrs.iter().any(|addr| !addr.is_loopback()) && !tls_enabled { log::warn!( "Remote control server is bound to a NON-LOOPBACK address ({}:{}) WITHOUT TLS.\n\ The connection is UNENCRYPTED (http/ws): the pairing token and all terminal\n\ I/O (including passwords, SSH keys, and any typed secrets) are sent in cleartext\n\ and visible to anyone on the network. Enable TLS in settings, only use this on a\n\ trusted network, or tunnel it over SSH/WireGuard.", - bind_addr, port + bind_addrs + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + port ); } + let mut local_endpoint = crate::local::default_local_endpoint(); + #[cfg(unix)] + let local_listener = match &local_endpoint { + Some(LocalEndpoint::UnixSocket { path }) => { + let path_buf = std::path::PathBuf::from(path); + // `UnixListener::bind` registers with the tokio reactor, so it + // must run inside the runtime context. The TCP binds get this for + // free via `block_on` above; here we're on the plain caller thread + // (daemon main / GPUI thread), so enter the runtime explicitly. + let bound = { + let _guard = runtime.enter(); + crate::serve::bind_unix_socket(&path_buf) + }; + match bound { + Ok(listener) => Some((path_buf, listener)), + Err(e) => { + log::warn!("Failed to bind local daemon socket at {path}: {e}"); + local_endpoint = None; + None + } + } + } + _ => None, + }; + // Write remote.json - if let Err(e) = write_remote_json(port, tls_enabled) { + if let Err(e) = write_remote_json(port, tls_enabled, &bind_addrs, local_endpoint.as_ref()) { log::warn!("Failed to write remote.json: {}", e); } let start_time = std::time::Instant::now(); + okena_ext_updater::installer::cleanup_old_binary(); + let update_info = okena_ext_updater::UpdateInfo::new(app_version.to_string()); // Spawn the server task - let shutdown_rx_clone = shutdown_rx.clone(); + let mut shutdown_rx_clone = shutdown_rx.clone(); runtime.spawn(async move { + routes::update::spawn_background_checker(update_info.clone()); let app = routes::build_router( bridge_tx, auth_store, @@ -125,40 +166,63 @@ impl RemoteServer { state_version, start_time, git_status, + toast_tx, remote_subscribed_terminals, + git_poll_trigger_tx, next_connection_id, + active_connections, + process_shutdown, + update_info, ); - let serve_result = if let Some(material) = tls_material { - // TLS enabled → dual-stack: accept BOTH http and TLS on this one - // port so already-paired plain-http clients keep working while - // new/auto clients negotiate TLS. - match crate::tls::server_config(&material) { - Ok(tls_config) => { + #[cfg(unix)] + let local_server = if let Some((path, listener)) = local_listener { + Some(tokio::spawn(crate::serve::serve_unix_listener( + path, + listener, + app.clone(), + shutdown_signal(shutdown_rx.clone()), + ))) + } else { + None + }; + + let mut tcp_servers = Vec::new(); + for (addr, listener) in listeners { + let app = app.clone(); + let shutdown_rx = shutdown_rx_clone.clone(); + let tls_config = tls_server_config.clone(); + tcp_servers.push(tokio::spawn(async move { + let result = if let Some(tls_config) = tls_config { + // TLS enabled → dual-stack: accept BOTH http and TLS on this one + // port so already-paired plain-http clients keep working while + // new/auto clients negotiate TLS. crate::serve::serve_dual_stack( listener, app, tls_config, - shutdown_signal(shutdown_rx_clone), + shutdown_signal(shutdown_rx), ) .await + } else { + // TLS disabled → plain http only. + crate::serve::serve_plain(listener, app, shutdown_signal(shutdown_rx)).await + }; + match result { + Ok(()) => log::info!("Remote control server on {addr} shut down"), + Err(e) => { + log::error!("Remote control server on {addr} exited with error: {e}") + } } - Err(e) => { - log::error!("Failed to build TLS server config: {e:#}"); - Err(std::io::Error::other(e.to_string())) - } - } - } else { - // TLS disabled → plain http only. - let make_service = - app.into_make_service_with_connect_info::(); - axum::serve(listener, make_service) - .with_graceful_shutdown(shutdown_signal(shutdown_rx_clone)) - .await - }; + })); + } + let _ = shutdown_rx_clone.changed().await; + for handle in tcp_servers { + let _ = handle.await; + } - match serve_result { - Ok(()) => log::info!("Remote control server shut down"), - Err(e) => log::error!("Remote control server exited with error: {e}"), + #[cfg(unix)] + if let Some(handle) = local_server { + handle.abort(); } }); @@ -197,6 +261,55 @@ impl RemoteServer { } } +fn normalize_bind_addrs(addrs: Vec) -> Vec { + let mut out = Vec::new(); + for addr in addrs { + if out.contains(&addr) { + continue; + } + out.push(addr); + } + if out.is_empty() { + out.push(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + } + out +} + +async fn bind_listeners( + bind_addrs: &[IpAddr], +) -> anyhow::Result> { + let primary = *bind_addrs + .first() + .unwrap_or(&IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + + for port in 19100..=19200 { + if let Ok(listeners) = bind_all_on_port(bind_addrs, port).await { + return Ok(listeners); + } + } + + let first = tokio::net::TcpListener::bind(SocketAddr::new(primary, 0)).await?; + let port = first.local_addr()?.port(); + let mut listeners = vec![(primary, first)]; + for addr in bind_addrs.iter().copied().skip(1) { + let listener = tokio::net::TcpListener::bind(SocketAddr::new(addr, port)).await?; + listeners.push((addr, listener)); + } + Ok(listeners) +} + +async fn bind_all_on_port( + bind_addrs: &[IpAddr], + port: u16, +) -> std::io::Result> { + let mut listeners = Vec::with_capacity(bind_addrs.len()); + for addr in bind_addrs { + let listener = tokio::net::TcpListener::bind(SocketAddr::new(*addr, port)).await?; + listeners.push((*addr, listener)); + } + Ok(listeners) +} + impl Drop for RemoteServer { fn drop(&mut self) { if self.runtime.is_some() { @@ -220,17 +333,26 @@ fn remote_json_path() -> std::path::PathBuf { } /// Write remote.json atomically (temp file + rename). -fn write_remote_json(port: u16, tls_enabled: bool) -> anyhow::Result<()> { +fn write_remote_json( + port: u16, + tls_enabled: bool, + bind_addrs: &[IpAddr], + local_endpoint: Option<&LocalEndpoint>, +) -> anyhow::Result<()> { let path = remote_json_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let content = serde_json::json!({ + let mut content = serde_json::json!({ "port": port, + "local_host": local_tcp_host(bind_addrs), "pid": std::process::id(), "tls": tls_enabled, }); + if let Some(local_endpoint) = local_endpoint { + content["local_endpoint"] = serde_json::to_value(local_endpoint)?; + } let tmp_path = path.with_extension("tmp"); std::fs::write(&tmp_path, serde_json::to_string_pretty(&content)?)?; @@ -247,14 +369,97 @@ fn write_remote_json(port: u16, tls_enabled: bool) -> anyhow::Result<()> { Ok(()) } -/// Remove remote.json on shutdown. -fn remove_remote_json() { +fn local_tcp_host(bind_addrs: &[IpAddr]) -> &'static str { + if bind_addrs.iter().any(|addr| match addr { + IpAddr::V4(v4) => *v4 == std::net::Ipv4Addr::LOCALHOST || v4.is_unspecified(), + IpAddr::V6(_) => false, + }) { + return crate::local::LOCAL_HOST; + } + + if bind_addrs.iter().any(|addr| match addr { + IpAddr::V4(_) => false, + IpAddr::V6(v6) => *v6 == std::net::Ipv6Addr::LOCALHOST || v6.is_unspecified(), + }) { + return "::1"; + } + + crate::local::LOCAL_HOST +} + +/// Read remote.json and return its path + parsed contents ONLY when it still +/// advertises THIS process. The shared pid-guard for every teardown path: a +/// restart handoff exits via `std::process::exit` (skipping teardown) and the +/// successor rewrites remote.json with its own pid, so a late/racy teardown must +/// never clobber that successor's discovery file. +fn read_remote_json_if_ours() -> Option<(std::path::PathBuf, serde_json::Value)> { let path = remote_json_path(); - if path.exists() { + let data = std::fs::read_to_string(&path).ok()?; + let json: serde_json::Value = serde_json::from_str(&data).ok()?; + let owned_by_us = json + .get("pid") + .and_then(|p| p.as_u64()) + .is_some_and(|pid| pid == u64::from(std::process::id())); + owned_by_us.then_some((path, json)) +} + +/// Remove remote.json on shutdown, pid-guarded (see `read_remote_json_if_ours`). +pub(crate) fn remove_remote_json() { + if let Some((path, _)) = read_remote_json_if_ours() { let _ = std::fs::remove_file(&path); } } +/// Hard-exit cleanup for the single-binary `okena --headless` `/v1/shutdown` +/// path, which exits via `process::exit` and so SKIPS the Drop teardown the +/// dedicated daemon-core path relies on (`RemoteServer::stop` + `LockGuard::drop`). +/// +/// Removes remote.json, unlinks the local unix socket it advertised, and drops +/// the instance lock — each pid-guarded to THIS process so a racy/late teardown +/// never clobbers a successor daemon's files. Without the socket unlink the +/// stale socket file lingers, and the next daemon's dtach GC could delete it out +/// from under a fresh listener; without the lock removal the next start would +/// see a stale lock owned by a dead pid. +pub(crate) fn cleanup_on_hard_exit() { + remove_remote_json_and_local_socket(); + remove_instance_lock_if_ours(); +} + +/// Pid-guarded (see `read_remote_json_if_ours`): unlink the local unix socket +/// remote.json advertises, then remove remote.json itself. Reading the endpoint +/// from the file (rather than guessing the path) keeps this in sync with +/// whatever the server actually bound. +fn remove_remote_json_and_local_socket() { + let Some((path, json)) = read_remote_json_if_ours() else { + return; + }; + + // Unlink the local socket first, while we still hold its path from the file. + // Only ever advertised on Unix; removing a nonexistent path elsewhere is a + // harmless ignored error. + if let Some(LocalEndpoint::UnixSocket { path: socket }) = json + .get("local_endpoint") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + let _ = std::fs::remove_file(&socket); + } + + let _ = std::fs::remove_file(&path); +} + +/// Remove the instance lock file, but ONLY if it still records THIS process's +/// pid (mirrors the remote.json pid-guard). The lock file holds a bare pid +/// string (see `acquire_instance_lock`). +fn remove_instance_lock_if_ours() { + let lock_path = okena_workspace::persistence::instance_lock_path(); + let Ok(content) = std::fs::read_to_string(&lock_path) else { + return; + }; + if content.trim().parse::().ok() == Some(std::process::id()) { + let _ = std::fs::remove_file(&lock_path); + } +} + /// Clean up stale remote.json left behind by a crashed process. fn cleanup_stale_remote_json() { let path = remote_json_path(); @@ -272,25 +477,29 @@ fn cleanup_stale_remote_json() { }; if let Some(pid) = json.get("pid").and_then(|v| v.as_u64()) - && !is_process_alive(pid as u32) { - log::info!("Removing stale remote.json (pid {} is dead)", pid); - let _ = std::fs::remove_file(&path); - } + && !crate::local::is_process_alive(pid as u32) + { + log::info!("Removing stale remote.json (pid {} is dead)", pid); + let _ = std::fs::remove_file(&path); + } } -/// Check if a process with the given PID is still running. -fn is_process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - // signal 0 checks if process exists without sending a signal - unsafe { libc::kill(pid as i32, 0) == 0 } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_tcp_host_prefers_ipv4_loopback_when_available() { + let addrs = [ + IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + ]; + assert_eq!(local_tcp_host(&addrs), crate::local::LOCAL_HOST); } - #[cfg(not(unix))] - { - // On non-Unix platforms, assume the process may be alive to avoid - // accidentally removing a valid remote.json. The stale file is - // harmless — the port will simply fail to bind and we'll pick another. - let _ = pid; - true + + #[test] + fn local_tcp_host_uses_ipv6_loopback_for_ipv6_only_binds() { + let addrs = [IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)]; + assert_eq!(local_tcp_host(&addrs), "::1"); } } diff --git a/crates/okena-remote-server/src/types.rs b/crates/okena-remote-server/src/types.rs index ba2837830..ce80f1997 100644 --- a/crates/okena-remote-server/src/types.rs +++ b/crates/okena-remote-server/src/types.rs @@ -1,8 +1,8 @@ #[allow(unused_imports)] pub use okena_core::api::{ ActionRequest, ApiFolder, ApiFullscreen, ApiGitStatus, ApiLayoutNode, ApiProject, - ApiServiceInfo, ApiWindow, ApiWindowBounds, ErrorResponse, HealthResponse, PairRequest, - PairResponse, StateResponse, + ApiServiceInfo, ApiSystemStats, ApiWindow, ApiWindowBounds, ErrorResponse, HealthResponse, + PairRequest, PairResponse, StateResponse, }; #[allow(unused_imports)] pub use okena_core::ws::{ diff --git a/crates/okena-services/Cargo.toml b/crates/okena-services/Cargo.toml index 6ae402a68..8252fae18 100644 --- a/crates/okena-services/Cargo.toml +++ b/crates/okena-services/Cargo.toml @@ -4,11 +4,15 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui"] +gpui = ["dep:gpui"] + [dependencies] okena-core = { path = "../okena-core" } okena-terminal = { path = "../okena-terminal" } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -17,4 +21,3 @@ log = "0.4" thiserror = "2.0" smol = "2.0" async-channel = "2.3" -libc = "0.2" diff --git a/crates/okena-services/src/manager/commands.rs b/crates/okena-services/src/manager/commands.rs index f2eebd7e6..c5392071b 100644 --- a/crates/okena-services/src/manager/commands.rs +++ b/crates/okena-services/src/manager/commands.rs @@ -1,8 +1,11 @@ //! Start / stop / restart individual services, plus PTY-exit handling. -use super::{MAX_RESTART_COUNT, ServiceKind, ServiceManager, ServiceStatus, is_process_alive}; +use super::{ + MAX_RESTART_COUNT, ServiceAsyncCx, ServiceCx, ServiceHandle, ServiceKind, ServiceManager, + ServiceStatus, +}; use crate::port_detect; -use gpui::{Context, WeakEntity}; +use okena_core::process::is_process_alive; use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::{Terminal, TerminalSize}; use std::path::Path; @@ -16,7 +19,7 @@ impl ServiceManager { project_id: &str, service_name: &str, project_path: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); let instance = match self.instances.get_mut(&key) { @@ -46,11 +49,18 @@ impl ServiceManager { // Fire-and-forget: status poller will pick up the change let log_name = name.clone(); - cx.spawn(async move |this: WeakEntity, cx| { - let result = cx.background_executor() - .spawn(async move { + cx.spawn_main(async move |this, cx| { + let result = cx + .spawn_blocking(async move { let mut cmd = okena_core::process::command("docker"); - cmd.args(["compose", "-f", &compose_file, "start", &name]) + // `up -d`, not `start`: `start` only (re)starts an + // already-created container and does NOT resolve + // `depends_on`, so a service like contember-engine + // fails with "missing dependency postgres" when its + // deps aren't already up. `up -d ` creates + + // starts the service and its dependency graph + // (postgres first); it's idempotent for shared deps. + cmd.args(["compose", "-f", &compose_file, "up", "-d", &name]) .current_dir(&path); okena_core::process::safe_output(&mut cmd) }) @@ -62,7 +72,7 @@ impl ServiceManager { } // Trigger an immediate status poll let _ = this.update(cx, |_this, cx| cx.notify()); - }).detach(); + }); } ServiceKind::Okena => { self.start_okena_service(project_id, service_name, project_path, cx); @@ -76,7 +86,7 @@ impl ServiceManager { project_id: &str, service_name: &str, project_path: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); let instance = match self.instances.get_mut(&key) { @@ -152,7 +162,7 @@ impl ServiceManager { &mut self, project_id: &str, service_name: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); let instance = match self.instances.get_mut(&key) { @@ -177,9 +187,9 @@ impl ServiceManager { cx.notify(); let log_name = name.clone(); - cx.spawn(async move |_this: WeakEntity, cx| { - let result = cx.background_executor() - .spawn(async move { + cx.spawn_main(async move |_this, cx| { + let result = cx + .spawn_blocking(async move { let mut cmd = okena_core::process::command("docker"); cmd.args(["compose", "-f", &compose_file, "stop", &name]) .current_dir(&path); @@ -194,7 +204,7 @@ impl ServiceManager { Err(e) => log::error!("docker compose stop failed to run for '{}': {}", log_name, e), _ => {} } - }).detach(); + }); } ServiceKind::Okena => { instance.status = ServiceStatus::Stopped; @@ -211,7 +221,7 @@ impl ServiceManager { project_id: &str, service_name: &str, project_path: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); let instance = match self.instances.get_mut(&key) { @@ -237,9 +247,9 @@ impl ServiceManager { cx.notify(); let log_name = name.clone(); - cx.spawn(async move |this: WeakEntity, cx| { - let result = cx.background_executor() - .spawn(async move { + cx.spawn_main(async move |this, cx| { + let result = cx + .spawn_blocking(async move { let mut cmd = okena_core::process::command("docker"); cmd.args(["compose", "-f", &compose_file, "restart", &name]) .current_dir(&path); @@ -255,7 +265,7 @@ impl ServiceManager { _ => {} } let _ = this.update(cx, |_this, cx| cx.notify()); - }).detach(); + }); } ServiceKind::Okena => { // Take terminal_id now to prevent concurrent access. @@ -273,15 +283,14 @@ impl ServiceManager { let backend = self.backend.clone(); let terminals = self.terminals.clone(); - cx.spawn(async move |this: WeakEntity, cx| { + cx.spawn_main(async move |this, cx| { // Collect descendant PIDs on background executor. // get_service_pids() may spawn subprocesses (lsof/tmux) // and get_descendant_pids() may call pgrep/wmic. let old_pids: Vec = if let Some(ref tid) = terminal_id { let tid = tid.clone(); let backend_ref = backend.clone(); - cx.background_executor() - .spawn(async move { + cx.spawn_blocking(async move { backend_ref.get_service_pids(&tid) .into_iter() .flat_map(port_detect::get_descendant_pids) @@ -308,7 +317,7 @@ impl ServiceManager { if old_pids.iter().all(|&p| !is_process_alive(p)) { break; } - cx.background_executor().timer(Duration::from_millis(50)).await; + cx.timer(Duration::from_millis(50)).await; } } @@ -319,8 +328,7 @@ impl ServiceManager { this.start_service(&pid, &name, &path, cx); } }); - }) - .detach(); + }); } } } @@ -330,7 +338,7 @@ impl ServiceManager { &mut self, project_id: &str, project_path: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let names: Vec = self .instances @@ -345,7 +353,7 @@ impl ServiceManager { } /// Stop all services for a project. - pub fn stop_all(&mut self, project_id: &str, cx: &mut Context) { + pub fn stop_all(&mut self, project_id: &str, cx: &mut impl ServiceCx) { let names: Vec = self .instances .keys() @@ -364,7 +372,7 @@ impl ServiceManager { &mut self, terminal_id: &str, exit_code: Option, - cx: &mut Context, + cx: &mut impl ServiceCx, ) -> bool { let key = match self.terminal_to_service.remove(terminal_id) { Some(key) => key, @@ -399,15 +407,14 @@ impl ServiceManager { let delay = Duration::from_millis(instance.definition.restart_delay_ms); - cx.spawn(async move |this: WeakEntity, cx| { - cx.background_executor().timer(delay).await; + cx.spawn_main(async move |this, cx| { + cx.timer(delay).await; let _ = this.update(cx, |this, cx| { if let Some(project_path) = this.project_paths.get(&project_id).cloned() { this.start_service(&project_id, &service_name, &project_path, cx); } }); - }) - .detach(); + }); } else { // Crash without restart: keep terminal_id and Terminal in registry // so the user can see the crash output until they manually restart. diff --git a/crates/okena-services/src/manager/context.rs b/crates/okena-services/src/manager/context.rs new file mode 100644 index 000000000..c8e898f64 --- /dev/null +++ b/crates/okena-services/src/manager/context.rs @@ -0,0 +1,168 @@ +//! Reactor abstraction for the service manager. +//! +//! `ServiceManager`'s methods need a handful of reactor capabilities from their +//! context: mark the manager dirty (`notify`), spawn a main-context async task +//! that gets a cloneable handle to re-enter the manager (`spawn_main`), and, +//! from inside that task, offload blocking subprocess work to a background +//! thread (`spawn_blocking`), sleep (`timer`), and re-enter the manager to +//! mutate it (`ServiceHandle::update`). +//! +//! Today the only implementer is GPUI: [`ServiceCx`] for +//! `gpui::Context<'_, ServiceManager>`, [`ServiceHandle`] for +//! `gpui::WeakEntity`, and [`ServiceAsyncCx`] for +//! `gpui::AsyncApp`. A future GPUI-free daemon will add a second set of +//! implementers backed by a tokio reactor: the handle becomes an +//! `Arc>` and `update` re-locks the mutex instead of +//! upgrading a `WeakEntity`. +//! +//! Manager methods take `cx: &mut impl ServiceCx` instead of +//! `&mut Context`. This is a non-breaking change for existing GUI callers: +//! they keep passing `&mut Context`, which satisfies the trait +//! via the impls below. + +use super::ServiceManager; +#[cfg(feature = "gpui")] +use gpui::{AsyncApp, Context, WeakEntity}; +use std::future::Future; +use std::time::Duration; + +/// The reactor capabilities a [`ServiceManager`] method needs from its +/// (synchronous, main-context) context. +/// +/// The associated [`Handle`](ServiceCx::Handle) and +/// [`AsyncCx`](ServiceCx::AsyncCx) types are what a spawned task receives so it +/// can re-enter the manager. For GPUI these are `WeakEntity` and +/// `AsyncApp`; for the future daemon they will be `Arc>` +/// and the daemon's async handle. +pub trait ServiceCx { + /// A cloneable, `'static` handle to the manager, captured by spawned tasks + /// so they can re-enter it (the GPUI `WeakEntity::update` reentry). + type Handle: ServiceHandle; + + /// The async context held across await points inside a spawned task. + type AsyncCx: ServiceAsyncCx; + + /// Mark the manager dirty so observers (and cached views) re-evaluate. + /// + /// GPUI: `Context::notify`. Daemon: invoke registered change callbacks. + fn notify(&mut self); + + /// Spawn a main-context async task. The task receives a cloned, `'static` + /// [`Handle`](ServiceCx::Handle) (to re-enter the manager) and a mutable + /// [`AsyncCx`](ServiceCx::AsyncCx) (to spawn blocking work, sleep, and drive + /// the reentry). The task runs detached. + /// + /// GPUI: `cx.spawn(async move |this, cx| ...).detach()`, handing the task + /// the `WeakEntity` as the handle. + fn spawn_main(&self, f: F) + where + F: AsyncFnOnce(Self::Handle, &mut Self::AsyncCx) + 'static; +} + +/// A cloneable, `'static` handle to the manager that a spawned task uses to +/// re-enter it and mutate state. +pub trait ServiceHandle: Clone + 'static { + /// The async context this handle is driven with (matches + /// [`ServiceCx::AsyncCx`]). + type AsyncCx: ServiceAsyncCx; + + /// Re-enter the manager and run `f` against it. The callback receives the + /// manager plus a fresh synchronous [`ServiceCx`] (so it can `notify` and + /// `spawn_main` again). Returns `None` if the manager is gone (the GPUI + /// entity was released), mirroring `WeakEntity::update`'s error case. + /// + /// GPUI: `WeakEntity::update`, whose callback gets `&mut Context<_>` — itself + /// a `ServiceCx`. + fn update( + &self, + cx: &mut Self::AsyncCx, + f: impl FnOnce(&mut ServiceManager, &mut ::ReentryCx<'_>) -> R, + ) -> Option; +} + +/// The async context held across await points inside a spawned task: offload +/// blocking work, sleep, and (via [`ServiceHandle::update`]) re-enter the +/// manager. +pub trait ServiceAsyncCx { + /// The synchronous context a reentry callback sees. For GPUI this is + /// `Context<'_, ServiceManager>`, the same type that implements + /// [`ServiceCx`] — so reentry code can `notify`/`spawn_main` exactly like + /// the top-level methods. + type ReentryCx<'a>: ServiceCx + where + Self: 'a; + + /// Offload blocking work (subprocess calls) to a background thread and await + /// the result. + /// + /// GPUI: `cx.background_executor().spawn(fut)`. + fn spawn_blocking( + &self, + fut: impl Future + Send + 'static, + ) -> impl Future + where + T: Send + 'static; + + /// Async sleep. + /// + /// GPUI: `cx.background_executor().timer(d)`. + fn timer(&self, duration: Duration) -> impl Future; +} + +// --- GPUI implementers --------------------------------------------------- + +#[cfg(feature = "gpui")] +impl ServiceCx for Context<'_, ServiceManager> { + type Handle = WeakEntity; + type AsyncCx = AsyncApp; + + fn notify(&mut self) { + // The inherent `Context::notify` shadows this trait method during method + // resolution (inherent methods win), so this is a direct call into GPUI, + // not recursion back into the trait impl. + self.notify(); + } + + fn spawn_main(&self, f: F) + where + F: AsyncFnOnce(Self::Handle, &mut Self::AsyncCx) + 'static, + { + // `Context::spawn` hands the closure the `WeakEntity` and `&mut AsyncApp` + // directly — the same shape as our trait method. + self.spawn(async move |this, cx| f(this, cx).await).detach(); + } +} + +#[cfg(feature = "gpui")] +impl ServiceHandle for WeakEntity { + type AsyncCx = AsyncApp; + + fn update( + &self, + cx: &mut Self::AsyncCx, + f: impl FnOnce(&mut ServiceManager, &mut Context<'_, ServiceManager>) -> R, + ) -> Option { + // `WeakEntity::update` returns Err if the entity was released; map that + // to None (callers already treat it as "manager gone, stop"). + WeakEntity::update(self, cx, f).ok() + } +} + +#[cfg(feature = "gpui")] +impl ServiceAsyncCx for AsyncApp { + type ReentryCx<'a> = Context<'a, ServiceManager>; + + fn spawn_blocking( + &self, + fut: impl Future + Send + 'static, + ) -> impl Future + where + T: Send + 'static, + { + self.background_executor().spawn(fut) + } + + fn timer(&self, duration: Duration) -> impl Future { + self.background_executor().timer(duration) + } +} diff --git a/crates/okena-services/src/manager/docker.rs b/crates/okena-services/src/manager/docker.rs index 33fe6446f..7a441a43b 100644 --- a/crates/okena-services/src/manager/docker.rs +++ b/crates/okena-services/src/manager/docker.rs @@ -1,9 +1,11 @@ //! Docker Compose service discovery, log-viewer PTYs, and status polling. -use super::{ServiceInstance, ServiceKind, ServiceManager, ServiceStatus}; +use super::{ + ServiceAsyncCx, ServiceCx, ServiceHandle, ServiceInstance, ServiceKind, ServiceManager, + ServiceStatus, +}; use crate::config::ServiceDefinition; use crate::docker_compose; -use gpui::{Context, WeakEntity}; use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::{Terminal, TerminalSize}; use std::collections::HashMap; @@ -20,7 +22,7 @@ impl ServiceManager { project_id: &str, project_path: &str, docker_config: Option<&crate::config::DockerComposeConfig>, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { // Check if explicitly disabled if docker_config.as_ref().is_some_and(|dc| dc.enabled == Some(false)) { @@ -43,7 +45,7 @@ impl ServiceManager { let project_path = project_path.to_string(); // Move docker subprocess calls to background executor - cx.spawn(async move |this: WeakEntity, cx| { + cx.spawn_main(async move |this, cx| { let service_names = { let path = project_path.clone(); let file = compose_file.clone(); @@ -92,8 +94,7 @@ impl ServiceManager { this.start_docker_status_poller(&project_id, &project_path, &compose_file, cx); cx.notify(); }); - }) - .detach(); + }); } /// Reload Docker Compose services on config reload. @@ -102,7 +103,7 @@ impl ServiceManager { project_id: &str, project_path: &str, docker_config: Option<&crate::config::DockerComposeConfig>, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { // Stop existing poller if let Some(cancel) = self.docker_pollers.remove(project_id) { @@ -136,7 +137,7 @@ impl ServiceManager { &mut self, project_id: &str, service_name: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); let instance = match self.instances.get_mut(&key) { @@ -206,7 +207,7 @@ impl ServiceManager { project_id: &str, project_path: &str, compose_file: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { // Cancel any existing poller for this project if let Some(old_cancel) = self.docker_pollers.remove(project_id) { @@ -220,9 +221,9 @@ impl ServiceManager { let path = project_path.to_string(); let file = compose_file.to_string(); - cx.spawn(async move |this: WeakEntity, cx| { + cx.spawn_main(async move |this, cx| { // Small initial delay - cx.background_executor().timer(Duration::from_secs(1)).await; + cx.timer(Duration::from_secs(1)).await; let mut consecutive_failures: u32 = 0; @@ -288,8 +289,8 @@ impl ServiceManager { } else { (5u64 << consecutive_failures.min(4)).min(60) }; - cx.background_executor().timer(Duration::from_secs(delay)).await; + cx.timer(Duration::from_secs(delay)).await; } - }).detach(); + }); } } diff --git a/crates/okena-services/src/manager/lifecycle.rs b/crates/okena-services/src/manager/lifecycle.rs index 53047b2ba..bf2f982b5 100644 --- a/crates/okena-services/src/manager/lifecycle.rs +++ b/crates/okena-services/src/manager/lifecycle.rs @@ -1,8 +1,7 @@ //! Project service set lifecycle: load, unload, reload `okena.yaml`. -use super::{ServiceInstance, ServiceKind, ServiceManager, ServiceStatus}; +use super::{ServiceCx, ServiceInstance, ServiceKind, ServiceManager, ServiceStatus}; use crate::config::load_project_config; -use gpui::Context; use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::{Terminal, TerminalSize}; use std::collections::HashMap; @@ -19,7 +18,7 @@ impl ServiceManager { project_id: &str, project_path: &str, saved_terminal_ids: &HashMap, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { log::info!("[services] load_project_services project_id={} path={}", project_id, project_path); let config = match load_project_config(project_path) { @@ -99,7 +98,7 @@ impl ServiceManager { service_name: &str, project_path: &str, saved_terminal_id: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); let instance = match self.instances.get_mut(&key) { @@ -153,7 +152,7 @@ impl ServiceManager { } /// Stop all running services for a project and remove all instances/configs. - pub fn unload_project_services(&mut self, project_id: &str, cx: &mut Context) { + pub fn unload_project_services(&mut self, project_id: &str, cx: &mut impl ServiceCx) { // Stop Docker status poller if let Some(cancel) = self.docker_pollers.remove(project_id) { cancel.store(true, Ordering::Relaxed); @@ -187,7 +186,7 @@ impl ServiceManager { &mut self, project_id: &str, project_path: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let new_config = match load_project_config(project_path) { Ok(Some(config)) => config, diff --git a/crates/okena-services/src/manager/mod.rs b/crates/okena-services/src/manager/mod.rs index 7b2394c3a..c3bd15efa 100644 --- a/crates/okena-services/src/manager/mod.rs +++ b/crates/okena-services/src/manager/mod.rs @@ -7,10 +7,13 @@ //! - [`port_detection`] — centralized listening-port discovery poller mod commands; +mod context; mod docker; mod lifecycle; mod port_detection; +pub use context::{ServiceAsyncCx, ServiceCx, ServiceHandle}; + use crate::config::ServiceDefinition; use okena_terminal::backend::TerminalBackend; use okena_terminal::TerminalsRegistry; @@ -57,6 +60,39 @@ pub struct ServiceInstance { pub is_extra: bool, } +impl ServiceInstance { + /// Project this runtime service instance onto its wire form + /// ([`okena_core::api::ApiServiceInfo`]). + /// + /// Shared by both remote command loops (GUI `remote_command_loop` and the + /// headless `daemon_command_loop`) so the `status` / `kind` string mapping + /// lives in exactly one place. Keeping it here (rather than in the gpui-free + /// `okena-app-core` shared builder) avoids adding an `okena-services` + /// dependency to `okena-app-core`. + pub fn to_api(&self) -> okena_core::api::ApiServiceInfo { + let (status, exit_code) = match &self.status { + ServiceStatus::Stopped => ("stopped", None), + ServiceStatus::Starting => ("starting", None), + ServiceStatus::Running => ("running", None), + ServiceStatus::Crashed { exit_code } => ("crashed", *exit_code), + ServiceStatus::Restarting => ("restarting", None), + }; + let kind = match &self.kind { + ServiceKind::Okena => "okena", + ServiceKind::DockerCompose { .. } => "docker_compose", + }; + okena_core::api::ApiServiceInfo { + name: self.definition.name.clone(), + status: status.to_string(), + terminal_id: self.terminal_id.clone(), + ports: self.detected_ports.clone(), + exit_code, + kind: kind.to_string(), + is_extra: self.is_extra, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub enum ServiceStatus { Stopped, @@ -81,6 +117,96 @@ impl ServiceStatus { pub(super) const MAX_RESTART_COUNT: u32 = 5; +impl ServiceManager { + /// Remote-action wrappers for the service commands, returning the wire + /// [`CommandResult`] directly. + /// + /// Both remote command loops (GUI `remote_command_loop` and the headless + /// `daemon_command_loop`) dispatch the service `ActionRequest`s with + /// identical inner logic: look up the project path and, if present, run the + /// command and reply `Ok`; otherwise reply `Err("project not found: …")`. + /// The only difference between the loops is how they obtain `&mut self` + + /// `cx` (entity `update` vs. `Mutex` lock + reactor cx). Centralizing the + /// logic here (generic over any [`ServiceCx`]) keeps the two loops to pure + /// cx-access glue. + pub fn start_service_action( + &mut self, + project_id: &str, + service_name: &str, + cx: &mut impl ServiceCx, + ) -> okena_core::api::CommandResult { + match self.project_path(project_id).cloned() { + Some(path) => { + self.start_service(project_id, service_name, &path, cx); + okena_core::api::CommandResult::Ok(None) + } + None => okena_core::api::CommandResult::Err(format!("project not found: {project_id}")), + } + } + + pub fn stop_service_action( + &mut self, + project_id: &str, + service_name: &str, + cx: &mut impl ServiceCx, + ) -> okena_core::api::CommandResult { + self.stop_service(project_id, service_name, cx); + okena_core::api::CommandResult::Ok(None) + } + + pub fn restart_service_action( + &mut self, + project_id: &str, + service_name: &str, + cx: &mut impl ServiceCx, + ) -> okena_core::api::CommandResult { + match self.project_path(project_id).cloned() { + Some(path) => { + self.restart_service(project_id, service_name, &path, cx); + okena_core::api::CommandResult::Ok(None) + } + None => okena_core::api::CommandResult::Err(format!("project not found: {project_id}")), + } + } + + pub fn start_all_action( + &mut self, + project_id: &str, + cx: &mut impl ServiceCx, + ) -> okena_core::api::CommandResult { + match self.project_path(project_id).cloned() { + Some(path) => { + self.start_all(project_id, &path, cx); + okena_core::api::CommandResult::Ok(None) + } + None => okena_core::api::CommandResult::Err(format!("project not found: {project_id}")), + } + } + + pub fn stop_all_action( + &mut self, + project_id: &str, + cx: &mut impl ServiceCx, + ) -> okena_core::api::CommandResult { + self.stop_all(project_id, cx); + okena_core::api::CommandResult::Ok(None) + } + + pub fn reload_services_action( + &mut self, + project_id: &str, + cx: &mut impl ServiceCx, + ) -> okena_core::api::CommandResult { + match self.project_path(project_id).cloned() { + Some(path) => { + self.reload_project_services(project_id, &path, cx); + okena_core::api::CommandResult::Ok(None) + } + None => okena_core::api::CommandResult::Err(format!("project not found: {project_id}")), + } + } +} + impl ServiceManager { pub fn new(backend: Arc, terminals: TerminalsRegistry) -> Self { Self { @@ -178,20 +304,5 @@ impl ServiceManager { } } -/// Check if a process with the given PID is still alive. -pub(super) fn is_process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - // signal 0 doesn't send a signal but checks if process exists - unsafe { libc::kill(pid as i32, 0) == 0 } - } - #[cfg(not(unix))] - { - // On non-Unix, conservatively assume alive (caller will time out) - let _ = pid; - true - } -} - #[cfg(test)] mod tests; diff --git a/crates/okena-services/src/manager/port_detection.rs b/crates/okena-services/src/manager/port_detection.rs index 712f55b4a..679f89fbb 100644 --- a/crates/okena-services/src/manager/port_detection.rs +++ b/crates/okena-services/src/manager/port_detection.rs @@ -1,9 +1,10 @@ //! Centralized port discovery poller: builds the process tree once per cycle //! and distributes listening ports to all services awaiting detection. -use super::{PortDetectionState, ServiceManager, ServiceStatus}; +use super::{ + PortDetectionState, ServiceAsyncCx, ServiceCx, ServiceHandle, ServiceManager, ServiceStatus, +}; use crate::port_detect; -use gpui::{Context, WeakEntity}; use std::time::Duration; impl ServiceManager { @@ -14,7 +15,7 @@ impl ServiceManager { &mut self, project_id: &str, service_name: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { let key = (project_id.to_string(), service_name.to_string()); if self.instances.get(&key).and_then(|i| i.terminal_id.as_ref()).is_none() { @@ -34,16 +35,16 @@ impl ServiceManager { /// Ensure the centralized port detection poller is running. /// One poller handles all services: builds the process tree once, /// calls the port scanner once, then distributes results. - fn ensure_port_detection_poller(&mut self, cx: &mut Context) { + fn ensure_port_detection_poller(&mut self, cx: &mut impl ServiceCx) { if self.port_detection_running { return; } self.port_detection_running = true; let backend = self.backend.clone(); - cx.spawn(async move |this: WeakEntity, cx| { + cx.spawn_main(async move |this, cx| { // Initial delay — let newly started services bind their ports - cx.background_executor().timer(Duration::from_secs(2)).await; + cx.timer(Duration::from_secs(2)).await; loop { // Collect all services that need port detection + their terminal IDs @@ -75,8 +76,7 @@ impl ServiceManager { // scan ports ONCE, distribute results. let backend_ref = backend.clone(); let results: Vec<((String, String), Vec)> = cx - .background_executor() - .spawn(async move { + .spawn_blocking(async move { // Get root PIDs for all services in one batch. // On Linux+dtach this reads /proc once instead of spawning lsof per terminal. let terminal_ids: Vec<&str> = @@ -201,9 +201,8 @@ impl ServiceManager { return; } - cx.background_executor().timer(Duration::from_secs(5)).await; + cx.timer(Duration::from_secs(5)).await; } - }) - .detach(); + }); } } diff --git a/crates/okena-services/src/port_detect.rs b/crates/okena-services/src/port_detect.rs index b49fc9fc3..5bb18fe64 100644 --- a/crates/okena-services/src/port_detect.rs +++ b/crates/okena-services/src/port_detect.rs @@ -266,7 +266,10 @@ fn extract_port_from_ss_line(line: &str) -> Option { } /// Parse `lsof -iTCP -sTCP:LISTEN -P -n` output into (pid, port) pairs. -#[cfg(any(target_os = "macos", test))] +/// +/// macOS now scans listening sockets via libproc (`get_listening_port_pairs_macos`) +/// rather than spawning `lsof`, so this parser is retained only for its tests. +#[cfg(test)] pub(crate) fn parse_lsof_output(stdout: &str) -> Vec<(u32, u16)> { let mut pairs = Vec::new(); for line in stdout.lines().skip(1) { diff --git a/crates/okena-state/src/hooks_config.rs b/crates/okena-state/src/hooks_config.rs index bb273e948..fae33f21c 100644 --- a/crates/okena-state/src/hooks_config.rs +++ b/crates/okena-state/src/hooks_config.rs @@ -50,7 +50,7 @@ pub struct WorktreeHooks { /// Grouped hook configuration (project, terminal, worktree). /// Backward-compatible: deserializes both the old flat format and the new grouped format. -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize)] pub struct HooksConfig { #[serde(default, skip_serializing_if = "is_default")] pub project: ProjectHooks, @@ -64,6 +64,58 @@ fn is_default(val: &T) -> bool { *val == T::default() } +impl HooksConfig { + /// Project onto the wire mirror in `okena-core` (see [`okena_core::api::ApiHooksConfig`]). + pub fn to_api(&self) -> okena_core::api::ApiHooksConfig { + okena_core::api::ApiHooksConfig { + project: okena_core::api::ApiProjectHooks { + on_open: self.project.on_open.clone(), + on_close: self.project.on_close.clone(), + }, + terminal: okena_core::api::ApiTerminalHooks { + on_create: self.terminal.on_create.clone(), + on_close: self.terminal.on_close.clone(), + shell_wrapper: self.terminal.shell_wrapper.clone(), + }, + worktree: okena_core::api::ApiWorktreeHooks { + on_create: self.worktree.on_create.clone(), + on_close: self.worktree.on_close.clone(), + pre_merge: self.worktree.pre_merge.clone(), + post_merge: self.worktree.post_merge.clone(), + before_remove: self.worktree.before_remove.clone(), + after_remove: self.worktree.after_remove.clone(), + on_rebase_conflict: self.worktree.on_rebase_conflict.clone(), + on_dirty_close: self.worktree.on_dirty_close.clone(), + }, + } + } + + /// Rebuild from the wire mirror. + pub fn from_api(api: &okena_core::api::ApiHooksConfig) -> Self { + HooksConfig { + project: ProjectHooks { + on_open: api.project.on_open.clone(), + on_close: api.project.on_close.clone(), + }, + terminal: TerminalHooks { + on_create: api.terminal.on_create.clone(), + on_close: api.terminal.on_close.clone(), + shell_wrapper: api.terminal.shell_wrapper.clone(), + }, + worktree: WorktreeHooks { + on_create: api.worktree.on_create.clone(), + on_close: api.worktree.on_close.clone(), + pre_merge: api.worktree.pre_merge.clone(), + post_merge: api.worktree.post_merge.clone(), + before_remove: api.worktree.before_remove.clone(), + after_remove: api.worktree.after_remove.clone(), + on_rebase_conflict: api.worktree.on_rebase_conflict.clone(), + on_dirty_close: api.worktree.on_dirty_close.clone(), + }, + } + } +} + const FLAT_HOOK_KEYS: &[&str] = &[ "on_project_open", "on_project_close", "on_worktree_create", "on_worktree_close", @@ -268,4 +320,40 @@ mod tests { assert_eq!(config.terminal, TerminalHooks::default()); assert_eq!(config.worktree, WorktreeHooks::default()); } + + #[test] + fn api_round_trip_preserves_every_field() { + // Every field set to a distinct value so a mis-wired converter (a copy + // paste swap between project/terminal/worktree) is caught. + let original = HooksConfig { + project: ProjectHooks { + on_open: Some("p-open".into()), + on_close: Some("p-close".into()), + }, + terminal: TerminalHooks { + on_create: Some("t-create".into()), + on_close: Some("t-close".into()), + shell_wrapper: Some("wrap {shell}".into()), + }, + worktree: WorktreeHooks { + on_create: Some("w-create".into()), + on_close: Some("w-close".into()), + pre_merge: Some("pre".into()), + post_merge: Some("post".into()), + before_remove: Some("before".into()), + after_remove: Some("after".into()), + on_rebase_conflict: Some("rebase".into()), + on_dirty_close: Some("dirty".into()), + }, + }; + let back = HooksConfig::from_api(&original.to_api()); + assert_eq!(back, original); + } + + #[test] + fn api_round_trip_default_is_empty() { + let api = HooksConfig::default().to_api(); + assert!(api.is_empty()); + assert_eq!(HooksConfig::from_api(&api), HooksConfig::default()); + } } diff --git a/crates/okena-state/src/toast.rs b/crates/okena-state/src/toast.rs index 756d84d16..a522140dc 100644 --- a/crates/okena-state/src/toast.rs +++ b/crates/okena-state/src/toast.rs @@ -2,6 +2,7 @@ //! //! Pure data — the GPUI-backed `ToastManager` lives in `okena-workspace`. +use okena_core::api::ApiToast; use std::time::{Duration, Instant}; /// Default time-to-live for toast notifications @@ -27,6 +28,25 @@ pub enum ToastActionStyle { Danger, } +impl ToastActionStyle { + /// Stable lowercase wire token. + fn as_wire_str(self) -> &'static str { + match self { + ToastActionStyle::Default => "default", + ToastActionStyle::Primary => "primary", + ToastActionStyle::Danger => "danger", + } + } + /// Parse a wire token; unknown tokens default to `Default`. + fn from_wire_str(s: &str) -> Self { + match s { + "primary" => ToastActionStyle::Primary, + "danger" => ToastActionStyle::Danger, + _ => ToastActionStyle::Default, + } + } +} + /// A clickable button rendered inside a toast. /// /// `id` is opaque to this crate — the view layer that posted the toast is @@ -134,6 +154,78 @@ impl Toast { pub fn is_expired(&self) -> bool { self.created.elapsed() >= self.ttl } + + /// Project onto the serde-serializable wire type for forwarding over the + /// remote protocol. Drops the local-only `created` (the receiver stamps its + /// own); the `ttl` travels as milliseconds and the `actions` travel as + /// [`okena_core::api::ApiToastAction`] buttons. + pub fn to_api(&self) -> ApiToast { + ApiToast { + id: self.id.clone(), + level: self.level.as_wire_str().to_string(), + message: self.message.clone(), + detail: self.detail.clone(), + // Saturate rather than wrap on the (practically impossible) overflow + // of a multi-billion-year TTL; avoids a lossy `as` cast. + ttl_ms: u64::try_from(self.ttl.as_millis()).unwrap_or(u64::MAX), + actions: self + .actions + .iter() + .map(|a| okena_core::api::ApiToastAction { + id: a.id.clone(), + label: a.label.clone(), + style: a.style.as_wire_str().to_string(), + }) + .collect(), + } + } + + /// Reconstruct a local toast from a wire `ApiToast`. Stamps a fresh + /// `created` (the wire type carries no timestamp), rebuilds the `ttl` from + /// `ttl_ms`, and rebuilds the `actions` from the wire buttons. An + /// unrecognized `level` string falls back to [`ToastLevel::Info`]. + pub fn from_api(api: &ApiToast) -> Self { + Self { + id: api.id.clone(), + level: ToastLevel::from_wire_str(&api.level), + message: api.message.clone(), + detail: api.detail.clone(), + created: Instant::now(), + ttl: Duration::from_millis(api.ttl_ms), + actions: api + .actions + .iter() + .map(|a| ToastAction { + id: a.id.clone(), + label: a.label.clone(), + style: ToastActionStyle::from_wire_str(&a.style), + }) + .collect(), + } + } +} + +impl ToastLevel { + /// Stable lowercase wire token for this level. + fn as_wire_str(self) -> &'static str { + match self { + ToastLevel::Success => "success", + ToastLevel::Error => "error", + ToastLevel::Warning => "warning", + ToastLevel::Info => "info", + } + } + + /// Parse a wire token back into a level, defaulting unknown tokens to `Info` + /// so a future server adding a level never breaks an older client. + fn from_wire_str(s: &str) -> Self { + match s { + "success" => ToastLevel::Success, + "error" => ToastLevel::Error, + "warning" => ToastLevel::Warning, + _ => ToastLevel::Info, + } + } } impl PartialEq for Toast { @@ -141,3 +233,90 @@ impl PartialEq for Toast { self.id == other.id } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_api_maps_all_fields() { + let toast = Toast::error("boom") + .with_id("fixed-id") + .with_detail("context line") + .with_ttl(Duration::from_millis(7500)); + let api = toast.to_api(); + assert_eq!(api.id, "fixed-id"); + assert_eq!(api.level, "error"); + assert_eq!(api.message, "boom"); + assert_eq!(api.detail.as_deref(), Some("context line")); + assert_eq!(api.ttl_ms, 7500); + } + + #[test] + fn all_levels_round_trip_through_wire() { + for level in [ + ToastLevel::Success, + ToastLevel::Error, + ToastLevel::Warning, + ToastLevel::Info, + ] { + assert_eq!(ToastLevel::from_wire_str(level.as_wire_str()), level); + } + } + + #[test] + fn unknown_wire_level_falls_back_to_info() { + assert_eq!(ToastLevel::from_wire_str("nope"), ToastLevel::Info); + } + + /// `to_api` → `from_api` preserves the serializable fields (id / level / + /// message / detail / ttl). `created` is freshly stamped, so it is + /// intentionally not part of the round-trip; the toast under test carries no + /// actions, so `restored.actions` stays empty. + #[test] + fn api_round_trip_preserves_serializable_fields() { + let original = Toast::warning("careful") + .with_id("rt-1") + .with_detail("more") + .with_ttl(Duration::from_millis(3210)); + let restored = Toast::from_api(&original.to_api()); + assert_eq!(restored.id, original.id); + assert_eq!(restored.level, original.level); + assert_eq!(restored.message, original.message); + assert_eq!(restored.detail, original.detail); + assert_eq!(restored.ttl, original.ttl); + assert!(restored.actions.is_empty()); + } + + #[test] + fn api_round_trip_preserves_actions() { + let original = Toast::info("closed") + .with_id("sc-1") + .with_actions(vec![ + ToastAction::new("soft_close_undo:p:t", "Undo", ToastActionStyle::Primary), + ToastAction::new("soft_close_kill:p:t", "Close now", ToastActionStyle::Danger), + ]); + let restored = Toast::from_api(&original.to_api()); + assert_eq!(restored.actions.len(), 2); + assert_eq!(restored.actions[0].id, "soft_close_undo:p:t"); + assert_eq!(restored.actions[0].label, "Undo"); + assert_eq!(restored.actions[0].style, ToastActionStyle::Primary); + assert_eq!(restored.actions[1].style, ToastActionStyle::Danger); + } + + #[test] + fn from_api_with_no_detail() { + let api = ApiToast { + id: "x".into(), + level: "info".into(), + message: "hi".into(), + detail: None, + ttl_ms: 1000, + actions: Vec::new(), + }; + let toast = Toast::from_api(&api); + assert_eq!(toast.level, ToastLevel::Info); + assert!(toast.detail.is_none()); + assert_eq!(toast.ttl, Duration::from_millis(1000)); + } +} diff --git a/crates/okena-state/src/workspace_data.rs b/crates/okena-state/src/workspace_data.rs index c72f73d36..1e1160b49 100644 --- a/crates/okena-state/src/workspace_data.rs +++ b/crates/okena-state/src/workspace_data.rs @@ -48,6 +48,23 @@ pub struct WorkspaceData { } impl WorkspaceData { + /// An empty workspace: no projects, no folders. Used as the GUI client's + /// starting state in `--daemon-client` mode — the daemon owns the real + /// workspace and its projects arrive via the mirror snapshot + /// (`apply_remote_snapshot`), so the client must not seed a default project. + pub fn empty() -> Self { + WorkspaceData { + version: default_workspace_version(), + projects: Vec::new(), + project_order: Vec::new(), + folders: Vec::new(), + service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), + main_window: WindowState::default(), + extra_windows: Vec::new(), + } + } + /// Return a copy with all remote projects, remote folders, and their /// associated widths/heights stripped out (for saving to disk). /// @@ -136,6 +153,56 @@ pub struct HookTerminalEntry { pub cwd: String, } +impl HookTerminalStatus { + /// Project onto the wire mirror (`okena-core` can't reference this enum). + pub fn to_api(&self) -> okena_core::api::ApiHookTerminalStatus { + use okena_core::api::ApiHookTerminalStatus as A; + match self { + HookTerminalStatus::Running => A::Running, + HookTerminalStatus::Succeeded => A::Succeeded, + HookTerminalStatus::Failed { exit_code } => A::Failed { exit_code: *exit_code }, + } + } + + /// Reconstruct from the wire mirror. + pub fn from_api(api: &okena_core::api::ApiHookTerminalStatus) -> Self { + use okena_core::api::ApiHookTerminalStatus as A; + match api { + A::Running => HookTerminalStatus::Running, + A::Succeeded => HookTerminalStatus::Succeeded, + A::Failed { exit_code } => HookTerminalStatus::Failed { exit_code: *exit_code }, + } + } +} + +impl HookTerminalEntry { + /// Project onto the wire mirror, inlining the map key as `terminal_id`. + pub fn to_api(&self, terminal_id: String) -> okena_core::api::ApiHookTerminalEntry { + okena_core::api::ApiHookTerminalEntry { + terminal_id, + label: self.label.clone(), + status: self.status.to_api(), + hook_type: self.hook_type.clone(), + command: self.command.clone(), + cwd: self.cwd.clone(), + } + } + + /// Reconstruct the `(terminal_id, entry)` pair from the wire mirror. + pub fn from_api(api: &okena_core::api::ApiHookTerminalEntry) -> (String, Self) { + ( + api.terminal_id.clone(), + HookTerminalEntry { + label: api.label.clone(), + status: HookTerminalStatus::from_api(&api.status), + hook_type: api.hook_type.clone(), + command: api.command.clone(), + cwd: api.cwd.clone(), + }, + ) + } +} + /// A single project with its layout tree #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProjectData { @@ -194,15 +261,14 @@ pub struct ProjectData { impl ProjectData { /// Get the display name for a terminal. - /// Priority: user-set custom name > non-prompt OSC title > directory-based fallback. - /// OSC titles matching bash prompt format (user@host:...) are ignored in favor - /// of the directory name. Explicit titles (e.g. from printf) are shown. + /// Priority: user-set custom name > useful OSC title > directory-based fallback. + /// Shell/runtime-generated titles are ignored in favor of the directory name. pub fn terminal_display_name(&self, terminal_id: &str, osc_title: Option) -> String { if let Some(custom_name) = self.terminal_names.get(terminal_id) { return custom_name.clone(); } if let Some(ref title) = osc_title - && !is_bash_prompt_title(title) { + && !is_generated_terminal_title(title) { return title.clone(); } self.directory_name() @@ -237,6 +303,10 @@ pub fn is_bash_prompt_title(title: &str) -> bool { i > 1 && i < bytes.len() && bytes[i] == b':' } +fn is_generated_terminal_title(title: &str) -> bool { + is_bash_prompt_title(title) || title == "MainThread" +} + fn default_workspace_version() -> u32 { 0 // pre-versioning workspace files } @@ -316,6 +386,15 @@ mod tests { ); } + #[test] + fn terminal_display_name_ignores_codex_main_thread_title() { + let project = make_project("/home/user/myproject"); + assert_eq!( + project.terminal_display_name("t1", Some("MainThread".to_string())), + "myproject" + ); + } + #[test] fn terminal_display_name_shows_explicit_osc_title() { let project = make_project("/home/user/myproject"); diff --git a/crates/okena-terminal/src/pty_manager.rs b/crates/okena-terminal/src/pty_manager.rs index b9f0c1b4d..6f81cd4cb 100644 --- a/crates/okena-terminal/src/pty_manager.rs +++ b/crates/okena-terminal/src/pty_manager.rs @@ -21,7 +21,15 @@ pub trait PtyOutputSink: Send + Sync { /// `server_owns` is true when the origin's local user currently holds resize /// authority. Clients use it to stop re-asserting their own window size and /// defer to the origin instead of fighting it back over the next round-trip. - fn publish_resize(&self, _terminal_id: String, _cols: u16, _rows: u16, _server_owns: bool) {} + fn publish_resize( + &self, + _terminal_id: String, + _cols: u16, + _rows: u16, + _server_owns: bool, + _owner_connection_id: Option, + ) { + } } /// Events from PTY processes @@ -119,6 +127,9 @@ struct PtyHandle { /// `Option` so teardown and the `Drop` backstop can both `take()` it /// idempotently to close the channel and unblock the writer thread. input_tx: Option>>, + /// Shared PTY writer, also held by the batched writer thread. Lets + /// `write_response` write query replies synchronously (see that method). + writer: Option>>>, reader_handle: Option>, writer_handle: Option>, shutdown: Arc, @@ -366,9 +377,12 @@ impl PtyManager { // Spawn the process let child = pair.slave.spawn_command(cmd)?; - // Get reader and writer + // Get reader and writer. The writer is shared (`Arc`) so query + // replies can be written synchronously via `write_response`, ahead of the + // batched writer thread, without racing the querying program's exit. let reader = pair.master.try_clone_reader()?; - let writer = pair.master.take_writer()?; + let writer: Arc>> = + Arc::new(Mutex::new(pair.master.take_writer()?)); let shutdown = Arc::new(PtyShutdownState::new(terminal_id.to_string())); let child_pid = child.process_id(); @@ -401,6 +415,8 @@ impl PtyManager { let writer_shutdown = Arc::clone(&shutdown); let writer_event_tx = self.event_tx.clone(); let writer_id = terminal_id.to_string(); + // The batched writer thread shares the writer with `write_response`. + let writer_for_thread = Arc::clone(&writer); let writer_handle = std::thread::Builder::new() .name(format!("pty-writer-{}", &terminal_id[..8.min(terminal_id.len())])) .spawn(move || { @@ -408,7 +424,7 @@ impl PtyManager { let shutdown_panic = Arc::clone(&writer_shutdown); let id_panic = writer_id.clone(); if let Err(panic) = std::panic::catch_unwind(AssertUnwindSafe(|| { - Self::write_loop(writer, input_rx, writer_shutdown, writer_event_tx, writer_id); + Self::write_loop(writer_for_thread, input_rx, writer_shutdown, writer_event_tx, writer_id); })) { log::error!("PTY writer thread panicked: {}", format_panic(&*panic)); shutdown_panic.mark_broken(); @@ -426,6 +442,7 @@ impl PtyManager { master: Some(pair.master), child, input_tx: Some(input_tx), + writer: Some(writer), reader_handle: Some(reader_handle), writer_handle: Some(writer_handle), shutdown, @@ -646,9 +663,11 @@ impl PtyManager { } } - /// Write loop for PTY input - batches writes for better performance + /// Write loop for PTY input - batches writes for better performance. + /// Shares the writer with `write_response` (query replies) via the `Mutex`; + /// the lock is held only for the duration of each `write_all`. fn write_loop( - mut writer: Box, + writer: Arc>>, rx: mpsc::Receiver>, shutdown: Arc, event_tx: Sender, @@ -663,7 +682,7 @@ impl PtyManager { } // Write the batched data - if let Err(e) = writer.write_all(&batch) { + if let Err(e) = writer.lock().write_all(&batch) { log::error!("Failed to write to PTY {}: {}", terminal_id, e); shutdown.mark_broken(); let _ = event_tx.send_blocking(PtyEvent::Exit { @@ -685,6 +704,26 @@ impl PtyManager { } } + /// Write a query reply straight to the PTY master + flush, bypassing the + /// batched input channel and writer-thread scheduling. This shrinks the + /// window between a program's Device-Attributes/cursor query and its exit + /// back to the shell, so the reply reaches the program instead of leaking to + /// the shell prompt (e.g. a stray `6c` after closing nvim). The writer `Arc` + /// is cloned out under the registry lock, which is released before the + /// (potentially blocking) PTY write. + pub fn write_response(&self, terminal_id: &str, data: &[u8]) { + let writer = { + let terminals = self.terminals.lock(); + terminals.get(terminal_id).and_then(|h| h.writer.clone()) + }; + if let Some(writer) = writer { + let mut w = writer.lock(); + if let Err(e) = w.write_all(data).and_then(|_| w.flush()) { + log::debug!("write_response to PTY {} failed: {}", terminal_id, e); + } + } + } + /// Resize a terminal pub fn resize(&self, terminal_id: &str, cols: u16, rows: u16) { if let Some(handle) = self.terminals.lock().get(terminal_id) @@ -702,8 +741,19 @@ impl PtyManager { // resize comes from the origin's local user reclaiming control — in // which case the client must stop re-asserting its own size. if let Some(sink) = self.output_sink.lock().as_ref() { - let server_owns = crate::terminal::is_resize_authority_local(); - sink.publish_resize(terminal_id.to_string(), cols, rows, server_owns); + let authority = crate::terminal::resize_authority_snapshot(terminal_id); + log::debug!( + "pty resize publish: terminal={terminal_id} {cols}x{rows} local={} owner={:?}", + authority.local, + authority.remote_owner_id + ); + sink.publish_resize( + terminal_id.to_string(), + cols, + rows, + authority.local, + authority.remote_owner_id, + ); } } @@ -1120,6 +1170,10 @@ impl crate::terminal::TerminalTransport for PtyManager { self.send_input(terminal_id, data) } + fn send_response(&self, terminal_id: &str, data: &[u8]) { + self.write_response(terminal_id, data) + } + fn resize(&self, terminal_id: &str, cols: u16, rows: u16) { self.resize(terminal_id, cols, rows) } diff --git a/crates/okena-terminal/src/session_backend.rs b/crates/okena-terminal/src/session_backend.rs index fa93c826d..eee95ef9f 100644 --- a/crates/okena-terminal/src/session_backend.rs +++ b/crates/okena-terminal/src/session_backend.rs @@ -164,6 +164,12 @@ pub enum ResolvedBackend { Psmux, } +/// Session-name prefix for Okena-managed persistent sessions (tmux/screen/dtach). +/// Shared by the session-name builder and the dtach socket-GC filter so cleanup +/// only ever considers our own `tm-*.sock` files — never the local daemon socket +/// (`<16hex>.sock`) that lives in the same runtime dir. +pub const SESSION_NAME_PREFIX: &str = "tm-"; + impl ResolvedBackend { /// Check if this backend supports session persistence pub fn supports_persistence(&self) -> bool { @@ -179,7 +185,7 @@ impl ResolvedBackend { } else { terminal_id }; - format!("tm-{}", short_id) + format!("{SESSION_NAME_PREFIX}{short_id}") } /// Get the socket path for dtach sessions @@ -451,8 +457,43 @@ impl ResolvedBackend { } } +/// Minimum age before a `tm-*.sock` file is a GC candidate. A socket created +/// just before this scan may not yet appear in the `/proc` snapshot, so treat +/// recent files as live (TOCTOU defense-in-depth on top of the name filter). +#[cfg(unix)] +const DTACH_SOCKET_GC_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(60); + +/// Whether a filename matches the dtach/tmux socket naming scheme (`tm-*.sock`). +/// Pure so it's unit-testable. The dtach GC runs in the daemon's own process and +/// scans the shared runtime dir, which also holds the daemon's control socket +/// (`<16hex>.sock`) — this filter keeps GC from ever considering those. +#[cfg(unix)] +fn is_stale_gc_candidate(name: &str) -> bool { + name.starts_with(SESSION_NAME_PREFIX) && name.ends_with(".sock") +} + +/// Whether a socket that old is too fresh to GC (see `DTACH_SOCKET_GC_MIN_AGE`). +/// Pure so the age threshold is unit-testable independent of filesystem mtime. +#[cfg(unix)] +fn is_too_fresh_to_gc(age: std::time::Duration) -> bool { + age < DTACH_SOCKET_GC_MIN_AGE +} + +/// Age of a socket file from its mtime. `None` when the file is gone/unreadable; +/// a future mtime (clock skew) reads as "just now" so it's treated as fresh. +#[cfg(unix)] +fn socket_age(path: &std::path::Path) -> Option { + let modified = std::fs::metadata(path).ok()?.modified().ok()?; + Some(modified.elapsed().unwrap_or(std::time::Duration::ZERO)) +} + /// Remove dtach socket files whose dtach process is no longer running. /// Called once at startup to clean up after crashes or ungraceful exits. +/// +/// Only `tm-*.sock` files (our own session sockets) are ever candidates — the +/// daemon control socket living in the same dir is off-limits (see +/// `is_stale_gc_candidate`) — and recently-created sockets are skipped to avoid +/// a TOCTOU delete of a socket not yet in the `/proc` snapshot. #[cfg(unix)] pub fn cleanup_stale_dtach_sockets() { let dir = get_dtach_socket_dir(); @@ -464,7 +505,14 @@ pub fn cleanup_stale_dtach_sockets() { let socket_paths: Vec = entries .flatten() .map(|e| e.path()) - .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("sock")) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(is_stale_gc_candidate) + }) + // TOCTOU guard: skip freshly-created sockets that may not be in the + // upcoming /proc snapshot yet. + .filter(|p| !socket_age(p).is_some_and(is_too_fresh_to_gc)) .collect(); // One /proc socket scan for every socket at once, instead of an `lsof -t` @@ -1024,6 +1072,31 @@ mod tests { assert_eq!(dtach_name, "tm-12345678"); } + #[cfg(unix)] + #[test] + fn stale_gc_candidate_matches_only_dtach_sockets() { + // Our own session sockets: candidates. + assert!(is_stale_gc_candidate("tm-01736dcb.sock")); + assert!(is_stale_gc_candidate("tm-12345678.sock")); + // Daemon control socket (`<16hex>.sock`): must NEVER be a candidate. + assert!(!is_stale_gc_candidate("b7253cd8ed7892af.sock")); + // Wrong extension / unrelated files. + assert!(!is_stale_gc_candidate("tm-x.txt")); + assert!(!is_stale_gc_candidate("okena.lock")); + assert!(!is_stale_gc_candidate("remote.json")); + } + + #[cfg(unix)] + #[test] + fn too_fresh_to_gc_respects_min_age() { + use std::time::Duration; + assert!(is_too_fresh_to_gc(Duration::from_secs(0))); + assert!(is_too_fresh_to_gc(Duration::from_secs(30))); + // At or beyond the threshold the file is old enough to GC. + assert!(!is_too_fresh_to_gc(DTACH_SOCKET_GC_MIN_AGE)); + assert!(!is_too_fresh_to_gc(Duration::from_secs(120))); + } + #[test] fn test_dtach_socket_path() { let backend = ResolvedBackend::Dtach; diff --git a/crates/okena-terminal/src/terminal/event_listener.rs b/crates/okena-terminal/src/terminal/event_listener.rs index c35e9e198..5ffabdcae 100644 --- a/crates/okena-terminal/src/terminal/event_listener.rs +++ b/crates/okena-terminal/src/terminal/event_listener.rs @@ -101,7 +101,18 @@ impl ZedEventListener { } let palette = self.state.palette.lock(); - let colors = palette.as_ref()?; + let process_palette; + let colors = match palette.as_ref() { + Some(colors) => colors, + None => { + // Headless daemon: no view ever pushes a per-terminal palette, + // so fall back to the process-wide palette set at boot / on + // theme change. Mirrors don't answer queries, so without this + // OSC color queries would go unanswered in daemon mode. + process_palette = process_palette_snapshot()?; + &process_palette + } + }; let hex = match index { 0 => colors.term_black, 1 => colors.term_red, @@ -128,6 +139,21 @@ impl ZedEventListener { } } +/// Process-wide fallback palette for answering OSC color queries when no +/// per-terminal palette was pushed (headless daemon — no views). Set once at +/// boot and on theme changes via [`set_process_palette`]. +static PROCESS_PALETTE: Mutex> = Mutex::new(None); + +/// Set the process-wide fallback palette used to answer OSC 10/11/12/4 color +/// queries for terminals without a per-terminal palette (headless daemon). +pub fn set_process_palette(colors: okena_core::theme::ThemeColors) { + *PROCESS_PALETTE.lock() = Some(colors); +} + +fn process_palette_snapshot() -> Option { + *PROCESS_PALETTE.lock() +} + /// xterm 6x6x6 color cube for palette indices 16..=231. /// /// Each axis uses the canonical xterm levels [0, 95, 135, 175, 215, 255] @@ -174,13 +200,22 @@ impl EventListener for ZedEventListener { self.clipboard.reads.lock().push(formatter); } TermEvent::ColorRequest(index, response_fn) => { + // Mirrors must not answer queries — the PTY owner is the + // single responder (duplicate replies corrupt the app's input + // and count as user input for resize ownership on the server). + if !self.transport.answers_terminal_queries() { + return; + } if let Some((r, g, b)) = self.resolve_color(index) { let reply = response_fn(alacritty_terminal::vte::ansi::Rgb { r, g, b }); - self.transport.send_input(&self.terminal_id, reply.as_bytes()); + self.transport.send_response(&self.terminal_id, reply.as_bytes()); } } TermEvent::TextAreaSizeRequest(formatter) => { + if !self.transport.answers_terminal_queries() { + return; + } // Answer `CSI 14 t` (report text-area size in pixels). alacritty // hands us the formatter; we supply the current geometry. Cell // dims are f32 pixels in TerminalSize; round to the nearest whole @@ -193,12 +228,17 @@ impl EventListener for ZedEventListener { cell_height: (size.cell_height.round() as u16).max(1), }; let reply = formatter(window_size); - self.transport.send_input(&self.terminal_id, reply.as_bytes()); + self.transport.send_response(&self.terminal_id, reply.as_bytes()); } TermEvent::PtyWrite(data) => { - // Write response back to PTY (e.g., cursor position report) + if !self.transport.answers_terminal_queries() { + return; + } + // Terminal→program reply (Device Attributes, cursor position + // report, DSR, …). Sent on the synchronous fast-lane so it beats + // the querying program's exit back to the shell. log::debug!("PtyWrite event: {:?}", data); - self.transport.send_input(&self.terminal_id, data.as_bytes()); + self.transport.send_response(&self.terminal_id, data.as_bytes()); } _ => { // Ignore other events diff --git a/crates/okena-terminal/src/terminal/mod.rs b/crates/okena-terminal/src/terminal/mod.rs index 46d17811e..a28ed4cb9 100644 --- a/crates/okena-terminal/src/terminal/mod.rs +++ b/crates/okena-terminal/src/terminal/mod.rs @@ -35,8 +35,11 @@ mod tests; pub use app_version::set_app_version; pub use child_processes::{foreground_command, has_child_processes}; pub use resize_authority::{ - claim_resize_authority_local, claim_resize_authority_remote, is_resize_authority_local, + claim_remote_resize_if_allowed, claim_resize_authority_local, + claim_resize_authority_remote, claim_resize_authority_remote_owner, + is_resize_authority_local, release_remote_resize_owner, resize_authority_snapshot, }; +pub use event_listener::set_process_palette; pub use transport::TerminalTransport; pub use types::{ AppCursorShape, ClipboardReadResponder, DetectedLink, PromptMark, PromptMarkKind, ResizeState, diff --git a/crates/okena-terminal/src/terminal/resize.rs b/crates/okena-terminal/src/terminal/resize.rs index 69628e62a..a763acdaf 100644 --- a/crates/okena-terminal/src/terminal/resize.rs +++ b/crates/okena-terminal/src/terminal/resize.rs @@ -3,7 +3,8 @@ use std::sync::atomic::Ordering; use super::Terminal; use super::resize_authority::{ - claim_resize_authority_local, claim_resize_authority_remote, is_resize_authority_local, + claim_resize_authority_local, claim_resize_authority_remote, + claim_resize_authority_remote_owner, is_resize_authority_local, }; use super::types::TerminalSize; @@ -50,6 +51,10 @@ impl Terminal { rs.pending_pty_resize = None; rs.last_pty_resize = now; drop(rs); + log::debug!( + "terminal resize send: terminal={} {}x{}", + self.terminal_id, new_size.cols, new_size.rows + ); self.transport.resize(&self.terminal_id, new_size.cols, new_size.rows); } else { // Store pending resize @@ -104,24 +109,26 @@ impl Terminal { self.content_generation.fetch_add(1, Ordering::Relaxed); } - /// Mark the local side (origin) as resize authority. Process-global: - /// any local keyboard/mouse input in any terminal claims authority for all - /// terminals. + /// Mark this process as the terminal's resize authority. pub fn claim_resize_local(&self) { - claim_resize_authority_local(); + claim_resize_authority_local(&self.terminal_id); } - /// Mark the remote side as resize authority. Called on the server when a - /// remote client sends input to any terminal. + /// Mark the remote side as this terminal's resize authority. pub fn claim_resize_remote(&self) { - claim_resize_authority_remote(); + claim_resize_authority_remote(&self.terminal_id); + } + + /// Mark a specific remote connection as this terminal's resize authority. + pub fn claim_resize_remote_owner(&self, owner_id: &str) { + claim_resize_authority_remote_owner(&self.terminal_id, owner_id); } /// Returns true if the local (origin) side currently has resize authority. /// The server's UI uses this to decide whether to push resize events to /// the PTY. pub fn is_resize_owner_local(&self) -> bool { - is_resize_authority_local() + is_resize_authority_local(&self.terminal_id) } /// Flush any pending PTY resize (call this when resize operations complete) diff --git a/crates/okena-terminal/src/terminal/resize_authority.rs b/crates/okena-terminal/src/terminal/resize_authority.rs index 4498ab79e..c8666a989 100644 --- a/crates/okena-terminal/src/terminal/resize_authority.rs +++ b/crates/okena-terminal/src/terminal/resize_authority.rs @@ -1,35 +1,151 @@ -use std::sync::atomic::{AtomicU64, Ordering}; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::OnceLock; -/// Process-global resize authority. "Last to interact wins" across all terminals -/// in this process: whichever side most recently typed or clicked gets to drive -/// resize for every terminal. No time-based reclaim — the origin side takes over -/// by actually interacting, not by waiting. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResizeAuthoritySnapshot { + pub local: bool, + pub remote_owner_id: Option, + pub claimed: bool, +} + +#[derive(Default)] +struct ResizeAuthorityState { + seq: u64, + last_local_seq: u64, + last_remote_seq: u64, + remote_owner_id: Option, +} + +/// Per-scope resize authority: within one scope, one side owns every PTY size +/// at a time ("last to interact wins", see PR history for the flicker that +/// per-terminal ownership caused). /// -/// Implemented with a monotonically-increasing sequence counter to avoid -/// timestamp collisions. Each claim bumps the counter and records the new value -/// on the claiming side. Higher value wins. Both zero (initial) resolves to -/// Local, so terminals behave normally before any interaction happens. -static RESIZE_AUTH_SEQ: AtomicU64 = AtomicU64::new(0); -static LAST_LOCAL_SEQ: AtomicU64 = AtomicU64::new(0); -static LAST_REMOTE_SEQ: AtomicU64 = AtomicU64::new(0); +/// A scope is one server's terminals. On the daemon/server side terminal ids +/// are plain, so everything shares the "" scope (process-global, as before). +/// On a client, mirror terminals are keyed `remote::`, so each +/// connection gets its own scope — another server's owner reclaiming must not +/// stop this client from resizing an unrelated server's terminals. +static RESIZE_AUTHORITY: OnceLock>> = OnceLock::new(); + +fn resize_authority() -> &'static Mutex> { + RESIZE_AUTHORITY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Authority scope of a terminal id: the connection prefix for client mirror +/// ids (`remote::` → `remote:`), "" for plain +/// server-side ids. +fn scope_of(terminal_id: &str) -> &str { + match terminal_id.rfind(':') { + Some(idx) => &terminal_id[..idx], + None => "", + } +} + +pub fn claim_resize_authority_local(terminal_id: &str) { + let mut map = resize_authority().lock(); + let authority = map.entry(scope_of(terminal_id).to_string()).or_default(); + authority.seq += 1; + authority.last_local_seq = authority.seq; + authority.remote_owner_id = None; + log::debug!("resize_authority: claim LOCAL (terminal={terminal_id})"); +} + +pub fn claim_resize_authority_remote(terminal_id: &str) { + let mut map = resize_authority().lock(); + let authority = map.entry(scope_of(terminal_id).to_string()).or_default(); + authority.seq += 1; + authority.last_remote_seq = authority.seq; + authority.remote_owner_id = None; + log::debug!("resize_authority: claim REMOTE ownerless (terminal={terminal_id})"); +} + +pub fn claim_resize_authority_remote_owner(terminal_id: &str, owner_id: &str) { + let mut map = resize_authority().lock(); + let authority = map.entry(scope_of(terminal_id).to_string()).or_default(); + authority.seq += 1; + authority.last_remote_seq = authority.seq; + authority.remote_owner_id = Some(owner_id.to_string()); + log::debug!("resize_authority: claim REMOTE owner={owner_id} (terminal={terminal_id})"); +} + +pub fn claim_remote_resize_if_allowed(terminal_id: &str, owner_id: &str) -> bool { + let mut map = resize_authority().lock(); + let authority = map.entry(scope_of(terminal_id).to_string()).or_default(); + + if authority.last_local_seq == 0 && authority.last_remote_seq == 0 { + authority.seq += 1; + authority.last_remote_seq = authority.seq; + authority.remote_owner_id = Some(owner_id.to_string()); + log::debug!("resize_authority: resize from {owner_id} ADOPTS unclaimed (terminal={terminal_id})"); + return true; + } + + if authority.last_local_seq > authority.last_remote_seq { + log::debug!("resize_authority: resize from {owner_id} DENIED, local owns (terminal={terminal_id})"); + return false; + } + + match authority.remote_owner_id.as_deref() { + Some(existing) => { + let allowed = existing == owner_id; + log::debug!( + "resize_authority: resize from {owner_id} {} (owner={existing}, terminal={terminal_id})", + if allowed { "ALLOWED" } else { "DENIED" } + ); + allowed + } + None => { + authority.remote_owner_id = Some(owner_id.to_string()); + log::debug!("resize_authority: resize from {owner_id} ADOPTS ownerless remote (terminal={terminal_id})"); + true + } + } +} -pub fn claim_resize_authority_local() { - let seq = RESIZE_AUTH_SEQ.fetch_add(1, Ordering::Relaxed) + 1; - LAST_LOCAL_SEQ.store(seq, Ordering::Relaxed); +/// Release resize ownership held by a disconnecting connection. Keeps the +/// remote side as authority but ownerless, so the next client's resize can +/// adopt it instead of being denied by a dead owner. Owner ids are unique per +/// process, so clearing them across every scope is safe. +pub fn release_remote_resize_owner(owner_id: &str) { + let mut map = resize_authority().lock(); + for authority in map.values_mut() { + if authority.remote_owner_id.as_deref() == Some(owner_id) { + authority.remote_owner_id = None; + log::debug!("resize_authority: RELEASED owner {owner_id}"); + } + } } -pub fn claim_resize_authority_remote() { - let seq = RESIZE_AUTH_SEQ.fetch_add(1, Ordering::Relaxed) + 1; - LAST_REMOTE_SEQ.store(seq, Ordering::Relaxed); +pub fn is_resize_authority_local(terminal_id: &str) -> bool { + resize_authority_snapshot(terminal_id).local } -pub fn is_resize_authority_local() -> bool { - LAST_LOCAL_SEQ.load(Ordering::Relaxed) >= LAST_REMOTE_SEQ.load(Ordering::Relaxed) +pub fn resize_authority_snapshot(terminal_id: &str) -> ResizeAuthoritySnapshot { + let map = resize_authority().lock(); + let Some(authority) = map.get(scope_of(terminal_id)) else { + return ResizeAuthoritySnapshot { + local: true, + remote_owner_id: None, + claimed: false, + }; + }; + if authority.last_local_seq >= authority.last_remote_seq { + ResizeAuthoritySnapshot { + local: true, + remote_owner_id: None, + claimed: authority.last_local_seq != 0, + } + } else { + ResizeAuthoritySnapshot { + local: false, + remote_owner_id: authority.remote_owner_id.clone(), + claimed: true, + } + } } #[cfg(test)] pub(super) fn reset_resize_authority() { - RESIZE_AUTH_SEQ.store(0, Ordering::Relaxed); - LAST_LOCAL_SEQ.store(0, Ordering::Relaxed); - LAST_REMOTE_SEQ.store(0, Ordering::Relaxed); + resize_authority().lock().clear(); } diff --git a/crates/okena-terminal/src/terminal/tests/helpers.rs b/crates/okena-terminal/src/terminal/tests/helpers.rs index 4815b6840..233f9dff3 100644 --- a/crates/okena-terminal/src/terminal/tests/helpers.rs +++ b/crates/okena-terminal/src/terminal/tests/helpers.rs @@ -31,3 +31,24 @@ impl TerminalTransport for CapturingTransport { fn resize(&self, _terminal_id: &str, _cols: u16, _rows: u16) {} fn uses_mouse_backend(&self) -> bool { false } } + +/// A remote-mirror transport: records writes like [`CapturingTransport`] but +/// reports that the PTY owner (not this side) answers terminal queries. +pub(crate) struct MirrorTransport { + pub(crate) inner: CapturingTransport, +} + +impl MirrorTransport { + pub(crate) fn new() -> Self { + Self { inner: CapturingTransport::new() } + } +} + +impl TerminalTransport for MirrorTransport { + fn send_input(&self, terminal_id: &str, data: &[u8]) { + self.inner.send_input(terminal_id, data); + } + fn resize(&self, _terminal_id: &str, _cols: u16, _rows: u16) {} + fn uses_mouse_backend(&self) -> bool { false } + fn answers_terminal_queries(&self) -> bool { false } +} diff --git a/crates/okena-terminal/src/terminal/tests/mod.rs b/crates/okena-terminal/src/terminal/tests/mod.rs index 3863b129b..88fd7ee7e 100644 --- a/crates/okena-terminal/src/terminal/tests/mod.rs +++ b/crates/okena-terminal/src/terminal/tests/mod.rs @@ -7,4 +7,4 @@ mod resize_authority; mod url_detect; mod xterm_color; -pub(crate) use helpers::{CapturingTransport, NullTransport}; +pub(crate) use helpers::{CapturingTransport, MirrorTransport, NullTransport}; diff --git a/crates/okena-terminal/src/terminal/tests/osc.rs b/crates/okena-terminal/src/terminal/tests/osc.rs index b6d11b3f1..e201a6bbc 100644 --- a/crates/okena-terminal/src/terminal/tests/osc.rs +++ b/crates/okena-terminal/src/terminal/tests/osc.rs @@ -1043,3 +1043,61 @@ fn test_parse_osc133_kind() { ); assert_eq!(parse_osc133_kind(b'Z', &[]), None); } + +#[test] +fn test_mirror_transport_does_not_answer_queries() { + use super::MirrorTransport; + + let size = TerminalSize { + cols: 80, + rows: 24, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(MirrorTransport::new()); + let terminal = Terminal::new( + "remote:conn:t".into(), + size, + transport.clone(), + "/tmp".into(), + ); + + // DSR cursor position, CSI 14/18 t size queries, DA1 — all answered by the + // PTY owner (daemon), never by a mirror: duplicated replies corrupt the + // app's input and count as user input for resize ownership on the server. + terminal.process_output(b"\x1b[6n"); + terminal.process_output(b"\x1b[14t"); + terminal.process_output(b"\x1b[18t"); + terminal.process_output(b"\x1b[c"); + + assert!( + transport.inner.writes().is_empty(), + "mirror must not reply to terminal queries: {:?}", + transport.inner.writes(), + ); +} + +#[test] +fn test_process_palette_answers_color_query_without_per_terminal_palette() { + use super::super::set_process_palette; + + // Headless daemon: no view pushes a per-terminal palette, so OSC color + // queries fall back to the process palette set at boot. + set_process_palette(okena_core::theme::DARK_THEME); + + let transport = Arc::new(CapturingTransport::new()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport.clone(), + "/tmp".into(), + ); + + // OSC 11 — query the background color. + terminal.process_output(b"\x1b]11;?\x07"); + + let writes = transport.writes(); + assert_eq!(writes.len(), 1, "expected one OSC 11 reply: {writes:?}"); + let reply = String::from_utf8_lossy(&writes[0]).into_owned(); + assert!(reply.starts_with("\x1b]11;rgb:"), "unexpected reply: {reply:?}"); +} diff --git a/crates/okena-terminal/src/terminal/tests/resize_authority.rs b/crates/okena-terminal/src/terminal/tests/resize_authority.rs index 0bec3fd13..208af0cab 100644 --- a/crates/okena-terminal/src/terminal/tests/resize_authority.rs +++ b/crates/okena-terminal/src/terminal/tests/resize_authority.rs @@ -1,5 +1,8 @@ use super::super::Terminal; -use super::super::resize_authority::reset_resize_authority; +use super::super::resize_authority::{ + claim_remote_resize_if_allowed, release_remote_resize_owner, reset_resize_authority, + resize_authority_snapshot, +}; use super::super::transport::TerminalTransport; use super::super::types::TerminalSize; use super::NullTransport; @@ -14,7 +17,12 @@ fn resize_owner_defaults_to_local() { let _g = RESIZE_AUTH_TEST_LOCK.lock(); reset_resize_authority(); let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, String::new()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + String::new(), + ); assert!(terminal.is_resize_owner_local()); } @@ -23,7 +31,12 @@ fn resize_owner_transitions() { let _g = RESIZE_AUTH_TEST_LOCK.lock(); reset_resize_authority(); let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, String::new()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + String::new(), + ); terminal.claim_resize_remote(); assert!(!terminal.is_resize_owner_local()); @@ -37,32 +50,159 @@ fn resize_owner_is_process_global() { let _g = RESIZE_AUTH_TEST_LOCK.lock(); reset_resize_authority(); let transport = Arc::new(NullTransport); - let term_a = Terminal::new("a".into(), TerminalSize::default(), transport.clone(), String::new()); - let term_b = Terminal::new("b".into(), TerminalSize::default(), transport, String::new()); + let term_a = Terminal::new( + "a".into(), + TerminalSize::default(), + transport.clone(), + String::new(), + ); + let term_b = Terminal::new( + "b".into(), + TerminalSize::default(), + transport, + String::new(), + ); - // Claiming remote on A flips authority for B as well. term_a.claim_resize_remote(); assert!(!term_b.is_resize_owner_local()); - // Claiming local on B flips authority back for A. term_b.claim_resize_local(); assert!(term_a.is_resize_owner_local()); } +#[test] +fn remote_resize_is_limited_to_current_owner() { + let _g = RESIZE_AUTH_TEST_LOCK.lock(); + reset_resize_authority(); + + assert!(claim_remote_resize_if_allowed("t", "conn-a")); + assert_eq!( + resize_authority_snapshot("t").remote_owner_id.as_deref(), + Some("conn-a") + ); + + assert!(claim_remote_resize_if_allowed("t", "conn-a")); + assert!(!claim_remote_resize_if_allowed("t", "conn-b")); +} + +#[test] +fn remote_resize_owner_is_process_global() { + let _g = RESIZE_AUTH_TEST_LOCK.lock(); + reset_resize_authority(); + + assert!(claim_remote_resize_if_allowed("t1", "conn-a")); + assert!(claim_remote_resize_if_allowed("t2", "conn-a")); + assert!(!claim_remote_resize_if_allowed("t2", "conn-b")); +} + +#[test] +fn released_owner_lets_next_connection_adopt() { + let _g = RESIZE_AUTH_TEST_LOCK.lock(); + reset_resize_authority(); + + assert!(claim_remote_resize_if_allowed("t", "conn-a")); + assert!(!claim_remote_resize_if_allowed("t", "conn-b")); + + // Releasing a non-owner is a no-op. + release_remote_resize_owner("conn-b"); + assert!(!claim_remote_resize_if_allowed("t", "conn-b")); + + // Releasing the owner keeps remote authority but lets anyone adopt it. + release_remote_resize_owner("conn-a"); + assert!(claim_remote_resize_if_allowed("t", "conn-b")); + assert_eq!( + resize_authority_snapshot("t").remote_owner_id.as_deref(), + Some("conn-b") + ); +} + +#[test] +fn local_input_blocks_remote_resize_until_remote_input() { + let _g = RESIZE_AUTH_TEST_LOCK.lock(); + reset_resize_authority(); + let transport = Arc::new(NullTransport); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + String::new(), + ); + + terminal.claim_resize_local(); + assert!(!claim_remote_resize_if_allowed("t", "conn-a")); + + terminal.claim_resize_remote_owner("conn-a"); + assert!(claim_remote_resize_if_allowed("other", "conn-a")); +} + +#[test] +fn authority_is_scoped_per_connection_prefix() { + let _g = RESIZE_AUTH_TEST_LOCK.lock(); + reset_resize_authority(); + let transport = Arc::new(NullTransport); + // Client mirrors of two different servers: `remote::`. + let term_a = Terminal::new( + "remote:conn-a:t1".into(), + TerminalSize::default(), + transport.clone(), + String::new(), + ); + let term_b = Terminal::new( + "remote:conn-b:t1".into(), + TerminalSize::default(), + transport, + String::new(), + ); + + // Server A's owner reclaiming must not stop this client from resizing + // server B's terminals (or vice versa). + term_a.claim_resize_remote(); + assert!(!term_a.is_resize_owner_local()); + assert!(term_b.is_resize_owner_local()); + + term_b.claim_resize_remote(); + term_a.claim_resize_local(); + assert!(term_a.is_resize_owner_local()); + assert!(!term_b.is_resize_owner_local()); +} + +#[test] +fn release_clears_owner_in_every_scope() { + let _g = RESIZE_AUTH_TEST_LOCK.lock(); + reset_resize_authority(); + + assert!(claim_remote_resize_if_allowed("t", "conn-a")); + assert!(claim_remote_resize_if_allowed("scoped:t", "conn-a")); + release_remote_resize_owner("conn-a"); + assert_eq!(resize_authority_snapshot("t").remote_owner_id, None); + assert_eq!(resize_authority_snapshot("scoped:t").remote_owner_id, None); +} + #[test] fn resize_grid_only_does_not_call_transport() { use std::sync::atomic::{AtomicBool, Ordering}; - struct SpyTransport { resize_called: AtomicBool } + struct SpyTransport { + resize_called: AtomicBool, + } impl TerminalTransport for SpyTransport { fn send_input(&self, _: &str, _: &[u8]) {} fn resize(&self, _: &str, _: u16, _: u16) { self.resize_called.store(true, Ordering::Relaxed); } - fn uses_mouse_backend(&self) -> bool { false } + fn uses_mouse_backend(&self) -> bool { + false + } } - let transport = Arc::new(SpyTransport { resize_called: AtomicBool::new(false) }); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport.clone(), String::new()); + let transport = Arc::new(SpyTransport { + resize_called: AtomicBool::new(false), + }); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport.clone(), + String::new(), + ); terminal.resize_grid_only(120, 40); assert!(!transport.resize_called.load(Ordering::Relaxed)); diff --git a/crates/okena-terminal/src/terminal/transport.rs b/crates/okena-terminal/src/terminal/transport.rs index 984893c50..e94093de9 100644 --- a/crates/okena-terminal/src/terminal/transport.rs +++ b/crates/okena-terminal/src/terminal/transport.rs @@ -2,10 +2,22 @@ /// Implemented by PtyManager (local) and RemoteTransport (remote). pub trait TerminalTransport: Send + Sync { fn send_input(&self, terminal_id: &str, data: &[u8]); + /// Write a terminal→program *reply* (Device Attributes, cursor/size report, + /// OSC color answer, …). These race the querying program's exit back to the + /// shell, so the local PTY writes them synchronously ahead of the batched + /// input queue. The default routes through `send_input`. + fn send_response(&self, terminal_id: &str, data: &[u8]) { + self.send_input(terminal_id, data) + } fn resize(&self, terminal_id: &str, cols: u16, rows: u16); fn uses_mouse_backend(&self) -> bool; /// Debounce interval for transport resize calls (ms). /// Local PTY uses 16ms (just enough to batch rapid resizes). /// Remote uses longer interval to avoid flooding the network. fn resize_debounce_ms(&self) -> u64 { 16 } + /// Whether this side's emulator answers terminal queries (DSR, DA, OSC + /// color, CSI 14/18 t). True only for the process that owns the PTY; + /// remote mirrors must not answer — duplicated replies corrupt the app's + /// input and the server would count them as user input (resize ownership). + fn answers_terminal_queries(&self) -> bool { true } } diff --git a/crates/okena-theme/Cargo.toml b/crates/okena-theme/Cargo.toml index a92376798..553a30349 100644 --- a/crates/okena-theme/Cargo.toml +++ b/crates/okena-theme/Cargo.toml @@ -4,10 +4,14 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui"] +gpui = ["dep:gpui"] + [dependencies] okena-core = { path = "../okena-core" } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } alacritty_terminal = "0.25" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/okena-theme/src/app_theme.rs b/crates/okena-theme/src/app_theme.rs index 41bf15ce9..6575bab17 100644 --- a/crates/okena-theme/src/app_theme.rs +++ b/crates/okena-theme/src/app_theme.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "gpui")] use gpui::*; use okena_core::theme::{ThemeColors, ThemeMode, DARK_THEME, LIGHT_THEME, PASTEL_DARK_THEME, HIGH_CONTRAST_THEME}; @@ -87,11 +88,14 @@ impl AppTheme { } /// Wrapper for global theme entity +#[cfg(feature = "gpui")] pub struct GlobalTheme(pub Entity); +#[cfg(feature = "gpui")] impl Global for GlobalTheme {} /// Get the theme entity for observation +#[cfg(feature = "gpui")] pub fn theme_entity(cx: &App) -> Entity { cx.global::().0.clone() } diff --git a/crates/okena-theme/src/lib.rs b/crates/okena-theme/src/lib.rs index b28438d6d..2fa91a08a 100644 --- a/crates/okena-theme/src/lib.rs +++ b/crates/okena-theme/src/lib.rs @@ -7,9 +7,13 @@ pub use okena_core::theme::{ }; pub mod custom; +#[cfg(feature = "gpui")] mod gpui_helpers; mod app_theme; +#[cfg(feature = "gpui")] pub use gpui_helpers::{with_alpha, ansi_to_hsla, GlobalThemeProvider, theme}; -pub use app_theme::{AppTheme, GlobalTheme, theme_entity}; +pub use app_theme::AppTheme; +#[cfg(feature = "gpui")] +pub use app_theme::{GlobalTheme, theme_entity}; pub use custom::{CustomThemeConfig, CustomThemeColors, get_themes_dir, load_custom_themes}; diff --git a/crates/okena-transport/src/client/config.rs b/crates/okena-transport/src/client/config.rs index c47181a6a..4fa0f67db 100644 --- a/crates/okena-transport/src/client/config.rs +++ b/crates/okena-transport/src/client/config.rs @@ -1,5 +1,19 @@ use serde::{Deserialize, Serialize}; +/// Connection id of the implicit, trusted loopback connection the desktop +/// registers to its own local daemon in `--daemon-client` mode. It is not a +/// user-managed remote: it is auto-registered, never persisted to settings, and +/// hidden from the sidebar's REMOTE management section. Shared so the +/// registration site (`Okena::new`) and the sidebar filter agree on the marker. +pub const LOCAL_DAEMON_CONNECTION_ID: &str = "local-daemon"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LocalEndpoint { + UnixSocket { path: String }, + NamedPipe { name: String }, +} + /// Configuration for a single remote server connection. /// Persisted in settings.json as part of `remote_connections`. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -28,6 +42,10 @@ pub struct RemoteConnectionConfig { /// first successful TLS handshake captures it. #[serde(default)] pub pinned_cert_sha256: Option, + /// Same-host transport endpoint for the implicit local daemon connection. + /// Remote user-managed connections leave this empty and use TCP host/port. + #[serde(default)] + pub local_endpoint: Option, } impl RemoteConnectionConfig { @@ -42,4 +60,36 @@ impl RemoteConnectionConfig { let scheme = if self.tls { "wss" } else { "ws" }; format!("{}://{}:{}/v1/stream", scheme, self.host, self.port) } + + pub fn http_origin(&self) -> String { + match &self.local_endpoint { + Some(LocalEndpoint::UnixSocket { .. }) => "http://okena.local".to_string(), + _ => self.base_url(), + } + } + + pub fn http_url(&self, path: &str) -> String { + format!("{}{}", self.http_origin(), path) + } + + pub fn display_endpoint(&self) -> String { + match &self.local_endpoint { + Some(LocalEndpoint::UnixSocket { path }) => format!("unix:{path}"), + Some(LocalEndpoint::NamedPipe { name }) => format!("pipe:{name}"), + None => format!("{}:{}", self.host, self.port), + } + } + + pub fn is_trusted_local_transport(&self) -> bool { + matches!(self.local_endpoint, Some(LocalEndpoint::UnixSocket { .. })) + } + + /// Bearer token to send on requests. Same-user Unix socket connections are + /// trusted by transport and use an empty token only to satisfy client flows + /// that carry a token string alongside remote actions. + pub fn effective_auth_token(&self) -> Option { + self.saved_token + .clone() + .or_else(|| self.is_trusted_local_transport().then(String::new)) + } } diff --git a/crates/okena-transport/src/client/connection.rs b/crates/okena-transport/src/client/connection.rs index 2bf606a3b..94ffe6dfc 100644 --- a/crates/okena-transport/src/client/connection.rs +++ b/crates/okena-transport/src/client/connection.rs @@ -1,5 +1,5 @@ -use okena_core::api::StateResponse; -use crate::client::config::RemoteConnectionConfig; +use okena_core::api::{ApiSystemStats, StateResponse}; +use crate::client::config::{LocalEndpoint, RemoteConnectionConfig, LOCAL_DAEMON_CONNECTION_ID}; use crate::client::id::make_prefixed_id; use crate::client::state::{collect_all_terminal_ids, collect_state_terminal_ids, collect_terminal_sizes, diff_states}; use crate::client::types::{ @@ -7,9 +7,143 @@ use crate::client::types::{ }; use std::collections::HashMap; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; +use futures::{Sink, Stream}; use tokio_tungstenite::tungstenite; +type TcpWsStream = + tokio_tungstenite::WebSocketStream>; +#[cfg(unix)] +type UnixWsStream = tokio_tungstenite::WebSocketStream; + +enum AnyWsStream { + Tcp(Box), + #[cfg(unix)] + Unix(Box), +} + +impl Stream for AnyWsStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut *self { + AnyWsStream::Tcp(stream) => Pin::new(stream.as_mut()).poll_next(cx), + #[cfg(unix)] + AnyWsStream::Unix(stream) => Pin::new(stream.as_mut()).poll_next(cx), + } + } +} + +impl Sink for AnyWsStream { + type Error = tungstenite::Error; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut *self { + AnyWsStream::Tcp(stream) => Pin::new(stream.as_mut()).poll_ready(cx), + #[cfg(unix)] + AnyWsStream::Unix(stream) => Pin::new(stream.as_mut()).poll_ready(cx), + } + } + + fn start_send(mut self: Pin<&mut Self>, item: tungstenite::Message) -> Result<(), Self::Error> { + match &mut *self { + AnyWsStream::Tcp(stream) => Pin::new(stream.as_mut()).start_send(item), + #[cfg(unix)] + AnyWsStream::Unix(stream) => Pin::new(stream.as_mut()).start_send(item), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut *self { + AnyWsStream::Tcp(stream) => Pin::new(stream.as_mut()).poll_flush(cx), + #[cfg(unix)] + AnyWsStream::Unix(stream) => Pin::new(stream.as_mut()).poll_flush(cx), + } + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut *self { + AnyWsStream::Tcp(stream) => Pin::new(stream.as_mut()).poll_close(cx), + #[cfg(unix)] + AnyWsStream::Unix(stream) => Pin::new(stream.as_mut()).poll_close(cx), + } + } +} + +#[cfg(unix)] +fn unix_http_client(path: &str) -> reqwest::Client { + reqwest::Client::builder() + .unix_socket(path) + .build() + .unwrap_or_else(|e| { + log::error!("Failed to build Unix socket HTTP client for {path}: {e}"); + reqwest::Client::new() + }) +} + +#[cfg(not(unix))] +fn unix_http_client(_path: &str) -> reqwest::Client { + reqwest::Client::new() +} + +fn local_unix_path(config: &RemoteConnectionConfig) -> Option<&str> { + #[cfg(unix)] + { + match &config.local_endpoint { + Some(LocalEndpoint::UnixSocket { path }) => Some(path.as_str()), + _ => None, + } + } + #[cfg(not(unix))] + { + let _ = config; + None + } +} + +fn initial_connect_attempts(config: &RemoteConnectionConfig) -> u32 { + if config.id == LOCAL_DAEMON_CONNECTION_ID { + // Fail fast: ensure_local_daemon() verified reachability right before + // this dial, and the app-layer self-heal re-runs it once Error fires. + 5 + } else if config.local_endpoint.is_some() { + 30 + } else { + 1 + } +} + +fn initial_connect_retry_delay(attempt: u32) -> std::time::Duration { + let millis = match attempt { + 1 => 100, + 2 => 200, + 3 => 400, + 4 => 800, + _ => 1_200, + }; + std::time::Duration::from_millis(millis) +} + +/// Reconnect budget after an established WS drops. The local daemon connection +/// dead-ends within a few seconds so the app-layer self-heal (re-running +/// ensure_local_daemon, which can respawn the daemon) takes over quickly; +/// user-managed remotes keep the patient schedule for flaky networks. +fn ws_reconnect_max_attempts(config: &RemoteConnectionConfig) -> u32 { + if config.id == LOCAL_DAEMON_CONNECTION_ID { 3 } else { 10 } +} + +/// Sleep before reconnect `attempt` (1-based). Local daemon: flat 1s (see +/// [`ws_reconnect_max_attempts`]); remotes: exponential 1,2,4,… capped at 30s. +fn ws_reconnect_backoff_secs(config: &RemoteConnectionConfig, attempt: u32) -> u64 { + if config.id == LOCAL_DAEMON_CONNECTION_ID { + 1 + } else { + std::cmp::min(2u64.saturating_pow(attempt.saturating_sub(1)), 30) + } +} + /// Platform-specific operations that the generic client delegates to. /// /// Desktop creates `Terminal` objects and inserts into `TerminalsRegistry`. @@ -56,6 +190,7 @@ pub struct RemoteClient { runtime: Arc, ws_tx: Option>, remote_state: Option, + system_stats: Option, stream_map: HashMap, reverse_stream_map: HashMap, handler: Arc, @@ -72,13 +207,14 @@ impl RemoteClient { handler: Arc, event_tx: async_channel::Sender, ) -> Self { - let shared_token = Arc::new(std::sync::RwLock::new(config.saved_token.clone())); + let shared_token = Arc::new(std::sync::RwLock::new(config.effective_auth_token())); Self { config, status: ConnectionStatus::Disconnected, runtime, ws_tx: None, remote_state: None, + system_stats: None, stream_map: HashMap::new(), reverse_stream_map: HashMap::new(), handler, @@ -120,6 +256,14 @@ impl RemoteClient { self.remote_state = state; } + pub fn system_stats(&self) -> Option<&ApiSystemStats> { + self.system_stats.as_ref() + } + + pub fn set_system_stats(&mut self, stats: Option) { + self.system_stats = stats; + } + /// Update the shared token so WS reconnect loop uses the latest token. pub fn update_shared_token(&self, token: &str) { if let Ok(mut guard) = self.shared_token.write() { @@ -143,10 +287,10 @@ impl RemoteClient { /// Start the connection process. /// /// 1. GET /health to verify server is alive - /// 2. If saved_token: GET /v1/state to validate token + /// 2. If auth token or trusted local transport: GET /v1/state to validate/reach state /// - 200: token valid, proceed to start_ws() /// - 401: token expired, set Pairing status - /// 3. No saved_token: set Pairing status + /// 3. No auth token: set Pairing status pub fn connect(&mut self) { // Tear down any prior connection so we don't orphan its WS task. self.abort_ws_task(); @@ -159,7 +303,7 @@ impl RemoteClient { // Update shared token from config if let Ok(mut guard) = self.shared_token.write() { - *guard = config.saved_token.clone(); + *guard = config.effective_auth_token(); } // Create fresh WS message channel @@ -174,26 +318,67 @@ impl RemoteClient { // tries TLS (never downgrade). A legacy plain connection prefers TLS // (auto-upgrade) but falls back to plain http so it keeps working // against a server that hasn't enabled TLS. - let schemes: &[bool] = if config.tls { &[true] } else { &[true, false] }; + let local_unix = local_unix_path(&config).map(str::to_string); + let schemes: &[bool] = if local_unix.is_some() { + &[false] + } else if config.tls { + &[true] + } else { + &[true, false] + }; let mut chosen: Option<(bool, reqwest::Client, String)> = None; - for &tls in schemes { - let client = crate::client::tls::build_reqwest_client( - tls, - config.pinned_cert_sha256.clone(), - observed.clone(), - ); - let scheme = if tls { "https" } else { "http" }; - let base_url = format!("{}://{}:{}", scheme, config.host, config.port); - let ok = matches!( - client + let attempts = initial_connect_attempts(&config); + let mut last_connect_failure: Option = None; + for attempt in 1..=attempts { + if attempt > 1 { + let delay = initial_connect_retry_delay(attempt - 1); + let detail = last_connect_failure + .as_deref() + .map(|failure| format!(": {failure}")) + .unwrap_or_default(); + log::warn!( + "Initial connection to {} failed{}. Retrying in {}ms (attempt {}/{})", + config.display_endpoint(), + detail, + delay.as_millis(), + attempt, + attempts + ); + tokio::time::sleep(delay).await; + } + + for &tls in schemes { + let (client, base_url) = if let Some(path) = local_unix.as_deref() { + (unix_http_client(path), config.http_origin()) + } else { + let client = crate::client::tls::build_reqwest_client( + tls, + config.pinned_cert_sha256.clone(), + observed.clone(), + ); + let scheme = if tls { "https" } else { "http" }; + (client, format!("{}://{}:{}", scheme, config.host, config.port)) + }; + let health_result = client .get(format!("{}/health", base_url)) .timeout(std::time::Duration::from_secs(5)) .send() - .await, - Ok(resp) if resp.status().is_success() - ); - if ok { - chosen = Some((tls, client, base_url)); + .await; + let ok = matches!( + health_result.as_ref(), + Ok(resp) if resp.status().is_success() + ); + last_connect_failure = match health_result { + Ok(resp) if resp.status().is_success() => None, + Ok(resp) => Some(format!("health returned HTTP {}", resp.status())), + Err(e) => Some(e.to_string()), + }; + if ok { + chosen = Some((tls, client, base_url)); + break; + } + } + if chosen.is_some() { break; } } @@ -201,7 +386,14 @@ impl RemoteClient { let (detected_tls, client, base_url) = match chosen { Some(v) => v, None => { - let msg = format!("Cannot reach server {}:{}", config.host, config.port); + let msg = match last_connect_failure { + Some(failure) => format!( + "Cannot reach server {} (last error: {})", + config.display_endpoint(), + failure + ), + None => format!("Cannot reach server {}", config.display_endpoint()), + }; log::warn!("{}", msg); let _ = event_tx .send(ConnectionEvent::StatusChanged { @@ -213,9 +405,8 @@ impl RemoteClient { } }; log::info!( - "Remote server {}:{} is healthy ({})", - config.host, - config.port, + "Remote server {} is healthy ({})", + config.display_endpoint(), if detected_tls { "TLS" } else { "plain http" } ); @@ -223,7 +414,7 @@ impl RemoteClient { // over TLS adopts TLS and pins the cert (TOFU), and asks the manager // to persist the upgrade so the sidebar reflects it and the pin is // enforced next time. - if detected_tls && !config.tls { + if local_unix.is_none() && detected_tls && !config.tls { config.tls = true; let fp = observed.lock().ok().and_then(|g| g.clone()); config.pinned_cert_sha256 = fp.clone(); @@ -240,8 +431,10 @@ impl RemoteClient { .await; } - // Step 2: Validate saved token (if any) - if let Some(token) = config.saved_token.clone() { + // Step 2: Validate saved token, or trust same-user Unix socket transport. + if let Some(token) = config.effective_auth_token() { + let trusted_local_transport = + config.saved_token.is_none() && config.is_trusted_local_transport(); match client .get(format!("{}/v1/state", base_url)) .header("Authorization", format!("Bearer {}", token)) @@ -250,16 +443,22 @@ impl RemoteClient { .await { Ok(resp) if resp.status().is_success() => { - log::info!("Token valid for {}:{}", config.host, config.port); + if trusted_local_transport { + log::info!( + "Trusted local transport accepted for {}", + config.display_endpoint() + ); + } else { + log::info!("Token valid for {}", config.display_endpoint()); + } // Token is valid - start WebSocket Self::run_ws_loop(config, token, event_tx, ws_tx, ws_rx, handler, shared_token).await; return; } Ok(resp) if resp.status() == reqwest::StatusCode::UNAUTHORIZED => { log::info!( - "Token expired for {}:{}, need re-pairing", - config.host, - config.port + "Token expired for {}, need re-pairing", + config.display_endpoint() ); // Only 401 means the token is actually invalid → need pairing let _ = event_tx @@ -331,13 +530,18 @@ impl RemoteClient { self.status = ConnectionStatus::Connecting; let task = self.runtime.spawn(async move { - let base_url = config.base_url(); + let local_unix = local_unix_path(&config).map(str::to_string); + let base_url = config.http_origin(); let observed = crate::client::tls::new_observed(); - let client = crate::client::tls::build_reqwest_client( - config.tls, - config.pinned_cert_sha256.clone(), - observed.clone(), - ); + let client = if let Some(path) = local_unix.as_deref() { + unix_http_client(path) + } else { + crate::client::tls::build_reqwest_client( + config.tls, + config.pinned_cert_sha256.clone(), + observed.clone(), + ) + }; // POST /v1/pair with the code let pair_body = serde_json::json!({ "code": code }); @@ -452,6 +656,7 @@ impl RemoteClient { self.stream_map.clear(); self.reverse_stream_map.clear(); self.remote_state = None; + self.system_stats = None; self.status = ConnectionStatus::Disconnected; } @@ -466,8 +671,7 @@ impl RemoteClient { shared_token: Arc>>, ) { let mut reconnect_attempt: u32 = 0; - let max_backoff_secs: u64 = 30; - let max_reconnect_attempts: u32 = 10; + let max_reconnect_attempts = ws_reconnect_max_attempts(&config); let mut current_token = token; loop { @@ -514,12 +718,7 @@ impl RemoteClient { break; } - let backoff = std::cmp::min( - 1u64.saturating_mul( - 2u64.saturating_pow(reconnect_attempt.saturating_sub(1)), - ), - max_backoff_secs, - ); + let backoff = ws_reconnect_backoff_secs(&config, reconnect_attempt); log::warn!( "WebSocket connection to {}:{} lost: {}. Reconnecting in {}s (attempt {}/{})", @@ -570,19 +769,42 @@ impl RemoteClient { // Connect WebSocket. With TLS we go through connect_async_tls_with_config // using the pinned rustls connector; otherwise the plain ws:// path. - let (ws_stream, _response) = if config.tls { + let (ws_stream, _response) = if let Some(path) = local_unix_path(config) { + #[cfg(unix)] + { + let stream = tokio::net::UnixStream::connect(path) + .await + .map_err(|e| SessionError::Transient(format!("Unix socket connect failed: {}", e)))?; + let (ws, response) = tokio_tungstenite::client_async( + "ws://okena.local/v1/stream", + stream, + ) + .await + .map_err(|e| SessionError::Transient(format!("WebSocket connect failed: {}", e)))?; + (AnyWsStream::Unix(Box::new(ws)), response) + } + #[cfg(not(unix))] + { + let _ = path; + return Err(SessionError::Transient( + "Unix socket transport is not supported on this platform".to_string(), + )); + } + } else if config.tls { let connector = crate::client::tls::ws_connector( true, config.pinned_cert_sha256.clone(), observed.clone(), ); - tokio_tungstenite::connect_async_tls_with_config(&ws_url, None, false, connector) + let (ws, response) = tokio_tungstenite::connect_async_tls_with_config(&ws_url, None, false, connector) .await - .map_err(|e| SessionError::Transient(format!("WebSocket connect failed: {}", e)))? + .map_err(|e| SessionError::Transient(format!("WebSocket connect failed: {}", e)))?; + (AnyWsStream::Tcp(Box::new(ws)), response) } else { - tokio_tungstenite::connect_async(&ws_url) + let (ws, response) = tokio_tungstenite::connect_async(&ws_url) .await - .map_err(|e| SessionError::Transient(format!("WebSocket connect failed: {}", e)))? + .map_err(|e| SessionError::Transient(format!("WebSocket connect failed: {}", e)))?; + (AnyWsStream::Tcp(Box::new(ws)), response) }; let (mut ws_write, mut ws_read) = futures::StreamExt::split(ws_stream); @@ -642,12 +864,16 @@ impl RemoteClient { } // Step 3: Fetch state via HTTP - let base_url = config.base_url(); - let client = crate::client::tls::build_reqwest_client( - config.tls, - config.pinned_cert_sha256.clone(), - observed.clone(), - ); + let base_url = config.http_origin(); + let client = if let Some(path) = local_unix_path(config) { + unix_http_client(path) + } else { + crate::client::tls::build_reqwest_client( + config.tls, + config.pinned_cert_sha256.clone(), + observed.clone(), + ) + }; let state_resp = client .get(format!("{}/v1/state", base_url)) .header("Authorization", format!("Bearer {}", token)) @@ -1051,6 +1277,39 @@ impl RemoteClient { .await; } } + "system_stats_changed" => { + if let Some(stats) = value.get("stats") + && let Ok(stats) = serde_json::from_value::< + okena_core::api::ApiSystemStats, + >(stats.clone()) { + let _ = event_tx_clone + .send(ConnectionEvent::SystemStatsChanged { + connection_id: config_id.clone(), + stats, + }) + .await; + } + } + "toast" => { + // `WsOutbound::Toast` is internally tagged, so + // the ApiToast fields sit at the top level of + // `value` alongside `"type":"toast"`. + match serde_json::from_value::( + value.clone(), + ) { + Ok(toast) => { + let _ = event_tx_clone + .send(ConnectionEvent::Toast { + connection_id: config_id.clone(), + toast, + }) + .await; + } + Err(e) => { + log::warn!("Failed to parse toast message: {}", e); + } + } + } _ => { log::debug!("Unknown WS message type: {}", msg_type); } @@ -1124,12 +1383,17 @@ pub async fn try_refresh_token( } // If token_obtained_at is None, attempt refresh (legacy token without timestamp) - let base_url = config.base_url(); - let client = crate::client::tls::build_reqwest_client( - config.tls, - config.pinned_cert_sha256.clone(), - crate::client::tls::new_observed(), - ); + let local_unix = local_unix_path(config); + let base_url = config.http_origin(); + let client = if let Some(path) = local_unix { + unix_http_client(path) + } else { + crate::client::tls::build_reqwest_client( + config.tls, + config.pinned_cert_sha256.clone(), + crate::client::tls::new_observed(), + ) + }; match client .post(format!("{}/v1/refresh", base_url)) @@ -1147,7 +1411,7 @@ pub async fn try_refresh_token( } match resp.json::().await { Ok(refresh_resp) => { - log::info!("Token refreshed for {}:{}", config.host, config.port); + log::info!("Token refreshed for {}", config.display_endpoint()); let _ = event_tx .send(ConnectionEvent::TokenRefreshed { connection_id: config.id.clone(), @@ -1183,3 +1447,77 @@ pub async fn try_refresh_token( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn config(id: &str, local_endpoint: Option) -> RemoteConnectionConfig { + RemoteConnectionConfig { + id: id.to_string(), + name: "test".to_string(), + host: "127.0.0.1".to_string(), + port: 19100, + saved_token: None, + token_obtained_at: None, + tls: false, + pinned_cert_sha256: None, + local_endpoint, + } + } + + fn unix_endpoint() -> Option { + Some(LocalEndpoint::UnixSocket { + path: "/tmp/okena-test.sock".to_string(), + }) + } + + #[test] + fn initial_attempts_local_daemon_fails_fast() { + // Small budget: the app-layer self-heal owns recovery once Error fires. + let cfg = config(LOCAL_DAEMON_CONNECTION_ID, unix_endpoint()); + assert_eq!(initial_connect_attempts(&cfg), 5); + } + + #[test] + fn initial_attempts_other_local_endpoint_keeps_patient_budget() { + // Only the implicit local-daemon id fails fast — a local_endpoint alone + // does not opt a connection into the small budget. + let cfg = config("some-other-connection", unix_endpoint()); + assert_eq!(initial_connect_attempts(&cfg), 30); + } + + #[test] + fn initial_attempts_user_remote_single_try() { + assert_eq!(initial_connect_attempts(&config("user-remote", None)), 1); + } + + #[test] + fn ws_reconnect_budget_local_vs_remote() { + let local = config(LOCAL_DAEMON_CONNECTION_ID, unix_endpoint()); + let remote = config("user-remote", None); + assert_eq!(ws_reconnect_max_attempts(&local), 3); + assert_eq!(ws_reconnect_max_attempts(&remote), 10); + } + + #[test] + fn ws_reconnect_backoff_local_is_flat_and_short() { + // ~3s total to Error, so recovery kicks in within seconds of a WS drop. + let local = config(LOCAL_DAEMON_CONNECTION_ID, unix_endpoint()); + let total: u64 = (1..=ws_reconnect_max_attempts(&local)) + .map(|attempt| ws_reconnect_backoff_secs(&local, attempt)) + .sum(); + assert_eq!(total, 3); + } + + #[test] + fn ws_reconnect_backoff_remote_is_exponential_capped() { + let remote = config("user-remote", None); + assert_eq!(ws_reconnect_backoff_secs(&remote, 1), 1); + assert_eq!(ws_reconnect_backoff_secs(&remote, 2), 2); + assert_eq!(ws_reconnect_backoff_secs(&remote, 3), 4); + assert_eq!(ws_reconnect_backoff_secs(&remote, 5), 16); + assert_eq!(ws_reconnect_backoff_secs(&remote, 6), 30); + assert_eq!(ws_reconnect_backoff_secs(&remote, 10), 30, "capped at 30s"); + } +} diff --git a/crates/okena-transport/src/client/mod.rs b/crates/okena-transport/src/client/mod.rs index 24632f7d0..1a6a96aa8 100644 --- a/crates/okena-transport/src/client/mod.rs +++ b/crates/okena-transport/src/client/mod.rs @@ -2,11 +2,20 @@ pub mod config; pub mod connection; pub mod id; pub mod state; +pub mod terminal; pub mod tls; pub mod types; -pub use config::RemoteConnectionConfig; +pub use config::{LocalEndpoint, RemoteConnectionConfig, LOCAL_DAEMON_CONNECTION_ID}; pub use connection::{ConnectionHandler, RemoteClient}; pub use id::{is_remote_terminal, make_prefixed_id, strip_prefix}; -pub use state::{collect_all_terminal_ids, collect_state_terminal_ids, collect_terminal_sizes, diff_states, StateDiff}; +pub use state::{ + collect_all_terminal_ids, collect_layout_terminal_ids, collect_state_terminal_ids, + collect_terminal_sizes, diff_states, StateDiff, +}; +pub use terminal::{ + close_remote_terminal, resize_remote_terminal, send_remote_terminal_input, + REMOTE_TERMINAL_ANSWERS_QUERIES, REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS, + REMOTE_TERMINAL_USES_MOUSE_BACKEND, +}; pub use types::{ConnectionEvent, ConnectionStatus, WsClientMessage, TOKEN_REFRESH_AGE_SECS}; diff --git a/crates/okena-transport/src/client/state.rs b/crates/okena-transport/src/client/state.rs index 223ffb82c..4e42483d6 100644 --- a/crates/okena-transport/src/client/state.rs +++ b/crates/okena-transport/src/client/state.rs @@ -65,7 +65,7 @@ pub fn collect_all_terminal_ids(state: &StateResponse) -> HashSet { let mut ids = HashSet::new(); for project in &state.projects { if let Some(ref layout) = project.layout { - collect_layout_terminal_ids(layout, &mut ids); + ids.extend(collect_layout_terminal_ids(layout)); } } ids @@ -76,28 +76,20 @@ pub fn collect_state_terminal_ids(state: &StateResponse) -> Vec { let mut ids = Vec::new(); for project in &state.projects { if let Some(ref layout) = project.layout { - collect_layout_ids_vec(layout, &mut ids); + collect_layout_terminal_ids_into(layout, &mut ids); } } ids } -fn collect_layout_terminal_ids(node: &ApiLayoutNode, ids: &mut HashSet) { - match node { - ApiLayoutNode::Terminal { terminal_id, .. } => { - if let Some(id) = terminal_id { - ids.insert(id.clone()); - } - } - ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { - for child in children { - collect_layout_terminal_ids(child, ids); - } - } - } +/// Collect terminal IDs from a single layout tree in render order. +pub fn collect_layout_terminal_ids(node: &ApiLayoutNode) -> Vec { + let mut ids = Vec::new(); + collect_layout_terminal_ids_into(node, &mut ids); + ids } -fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec) { +fn collect_layout_terminal_ids_into(node: &ApiLayoutNode, ids: &mut Vec) { match node { ApiLayoutNode::Terminal { terminal_id, .. } => { if let Some(id) = terminal_id { @@ -106,7 +98,7 @@ fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec) { } ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { for child in children { - collect_layout_ids_vec(child, ids); + collect_layout_terminal_ids_into(child, ids); } } } @@ -207,6 +199,11 @@ mod tests { services: vec![], worktree_info: None, worktree_ids: vec![], + pinned: false, + last_activity_at: None, + default_shell: None, + hook_terminals: Vec::new(), + hooks: Default::default(), } } @@ -246,6 +243,51 @@ mod tests { assert!(diff.changed_projects.is_empty()); } + #[test] + fn collect_layout_terminal_ids_preserves_layout_order_and_skips_empty_ids() { + let layout = ApiLayoutNode::Tabs { + active_tab: 0, + children: vec![ + ApiLayoutNode::Terminal { + terminal_id: Some("t1".to_string()), + minimized: false, + detached: false, + cols: None, + rows: None, + }, + ApiLayoutNode::Terminal { + terminal_id: None, + minimized: false, + detached: false, + cols: None, + rows: None, + }, + ApiLayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![ + ApiLayoutNode::Terminal { + terminal_id: Some("t2".to_string()), + minimized: false, + detached: false, + cols: None, + rows: None, + }, + ApiLayoutNode::Terminal { + terminal_id: Some("t3".to_string()), + minimized: false, + detached: false, + cols: None, + rows: None, + }, + ], + }, + ], + }; + + assert_eq!(collect_layout_terminal_ids(&layout), vec!["t1", "t2", "t3"]); + } + #[test] fn collect_terminal_sizes_extracts_from_layout() { let state = make_state(vec![ApiProject { @@ -279,6 +321,11 @@ mod tests { services: Vec::new(), worktree_info: None, worktree_ids: Vec::new(), + pinned: false, + last_activity_at: None, + default_shell: None, + hook_terminals: Vec::new(), + hooks: Default::default(), }]); let sizes = collect_terminal_sizes(&state); assert_eq!(sizes.get("t1"), Some(&(120, 40))); diff --git a/crates/okena-transport/src/client/terminal.rs b/crates/okena-transport/src/client/terminal.rs new file mode 100644 index 000000000..ddeb66654 --- /dev/null +++ b/crates/okena-transport/src/client/terminal.rs @@ -0,0 +1,99 @@ +use crate::client::id::strip_prefix; +use crate::client::types::WsClientMessage; + +pub const REMOTE_TERMINAL_USES_MOUSE_BACKEND: bool = false; +pub const REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS: u64 = 150; +pub const REMOTE_TERMINAL_ANSWERS_QUERIES: bool = false; + +pub fn send_remote_terminal_input( + ws_tx: &async_channel::Sender, + connection_id: &str, + terminal_id: &str, + data: &[u8], +) { + let remote_id = strip_prefix(terminal_id, connection_id); + let _ = ws_tx.try_send(WsClientMessage::SendText { + terminal_id: remote_id, + text: String::from_utf8_lossy(data).to_string(), + }); +} + +pub fn resize_remote_terminal( + ws_tx: &async_channel::Sender, + connection_id: &str, + terminal_id: &str, + cols: u16, + rows: u16, +) { + let remote_id = strip_prefix(terminal_id, connection_id); + let _ = ws_tx.try_send(WsClientMessage::Resize { + terminal_id: remote_id, + cols, + rows, + }); +} + +pub fn close_remote_terminal( + ws_tx: &async_channel::Sender, + connection_id: &str, + terminal_id: &str, +) { + let remote_id = strip_prefix(terminal_id, connection_id); + let _ = ws_tx.try_send(WsClientMessage::CloseTerminal { + terminal_id: remote_id, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_remote_terminal_input_strips_prefix_and_encodes_lossy_utf8() { + let (tx, rx) = async_channel::bounded(1); + + send_remote_terminal_input(&tx, "conn-1", "remote:conn-1:term-a", b"a\xff"); + + match rx.try_recv().expect("message queued") { + WsClientMessage::SendText { terminal_id, text } => { + assert_eq!(terminal_id, "term-a"); + assert_eq!(text, "a\u{fffd}"); + } + other => panic!("unexpected message: {other:?}"), + } + } + + #[test] + fn resize_remote_terminal_strips_prefix() { + let (tx, rx) = async_channel::bounded(1); + + resize_remote_terminal(&tx, "conn-1", "remote:conn-1:term-a", 120, 40); + + match rx.try_recv().expect("message queued") { + WsClientMessage::Resize { + terminal_id, + cols, + rows, + } => { + assert_eq!(terminal_id, "term-a"); + assert_eq!(cols, 120); + assert_eq!(rows, 40); + } + other => panic!("unexpected message: {other:?}"), + } + } + + #[test] + fn close_remote_terminal_strips_prefix() { + let (tx, rx) = async_channel::bounded(1); + + close_remote_terminal(&tx, "conn-1", "remote:conn-1:term-a"); + + match rx.try_recv().expect("message queued") { + WsClientMessage::CloseTerminal { terminal_id } => { + assert_eq!(terminal_id, "term-a"); + } + other => panic!("unexpected message: {other:?}"), + } + } +} diff --git a/crates/okena-transport/src/client/types.rs b/crates/okena-transport/src/client/types.rs index aad6a755e..e413230a2 100644 --- a/crates/okena-transport/src/client/types.rs +++ b/crates/okena-transport/src/client/types.rs @@ -87,6 +87,18 @@ pub enum ConnectionEvent { connection_id: String, statuses: HashMap, }, + /// System metrics changed on the remote host. + SystemStatsChanged { + connection_id: String, + stats: okena_core::api::ApiSystemStats, + }, + /// A daemon-originated toast to display on this client (e.g. a remote + /// lifecycle-hook failure). The daemon has no surface, so it forwards these + /// over the WebSocket and the client renders them via its `ToastManager`. + Toast { + connection_id: String, + toast: okena_core::api::ApiToast, + }, /// Token was refreshed — save new token and update timestamp TokenRefreshed { connection_id: String, diff --git a/crates/okena-transport/src/remote_action.rs b/crates/okena-transport/src/remote_action.rs index 8a94ed675..1dfb1d4e0 100644 --- a/crates/okena-transport/src/remote_action.rs +++ b/crates/okena-transport/src/remote_action.rs @@ -1,6 +1,7 @@ //! Shared blocking HTTP helper for posting ActionRequests to a remote server. use okena_core::api::ActionRequest; +use crate::client::config::{LocalEndpoint, RemoteConnectionConfig}; /// Total request timeout for "fast" actions (terminal control, listings, /// metadata). 10 s is generous for these; longer would mask real failures. @@ -64,6 +65,23 @@ fn client_for(action: &ActionRequest) -> Result<&'static reqwest::blocking::Clie } } +fn timeout_for(action: &ActionRequest) -> u64 { + match action { + ActionRequest::ReadFileBytes { .. } => BYTES_TIMEOUT_SECS, + _ => FAST_TIMEOUT_SECS, + } +} + +#[cfg(unix)] +fn unix_client(path: &str, timeout_secs: u64) -> Result { + reqwest::blocking::Client::builder() + .unix_socket(path) + .timeout(std::time::Duration::from_secs(timeout_secs)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| format!("Cannot initialise Unix socket HTTP client: {}", e)) +} + /// Post an action request to a remote server and return the JSON response body. pub fn post_action( host: &str, @@ -73,8 +91,51 @@ pub fn post_action( ) -> Result, String> { let url = format!("http://{}:{}/v1/actions", host, port); let client = client_for(&action)?; + post_action_inner(client, &url, token, action) +} + +/// Post an action using the full connection config. Local daemon configs can use +/// their same-host transport endpoint while normal remotes keep host/port TCP. +pub fn post_action_with_config( + config: &RemoteConnectionConfig, + token: &str, + action: ActionRequest, +) -> Result, String> { + post_action_with_endpoint( + &config.host, + config.port, + token, + config.local_endpoint.as_ref(), + action, + ) +} + +/// Post an action with an optional same-host endpoint. This keeps older view +/// models that store host/port/token from needing the whole connection config. +pub fn post_action_with_endpoint( + host: &str, + port: u16, + token: &str, + local_endpoint: Option<&LocalEndpoint>, + action: ActionRequest, +) -> Result, String> { + #[cfg(unix)] + if let Some(LocalEndpoint::UnixSocket { path }) = local_endpoint { + let client = unix_client(path, timeout_for(&action))?; + return post_action_inner(&client, "http://okena.local/v1/actions", token, action); + } + + post_action(host, port, token, action) +} + +fn post_action_inner( + client: &reqwest::blocking::Client, + url: &str, + token: &str, + action: ActionRequest, +) -> Result, String> { let resp = client - .post(&url) + .post(url) .bearer_auth(token) .json(&action) .send() diff --git a/crates/okena-tui/Cargo.toml b/crates/okena-tui/Cargo.toml new file mode 100644 index 000000000..59038abeb --- /dev/null +++ b/crates/okena-tui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "okena-tui" +version.workspace = true +edition = "2024" +license = "MIT" + +[[bin]] +name = "okena-tui" +path = "src/main.rs" + +[dependencies] +okena-core = { path = "../okena-core" } +okena-transport = { path = "../okena-transport", features = ["client"] } +okena-terminal = { path = "../okena-terminal" } + +anyhow = "1.0" +async-channel = "2.3" +clap = { version = "4", features = ["derive", "env"] } +crossterm = "0.28" +env_logger = "0.11" +log = "0.4" +parking_lot = "0.12" +serde_json = "1.0" +tokio = { version = "1", features = ["rt-multi-thread", "time"] } +uuid = { version = "1.10", features = ["v4"] } diff --git a/crates/okena-tui/src/main.rs b/crates/okena-tui/src/main.rs new file mode 100644 index 000000000..d3385cb14 --- /dev/null +++ b/crates/okena-tui/src/main.rs @@ -0,0 +1,867 @@ +#![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] + +use std::collections::HashMap; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use clap::Parser; +use crossterm::{ + cursor, + event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, + KeyEvent as CrosstermKeyEvent, KeyEventKind, KeyModifiers as CrosstermKeyModifiers, + }, + execute, queue, + style::{Attribute, Print, SetAttribute}, + terminal::{self, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use okena_core::api::{ApiLayoutNode, ApiProject, StateResponse}; +use okena_terminal::input::{KeyEvent, KeyModifiers, key_to_bytes}; +use okena_terminal::terminal::{Terminal, TerminalSize, TerminalTransport}; +use okena_transport::client::{ + ConnectionEvent, ConnectionHandler, ConnectionStatus, LocalEndpoint, RemoteClient, + RemoteConnectionConfig, WsClientMessage, is_remote_terminal, make_prefixed_id, + resize_remote_terminal, send_remote_terminal_input, strip_prefix, + REMOTE_TERMINAL_ANSWERS_QUERIES, REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS, + REMOTE_TERMINAL_USES_MOUSE_BACKEND, +}; +use parking_lot::RwLock; + +#[derive(Parser, Debug)] +#[command( + name = "okena-tui", + about = "Proof-of-concept terminal UI remote client for a running Okena daemon" +)] +struct Args { + /// Remote host. Defaults to discovered local daemon host when omitted. + #[arg(long)] + host: Option, + + /// Remote port. Defaults to discovered local daemon port when omitted. + #[arg(long)] + port: Option, + + /// Bearer token for TCP remotes. Also read from OKENA_TOKEN. + #[arg(long, env = "OKENA_TOKEN")] + token: Option, + + /// Pairing code to exchange for a token for this run. + #[arg(long)] + pair: Option, + + /// Force TLS for TCP remotes. + #[arg(long)] + tls: bool, + + /// Connect over a same-user Unix socket. + #[arg(long)] + socket: Option, + + /// Profile id used only for local remote.json discovery. + #[arg(long, env = "OKENA_PROFILE")] + profile: Option, + + /// Start focused on this terminal id. + #[arg(long)] + terminal: Option, +} + +struct TuiRemoteTransport { + ws_tx: async_channel::Sender, + connection_id: String, +} + +impl TerminalTransport for TuiRemoteTransport { + fn send_input(&self, terminal_id: &str, data: &[u8]) { + send_remote_terminal_input(&self.ws_tx, &self.connection_id, terminal_id, data); + } + + fn send_response(&self, _terminal_id: &str, _data: &[u8]) {} + + fn resize(&self, terminal_id: &str, cols: u16, rows: u16) { + resize_remote_terminal(&self.ws_tx, &self.connection_id, terminal_id, cols, rows); + } + + fn uses_mouse_backend(&self) -> bool { + REMOTE_TERMINAL_USES_MOUSE_BACKEND + } + + fn resize_debounce_ms(&self) -> u64 { + REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS + } + + fn answers_terminal_queries(&self) -> bool { + REMOTE_TERMINAL_ANSWERS_QUERIES + } +} + +struct TuiConnectionHandler { + terminals: Arc>>>, + dirty_tx: async_channel::Sender<()>, +} + +impl TuiConnectionHandler { + fn new( + terminals: Arc>>>, + dirty_tx: async_channel::Sender<()>, + ) -> Self { + Self { + terminals, + dirty_tx, + } + } +} + +impl ConnectionHandler for TuiConnectionHandler { + fn create_terminal( + &self, + connection_id: &str, + _terminal_id: &str, + prefixed_id: &str, + ws_sender: async_channel::Sender, + cols: u16, + rows: u16, + ) { + if self.terminals.read().contains_key(prefixed_id) { + return; + } + + let size = if cols > 0 && rows > 0 { + TerminalSize { + cols, + rows, + ..TerminalSize::default() + } + } else { + TerminalSize::default() + }; + let transport = Arc::new(TuiRemoteTransport { + ws_tx: ws_sender, + connection_id: connection_id.to_string(), + }); + let terminal = Arc::new(Terminal::new( + prefixed_id.to_string(), + size, + transport, + String::new(), + )); + self.terminals + .write() + .insert(prefixed_id.to_string(), terminal); + } + + fn on_terminal_output(&self, prefixed_id: &str, data: &[u8]) { + if let Some(terminal) = self.terminals.read().get(prefixed_id) { + terminal.enqueue_output(data); + let _ = self.dirty_tx.try_send(()); + } + } + + fn resize_terminal(&self, prefixed_id: &str, cols: u16, rows: u16, server_owns: bool) { + if let Some(terminal) = self.terminals.read().get(prefixed_id) { + if server_owns { + terminal.claim_resize_remote(); + } + terminal.resize_grid_only(cols, rows); + let _ = self.dirty_tx.try_send(()); + } + } + + fn remove_terminal(&self, prefixed_id: &str) { + self.terminals.write().remove(prefixed_id); + let _ = self.dirty_tx.try_send(()); + } + + fn remove_all_terminals(&self, connection_id: &str) { + let mut terminals = self.terminals.write(); + let to_remove: Vec = terminals + .keys() + .filter(|key| is_remote_terminal(key, connection_id)) + .cloned() + .collect(); + for key in to_remove { + terminals.remove(&key); + } + let _ = self.dirty_tx.try_send(()); + } + + fn remove_terminals_except( + &self, + connection_id: &str, + keep_ids: &std::collections::HashSet, + ) { + let mut terminals = self.terminals.write(); + let to_remove: Vec = terminals + .keys() + .filter(|key| { + is_remote_terminal(key, connection_id) + && !keep_ids.contains(&strip_prefix(key, connection_id)) + }) + .cloned() + .collect(); + for key in to_remove { + terminals.remove(&key); + } + let _ = self.dirty_tx.try_send(()); + } +} + +#[derive(Clone)] +struct TerminalEntry { + id: String, + label: String, +} + +struct TuiState { + status: ConnectionStatus, + state: Option, + active_terminal: Option, + terminal_request: Option, + message: Option, + last_resize: Option<(String, u16, u16)>, +} + +impl TuiState { + fn new(terminal_request: Option) -> Self { + Self { + status: ConnectionStatus::Disconnected, + state: None, + active_terminal: terminal_request.clone(), + terminal_request, + message: None, + last_resize: None, + } + } + + fn entries(&self) -> Vec { + self.state + .as_ref() + .map(collect_terminal_entries) + .unwrap_or_default() + } + + fn ensure_active_terminal(&mut self) { + let entries = self.entries(); + if entries.is_empty() { + self.active_terminal = None; + return; + } + + if let Some(requested) = self.terminal_request.as_deref() + && let Some(entry) = entries.iter().find(|entry| entry.id.starts_with(requested)) + { + self.active_terminal = Some(entry.id.clone()); + self.terminal_request = None; + return; + } + + if let Some(active) = self.active_terminal.as_deref() + && entries.iter().any(|entry| entry.id == active) + { + return; + } + + self.active_terminal = entries.first().map(|entry| entry.id.clone()); + } + + fn cycle_terminal(&mut self) { + let entries = self.entries(); + if entries.is_empty() { + self.active_terminal = None; + return; + } + + let next = match self.active_terminal.as_deref() { + Some(active) => entries + .iter() + .position(|entry| entry.id == active) + .map(|index| (index + 1) % entries.len()) + .unwrap_or(0), + None => 0, + }; + self.active_terminal = Some(entries[next].id.clone()); + self.last_resize = None; + } +} + +struct TerminalGuard; + +impl TerminalGuard { + fn enter() -> Result { + terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!( + stdout, + EnterAlternateScreen, + EnableBracketedPaste, + cursor::Hide + )?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let mut stdout = io::stdout(); + let _ = execute!( + stdout, + cursor::Show, + DisableBracketedPaste, + LeaveAlternateScreen + ); + } +} + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + let args = Args::parse(); + let config = connection_config(&args)?; + let runtime = Arc::new( + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("okena-tui") + .build() + .context("creating tokio runtime")?, + ); + + runtime.block_on(run(args, config, runtime.clone())) +} + +async fn run( + args: Args, + config: RemoteConnectionConfig, + runtime: Arc, +) -> Result<()> { + let connection_id = config.id.clone(); + let terminals = Arc::new(RwLock::new(HashMap::new())); + let (dirty_tx, dirty_rx) = async_channel::bounded::<()>(1); + let handler = Arc::new(TuiConnectionHandler::new(terminals.clone(), dirty_tx)); + let (event_tx, event_rx) = async_channel::bounded::(256); + + let mut client = RemoteClient::new(config, runtime, handler, event_tx); + let mut state = TuiState::new(args.terminal.clone()); + client.connect(); + + wait_for_initial_state(&mut client, &mut state, &event_rx, args.pair.as_deref()).await?; + + let _guard = TerminalGuard::enter()?; + render(&connection_id, &terminals, &mut state)?; + + let mut needs_render = false; + loop { + while let Ok(event) = event_rx.try_recv() { + handle_connection_event(&mut client, &mut state, event); + state.ensure_active_terminal(); + needs_render = true; + } + + while dirty_rx.try_recv().is_ok() { + needs_render = true; + } + + if event::poll(Duration::from_millis(16))? + && let Event::Key(key) = event::read()? + { + match handle_key(&connection_id, &terminals, &mut state, key)? { + LoopControl::Continue => needs_render = true, + LoopControl::Quit => break, + } + } + + if needs_render { + render(&connection_id, &terminals, &mut state)?; + needs_render = false; + } + } + + client.disconnect(); + Ok(()) +} + +async fn wait_for_initial_state( + client: &mut RemoteClient, + state: &mut TuiState, + event_rx: &async_channel::Receiver, + pair_code: Option<&str>, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + let mut pair_sent = false; + + loop { + while let Ok(event) = event_rx.try_recv() { + handle_connection_event(client, state, event); + } + + match &state.status { + ConnectionStatus::Connected if state.state.is_some() => { + state.ensure_active_terminal(); + return Ok(()); + } + ConnectionStatus::Pairing => { + let Some(code) = pair_code else { + bail!( + "pairing required. Pass --pair , --token , or connect over discovered local Unix socket" + ); + }; + if !pair_sent { + client.pair(code); + pair_sent = true; + } + } + ConnectionStatus::Error(message) => { + bail!("{message}"); + } + _ => {} + } + + if Instant::now() >= deadline { + bail!("timed out waiting for remote state"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn handle_connection_event( + client: &mut RemoteClient, + state: &mut TuiState, + event: ConnectionEvent, +) { + match event { + ConnectionEvent::StatusChanged { status, .. } => { + state.status = status; + } + ConnectionEvent::TokenObtained { + token, + cert_fingerprint, + .. + } => { + client.update_shared_token(&token); + client.config_mut().saved_token = Some(token); + client.config_mut().token_obtained_at = Some(unix_now()); + client.config_mut().pinned_cert_sha256 = cert_fingerprint; + } + ConnectionEvent::TlsUpgraded { + cert_fingerprint, .. + } => { + client.config_mut().tls = true; + client.config_mut().pinned_cert_sha256 = cert_fingerprint; + } + ConnectionEvent::StateReceived { + state: new_state, .. + } => { + client.set_remote_state(Some(new_state.clone())); + state.state = Some(new_state); + } + ConnectionEvent::SubscriptionMappings { mappings, .. } => { + client.update_stream_mappings(mappings); + } + ConnectionEvent::ServerWarning { message, .. } => { + state.message = Some(message); + } + ConnectionEvent::GitStatusChanged { statuses, .. } => { + if let Some(remote_state) = state.state.as_mut() { + for project in &mut remote_state.projects { + project.git_status = statuses.get(&project.id).cloned(); + } + } + } + ConnectionEvent::SystemStatsChanged { .. } => {} + ConnectionEvent::Toast { toast, .. } => { + state.message = Some(format!("{}: {}", toast.level, toast.message)); + } + ConnectionEvent::TokenRefreshed { token, .. } => { + client.update_shared_token(&token); + client.config_mut().saved_token = Some(token); + client.config_mut().token_obtained_at = Some(unix_now()); + } + } +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or_default() +} + +enum LoopControl { + Continue, + Quit, +} + +fn handle_key( + connection_id: &str, + terminals: &Arc>>>, + state: &mut TuiState, + key: CrosstermKeyEvent, +) -> Result { + if key.kind != KeyEventKind::Press { + return Ok(LoopControl::Continue); + } + + if key.modifiers.contains(CrosstermKeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char(']')) + { + return Ok(LoopControl::Quit); + } + + if key.modifiers.contains(CrosstermKeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('t')) + { + state.cycle_terminal(); + return Ok(LoopControl::Continue); + } + + let Some(active) = state.active_terminal.as_deref() else { + return Ok(LoopControl::Continue); + }; + let prefixed = make_prefixed_id(connection_id, active); + let terminal = terminals.read().get(&prefixed).cloned(); + let Some(terminal) = terminal else { + return Ok(LoopControl::Continue); + }; + + if let Some(bytes) = key_bytes(&terminal, key) { + terminal.send_bytes(&bytes); + } + + Ok(LoopControl::Continue) +} + +fn key_bytes(terminal: &Terminal, key: CrosstermKeyEvent) -> Option> { + let modifiers = key_modifiers(key.modifiers); + + if let KeyCode::Char(ch) = key.code + && !modifiers.control + && !modifiers.alt + && !modifiers.platform + { + return Some(ch.to_string().into_bytes()); + } + + let key_name = match key.code { + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "pageup".to_string(), + KeyCode::PageDown => "pagedown".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => "tab".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::Insert => "insert".to_string(), + KeyCode::Esc => "escape".to_string(), + KeyCode::F(n) => format!("f{n}"), + KeyCode::Char(ch) => ch.to_string(), + KeyCode::Null + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) => return None, + }; + + let mut event = KeyEvent { + key: key_name, + key_char: None, + modifiers, + }; + if matches!(key.code, KeyCode::BackTab) { + event.modifiers.shift = true; + } + + key_to_bytes( + &event, + terminal.is_app_cursor_mode(), + terminal.kitty_keyboard_flags(), + ) +} + +fn key_modifiers(modifiers: CrosstermKeyModifiers) -> KeyModifiers { + KeyModifiers { + control: modifiers.contains(CrosstermKeyModifiers::CONTROL), + shift: modifiers.contains(CrosstermKeyModifiers::SHIFT), + alt: modifiers.contains(CrosstermKeyModifiers::ALT), + platform: false, + } +} + +fn render( + connection_id: &str, + terminals: &Arc>>>, + state: &mut TuiState, +) -> Result<()> { + let (cols, rows) = terminal::size()?; + let terminal_rows = rows.saturating_sub(1).max(1); + resize_active_terminal(connection_id, terminals, state, cols, terminal_rows); + + let mut stdout = io::stdout(); + queue!(stdout, cursor::Hide, terminal::Clear(ClearType::All))?; + + if let Some(active) = state.active_terminal.as_deref() { + let prefixed = make_prefixed_id(connection_id, active); + let terminal = terminals.read().get(&prefixed).cloned(); + if let Some(terminal) = terminal { + stdout.write_all(&terminal.render_snapshot())?; + } else { + queue!( + stdout, + cursor::MoveTo(0, 0), + Print("Waiting for terminal stream...") + )?; + } + } else { + queue!( + stdout, + cursor::MoveTo(0, 0), + Print("No remote terminals in workspace.") + )?; + } + + draw_status(&mut stdout, state, cols, rows)?; + stdout.flush()?; + Ok(()) +} + +fn resize_active_terminal( + connection_id: &str, + terminals: &Arc>>>, + state: &mut TuiState, + cols: u16, + rows: u16, +) { + let Some(active) = state.active_terminal.as_deref() else { + return; + }; + let next = (active.to_string(), cols, rows); + if state.last_resize.as_ref() == Some(&next) { + return; + } + + let prefixed = make_prefixed_id(connection_id, active); + if let Some(terminal) = terminals.read().get(&prefixed) { + terminal.claim_resize_local(); + terminal.resize(TerminalSize { + cols, + rows, + ..TerminalSize::default() + }); + state.last_resize = Some(next); + } +} + +fn draw_status(stdout: &mut io::Stdout, state: &TuiState, cols: u16, rows: u16) -> Result<()> { + let entries = state.entries(); + let active_index = state + .active_terminal + .as_deref() + .and_then(|active| entries.iter().position(|entry| entry.id == active)) + .map(|index| index + 1) + .unwrap_or(0); + let active_label = state + .active_terminal + .as_deref() + .and_then(|active| entries.iter().find(|entry| entry.id == active)) + .map(|entry| entry.label.as_str()) + .unwrap_or("none"); + let message = state.message.as_deref().unwrap_or(""); + let status = format!( + " Okena TUI | {} | {}/{} {} | Ctrl-] quit | Ctrl-T next {}{}", + status_label(&state.status), + active_index, + entries.len(), + active_label, + if message.is_empty() { "" } else { "| " }, + message + ); + + queue!( + stdout, + cursor::MoveTo(0, rows.saturating_sub(1)), + SetAttribute(Attribute::Reverse), + Print(fit_line(&status, cols)), + terminal::Clear(ClearType::UntilNewLine), + SetAttribute(Attribute::Reset) + )?; + Ok(()) +} + +fn status_label(status: &ConnectionStatus) -> String { + match status { + ConnectionStatus::Disconnected => "disconnected".to_string(), + ConnectionStatus::Connecting => "connecting".to_string(), + ConnectionStatus::Pairing => "pairing".to_string(), + ConnectionStatus::Connected => "connected".to_string(), + ConnectionStatus::Reconnecting { attempt } => format!("reconnecting:{attempt}"), + ConnectionStatus::Error(message) => format!("error:{message}"), + } +} + +fn fit_line(line: &str, cols: u16) -> String { + line.chars().take(usize::from(cols)).collect() +} + +fn collect_terminal_entries(state: &StateResponse) -> Vec { + let mut entries = Vec::new(); + for project in &state.projects { + if let Some(layout) = &project.layout { + collect_layout_entries(project, layout, &mut entries); + } + } + entries +} + +fn collect_layout_entries( + project: &ApiProject, + node: &ApiLayoutNode, + entries: &mut Vec, +) { + match node { + ApiLayoutNode::Terminal { + terminal_id: Some(id), + .. + } => { + let name = project + .terminal_names + .get(id) + .map(String::as_str) + .unwrap_or(id); + entries.push(TerminalEntry { + id: id.clone(), + label: format!("{}:{}", project.name, name), + }); + } + ApiLayoutNode::Terminal { .. } => {} + ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { + for child in children { + collect_layout_entries(project, child, entries); + } + } + } +} + +fn connection_config(args: &Args) -> Result { + let discovered = if args.port.is_none() && args.socket.is_none() { + discover_local(args.profile.as_deref()).transpose()? + } else { + None + }; + + let local_endpoint = match &args.socket { + Some(path) => Some(LocalEndpoint::UnixSocket { + path: path.to_string_lossy().into_owned(), + }), + None => discovered + .as_ref() + .and_then(|discovered| discovered.local_endpoint.clone()), + }; + let host = args + .host + .clone() + .or_else(|| { + discovered + .as_ref() + .map(|discovered| discovered.host.clone()) + }) + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = args + .port + .or_else(|| discovered.as_ref().map(|discovered| discovered.port)) + .ok_or_else(|| anyhow!("missing --port and no local remote.json was discovered"))?; + let tls = args.tls || discovered.as_ref().is_some_and(|discovered| discovered.tls); + + Ok(RemoteConnectionConfig { + id: uuid::Uuid::new_v4().to_string(), + name: "Okena TUI".to_string(), + host, + port, + saved_token: args.token.clone(), + token_obtained_at: None, + tls, + pinned_cert_sha256: None, + local_endpoint, + }) +} + +struct DiscoveredDaemon { + host: String, + port: u16, + tls: bool, + local_endpoint: Option, +} + +fn discover_local(profile: Option<&str>) -> Option> { + let root = okena_core::profiles::config_root(); + let mut candidates = Vec::new(); + + if let Some(profile) = profile { + candidates.push(root.join("profiles").join(profile).join("remote.json")); + } else if let Ok(index) = okena_core::profiles::ProfileIndex::load(&root) { + if let Some(last_used) = index.last_used { + candidates.push(root.join("profiles").join(last_used).join("remote.json")); + } + if index.profiles.len() == 1 + && let Some(profile) = index.profiles.first() + { + candidates.push(root.join("profiles").join(&profile.id).join("remote.json")); + } + candidates.push( + root.join("profiles") + .join(index.default_profile) + .join("remote.json"), + ); + } + + candidates.push(root.join("remote.json")); + candidates + .into_iter() + .find(|path| path.exists()) + .map(|path| parse_remote_json(&path)) +} + +fn parse_remote_json(path: &Path) -> Result { + let data = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let value: serde_json::Value = + serde_json::from_str(&data).with_context(|| format!("parsing {}", path.display()))?; + let port = value + .get("port") + .and_then(|port| port.as_u64()) + .and_then(|port| u16::try_from(port).ok()) + .ok_or_else(|| anyhow!("{} is missing a valid port", path.display()))?; + let host = value + .get("local_host") + .and_then(|host| host.as_str()) + .filter(|host| !host.is_empty()) + .unwrap_or("127.0.0.1") + .to_string(); + let tls = value + .get("tls") + .and_then(|tls| tls.as_bool()) + .unwrap_or(false); + let local_endpoint = value + .get("local_endpoint") + .and_then(|endpoint| serde_json::from_value(endpoint.clone()).ok()); + + Ok(DiscoveredDaemon { + host, + port, + tls, + local_endpoint, + }) +} diff --git a/crates/okena-views-git/src/blame.rs b/crates/okena-views-git/src/blame.rs index 7f4270e41..c132895ed 100644 --- a/crates/okena-views-git/src/blame.rs +++ b/crates/okena-views-git/src/blame.rs @@ -1,65 +1,13 @@ -//! `BlameProvider` impls: local (gix-backed) and remote (HTTP). +//! Remote `BlameProvider` impl (HTTP). //! -//! Mirrors the `GitProvider` split in `diff_viewer/provider.rs`. The trait -//! and data types live in `okena-files::blame` so the file viewer doesn't -//! depend on any git crate. +//! The trait and data types live in `okena-files::blame` so the file viewer +//! doesn't depend on any git crate. use std::collections::HashMap; use std::sync::Arc; use okena_files::blame::{BlameCommit, BlameError, BlameKind, BlameLine, BlameProvider}; -/// Local provider — calls `okena_git::get_blame` and converts types. -pub struct LocalBlameProvider { - path: String, -} - -impl LocalBlameProvider { - pub fn new(path: String) -> Self { - Self { path } - } -} - -impl BlameProvider for LocalBlameProvider { - fn get_blame(&self, relative_path: &str) -> Result, BlameError> { - let result = okena_git::get_blame(std::path::Path::new(&self.path), relative_path) - .map_err(map_git_error)?; - Ok(result.into_iter().map(convert_line).collect()) - } -} - -fn map_git_error(e: okena_git::BlameError) -> BlameError { - use okena_git::BlameError as E; - match e { - E::NotGitRepo => BlameError::NotGitRepo, - E::NotTracked => BlameError::NotTracked, - E::NoCommits => BlameError::NoCommits, - E::Backend(s) | E::Io(s) => BlameError::Backend(s), - } -} - -fn convert_commit(c: &okena_git::BlameCommit) -> BlameCommit { - BlameCommit { - hash: c.hash.clone(), - short_hash: c.short_hash.clone(), - author: c.author.clone(), - author_email: c.author_email.clone(), - timestamp: c.timestamp, - summary: c.summary.clone(), - } -} - -fn convert_line(l: okena_git::BlameLine) -> BlameLine { - BlameLine { - line_number: l.line_number, - commit: Arc::new(convert_commit(&l.commit)), - kind: match l.kind { - okena_git::BlameKind::Committed => BlameKind::Committed, - okena_git::BlameKind::Uncommitted => BlameKind::Uncommitted, - }, - } -} - /// Remote provider — fetches blame from the remote server via the `GitBlame` /// action. The wire format is `Vec` (no `Arc` sharing); this /// impl re-dedups commits client-side so the rendered gutter keeps one @@ -68,12 +16,19 @@ pub struct RemoteBlameProvider { host: String, port: u16, token: String, + local_endpoint: Option, project_id: String, } impl RemoteBlameProvider { - pub fn new(host: String, port: u16, token: String, project_id: String) -> Self { - Self { host, port, token, project_id } + pub fn new( + host: String, + port: u16, + token: String, + local_endpoint: Option, + project_id: String, + ) -> Self { + Self { host, port, token, local_endpoint, project_id } } } @@ -90,10 +45,11 @@ impl BlameProvider for RemoteBlameProvider { project_id: self.project_id.clone(), relative_path: relative_path.to_string(), }; - let value = okena_transport::remote_action::post_action( + let value = okena_transport::remote_action::post_action_with_endpoint( &self.host, self.port, &self.token, + self.local_endpoint.as_ref(), action, ) .map_err(BlameError::Backend)?; diff --git a/crates/okena-views-git/src/close_worktree_dialog.rs b/crates/okena-views-git/src/close_worktree_dialog.rs index a0f9b16f4..93d1b9da0 100644 --- a/crates/okena-views-git/src/close_worktree_dialog.rs +++ b/crates/okena-views-git/src/close_worktree_dialog.rs @@ -5,13 +5,11 @@ //! `execute.rs` holds the async close pipeline; `view.rs` holds the //! `Render` impl. -use okena_git as git; use okena_workspace::settings::{HooksConfig, WorktreeConfig}; use okena_workspace::state::Workspace; use gpui::prelude::*; use gpui::*; -use std::path::PathBuf; mod execute; mod view; @@ -29,31 +27,31 @@ impl okena_ui::overlay::CloseEvent for CloseWorktreeDialogEvent { fn is_close(&self) -> bool { matches!(self, Self::Closed) } } -/// Processing state for async operations +/// Processing state for the close operation. +/// +/// The per-step pipeline (stash/fetch/rebase/merge/push/delete-branch) runs +/// daemon-side inside `Workspace::close_worktree`, so the dialog only tracks +/// whether the single `CloseWorktree` action is in flight — it has no +/// per-step progress to surface. #[derive(Clone, Debug, PartialEq)] pub(super) enum ProcessingState { Idle, - Stashing, - Fetching, - Rebasing, - Merging, - Pushing, - DeletingBranch, - Removing, + Working, } /// Confirmation dialog shown when closing a worktree. /// Checks for dirty state and optionally merges the branch back. pub struct CloseWorktreeDialog { - pub(super) workspace: Entity, - pub(super) focus_manager: Entity, + pub(super) host: String, + pub(super) port: u16, + pub(super) token: String, + pub(super) local_endpoint: Option, + pub(super) daemon_project_id: String, pub(super) focus_handle: FocusHandle, - pub(super) project_id: String, pub(super) project_name: String, pub(super) project_path: String, pub(super) branch: Option, pub(super) default_branch: Option, - pub(super) main_repo_path: Option, pub(super) is_dirty: bool, pub(super) merge_enabled: bool, pub(super) stash_enabled: bool, @@ -63,16 +61,25 @@ pub struct CloseWorktreeDialog { pub(super) unpushed_count: usize, pub(super) error_message: Option, pub(super) processing: ProcessingState, - pub(super) hooks_config: HooksConfig, } impl CloseWorktreeDialog { + #[allow(clippy::too_many_arguments)] pub fn new( + host: String, + port: u16, + token: String, + local_endpoint: Option, + daemon_project_id: String, workspace: Entity, - focus_manager: Entity, + // The daemon owns worktree removal; the dialog no longer scrubs focus + // state itself, so this is unused (kept for call-site stability). + _focus_manager: Entity, project_id: String, worktree_config: WorktreeConfig, - hooks_config: HooksConfig, + // Hooks now fire daemon-side inside `Workspace::close_worktree`; the + // dialog no longer reads them (kept for call-site stability). + _hooks_config: HooksConfig, cx: &mut Context, ) -> Self { let ws = workspace.read(cx); @@ -80,26 +87,21 @@ impl CloseWorktreeDialog { let project_name = project.map(|p| p.name.clone()).unwrap_or_default(); let project_path = project.map(|p| p.path.clone()).unwrap_or_default(); - let main_repo_path = ws.worktree_parent_path(&project_id); - let path = PathBuf::from(&project_path); - let is_dirty = git::has_uncommitted_changes(&path); - let branch = git::get_current_branch(&path); - let default_branch = main_repo_path - .as_ref() - .and_then(|p| git::get_default_branch(&PathBuf::from(p))); - let unpushed_count = git::count_unpushed_commits(&path).unwrap_or(0); + let (is_dirty, branch, default_branch, unpushed_count) = + Self::fetch_close_info(&host, port, &token, local_endpoint.as_ref(), daemon_project_id.clone()); Self { - workspace, - focus_manager, + host, + port, + token, + local_endpoint, + daemon_project_id, focus_handle: cx.focus_handle(), - project_id, project_name, project_path, branch, default_branch, - main_repo_path, is_dirty, merge_enabled: worktree_config.default_merge, stash_enabled: worktree_config.default_stash, @@ -109,7 +111,38 @@ impl CloseWorktreeDialog { unpushed_count, error_message: None, processing: ProcessingState::Idle, - hooks_config, + } + } + + /// Fetch the git-derived close info from the daemon. The repo lives on the + /// daemon, so we post a `WorktreeCloseInfo` action rather than reading local + /// git. Kept synchronous on purpose — the old code did blocking local git + /// here, so a blocking HTTP call is no worse. + fn fetch_close_info( + host: &str, + port: u16, + token: &str, + local_endpoint: Option<&okena_transport::client::LocalEndpoint>, + project_id: String, + ) + -> (bool, Option, Option, usize) + { + let action = okena_core::api::ActionRequest::WorktreeCloseInfo { project_id }; + match okena_transport::remote_action::post_action_with_endpoint( + host, + port, + token, + local_endpoint, + action, + ) { + Ok(Some(v)) => { + let is_dirty = v.get("is_dirty").and_then(|x| x.as_bool()).unwrap_or(false); + let branch = v.get("branch").and_then(|x| x.as_str()).map(String::from); + let default_branch = v.get("default_branch").and_then(|x| x.as_str()).map(String::from); + let unpushed_count = v.get("unpushed_count").and_then(|x| x.as_u64()).unwrap_or(0) as usize; + (is_dirty, branch, default_branch, unpushed_count) + } + _ => (false, None, None, 0), } } diff --git a/crates/okena-views-git/src/close_worktree_dialog/execute.rs b/crates/okena-views-git/src/close_worktree_dialog/execute.rs index 59f58c5ab..df5aa5b7f 100644 --- a/crates/okena-views-git/src/close_worktree_dialog/execute.rs +++ b/crates/okena-views-git/src/close_worktree_dialog/execute.rs @@ -1,560 +1,61 @@ -//! Confirm path of CloseWorktreeDialog — the async pipeline that optionally -//! stashes/fetches/rebases/merges and then removes the worktree. Hook -//! integration runs before the merge step and before the actual removal. +//! Confirm path of CloseWorktreeDialog — dispatches the daemon-side +//! `CloseWorktree` action. The stash/fetch/rebase/merge/push/delete-branch +//! pipeline (and all hook integration) now runs on the daemon inside +//! `Workspace::close_worktree`; the dialog only forwards the raw checkbox flags +//! and reflects success/failure. use super::{CloseWorktreeDialog, ProcessingState}; -use okena_git as git; -use okena_workspace::hooks; -use okena_workspace::state::PendingWorktreeClose; -use okena_workspace::toast::ToastManager; - use gpui::Context; -use std::path::PathBuf; impl CloseWorktreeDialog { pub(super) fn execute(&mut self, cx: &mut Context) { if self.processing != ProcessingState::Idle { return; } - self.error_message = None; - - let project_id = self.project_id.clone(); - let project_name = self.project_name.clone(); - let project_path = self.project_path.clone(); - let branch = self.branch.clone().unwrap_or_default(); - let default_branch = self.default_branch.clone().unwrap_or_default(); - let main_repo_path = self.main_repo_path.clone().unwrap_or_default(); - let merge_enabled = self.merge_enabled && self.can_merge(); - let stash_enabled = self.stash_enabled && self.is_dirty; - let fetch_enabled = self.fetch_enabled; - let push_enabled = self.push_enabled; - let delete_branch_enabled = self.delete_branch_enabled; - let is_dirty = self.is_dirty; - let workspace = self.workspace.clone(); - let focus_manager = self.focus_manager.clone(); - - // Read hooks config and monitor before spawning - let ws = workspace.read(cx); - let project_hooks = ws - .project(&project_id) - .map(|p| p.hooks.clone()) - .unwrap_or_default(); - let global_hooks = self.hooks_config.clone(); - let folder = ws.folder_for_project_or_parent(&project_id); - let folder_id = folder.map(|f| f.id.clone()); - let folder_name = folder.map(|f| f.name.clone()); - let monitor = hooks::try_monitor(cx); - let runner = hooks::try_runner(cx); + // Single generic working state — the per-step pipeline now runs on the + // daemon, so the dialog no longer drives stash/rebase/merge progress. + self.processing = ProcessingState::Working; + cx.notify(); + + let host = self.host.clone(); + let port = self.port; + let token = self.token.clone(); + let local_endpoint = self.local_endpoint.clone(); + let project_id = self.daemon_project_id.clone(); + let merge = self.merge_enabled; + let stash = self.stash_enabled; + let fetch = self.fetch_enabled; + let push = self.push_enabled; + let delete_branch = self.delete_branch_enabled; cx.spawn(async move |this, cx| { - let mut did_stash = false; - - // Step 1: If merge enabled, run merge flow - if merge_enabled { - // Stash (if stash_enabled and is_dirty) - if stash_enabled { - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::Stashing; - cx.notify(); - }) - }); - - let stash_path = PathBuf::from(&project_path); - let stash_result = - smol::unblock(move || git::stash_changes(&stash_path)).await; - - if let Err(e) = stash_result { - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.error_message = - Some(format!("Stash failed: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }) - }); - return; - } - - did_stash = true; - } - - // Fetch (if fetch_enabled) - if fetch_enabled { - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::Fetching; - cx.notify(); - }) - }); - - let fetch_path = PathBuf::from(&project_path); - let fetch_result = - smol::unblock(move || git::fetch_all(&fetch_path)).await; - - if let Err(e) = fetch_result { - if did_stash { - let pop_path = PathBuf::from(&project_path); - if let Err(pop_err) = - smol::unblock(move || git::stash_pop(&pop_path)).await - { - log::warn!( - "Failed to restore stashed changes for worktree '{}' at {} after fetch failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", - branch, project_path, pop_err - ); - cx.update(|cx| { - ToastManager::warning( - format!( - "Couldn't restore your stashed changes in '{}'. They are still saved in the git stash — run `git stash pop` to recover them.", - branch - ), - cx, - ); - }); - } - } - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.error_message = - Some(format!("Fetch failed: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }) - }); - return; - } - } - - // pre_merge hook (sync) - let pre_merge_result = smol::unblock({ - let project_hooks = project_hooks.clone(); - let global_hooks = global_hooks.clone(); - let project_id = project_id.clone(); - let project_name = project_name.clone(); - let project_path = project_path.clone(); - let branch = branch.clone(); - let default_branch = default_branch.clone(); - let main_repo_path = main_repo_path.clone(); - let folder_id = folder_id.clone(); - let folder_name = folder_name.clone(); - let monitor = monitor.clone(); - move || { - // Sync hooks run headlessly (no PTY) — they block the flow - // and can't be shown in the UI anyway - hooks::fire_pre_merge( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - &default_branch, - &main_repo_path, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - None, - ) - } - }) - .await; - - if let Err(e) = pre_merge_result { - if did_stash { - let pop_path = PathBuf::from(&project_path); - if let Err(pop_err) = - smol::unblock(move || git::stash_pop(&pop_path)).await - { - log::warn!( - "Failed to restore stashed changes for worktree '{}' at {} after pre_merge hook failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", - branch, project_path, pop_err - ); - cx.update(|cx| { - ToastManager::warning( - format!( - "Couldn't restore your stashed changes in '{}'. They are still saved in the git stash — run `git stash pop` to recover them.", - branch - ), - cx, - ); - }); - } - } - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.error_message = Some(format!("pre_merge hook failed: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }) - }); - return; - } - - // Rebase - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::Rebasing; - cx.notify(); - }) - }); - - let worktree_path = PathBuf::from(&project_path); - let rebase_target = default_branch.clone(); - let rebase_result = smol::unblock(move || { - git::rebase_onto(&worktree_path, &rebase_target) - }) - .await; - - if let Err(e) = rebase_result { - // Fire on_rebase_conflict hook - let error_msg = e.to_string(); - let (terminal_actions, hook_results) = hooks::fire_on_rebase_conflict( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - &default_branch, - &main_repo_path, - &error_msg, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - runner.as_ref(), - ); - cx.update(|cx| { - workspace.update(cx, |ws, cx| { - for (cmd, env) in terminal_actions { - ws.add_terminal_with_command(&project_id, &cmd, &env, cx); - } - ws.register_hook_results(hook_results, cx); - }) - }); - - if did_stash { - let pop_path = PathBuf::from(&project_path); - if let Err(pop_err) = - smol::unblock(move || git::stash_pop(&pop_path)).await - { - log::warn!( - "Failed to restore stashed changes for worktree '{}' at {} after rebase failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", - branch, project_path, pop_err - ); - cx.update(|cx| { - ToastManager::warning( - format!( - "Couldn't restore your stashed changes in '{}'. They are still saved in the git stash — run `git stash pop` to recover them.", - branch - ), - cx, - ); - }); - } - } - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.error_message = Some(format!("Rebase failed: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }) - }); - return; - } - - // Merge (ff-only) in the main repo - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::Merging; - cx.notify(); - }) - }); - - let main_path = PathBuf::from(&main_repo_path); - let merge_branch = branch.clone(); - let merge_result = smol::unblock(move || { - git::merge_branch(&main_path, &merge_branch, true) - }) - .await; - - if let Err(e) = merge_result { - if did_stash { - let pop_path = PathBuf::from(&project_path); - if let Err(pop_err) = - smol::unblock(move || git::stash_pop(&pop_path)).await - { - log::warn!( - "Failed to restore stashed changes for worktree '{}' at {} after merge failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", - branch, project_path, pop_err - ); - cx.update(|cx| { - ToastManager::warning( - format!( - "Couldn't restore your stashed changes in '{}'. They are still saved in the git stash — run `git stash pop` to recover them.", - branch - ), - cx, - ); - }); - } - } - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.error_message = Some(format!("Merge failed: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }) - }); - return; - } - - // post_merge hook (async) - let _ = hooks::fire_post_merge( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - &default_branch, - &main_repo_path, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - runner.as_ref(), - ); - - // Push default branch (if push_enabled) - if push_enabled { - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::Pushing; - cx.notify(); - }) - }); - - let push_path = PathBuf::from(&main_repo_path); - let push_branch = default_branch.clone(); - let push_result = smol::unblock(move || { - git::push_branch(&push_path, &push_branch) - }) - .await; - - if let Err(e) = push_result { - log::warn!("Push failed (continuing): {}", e); - } - } - - // Delete branch (if delete_branch_enabled) - if delete_branch_enabled { - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::DeletingBranch; - cx.notify(); - }) - }); - - let del_local_path = PathBuf::from(&main_repo_path); - let del_local_branch = branch.clone(); - let del_local_result = smol::unblock(move || { - git::delete_local_branch(&del_local_path, &del_local_branch) - }) - .await; - - if let Err(e) = del_local_result { - log::warn!("Delete local branch failed (continuing): {}", e); - } - - let del_remote_path = PathBuf::from(&main_repo_path); - let del_remote_branch = branch.clone(); - let del_remote_result = smol::unblock(move || { - git::delete_remote_branch(&del_remote_path, &del_remote_branch) - }) - .await; - - if let Err(e) = del_remote_result { - log::warn!("Delete remote branch failed (continuing): {}", e); - } - } - } - - let force_remove = is_dirty && !did_stash; - - // Step 2: before_worktree_remove hook - // If the hook exists and we have a runner, fire it as a visible PTY terminal - // and register a pending close — the actual removal happens when the hook exits. - // If no hook or no runner, proceed with immediate removal. - let has_before_remove_hook = - project_hooks.worktree.before_remove.is_some() || global_hooks.worktree.before_remove.is_some(); - - if has_before_remove_hook && runner.is_some() { - // Fire hook as visible PTY terminal and defer removal - let ok = cx.update(|cx| { - let hook_results = hooks::fire_before_worktree_remove_async( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - &main_repo_path, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - runner.as_ref(), - ); - - let pending_terminal_id = hook_results.first().map(|r| r.terminal_id.clone()); - - if pending_terminal_id.is_some() { - workspace.update(cx, |ws, cx| { - ws.register_hook_results(hook_results, cx); - - // Register pending close — PTY exit handler will complete it - if let Some(hook_terminal_id) = pending_terminal_id { - ws.register_pending_worktree_close(PendingWorktreeClose { - project_id: project_id.clone(), - hook_terminal_id, - branch: branch.clone(), - main_repo_path: main_repo_path.clone(), - }); - } - }); - - // Close dialog — removal will happen when hook exits - let _ = this.update(cx, |this, cx| { - this.close(cx); - }); - true - } else { - // Hook terminal failed to spawn — abort, don't remove - let _ = this.update(cx, |this, cx| { - this.error_message = Some("before_worktree_remove hook failed to start".into()); - this.processing = ProcessingState::Idle; - cx.notify(); - }); - false - } - }); - if !ok { - } - } else { - // No hook or no runner — run headlessly then remove immediately - if has_before_remove_hook { - let before_remove_result = smol::unblock({ - let project_hooks = project_hooks.clone(); - let global_hooks = global_hooks.clone(); - let project_id = project_id.clone(); - let project_name = project_name.clone(); - let project_path = project_path.clone(); - let branch = branch.clone(); - let main_repo_path = main_repo_path.clone(); - let folder_id = folder_id.clone(); - let folder_name = folder_name.clone(); - let monitor = monitor.clone(); - move || { - hooks::fire_before_worktree_remove( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - &main_repo_path, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - None, - ) - } - }) - .await; - - if let Err(e) = before_remove_result { - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.error_message = - Some(format!("before_worktree_remove hook failed: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }) - }); - return; - } - } - - // Fire on_dirty_worktree_close hook when closing dirty worktree without stash - if force_remove { - let (terminal_actions, hook_results) = hooks::fire_on_dirty_worktree_close( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - runner.as_ref(), - ); - cx.update(|cx| { - workspace.update(cx, |ws, cx| { - for (cmd, env) in terminal_actions { - ws.add_terminal_with_command(&project_id, &cmd, &env, cx); - } - ws.register_hook_results(hook_results, cx); - }) - }); - } - - let _ = cx.update(|cx| { - this.update(cx, |this, cx| { - this.processing = ProcessingState::Removing; - cx.notify(); - }) - }); - - cx.update(|cx| { - let result = focus_manager.update(cx, |fm, cx| { - let result = workspace.update(cx, |ws, cx| { - ws.remove_worktree_project(fm, &project_id, force_remove, &global_hooks, cx) - }); - cx.notify(); - result - }); - - match result { - Ok(()) => { - let _ = hooks::fire_worktree_removed( - &project_hooks, - &global_hooks, - &project_id, - &project_name, - &project_path, - &branch, - &main_repo_path, - folder_id.as_deref(), - folder_name.as_deref(), - monitor.as_ref(), - runner.as_ref(), - ); - - let _ = this.update(cx, |this, cx| { - this.close(cx); - }); - } - Err(e) => { - let _ = this.update(cx, |this, cx| { - this.error_message = Some(format!("Failed to remove worktree: {}", e)); - this.processing = ProcessingState::Idle; - cx.notify(); - }); - } - } - }); - } + let result = smol::unblock(move || { + okena_transport::remote_action::post_action_with_endpoint( + &host, + port, + &token, + local_endpoint.as_ref(), + okena_core::api::ActionRequest::CloseWorktree { + project_id, merge, stash, fetch, push, delete_branch, + }, + ) + }) + .await; + + let _ = this.update(cx, |this, cx| match result { + Ok(_) => { + // Daemon completed the close (or deferred it behind a visible + // before_remove hook PTY); the removal mirrors back. + this.close(cx); + } + Err(e) => { + this.error_message = Some(e); + this.processing = ProcessingState::Idle; + cx.notify(); + } + }); }) .detach(); } diff --git a/crates/okena-views-git/src/close_worktree_dialog/view.rs b/crates/okena-views-git/src/close_worktree_dialog/view.rs index 319258ee5..9a3875d1f 100644 --- a/crates/okena-views-git/src/close_worktree_dialog/view.rs +++ b/crates/okena-views-git/src/close_worktree_dialog/view.rs @@ -26,13 +26,7 @@ impl Render for CloseWorktreeDialog { let is_processing = self.processing != ProcessingState::Idle; let status_text = match &self.processing { - ProcessingState::Stashing => Some("Stashing changes..."), - ProcessingState::Fetching => Some("Fetching remote..."), - ProcessingState::Rebasing => Some("Rebasing..."), - ProcessingState::Merging => Some("Merging..."), - ProcessingState::Pushing => Some("Pushing branch..."), - ProcessingState::DeletingBranch => Some("Deleting branch..."), - ProcessingState::Removing => Some("Removing worktree..."), + ProcessingState::Working => Some("Closing worktree..."), ProcessingState::Idle => None, }; diff --git a/crates/okena-views-git/src/diff_viewer/provider.rs b/crates/okena-views-git/src/diff_viewer/provider.rs index 6f22e2332..401d647e0 100644 --- a/crates/okena-views-git/src/diff_viewer/provider.rs +++ b/crates/okena-views-git/src/diff_viewer/provider.rs @@ -1,6 +1,7 @@ -//! GitProvider trait and implementations for local and remote git operations. +//! GitProvider trait and the remote-server (HTTP) implementation. use okena_git::{BranchList, CommitLogEntry, DiffMode, DiffResult, FileDiffSummary}; +use serde::de::DeserializeOwned; /// Provides git data from either local git commands or a remote server. pub trait GitProvider: Send + Sync + 'static { @@ -53,116 +54,53 @@ pub trait GitProvider: Send + Sync + 'static { fn absolute_file_path(&self, file_path: &str) -> Option; } -/// Local git provider — wraps existing git functions. -pub struct LocalGitProvider { - path: String, -} - -impl LocalGitProvider { - pub fn new(path: String) -> Self { - Self { path } - } -} - -impl GitProvider for LocalGitProvider { - fn is_git_repo(&self) -> bool { - okena_git::is_git_repo(std::path::Path::new(&self.path)) - } - - fn get_diff(&self, mode: DiffMode, ignore_whitespace: bool) -> Result { - okena_git::get_diff_with_options(std::path::Path::new(&self.path), mode, ignore_whitespace) - .map_err(|e| e.to_string()) - } - - fn get_file_contents(&self, file_path: &str, mode: DiffMode) -> (Option, Option) { - okena_git::get_file_contents_for_diff(std::path::Path::new(&self.path), file_path, mode) - } - - fn get_diff_file_summary(&self) -> Vec { - okena_git::get_diff_file_summary(std::path::Path::new(&self.path)) - } - - fn get_commit_graph(&self, count: usize, branch: Option<&str>) -> Vec { - okena_git::fetch_commit_log(std::path::Path::new(&self.path), count, branch) - } - - fn list_branches(&self) -> Vec { - okena_git::list_branches(std::path::Path::new(&self.path)) - } - - fn list_branches_classified(&self) -> BranchList { - okena_git::list_branches_classified(std::path::Path::new(&self.path)) - } - - fn checkout_local_branch(&self, branch: &str) -> Result<(), String> { - okena_git::checkout_local_branch(std::path::Path::new(&self.path), branch) - .map_err(|e| e.to_string()) - } - - fn checkout_remote_branch(&self, remote_branch: &str) -> Result<(), String> { - okena_git::checkout_remote_branch(std::path::Path::new(&self.path), remote_branch) - .map_err(|e| e.to_string()) - } - - fn create_and_checkout_branch( - &self, - new_name: &str, - start_point: Option<&str>, - ) -> Result<(), String> { - okena_git::create_and_checkout_branch( - std::path::Path::new(&self.path), - new_name, - start_point, - ) - .map_err(|e| e.to_string()) - } - - fn stage_file(&self, file_path: &str) -> Result<(), String> { - okena_git::stage_file(std::path::Path::new(&self.path), file_path) - .map_err(|e| e.to_string()) - } - - fn unstage_file(&self, file_path: &str) -> Result<(), String> { - okena_git::unstage_file(std::path::Path::new(&self.path), file_path) - .map_err(|e| e.to_string()) - } - - fn discard_file(&self, file_path: &str) -> Result<(), String> { - okena_git::discard_file_changes(std::path::Path::new(&self.path), file_path) - .map_err(|e| e.to_string()) - } - - fn delete_file(&self, file_path: &str) -> Result<(), String> { - let abs = std::path::Path::new(&self.path).join(file_path); - std::fs::remove_file(&abs) - .map_err(|e| format!("Failed to delete file: {}", e)) - } - - fn absolute_file_path(&self, file_path: &str) -> Option { - Some( - std::path::Path::new(&self.path) - .join(file_path) - .to_string_lossy() - .to_string(), - ) - } -} - /// Remote git provider — fetches git data via HTTP from a remote server. pub struct RemoteGitProvider { host: String, port: u16, token: String, + local_endpoint: Option, project_id: String, + root: String, } impl RemoteGitProvider { - pub fn new(host: String, port: u16, token: String, project_id: String) -> Self { - Self { host, port, token, project_id } + pub fn new( + host: String, + port: u16, + token: String, + local_endpoint: Option, + project_id: String, + root: String, + ) -> Self { + Self { host, port, token, local_endpoint, project_id, root } } fn post_action(&self, action: okena_core::api::ActionRequest) -> Result, String> { - okena_transport::remote_action::post_action(&self.host, self.port, &self.token, action) + okena_transport::remote_action::post_action_with_endpoint( + &self.host, + self.port, + &self.token, + self.local_endpoint.as_ref(), + action, + ) + } + + fn post_json_or_default(&self, action: okena_core::api::ActionRequest, label: &str) -> T + where + T: DeserializeOwned + Default, + { + match self.post_action(action) { + Ok(Some(value)) => serde_json::from_value(value).unwrap_or_else(|e| { + log::warn!("Failed to deserialize {label}: {e}"); + T::default() + }), + _ => T::default(), + } + } + + fn post_unit(&self, action: okena_core::api::ActionRequest) -> Result<(), String> { + self.post_action(action).map(|_| ()) } } @@ -172,7 +110,7 @@ impl GitProvider for RemoteGitProvider { } fn supports_mutations(&self) -> bool { - false + true } fn get_diff(&self, mode: DiffMode, ignore_whitespace: bool) -> Result { @@ -208,13 +146,7 @@ impl GitProvider for RemoteGitProvider { let action = okena_core::api::ActionRequest::GitDiffSummary { project_id: self.project_id.clone(), }; - match self.post_action(action) { - Ok(Some(value)) => serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("Failed to deserialize diff summary: {}", e); - Vec::new() - }), - _ => Vec::new(), - } + self.post_json_or_default(action, "diff summary") } fn get_commit_graph(&self, count: usize, branch: Option<&str>) -> Vec { @@ -223,26 +155,21 @@ impl GitProvider for RemoteGitProvider { count, branch: branch.map(String::from), }; - match self.post_action(action) { - Ok(Some(value)) => serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("Failed to deserialize commit graph: {}", e); - Vec::new() - }), - _ => Vec::new(), - } + self.post_json_or_default(action, "commit graph") } fn list_branches(&self) -> Vec { let action = okena_core::api::ActionRequest::GitListBranches { project_id: self.project_id.clone(), }; - match self.post_action(action) { - Ok(Some(value)) => serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("Failed to deserialize branch list: {}", e); - Vec::new() - }), - _ => Vec::new(), - } + self.post_json_or_default(action, "branch list") + } + + fn list_branches_classified(&self) -> BranchList { + let action = okena_core::api::ActionRequest::GitListBranchesClassified { + project_id: self.project_id.clone(), + }; + self.post_json_or_default(action, "classified branch list") } fn stage_file(&self, file_path: &str) -> Result<(), String> { @@ -250,7 +177,7 @@ impl GitProvider for RemoteGitProvider { project_id: self.project_id.clone(), file_path: file_path.to_string(), }; - self.post_action(action).map(|_| ()) + self.post_unit(action) } fn unstage_file(&self, file_path: &str) -> Result<(), String> { @@ -258,7 +185,7 @@ impl GitProvider for RemoteGitProvider { project_id: self.project_id.clone(), file_path: file_path.to_string(), }; - self.post_action(action).map(|_| ()) + self.post_unit(action) } fn discard_file(&self, file_path: &str) -> Result<(), String> { @@ -266,7 +193,7 @@ impl GitProvider for RemoteGitProvider { project_id: self.project_id.clone(), file_path: file_path.to_string(), }; - self.post_action(action).map(|_| ()) + self.post_unit(action) } fn delete_file(&self, file_path: &str) -> Result<(), String> { @@ -274,10 +201,43 @@ impl GitProvider for RemoteGitProvider { project_id: self.project_id.clone(), relative_path: file_path.to_string(), }; - self.post_action(action).map(|_| ()) + self.post_unit(action) + } + + fn checkout_local_branch(&self, branch: &str) -> Result<(), String> { + let action = okena_core::api::ActionRequest::GitCheckoutLocalBranch { + project_id: self.project_id.clone(), + branch: branch.to_string(), + }; + self.post_unit(action) } - fn absolute_file_path(&self, _file_path: &str) -> Option { - None + fn checkout_remote_branch(&self, remote_branch: &str) -> Result<(), String> { + let action = okena_core::api::ActionRequest::GitCheckoutRemoteBranch { + project_id: self.project_id.clone(), + remote_branch: remote_branch.to_string(), + }; + self.post_unit(action) + } + + fn create_and_checkout_branch( + &self, + new_name: &str, + start_point: Option<&str>, + ) -> Result<(), String> { + let action = okena_core::api::ActionRequest::GitCreateAndCheckoutBranch { + project_id: self.project_id.clone(), + new_name: new_name.to_string(), + start_point: start_point.map(String::from), + }; + self.post_unit(action) + } + + fn absolute_file_path(&self, file_path: &str) -> Option { + if self.root.is_empty() { + return None; + } + let base = self.root.trim_end_matches(['/', '\\']); + Some(format!("{}/{}", base, file_path)) } } diff --git a/crates/okena-views-git/src/watcher.rs b/crates/okena-views-git/src/watcher.rs index a0da033ed..3d19c5df6 100644 --- a/crates/okena-views-git/src/watcher.rs +++ b/crates/okena-views-git/src/watcher.rs @@ -3,6 +3,7 @@ use okena_workspace::state::Workspace; use gpui::prelude::*; use gpui::*; use okena_core::api::ApiGitStatus; +use okena_core::git_poll::GitPollTrigger; use okena_core::process::{with_lane, Lane}; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -22,6 +23,8 @@ fn to_api(s: &GitStatus) -> ApiGitStatus { ahead: s.ahead, behind: s.behind, unpushed: s.unpushed, + review_base: s.review_base.clone(), + default_branch: s.default_branch.clone(), } } @@ -122,6 +125,10 @@ impl GitStatusWatcher { for (id, status) in new_statuses { self.statuses.insert(id.clone(), status.clone()); } + self.publish_statuses(cx); + } + + fn publish_statuses(&self, cx: &mut Context) { cx.notify(); let api_statuses: HashMap = self .statuses @@ -133,12 +140,78 @@ impl GitStatusWatcher { }); } + fn missing_github_stats(&self, project_id: &str) -> bool { + !(self.pr_infos.contains_key(project_id) && self.ci_checks.contains_key(project_id)) + } + /// Trigger an immediate git status refresh for a single project, bypassing /// the 5-second polling cadence. Used after explicit user actions like - /// branch checkout so the UI reflects the new state without waiting for - /// the next poll cycle. PR/CI info is preserved from cache and refreshed - /// by the regular loop. + /// branch checkout so the UI reflects the new state without waiting for the + /// next poll cycle. pub fn refresh_project(&mut self, project_id: String, cx: &mut Context) { + self.refresh_project_with_github(project_id, true, cx); + } + + /// Trigger an immediate refresh for a project that just became visible. GH + /// is fetched only when this project has no cached PR/CI result yet. + pub fn refresh_visible_project(&mut self, project_id: String, cx: &mut Context) { + self.refresh_project_with_github(project_id, false, cx); + } + + /// Drive an immediate refresh off an external [`GitPollTrigger`]. Lets the + /// single-binary `okena --headless` daemon react to the same wake-ups the + /// dedicated `okena-daemon` handles in its poll loop: a client showing a + /// project or requesting git status, a branch checkout, or a client + /// subscribing to terminals — without waiting for the next poll cycle. + pub fn apply_poll_trigger(&mut self, trigger: GitPollTrigger, cx: &mut Context) { + match trigger.project_id { + // Branch checkout: cached PR/CI belongs to the old branch — refresh + // and re-fetch `gh`. Otherwise (project shown / git status asked): + // refresh, fetching `gh` only when it isn't cached yet. + Some(project_id) if trigger.invalidate_github => { + self.refresh_project(project_id, cx); + } + Some(project_id) => { + self.refresh_visible_project(project_id, cx); + } + // No project id (a client's terminal subscription changed): fetch + // `gh` for any newly visible/subscribed project not yet cached. + None => self.refresh_visible_projects_missing_github(cx), + } + } + + /// Refresh every currently visible-or-subscribed non-remote project that has + /// no cached PR/CI yet. Mirrors the poll loop's `gh_ids` set so a project + /// that just entered view (e.g. a remote client subscribed to its terminal) + /// gets its badge immediately instead of on the next `gh` cadence cycle. + fn refresh_visible_projects_missing_github(&mut self, cx: &mut Context) { + let mut gh_ids = self.workspace.read(cx).all_visible_project_ids(); + if let Ok(remote_terminals) = self.remote_subscribed_terminals.read() { + for terminal_ids in remote_terminals.values() { + for tid in terminal_ids { + if let Some(p) = self.workspace.read(cx).find_project_for_terminal(tid) + && !p.is_remote + { + gh_ids.insert(p.id.clone()); + } + } + } + } + let stale: Vec = gh_ids + .into_iter() + .filter(|id| !self.statuses.contains_key(id) || self.missing_github_stats(id)) + .collect(); + for id in stale { + self.refresh_visible_project(id, cx); + } + } + + fn refresh_project_with_github( + &mut self, + project_id: String, + invalidate_github: bool, + cx: &mut Context, + ) { let path = self .workspace .read(cx) @@ -148,24 +221,65 @@ impl GitStatusWatcher { .map(|p| p.path.clone()); let Some(path) = path else { return }; + if invalidate_github { + self.pr_infos.remove(&project_id); + self.ci_checks.remove(&project_id); + self.any_pending_ci = self.ci_checks.values() + .any(|c| c.as_ref().map(|s| s.status.is_pending()).unwrap_or(false)); + let mut changed = false; + if let Some(Some(status)) = self.statuses.get_mut(&project_id) + && (status.pr_info.is_some() || status.ci_checks.is_some()) + { + status.pr_info = None; + status.ci_checks = None; + changed = true; + } + if changed { + self.publish_statuses(cx); + } + } + // Reuse the cached PR base so an immediate post-action refresh measures // ahead/behind against the PR's real target, matching the poll loop. - let pr_base = self - .pr_infos - .get(&project_id) - .and_then(|p| p.as_ref()) - .and_then(|p| p.base.clone()); + let pr_base = if invalidate_github { + None + } else { + self.pr_infos + .get(&project_id) + .and_then(|p| p.as_ref()) + .and_then(|p| p.base.clone()) + }; + let poll_github = invalidate_github || self.missing_github_stats(&project_id); cx.spawn(async move |this: WeakEntity, cx| { - let new_status = smol::unblock(move || { + let (new_status, fetched_pr_info, fetched_ci_checks) = smol::unblock(move || { with_lane(Lane::Poll, || { - git::refresh_git_status_with_pr_base(Path::new(&path), pr_base.as_deref()) + let path = Path::new(&path); + let status = git::refresh_git_status_with_pr_base(path, pr_base.as_deref()); + if !poll_github { + return (status, None, None); + } + if !git::repository::has_github_remote(path) { + return (status, Some(None), Some(None)); + } + let pr_info = git::repository::get_pr_info(path); + let pr_number = pr_info.as_ref().map(|p| p.number); + let ci_checks = git::repository::get_ci_checks(path, pr_number); + (status, Some(pr_info), Some(ci_checks)) }) }) .await; let _ = this.update(cx, |this, cx| { let mut new_status = new_status; + if let Some(pr_info) = fetched_pr_info { + this.pr_infos.insert(project_id.clone(), pr_info); + } + if let Some(ci_checks) = fetched_ci_checks { + this.ci_checks.insert(project_id.clone(), ci_checks); + this.any_pending_ci = this.ci_checks.values() + .any(|c| c.as_ref().map(|s| s.status.is_pending()).unwrap_or(false)); + } if let Some(status) = new_status.as_mut() { status.pr_info = this.pr_infos.get(&project_id).cloned().flatten(); status.ci_checks = this.ci_checks.get(&project_id).cloned().flatten(); @@ -175,15 +289,7 @@ impl GitStatusWatcher { this.statuses.insert(project_id, new_status); if changed { - cx.notify(); - let api_statuses: HashMap = this - .statuses - .iter() - .filter_map(|(id, status)| status.as_ref().map(|s| (id.clone(), to_api(s)))) - .collect(); - this.remote_tx.send_modify(|current| { - *current = api_statuses; - }); + this.publish_statuses(cx); } }); }) @@ -256,8 +362,8 @@ impl GitStatusWatcher { } else { CI_SETTLED_POLL_EVERY_N_CYCLES }; - let check_prs = cycle == 1 || (cycle != 0 && cycle.is_multiple_of(PR_POLL_EVERY_N_CYCLES)); - let check_ci = cycle == 1 || (cycle != 0 && cycle.is_multiple_of(ci_poll_interval)); + let pr_cadence = cycle == 1 || (cycle != 0 && cycle.is_multiple_of(PR_POLL_EVERY_N_CYCLES)); + let ci_cadence = cycle == 1 || (cycle != 0 && cycle.is_multiple_of(ci_poll_interval)); // Snapshot each project's known PR base branch (from the cached // PR info fetched on prior `check_prs` cycles). Passing it into @@ -296,7 +402,27 @@ impl GitStatusWatcher { // `gh` can never block the branch/diff badge from appearing — // and the poll loop keeps cycling. Inject whatever PR/CI we // already have cached so we don't blank those mid-cycle. - let should_continue = this.update(cx, |this, cx| { + let force_gh_ids = match this.update(cx, |this, cx| { + let branch_changes: HashSet = new_statuses + .iter() + .filter_map(|(id, status)| { + let prev = this.statuses.get(id).and_then(|status| status.as_ref())?; + let status = status.as_ref()?; + (prev.branch != status.branch).then(|| id.clone()) + }) + .collect(); + if !branch_changes.is_empty() { + for id in &branch_changes { + this.pr_infos.remove(id); + this.ci_checks.remove(id); + if let Some(Some(status)) = new_statuses.get_mut(id) { + status.pr_info = None; + status.ci_checks = None; + } + } + this.any_pending_ci = this.ci_checks.values() + .any(|c| c.as_ref().map(|s| s.status.is_pending()).unwrap_or(false)); + } for (id, status) in new_statuses.iter_mut() { if let Some(s) = status.as_mut() { s.pr_info = this.pr_infos.get(id).cloned().flatten(); @@ -304,17 +430,19 @@ impl GitStatusWatcher { } } this.commit_statuses(&new_statuses, cx); - true - }).unwrap_or(false); - if !should_continue { - break; - } + branch_changes + }) { + Ok(ids) => ids, + Err(_) => break, + }; + let check_prs = pr_cadence || !force_gh_ids.is_empty(); + let check_ci = ci_cadence || !force_gh_ids.is_empty(); // Phase 2: Fetch PR info in parallel (slower, network calls) — only on PR poll cycles. // Runs after all statuses are updated so git status isn't delayed by PR checks. let new_pr_infos: HashMap> = if check_prs { let pr_futures: Vec<_> = projects.iter() - .filter(|(id, _)| gh_ids.contains(id)) + .filter(|(id, _)| gh_ids.contains(id) && (pr_cadence || force_gh_ids.contains(id))) .map(|(id, path)| { let id = id.clone(); let path = path.clone(); @@ -348,7 +476,7 @@ impl GitStatusWatcher { this.update(cx, |this, _| this.pr_infos.clone()).unwrap_or_default() }; let ci_futures: Vec<_> = projects.iter() - .filter(|(id, _)| gh_ids.contains(id)) + .filter(|(id, _)| gh_ids.contains(id) && (ci_cadence || force_gh_ids.contains(id))) .map(|(id, path)| { let id = id.clone(); let path = path.clone(); diff --git a/crates/okena-views-git/src/worktree_dialog.rs b/crates/okena-views-git/src/worktree_dialog.rs index 142cc835b..fd5c07f5b 100644 --- a/crates/okena-views-git/src/worktree_dialog.rs +++ b/crates/okena-views-git/src/worktree_dialog.rs @@ -4,7 +4,6 @@ //! The `Render` impl lives in `worktree_dialog/view.rs`. use okena_git as git; -use okena_git::repository::{compute_target_paths, normalize_path}; use okena_core::process::command; use okena_workspace::settings::{HooksConfig, WorktreeConfig}; use okena_workspace::state::{WindowId, Workspace}; @@ -13,7 +12,7 @@ use crate::simple_input::SimpleInputState; use gpui::prelude::*; use gpui::*; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; mod view; @@ -29,26 +28,31 @@ pub(super) struct PrInfo { pub enum WorktreeDialogEvent { /// Dialog closed without creating a worktree (cancelled) Close, - /// Worktree was successfully created, contains the new project ID - Created(String), + /// User confirmed creation. The daemon owns worktree creation, so the host + /// dispatches `ActionRequest::CreateWorktree { project_id, branch, + /// create_branch }`; the new worktree project (and its terminals) mirror + /// back. `project_id` is the parent project the worktree is created from. + RequestCreate { + project_id: String, + branch: String, + create_branch: bool, + }, } impl EventEmitter for WorktreeDialog {} -/// Dialog for creating a new worktree from a project +/// Dialog for creating a new worktree from a project. +/// +/// The dialog only collects user intent (which branch / PR, or a new branch +/// name). On confirm it emits `WorktreeDialogEvent::RequestCreate`; the host +/// dispatches `ActionRequest::CreateWorktree` to the daemon, which owns the +/// actual worktree creation (path computation, fetch, `git worktree add`, +/// project registration, terminals and hooks). The new worktree project then +/// mirrors back. Hence the dialog holds no `workspace`, git-root, path-template +/// or hooks state — only branch selection. pub struct WorktreeDialog { - pub(super) workspace: Entity, - /// Spawning window for the multi-window new-project visibility rule - /// (PRD user story 14): the new worktree is visible in this window - /// only, hidden in every other window. Threaded from the originating - /// `WindowView` through `OverlayManager::show_worktree_dialog`. - pub(super) window_id: WindowId, pub(super) project_id: String, pub(super) project_path: String, - /// The git repository root (may differ from project_path in monorepos) - pub(super) git_root: PathBuf, - /// Relative path from git root to project (empty if project is at repo root) - pub(super) subdir: PathBuf, pub(super) branches: Vec, pub(super) filtered_branches: Vec, pub(super) selected_branch_index: Option, @@ -63,8 +67,6 @@ pub struct WorktreeDialog { pub(super) pr_error: Option, pub(super) selected_pr_branch: Option, pub(super) prs_loaded_once: bool, - pub(super) path_template: String, - pub(super) hooks_config: HooksConfig, } impl WorktreeDialog { @@ -72,26 +74,21 @@ impl WorktreeDialog { workspace: Entity, project_id: String, project_path: String, - worktree_config: WorktreeConfig, - hooks_config: HooksConfig, - window_id: WindowId, + _worktree_config: WorktreeConfig, + _hooks_config: HooksConfig, + _window_id: WindowId, cx: &mut Context, ) -> Self { - // Determine git repo root: if parent is already a worktree, use its - // stored main_repo_path; otherwise detect via `git rev-parse --show-toplevel`. + // Determine git repo root for branch listing / name suggestion: if the + // parent is already a worktree use its stored main_repo_path; otherwise + // detect via `git rev-parse --show-toplevel`. (The daemon recomputes + // paths on its side at create time; this is only for the picker.) let project_pathbuf = PathBuf::from(&project_path); let parent_main_repo = workspace.read(cx).worktree_parent_path(&project_id) .map(PathBuf::from); let git_root = parent_main_repo .or_else(|| git::get_repo_root(&project_pathbuf)) .unwrap_or_else(|| project_pathbuf.clone()); - // Normalize both paths before strip_prefix to handle relative paths, - // symlinks, or platform-specific path representations - let normalized_project = normalize_path(&project_pathbuf); - let normalized_root = normalize_path(&git_root); - let subdir = normalized_project.strip_prefix(&normalized_root) - .unwrap_or(Path::new("")) - .to_path_buf(); // Get available branches using the git root let branches = git::get_available_branches_for_worktree(&git_root); @@ -109,15 +106,10 @@ impl WorktreeDialog { let filtered_branches: Vec = (0..branches.len()).collect(); let focus_handle = cx.focus_handle(); - let path_template = worktree_config.path_template; Self { - workspace, - window_id, project_id, project_path, - git_root, - subdir, branches, filtered_branches, selected_branch_index: None, @@ -132,8 +124,6 @@ impl WorktreeDialog { pr_error: None, selected_pr_branch: None, prs_loaded_once: false, - path_template, - hooks_config, } } @@ -164,14 +154,6 @@ impl WorktreeDialog { cx.emit(WorktreeDialogEvent::Close); } - /// Returns (worktree_path, project_path). - /// `worktree_path` is where `git worktree add` creates the checkout (at the repo root level). - /// `project_path` is the subdirectory within that worktree where the project lives - /// (same as worktree_path when project is at repo root). - fn get_target_paths(&self, branch: &str) -> (String, String) { - compute_target_paths(&self.git_root, &self.subdir, &self.path_template, branch) - } - pub(super) fn create_worktree(&mut self, cx: &mut Context) { let (branch, create_branch) = if self.pr_mode { // PR mode: use selected PR branch @@ -213,26 +195,17 @@ impl WorktreeDialog { } }; - let (worktree_path, project_path) = self.get_target_paths(&branch); + // The daemon owns worktree creation. Emit a request so the host + // dispatches `ActionRequest::CreateWorktree`; the new worktree project + // and its terminals mirror back. The GUI no longer mutates the + // read-only mirror or computes the worktree path itself (the daemon + // does, from its settings). let project_id = self.project_id.clone(); - let git_root = self.git_root.clone(); - let hooks_config = self.hooks_config.clone(); - - // Create the worktree project - let window_id = self.window_id; - let result = self.workspace.update(cx, |ws, cx| { - ws.create_worktree_project(&project_id, &branch, &git_root, &worktree_path, &project_path, create_branch, &hooks_config, window_id, cx) + cx.emit(WorktreeDialogEvent::RequestCreate { + project_id, + branch, + create_branch, }); - - match result { - Ok(new_project_id) => { - cx.emit(WorktreeDialogEvent::Created(new_project_id)); - } - Err(e) => { - self.error_message = Some(e); - cx.notify(); - } - } } pub(super) fn load_prs(&mut self, cx: &mut Context) { diff --git a/crates/okena-views-remote/src/remote_connect_dialog.rs b/crates/okena-views-remote/src/remote_connect_dialog.rs index 9bb47c1b4..a131cdcd3 100644 --- a/crates/okena-views-remote/src/remote_connect_dialog.rs +++ b/crates/okena-views-remote/src/remote_connect_dialog.rs @@ -228,6 +228,7 @@ impl RemoteConnectDialog { token_obtained_at: None, tls: false, // set by auto-detection below pinned_cert_sha256: None, + local_endpoint: None, }; let runtime = self.runtime(cx); diff --git a/crates/okena-views-services/src/service_panel.rs b/crates/okena-views-services/src/service_panel.rs index 36c7dc3da..9c9e3b229 100644 --- a/crates/okena-views-services/src/service_panel.rs +++ b/crates/okena-views-services/src/service_panel.rs @@ -542,6 +542,10 @@ impl ServicePanel { let remote_host = self.workspace.read(cx).remote_snapshot(&self.project_id) .and_then(|snap| snap.host.clone()); + // Port links must target the daemon's host, not the client's localhost + // (the service runs on the daemon machine). Falls back to localhost for + // a loopback/local daemon that reports no host. + let port_host = remote_host.clone().unwrap_or_else(|| "localhost".to_string()); let project_id = self.project_id.clone(); let entity = cx.entity().downgrade(); @@ -604,8 +608,8 @@ impl ServicePanel { } }, // on_port_click - |port: u16| { - let url = format!("http://localhost:{}", port); + move |port: u16| { + let url = format!("http://{}:{}", port_host, port); open_url(&url); }, ) diff --git a/crates/okena-views-sidebar/Cargo.toml b/crates/okena-views-sidebar/Cargo.toml index 9480aa123..1435492a1 100644 --- a/crates/okena-views-sidebar/Cargo.toml +++ b/crates/okena-views-sidebar/Cargo.toml @@ -20,3 +20,4 @@ gpui-component = { git = "https://github.com/longbridge/gpui-component", package smol = "2.0" log = "0.4" +serde_json = "1.0" diff --git a/crates/okena-views-sidebar/src/color_picker.rs b/crates/okena-views-sidebar/src/color_picker.rs index 0dd1396ec..8530025bd 100644 --- a/crates/okena-views-sidebar/src/color_picker.rs +++ b/crates/okena-views-sidebar/src/color_picker.rs @@ -25,6 +25,10 @@ pub enum ColorPickerPopoverEvent { Close, /// Color was set on a project — sidebar should handle remote sync. ProjectColorChanged { project_id: String, color: FolderColor }, + /// A worktree project's color override was reset to its parent. + WorktreeColorReset { project_id: String }, + /// Color was set on a folder. + FolderColorChanged { folder_id: String, color: FolderColor }, } impl CloseEvent for ColorPickerPopoverEvent { @@ -131,9 +135,6 @@ impl Render for ColorPickerPopover { .child({ let pid = project_id_owned.clone(); color_swatch_grid("color", current_color, &t, cx, move |this, color, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.set_folder_color(&pid, color, cx); - }); cx.emit(ColorPickerPopoverEvent::ProjectColorChanged { project_id: pid.clone(), color, @@ -164,8 +165,8 @@ impl Render for ColorPickerPopover { .hover(|s| s.text_color(rgb(t.text_primary)).bg(rgb(t.bg_hover))) .child("Reset to parent") .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.set_worktree_color_override(&project_id_clone, None, cx); + cx.emit(ColorPickerPopoverEvent::WorktreeColorReset { + project_id: project_id_clone.clone(), }); this.close(cx); })) @@ -186,8 +187,9 @@ impl Render for ColorPickerPopover { .child({ let fid = folder_id_owned.clone(); color_swatch_grid("folder-color", current_color, &t, cx, move |this, color, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.set_folder_item_color(&fid, color, cx); + cx.emit(ColorPickerPopoverEvent::FolderColorChanged { + folder_id: fid.clone(), + color, }); this.close(cx); }) diff --git a/crates/okena-views-sidebar/src/context_menu.rs b/crates/okena-views-sidebar/src/context_menu.rs index 3e8413d9d..d676d00e4 100644 --- a/crates/okena-views-sidebar/src/context_menu.rs +++ b/crates/okena-views-sidebar/src/context_menu.rs @@ -1,7 +1,6 @@ //! Project context menu overlay. use crate::Cancel; -use okena_git; use okena_ui::menu::{context_menu_panel, menu_item, menu_item_with_color, menu_separator}; use okena_ui::theme::theme; use okena_workspace::requests::ContextMenuRequest; @@ -75,6 +74,7 @@ pub enum ContextMenuEvent { ShowDiff { project_id: String }, FocusProject { project_id: String }, HideProject { project_id: String }, + ToggleProjectPinned { project_id: String }, } impl okena_ui::overlay::CloseEvent for ContextMenuEvent { @@ -153,14 +153,14 @@ impl ContextMenu { }); } - /// Toggle the project's pinned state directly on the workspace, then close. - /// Self-contained (no event round-trip) since pinning is a single persisted - /// bool flip — see [`okena_workspace::state::Workspace::toggle_project_pinned`]. + /// Toggle the project's pinned state. The daemon owns the authoritative + /// `ProjectData.pinned` flag, so emit an event that routes up to + /// `WindowView`, which dispatches `ActionRequest::ToggleProjectPinned`; the + /// new pinned state mirrors back. The GUI must not mutate its read-only + /// mirror directly. fn toggle_pinned(&self, cx: &mut Context) { let project_id = self.request.project_id.clone(); - self.workspace.update(cx, |ws, cx| { - ws.toggle_project_pinned(&project_id, cx); - }); + cx.emit(ContextMenuEvent::ToggleProjectPinned { project_id }); self.close(cx); } @@ -243,7 +243,10 @@ impl Render for ContextMenu { let project_path = project.map(|p| p.path.clone()).unwrap_or_default(); let is_worktree = project.map(|p| p.worktree_info.is_some()).unwrap_or(false); let is_pinned = project.map(|p| p.pinned).unwrap_or(false); - let is_git_repo = okena_git::is_git_repo(std::path::Path::new(&project_path)); + let is_git_repo = ws + .remote_snapshot(&self.request.project_id) + .and_then(|s| s.git_status.as_ref()) + .is_some(); let project_path_for_worktree = project_path.clone(); let project_path_for_rename_dir = project_path.clone(); let project_name_for_rename = project_name.clone(); diff --git a/crates/okena-views-sidebar/src/folder_list.rs b/crates/okena-views-sidebar/src/folder_list.rs index 6222d7505..d2a77ca7b 100644 --- a/crates/okena-views-sidebar/src/folder_list.rs +++ b/crates/okena-views-sidebar/src/folder_list.rs @@ -15,27 +15,6 @@ use crate::drag::{ProjectDrag, ProjectDragView, FolderDrag, FolderDragView}; use okena_workspace::state::FolderData; impl Sidebar { - /// Send a reorder action to the remote server when a project is reordered - /// within a remote folder on the client. - fn send_remote_reorder(this: &mut Self, conn_id: &str, prefixed_project_id: &str, new_index: usize, cx: &mut App) { - let server_project_id = okena_transport::client::strip_prefix(prefixed_project_id, conn_id); - - // Look up the server's folder structure from the cached state - let server_folder_id = if let Some(ref get_folder) = this.get_remote_folder { - (get_folder)(conn_id, prefixed_project_id, cx) - } else { - None - }; - - if let Some(folder_id) = server_folder_id - && let Some(ref send_action) = this.send_remote_action { - (send_action)(conn_id, okena_core::api::ActionRequest::ReorderProjectInFolder { - folder_id, - project_id: server_project_id, - new_index, - }, cx); - } - } /// Renders only the folder header row (expand arrow, icon, name, badges) // GPUI render helper: params are render inputs (indent, indices, state flags). @@ -87,9 +66,10 @@ impl Sidebar { let folder_id = folder_id.clone(); move |this, drag: &FolderDrag, _window, cx| { if drag.folder_id != folder_id { - this.workspace.update(cx, |ws, cx| { - ws.move_item_in_order(&drag.folder_id, index, cx); - }); + this.dispatch_action_for_folder(&drag.folder_id, okena_core::api::ActionRequest::MoveItemInOrder { + item_id: drag.folder_id.clone(), + new_index: index, + }, cx); } } })) @@ -100,9 +80,11 @@ impl Sidebar { .on_drop(cx.listener({ let folder_id = folder_id.clone(); move |this, drag: &ProjectDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_project_to_folder(&drag.project_id, &folder_id, None, cx); - }); + this.dispatch_action_for_project(&drag.project_id, okena_core::api::ActionRequest::MoveProjectToFolder { + project_id: drag.project_id.clone(), + folder_id: folder_id.clone(), + position: None, + }, cx); } })) // Right-click context menu @@ -226,9 +208,9 @@ impl Sidebar { let folder_id = folder_id.clone(); move |this, _, _window, cx| { cx.stop_propagation(); - this.workspace.update(cx, |ws, cx| { - ws.delete_folder(&folder_id, cx); - }); + this.dispatch_action_for_folder(&folder_id, okena_core::api::ActionRequest::DeleteFolder { + folder_id: folder_id.clone(), + }, cx); } })) .tooltip(|_window, cx| Tooltip::new("Delete folder (keeps projects)").build(_window, cx)) @@ -294,17 +276,15 @@ impl Sidebar { let pos = this.workspace.read(cx).folder(&folder_id) .and_then(|f| f.project_ids.iter().position(|id| id == &project_id)); if let Some(pos) = pos { - this.workspace.update(cx, |ws, cx| { - ws.move_project_to_folder(&drag.project_id, &folder_id, Some(pos), cx); - }); - // Send reorder to server for remote folders - if folder_id.starts_with("remote:") { - // Folder ID is "remote:{conn_id}:{folder_id}" — extract conn_id - if let Some(rest) = folder_id.strip_prefix("remote:") - && let Some(conn_id) = rest.split(':').next() { - Self::send_remote_reorder(this, conn_id, &drag.project_id, pos, cx); - } - } + // Daemon-owned: a single MoveProjectToFolder carries the + // reorder; the daemon persists + mirrors it back. (Was a + // mirror write plus a separate ReorderProjectInFolder + // side-send — both now collapsed into one dispatch.) + this.dispatch_action_for_project(&drag.project_id, okena_core::api::ActionRequest::MoveProjectToFolder { + project_id: drag.project_id.clone(), + folder_id: folder_id.clone(), + position: Some(pos), + }, cx); } } } @@ -314,9 +294,10 @@ impl Sidebar { style.border_t_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &FolderDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_item_in_order(&drag.folder_id, 0, cx); - }); + this.dispatch_action_for_folder(&drag.folder_id, okena_core::api::ActionRequest::MoveItemInOrder { + item_id: drag.folder_id.clone(), + new_index: 0, + }, cx); })) .on_mouse_down(MouseButton::Right, cx.listener({ let project_id = project_id.clone(); diff --git a/crates/okena-views-sidebar/src/hook_list.rs b/crates/okena-views-sidebar/src/hook_list.rs index 10412817b..d03e32b23 100644 --- a/crates/okena-views-sidebar/src/hook_list.rs +++ b/crates/okena-views-sidebar/src/hook_list.rs @@ -1,5 +1,6 @@ //! Hook terminal list rendering for the sidebar +use okena_core::api::ActionRequest; use okena_ui::theme::theme; use okena_ui::tokens::ui_text_md; use okena_workspace::state::HookTerminalStatus; @@ -128,8 +129,6 @@ impl Sidebar { .when(!is_running, { let terminal_id = terminal_id.clone(); let project_id = project_id.clone(); - let command = hook.command.clone(); - let cwd = hook.cwd.clone(); |el| el.child( icon_button( ElementId::Name(format!("hook-rerun-{}", terminal_id).into()), @@ -139,13 +138,7 @@ impl Sidebar { .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(cx.listener(move |this, _, _window, cx| { cx.stop_propagation(); - this.rerun_hook_terminal( - &project_id, - &terminal_id, - &command, - &cwd, - cx, - ); + this.rerun_hook_terminal(&project_id, &terminal_id, cx); })), ) }) @@ -176,56 +169,18 @@ impl Sidebar { ) } - /// Rerun a hook by killing the old PTY and creating a new one with the same command. - pub fn rerun_hook_terminal( - &self, - project_id: &str, - terminal_id: &str, - command: &str, - cwd: &str, - cx: &mut Context, - ) { - let Some(runner) = cx.try_global::().cloned() else { - log::warn!("Cannot rerun hook: no HookRunner available"); - return; - }; - - // Kill old PTY - runner.backend.kill(terminal_id); - - // Create new PTY with a live shell, then type the command into it - match runner.backend.create_terminal(cwd, None) { - Ok(new_terminal_id) => { - let transport = runner.backend.transport(); - let terminal = std::sync::Arc::new(okena_terminal::terminal::Terminal::new( - new_terminal_id.clone(), - okena_terminal::terminal::TerminalSize::default(), - transport.clone(), - cwd.to_string(), - )); - - // Replace in TerminalsRegistry: remove old, insert new (single lock) - let terminals = self.terminals.clone(); - { - let mut guard = terminals.lock(); - guard.remove(terminal_id); - guard.insert(new_terminal_id.clone(), terminal); - } - - // Update workspace: swap terminal ID in hook_terminals and layout - self.workspace.update(cx, |ws, cx| { - ws.swap_hook_terminal_id(project_id, terminal_id, &new_terminal_id, cx); - }); - - // Type the command into the new shell - let cmd_with_newline = format!("{}\n", command); - transport.send_input(&new_terminal_id, cmd_with_newline.as_bytes()); - - log::info!("Hook rerun: replaced {} with {}", terminal_id, new_terminal_id); - } - Err(e) => { - log::error!("Failed to rerun hook terminal: {}", e); - } - } + /// Rerun a hook terminal: dispatch to the daemon, which kills the old PTY, + /// spawns a fresh shell at the hook's cwd, and re-types the stored command. + /// The daemon owns the hook terminal's command + cwd, so the action carries + /// only the ids. + pub fn rerun_hook_terminal(&self, project_id: &str, terminal_id: &str, cx: &mut Context) { + self.dispatch_action_for_project( + project_id, + ActionRequest::RerunHook { + project_id: project_id.to_string(), + terminal_id: terminal_id.to_string(), + }, + cx, + ); } } diff --git a/crates/okena-views-sidebar/src/project_list.rs b/crates/okena-views-sidebar/src/project_list.rs index 43477374e..463a20827 100644 --- a/crates/okena-views-sidebar/src/project_list.rs +++ b/crates/okena-views-sidebar/src/project_list.rs @@ -250,9 +250,10 @@ impl Sidebar { let project_id = project_id.clone(); move |this, drag: &ProjectDrag, _window, cx| { if drag.project_id != project_id { - this.workspace.update(cx, |ws, cx| { - ws.move_project(&drag.project_id, index, cx); - }); + this.dispatch_action_for_project(&drag.project_id, ActionRequest::MoveProject { + project_id: drag.project_id.clone(), + new_index: index, + }, cx); } } })) @@ -260,9 +261,10 @@ impl Sidebar { style.border_t_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &FolderDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_item_in_order(&drag.folder_id, index, cx); - }); + this.dispatch_action_for_folder(&drag.folder_id, ActionRequest::MoveItemInOrder { + item_id: drag.folder_id.clone(), + new_index: index, + }, cx); })) .on_mouse_down(MouseButton::Right, cx.listener({ let project_id = project_id.clone(); @@ -332,9 +334,11 @@ impl Sidebar { let parent_id = parent_id.clone(); move |this, drag: &WorktreeDrag, _window, cx| { if drag.worktree_id != project_id && drag.parent_id == parent_id { - this.workspace.update(cx, |ws, cx| { - ws.reorder_worktree(&parent_id, &drag.worktree_id, worktree_index, cx); - }); + this.dispatch_action_for_project(&parent_id, ActionRequest::ReorderWorktree { + parent_id: parent_id.clone(), + worktree_id: drag.worktree_id.clone(), + new_index: worktree_index, + }, cx); } } })) @@ -666,7 +670,7 @@ impl Sidebar { let project_id = project_id.clone(); move |this, drag: &ProjectDrag, _window, cx| { if drag.project_id != project_id { - this.workspace.update(cx, |ws, cx| { ws.move_project(&drag.project_id, index, cx); }); + this.dispatch_action_for_project(&drag.project_id, ActionRequest::MoveProject { project_id: drag.project_id.clone(), new_index: index }, cx); } } })) @@ -674,7 +678,7 @@ impl Sidebar { style.border_t_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &FolderDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { ws.move_item_in_order(&drag.folder_id, index, cx); }); + this.dispatch_action_for_folder(&drag.folder_id, ActionRequest::MoveItemInOrder { item_id: drag.folder_id.clone(), new_index: index }, cx); })) } GroupHeaderDragConfig::InFolder { folder_id } => { @@ -687,7 +691,7 @@ impl Sidebar { let pos = this.workspace.read(cx).folder(&folder_id) .and_then(|f| f.project_ids.iter().position(|id| id == &project_id)); if let Some(pos) = pos { - this.workspace.update(cx, |ws, cx| { ws.move_project_to_folder(&drag.project_id, &folder_id, Some(pos), cx); }); + this.dispatch_action_for_project(&drag.project_id, ActionRequest::MoveProjectToFolder { project_id: drag.project_id.clone(), folder_id: folder_id.clone(), position: Some(pos) }, cx); } } } diff --git a/crates/okena-views-sidebar/src/remote_list.rs b/crates/okena-views-sidebar/src/remote_list.rs index db183c15b..06d001f22 100644 --- a/crates/okena-views-sidebar/src/remote_list.rs +++ b/crates/okena-views-sidebar/src/remote_list.rs @@ -1,4 +1,4 @@ -use okena_transport::client::{ConnectionStatus, RemoteConnectionConfig}; +use okena_transport::client::{ConnectionStatus, RemoteConnectionConfig, LOCAL_DAEMON_CONNECTION_ID}; use okena_ui::theme::theme; use okena_ui::tokens::{ui_text_ms, ui_text_md, ui_text_sm, ui_text_xl}; use okena_workspace::requests::OverlayRequest; @@ -23,12 +23,18 @@ impl Sidebar { return div().into_any_element(); } - // Snapshot connection data via callback + // Snapshot connection data via callback. The implicit loopback + // connection to the local daemon (`--daemon-client` mode) is not a + // user-managed remote — its projects already render as ordinary + // workspace projects — so it is hidden from this REMOTE management + // section. Real remote daemons still appear here. let snapshots: Vec = if let Some(ref get_connections) = self.get_remote_connections { - (get_connections)(cx).into_iter().map(|s| ConnectionSnapshot { - config: s.config, - status: s.status, - }).collect() + (get_connections)(cx).into_iter() + .filter(|s| s.config.id != LOCAL_DAEMON_CONNECTION_ID) + .map(|s| ConnectionSnapshot { + config: s.config, + status: s.status, + }).collect() } else { Vec::new() }; diff --git a/crates/okena-views-sidebar/src/rename_directory_dialog.rs b/crates/okena-views-sidebar/src/rename_directory_dialog.rs index c9b519352..9e12ee1fc 100644 --- a/crates/okena-views-sidebar/src/rename_directory_dialog.rs +++ b/crates/okena-views-sidebar/src/rename_directory_dialog.rs @@ -7,7 +7,6 @@ use okena_ui::modal::{modal_backdrop, modal_content}; use okena_ui::simple_input::{SimpleInput, SimpleInputState}; use okena_ui::theme::theme; use okena_ui::tokens::{ui_text_ms, ui_text_md, ui_text_xl, ui_text}; -use okena_workspace::state::Workspace; use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; @@ -18,19 +17,19 @@ use std::path::Path; pub enum RenameDirectoryDialogEvent { /// Dialog closed (cancelled or renamed) Close, - /// Directory was successfully renamed - Renamed, + /// Rename was confirmed: the daemon performs the rename, updates the + /// record, and mirrors the new path+name back. + Confirmed { project_id: String, new_name: String }, } impl okena_ui::overlay::CloseEvent for RenameDirectoryDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close | Self::Renamed) } + fn is_close(&self) -> bool { matches!(self, Self::Close | Self::Confirmed { .. }) } } impl EventEmitter for RenameDirectoryDialog {} /// Dialog for renaming a project's directory on disk. pub struct RenameDirectoryDialog { - workspace: Entity, project_id: String, project_path: String, name_input: Entity, @@ -41,7 +40,6 @@ pub struct RenameDirectoryDialog { impl RenameDirectoryDialog { pub fn new( - workspace: Entity, project_id: String, project_path: String, cx: &mut Context, @@ -60,7 +58,6 @@ impl RenameDirectoryDialog { }); Self { - workspace, project_id, project_path, name_input, @@ -89,8 +86,7 @@ impl RenameDirectoryDialog { return; } - let old_path = Path::new(&self.project_path); - let current_name = old_path + let current_name = Path::new(&self.project_path) .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); @@ -101,35 +97,10 @@ impl RenameDirectoryDialog { return; } - let new_path = match old_path.parent() { - Some(parent) => parent.join(&new_name), - None => { - self.error_message = Some("Cannot determine parent directory".to_string()); - cx.notify(); - return; - } - }; - - if new_path.exists() { - self.error_message = Some(format!("'{}' already exists", new_name)); - cx.notify(); - return; - } - - if let Err(e) = std::fs::rename(&self.project_path, &new_path) { - self.error_message = Some(format!("Failed to rename: {}", e)); - cx.notify(); - return; - } - - let new_path_str = new_path.to_string_lossy().to_string(); - let project_id = self.project_id.clone(); - let new_name_clone = new_name.clone(); - self.workspace.update(cx, |ws, cx| { - ws.rename_project_directory(&project_id, new_path_str, new_name_clone, cx); + cx.emit(RenameDirectoryDialogEvent::Confirmed { + project_id: self.project_id.clone(), + new_name, }); - - cx.emit(RenameDirectoryDialogEvent::Renamed); } } diff --git a/crates/okena-views-sidebar/src/sidebar/mod.rs b/crates/okena-views-sidebar/src/sidebar/mod.rs index b7b1e375c..b5cbfcb32 100644 --- a/crates/okena-views-sidebar/src/sidebar/mod.rs +++ b/crates/okena-views-sidebar/src/sidebar/mod.rs @@ -59,6 +59,12 @@ pub type GetRemoteConnectionsFn = Box Vec; +/// Callback to dispatch an action to a connection by id, with id-stripping (the +/// connection-targeted analog of [`DispatchActionFn`], for folder-scoped and +/// workspace-global actions that carry no project to resolve a connection from). +/// Arguments: (conn_id, action, cx) +pub type DispatchForConnectionFn = Box; + /// Callback to get the server folder ID for a remote folder reorder operation. /// Arguments: (conn_id, prefixed_project_id, cx) -> Option pub type GetRemoteFolderFn = Box Option>; @@ -152,12 +158,13 @@ pub struct Sidebar { pub(crate) collapsed_groups: HashSet<(String, GroupKind)>, /// Parent project IDs with in-flight worktree creation (debounce guard) pub(crate) creating_worktree: HashSet, - /// Callback to get settings - pub(crate) get_settings: Option, /// Callback to get remote connections pub(crate) get_remote_connections: Option, /// Callback to send remote actions pub(crate) send_remote_action: Option, + /// Callback to dispatch a folder-scoped / workspace-global action to a + /// connection by id (with id-stripping). See [`DispatchForConnectionFn`]. + pub(crate) dispatch_for_connection: Option, /// Callback to get remote folder ID for reordering pub(crate) get_remote_folder: Option, /// Last computed activity-view ordering `(tier, project_id)`. Reused while @@ -226,9 +233,9 @@ impl Sidebar { service_manager: None, collapsed_groups: HashSet::new(), creating_worktree: HashSet::new(), - get_settings: None, get_remote_connections: None, send_remote_action: None, + dispatch_for_connection: None, get_remote_folder: None, activity_order_cache: Vec::new(), activity_pointer_inside: false, @@ -258,11 +265,6 @@ impl Sidebar { self.dispatch_action = Some(f); } - /// Set the settings callback. - pub fn set_settings(&mut self, f: GetSettingsFn) { - self.get_settings = Some(f); - } - /// Set the remote connections callback. pub fn set_remote_connections(&mut self, f: GetRemoteConnectionsFn) { self.get_remote_connections = Some(f); @@ -273,6 +275,11 @@ impl Sidebar { self.send_remote_action = Some(f); } + /// Set the connection-targeted dispatch callback. + pub fn set_dispatch_for_connection(&mut self, f: DispatchForConnectionFn) { + self.dispatch_for_connection = Some(f); + } + /// Set the get remote folder callback. pub fn set_get_remote_folder(&mut self, f: GetRemoteFolderFn) { self.get_remote_folder = Some(f); @@ -285,9 +292,27 @@ impl Sidebar { } } - /// Get the current sidebar settings. - pub(crate) fn sidebar_settings(&self, cx: &App) -> SidebarSettings { - self.get_settings.as_ref().map(|f| (f)(cx)).unwrap_or_default() + /// Dispatch a folder-scoped action to the connection that owns the folder. + /// Folder ids are `remote::`; the dispatcher strips the prefix + /// against the resolved connection. Falls back to the local daemon for an + /// unprefixed id. + pub(crate) fn dispatch_action_for_folder(&self, folder_id: &str, action: ActionRequest, cx: &mut App) { + let conn_id = folder_id + .strip_prefix("remote:") + .and_then(|rest| rest.split(':').next()) + .unwrap_or(okena_transport::client::LOCAL_DAEMON_CONNECTION_ID); + if let Some(ref dispatch) = self.dispatch_for_connection { + (dispatch)(conn_id, action, cx); + } + } + + /// Dispatch a workspace-global action (e.g. creating a folder) to the local + /// daemon, which owns the authoritative workspace; the result mirrors back + /// on the next snapshot. + pub(crate) fn dispatch_daemon_action(&self, action: ActionRequest, cx: &mut App) { + if let Some(ref dispatch) = self.dispatch_for_connection { + (dispatch)(okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, action, cx); + } } /// Check for double-click on terminal and return true if detected @@ -649,9 +674,14 @@ impl SidebarProjectInfo { pub(crate) fn from_project(project: &ProjectData, workspace: &Workspace, window_id: WindowId) -> Self { let layout = project.layout.as_ref(); // For worktree projects, show the git branch instead of the stored name. + // The branch comes from the daemon's git poll over the wire + // (`remote_snapshot().git_status.branch`) — a local `get_git_status` would + // return None in client mode since the daemon owns the working tree. let name = if project.worktree_info.is_some() { - okena_git::get_git_status(std::path::Path::new(&project.path)) - .and_then(|s| s.branch) + workspace + .remote_snapshot(&project.id) + .and_then(|s| s.git_status.as_ref()) + .and_then(|g| g.branch.clone()) .unwrap_or_else(|| project.name.clone()) } else { project.name.clone() diff --git a/crates/okena-views-sidebar/src/sidebar/renames.rs b/crates/okena-views-sidebar/src/sidebar/renames.rs index 8f58bfa6f..465954357 100644 --- a/crates/okena-views-sidebar/src/sidebar/renames.rs +++ b/crates/okena-views-sidebar/src/sidebar/renames.rs @@ -70,9 +70,12 @@ impl Sidebar { pub fn finish_project_rename(&mut self, cx: &mut Context) { if let Some((project_id, new_name)) = finish_rename(&mut self.project_rename, cx) { - self.workspace.update(cx, |ws, cx| { - ws.rename_project(&project_id, new_name, cx); - }); + // Daemon-owned: dispatch, don't mutate the mirror (which the next + // state sync would overwrite). The rename mirrors back on sync. + self.dispatch_action_for_project(&project_id, ActionRequest::RenameProject { + project_id: project_id.clone(), + name: new_name, + }, cx); } let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { @@ -145,9 +148,11 @@ impl Sidebar { pub fn finish_folder_rename(&mut self, cx: &mut Context) { if let Some((folder_id, new_name)) = finish_rename(&mut self.folder_rename, cx) { - self.workspace.update(cx, |ws, cx| { - ws.rename_folder(&folder_id, new_name, cx); - }); + // Daemon-owned folder mutation — dispatch to the folder's connection. + self.dispatch_action_for_folder(&folder_id, ActionRequest::RenameFolder { + folder_id: folder_id.clone(), + name: new_name, + }, cx); } let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { @@ -167,11 +172,13 @@ impl Sidebar { cx.notify(); } - pub(super) fn create_folder(&mut self, window: &mut Window, cx: &mut Context) { - let folder_id = self.workspace.update(cx, |ws, cx| { - ws.create_folder("New Folder".to_string(), cx) - }); - // Immediately start renaming the new folder - self.start_folder_rename(folder_id, "New Folder".to_string(), window, cx); + pub(super) fn create_folder(&mut self, _window: &mut Window, cx: &mut Context) { + // The daemon owns folder creation and assigns the folder id, mirroring + // it back on the next snapshot. Because the id isn't known synchronously, + // we can't drop straight into rename like the in-process path did; the + // folder appears as "New Folder" and is renamed via its context menu. + self.dispatch_daemon_action(ActionRequest::CreateFolder { + name: "New Folder".to_string(), + }, cx); } } diff --git a/crates/okena-views-sidebar/src/sidebar/render.rs b/crates/okena-views-sidebar/src/sidebar/render.rs index 42cef9785..832e04fc0 100644 --- a/crates/okena-views-sidebar/src/sidebar/render.rs +++ b/crates/okena-views-sidebar/src/sidebar/render.rs @@ -821,17 +821,19 @@ impl Render for Sidebar { style.h(px(8.0)).border_b_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &ProjectDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_project(&drag.project_id, 0, cx); - }); + this.dispatch_action_for_project(&drag.project_id, okena_core::api::ActionRequest::MoveProject { + project_id: drag.project_id.clone(), + new_index: 0, + }, cx); })) .drag_over::(move |style, _, _, _| { style.h(px(8.0)).border_b_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &FolderDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_item_in_order(&drag.folder_id, 0, cx); - }); + this.dispatch_action_for_folder(&drag.folder_id, okena_core::api::ActionRequest::MoveItemInOrder { + item_id: drag.folder_id.clone(), + new_index: 0, + }, cx); })) .into_any_element() ); @@ -1030,17 +1032,19 @@ impl Render for Sidebar { style.border_t_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &ProjectDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_project(&drag.project_id, end_index, cx); - }); + this.dispatch_action_for_project(&drag.project_id, okena_core::api::ActionRequest::MoveProject { + project_id: drag.project_id.clone(), + new_index: end_index, + }, cx); })) .drag_over::(move |style, _, _, _| { style.border_t_2().border_color(rgb(t.border_active)) }) .on_drop(cx.listener(move |this, drag: &FolderDrag, _window, cx| { - this.workspace.update(cx, |ws, cx| { - ws.move_item_in_order(&drag.folder_id, end_index, cx); - }); + this.dispatch_action_for_folder(&drag.folder_id, okena_core::api::ActionRequest::MoveItemInOrder { + item_id: drag.folder_id.clone(), + new_index: end_index, + }, cx); })) .into_any_element() ); diff --git a/crates/okena-views-sidebar/src/sidebar/worktree.rs b/crates/okena-views-sidebar/src/sidebar/worktree.rs index 0c8c51f93..6453cf106 100644 --- a/crates/okena-views-sidebar/src/sidebar/worktree.rs +++ b/crates/okena-views-sidebar/src/sidebar/worktree.rs @@ -1,78 +1,77 @@ -//! Quick worktree creation — spawns a background task that generates a branch -//! name, registers the project optimistically, then runs `git fetch` + `git -//! worktree add` and fires configured hooks on success. +//! Quick worktree creation — the branch name is generated by the daemon (which +//! owns the git repo), then we dispatch `ActionRequest::CreateWorktree`. The +//! daemon owns the worktree creation (fetch + `git worktree add`), project +//! registration, terminal spawning and hooks; the new worktree project mirrors +//! back into the sidebar. The GUI never mutates its read-only mirror directly. use super::Sidebar; use gpui::*; +use okena_core::api::ActionRequest; impl Sidebar { - /// Spawn quick worktree creation on a background thread. - /// All blocking git operations (branch name generation, worktree creation) - /// run off the main thread to avoid UI jank. + /// Resolve the remote connection params for a project: + /// `(host, port, token, daemon-side project id)`. Returns `None` if the + /// project has no connection, no matching remote connection, or no saved + /// token. + fn resolve_remote_params( + &self, + project_id: &str, + cx: &App, + ) -> Option<(String, u16, String, Option, String)> { + let conn_id = self.workspace.read(cx).project(project_id)?.connection_id.clone()?; + let get = self.get_remote_connections.as_ref()?; + let config = get(cx).into_iter().find(|s| s.config.id == conn_id)?.config; + let token = config.saved_token?; + let daemon_id = okena_transport::client::strip_prefix(project_id, &conn_id); + Some((config.host, config.port, token, config.local_endpoint, daemon_id)) + } + + /// Spawn quick worktree creation. The branch name is generated on the daemon + /// via `ActionRequest::GenerateWorktreeBranchName` (the git repo lives there, + /// so local generation fails in headless mode). The actual worktree creation + /// is the daemon's job via `ActionRequest::CreateWorktree`. pub fn spawn_quick_create_worktree(&mut self, project_id: &str, cx: &mut Context) { // Debounce: prevent concurrent creation for the same parent if !self.creating_worktree.insert(project_id.to_string()) { return; } - let workspace = self.workspace.clone(); - let focus_manager = self.focus_manager.clone(); let parent_id = project_id.to_string(); let parent_id_for_cleanup = parent_id.clone(); - // Multi-window new-project visibility rule (PRD user story 14): the - // quick-created worktree lands visible in this sidebar's window only. - let window_id = self.window_id; - // Collect data from workspace and settings (non-blocking reads) - let prep = self.workspace.read(cx).prepare_quick_create(project_id); - let settings = self.sidebar_settings(cx); - let path_template = settings.worktree_path_template.clone(); - let hooks = settings.hooks.clone(); - let Some((parent_path, main_repo_path)) = prep else { - log::error!("Quick worktree creation failed: parent project not found"); + // Resolve remote connection params on the main thread before spawning. + // The daemon owns path resolution and branch-name generation. + let Some((host, port, token, local_endpoint, daemon_id)) = self.resolve_remote_params(project_id, cx) else { + log::error!("Quick worktree creation failed: could not resolve remote connection"); self.creating_worktree.remove(project_id); return; }; - // Store hooks for later use in async block - let hooks_for_register = hooks.clone(); - let hooks_for_fire = hooks.clone(); - let hooks_for_error = hooks.clone(); - cx.spawn(async move |sidebar_weak, cx| { - // Phase 1 (fast): resolve git root, generate branch name, compute - // paths — no network calls needed. - let prep_result = smol::unblock(move || -> Result<(String, std::path::PathBuf, String, String, Option), String> { - let project_path = std::path::PathBuf::from(&parent_path); - - // Determine git root - let git_root = main_repo_path - .map(std::path::PathBuf::from) - .or_else(|| okena_git::get_repo_root(&project_path)) - .ok_or_else(|| "Not a git repository".to_string())?; - - // Compute subdir (project path relative to git root) - let normalized_project = okena_git::repository::normalize_path(&project_path); - let normalized_root = okena_git::repository::normalize_path(&git_root); - let subdir = normalized_project.strip_prefix(&normalized_root) - .unwrap_or(std::path::Path::new("")) - .to_path_buf(); - - // Generate branch name (username cached, branch listing is local) - let branch = okena_git::branch_names::generate_branch_name(&git_root); - - // Fast local lookup for default branch (no network) - let default_branch = okena_git::repository::get_default_branch(&git_root); - - // Compute target paths - let (worktree_path, project_path) = okena_git::repository::compute_target_paths( - &git_root, &subdir, &path_template, &branch, - ); - - Ok((branch, git_root, worktree_path, project_path, default_branch)) - }).await; + // Ask the daemon to generate the branch name off the main thread. + // The client must know the branch before dispatching CreateWorktree + // (visibility is keyed on it). + let branch_result = smol::unblock(move || -> Result { + let action = ActionRequest::GenerateWorktreeBranchName { project_id: daemon_id }; + match okena_transport::remote_action::post_action_with_endpoint( + &host, + port, + &token, + local_endpoint.as_ref(), + action, + ) { + Ok(Some(v)) => v + .get("branch") + .and_then(|b| b.as_str()) + .map(String::from) + .ok_or_else(|| "missing branch in response".to_string()), + Ok(None) => Err("empty response".to_string()), + Err(e) => Err(e), + } + }) + .await; - let (branch, git_root, worktree_path, project_path, default_branch) = match prep_result { + let branch = match branch_result { Ok(v) => v, Err(e) => { log::error!("Quick worktree creation failed: {}", e); @@ -84,83 +83,19 @@ impl Sidebar { } }; - // Register project in sidebar immediately so it appears instantly. - // Hooks are deferred until the worktree directory exists on disk. - let project_id = cx.update(|cx| { - workspace.update(cx, |ws, cx| { - let id = ws.register_worktree_project_deferred_hooks( - &parent_id, &branch, &git_root, - &worktree_path, &project_path, &hooks_for_register, window_id, cx, - ); - if let Ok(ref id) = id { - ws.mark_creating_project(id); - } - id - }) - }); - - let Ok(project_id) = project_id else { - log::error!("Quick worktree creation failed: could not register project"); - let _ = sidebar_weak.update(cx, |sidebar, cx| { - sidebar.creating_worktree.remove(&parent_id_for_cleanup); - cx.notify(); - }); - return; - }; - - // Phase 2 (slow): fetch + git worktree add in background. - // The project is already visible in the sidebar. - let branch_clone = branch.clone(); - let worktree_path_clone = worktree_path.clone(); - let git_root_clone = git_root.clone(); - let create_result = smol::unblock(move || -> Result<(), String> { - let target = std::path::PathBuf::from(&worktree_path_clone); - - // Fetch and create worktree — fetch runs first if we have a default branch - if let Some(ref db) = default_branch - && let Some(repo_str) = git_root_clone.to_str() { - let _ = okena_core::process::safe_output( - okena_core::process::command("git") - .args(["-C", repo_str, "fetch", "origin", db.as_str()]), - ); - } - - okena_git::repository::create_worktree_with_start_point( - &git_root_clone, - &branch_clone, - &target, - default_branch.as_deref(), - ).map_err(|e| e.to_string()) - }).await; - - match create_result { - Ok(()) => { - // Worktree directory exists — clear creating state and fire hooks - cx.update(|cx| { - workspace.update(cx, |ws, cx| { - ws.finish_creating_project(&project_id); - ws.fire_worktree_hooks(&project_id, &hooks_for_fire, cx); - ws.notify_data(cx); - }); - }); - } - Err(e) => { - log::error!("Quick worktree git operation failed: {}", e); - // Remove the optimistically-added project since git worktree add failed - cx.update(|cx| { - focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.finish_creating_project(&project_id); - ws.delete_project(fm, &project_id, &hooks_for_error, cx); - }); - cx.notify(); - }); - }); - } - } - - // Clear debounce guard + // Dispatch CreateWorktree to the daemon. The dispatcher queues + // pending visibility keyed by branch name and sends the action; the + // new worktree project mirrors back when the daemon finishes. let _ = sidebar_weak.update(cx, |sidebar, cx| { + sidebar.dispatch_action_for_project( + &parent_id, + ActionRequest::CreateWorktree { + project_id: parent_id.clone(), + branch, + create_branch: true, + }, + cx, + ); sidebar.creating_worktree.remove(&parent_id_for_cleanup); cx.notify(); }); diff --git a/crates/okena-views-sidebar/src/worktree_list.rs b/crates/okena-views-sidebar/src/worktree_list.rs index 8ed028504..90d4b09c1 100644 --- a/crates/okena-views-sidebar/src/worktree_list.rs +++ b/crates/okena-views-sidebar/src/worktree_list.rs @@ -6,8 +6,7 @@ use okena_ui::overlay::CloseEvent; use okena_ui::theme::theme; use okena_ui::tokens::{ui_text_ms, ui_text_md}; -use okena_workspace::settings::HooksConfig; -use okena_workspace::state::{WindowId, Workspace}; +use okena_workspace::state::Workspace; use gpui::*; use gpui::prelude::*; @@ -16,6 +15,18 @@ use crate::Cancel; /// Event emitted by WorktreeListPopover. pub enum WorktreeListPopoverEvent { Close, + /// Untrack (delete) a tracked worktree project. The daemon owns the + /// project, so the host routes this to `ActionRequest::DeleteProject` + /// rather than mutating the read-only mirror here. + DeleteProject { project_id: String }, + /// Track an already-on-disk worktree as a project. The daemon owns the + /// project list, so the host routes this to + /// `ActionRequest::AddDiscoveredWorktree` rather than mutating the mirror. + AddDiscoveredWorktree { + parent_project_id: String, + worktree_path: String, + branch: String, + }, } impl CloseEvent for WorktreeListPopoverEvent { @@ -27,17 +38,9 @@ impl EventEmitter for WorktreeListPopover {} /// Standalone worktree list popover entity. pub struct WorktreeListPopover { workspace: Entity, - focus_manager: Entity, - /// Spawning window for the multi-window new-project visibility rule - /// (PRD user story 14): a click that adds a discovered worktree - /// makes the new project visible in this window only, hidden in - /// every other window. Threaded from the originating `WindowView` - /// through `OverlayManager::show_worktree_list`. - window_id: WindowId, project_id: String, entries: Vec<(String, String)>, position: Point, - hooks: HooksConfig, focus_handle: FocusHandle, /// Normalized git root (for filtering out the main repo entry). norm_git_root: std::path::PathBuf, @@ -46,25 +49,62 @@ pub struct WorktreeListPopover { } impl WorktreeListPopover { + #[allow(clippy::too_many_arguments)] pub fn new( + host: String, + port: u16, + token: String, + local_endpoint: Option, + daemon_project_id: String, workspace: Entity, - focus_manager: Entity, project_id: String, position: Point, - hooks: HooksConfig, - window_id: WindowId, cx: &mut Context, ) -> Self { - let project_path = workspace.read(cx).project(&project_id) - .map(|p| p.path.clone()) - .unwrap_or_default(); - let (git_root, subdir) = okena_git::resolve_git_root_and_subdir( - std::path::Path::new(&project_path), - ); - let norm_git_root = okena_git::repository::normalize_path(&git_root); - let entries = okena_git::repository::list_git_worktrees(&git_root); + let (norm_git_root, subdir, entries) = + Self::fetch_worktrees(&host, port, &token, local_endpoint.as_ref(), daemon_project_id); let focus_handle = cx.focus_handle(); - Self { workspace, focus_manager, window_id, project_id, entries, position, hooks, focus_handle, norm_git_root, subdir } + Self { + workspace, + project_id, + entries, + position, + focus_handle, + norm_git_root, + subdir, + } + } + + /// Fetch the worktree listing from the daemon. The git repo lives on the + /// daemon, so we post a `GitListWorktrees` action rather than scanning the + /// local filesystem. Kept synchronous on purpose — the old code did a + /// blocking local git scan here, so a blocking HTTP call is no worse. + fn fetch_worktrees( + host: &str, + port: u16, + token: &str, + local_endpoint: Option<&okena_transport::client::LocalEndpoint>, + project_id: String, + ) -> (std::path::PathBuf, std::path::PathBuf, Vec<(String, String)>) { + let action = okena_core::api::ActionRequest::GitListWorktrees { project_id }; + match okena_transport::remote_action::post_action_with_endpoint( + host, + port, + token, + local_endpoint, + action, + ) { + Ok(Some(value)) => { + let git_root = value.get("git_root").and_then(|v| v.as_str()).unwrap_or_default(); + let subdir = value.get("subdir").and_then(|v| v.as_str()).unwrap_or_default(); + let worktrees: Vec<(String, String)> = value + .get("worktrees") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + (std::path::PathBuf::from(git_root), std::path::PathBuf::from(subdir), worktrees) + } + _ => (std::path::PathBuf::new(), std::path::PathBuf::new(), Vec::new()), + } } /// Find a tracked worktree project by its worktree root path. @@ -150,7 +190,6 @@ impl Render for WorktreeListPopover { let project_id = self.project_id.clone(); let wt_path_clone = wt_path.clone(); let branch_clone = branch.clone(); - let hooks = self.hooks.clone(); div() .id(ElementId::Name(format!("wt-list-{}", wt_path).into())) @@ -165,26 +204,20 @@ impl Render for WorktreeListPopover { .on_click(cx.listener(move |this, _, _window, cx| { if is_tracked { if let Some(id) = this.find_tracked_project_id(&wt_path_clone, cx) { - let workspace = this.workspace.clone(); - this.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.delete_project(fm, &id, &hooks, cx); - }); - cx.notify(); - }); + // Daemon owns the project — emit an event so the + // host dispatches DeleteProject; the removal + // mirrors back. No direct mirror mutation here. + cx.emit(WorktreeListPopoverEvent::DeleteProject { project_id: id }); } } else { - let window_id = this.window_id; - this.workspace.update(cx, |ws, cx| { - if let Some(new_id) = ws.add_discovered_worktree( - &wt_path_clone, - &branch_clone, - &project_id, - window_id, - ) { - ws.add_to_worktree_ids(&project_id, &new_id); - } - ws.notify_data(cx); + // The daemon owns the project list — emit an event so + // the host dispatches AddDiscoveredWorktree; the new + // worktree project mirrors back. No direct mirror + // mutation here. + cx.emit(WorktreeListPopoverEvent::AddDiscoveredWorktree { + parent_project_id: project_id.clone(), + worktree_path: wt_path_clone.clone(), + branch: branch_clone.clone(), }); } cx.notify(); diff --git a/crates/okena-views-terminal/src/layout/tabs/mod.rs b/crates/okena-views-terminal/src/layout/tabs/mod.rs index 830862065..1fd153737 100644 --- a/crates/okena-views-terminal/src/layout/tabs/mod.rs +++ b/crates/okena-views-terminal/src/layout/tabs/mod.rs @@ -76,7 +76,6 @@ impl LayoutContainer { let id_suffix = format!("tabs-{:?}", ctx.layout_path); let supports_buffer_capture = self.backend.supports_buffer_capture(); - let backend_for_export = self.backend.clone(); let terminal_id_for_export = terminal_id.clone(); let terminal_id_for_close = terminal_id.clone(); let terminal_id_for_fullscreen = terminal_id.clone(); @@ -85,6 +84,7 @@ impl LayoutContainer { let ctx_split_h = ctx.clone(); let ctx_add_tab = ctx.clone(); let ctx_minimize = ctx.clone(); + let ctx_export = ctx.clone(); let ctx_fullscreen = ctx.clone(); let ctx_detach = ctx.clone(); let ctx_close = ctx.clone(); @@ -154,9 +154,13 @@ impl LayoutContainer { header_button_base(HeaderAction::ExportBuffer, &id_suffix, ButtonSize::COMPACT, &t, None, None) .on_click(move |_, _window, cx| { if let Some(ref tid) = terminal_id_for_export - && let Some(path) = backend_for_export.capture_buffer(tid) { - cx.write_to_clipboard(ClipboardItem::new_string(path.display().to_string())); - log::info!("Buffer exported to {} (path copied to clipboard)", path.display()); + && let Some(ref dispatcher) = ctx_export.action_dispatcher { + if let Some(path) = dispatcher.export_buffer(tid, cx) { + cx.write_to_clipboard(ClipboardItem::new_string(path.display().to_string())); + log::info!("Buffer exported to {} (path copied to clipboard)", path.display()); + } else { + log::warn!("Buffer export unavailable for terminal {} (server needs a tmux session backend)", tid); + } } }), ) @@ -804,7 +808,10 @@ impl LayoutContainer { let action_buttons = self.render_tab_action_buttons(action_ctx, terminal_id_for_actions.clone(), cx); - let show_shell = terminal_view_settings(cx).show_shell_selector && !self.backend.is_remote(); + // Shell switching is routed through the daemon (SwitchTerminalShell) and + // the current shell comes from mirrored layout state, so the selector + // works for remote backends too — gate only on the user setting. + let show_shell = terminal_view_settings(cx).show_shell_selector; if self.last_scrolled_to_tab != Some(active_tab) { self.tab_scroll_handle.scroll_to_item(active_tab); diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/content.rs b/crates/okena-views-terminal/src/layout/terminal_pane/content.rs index a4150226e..2a879aedc 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/content.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/content.rs @@ -9,6 +9,7 @@ use okena_terminal::terminal::Terminal; use okena_files::theme::theme; use okena_ui::color_utils::tint_color; use crate::layout::navigation::register_pane_bounds; +use okena_workspace::request_broker::RequestBroker; use okena_workspace::state::{WindowId, Workspace}; use gpui::*; use std::sync::Arc; @@ -44,6 +45,7 @@ pub struct TerminalContent { layout_path: Vec, window_id: Option, workspace: Entity, + request_broker: Entity, scroll_accumulator: f32, mouse_down_cell: Option<(usize, i32)>, forwarded_button: Option<(u8, u8)>, @@ -60,6 +62,7 @@ impl TerminalContent { project_id: String, layout_path: Vec, workspace: Entity, + request_broker: Entity, cx: &mut Context, ) -> Self { let scrollbar = cx.new(Scrollbar::new); @@ -81,6 +84,7 @@ impl TerminalContent { layout_path, window_id, workspace, + request_broker, scroll_accumulator: 0.0, mouse_down_cell: None, forwarded_button: None, @@ -289,6 +293,22 @@ impl TerminalContent { } } + /// Best-effort project-relative path for opening a clicked terminal path in + /// the file viewer. Strips any trailing :line:col, then makes it relative to + /// the project root (the daemon-side project path). Returns None for absolute + /// paths outside the project or `~`-prefixed paths we can't resolve. + fn project_relative_path(&self, raw: &str, cx: &App) -> Option { + let clean = super::url_detector::strip_line_col_suffix(raw); + let project_path = self.workspace.read(cx).project(&self.project_id)?.path.clone(); + if let Some(stripped) = clean.strip_prefix(&project_path) { + Some(stripped.trim_start_matches('/').to_string()) + } else if clean.starts_with('/') || clean.starts_with('~') { + None // absolute path outside the project (or unresolved ~) — can't open via this project's daemon fs + } else { + Some(clean.to_string()) // already relative — best effort, treated as project-root-relative + } + } + fn handle_mouse_down( &mut self, event: &MouseDownEvent, @@ -314,8 +334,22 @@ impl TerminalContent { UrlDetector::open_url(&url_match.url); } LinkKind::FilePath { line, col } => { - let file_opener = terminal_view_settings(cx).file_opener.clone(); - UrlDetector::open_file(&url_match.url, *line, *col, &file_opener); + if self.workspace.read(cx).is_local_daemon_project(&self.project_id) { + let file_opener = terminal_view_settings(cx).file_opener.clone(); + UrlDetector::open_file(&url_match.url, *line, *col, &file_opener); + } else if let Some(relative_path) = self.project_relative_path(&url_match.url, cx) { + self.request_broker.update(cx, |broker, cx| { + broker.push_overlay_request( + okena_workspace::requests::OverlayRequest::Project( + okena_workspace::requests::ProjectOverlay { + project_id: self.project_id.clone(), + kind: okena_workspace::requests::ProjectOverlayKind::FileViewer { relative_path }, + }, + ), + cx, + ); + }); + } } } self.mouse_down_cell = None; diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs b/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs index 406590c84..553768d47 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs @@ -21,7 +21,6 @@ use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::{Terminal, TerminalSize}; use okena_terminal::TerminalsRegistry; use okena_workspace::focus::FocusManager; -use okena_workspace::hooks; use okena_workspace::request_broker::RequestBroker; use okena_workspace::state::{WindowId, Workspace}; use gpui::*; @@ -97,6 +96,7 @@ impl TerminalPane { project_id.clone(), layout_path.clone(), workspace.clone(), + request_broker.clone(), cx, ) }); @@ -378,78 +378,12 @@ impl TerminalPane { self.update_child_terminals(terminal, cx); } - fn create_new_terminal(&mut self, cx: &mut Context) { - if self.backend.is_remote() { - return; - } - - let settings = terminal_view_settings(cx); - let ws = self.workspace.read(cx); - let mut shell = self.shell_type.clone().resolve_default( - ws.project(&self.project_id).and_then(|p| p.default_shell.as_ref()), - &settings.default_shell, + fn create_new_terminal(&mut self, _cx: &mut Context) { + log::warn!( + "TerminalPane rendered without daemon-assigned terminal id for project {} at {:?}", + self.project_id, + self.layout_path ); - - // Read fresh path and project info from workspace state - let (project_path, project_name, project_hooks, parent_hooks, is_worktree, folder_id, folder_name) = { - let project = ws.project(&self.project_id); - let path = project.map(|p| p.path.clone()) - .unwrap_or_else(|| self.project_path.clone()); - let name = project.map(|p| p.name.clone()).unwrap_or_default(); - let hooks_cfg = project.map(|p| p.hooks.clone()).unwrap_or_default(); - let parent = project - .and_then(|p| p.worktree_info.as_ref()) - .and_then(|wt| ws.project(&wt.parent_project_id)) - .map(|p| p.hooks.clone()); - let is_wt = project.map(|p| p.worktree_info.is_some()).unwrap_or(false); - let folder = ws.folder_for_project_or_parent(&self.project_id); - let fid = folder.map(|f| f.id.clone()); - let fname = folder.map(|f| f.name.clone()); - (path, name, hooks_cfg, parent, is_wt, fid, fname) - }; - - let env = hooks::terminal_hook_env(&self.project_id, &project_name, &project_path, is_worktree, folder_id.as_deref(), folder_name.as_deref()); - - // Apply shell_wrapper if configured - let global_hooks = settings.hooks; - if let Some(wrapper) = hooks::resolve_shell_wrapper(&project_hooks, parent_hooks.as_ref(), &global_hooks) { - shell = hooks::apply_shell_wrapper(&shell, &wrapper, &env); - } - - // Apply on_create: wrap shell to run command first, then exec into shell - if let Some(cmd) = hooks::resolve_terminal_on_create_simple(&project_hooks, parent_hooks.as_ref(), &global_hooks) { - shell = hooks::apply_on_create(&shell, &cmd, &env); - } - - match self - .backend - .create_terminal(&project_path, Some(&shell)) - { - Ok(terminal_id) => { - self.terminal_id = Some(terminal_id.clone()); - self.workspace.update(cx, |ws, cx| { - ws.set_terminal_id(&self.project_id, &self.layout_path, terminal_id.clone(), cx); - }); - - let size = TerminalSize::default(); - let terminal = - Arc::new(Terminal::new(terminal_id.clone(), size, self.backend.transport(), project_path)); - if let Some(pid) = self.backend.get_shell_pid(&terminal_id) { - terminal.set_shell_pid(pid); - } - self.terminals.lock().insert(terminal_id.clone(), terminal.clone()); - self.terminal = Some(terminal.clone()); - - self.update_child_terminals(terminal, cx); - - self.pending_focus = true; - cx.notify(); - } - Err(e) => { - log::error!("Failed to create terminal: {}", e); - crate::toast_error(format!("Failed to create terminal: {}", e), cx); - } - } } fn update_child_terminals(&mut self, terminal: Arc, cx: &mut Context) { diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/url_detector.rs b/crates/okena-views-terminal/src/layout/terminal_pane/url_detector.rs index 944c6d1e9..72edffba5 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/url_detector.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/url_detector.rs @@ -345,7 +345,7 @@ impl UrlDetector { } /// Strip `:line` or `:line:col` suffix from a path string. -fn strip_line_col_suffix(path: &str) -> &str { +pub(crate) fn strip_line_col_suffix(path: &str) -> &str { if let Some(colon_pos) = path.rfind(':') { let after = &path[colon_pos + 1..]; if after.chars().all(|c| c.is_ascii_digit()) && !after.is_empty() { diff --git a/crates/okena-views-terminal/src/lib.rs b/crates/okena-views-terminal/src/lib.rs index 8d6aafc58..cb041703b 100644 --- a/crates/okena-views-terminal/src/lib.rs +++ b/crates/okena-views-terminal/src/lib.rs @@ -62,6 +62,15 @@ pub trait ActionDispatch: Clone + 'static { ) { let _ = (terminal_id, mime, bytes, cx); } + + /// Export a terminal's scrollback buffer to a client-side temp file and + /// return its path (the caller copies it to the clipboard). The capture + /// runs on the server; remote dispatchers fetch the content over HTTP and + /// write the local file. Default: `None`. + fn export_buffer(&self, terminal_id: &str, cx: &mut gpui::App) -> Option { + let _ = (terminal_id, cx); + None + } } /// Settings namespace used in ExtensionSettingsStore. diff --git a/crates/okena-views-terminal/src/overlays/detached_terminal.rs b/crates/okena-views-terminal/src/overlays/detached_terminal.rs index f74c71a1d..47d6d912a 100644 --- a/crates/okena-views-terminal/src/overlays/detached_terminal.rs +++ b/crates/okena-views-terminal/src/overlays/detached_terminal.rs @@ -57,6 +57,12 @@ impl DetachedTerminalView { // Get or create terminal from registry let terminal = get_or_create_terminal(&terminal_id, &transport, &terminals, &project_path); + // Detached windows have no overlay manager observing requests, so the + // file-viewer overlay (pushed for remote file Ctrl+clicks) is a no-op + // here. A local broker keeps TerminalContent self-contained without + // reaching into the main window's broker. + let request_broker = cx.new(|_| okena_workspace::request_broker::RequestBroker::new()); + // Create terminal content view let content = create_terminal_content( cx, @@ -64,6 +70,7 @@ impl DetachedTerminalView { project_id, layout_path, workspace.clone(), + request_broker, terminal.clone(), ); diff --git a/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs b/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs index 83b00b6b5..9968818cc 100644 --- a/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs +++ b/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs @@ -10,6 +10,7 @@ use okena_terminal::input::{KeyEvent, KeyModifiers, KittyKeyboardFlags, key_to_b use okena_terminal::terminal::{Terminal, TerminalSize, TerminalTransport}; use okena_terminal::TerminalsRegistry; use crate::layout::terminal_pane::TerminalContent; +use okena_workspace::request_broker::RequestBroker; use okena_workspace::state::Workspace; use gpui::*; use std::sync::Arc; @@ -71,12 +72,14 @@ pub fn get_or_create_terminal( /// /// This is a convenience function that creates a TerminalContent, sets its terminal, /// and marks it as focused. +#[allow(clippy::too_many_arguments)] pub fn create_terminal_content( cx: &mut Context, focus_handle: FocusHandle, project_id: String, layout_path: Vec, workspace: Entity, + request_broker: Entity, terminal: Arc, ) -> Entity { cx.new(|cx| { @@ -86,6 +89,7 @@ pub fn create_terminal_content( project_id, layout_path, workspace, + request_broker, cx, ); content.set_terminal(Some(terminal), cx); diff --git a/crates/okena-workspace/Cargo.toml b/crates/okena-workspace/Cargo.toml index 6b5d2466c..f5f5c5793 100644 --- a/crates/okena-workspace/Cargo.toml +++ b/crates/okena-workspace/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" license = "MIT" +[features] +default = ["gpui"] +gpui = ["dep:gpui", "okena-hooks/gpui"] + [dependencies] okena-core = { path = "../okena-core" } okena-transport = { path = "../okena-transport", features = ["client"] } @@ -11,9 +15,9 @@ okena-terminal = { path = "../okena-terminal" } okena-git = { path = "../okena-git" } okena-layout = { path = "../okena-layout" } okena-state = { path = "../okena-state" } -okena-hooks = { path = "../okena-hooks" } +okena-hooks = { path = "../okena-hooks", default-features = false } -gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -26,5 +30,14 @@ anyhow = "1.0" smol = "2.0" libc = "0.2" +# `gpui` here is the *same* optional dependency declared in `[dependencies]`; +# this entry only layers on the `test-support` feature so the gpui-gated test +# modules can use `#[gpui::test]` / `TestAppContext`. As a dev-dependency it is +# never part of a production (non-test) build, so the shipped binary's gpui +# feature set is unchanged. It is intentionally NOT routed through the `gpui` +# feature: doing so would enable `test-support` in consumers' production builds +# (a real feature-set change). A `--no-default-features` build never compiles +# gpui (verified: zero `Compiling gpui`); the dependency only appears in the +# `cargo tree` metadata graph, not in any compiled artifact. [dev-dependencies] gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", features = ["test-support"] } diff --git a/crates/okena-workspace/src/actions/focus.rs b/crates/okena-workspace/src/actions/focus.rs index 8c8f09491..a78a175a4 100644 --- a/crates/okena-workspace/src/actions/focus.rs +++ b/crates/okena-workspace/src/actions/focus.rs @@ -8,9 +8,9 @@ //! every caller threads in the focus state belonging to the window driving //! the action -- mutations stay scoped to that window. +use crate::context::WorkspaceCx; use crate::focus::{FocusManager, FocusTarget}; use crate::state::{Workspace, WindowId}; -use gpui::*; impl Workspace { /// Set focused project (focus mode) @@ -22,7 +22,7 @@ impl Workspace { &mut self, focus_manager: &mut FocusManager, project_id: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { // Clear fullscreen without restoring old project_id (we're overriding it) focus_manager.clear_fullscreen_without_restore(); @@ -44,7 +44,7 @@ impl Workspace { &mut self, focus_manager: &mut FocusManager, project_id: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { focus_manager.clear_fullscreen_without_restore(); focus_manager.set_focused_project_id_individual(project_id.clone()); @@ -72,7 +72,7 @@ impl Workspace { focus_manager: &mut FocusManager, window_id: WindowId, folder_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let selecting = self.active_folder_filter(window_id).map(|s| s.as_str()) != Some(folder_id); if selecting { @@ -133,7 +133,7 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: String, terminal_id: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { log::info!("set_fullscreen_terminal called with project_id={}, terminal_id={}", project_id, terminal_id); @@ -156,7 +156,7 @@ impl Workspace { /// Exit fullscreen mode /// /// Restores focus to the previously focused terminal and project view mode. - pub fn exit_fullscreen(&mut self, focus_manager: &mut FocusManager, cx: &mut Context) { + pub fn exit_fullscreen(&mut self, focus_manager: &mut FocusManager, cx: &mut impl WorkspaceCx) { focus_manager.exit_fullscreen(); cx.notify(); } @@ -169,7 +169,7 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: String, layout_path: Vec, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { // Update FocusManager focus_manager.focus_terminal(project_id.clone(), layout_path.clone()); @@ -187,7 +187,7 @@ impl Workspace { /// /// This is typically called when entering a modal context (search, rename, etc.) /// The current focus is saved for restoration when the modal closes. - pub fn clear_focused_terminal(&mut self, focus_manager: &mut FocusManager, cx: &mut Context) { + pub fn clear_focused_terminal(&mut self, focus_manager: &mut FocusManager, cx: &mut impl WorkspaceCx) { focus_manager.enter_modal(); cx.notify(); } @@ -195,7 +195,7 @@ impl Workspace { /// Restore focused terminal after modal dismissal /// /// Called when exiting a modal context to restore the previous focus. - pub fn restore_focused_terminal(&mut self, focus_manager: &mut FocusManager, cx: &mut Context) { + pub fn restore_focused_terminal(&mut self, focus_manager: &mut FocusManager, cx: &mut impl WorkspaceCx) { focus_manager.exit_modal(); cx.notify(); } @@ -208,7 +208,7 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: &str, terminal_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project(project_id) && let Some(ref layout) = project.layout @@ -225,7 +225,7 @@ impl Workspace { } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod gpui_tests { use gpui::AppContext as _; use crate::focus::FocusManager; diff --git a/crates/okena-workspace/src/actions/folder.rs b/crates/okena-workspace/src/actions/folder.rs index 08d399680..5ee5c182e 100644 --- a/crates/okena-workspace/src/actions/folder.rs +++ b/crates/okena-workspace/src/actions/folder.rs @@ -3,12 +3,12 @@ //! Actions for creating, modifying, and deleting sidebar folders. use okena_core::theme::FolderColor; +use crate::context::WorkspaceCx; use crate::state::{FolderData, WindowId, Workspace}; -use gpui::*; impl Workspace { /// Create a new folder, appending it to project_order - pub fn create_folder(&mut self, name: String, cx: &mut Context) -> String { + pub fn create_folder(&mut self, name: String, cx: &mut impl WorkspaceCx) -> String { let id = uuid::Uuid::new_v4().to_string(); self.data.folders.push(FolderData { id: id.clone(), @@ -22,7 +22,7 @@ impl Workspace { } /// Delete a folder, splicing its contained projects back into project_order at the folder's position - pub fn delete_folder(&mut self, folder_id: &str, cx: &mut Context) { + pub fn delete_folder(&mut self, folder_id: &str, cx: &mut impl WorkspaceCx) { let project_ids = self.data.folders.iter() .find(|f| f.id == folder_id) .map(|f| f.project_ids.clone()) @@ -43,7 +43,7 @@ impl Workspace { } /// Rename a folder - pub fn rename_folder(&mut self, folder_id: &str, new_name: String, cx: &mut Context) { + pub fn rename_folder(&mut self, folder_id: &str, new_name: String, cx: &mut impl WorkspaceCx) { if let Some(folder) = self.folder_mut(folder_id) { folder.name = new_name; self.notify_data(cx); @@ -51,7 +51,7 @@ impl Workspace { } /// Set the color for a folder - pub fn set_folder_item_color(&mut self, folder_id: &str, color: FolderColor, cx: &mut Context) { + pub fn set_folder_item_color(&mut self, folder_id: &str, color: FolderColor, cx: &mut impl WorkspaceCx) { if let Some(folder) = self.folder_mut(folder_id) { folder.folder_color = color; self.notify_data(cx); @@ -91,7 +91,7 @@ impl Workspace { /// /// Unknown extra ids inherit the silent no-op contract from /// `set_folder_collapsed`. - pub fn toggle_folder_collapsed(&mut self, window_id: WindowId, folder_id: &str, cx: &mut Context) { + pub fn toggle_folder_collapsed(&mut self, window_id: WindowId, folder_id: &str, cx: &mut impl WorkspaceCx) { if !self.data.folders.iter().any(|f| f.id == folder_id) { return; } @@ -100,7 +100,7 @@ impl Workspace { } /// Move a project into a folder at a given position - pub fn move_project_to_folder(&mut self, project_id: &str, folder_id: &str, position: Option, cx: &mut Context) { + pub fn move_project_to_folder(&mut self, project_id: &str, folder_id: &str, position: Option, cx: &mut impl WorkspaceCx) { // Remove from any current folder for folder in &mut self.data.folders { folder.project_ids.retain(|id| id != project_id); @@ -119,7 +119,7 @@ impl Workspace { /// Move a project out of its folder into the top-level project_order #[allow(dead_code)] - pub fn move_project_out_of_folder(&mut self, project_id: &str, top_level_index: usize, cx: &mut Context) { + pub fn move_project_out_of_folder(&mut self, project_id: &str, top_level_index: usize, cx: &mut impl WorkspaceCx) { // Remove from any folder for folder in &mut self.data.folders { folder.project_ids.retain(|id| id != project_id); @@ -134,7 +134,7 @@ impl Workspace { /// Reorder a project within a folder #[allow(dead_code)] - pub fn reorder_project_in_folder(&mut self, folder_id: &str, project_id: &str, new_index: usize, cx: &mut Context) { + pub fn reorder_project_in_folder(&mut self, folder_id: &str, project_id: &str, new_index: usize, cx: &mut impl WorkspaceCx) { if let Some(folder) = self.folder_mut(folder_id) && let Some(current) = folder.project_ids.iter().position(|id| id == project_id) { let id = folder.project_ids.remove(current); @@ -150,7 +150,7 @@ impl Workspace { } /// Reorder any top-level item (project or folder) in project_order - pub fn move_item_in_order(&mut self, item_id: &str, new_index: usize, cx: &mut Context) { + pub fn move_item_in_order(&mut self, item_id: &str, new_index: usize, cx: &mut impl WorkspaceCx) { if let Some(current) = self.data.project_order.iter().position(|id| id == item_id) { let id = self.data.project_order.remove(current); let target = if new_index > current { @@ -276,7 +276,7 @@ mod tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod gpui_tests { use gpui::AppContext as _; use crate::state::{FolderData, LayoutNode, ProjectData, WindowId, WindowState, Workspace, WorkspaceData}; diff --git a/crates/okena-workspace/src/actions/layout/close.rs b/crates/okena-workspace/src/actions/layout/close.rs index 6e45a0fd0..0c0cf896e 100644 --- a/crates/okena-workspace/src/actions/layout/close.rs +++ b/crates/okena-workspace/src/actions/layout/close.rs @@ -1,13 +1,13 @@ //! Terminal and tab close operations. +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::state::{LayoutNode, Workspace}; -use gpui::*; impl Workspace { /// Close a terminal at a path. /// Returns the terminal IDs that were removed from the layout. - pub fn close_terminal(&mut self, project_id: &str, path: &[usize], cx: &mut Context) -> Vec { + pub fn close_terminal(&mut self, project_id: &str, path: &[usize], cx: &mut impl WorkspaceCx) -> Vec { if let Some(project) = self.project_mut(project_id) && let Some(ref mut layout) = project.layout { if path.is_empty() { @@ -55,7 +55,7 @@ impl Workspace { /// Close a terminal and focus its sibling (reverse of splitting). /// Returns the terminal IDs that were removed from the layout. - pub fn close_terminal_and_focus_sibling(&mut self, focus_manager: &mut FocusManager, project_id: &str, path: &[usize], cx: &mut Context) -> Vec { + pub fn close_terminal_and_focus_sibling(&mut self, focus_manager: &mut FocusManager, project_id: &str, path: &[usize], cx: &mut impl WorkspaceCx) -> Vec { if path.is_empty() { // Closing root - remove layout (project becomes bookmark) let removed = self.close_terminal(project_id, path, cx); @@ -135,7 +135,7 @@ impl Workspace { project_id: &str, path: &[usize], tab_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Vec { let applied = self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { children, active_tab } = node { @@ -175,7 +175,7 @@ impl Workspace { project_id: &str, path: &[usize], keep_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Vec { let applied = self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { children, .. } = node { @@ -202,7 +202,7 @@ impl Workspace { project_id: &str, path: &[usize], from_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Vec { let applied = self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { children, active_tab } = node { diff --git a/crates/okena-workspace/src/actions/layout/mod.rs b/crates/okena-workspace/src/actions/layout/mod.rs index b130adfc7..bab91a188 100644 --- a/crates/okena-workspace/src/actions/layout/mod.rs +++ b/crates/okena-workspace/src/actions/layout/mod.rs @@ -43,5 +43,5 @@ impl Workspace { #[cfg(test)] mod tests_simulate; -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod tests_gpui; diff --git a/crates/okena-workspace/src/actions/layout/move_ops.rs b/crates/okena-workspace/src/actions/layout/move_ops.rs index 1207430f3..205791943 100644 --- a/crates/okena-workspace/src/actions/layout/move_ops.rs +++ b/crates/okena-workspace/src/actions/layout/move_ops.rs @@ -5,9 +5,9 @@ // clarifies here. #![allow(clippy::too_many_arguments)] +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::state::{DropZone, LayoutNode, SplitDirection, Workspace}; -use gpui::*; impl Workspace { /// Move a terminal pane to a new position relative to a target terminal. @@ -23,7 +23,7 @@ impl Workspace { target_project_id: &str, target_terminal_id: &str, zone: DropZone, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { // Self-drop check if source_terminal_id == target_terminal_id { @@ -45,7 +45,7 @@ impl Workspace { source_terminal_id: &str, target_terminal_id: &str, zone: DropZone, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let project = match self.project(project_id) { Some(p) => p, @@ -136,7 +136,7 @@ impl Workspace { target_project_id: &str, target_terminal_id: &str, zone: DropZone, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { // Find project indices (needed for split borrows) let src_idx = match self.data.projects.iter().position(|p| p.id == source_project_id) { @@ -298,7 +298,7 @@ impl Workspace { target_project_id: &str, tabs_path: &[usize], insert_index: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if source_project_id == target_project_id { self.move_terminal_to_tab_group_same_project(focus_manager, source_project_id, terminal_id, tabs_path, insert_index, cx); @@ -315,7 +315,7 @@ impl Workspace { terminal_id: &str, tabs_path: &[usize], insert_index: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let project = match self.project(project_id) { Some(p) => p, @@ -435,7 +435,7 @@ impl Workspace { target_project_id: &str, tabs_path: &[usize], insert_index: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let src_idx = match self.data.projects.iter().position(|p| p.id == source_project_id) { Some(i) => i, diff --git a/crates/okena-workspace/src/actions/layout/split.rs b/crates/okena-workspace/src/actions/layout/split.rs index f52575066..5f23a3c23 100644 --- a/crates/okena-workspace/src/actions/layout/split.rs +++ b/crates/okena-workspace/src/actions/layout/split.rs @@ -1,8 +1,8 @@ //! Split operations: `split_terminal`, split-size updates, equalize. +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::state::{LayoutNode, SplitDirection, Workspace}; -use gpui::*; impl Workspace { /// Split a terminal at a path @@ -12,7 +12,7 @@ impl Workspace { project_id: &str, path: &[usize], direction: SplitDirection, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { log::info!("Workspace::split_terminal called for project {} at path {:?}", project_id, path); @@ -76,7 +76,7 @@ impl Workspace { project_id: &str, path: &[usize], new_sizes: Vec, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Split { sizes, .. } = node { @@ -96,7 +96,7 @@ impl Workspace { project_id: &str, path: &[usize], new_sizes: Vec, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) && let Some(ref mut layout) = project.layout @@ -108,7 +108,7 @@ impl Workspace { } /// Equalize pane sizes in the focused terminal's parent split. - pub fn equalize_focused_split(&mut self, focus_manager: &FocusManager, cx: &mut Context) { + pub fn equalize_focused_split(&mut self, focus_manager: &FocusManager, cx: &mut impl WorkspaceCx) { if let Some(target) = focus_manager.focused_terminal_state() && let Some(project) = self.project_mut(&target.project_id) && let Some(ref mut layout) = project.layout { diff --git a/crates/okena-workspace/src/actions/layout/tabs.rs b/crates/okena-workspace/src/actions/layout/tabs.rs index b011dd5d2..9d8c5868d 100644 --- a/crates/okena-workspace/src/actions/layout/tabs.rs +++ b/crates/okena-workspace/src/actions/layout/tabs.rs @@ -1,8 +1,8 @@ //! Tab group operations: add, set-active, reorder. +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::state::{LayoutNode, Workspace}; -use gpui::*; impl Workspace { /// Add a new tab - either to existing tab group (if parent is Tabs) or create new tab group @@ -11,7 +11,7 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: &str, path: &[usize], - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { log::info!("Workspace::add_tab called for project {} at path {:?}", project_id, path); @@ -50,7 +50,7 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: &str, tabs_path: &[usize], - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let mut new_tab_index = 0; self.with_layout_node(project_id, tabs_path, cx, |node| { @@ -77,7 +77,7 @@ impl Workspace { project_id: &str, path: &[usize], tab_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { active_tab, .. } = node { @@ -96,7 +96,7 @@ impl Workspace { path: &[usize], from_index: usize, to_index: usize, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { children, active_tab } = node { diff --git a/crates/okena-workspace/src/actions/project.rs b/crates/okena-workspace/src/actions/project.rs index ab5ab835b..0848af926 100644 --- a/crates/okena-workspace/src/actions/project.rs +++ b/crates/okena-workspace/src/actions/project.rs @@ -3,11 +3,11 @@ //! Actions for creating, modifying, and deleting projects. use okena_core::theme::FolderColor; +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::hooks; use crate::persistence::HooksConfig; use crate::state::{LayoutNode, ProjectData, Workspace, WindowId}; -use gpui::*; use std::collections::HashMap; /// Pick a replacement focus target after hiding `hidden_id`. @@ -83,7 +83,7 @@ impl Workspace { focus_manager: &mut FocusManager, window_id: WindowId, project_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if self.project(project_id).is_none() { return; @@ -167,7 +167,7 @@ impl Workspace { /// `Okena::focus_manager_for_active_window` (slice 05 cri 13). When /// only main exists (zero extras), the rule degenerates to a no-op /// for the hide-elsewhere step, matching pre-multi-window behavior. - pub fn add_project(&mut self, name: String, path: String, with_terminal: bool, global_hooks: &HooksConfig, window_id: WindowId, cx: &mut Context) -> String { + pub fn add_project(&mut self, name: String, path: String, with_terminal: bool, global_hooks: &HooksConfig, window_id: WindowId, cx: &mut impl WorkspaceCx) -> String { let path = expand_tilde(&path); // Auto-detect WSL UNC paths and set default shell accordingly @@ -208,13 +208,15 @@ impl Workspace { let folder = self.folder_for_project_or_parent(&id); let folder_id = folder.map(|f| f.id.as_str()); let folder_name = folder.map(|f| f.name.as_str()); - let hook_results = hooks::fire_on_project_open(&project_hooks, &id, &name, &path, folder_id, folder_name, global_hooks, cx); + let runner = cx.hook_runner(); + let monitor = cx.hook_monitor(); + let hook_results = hooks::fire_on_project_open(&project_hooks, &id, &name, &path, folder_id, folder_name, global_hooks, runner.as_ref(), monitor.as_ref()); self.register_hook_results(hook_results, cx); id } /// Add a new terminal to a project by splitting the root layout - pub fn add_terminal(&mut self, focus_manager: &mut FocusManager, project_id: &str, cx: &mut Context) { + pub fn add_terminal(&mut self, focus_manager: &mut FocusManager, project_id: &str, cx: &mut impl WorkspaceCx) { if let Some(project) = self.project_mut(project_id) { if let Some(ref old_layout) = project.layout { let old_layout = old_layout.clone(); @@ -245,7 +247,7 @@ impl Workspace { project_id: &str, command: &str, env_vars: &HashMap, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) { let new_node = LayoutNode::new_terminal_with_command(command, env_vars); @@ -264,7 +266,7 @@ impl Workspace { } /// Rename a project - pub fn rename_project(&mut self, project_id: &str, new_name: String, cx: &mut Context) { + pub fn rename_project(&mut self, project_id: &str, new_name: String, cx: &mut impl WorkspaceCx) { self.with_project(project_id, cx, |project| { project.name = new_name; true @@ -272,7 +274,7 @@ impl Workspace { } /// Rename a project's directory path and update the project name to match - pub fn rename_project_directory(&mut self, project_id: &str, new_path: String, new_name: String, cx: &mut Context) { + pub fn rename_project_directory(&mut self, project_id: &str, new_path: String, new_name: String, cx: &mut impl WorkspaceCx) { self.with_project(project_id, cx, |project| { project.path = new_path; project.name = new_name; @@ -281,7 +283,7 @@ impl Workspace { } /// Set the folder color for a project (also propagates to worktree children without overrides) - pub fn set_folder_color(&mut self, project_id: &str, color: FolderColor, cx: &mut Context) { + pub fn set_folder_color(&mut self, project_id: &str, color: FolderColor, cx: &mut impl WorkspaceCx) { let is_worktree = self.project(project_id) .and_then(|p| p.worktree_info.as_ref()) .is_some(); @@ -317,7 +319,7 @@ impl Workspace { } /// Delete a project - pub fn delete_project(&mut self, focus_manager: &mut FocusManager, project_id: &str, global_hooks: &HooksConfig, cx: &mut Context) { + pub fn delete_project(&mut self, focus_manager: &mut FocusManager, project_id: &str, global_hooks: &HooksConfig, cx: &mut impl WorkspaceCx) { // Queue all project terminals for killing before removing state. // Okena (which owns PtyManager) drains this queue via observer. if let Some(project) = self.project(project_id) { @@ -392,14 +394,15 @@ impl Workspace { self.notify_data(cx); if let Some((project_hooks, id, name, path)) = hook_info { - hooks::fire_on_project_close(&project_hooks, &id, &name, &path, hook_folder_id.as_deref(), hook_folder_name.as_deref(), global_hooks, cx); + let monitor = cx.hook_monitor(); + hooks::fire_on_project_close(&project_hooks, &id, &name, &path, hook_folder_id.as_deref(), hook_folder_name.as_deref(), global_hooks, monitor.as_ref()); } } /// Move a project to a new position in the top-level order. /// Also removes the project from any folder it may be in. /// Worktree children are moved along with their parent. - pub fn move_project(&mut self, project_id: &str, new_index: usize, cx: &mut Context) { + pub fn move_project(&mut self, project_id: &str, new_index: usize, cx: &mut impl WorkspaceCx) { // Remove from any folder first for folder in &mut self.data.folders { folder.project_ids.retain(|id| id != project_id); @@ -465,7 +468,7 @@ impl Workspace { /// layer setter does not notify, so the single trailing `notify_data` keeps /// the auto-save observer's debounce cadence identical to the pre-migration /// body. - pub fn update_project_widths(&mut self, window_id: WindowId, widths: HashMap, cx: &mut Context) { + pub fn update_project_widths(&mut self, window_id: WindowId, widths: HashMap, cx: &mut impl WorkspaceCx) { if let Some(w) = self.data.window_mut(window_id) { w.project_widths.clear(); } @@ -476,13 +479,13 @@ impl Workspace { } /// Update service panel height for a project - pub fn update_service_panel_height(&mut self, project_id: &str, height: f32, cx: &mut Context) { + pub fn update_service_panel_height(&mut self, project_id: &str, height: f32, cx: &mut impl WorkspaceCx) { self.data.service_panel_heights.insert(project_id.to_string(), height); self.notify_data(cx); } /// Update hook panel height for a project - pub fn update_hook_panel_height(&mut self, project_id: &str, height: f32, cx: &mut Context) { + pub fn update_hook_panel_height(&mut self, project_id: &str, height: f32, cx: &mut impl WorkspaceCx) { self.data.hook_panel_heights.insert(project_id.to_string(), height); self.notify_data(cx); } @@ -710,7 +713,7 @@ mod tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod gpui_tests { use gpui::AppContext as _; use crate::focus::FocusManager; diff --git a/crates/okena-workspace/src/actions/soft_close.rs b/crates/okena-workspace/src/actions/soft_close.rs index 56d82ef75..1a6fff593 100644 --- a/crates/okena-workspace/src/actions/soft_close.rs +++ b/crates/okena-workspace/src/actions/soft_close.rs @@ -5,9 +5,254 @@ //! desktop layer drives the timer + toast; this module owns the layout //! bookkeeping: recording the close, restoring it, and finalizing the kill. +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; + +use okena_core::soft_close::{encode_action, SOFT_CLOSE_KILL_PREFIX, SOFT_CLOSE_UNDO_PREFIX}; +use okena_state::{Toast, ToastAction, ToastActionStyle}; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::TerminalsRegistry; + +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::state::{LayoutNode, PendingClose, RestoredClose, Workspace}; -use gpui::*; + +/// Shared `terminal_id -> grace deadline` map for in-flight soft-closes. +/// +/// Runtime-agnostic: the daemon-core loop and the headless loop each own one of +/// these and hand a reference to the shared flows below. The command path arms a +/// deadline; the finalizer tick ([`finalize_expired`]) reaps the ones that +/// elapsed; Undo / Close-now remove the deadline first. +pub type SoftCloseDeadlines = Arc>>; + +/// Probe whether a terminal is "busy" (has a live foreground child) and, if so, +/// what its foreground command is (for the toast label). +/// +/// This forks `tmux`/`lsof`/`pgrep` under the hood, so callers must run it OFF +/// their reactor thread (tokio `spawn_blocking` / `smol::unblock`) and hold NO +/// state locks across it. Returns `(busy, command)`. +pub fn probe_busy(backend: &dyn TerminalBackend, terminal_id: &str) -> (bool, Option) { + let fg = backend.get_foreground_shell_pid(terminal_id); + let busy = fg + .map(okena_terminal::terminal::has_child_processes) + .unwrap_or(false); + let command = fg.and_then(okena_terminal::terminal::foreground_command); + (busy, command) +} + +/// Build the two-line Undo / Close-now toast for a busy soft-close: +/// +/// title: Closed "make" — what's closing +/// detail: okena · ~/projects/okena — project · working directory (muted) +/// +/// `command` is the live foreground command (probed off-thread by the caller), +/// used as the title fallback when the terminal has no meaningful display name. +pub fn build_soft_close_toast( + ws: &Workspace, + terminals: &TerminalsRegistry, + project_id: &str, + terminal_id: &str, + command: Option, + toast_id: &str, + grace: u32, +) -> Toast { + // Read the live OSC title + cwd under a single registry lock. + let (osc_title, cwd) = { + let reg = terminals.lock(); + let term = reg.get(terminal_id); + (term.and_then(|t| t.title()), term.map(|t| t.current_cwd())) + }; + + let (title, detail) = ws + .project(project_id) + .map(|p| { + // Title label precedence: a meaningful display name (user-set custom + // name or non-prompt OSC title) wins; else the live foreground + // command; else a generic "Terminal closed". + let display = p.terminal_display_name(terminal_id, osc_title); + let label = if display == p.directory_name() { command } else { Some(display) }; + let title = match label { + Some(l) => format!("Closed \u{201c}{}\u{201d}", truncate_label(&l)), + None => "Terminal closed".to_string(), + }; + // Detail line: project name, plus the cwd when we have one. + let mut detail = p.name.clone(); + if let Some(cwd) = &cwd { + detail.push_str(" \u{00b7} "); + detail.push_str(&shorten_cwd(cwd)); + } + (title, detail) + }) + .unwrap_or_else(|| ("Terminal closed".to_string(), String::new())); + + let actions = vec![ + ToastAction::new( + encode_action(SOFT_CLOSE_UNDO_PREFIX, project_id, terminal_id), + "Undo", + ToastActionStyle::Primary, + ), + ToastAction::new( + encode_action(SOFT_CLOSE_KILL_PREFIX, project_id, terminal_id), + "Close now", + ToastActionStyle::Danger, + ), + ]; + let base = Toast::info(title) + .with_id(toast_id) + .with_ttl(Duration::from_secs(grace as u64)) + .with_actions(actions); + if detail.is_empty() { base } else { base.with_detail(detail) } +} + +/// Cap a terminal label so the toast stays tidy. OSC titles can be arbitrarily +/// long; truncate on a char boundary with an ellipsis. +fn truncate_label(label: &str) -> String { + const MAX_CHARS: usize = 42; + if label.chars().count() <= MAX_CHARS { + return label.to_string(); + } + let mut out: String = label.chars().take(MAX_CHARS - 1).collect(); + out.push('\u{2026}'); + out +} + +/// Home-relative, tail-preserving working directory for the toast detail line. +/// `~`-collapses the home dir and keeps the *end* of long paths. +fn shorten_cwd(path: &str) -> String { + let shown = match std::env::var("HOME") { + Ok(home) if !home.is_empty() && path == home => return "~".to_string(), + Ok(home) if !home.is_empty() && path.starts_with(&format!("{home}/")) => { + format!("~{}", &path[home.len()..]) + } + _ => path.to_string(), + }; + const MAX_CHARS: usize = 30; + if shown.chars().count() <= MAX_CHARS { + return shown; + } + let tail: String = shown + .chars() + .rev() + .take(MAX_CHARS - 1) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("\u{2026}{tail}") +} + +/// Begin a soft close: eject the busy pane, build its Undo / Close-now toast, +/// and arm the grace deadline for the finalizer tick. +/// +/// Returns `Some(toast)` when the terminal was in the layout (the caller pushes +/// it into its own `HookMonitor`). Returns `None` when the terminal has no +/// layout path — the caller should fall back to an immediate close. +/// +/// The engine stays `HookMonitor`-free on purpose: it returns the toast rather +/// than pushing it, so each loop wires its own toast surface. +#[allow(clippy::too_many_arguments)] +pub fn begin_soft_close_flow( + deadlines: &SoftCloseDeadlines, + ws: &mut Workspace, + focus_manager: &mut FocusManager, + terminals: &TerminalsRegistry, + project_id: &str, + terminal_id: &str, + grace: u32, + command: Option, + cx: &mut impl WorkspaceCx, +) -> Option { + let path = ws + .project(project_id) + .and_then(|p| p.layout.as_ref()) + .and_then(|l| l.find_terminal_path(terminal_id))?; + + let toast_id = format!("soft-close:{terminal_id}"); + let toast = + build_soft_close_toast(ws, terminals, project_id, terminal_id, command, &toast_id, grace); + ws.begin_soft_close(focus_manager, project_id, &path, terminal_id, &toast_id, cx); + deadlines.lock().insert( + terminal_id.to_string(), + Instant::now() + Duration::from_secs(grace as u64), + ); + Some(toast) +} + +/// Undo a soft close: drop the grace deadline and restore the ejected pane (if +/// its PTY is still alive in the registry). +pub fn undo_soft_close_flow( + deadlines: &SoftCloseDeadlines, + ws: &mut Workspace, + focus_manager: &mut FocusManager, + terminals: &TerminalsRegistry, + terminal_id: &str, + cx: &mut impl WorkspaceCx, +) { + deadlines.lock().remove(terminal_id); + // The PTY is restorable only if still in the registry; the loop owns it, so + // the alive-check happens here. + let alive = terminals.lock().contains_key(terminal_id); + ws.undo_soft_close(focus_manager, terminal_id, alive, cx); +} + +/// Finalize a soft close now ("Close now"): drop the deadline, finalize the +/// pending record, then kill the PTY + drop it from the registry. +pub fn close_now_flow( + deadlines: &SoftCloseDeadlines, + ws: &mut Workspace, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + terminal_id: &str, + cx: &mut impl WorkspaceCx, +) { + deadlines.lock().remove(terminal_id); + ws.finalize_soft_close(terminal_id, cx); + for id in ws.drain_pending_terminal_kills() { + backend.kill(&id); + terminals.lock().remove(&id); + } +} + +/// One finalizer tick: reap every soft-close whose grace period elapsed. +/// +/// Collects + removes the expired ids under the deadline lock, finalizes each on +/// the workspace (queues the kills), then drains the kill queue and tears the +/// PTYs down. The client toast TTLs out on its own. Callers drive this on a +/// timer (~200ms). +pub fn finalize_expired( + deadlines: &SoftCloseDeadlines, + ws: &mut Workspace, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + cx: &mut impl WorkspaceCx, +) { + // Collect + remove expired ids under the deadline lock only. + let expired: Vec = { + let now = Instant::now(); + let mut d = deadlines.lock(); + let exp: Vec = + d.iter().filter(|(_, dl)| **dl <= now).map(|(t, _)| t.clone()).collect(); + for t in &exp { + d.remove(t); + } + exp + }; + if expired.is_empty() { + return; + } + + // Finalize on the workspace (queues kills), then drain the kill queue. + for tid in &expired { + ws.finalize_soft_close(tid, cx); + } + for id in ws.drain_pending_terminal_kills() { + backend.kill(&id); + terminals.lock().remove(&id); + } +} /// Outcome of resolving an optimistic close once the (off-thread) busy check /// has come back. The terminal was already removed from the layout when the @@ -39,7 +284,7 @@ impl Workspace { path: &[usize], terminal_id: &str, toast_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let pre_close_layout = self.project(project_id).and_then(|p| p.layout.clone()); @@ -80,7 +325,7 @@ impl Workspace { /// real teardown (the Okena observer drains `pending_terminal_kills` and /// calls `pty_manager.kill` + removes it from the registry). Idempotent — /// returns false if there was no pending close for this terminal. - pub fn finalize_soft_close(&mut self, terminal_id: &str, cx: &mut Context) -> bool { + pub fn finalize_soft_close(&mut self, terminal_id: &str, cx: &mut impl WorkspaceCx) -> bool { if self.take_pending_close(terminal_id).is_none() { return false; } @@ -104,7 +349,7 @@ impl Workspace { &mut self, terminal_id: &str, busy: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> PendingDecision { if !self.has_pending_close(terminal_id) { return PendingDecision::Raced; @@ -128,7 +373,7 @@ impl Workspace { focus_manager: &mut FocusManager, terminal_id: &str, alive: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> bool { let pending = match self.take_pending_close(terminal_id) { Some(p) => p, @@ -200,7 +445,7 @@ impl Workspace { /// still sitting in the layout, remove that pane (it can't be reconnected, and /// a lingering layout node would respawn a fresh shell on the next render). /// Returns true if a breadcrumb was consumed. Idempotent. - pub fn reap_restored_close(&mut self, terminal_id: &str, cx: &mut Context) -> bool { + pub fn reap_restored_close(&mut self, terminal_id: &str, cx: &mut impl WorkspaceCx) -> bool { let Some(idx) = self .restored_closes .iter() @@ -256,17 +501,26 @@ impl Workspace { } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod tests { use gpui::AppContext as _; - use super::PendingDecision; + use super::{ + begin_soft_close_flow, build_soft_close_toast, close_now_flow, finalize_expired, undo_soft_close_flow, + PendingDecision, SoftCloseDeadlines, + }; use crate::focus::FocusManager; use crate::settings::HooksConfig; use crate::state::{LayoutNode, ProjectData, SplitDirection, Workspace, WorkspaceData}; use okena_core::theme::FolderColor; + use okena_terminal::backend::TerminalBackend; use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::{Terminal, TerminalSize, TerminalTransport}; + use okena_terminal::TerminalsRegistry; + use parking_lot::Mutex; use std::collections::HashMap; + use std::sync::Arc; + use std::time::{Duration, Instant}; fn term(id: &str) -> LayoutNode { LayoutNode::Terminal { @@ -555,4 +809,222 @@ mod tests { assert!(!ws.has_pending_close("a")); }); } + + // ── Shared soft-close flow tests ───────────────────────────────────────── + + /// No-op transport for the test backend. + struct StubTransport; + impl TerminalTransport for StubTransport { + fn send_input(&self, _terminal_id: &str, _data: &[u8]) {} + fn resize(&self, _terminal_id: &str, _cols: u16, _rows: u16) {} + fn uses_mouse_backend(&self) -> bool { + false + } + } + + /// `TerminalBackend` that records every `kill`ed id. The soft-close flow + /// tests never spawn PTYs, so the create/reconnect paths are unreachable. + struct RecordingBackend { + killed: Arc>>, + } + + impl TerminalBackend for RecordingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + fn create_terminal(&self, _cwd: &str, _shell: Option<&ShellType>) -> anyhow::Result { + anyhow::bail!("stub backend: create_terminal not supported") + } + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("stub backend: reconnect_terminal not supported") + } + fn kill(&self, terminal_id: &str) { + self.killed.lock().push(terminal_id.to_string()); + } + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + fn supports_buffer_capture(&self) -> bool { + false + } + fn is_remote(&self) -> bool { + false + } + fn get_shell_pid(&self, _terminal_id: &str) -> Option { + None + } + fn get_service_pids(&self, _terminal_id: &str) -> Vec { + Vec::new() + } + } + + fn empty_deadlines() -> SoftCloseDeadlines { + Arc::new(Mutex::new(HashMap::new())) + } + + fn empty_registry() -> TerminalsRegistry { + Arc::new(Mutex::new(HashMap::new())) + } + + #[gpui::test] + fn begin_flow_arms_deadline_and_returns_toast(cx: &mut gpui::TestAppContext) { + let data = workspace_data(hsplit(vec![term("a"), term("b")])); + let workspace = cx.new(|_cx| Workspace::new(data)); + + workspace.update(cx, |ws: &mut Workspace, cx| { + let mut fm = FocusManager::new(); + let deadlines = empty_deadlines(); + let terminals = empty_registry(); + + let toast = begin_soft_close_flow( + &deadlines, ws, &mut fm, &terminals, "p1", "a", 5, Some("make".into()), cx, + ); + + let toast = toast.expect("terminal in layout → toast returned"); + assert_eq!(toast.id, "soft-close:a"); + assert_eq!(toast.message, "Closed \u{201c}make\u{201d}"); + assert_eq!(toast.actions.len(), 2, "Undo + Close now"); + assert!(ws.has_pending_close("a"), "pending close recorded"); + assert!(deadlines.lock().contains_key("a"), "deadline armed"); + assert_eq!( + ws.project("p1").unwrap().layout, + Some(term("b")), + "pane ejected from layout" + ); + }); + } + + #[test] + fn soft_close_toast_ignores_codex_main_thread_title_for_command_label() { + let ws = Workspace::new(workspace_data(term("a"))); + let terminal = Arc::new(Terminal::new( + "a".to_string(), + TerminalSize::default(), + Arc::new(StubTransport), + "/tmp/test".to_string(), + )); + terminal.process_output(b"\x1b]0;MainThread\x07"); + + let terminals = empty_registry(); + terminals.lock().insert("a".to_string(), terminal); + + let toast = build_soft_close_toast( + &ws, + &terminals, + "p1", + "a", + Some("codex".to_string()), + "soft-close:a", + 5, + ); + + assert_eq!(toast.message, "Closed \u{201c}codex\u{201d}"); + } + + #[gpui::test] + fn begin_flow_returns_none_when_terminal_not_in_layout(cx: &mut gpui::TestAppContext) { + let data = workspace_data(hsplit(vec![term("a"), term("b")])); + let workspace = cx.new(|_cx| Workspace::new(data)); + + workspace.update(cx, |ws: &mut Workspace, cx| { + let mut fm = FocusManager::new(); + let deadlines = empty_deadlines(); + let terminals = empty_registry(); + + // "z" is not in the layout → caller should immediate-close instead. + let toast = begin_soft_close_flow( + &deadlines, ws, &mut fm, &terminals, "p1", "z", 5, None, cx, + ); + assert!(toast.is_none()); + assert!(!ws.has_pending_close("z")); + assert!(deadlines.lock().is_empty(), "no deadline armed"); + }); + } + + #[gpui::test] + fn finalize_expired_kills_only_past_deadline_ids(cx: &mut gpui::TestAppContext) { + let data = workspace_data(hsplit(vec![term("a"), term("b")])); + let workspace = cx.new(|_cx| Workspace::new(data)); + + workspace.update(cx, |ws: &mut Workspace, cx| { + let mut fm = FocusManager::new(); + let killed = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { killed: killed.clone() }; + let terminals = empty_registry(); + let deadlines = empty_deadlines(); + + // "a" is mid soft-close with an already-expired deadline; "b" is + // soft-closed but its deadline is far in the future. + ws.begin_soft_close(&mut fm, "p1", &[0], "a", "toast-a", cx); + ws.begin_soft_close(&mut fm, "p1", &[0], "b", "toast-b", cx); + deadlines + .lock() + .insert("a".to_string(), Instant::now() - Duration::from_secs(1)); + deadlines + .lock() + .insert("b".to_string(), Instant::now() + Duration::from_secs(60)); + + finalize_expired(&deadlines, ws, &backend, &terminals, cx); + + assert_eq!(&*killed.lock(), &vec!["a".to_string()], "only past-deadline killed"); + assert!(!deadlines.lock().contains_key("a"), "expired deadline removed"); + assert!(deadlines.lock().contains_key("b"), "future deadline retained"); + assert!(!ws.has_pending_close("a"), "finalized"); + assert!(ws.has_pending_close("b"), "still pending"); + }); + } + + #[gpui::test] + fn close_now_flow_clears_deadline_and_kills(cx: &mut gpui::TestAppContext) { + let data = workspace_data(hsplit(vec![term("a"), term("b")])); + let workspace = cx.new(|_cx| Workspace::new(data)); + + workspace.update(cx, |ws: &mut Workspace, cx| { + let mut fm = FocusManager::new(); + let killed = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { killed: killed.clone() }; + let terminals = empty_registry(); + let deadlines = empty_deadlines(); + + ws.begin_soft_close(&mut fm, "p1", &[0], "a", "toast-a", cx); + deadlines + .lock() + .insert("a".to_string(), Instant::now() + Duration::from_secs(60)); + + close_now_flow(&deadlines, ws, &backend, &terminals, "a", cx); + + assert!(!deadlines.lock().contains_key("a"), "deadline cleared"); + assert!(!ws.has_pending_close("a"), "pending finalized"); + assert_eq!(&*killed.lock(), &vec!["a".to_string()], "PTY killed"); + }); + } + + #[gpui::test] + fn undo_flow_clears_deadline(cx: &mut gpui::TestAppContext) { + let data = workspace_data(hsplit(vec![term("a"), term("b")])); + let workspace = cx.new(|_cx| Workspace::new(data)); + + workspace.update(cx, |ws: &mut Workspace, cx| { + let mut fm = FocusManager::new(); + let terminals = empty_registry(); + let deadlines = empty_deadlines(); + + ws.begin_soft_close(&mut fm, "p1", &[0], "a", "toast-a", cx); + deadlines + .lock() + .insert("a".to_string(), Instant::now() + Duration::from_secs(60)); + + // Empty registry → PTY reads as dead, so nothing is restored, but the + // deadline is always cleared and the pending record dropped. + undo_soft_close_flow(&deadlines, ws, &mut fm, &terminals, "a", cx); + + assert!(!deadlines.lock().contains_key("a"), "deadline cleared"); + assert!(!ws.has_pending_close("a"), "pending dropped"); + }); + } } diff --git a/crates/okena-workspace/src/actions/terminal.rs b/crates/okena-workspace/src/actions/terminal.rs index c77d1bcd7..ccf3b0a2e 100644 --- a/crates/okena-workspace/src/actions/terminal.rs +++ b/crates/okena-workspace/src/actions/terminal.rs @@ -3,8 +3,8 @@ //! Actions for managing individual terminals within projects. use okena_terminal::shell_config::ShellType; +use crate::context::WorkspaceCx; use crate::state::{LayoutNode, Workspace}; -use gpui::*; impl Workspace { /// Set terminal ID at a layout path @@ -13,7 +13,7 @@ impl Workspace { project_id: &str, path: &[usize], terminal_id: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { self.with_project(project_id, cx, |project| { if let Some(ref mut layout) = project.layout @@ -26,13 +26,29 @@ impl Workspace { }); } + /// Clear the terminal id at a layout path (back to uninitialized) so a + /// subsequent `spawn_uninitialized_terminals` re-materializes it. Used by + /// shell-switch: kill the old PTY, clear the id, then respawn the node with + /// its new shell. + pub fn clear_terminal_id(&mut self, project_id: &str, path: &[usize], cx: &mut impl WorkspaceCx) { + self.with_project(project_id, cx, |project| { + if let Some(ref mut layout) = project.layout + && let Some(node) = layout.get_at_path_mut(path) + && let LayoutNode::Terminal { terminal_id: id, .. } = node { + *id = None; + return true; + } + false + }); + } + /// Set shell type for a terminal at a layout path pub fn set_terminal_shell( &mut self, project_id: &str, path: &[usize], shell_type: ShellType, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Terminal { shell_type: st, .. } = node { @@ -59,7 +75,7 @@ impl Workspace { project_id: &str, terminal_id: &str, new_name: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let terminal_id = terminal_id.to_string(); self.with_project(project_id, cx, |project| { @@ -75,7 +91,7 @@ impl Workspace { project_id: &str, terminal_id: &str, hidden: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let terminal_id = terminal_id.to_string(); self.with_project(project_id, cx, |project| { @@ -85,7 +101,7 @@ impl Workspace { } /// Restore (un-minimize) a terminal at a path - pub fn restore_terminal(&mut self, project_id: &str, path: &[usize], cx: &mut Context) { + pub fn restore_terminal(&mut self, project_id: &str, path: &[usize], cx: &mut impl WorkspaceCx) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Terminal { minimized, .. } = node { *minimized = false; @@ -101,7 +117,7 @@ impl Workspace { &mut self, project_id: &str, terminal_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) && let Some(ref mut layout) = project.layout @@ -131,7 +147,7 @@ impl Workspace { &mut self, project_id: &str, path: &[usize], - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> bool { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Terminal { terminal_id: Some(_), detached, .. } = node @@ -145,7 +161,7 @@ impl Workspace { /// Re-attach a detached terminal back to its original location. /// Scans all project layouts to find the terminal and clear the detached flag. - pub fn attach_terminal(&mut self, terminal_id: &str, cx: &mut Context) { + pub fn attach_terminal(&mut self, terminal_id: &str, cx: &mut impl WorkspaceCx) { for project in &mut self.data.projects { if let Some(ref mut layout) = project.layout && let Some(path) = layout.find_terminal_path(terminal_id) { @@ -192,7 +208,7 @@ impl Workspace { project_id: &str, path: &[usize], zoom: f32, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let clamped = zoom.clamp(0.5, 3.0); self.with_layout_node(project_id, path, cx, |node| { diff --git a/crates/okena-workspace/src/actions/worktree.rs b/crates/okena-workspace/src/actions/worktree.rs index b04836664..fc8af8d24 100644 --- a/crates/okena-workspace/src/actions/worktree.rs +++ b/crates/okena-workspace/src/actions/worktree.rs @@ -4,11 +4,11 @@ //! worktree projects, plus worktree-specific properties and ordering. use okena_core::theme::FolderColor; +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::hooks; use crate::persistence::HooksConfig; -use crate::state::{LayoutNode, ProjectData, Workspace, WindowId}; -use gpui::*; +use crate::state::{LayoutNode, PendingWorktreeClose, ProjectData, Workspace, WindowId}; use std::collections::HashMap; impl Workspace { @@ -20,12 +20,12 @@ impl Workspace { /// viewport model, hidden state IS persisted -- the bump is unconditional, /// even for ids that do not currently match a project. Unknown extra ids /// are a silent no-op (close-race contract inherited from `toggle_hidden`). - pub fn toggle_worktree_visibility(&mut self, window_id: WindowId, project_id: &str, cx: &mut Context) { + pub fn toggle_worktree_visibility(&mut self, window_id: WindowId, project_id: &str, cx: &mut impl WorkspaceCx) { self.toggle_hidden(window_id, project_id, cx); } /// Set or clear the color override for a worktree project - pub fn set_worktree_color_override(&mut self, project_id: &str, color: Option, cx: &mut Context) { + pub fn set_worktree_color_override(&mut self, project_id: &str, color: Option, cx: &mut impl WorkspaceCx) { self.with_project(project_id, cx, |project| { if let Some(ref mut wt) = project.worktree_info { wt.color_override = color; @@ -37,7 +37,7 @@ impl Workspace { } /// Reorder a worktree within its parent's worktree_ids list - pub fn reorder_worktree(&mut self, parent_id: &str, worktree_id: &str, new_index: usize, cx: &mut Context) { + pub fn reorder_worktree(&mut self, parent_id: &str, worktree_id: &str, new_index: usize, cx: &mut impl WorkspaceCx) { if let Some(parent) = self.data.projects.iter_mut().find(|p| p.id == parent_id) && let Some(current_index) = parent.worktree_ids.iter().position(|id| id == worktree_id) { let id = parent.worktree_ids.remove(current_index); @@ -79,7 +79,7 @@ impl Workspace { create_branch: bool, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Result { // Create the git worktree at the repo-level target path let target = std::path::PathBuf::from(worktree_path); @@ -114,7 +114,7 @@ impl Workspace { project_path: &str, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Result { self.register_worktree_project_inner(parent_project_id, branch, repo_path, worktree_path, project_path, true, global_hooks, window_id, cx) } @@ -135,7 +135,7 @@ impl Workspace { project_path: &str, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Result { self.register_worktree_project_inner(parent_project_id, branch, repo_path, worktree_path, project_path, false, global_hooks, window_id, cx) } @@ -151,7 +151,7 @@ impl Workspace { fire_hooks: bool, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> Result { // Get parent project info let parent = self.project(parent_project_id) @@ -219,6 +219,8 @@ impl Workspace { let folder = self.folder_for_project_or_parent(&id); let folder_id = folder.map(|f| f.id.as_str()); let folder_name = folder.map(|f| f.name.as_str()); + let runner = cx.hook_runner(); + let monitor = cx.hook_monitor(); let hook_results = hooks::fire_on_worktree_create( &new_project_hooks, &id, @@ -228,7 +230,8 @@ impl Workspace { folder_id, folder_name, global_hooks, - cx, + runner.as_ref(), + monitor.as_ref(), ); self.register_hook_results(hook_results, cx); } @@ -238,7 +241,7 @@ impl Workspace { /// Finalize a deferred worktree: set the layout from the parent and fire hooks. /// Called once the worktree directory exists on disk. - pub fn fire_worktree_hooks(&mut self, project_id: &str, global_hooks: &HooksConfig, cx: &mut Context) { + pub fn fire_worktree_hooks(&mut self, project_id: &str, global_hooks: &HooksConfig, cx: &mut impl WorkspaceCx) { let Some(project) = self.project(project_id) else { return }; let hooks_config = project.hooks.clone(); let name = project.name.clone(); @@ -262,6 +265,8 @@ impl Workspace { let folder = self.folder_for_project_or_parent(project_id); let folder_id = folder.map(|f| f.id.as_str()); let folder_name = folder.map(|f| f.name.as_str()); + let runner = cx.hook_runner(); + let monitor = cx.hook_monitor(); let hook_results = hooks::fire_on_worktree_create( &hooks_config, project_id, @@ -271,7 +276,8 @@ impl Workspace { folder_id, folder_name, global_hooks, - cx, + runner.as_ref(), + monitor.as_ref(), ); self.register_hook_results(hook_results, cx); } @@ -419,7 +425,7 @@ impl Workspace { } /// Remove a worktree project and its git worktree - pub fn remove_worktree_project(&mut self, focus_manager: &mut FocusManager, project_id: &str, force: bool, global_hooks: &HooksConfig, cx: &mut Context) -> Result<(), String> { + pub fn remove_worktree_project(&mut self, focus_manager: &mut FocusManager, project_id: &str, force: bool, global_hooks: &HooksConfig, cx: &mut impl WorkspaceCx) -> Result<(), String> { let project = self.project(project_id) .ok_or_else(|| "Project not found".to_string())?; @@ -445,7 +451,8 @@ impl Workspace { let branch = okena_git::get_current_branch(&worktree_path).unwrap_or_default(); // Fire on_worktree_close hook BEFORE removal so the hook has a valid CWD - hooks::fire_on_worktree_close(&project_hooks, project_id, &project_name, &project_path, &branch, hook_folder_id.as_deref(), hook_folder_name.as_deref(), global_hooks, cx); + let monitor = cx.hook_monitor(); + hooks::fire_on_worktree_close_with_services(&project_hooks, project_id, &project_name, &project_path, &branch, hook_folder_id.as_deref(), hook_folder_name.as_deref(), global_hooks, monitor.as_ref()); // Remove the git worktree okena_git::remove_worktree(&worktree_path, force) @@ -456,4 +463,273 @@ impl Workspace { Ok(()) } + + /// Close a worktree project: optionally stash/fetch/rebase/merge/push/ + /// delete-branch, then remove the worktree. Hook integration runs before + /// the merge step and before the actual removal. + /// + /// Daemon-side port of the client `CloseWorktreeDialog::execute` pipeline: + /// runs synchronously off the UI thread, so there is no `processing`/error + /// UI state — failures return `Err` with the same message text. The + /// stash-pop recovery on a failed merge step still runs; a failed recovery + /// only logs a warning, and the original step error is returned. + /// + /// Inputs are recomputed authoritatively from git/state (the client request + /// only carries the toggle booleans). + #[allow(clippy::too_many_arguments)] // cohesive close-pipeline toggle flags + pub fn close_worktree( + &mut self, + focus_manager: &mut FocusManager, + project_id: &str, + merge: bool, + stash: bool, + fetch: bool, + push: bool, + delete_branch: bool, + global_hooks: &HooksConfig, + cx: &mut impl WorkspaceCx, + ) -> Result<(), String> { + // Recompute the git-derived values authoritatively (don't trust the client). + let project = self.project(project_id) + .ok_or_else(|| "Project not found".to_string())?; + let project_name = project.name.clone(); + let project_path = project.path.clone(); + let project_hooks = project.hooks.clone(); + + let main_repo_path = self.worktree_parent_path(project_id).unwrap_or_default(); + let branch = okena_git::get_current_branch(std::path::Path::new(&project_path)).unwrap_or_default(); + let default_branch = okena_git::get_default_branch(std::path::Path::new(&main_repo_path)).unwrap_or_default(); + let is_dirty = okena_git::has_uncommitted_changes(std::path::Path::new(&project_path)); + + let merge_enabled = merge && (!is_dirty || stash) && !branch.is_empty() && !default_branch.is_empty(); + let stash_enabled = stash && is_dirty; + let fetch_enabled = fetch; + let push_enabled = push; + let delete_branch_enabled = delete_branch; + + let folder = self.folder_for_project_or_parent(project_id); + let folder_id = folder.map(|f| f.id.clone()); + let folder_name = folder.map(|f| f.name.clone()); + + let monitor = cx.hook_monitor(); + let runner = cx.hook_runner(); + + let mut did_stash = false; + + // Step 1: If merge enabled, run merge flow + if merge_enabled { + // Stash (if stash_enabled and is_dirty) + if stash_enabled { + if let Err(e) = okena_git::stash_changes(std::path::Path::new(&project_path)) { + return Err(format!("Stash failed: {}", e)); + } + did_stash = true; + } + + // Fetch (if fetch_enabled) + if fetch_enabled + && let Err(e) = okena_git::fetch_all(std::path::Path::new(&project_path)) { + if did_stash + && let Err(pop_err) = okena_git::stash_pop(std::path::Path::new(&project_path)) { + log::warn!( + "Failed to restore stashed changes for worktree '{}' at {} after fetch failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", + branch, project_path, pop_err + ); + } + return Err(format!("Fetch failed: {}", e)); + } + + // pre_merge hook (sync, headless — no PTY runner) + let pre_merge_result = hooks::fire_pre_merge( + &project_hooks, + global_hooks, + project_id, + &project_name, + &project_path, + &branch, + &default_branch, + &main_repo_path, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + None, + ); + + if let Err(e) = pre_merge_result { + if did_stash + && let Err(pop_err) = okena_git::stash_pop(std::path::Path::new(&project_path)) { + log::warn!( + "Failed to restore stashed changes for worktree '{}' at {} after pre_merge hook failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", + branch, project_path, pop_err + ); + } + return Err(format!("pre_merge hook failed: {}", e)); + } + + // Rebase + if let Err(e) = okena_git::rebase_onto(std::path::Path::new(&project_path), &default_branch) { + // Fire on_rebase_conflict hook + let error_msg = e.to_string(); + let (terminal_actions, hook_results) = hooks::fire_on_rebase_conflict( + &project_hooks, + global_hooks, + project_id, + &project_name, + &project_path, + &branch, + &default_branch, + &main_repo_path, + &error_msg, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + runner.as_ref(), + ); + for (cmd, env) in terminal_actions { + self.add_terminal_with_command(project_id, &cmd, &env, cx); + } + self.register_hook_results(hook_results, cx); + + if did_stash + && let Err(pop_err) = okena_git::stash_pop(std::path::Path::new(&project_path)) { + log::warn!( + "Failed to restore stashed changes for worktree '{}' at {} after rebase failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", + branch, project_path, pop_err + ); + } + return Err(format!("Rebase failed: {}", e)); + } + + // Merge (ff-only) in the main repo + if let Err(e) = okena_git::merge_branch(std::path::Path::new(&main_repo_path), &branch, true) { + if did_stash + && let Err(pop_err) = okena_git::stash_pop(std::path::Path::new(&project_path)) { + log::warn!( + "Failed to restore stashed changes for worktree '{}' at {} after merge failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", + branch, project_path, pop_err + ); + } + return Err(format!("Merge failed: {}", e)); + } + + // post_merge hook (async) + let _ = hooks::fire_post_merge( + &project_hooks, + global_hooks, + project_id, + &project_name, + &project_path, + &branch, + &default_branch, + &main_repo_path, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + runner.as_ref(), + ); + + // Push default branch (if push_enabled) + if push_enabled + && let Err(e) = okena_git::push_branch(std::path::Path::new(&main_repo_path), &default_branch) { + log::warn!("Push failed (continuing): {}", e); + } + + // Delete branch (if delete_branch_enabled) + if delete_branch_enabled { + if let Err(e) = okena_git::delete_local_branch(std::path::Path::new(&main_repo_path), &branch) { + log::warn!("Delete local branch failed (continuing): {}", e); + } + + if let Err(e) = okena_git::delete_remote_branch(std::path::Path::new(&main_repo_path), &branch) { + log::warn!("Delete remote branch failed (continuing): {}", e); + } + } + } + + let force_remove = is_dirty && !did_stash; + + // Step 2: before_worktree_remove hook + // If the hook exists and we have a runner, fire it as a visible PTY terminal + // and register a pending close — the actual removal happens when the hook exits. + // If no hook or no runner, proceed with immediate removal. + let has_before_remove_hook = + project_hooks.worktree.before_remove.is_some() || global_hooks.worktree.before_remove.is_some(); + + if has_before_remove_hook && runner.is_some() { + // Fire hook as visible PTY terminal and defer removal + let hook_results = hooks::fire_before_worktree_remove_async( + &project_hooks, + global_hooks, + project_id, + &project_name, + &project_path, + &branch, + &main_repo_path, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + runner.as_ref(), + ); + + let pending_terminal_id = hook_results.first().map(|r| r.terminal_id.clone()); + + if let Some(hook_terminal_id) = pending_terminal_id { + self.register_hook_results(hook_results, cx); + + // Register pending close — PTY exit handler will complete it + self.register_pending_worktree_close(PendingWorktreeClose { + project_id: project_id.to_string(), + hook_terminal_id, + branch: branch.clone(), + main_repo_path: main_repo_path.clone(), + }); + Ok(()) + } else { + // Hook terminal failed to spawn — abort, don't remove + Err("before_worktree_remove hook failed to start".to_string()) + } + } else { + // No hook or no runner — run headlessly then remove immediately + if has_before_remove_hook + && let Err(e) = hooks::fire_before_worktree_remove( + &project_hooks, + global_hooks, + project_id, + &project_name, + &project_path, + &branch, + &main_repo_path, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + None, + ) { + return Err(format!("before_worktree_remove hook failed: {}", e)); + } + + // Fire on_dirty_worktree_close hook when closing dirty worktree without stash + if force_remove { + let (terminal_actions, hook_results) = hooks::fire_on_dirty_worktree_close( + &project_hooks, + global_hooks, + project_id, + &project_name, + &project_path, + &branch, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + runner.as_ref(), + ); + for (cmd, env) in terminal_actions { + self.add_terminal_with_command(project_id, &cmd, &env, cx); + } + self.register_hook_results(hook_results, cx); + } + + // remove_worktree_project fires on_worktree_close + removes the git + // worktree + deletes the project (which fires on_project_close). + self.remove_worktree_project(focus_manager, project_id, force_remove, global_hooks, cx) + } + } } diff --git a/crates/okena-workspace/src/claude_env.rs b/crates/okena-workspace/src/claude_env.rs new file mode 100644 index 000000000..53ad35584 --- /dev/null +++ b/crates/okena-workspace/src/claude_env.rs @@ -0,0 +1,176 @@ +//! GPUI-free Claude config-dir resolution + the per-PTY `CLAUDE_CONFIG_DIR` +//! environment override. +//! +//! Both the desktop GUI (`okena-app`) and the headless daemon +//! (`okena-daemon-core`) need to push the right `CLAUDE_CONFIG_DIR` into the PTYs +//! they spawn so the `claude` CLI inside Okena terminals reads the per-profile +//! account. The GUI used to resolve the dir through the gpui extension registry +//! (`okena-ext-claude::resolve_claude_dir`, which reads the `ExtensionSettingsStore` +//! global). That global is just a thin wrapper over the **gpui-free** +//! [`AppSettings::extension_settings`](crate::settings::AppSettings) map — so the +//! same three-tier resolution can be done without gpui here, against the +//! settings the daemon already owns. +//! +//! Keeping this logic in `okena-workspace` (which both callers depend on, and +//! which builds gpui-free with `default-features = false`) lets the daemon set +//! `CLAUDE_CONFIG_DIR` on its own `PtyManager` without pulling gpui in. + +use std::path::{Path, PathBuf}; + +use crate::settings::AppSettings; + +/// Expand a leading `~` / `~/` to the user's home directory. Mirrors the +/// expansion the GUI's `okena-ext-claude::usage::expand_tilde` did. +fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } else if path == "~" + && let Some(home) = dirs::home_dir() + { + return home; + } + PathBuf::from(path) +} + +/// Return the expanded path only if it exists on disk (the GUI fell back to the +/// next precedence tier when a configured dir was missing). An empty string is +/// treated as unset. +fn existing_path(path: &str, source: &str) -> Option { + if path.is_empty() { + return None; + } + let expanded = expand_tilde(path); + if expanded.exists() { + Some(expanded) + } else { + log::warn!("[claude-env] {source} '{path}' does not exist, falling back"); + None + } +} + +/// Resolve the Claude config directory using the same three-tier precedence as +/// the GUI's `okena-ext-claude::resolve_claude_dir`, but gpui-free: +/// 1. `extension_settings."claude-code".config_dir` in settings.json +/// 2. `CLAUDE_CONFIG_DIR` environment variable (Claude CLI convention) +/// 3. `$HOME/.claude` (default) +/// +/// The only difference from the gpui version is tier 1: instead of reading the +/// `ExtensionSettingsStore` gpui global, it reads the identical +/// [`AppSettings::extension_settings`] map directly. +pub fn resolve_claude_dir(settings: &AppSettings) -> PathBuf { + if let Some(blob) = settings.extension_settings.get("claude-code") + && let Some(dir) = blob.get("config_dir").and_then(|v| v.as_str()) + && let Some(expanded) = existing_path(dir, "settings config_dir") + { + return expanded; + } + if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") + && let Some(expanded) = existing_path(&dir, "CLAUDE_CONFIG_DIR") + { + return expanded; + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") +} + +/// Whether `claude_dir` resolves to the canonical default `$HOME/.claude`. +fn is_default_claude_dir(claude_dir: &Path) -> bool { + let Some(home) = dirs::home_dir() else { + return false; + }; + let default_dir = home.join(".claude"); + let canonical_default = default_dir.canonicalize().unwrap_or(default_dir); + let canonical_dir = claude_dir + .canonicalize() + .unwrap_or_else(|_| claude_dir.to_path_buf()); + canonical_dir == canonical_default +} + +/// Compute the per-PTY `CLAUDE_CONFIG_DIR` override list to hand to +/// `PtyManager::set_extra_env`. Pure, gpui-free logic. +/// +/// * `claude_dir` — the resolved Claude config dir (see [`resolve_claude_dir`]). +/// * `multi_profile` — whether more than one Okena profile exists. +/// * `parent_has_claude_config_dir` — whether the process that launched Okena +/// already had `CLAUDE_CONFIG_DIR` exported. +pub fn claude_pty_extra_env( + claude_dir: &Path, + multi_profile: bool, + parent_has_claude_config_dir: bool, +) -> Vec<(String, Option)> { + // Default `~/.claude`: actively remove CLAUDE_CONFIG_DIR from the PTY rather + // than just leaving it unset. This keeps Claude Code on its canonical Keychain + // service (an explicit CLAUDE_CONFIG_DIR=~/.claude makes it create a suffixed + // duplicate) *and* prevents a stale value — e.g. one exported in the shell + // that launched Okena and inherited by our process — from leaking into the + // terminal and silently pointing `claude` at the wrong account. + if is_default_claude_dir(claude_dir) { + return vec![("CLAUDE_CONFIG_DIR".to_string(), None)]; + } + + // Single-profile user who manages CLAUDE_CONFIG_DIR themselves: there's no + // profile boundary to enforce, so leave their exported value untouched. + if !multi_profile && parent_has_claude_config_dir { + return Vec::new(); + } + + vec![( + "CLAUDE_CONFIG_DIR".to_string(), + Some(claude_dir.to_string_lossy().into_owned()), + )] +} + +/// Resolve the Claude dir + profile context and compute the `CLAUDE_CONFIG_DIR` +/// PTY override in one gpui-free call. Shared by the GUI's `sync_claude_pty_env` +/// and the daemon's PTY-manager wiring so both apply identical isolation. +pub fn claude_pty_env_for_settings(settings: &AppSettings) -> Vec<(String, Option)> { + let multi_profile = okena_core::profiles::all_profiles() + .map(|p| p.len() > 1) + .unwrap_or(false); + let claude_dir = resolve_claude_dir(settings); + claude_pty_extra_env( + &claude_dir, + multi_profile, + std::env::var("CLAUDE_CONFIG_DIR").is_ok(), + ) +} + +#[cfg(test)] +mod tests { + use super::claude_pty_extra_env; + + #[test] + fn default_claude_dir_unsets_pty_env() { + let default_dir = dirs::home_dir().unwrap().join(".claude"); + + // The default dir must produce an explicit removal so a stale inherited + // CLAUDE_CONFIG_DIR can't leak in — regardless of profile count or whether + // the parent process happened to have the var set. + for &(multi, parent) in &[(false, false), (true, false), (false, true), (true, true)] { + let env = claude_pty_extra_env(&default_dir, multi, parent); + assert_eq!(env.len(), 1, "multi={multi} parent={parent}"); + assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR"); + assert_eq!(env[0].1, None, "default dir must unset, not set"); + } + } + + #[test] + fn single_profile_keeps_parent_claude_config_dir() { + let custom_dir = std::env::temp_dir().join("okena-custom-claude-dir"); + + assert!(claude_pty_extra_env(&custom_dir, false, true).is_empty()); + } + + #[test] + fn custom_claude_dir_is_exported_to_pty() { + let custom_dir = std::env::temp_dir().join("okena-custom-claude-dir"); + let env = claude_pty_extra_env(&custom_dir, true, true); + + assert_eq!(env.len(), 1); + assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR"); + assert_eq!(env[0].1.as_deref(), Some(custom_dir.to_string_lossy().as_ref())); + } +} diff --git a/crates/okena-workspace/src/context.rs b/crates/okena-workspace/src/context.rs new file mode 100644 index 000000000..ccb8caf1a --- /dev/null +++ b/crates/okena-workspace/src/context.rs @@ -0,0 +1,78 @@ +//! Reactor abstraction for the workspace action/state layer. +//! +//! `Workspace` mutations need exactly two reactor capabilities from their +//! context: mark the entity dirty (so the autosave + `state_version` observers +//! and any cached views re-evaluate), and invalidate cached views so structural +//! changes repaint. Today the only implementer is GPUI's [`Context`]; the +//! headless daemon will add a second, GPUI-free implementer backed by a plain +//! tokio reactor (notify = fire registered observers, refresh_views = no-op). +//! +//! Action methods take `cx: &mut impl WorkspaceCx` instead of +//! `&mut Context`. This is a non-breaking change for existing callers: +//! they pass `&mut Context`, which satisfies the trait via the impl +//! below. Once every action is generic, the daemon can drive the same +//! `execute_action` code path with no GPUI in scope — the seam that makes the +//! GPUI-free daemon a swap behind the protocol rather than a rewrite. + +#[cfg(feature = "gpui")] +use crate::state::Workspace; +#[cfg(feature = "gpui")] +use gpui::Context; + +/// The reactor capabilities the workspace action/state layer needs. +/// +/// Deliberately minimal: anything heavier (spawning tasks, reading other +/// entities) lives in the app/observer layer, not in the action layer, so it +/// does not belong here. +pub trait WorkspaceCx { + /// Mark the workspace dirty. Fires the change observers (autosave debounce, + /// `state_version` bump) and flags cached views for re-evaluation. + /// + /// GPUI: `Context::notify`. Daemon: invoke registered change callbacks. + fn notify(&mut self); + + /// Invalidate cached views so a structural data change actually repaints. + /// + /// GPUI: `App::refresh_windows` (bypasses `.cached()` view wrappers). + /// Daemon: no-op — there are no local views to refresh. + fn refresh_views(&mut self); + + /// The hook runner service (creates PTY-backed hook terminals), if present. + /// + /// Returns a cheap owned clone (the runner is `Arc`-backed) so callers can + /// fetch it without holding a borrow on `self` across the subsequent + /// `notify`/`refresh_views`. + /// + /// GPUI: the `HookRunner` global. Daemon: the daemon's runner, or `None`. + fn hook_runner(&self) -> Option; + + /// The hook monitor service (tracks in-flight/completed hook runs), if + /// present. Returns a cheap owned clone (`Arc`-backed). + /// + /// GPUI: the `HookMonitor` global. Daemon: the daemon's monitor, or `None`. + fn hook_monitor(&self) -> Option; +} + +#[cfg(feature = "gpui")] +impl WorkspaceCx for Context<'_, Workspace> { + fn notify(&mut self) { + // The inherent `Context::notify` shadows this trait method during + // method resolution (inherent methods win), so this is a direct call + // into GPUI — not recursion back into the trait impl. + self.notify(); + } + + fn refresh_views(&mut self) { + // Reaches `App::refresh_windows` through `Context`'s `DerefMut`. + self.refresh_windows(); + } + + fn hook_runner(&self) -> Option { + // Reaches `App::try_global` through `Context`'s `Deref`. + self.try_global::().cloned() + } + + fn hook_monitor(&self) -> Option { + self.try_global::().cloned() + } +} diff --git a/crates/okena-workspace/src/lib.rs b/crates/okena-workspace/src/lib.rs index 596d06e69..683b7cd69 100644 --- a/crates/okena-workspace/src/lib.rs +++ b/crates/okena-workspace/src/lib.rs @@ -2,6 +2,8 @@ pub mod access_history; pub mod actions; +pub mod claude_env; +pub mod context; pub mod focus; pub mod hook_monitor; pub mod hooks; @@ -9,7 +11,9 @@ pub mod lifecycle; pub mod persistence; pub mod remote_apply; pub mod remote_sync; +#[cfg(feature = "gpui")] pub mod request_broker; +#[cfg(feature = "gpui")] pub mod requests; pub mod sessions; pub mod sidebar_controller; @@ -17,4 +21,5 @@ pub mod settings; pub mod state; pub mod toast; pub mod visibility; +#[cfg(feature = "gpui")] pub mod worktree_sync; diff --git a/crates/okena-workspace/src/persistence.rs b/crates/okena-workspace/src/persistence.rs index 13f903c94..e2df6265e 100644 --- a/crates/okena-workspace/src/persistence.rs +++ b/crates/okena-workspace/src/persistence.rs @@ -69,14 +69,22 @@ pub fn get_workspace_path() -> PathBuf { } } +/// Path to the instance lock file for the active profile (falling back to the +/// legacy flat layout). Shared by `acquire_instance_lock` and any teardown that +/// needs to remove the lock without holding a `LockGuard` (e.g. the hard-exit +/// daemon shutdown path), so both agree on the location. +pub fn instance_lock_path() -> PathBuf { + okena_core::profiles::try_current() + .map(|p| p.lock_path()) + .unwrap_or_else(|| get_config_dir().join("okena.lock")) +} + /// Acquire a lock file to prevent multiple instances from running simultaneously. /// Returns a held `LockGuard` that releases the lock on drop. /// If another instance is already running, returns an error with its PID. pub fn acquire_instance_lock() -> Result { let _slow = okena_core::timing::SlowGuard::new("acquire_instance_lock"); - let lock_path = okena_core::profiles::try_current() - .map(|p| p.lock_path()) - .unwrap_or_else(|| get_config_dir().join("okena.lock")); + let lock_path = instance_lock_path(); if let Some(parent) = lock_path.parent() { std::fs::create_dir_all(parent)?; @@ -445,6 +453,248 @@ pub fn save_workspace(data: &WorkspaceData) -> Result<()> { Ok(()) } +/// Schema version for the client-owned window-layout file. +/// +/// This file is a pure PRESENTATION cache (which windows are open, their OS +/// bounds, per-window visibility). It must NEVER be migrated destructively: +/// dropping an unknown field degrades to a sensible default, but wiping user +/// state (e.g. per-window `hidden_project_ids`) on upgrade is unacceptable. +/// Schema evolution is handled by `#[serde(default)]` on the fields; +/// [`migrate_window_layout`] only performs forward-compatible, non-destructive +/// transforms and stamps the version. +/// +/// History: v2 once reset per-window visibility to "recover" a supposed +/// compounded hidden-set bug. That recovery was wrong — most users legitimately +/// hide most projects per window, so it just discarded their curation on +/// upgrade — and has been removed. The actual fix lives in +/// `apply_initial_remote_project_visibility` (the daemon's single-window +/// `show_in_overview` no longer drives client visibility). The version is kept +/// only as a forward-compat marker for genuine future schema changes. +pub const WINDOW_LAYOUT_VERSION: u32 = 2; + +/// Process-level mutex serializing window-layout saves (mirrors WORKSPACE_LOCK +/// — the debounced client save can fire concurrently during a window drag). +static WINDOW_LAYOUT_LOCK: Mutex<()> = Mutex::new(()); + +/// Client-owned window layout: the per-window PRESENTATION state (which windows +/// are open, their OS bounds, per-window viewport) that the desktop GUI persists +/// locally — separate from the daemon-owned `workspace.json`. Restored on +/// startup so multiple windows + their bounds survive a relaunch. +/// +/// The GUI is a thin client of the daemon: the daemon owns project DATA (and is +/// the single writer of workspace.json), while window geometry/visibility is +/// client presentation and must not be round-tripped through the daemon — doing +/// so clobbered multi-window state (see the `quit` handler in src/main.rs). +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct ClientWindowLayout { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub main_window: WindowState, + #[serde(default)] + pub extra_windows: Vec, +} + +/// Path to the client-owned window-layout file (alongside settings.json). +pub fn get_window_layout_path() -> PathBuf { + get_config_dir().join("window-layout.json") +} + +/// Load the client window layout, or `None` if absent/unreadable/corrupt (the +/// app then falls back to a single default-bounds main window). Migrates older +/// schema versions in place (see [`WINDOW_LAYOUT_VERSION`]). +pub fn load_window_layout() -> Option { + let path = get_window_layout_path(); + let legacy_layout = load_workspace_window_layout(); + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(_) => return legacy_layout, + }; + match serde_json::from_str::(&content) { + Ok(mut layout) => { + migrate_window_layout(&mut layout); + if let Some(legacy_layout) = legacy_layout { + merge_missing_window_layout_state(&mut layout, legacy_layout); + } + Some(layout) + } + Err(e) => { + log::warn!("Failed to parse window-layout.json: {e}; ignoring."); + legacy_layout + } + } +} + +/// Migrate a loaded window layout to the current schema version. +/// +/// NON-DESTRUCTIVE by contract: this file is a presentation cache, so migration +/// may only add or forward-transform state — never clear user choices like +/// per-window `hidden_project_ids` or `folder_filter`. There is no field +/// transform to apply today, so this just stamps the current version. (See +/// [`WINDOW_LAYOUT_VERSION`] for why the old v1 → v2 visibility wipe was a +/// regression and is gone.) +fn migrate_window_layout(layout: &mut ClientWindowLayout) { + prefix_local_daemon_window_refs(layout); + layout.version = WINDOW_LAYOUT_VERSION; +} + +/// Best-effort upgrade bridge from the pre-daemon-client layout shape. +/// +/// Before `window-layout.json` existed, the main/extra window presentation lived +/// inside `workspace.json` and referenced local project/folder ids directly +/// (`p1`, `f1`). The GUI now mirrors the local daemon as +/// `remote:local-daemon:p1`, while the daemon continues to persist the raw +/// local ids in `workspace.json`. On first launch after the daemon-client +/// upgrade, use the old workspace window state as the initial client layout and +/// rewrite its local refs to the mirror ids. +fn load_workspace_window_layout() -> Option { + let content = std::fs::read_to_string(get_workspace_path()).ok()?; + let migrated = match migrate_legacy_json(&content) { + Ok(content) => content, + Err(e) => { + log::warn!("Failed to migrate workspace window layout JSON: {e}; ignoring."); + content + } + }; + let data = match serde_json::from_str::(&migrated) { + Ok(data) => migrate_workspace(data), + Err(e) => { + log::warn!("Failed to parse workspace window layout fallback: {e}; ignoring."); + return None; + } + }; + + let mut layout = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: data.main_window, + extra_windows: data.extra_windows, + }; + prefix_local_daemon_window_refs(&mut layout); + Some(layout) +} + +fn merge_missing_window_layout_state(target: &mut ClientWindowLayout, source: ClientWindowLayout) { + merge_missing_window_state(&mut target.main_window, source.main_window); + + for source_extra in source.extra_windows { + if let Some(target_extra) = target + .extra_windows + .iter_mut() + .find(|extra| extra.id == source_extra.id) + { + merge_missing_window_state(target_extra, source_extra); + } else if target.extra_windows.is_empty() && window_state_has_presentation(&source_extra) { + target.extra_windows.push(source_extra); + } + } +} + +fn merge_missing_window_state(target: &mut WindowState, source: WindowState) { + if target.hidden_project_ids.is_empty() { + target.hidden_project_ids = source.hidden_project_ids; + } + if target.folder_filter.is_none() { + target.folder_filter = source.folder_filter; + } + if target.project_widths.is_empty() { + target.project_widths = source.project_widths; + } + if target.folder_collapsed.is_empty() { + target.folder_collapsed = source.folder_collapsed; + } + if target.project_layout == Default::default() { + target.project_layout = source.project_layout; + } + if target.project_sort_mode == Default::default() { + target.project_sort_mode = source.project_sort_mode; + } + if !target.show_attention_section { + target.show_attention_section = source.show_attention_section; + } + if target.sidebar_open.is_none() { + target.sidebar_open = source.sidebar_open; + } +} + +fn window_state_has_presentation(window: &WindowState) -> bool { + !window.hidden_project_ids.is_empty() + || window.folder_filter.is_some() + || !window.project_widths.is_empty() + || !window.folder_collapsed.is_empty() + || window.os_bounds.is_some() + || window.sidebar_open.is_some() + || window.project_layout != Default::default() + || window.project_sort_mode != Default::default() + || window.show_attention_section +} + +fn prefix_local_daemon_window_refs(layout: &mut ClientWindowLayout) { + prefix_local_daemon_window_state_refs(&mut layout.main_window); + for extra in &mut layout.extra_windows { + prefix_local_daemon_window_state_refs(extra); + } +} + +fn prefix_local_daemon_window_state_refs(window: &mut WindowState) { + window.hidden_project_ids = window + .hidden_project_ids + .drain() + .map(|id| prefix_local_daemon_id(&id)) + .collect(); + window.project_widths = window + .project_widths + .drain() + .map(|(id, width)| (prefix_local_daemon_id(&id), width)) + .collect(); + window.folder_collapsed = window + .folder_collapsed + .drain() + .map(|(id, collapsed)| (prefix_local_daemon_id(&id), collapsed)) + .collect(); + if let Some(filter) = window.folder_filter.take() { + window.folder_filter = Some(prefix_local_daemon_id(&filter)); + } +} + +fn prefix_local_daemon_id(id: &str) -> String { + if id.starts_with("remote:") { + id.to_string() + } else { + format!( + "remote:{}:{}", + okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, + id + ) + } +} + +/// Save the client window layout (main + extra windows) atomically +/// (tmp + fsync + rename). Extracts only the presentation state from `data`; +/// never touches workspace.json. Best-effort — a failure just means stale +/// window restore. +pub fn save_window_layout(data: &WorkspaceData) -> Result<()> { + let _guard = WINDOW_LAYOUT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let layout = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: data.main_window.clone(), + extra_windows: data.extra_windows.clone(), + }; + let path = get_window_layout_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(&layout)?; + let tmp_path = path.with_extension("json.tmp"); + { + use std::io::Write; + let mut f = std::fs::File::create(&tmp_path)?; + f.write_all(json.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp_path, &path)?; + Ok(()) +} + /// Pre-deserialization JSON migration: fold legacy v0/v1 fields into /// `main_window` before the typed parse drops them. /// @@ -662,6 +912,240 @@ mod tests { use super::*; use crate::state::{FolderData, SplitDirection}; + #[test] + fn client_window_layout_round_trips() { + let mut extra = WindowState::default(); + extra.hidden_project_ids.insert("remote:local-daemon:p1".to_string()); + extra.os_bounds = Some(crate::state::WindowBounds { + origin_x: 100.0, + origin_y: 200.0, + width: 1280.0, + height: 720.0, + }); + let layout = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: WindowState::default(), + extra_windows: vec![extra], + }; + let json = serde_json::to_string(&layout).unwrap(); + let parsed: ClientWindowLayout = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.version, WINDOW_LAYOUT_VERSION); + assert_eq!(parsed.extra_windows.len(), 1); + let b = parsed.extra_windows[0].os_bounds.unwrap(); + assert_eq!(b.width, 1280.0); + assert!(parsed.extra_windows[0] + .hidden_project_ids + .contains("remote:local-daemon:p1")); + + // An empty/absent file shape parses to defaults. + let empty: ClientWindowLayout = serde_json::from_str("{}").unwrap(); + assert_eq!(empty.version, 0); + assert!(empty.extra_windows.is_empty()); + } + + #[test] + fn migrate_window_layout_preserves_visibility() { + // Migration is NON-DESTRUCTIVE: an old (v1) layout keeps its per-window + // hidden sets + folder filters and is merely stamped to the current + // version. Wiping them on upgrade was the regression this guards against + // (most users legitimately hide most projects per window). + let mut main = WindowState::default(); + main.hidden_project_ids.insert("remote:local-daemon:p1".to_string()); + main.folder_filter = Some("f1".to_string()); + let mut extra = WindowState::default(); + extra.hidden_project_ids.insert("remote:local-daemon:p2".to_string()); + let mut layout = ClientWindowLayout { + version: 1, + main_window: main, + extra_windows: vec![extra], + }; + + migrate_window_layout(&mut layout); + + assert_eq!(layout.version, WINDOW_LAYOUT_VERSION); + assert!(layout.main_window.hidden_project_ids.contains("remote:local-daemon:p1")); + assert_eq!( + layout.main_window.folder_filter.as_deref(), + Some("remote:local-daemon:f1"), + ); + assert!(layout.extra_windows[0].hidden_project_ids.contains("remote:local-daemon:p2")); + + // A current-version layout is likewise left untouched. + let mut keep = WindowState::default(); + keep.hidden_project_ids.insert("remote:local-daemon:p3".to_string()); + let mut current = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: keep, + extra_windows: vec![], + }; + migrate_window_layout(&mut current); + assert!(current.main_window.hidden_project_ids.contains("remote:local-daemon:p3")); + } + + #[test] + fn migrate_window_layout_prefixes_legacy_local_refs_for_daemon_mirror() { + let mut main = WindowState::default(); + main.hidden_project_ids.insert("p1".to_string()); + main.project_widths.insert("p2".to_string(), 0.4); + main.folder_filter = Some("f1".to_string()); + main.folder_collapsed.insert("f2".to_string(), true); + main.hidden_project_ids.insert("remote:server:p3".to_string()); + let mut layout = ClientWindowLayout { + version: 1, + main_window: main, + extra_windows: Vec::new(), + }; + + migrate_window_layout(&mut layout); + + assert!(layout + .main_window + .hidden_project_ids + .contains("remote:local-daemon:p1")); + assert!(layout + .main_window + .hidden_project_ids + .contains("remote:server:p3")); + assert!(!layout.main_window.hidden_project_ids.contains("p1")); + assert_eq!( + layout + .main_window + .project_widths + .get("remote:local-daemon:p2") + .copied(), + Some(0.4), + ); + assert_eq!( + layout.main_window.folder_filter.as_deref(), + Some("remote:local-daemon:f1"), + ); + assert_eq!( + layout + .main_window + .folder_collapsed + .get("remote:local-daemon:f2") + .copied(), + Some(true), + ); + } + + #[test] + fn merge_missing_window_layout_state_recovers_hidden_ids_from_workspace_fallback() { + // Covers users who already launched one bad daemon-client build: the + // newly-created window-layout.json can be empty, but workspace.json + // still carries the old local hidden ids. Merge those ids in and map + // them to the local-daemon mirror instead of treating every project as + // visible forever. + let mut source_main = WindowState::default(); + source_main.hidden_project_ids.insert("p1".to_string()); + source_main.project_widths.insert("p1".to_string(), 0.5); + source_main.folder_filter = Some("f1".to_string()); + let mut source = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: source_main, + extra_windows: Vec::new(), + }; + prefix_local_daemon_window_refs(&mut source); + + let mut target = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: WindowState::default(), + extra_windows: Vec::new(), + }; + + merge_missing_window_layout_state(&mut target, source); + + assert!(target + .main_window + .hidden_project_ids + .contains("remote:local-daemon:p1")); + assert_eq!( + target + .main_window + .project_widths + .get("remote:local-daemon:p1") + .copied(), + Some(0.5), + ); + assert_eq!( + target.main_window.folder_filter.as_deref(), + Some("remote:local-daemon:f1"), + ); + } + + #[test] + fn merge_missing_window_layout_state_does_not_override_existing_client_choices() { + let mut target_main = WindowState::default(); + target_main + .hidden_project_ids + .insert("remote:local-daemon:client-hidden".to_string()); + let mut source_main = WindowState::default(); + source_main + .hidden_project_ids + .insert("remote:local-daemon:workspace-hidden".to_string()); + + let mut target = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: target_main, + extra_windows: Vec::new(), + }; + let source = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: source_main, + extra_windows: Vec::new(), + }; + + merge_missing_window_layout_state(&mut target, source); + + assert!(target + .main_window + .hidden_project_ids + .contains("remote:local-daemon:client-hidden")); + assert!(!target + .main_window + .hidden_project_ids + .contains("remote:local-daemon:workspace-hidden")); + } + + #[test] + fn merge_missing_window_layout_state_does_not_append_stale_workspace_extras() { + let client_extra_id = + uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); + let workspace_extra_id = + uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + let target_extra = WindowState { + id: client_extra_id, + os_bounds: Some(crate::state::WindowBounds { + origin_x: 0.0, + origin_y: 0.0, + width: 1200.0, + height: 800.0, + }), + ..Default::default() + }; + let source_extra = WindowState { + id: workspace_extra_id, + os_bounds: target_extra.os_bounds, + ..Default::default() + }; + + let mut target = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: WindowState::default(), + extra_windows: vec![target_extra], + }; + let source = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: WindowState::default(), + extra_windows: vec![source_extra], + }; + + merge_missing_window_layout_state(&mut target, source); + + assert_eq!(target.extra_windows.len(), 1); + assert_eq!(target.extra_windows[0].id, client_extra_id); + } + fn make_project(id: &str) -> ProjectData { ProjectData { id: id.to_string(), diff --git a/crates/okena-workspace/src/remote_apply.rs b/crates/okena-workspace/src/remote_apply.rs index b617895d1..d8098ec67 100644 --- a/crates/okena-workspace/src/remote_apply.rs +++ b/crates/okena-workspace/src/remote_apply.rs @@ -15,7 +15,10 @@ use std::collections::{HashMap, HashSet}; use okena_core::api::StateResponse; use okena_transport::client::RemoteConnectionConfig; use okena_layout::LayoutNode; -use okena_state::{FolderData, HooksConfig, ProjectData, WindowId, WorkspaceData, WorktreeMetadata}; +use okena_state::{ + FolderData, HookTerminalEntry, HooksConfig, ProjectData, WindowId, WorkspaceData, + WorktreeMetadata, +}; use crate::remote_sync::RemoteSyncState; @@ -127,6 +130,19 @@ pub fn apply_remote_snapshot( let remote_host = Some(snap.config.host.clone()); let remote_git_status = api_project.git_status.clone(); + // Daemon-owned per-project data surfaced for rendering: pin + // marker, activity-sort timestamp, shell-picker selection, and + // the hook terminals shown in the service panel (keys prefixed + // like terminal_names so they match the prefixed layout ids). + let remote_hook_terminals: HashMap = api_project + .hook_terminals + .iter() + .map(|api| { + let (tid, entry) = HookTerminalEntry::from_api(api); + (format!("remote:{}:{}", conn_id, tid), entry) + }) + .collect(); + if let Some(existing) = data.projects.iter_mut().find(|p| p.id == prefixed_id) { existing.name = api_project.name.clone(); existing.path = api_project.path.clone(); @@ -152,6 +168,15 @@ pub fn apply_remote_snapshot( existing.worktree_ids = api_project.worktree_ids.iter() .map(|id| format!("remote:{}:{}", conn_id, id)) .collect(); + existing.pinned = api_project.pinned; + existing.last_activity_at = api_project.last_activity_at; + existing.default_shell = api_project.default_shell.clone(); + existing.hook_terminals = remote_hook_terminals; + // Per-project hooks are daemon-authoritative (it applies them on + // PTY spawn). The settings panel edits a separate input buffer and + // dispatches UpdateProjectHooks on close, so syncing here won't + // clobber an in-progress edit. + existing.hooks = HooksConfig::from_api(&api_project.hooks); // Don't overwrite show_in_overview — it's client-side state // (the user may have toggled visibility locally). } else { @@ -174,7 +199,6 @@ pub fn apply_remote_snapshot( &prefixed_id, &api_project.name, &api_project.path, - api_project.show_in_overview, ); data.projects.push(ProjectData { id: prefixed_id.clone(), @@ -186,14 +210,14 @@ pub fn apply_remote_snapshot( worktree_info, worktree_ids, folder_color: project_color, - hooks: HooksConfig::default(), + hooks: HooksConfig::from_api(&api_project.hooks), is_remote: true, connection_id: Some(conn_id_owned), service_terminals: HashMap::new(), - default_shell: None, - hook_terminals: HashMap::new(), - pinned: false, - last_activity_at: None, + default_shell: api_project.default_shell.clone(), + hook_terminals: remote_hook_terminals, + pinned: api_project.pinned, + last_activity_at: api_project.last_activity_at, }); } // Update the transient remote snapshot regardless of create/update path. @@ -228,22 +252,14 @@ pub fn apply_remote_snapshot( // Add new remote project_order entries data.project_order.extend(remote_order); } else { - // No state (disconnected/connecting) — remove materialized projects and folders + // No state (disconnected/connecting) — remove materialized projects + // and folders, but keep per-window presentation state. The same + // connection may reconnect with the same prefixed ids; scrubbing + // hidden_project_ids here would make every project visible again. + // Permanent removals still scrub below when a connection disappears + // from `snapshots`, and server-side deletions scrub via the stale + // project pass after a successful state snapshot. let prefix = format!("remote:{}:", conn_id); - let removed_project_ids: Vec = data.projects.iter() - .filter(|p| p.id.starts_with(&prefix)) - .map(|p| p.id.clone()) - .collect(); - let removed_folder_ids: Vec = data.folders.iter() - .filter(|f| f.id.starts_with(&prefix)) - .map(|f| f.id.clone()) - .collect(); - for project_id in removed_project_ids { - data.delete_project_scrub_all_windows(&project_id); - } - for folder_id in removed_folder_ids { - data.delete_folder_scrub_all_windows(&folder_id); - } data.projects.retain(|p| !p.id.starts_with(&prefix)); data.folders.retain(|f| !f.id.starts_with(&prefix)); data.project_order.retain(|id| !id.starts_with(&prefix)); @@ -326,9 +342,16 @@ pub fn apply_remote_snapshot( /// Apply one-shot per-window visibility for a freshly materialized remote /// project. When a local window issued the create, the spawn intent ("visible -/// in this window, hidden everywhere else") wins over the wire -/// `show_in_overview` flag; otherwise the wire flag is translated into -/// per-window hidden state on this first sync. +/// in this window, hidden everywhere else") is applied. Otherwise the project is +/// left visible in every window. +/// +/// Per-window project visibility is CLIENT-owned: each window's +/// `hidden_project_ids` is toggled locally (`toggle_project_overview_visibility`) +/// and persisted in window-layout.json. The daemon has a single synthetic main +/// window, so its wire `show_in_overview` must NOT drive client visibility — +/// re-applying the daemon's (frozen, single-window) hidden set on every +/// reconnect compounded across restarts until every project was hidden and the +/// main window came up empty. fn apply_initial_remote_project_visibility( data: &mut WorkspaceData, remote_sync: &mut RemoteSyncState, @@ -336,21 +359,19 @@ fn apply_initial_remote_project_visibility( prefixed_id: &str, name: &str, path: &str, - show_in_overview: bool, ) { if let Some(spawning_window) = remote_sync.take_project_visibility(connection_id, name, path) { data.add_project_hide_in_other_windows(prefixed_id, spawning_window); - return; - } - if !show_in_overview { - data.hide_project_in_all_windows(prefixed_id); } } #[cfg(test)] mod tests { use super::*; - use okena_core::api::{ApiFolder, ApiLayoutNode, ApiProject, StateResponse}; + use okena_core::api::{ + ApiFolder, ApiHookTerminalEntry, ApiHookTerminalStatus, ApiLayoutNode, ApiProject, + StateResponse, + }; use okena_core::theme::FolderColor; fn empty_data() -> WorkspaceData { @@ -376,6 +397,7 @@ mod tests { token_obtained_at: None, tls: false, pinned_cert_sha256: None, + local_endpoint: None, } } @@ -392,6 +414,11 @@ mod tests { services: Vec::new(), worktree_info: None, worktree_ids: Vec::new(), + pinned: false, + last_activity_at: None, + default_shell: None, + hook_terminals: Vec::new(), + hooks: Default::default(), } } @@ -449,6 +476,42 @@ mod tests { assert_eq!(rs.snapshot("remote:c1:a").unwrap().host.as_deref(), Some("c1.example.com")); } + #[test] + fn applies_pinned_activity_shell_and_prefixed_hook_terminals() { + let mut data = empty_data(); + let mut rs = RemoteSyncState::new(); + let mut p = api_project("a", Some(terminal("ta"))); + p.pinned = true; + p.last_activity_at = Some(1_700_000_000_000); + p.default_shell = Some(okena_core::shell::ShellType::Default); + p.hook_terminals = vec![ApiHookTerminalEntry { + terminal_id: "h1".into(), + label: "on_project_open".into(), + status: ApiHookTerminalStatus::Failed { exit_code: 3 }, + hook_type: "on_project_open".into(), + command: "make".into(), + cwd: "/srv/a".into(), + }]; + let snap = RemoteSnapshot { + config: config("c1"), + state: Some(state_with(vec![p], vec!["a".into()], vec![])), + }; + + apply_remote_snapshot(&mut data, &mut rs, &[snap], WindowId::Main); + + let proj = &data.projects[0]; + assert!(proj.pinned); + assert_eq!(proj.last_activity_at, Some(1_700_000_000_000)); + assert_eq!(proj.default_shell, Some(okena_core::shell::ShellType::Default)); + // Hook-terminal map key is prefixed like the layout terminal ids. + let entry = proj.hook_terminals.get("remote:c1:h1").expect("prefixed hook terminal"); + assert_eq!(entry.command, "make"); + assert!(matches!( + entry.status, + okena_state::HookTerminalStatus::Failed { exit_code: 3 } + )); + } + #[test] fn builds_prefixed_folders_from_server_order() { let mut data = empty_data(); @@ -562,6 +625,50 @@ mod tests { assert!(data.project_order.is_empty()); } + #[test] + fn transient_disconnect_preserves_per_window_visibility_for_reconnect() { + let mut data = empty_data(); + let extra = okena_state::WindowState::default(); + let extra_id = extra.id; + data.extra_windows = vec![extra]; + let mut rs = RemoteSyncState::new(); + + apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with(vec![api_project("a", None)], vec!["a".into()], vec![])), + }], WindowId::Main); + data.main_window.hidden_project_ids.insert("remote:c1:a".to_string()); + data.window_mut(WindowId::Extra(extra_id)).unwrap() + .hidden_project_ids + .insert("remote:c1:a".to_string()); + + apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { + config: config("c1"), + state: None, + }], WindowId::Main); + + assert!(data.projects.is_empty()); + assert!(data.main_window.hidden_project_ids.contains("remote:c1:a")); + assert!( + data.window(WindowId::Extra(extra_id)).unwrap() + .hidden_project_ids + .contains("remote:c1:a") + ); + + apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with(vec![api_project("a", None)], vec!["a".into()], vec![])), + }], WindowId::Main); + + assert_eq!(data.projects.len(), 1); + assert!(data.main_window.hidden_project_ids.contains("remote:c1:a")); + assert!( + data.window(WindowId::Extra(extra_id)).unwrap() + .hidden_project_ids + .contains("remote:c1:a") + ); + } + #[test] fn pending_focus_detects_new_terminal() { let mut data = empty_data(); @@ -640,7 +747,6 @@ mod tests { "remote:conn:p1", "Project", "/repo/project", - true, ); assert!(data.main_window.hidden_project_ids.contains("remote:conn:p1")); @@ -657,7 +763,11 @@ mod tests { } #[test] - fn initial_visibility_without_pending_uses_wire_hidden_flag() { + fn initial_visibility_without_pending_leaves_project_visible() { + // Per-window visibility is client-owned: without a spawn intent a freshly + // synced project is left visible in every window. The daemon's + // single-window `show_in_overview` must NOT hide it (that compounded into + // an all-hidden main window across restarts). let mut data = empty_data(); let extra = okena_state::WindowState::default(); let extra_id = extra.id; @@ -671,12 +781,11 @@ mod tests { "remote:conn:p1", "Project", "/repo/project", - false, ); - assert!(data.main_window.hidden_project_ids.contains("remote:conn:p1")); + assert!(!data.main_window.hidden_project_ids.contains("remote:conn:p1")); assert!( - data.window(WindowId::Extra(extra_id)).unwrap() + !data.window(WindowId::Extra(extra_id)).unwrap() .hidden_project_ids.contains("remote:conn:p1") ); } diff --git a/crates/okena-workspace/src/requests.rs b/crates/okena-workspace/src/requests.rs index 0017ec9f7..986ff33d0 100644 --- a/crates/okena-workspace/src/requests.rs +++ b/crates/okena-workspace/src/requests.rs @@ -58,6 +58,8 @@ pub enum ProjectOverlayKind { FileSearch, ContentSearch, FileBrowser, + /// Open a specific file (project-relative path) in the file viewer. + FileViewer { relative_path: String }, ColorPicker { position: gpui::Point }, WorktreeList { position: gpui::Point }, } diff --git a/crates/okena-workspace/src/state.rs b/crates/okena-workspace/src/state.rs index d21528795..62fb2e0b4 100644 --- a/crates/okena-workspace/src/state.rs +++ b/crates/okena-workspace/src/state.rs @@ -7,10 +7,12 @@ use okena_core::theme::FolderColor; use crate::access_history::ProjectAccessHistory; +use crate::context::WorkspaceCx; use crate::focus::FocusManager; use crate::lifecycle::ProjectLifecycleTracker; use crate::remote_sync::{PendingRemoteFocus, RemoteProjectSnapshot, RemoteSyncState}; use crate::visibility::compute_visible_projects; +#[cfg(feature = "gpui")] use gpui::*; use std::collections::HashMap; @@ -22,9 +24,11 @@ pub use okena_state::{ }; /// Global workspace wrapper for app-wide access (used by quit handler) +#[cfg(feature = "gpui")] #[derive(Clone)] pub struct GlobalWorkspace(pub Entity); +#[cfg(feature = "gpui")] impl Global for GlobalWorkspace {} /// GPUI Entity for workspace state. @@ -130,15 +134,20 @@ impl Workspace { /// data changes, but it means callers fired in a hot loop will re-shape /// every visible terminal grid each time. Keep such callers rare or /// throttled (see `bump_activity`). - pub fn notify_data(&mut self, cx: &mut Context) { + pub fn notify_data(&mut self, cx: &mut impl WorkspaceCx) { self.data_version += 1; cx.notify(); - cx.refresh_windows(); + cx.refresh_views(); + } + + fn mutate_data(&mut self, cx: &mut impl WorkspaceCx, f: impl FnOnce(&mut WorkspaceData)) { + f(&mut self.data); + self.notify_data(cx); } /// Replace workspace data wholesale (e.g. from disk reload). /// Does NOT bump data_version — the data came from disk, not a user edit. - pub fn replace_data(&mut self, focus_manager: &mut FocusManager, data: WorkspaceData, cx: &mut Context) { + pub fn replace_data(&mut self, focus_manager: &mut FocusManager, data: WorkspaceData, cx: &mut impl WorkspaceCx) { self.data = data; self.data_replacement_epoch += 1; // Snapshots in pending_closes refer to the old data — drop them so an @@ -148,7 +157,7 @@ impl Workspace { self.restored_closes.clear(); focus_manager.clear_all(); cx.notify(); - cx.refresh_windows(); + cx.refresh_views(); } /// Record that a project was accessed (for sorting by recency) @@ -163,7 +172,7 @@ impl Workspace { /// on raw terminal output, since output volume is not "activity". A no-op /// for an unknown project id. Uses `notify_data` so the change is persisted /// (debounced) and the sidebar re-renders to reorder. - pub fn bump_activity(&mut self, project_id: &str, cx: &mut Context) { + pub fn bump_activity(&mut self, project_id: &str, cx: &mut impl WorkspaceCx) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -224,10 +233,9 @@ impl Workspace { &mut self, window_id: WindowId, folder_id: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { - self.data.set_folder_filter(window_id, folder_id); - self.notify_data(cx); + self.mutate_data(cx, |data| data.set_folder_filter(window_id, folder_id)); } /// Toggle a project's hidden state in the targeted window. @@ -243,10 +251,9 @@ impl Workspace { &mut self, window_id: WindowId, project_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { - self.data.toggle_hidden(window_id, project_id); - self.notify_data(cx); + self.mutate_data(cx, |data| data.toggle_hidden(window_id, project_id)); } /// Set a single project's column width on the targeted window. @@ -263,10 +270,9 @@ impl Workspace { window_id: WindowId, project_id: &str, width: f32, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { - self.data.set_project_width(window_id, project_id, width); - self.notify_data(cx); + self.mutate_data(cx, |data| data.set_project_width(window_id, project_id, width)); } /// Set a folder's collapsed state on the targeted window. @@ -285,10 +291,11 @@ impl Workspace { window_id: WindowId, folder_id: &str, collapsed: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { - self.data.set_folder_collapsed(window_id, folder_id, collapsed); - self.notify_data(cx); + self.mutate_data(cx, |data| { + data.set_folder_collapsed(window_id, folder_id, collapsed); + }); } /// Set the OS window bounds on the targeted window. @@ -308,10 +315,9 @@ impl Workspace { &mut self, window_id: WindowId, bounds: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { - self.data.set_os_bounds(window_id, bounds); - self.notify_data(cx); + self.mutate_data(cx, |data| data.set_os_bounds(window_id, bounds)); } /// Set sidebar open/closed state for the targeted window. Persisted @@ -320,10 +326,9 @@ impl Workspace { &mut self, window_id: WindowId, open: bool, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { - self.data.set_sidebar_open(window_id, open); - self.notify_data(cx); + self.mutate_data(cx, |data| data.set_sidebar_open(window_id, open)); } /// Read the project-grid orientation for the targeted window. Falls back @@ -348,7 +353,7 @@ impl Workspace { /// /// Percentages in `project_widths` are axis-agnostic, so relative grid /// sizing is preserved across the flip. Persisted via `notify_data`. - pub fn toggle_project_layout_mode(&mut self, window_id: WindowId, cx: &mut Context) { + pub fn toggle_project_layout_mode(&mut self, window_id: WindowId, cx: &mut impl WorkspaceCx) { let Some(window_state) = self.data.window(window_id) else { return; }; @@ -377,7 +382,7 @@ impl Workspace { /// Flip the sidebar project sort mode (manual ↔ activity) for a window. /// Persisted via `notify_data`. - pub fn toggle_project_sort_mode(&mut self, window_id: WindowId, cx: &mut Context) { + pub fn toggle_project_sort_mode(&mut self, window_id: WindowId, cx: &mut impl WorkspaceCx) { if self.data.toggle_project_sort_mode(window_id).is_some() { self.notify_data(cx); } @@ -385,7 +390,7 @@ impl Workspace { /// Flip the "needs attention" section opt-in for a window's manual view. /// Persisted via `notify_data`. - pub fn toggle_show_attention_section(&mut self, window_id: WindowId, cx: &mut Context) { + pub fn toggle_show_attention_section(&mut self, window_id: WindowId, cx: &mut impl WorkspaceCx) { if self.data.toggle_show_attention_section(window_id).is_some() { self.notify_data(cx); } @@ -393,7 +398,7 @@ impl Workspace { /// Toggle whether a project is pinned to the top of the activity-sorted /// view. No-op for an unknown project id. Persisted via `notify_data`. - pub fn toggle_project_pinned(&mut self, project_id: &str, cx: &mut Context) { + pub fn toggle_project_pinned(&mut self, project_id: &str, cx: &mut impl WorkspaceCx) { if let Some(project) = self.project_mut(project_id) { project.pinned = !project.pinned; self.notify_data(cx); @@ -426,7 +431,7 @@ impl Workspace { pub fn spawn_extra_window( &mut self, spawning_bounds: Option, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> WindowId { let id = self.data.spawn_extra_window(spawning_bounds); self.notify_data(cx); @@ -450,7 +455,7 @@ impl Workspace { /// persisted state — the auto-save observer must trigger so the /// next launch (slice 07 cri 6) does not see the closed extra /// reappear. - pub fn close_extra_window(&mut self, id: WindowId, cx: &mut Context) { + pub fn close_extra_window(&mut self, id: WindowId, cx: &mut impl WorkspaceCx) { self.data.close_extra_window(id); self.notify_data(cx); } @@ -534,7 +539,7 @@ impl Workspace { /// Update the saved service terminal IDs for a project. /// Called by the ServiceManager observer to persist terminal IDs across restarts. - pub fn sync_service_terminals(&mut self, project_id: &str, terminals: HashMap, cx: &mut Context) { + pub fn sync_service_terminals(&mut self, project_id: &str, terminals: HashMap, cx: &mut impl WorkspaceCx) { if let Some(project) = self.project_mut(project_id) && project.service_terminals != terminals { project.service_terminals = terminals; @@ -547,7 +552,7 @@ impl Workspace { project_id: &str, terminal_id: &str, entry: HookTerminalEntry, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) { let label = entry.label.clone(); @@ -566,7 +571,7 @@ impl Workspace { pub fn register_hook_results( &mut self, results: Vec, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { for result in results { self.register_hook_terminal(&result.project_id, &result.terminal_id, HookTerminalEntry { @@ -583,7 +588,7 @@ impl Workspace { &mut self, terminal_id: &str, status: HookTerminalStatus, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { for project in &mut self.data.projects { if let Some(entry) = project.hook_terminals.get_mut(terminal_id) { @@ -599,7 +604,7 @@ impl Workspace { pub fn remove_hook_terminal( &mut self, terminal_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { for project in &mut self.data.projects { if project.hook_terminals.remove(terminal_id).is_some() { @@ -649,7 +654,7 @@ impl Workspace { project_id: &str, old_id: &str, new_id: &str, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let Some(project) = self.project_mut(project_id) else { return; @@ -753,6 +758,17 @@ impl Workspace { self.data.projects.iter().find(|p| p.id == id) } + /// True when the project is served by the co-located local daemon (shared + /// filesystem) — i.e. local paths are openable on this machine. A project + /// mirrored from a user-added remote connection returns false. A project + /// with no connection is treated as local (legacy / non-headless). + pub fn is_local_daemon_project(&self, project_id: &str) -> bool { + match self.project(project_id).and_then(|p| p.connection_id.as_deref()) { + Some(id) => id == okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, + None => true, + } + } + /// Get the parent project's path for a worktree project (i.e. the main repo path). pub fn worktree_parent_path(&self, project_id: &str) -> Option { self.project(project_id) @@ -835,7 +851,7 @@ impl Workspace { /// Remove all remote projects (and their folder) for a given connection_id. #[allow(dead_code)] - pub fn remove_remote_projects(&mut self, focus_manager: &mut FocusManager, connection_id: &str, cx: &mut Context) { + pub fn remove_remote_projects(&mut self, focus_manager: &mut FocusManager, connection_id: &str, cx: &mut impl WorkspaceCx) { let prefix = format!("remote:{}:", connection_id); let removed_project_ids: Vec = self @@ -875,7 +891,7 @@ impl Workspace { } /// Notify UI without bumping data_version (for remote state changes that shouldn't trigger auto-save). - pub fn notify_ui_only(&mut self, cx: &mut Context) { + pub fn notify_ui_only(&mut self, cx: &mut impl WorkspaceCx) { cx.notify(); } @@ -890,7 +906,7 @@ impl Workspace { snapshots: &[crate::remote_apply::RemoteSnapshot], window_id: WindowId, focus_manager: &mut FocusManager, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) { let outcome = crate::remote_apply::apply_remote_snapshot( &mut self.data, @@ -909,7 +925,7 @@ impl Workspace { /// Helper to mutate a layout node at a path, with automatic notify. /// Returns true if the mutation was applied. - pub fn with_layout_node(&mut self, project_id: &str, path: &[usize], cx: &mut Context, f: F) -> bool + pub fn with_layout_node(&mut self, project_id: &str, path: &[usize], cx: &mut impl WorkspaceCx, f: F) -> bool where F: FnOnce(&mut LayoutNode) -> bool, { @@ -925,7 +941,7 @@ impl Workspace { /// Helper to mutate a project, with automatic notify. /// Returns true if the mutation was applied. - pub fn with_project(&mut self, project_id: &str, cx: &mut Context, f: F) -> bool + pub fn with_project(&mut self, project_id: &str, cx: &mut impl WorkspaceCx, f: F) -> bool where F: FnOnce(&mut ProjectData) -> bool, { @@ -1704,7 +1720,7 @@ mod workspace_tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod gpui_tests { use gpui::AppContext as _; use crate::state::{HookTerminalEntry, HookTerminalStatus, LayoutNode, ProjectData, WindowBounds, WindowId, WindowState, Workspace, WorkspaceData}; diff --git a/crates/okena-workspace/src/toast.rs b/crates/okena-workspace/src/toast.rs index 0349824b7..e90e34d28 100644 --- a/crates/okena-workspace/src/toast.rs +++ b/crates/okena-workspace/src/toast.rs @@ -2,13 +2,17 @@ pub use okena_state::{Toast, ToastAction, ToastActionStyle, ToastLevel}; +#[cfg(feature = "gpui")] use gpui::{App, Global}; +#[cfg(feature = "gpui")] use parking_lot::Mutex; +#[cfg(feature = "gpui")] use std::sync::Arc; // ─── ToastManager (Global) ───────────────────────────────────────────────── /// Maximum number of visible toasts +#[cfg(any(feature = "gpui", test))] const MAX_VISIBLE_TOASTS: usize = 5; /// Trim the queue to `MAX_VISIBLE_TOASTS`, preferring to drop the oldest toast @@ -17,6 +21,7 @@ const MAX_VISIBLE_TOASTS: usize = 5; /// its TTL — evicting one early would silently strip the user's undo affordance /// while the underlying close still goes through. Falls back to the oldest toast /// when every toast has actions, so the hard cap is always honoured. +#[cfg(any(feature = "gpui", test))] fn trim_to_cap(queue: &mut Vec) { while queue.len() > MAX_VISIBLE_TOASTS { let idx = queue @@ -27,17 +32,21 @@ fn trim_to_cap(queue: &mut Vec) { } } +#[cfg(feature = "gpui")] #[derive(Clone)] pub struct ToastManager(pub Arc>>); +#[cfg(feature = "gpui")] impl Global for ToastManager {} +#[cfg(feature = "gpui")] impl Default for ToastManager { fn default() -> Self { Self::new() } } +#[cfg(feature = "gpui")] impl ToastManager { pub fn new() -> Self { Self(Arc::new(Mutex::new(Vec::new()))) diff --git a/docs/headless-migration.md b/docs/headless-migration.md new file mode 100644 index 000000000..9a2978b29 --- /dev/null +++ b/docs/headless-migration.md @@ -0,0 +1,627 @@ +# Full Headless Mode — Migration Roadmap + +Status doc for the migration from Okena's single-process "local + mirrored-remote" +architecture to a **two-process** model: a headless **daemon** that owns all state, +PTYs, logic and persistence, and **thin UI clients** (desktop, web, mobile, remote) +that speak a single protocol over a local socket. The in-process "local" branch is +deleted at the end. + +This is the team-facing execution plan. The decision memo (why two processes, what +was rejected) lives in the plan-mode artifact; this doc is the *how* and *in what +order*. + +--- + +## 1. Goal & end state + +- **One architecture.** No more parallel "local" and "remote" code paths. There is + the daemon (authoritative) and clients (views). Local projects and remote + projects render through the **same** machinery. +- **Daemon is GPUI-free** (user-fixed decision — not merely windowless). +- **A standalone, GPUI-free daemon binary is a first-class shippable artifact.** + `okena-daemon` must be buildable and runnable on its own, with **zero gpui in its + dependency graph** — so it can run on a headless server / CI box / container that + has no windowing stack at all, and a desktop/web/mobile client connects to it + remotely. This is stricter than "the daemon process doesn't open a window": it + means gpui must not be *linked* into the binary. The falsifiable gate is + `cargo tree -i gpui -p okena-daemon` returning nothing. +- **Desktop runs as two processes**: the first UI spawns a local daemon and + connects to it over loopback; the daemon dies with the last UI (UI-owned + lifecycle, user-fixed decision). +- **First-class clients**: desktop, web, mobile and remote are all thin clients of + the same protocol. View/focus state is client-owned; everything authoritative is + daemon-owned. + +The **same** `okena-daemon` binary serves two deployment modes: + +| Mode | Invocation | Lifecycle | Transport | +|---|---|---|---| +| **Local UI-owned daemon** (desktop) | spawned by the first UI as `okena-daemon --listen 127.0.0.1` | dies with the last UI | loopback TCP, TLS off | +| **Standalone headless server** | run manually, e.g. `okena-daemon --listen 0.0.0.0` on a server/CI/container | long-running, independent | TLS on, paired clients connect remotely | + +The standalone-server mode already *mostly works today* via `run_headless()` + +`--listen` + TLS (`src/main.rs:294`, `crates/okena-remote-server/src/tls.rs`). What's +missing is exactly the GPUI-free packaging — see Phases E/F. + +End-state process split: + +| Process | Owns | +|---|---| +| **Daemon** (`okena --headless` during strangler, then the standalone `okena-daemon` binary) | `Workspace` (authoritative), `PtyManager`, `execute_action`, `ServiceManager`, hooks, git watcher, persistence + instance lock, the HTTP/WS server. **No gpui linked.** | +| **GUI** | `WindowView` / `ProjectColumn` / layout views, a **mirror** `Workspace` (read-only projection via `apply_remote_snapshot`), per-window focus state, the remote-client state machine. No PTYs, no `execute_action`, no persistence. | + +### 1b. The split rule: DATA vs PRESENTATION (not local vs remote) + +The boundary between daemon and client is **data vs presentation**, not "local vs +remote": + +- **Daemon owns DATA**: projects, layout *as data* (the tree, not pixels), + terminals + PTYs, git status, services, and **persisted config including the + theme *preference***. +- **Client owns PRESENTATION**: rendering, *applying* the theme (gpui colors, + fonts), focus, window geometry, and — for the CLI — output formatting. + +The protocol carries **data**; each client renders it its own way. Consequences: + +- **Theme**: the daemon stores the *preference* (a string/enum in `settings.json`, + broadcast to clients) but never renders — the GUI applies gpui colors, the CLI + ignores it. (This is exactly why `okena-theme`'s data is gpui-free while the gpui + conversions are behind the `gpui` feature.) +- **CLI**: just another thin protocol client — it gets a `StateResponse` (data) and + formats plain text itself. No "UI-specific" thing crosses the wire pre-rendered. +- A client decides **per request**: a presentation concern (theme, focus, display) + it handles locally; a workspace concern (create terminal, git diff) it sends to + the daemon. No second "intercepting" server is needed. + +### 1c. Remotes: Model A — the UI is the aggregation hub (chosen) + +Okena aggregates local + multiple remote daemons in one sidebar (unlike VS Code, +where one window = one backend). So we must choose who aggregates. **Decision: +Model A — the UI is the hub.** + +- The UI connects directly to its **local daemon** (loopback, for local projects) + **and** to each **remote daemon** (for remote projects), all over the same + protocol. "Local" is just *a connection to 127.0.0.1*. The UI's existing + `RemoteConnectionManager` already does the multi-connect; the local-daemon + connection is the only new piece. +- **The local daemon handles only its own machine** (local projects + their + PTY/services/git). It does NOT connect to or proxy remotes — that keeps the + daemon simple and remote PTY at one hop (remote→UI, no double-hop relay). +- Trade-off accepted: a mobile/web/CLI client connected to the local daemon sees + only that machine's projects, not the remotes the desktop UI aggregates. + +This is **not a one-way door**: because everything speaks the same protocol, the +remote-connection-manager can later move *into* the daemon (Model B — daemon as a +gateway/aggregator visible to all clients, remotes persisting across UI restarts) +behind the same protocol, if/when that property is wanted. Model A is chosen now +for least change + best remote-PTY performance; Model B is the eventual option, not +a prerequisite. + +--- + +## 2. Why this is tractable: the seam already exists + +The "remote mode" already *is* the daemon/UI split, fully built and battle-tested. +The migration is largely **pointing the existing remote-client machinery at a local +daemon** and then deleting the in-process shortcut. + +What already exists and is reused unchanged: + +- **Snapshot reconciliation** — `apply_remote_snapshot()` + (`crates/okena-workspace/src/remote_apply.rs:55`) materializes a `StateResponse` + into `WorkspaceData` (projects, layouts, git, terminals), merging client-owned + visual state. Pure, no GPUI. **No change needed.** +- **Generic thin-client state machine** — `RemoteClient` + (`crates/okena-transport/src/client/connection.rs:53`): auth, `GET /v1/state`, + subscribe, binary frame reader, state-changed diffing. Parameterized by handler; + desktop and mobile already use it. +- **Desktop thin-client handler** — `DesktopConnectionHandler` + (`crates/okena-remote-client/src/connection.rs:16`) creates `Terminal` objects + backed by `RemoteTransport` and feeds raw PTY bytes to the per-pane alacritty + parser. +- **Remote action dispatch** — `ActionDispatcher::Remote` + (`crates/okena-app/src/action_dispatch.rs:219`): visual-only actions stay + client-side; everything else is `POST /v1/actions`. +- **Provider abstraction** — `GitProvider` with `LocalGitProvider` / + `RemoteGitProvider` (`crates/okena-views-git/src/diff_viewer/provider.rs:57,151`); + blame mirrors this. The daemon becomes the "remote". +- **Binary frame protocol** — `crates/okena-core/src/ws.rs:77` (`PROTO_VERSION=1`, + `FRAME_TYPE_PTY=1` / `SNAPSHOT=2` / `INPUT=3`). +- **Reference zero-GPUI client** — `okena-mobile-ffi` proves the protocol is + sufficient for a client with **zero** deps on gpui/workspace/PTY. +- **Headless host** — `run_headless()` (`src/main.rs:294`) + `HeadlessApp` + (`crates/okena-app/src/app/headless.rs:34`) already run the whole stack windowless + on `gpui_platform::current_platform(true)`. **This is the daemon, today.** + +The migration's hard part is therefore **not** "build a daemon" — it's "make local +projects ride the remote rails" + "remove GPUI from the daemon" + "delete the old +rails". + +--- + +## 3. Current status (done) + +| Increment | Commit | What | +|---|---|---| +| **Phase 0 — spike + full action-layer migration** | `9ae348f4` | `WorkspaceCx` reactor trait (`notify`/`refresh_views`) in `crates/okena-workspace/src/context.rs`. Whole action/state layer of `okena-workspace` converted from `&mut Context` to `&mut impl WorkspaceCx` **except** the hook chain (which needs `&App` for `HookMonitor`/`HookRunner` globals — deferred to Phase E). Non-breaking: `Context<'_, Workspace>: WorkspaceCx`, so every existing caller still compiles. 294/294 tests green. No `as`/`unsafe`/downcast. | +| **Phase 1a — shared local toolkit** | `f6b1e812` | `okena_remote_server::local`: `discover()` / `running_daemon()` (parse `remote.json`), `is_process_alive()`, `mint_local_token()` (local-trust via `remote_secret`). CLI `register` DRYed onto it. | +| **Phase 1b — spawn/wait primitives** | `36d580b7` | `spawn_daemon()` (`--headless --listen 127.0.0.1`, caller owns the `Child`) + `wait_until_ready()` (poll `remote.json`, skip stale pid). Toolkit complete: discover + mint + spawn + wait. | +| **Phase 1c — ensure_local_daemon** | `0b954560` | `ensure_local_daemon_in()` orchestration (discover-or-spawn → mint → `notify_auth_reload`), `EnsuredDaemon` (only the spawner kills its `Child`). | +| **Phase E — gpui-optional crate track** | `dffc5244` `7dfece2c` `8f705eca` `73e474a4` `f723f8a0` `06e1d0f1` `260572fd` `10ae40d8` `470648a1` `b7f45ecd` `56b64515` | Made `gpui` an optional feature across the daemon dependency tree (hooks → workspace → services → app-core → theme → files → remote-server), extending `WorkspaceCx` with the hook accessors and adding the `ServiceCx`/`ServiceHandle`/`ServiceAsyncCx` trait family so the action + service layers run reactor-agnostic. Milestone: the entire daemon graph compiles **gpui-free** (`cargo tree -i gpui` empty for each crate). No `as`/`unsafe`/type-hacks. | +| **Phase E — `okena-daemon-core`** | `d3f36d25` `0817c5a5` `d6dc1841` `076d80de` `1333940a` `760265a6` | New gpui-free crate: `DaemonReactor` + tokio impls of the reactor traits; the observer reactor (autosave / `state_version` / service-sync, re-entrancy-guarded); the PTY event loop; the git-status poller; `daemon_config` (gpui-free settings/theme handlers); `daemon_command_loop` (gpui-free port of `remote_command_loop`); and `DaemonCore::{new,run}` wiring the `RemoteServer` + reactor + all loops inside a `LocalSet`. 24 tests; gpui gate empty. | +| **Phase F — `okena-daemon` binary** | `48e77591` | Standalone, 100% gpui-free headless server binary wrapping `okena-daemon-core` (mirrors `run_headless` without GPUI). `cargo tree -i gpui -p okena-daemon` is **empty** — the standalone-binary goal is met. Smoke-verified end-to-end (isolated `XDG_CONFIG_HOME`): boots → loads settings/workspace → dtach auto-detect → dual-stack TLS server + pairing code + `remote.json` → observer reactor fires on the `LocalSet` (`load_project_services`) → `/health` ok → after pairing, `/v1/state` returns a full `StateResponse` (the whole `RemoteServer → bridge → daemon_command_loop → GetState` path). | +| **Phase A — `--daemon-client` mode** | `d709c83a` | Desktop flag (OFF by default → classic behavior byte-for-byte unchanged). ON: `main.rs` skips `acquire_instance_lock()`, starts from `WorkspaceData::empty()`, and calls `ensure_local_daemon()`; `Okena::new` takes `Option`, registers an implicit trusted loopback `RemoteConnectionConfig` (id `local-daemon`, tls off) via `add_connection`, holds the spawned `Child` (killed on `on_app_quit` — only the spawner kills), and makes the autosave observer inert (§5). Loopback connection never reaches persisted settings. Build + 405 tests green. | +| **Phase B — single-writer to the daemon** | `e2ee4e71` | `DaemonCore::new` acquires `acquire_instance_lock()` as step 0 (before binding a port / writing `remote.json`) and holds the `LockGuard` for the daemon's lifetime. Closes the §5 gap where the dedicated `okena-daemon` binary (preferred by `spawn_daemon`) never locked, so with the `--daemon-client` GUI also skipping the lock, no process owned the profile. Lock is gpui-free. | +| **Phase B/E — lifecycle hooks in the daemon** | `ea2ffc34` | `DaemonReactor` now gets a real `HookRunner::new(backend, terminals)` + `HookMonitor::new()` instead of `None`. All plumbing pre-existed (action layer → `WorkspaceCx::{hook_runner,hook_monitor}` → `DaemonWorkspaceCx`). Hook PTYs register in the shared registry + broadcast over `PtyBroadcaster`, so hook terminals reach clients via the normal remote terminal path. Daemon stays gpui-free. Follow-up: surface `HookMonitor` run status into `StateResponse`. | +| **Phase B — hide loopback connection** | `31af0b4e` | Shared const `LOCAL_DAEMON_CONNECTION_ID`; the implicit loopback connection is filtered out of the sidebar's REMOTE management section (its projects already render as ordinary flat-list projects via `apply_remote_snapshot`, which mirrors the daemon's project/folder structure 1:1). Confirmed: no separate "remote workspace" / chrome — Phase B's feared view-UX question was a non-issue. | +| **Phase D1 — flip default to daemon-client** | `662d311a` | The `--daemon-client` flag is gone: the GUI is **always** a thin client. Startup: the `--headless` branch (the daemon) takes the instance lock; the GUI path never locks, starts from `WorkspaceData::empty()`, and unconditionally `ensure_local_daemon()`s. | +| **Phase D2 — delete in-process local PROJECT path** | `a56c399b` | Every project is daemon-backed now, so the local path is dead. Deleted `ActionDispatcher::Local` (+ its dispatch/split/tab arms + the local branch of `dispatcher_for_project`), `create_local_column`, and the Local branches of `build_git_provider`/`build_project_fs`/`build_blame_provider`. The remote path is unconditional. −293 net. | +| **Phase D3 — delete the GUI's in-process infrastructure** | `885e0e76` | Removed from `Okena`: the self-hosted `RemoteServer` (11 fields + start/stop + bridge + command loop + resolvers — external clients connect to the daemon, Model A), the in-process `GitStatusWatcher`, `WorktreeSyncWatcher`, the GUI `HookRunner` global, the in-process `ServiceManager` field + sync observer, and the autosave observer (the daemon is the sole writer). `Okena::new` drops `listen_addr` and takes a non-Option `EnsuredDaemon`. KEPT: the `terminals` registry, `HookMonitor` (client renders hook UI from it), the daemon-shared `sync_services`/`observe_project_services`, and the whole daemon path (`run_headless`/`HeadlessApp` still starts its server + `remote_command_loop`). −705 net. | + +Branch: `refactorx/full-headless`. My commits are atomic and listed above; the +unrelated `M` working-tree entries + untracked `profile.json.gz` are not ours — +leave them. + +**Daemon-core is complete and the standalone gpui-free binary ships.** The +desktop now also runs as a thin client via `--daemon-client` (spawns a daemon, +mirrors its projects over loopback), with the daemon as the single writer and +lifecycle hooks live. + +### StateResponse data parity — verified (static builder diff) + +A field-by-field comparison of the GUI's `StateResponse` builder +(`app/remote_commands.rs` + `app/extras.rs::build_api_windows`) against the +daemon's (`daemon-core/command_loop.rs` `GetState` arm) confirms **the core data +is at full parity**: `ApiProject` (id/name/path/visibility/**layout+sizes**/ +terminal_names/git_status/folder_color/**services**/**worktree_info**/worktree_ids), +`ApiFolder`, `ApiServiceInfo`, `ApiWorktreeMetadata`, `ApiGitStatus`, +`project_order`, and `visible_project_ids` are all populated identically. + +The only daemon-stubbed fields are **per-window presentation state**: +`focused_project_id`, `focused_terminal_id`, `fullscreen`, `bounds`, +`sidebar_open`, `folder_filter`, and the multi-window `windows` list. These are +**client-owned** in the data/presentation split (§1b) — the desktop client has +real GPUI windows and tracks its own focus/bounds/sidebar locally, so the daemon +correctly does not dictate them. The single genuine (minor) gap is **restoring** +per-window UI state (sidebar_open / folder_filter / os_bounds / panel heights) +across a *client restart* in daemon-client mode, since the client no longer +loads `workspace.json` itself — a Phase C round-trip item, low priority. + +## 3b. Architectural goal: MET + +After Phases A→D the **migration's core goal is achieved**: there is one +architecture — the daemon (authoritative) and thin clients (views). The desktop +GUI is always a client of a local daemon it spawns; the in-process "local +project" path and the GUI's serving/owning infrastructure are deleted. The +daemon ships both as the standalone gpui-free `okena-daemon` binary and as the +GPUI-headless `run_headless`/`HeadlessApp` (the spawn fallback). + +**Rollback:** annotated tag `pre-daemon-flip-2026-06-25` (commit `a45aacbb`) is +the return point before the irreversible Phase D deletions. + +**Verification done blind:** every commit above is `cargo build`/`cargo test` +green (whole workspace builds with zero new warnings; okena-app 56, smoke 5, +daemon-core 24, workspace 294; daemon gpui-free gate empty). The **runtime** +behavior of the daemon-client desktop (opening a real window, rendering mirrored +projects, live terminals) was **not** verifiable headless — it needs a +run-capable session. Test with `cargo run`; if the daemon-client path misbehaves, +`git checkout pre-daemon-flip-2026-06-25`. + +## 3c. Remaining follow-ups (runtime-validated — close in a live session) + +These do not block the architecture; they are last-mile completeness/parity that +genuinely needs a running app to get right: + +1. **Terminal scrollback on (re)attach.** Pre-existing remote-protocol limitation + (the SNAPSHOT frame replays the viewport, not history) — affects all remote + clients, not just daemon-client. Matters on reconnect to an existing daemon + session; the primary "create terminals live" flow is unaffected. +2. **Per-window presentation restore** (sidebar_open / folder_filter / os_bounds / + panel heights) across a client restart — client-owned presentation that the + client no longer persists locally. Decide: client-side persistence vs. daemon + round-trip. +3. **`HookMonitor` run status** into `StateResponse` for the client hooks panel. +4. **Soft-close-on-quit flush.** `DaemonCore` still has no shutdown/`Drop` hook, + so a soft-closed terminal's persistent (dtach/tmux) session could outlive the + daemon if the daemon exits during the grace window. +5. **`worktree_removed` hook + background removal.** The daemon removes the + worktree synchronously and does **not** fire `worktree_removed` (matches the + normal `RemoveWorktreeProject` action). If wanted, add both to + `remove_worktree_project` so both entry points share them. +6. **Claude env live refresh.** `CLAUDE_CONFIG_DIR` is resolved once at daemon + startup. Hook a `set_extra_env` re-call into the daemon's settings-update path + if live re-sync is desired after `claude-code.config_dir` changes. + +**Key spike conclusions carried forward:** +- The action layer needs only `notify`/`refresh_views` — **no `spawn` on the trait.** +- The only real residual GPUI coupling is `&App` for the global hook services + (`HookMonitor`/`HookRunner`). That, plus the autosave/`state_version`/git/services + observers, is the entire content of the GPUI-free extraction (Phase E). + +## 3d. GUI PTY loop retired; terminal lifecycle ported to the daemon (2026-06-26) + +This closes §3c follow-up #1: the GUI's in-process `pty_manager` + +`start_pty_event_loop` are **deleted**. The desktop client now owns **no** local +PTY machinery. The dead loop turned out to be the *sole* home of several +terminal-lifecycle behaviors (it never ran in daemon-client mode — nothing fed +the GUI's `pty_manager` — so they were silently inert). Each was ported to its +correct owner **first**, then the loop was removed. + +| Behavior | Owner (data vs presentation) | Where it landed | +|---|---|---| +| OSC 52 clipboard *reads* | **client** (the clipboard is on the client machine) | wired into the remote activity pump (`RemoteManagerEvent::TerminalActivity` → `process_clipboard_reads`); the reply rides the terminal's `RemoteTransport` back to the daemon PTY | +| hook-terminal exits (status + pending worktree close) | **daemon** | `daemon-core/pty_loop.rs` `handle_hook_terminal_exits` via `DaemonWorkspaceCx` | +| `terminal.on_close` hooks | **daemon** | `fire_terminal_on_close_with_services` (new gpui-free split of `fire_terminal_on_close`) | +| OSC `__okena_hook_exit:` title detection | **daemon** | `daemon-core/pty_loop.rs` data path | +| command-finished activity (OSC 133 ;D → `last_activity_at`) | **daemon** | `bump_activity` on the dirty terminals' owning projects | +| `CLAUDE_CONFIG_DIR` per-PTY env | **daemon** | shared gpui-free `okena-workspace::claude_env`; the daemon `set_extra_env`s its own `PtyManager` | +| worktree removal on hook-close (`git worktree remove` + hooks) | **daemon** | converged on the canonical `remove_worktree_project` (same path as `RemoveWorktreeProject`) | + +Also done this session: deleted the dead `Local{Git,Blame}Provider` / +`LocalProjectFs` (no instantiation sites remained — every project rides the +`Remote*` providers), and pointed detached-terminal windows at the project's +`RemoteTransport` instead of the GUI's empty `pty_manager`. + +**Residual daemon-side gaps (flagged, *not* regressions — all were already inert +in the dead GUI loop):** +1. **Soft-close-on-quit flush.** Still needs a daemon shutdown hook. +2. **`worktree_removed` hook + background removal.** If wanted, add both to + `remove_worktree_project` so both entry points share them. +3. **Claude env live refresh.** Re-run `PtyManager::set_extra_env` from the + daemon settings-update path if live re-sync is desired. + +Closed since the original gap list: queued terminal kills are drained after each +daemon workspace action, daemon-originated toasts are forwarded over the stream, +and the GUI no longer owns local PTY machinery. + +These need a running app to validate end-to-end (the GPUI client + daemon can't +be exercised headless). + +--- + +## 4. The key sequencing insight (a refinement of the original phase order) + +**The daemon can ship as a headless-GPUI process first.** `run_headless` already +runs the full stack windowless. That means we can reach the *architectural goal* +(two processes, one protocol, in-process local path deleted from the GUI) **before** +doing the hardest piece (removing GPUI from the daemon). + +This reorders the work versus the original plan (which put GPUI-free extraction +before the flip), and it is strictly safer: + +1. Get the desktop running as a thin client of a **headless-GPUI** daemon and make + it the default (Phases A→D). The user-visible architecture is now "two + processes, one protocol." Local in-process path is gone from the GUI. +2. **Then** strip GPUI out of the daemon (Phase E) as a pure internal refactor. + Because clients only speak the protocol, the daemon's internals are swappable + behind their back — this is the **two-way door** the whole plan was designed to + create. If GPUI-free hits a wall, we still shipped the headless architecture. + +So: **functional two-process split is decoupled from GPUI-free.** We bank the +architecture win early and de-risk the irreversible-feeling part by doing it last, +behind the seam. + +Phase letters below (A–F) map to the original Fáze numbers in parentheses. + +--- + +## 5. Strangler invariants (must hold from Phase A until the Phase D flip) + +Both paths coexist during the transition. The desktop must support, switchable at +runtime, **(i)** classic in-process local projects and **(ii)** daemon-client local +projects. To keep that honest: + +- **Single writer.** Exactly one process owns persistence + the instance lock + (`crates/okena-workspace/src/persistence.rs::acquire_instance_lock`). In + daemon-client mode the **daemon** holds it; the GUI's `Workspace` is a pure mirror + and must **not** autosave (`app/mod.rs:243` autosave observer must be inert in + client mode). +- **Single PTY owner.** In daemon-client mode the GUI must **not** run + `start_pty_event_loop` for local projects, **not** instantiate `LocalBackend`, + **not** run `ServiceManager`/hooks. The daemon does all of it. +- **Single server.** In daemon-client mode the GUI must **not** start its own remote + server (`app/mod.rs:571`); the daemon is the server. External remote clients + (mobile, etc.) connect to the **daemon**, which unifies remote access for free. +- **Flag, not fork.** The classic path stays the default until parity (§ Phase D). + Selection is a single runtime switch, not duplicated call sites. + +--- + +## 6. Phases + +### Phase A — Daemon lifecycle + loopback attach *(Fáze 1c; additive, testable headless)* + +**Goal:** desktop startup can discover-or-spawn a local daemon and establish a +loopback client connection to it, using the local-trust token. No rendering change +yet — this only proves the plumbing. + +**Steps:** +1. `okena_remote_server::local::ensure_local_daemon()` — orchestrate the toolkit: + `running_daemon()` → if absent `spawn_daemon()` + `wait_until_ready()` → + `mint_local_token()` → notify `/v1/auth/reload` (via `okena-transport` + blocking-http). Returns `{ LocalDaemon, token }`. UI-owned: return the `Child` + (or a guard) to the caller so the last UI can kill it. +2. Wire `src/main.rs` GUI startup to call `ensure_local_daemon()` and register the + daemon as a loopback **remote connection** through the existing + `RemoteConnectionManager` (`crates/okena-remote-client`), TLS off on loopback. +3. Lifecycle guard: hold the spawned `Child` in the `Okena` coordinator + (`crates/okena-app/src/app/mod.rs`); on last-window-close, terminate it. Don't + kill a daemon we merely attached to (only the one we spawned), to avoid killing a + daemon shared with other UIs in future. + +**Gate:** desktop boots, spawns/attaches a daemon, the loopback connection reaches +`AuthOk` and pulls a `StateResponse`. Verified by logs + `okena ls` against the +loopback port. Classic local rendering still in force — zero regression. + +**Risk/reversibility:** additive, fully reversible. Two-way door. + +--- + +### Phase B — Local projects render via the daemon *(Fáze 3; behind a dev flag)* + +**Goal:** make a **local** project's actions and terminal rendering go through the +daemon over loopback, exactly as a remote project does today. This is where +protocol gaps surface, so it goes behind a `--daemon-client` (or settings) dev flag +first. + +**Prerequisite — move the single-writer to the daemon (do this FIRST).** Today the +GUI acquires `persistence::acquire_instance_lock()` at startup (`src/main.rs:497`, +impl `crates/okena-workspace/src/persistence.rs:75`) and owns `workspace.json` +writes. In daemon-client mode the **daemon** must be the sole writer of the profile +(lock + `workspace.json` + autosave); the desktop client must NOT acquire the lock +or write the workspace, or the two clobber each other. Concretely: +1. `DaemonCore::new` acquires the instance lock (hold the `LockGuard` for the + daemon's lifetime). The daemon already owns autosave (the observer reactor). +2. In daemon-client mode the desktop skips `acquire_instance_lock()` and treats its + `Workspace` as **mirror-only** (no autosave observer). In classic mode it still + acquires it. This is gated by the same dev flag as the rest of Phase B. +3. `ensure_local_daemon` (already built, `okena_remote_server::local`) is the + desktop's entry: discover-or-spawn the daemon, mint a loopback token, connect. +This is the Phase-A2 / instance-lock conflict folded into Phase B — it is the one +hard ordering constraint and must land before the desktop attaches as a client. + +**Exact seams (from the desktop architecture map):** the local-vs-remote split is +cleanly keyed on `project.is_remote` + the `ActionDispatcher` enum, so "local +project via daemon" reuses the remote path wholesale. The touch points: +- **Dispatch:** `ActionDispatcher::{Local,Remote}` + `dispatcher_for_project` + (`crates/okena-app/src/action_dispatch.rs:34,78,94,110,224`). Local projects in + daemon-client mode take the `Remote` branch (visual actions stay client-side; the + rest go over the wire). +- **Column/render:** `create_local_column` vs `create_remote_column` + (`crates/okena-app/src/views/window/mod.rs:744`,`:677`); snapshot apply + `apply_remote_snapshot` (`crates/okena-workspace/src/remote_apply.rs:55`, via + `state.rs:892`). +- **Providers (per-project `is_remote` branch):** `build_git_provider` / + `build_project_fs` / `build_blame_provider` + (`crates/okena-app/src/views/window/handlers.rs:59,124,171`). +- **In-process pieces that become daemon-only:** `Okena::start_pty_event_loop` + (`app/mod.rs:660`), the in-process `ServiceManager` (`app/mod.rs:315`) and + `GitStatusWatcher` (`app/mod.rs:356`) wiring, the conditional self-hosted server + (`app/mod.rs:571`). + +**Mechanism (reuse, don't rebuild):** +- The daemon owns the local project (it added it / loaded it from disk). The GUI + receives it through `apply_remote_snapshot` like any mirrored project — same + prefixing (`remote:{connid}:{id}`), same layout/terminal materialization. +- Route the GUI's project actions through `ActionDispatcher::Remote` + (`action_dispatch.rs:219`) instead of `::Local`. The dispatcher selection + (`dispatcher_for_project`, `action_dispatch.rs:34`) keys off `is_remote`; in + daemon-client mode local projects are effectively remote-from-a-local-daemon. +- Terminals render via `DesktopConnectionHandler` + `RemoteTransport` + the per-pane + alacritty parser — the existing remote terminal path. +- Git/blame use `RemoteGitProvider` against the daemon. + +**Key design question to resolve here:** *how does a local project on the daemon get +surfaced to the GUI as a connection-scoped project without the user "pairing"?* The +loopback connection from Phase A is implicit and trusted; local projects on the +daemon should appear in the GUI's default window automatically. Likely: a +"local-daemon connection" that is auto-subscribed and whose projects render in the +main window rather than as a separate remote workspace. This is the main new view- +wiring (`crates/okena-app/src/views/window/mod.rs` snapshot sync, +`create_local_column` → mirror path). + +**Gate:** with the flag on, open a local folder → it runs entirely through the +daemon: new terminal, split, type/echo, resize, close, git diff, services all work. +Compare side-by-side with classic mode. Enumerate every gap found (feeds Phase C). +**This phase needs a run-capable session** — the GPUI desktop cannot be fully +verified headless. + +**Risk/reversibility:** flagged, default-off → reversible. The flag is the strangler. + +--- + +### Phase C — Protocol parity *(Fáze 2; iterate with Phase B until no gaps)* + +**Goal:** close every gap Phase B surfaces, so daemon-client mode is +indistinguishable from classic mode. Driven by the Phase B gap list, but the known +suspects: + +- **Toasts** — forward over WS; UI renders. (`crates/okena-core/src/{api,ws}.rs`, + `crates/okena-app/src/app/notifications.rs`.) +- **OS notifications** — daemon emits an event; the UI fires the OS notification. +- **Scrollback** — a fetch action / frame so a freshly-attached client can pull + history, not just live output. (`crates/okena-terminal/src/terminal/*`.) +- **Soft-close & command-palette `InvokeAction`** — today these return errors in + headless (`app/remote_commands.rs`). Model window/focus in **data**, not GPUI + windows, so they work without a GUI. +- **Typed schemas** — replace untyped `serde_json::Value` for git/files/settings in + the wire types with typed structs. +- **Unsynced persistent fields** — promote fields the client needs but that aren't + mirrored today: `hooks`, `default_shell`, `pinned`, panel heights, per-window + bounds/widths. (`crates/okena-state/src/workspace_data.rs`, `StateResponse` + builder in `app/remote_commands.rs`.) + +**Gate:** the Phase B gap list is empty; daemon-client mode passes the same manual +checklist as classic for projects/layout/git/services/terminals/scrollback/toasts/ +notifications/soft-close/command-palette. + +**Risk/reversibility:** additive protocol growth; reversible. + +--- + +### Phase D — Flip the default + delete the in-process local path from the GUI *(Fáze 5a)* + +**Goal:** daemon-client becomes the desktop default (honoring the "flip hned" +decision — default the moment parity holds, not a permanent opt-in). The GUI process +loses its in-process local machinery. **The daemon is still headless-GPUI at this +point** — that's fine and intended. + +**Pre-flip:** run the §7 benchmark suite; require no perceptible interactive +regression. Tag/branch as the rollback point (one-way-ish door). + +**Delete from the GUI process:** +- `ActionDispatcher::Local` and the `dispatcher_for_project` local branch + (`action_dispatch.rs:34,110`). +- `create_local_column`'s in-process wiring → only the mirror path remains + (`crates/okena-app/src/views/window/*`, `views/panels/project_column.rs`). +- The GUI's in-process PTY loop, `LocalBackend` instantiation, `ServiceManager`, + hooks, git watcher, autosave, and self-hosted remote server + (`crates/okena-app/src/app/mod.rs` — these stay only in the daemon's `HeadlessApp`). +- The GUI's `Workspace` becomes mirror-only. + +**What is *not* deleted:** the shared code itself (`execute_action`, `PtyManager`, +`LocalBackend`, services, hooks) — it now lives and runs **in the daemon** +(`HeadlessApp`, which is `run_headless`). "Deleting the local branch" means removing +the GUI's *ownership* of it, not the code. + +**Gate:** desktop runs daemon-client by default; smoke tests (`src/smoke_tests.rs`) +green; classic path removed; one daemon owns persistence/PTYs/server. + +**Risk/reversibility:** the deletion is the one-way door → gated on the benchmark and +a parallel-run soak period, with a tagged rollback point. After this, the +architectural goal is **met**: two processes, one protocol. + +--- + +### Phase E — GPUI-free daemon extraction *(Fáze 4; internal, behind the protocol seam)* + +**Goal:** make the daemon's entire dependency tree build with **gpui absent**, not +merely unused. Now safe and reversible because clients only see the protocol — the +daemon's internals are invisible to them. This is the work that turns "headless-GPUI +daemon" into "standalone GPUI-free binary." + +**The coupling to remove (grounded inventory, measured on `refactorx/full-headless`):** + +| Crate | gpui coupling today | Action | +|---|---|---| +| `okena-remote-server` | 1 file, only `gpui::Global` for the `GlobalRemoteInfo` wrapper | Move/feature-gate the `Global` wrapper out; core server is already gpui-free. Trivial. | +| `okena-hooks` | `HookMonitor` / `HookRunner` exposed as gpui globals (`impl Global`, accessed via `&App`) | Replace global access with a plain accessor owned by the reactor. Low. | +| `okena-services` | `ServiceManager` is a gpui `Entity` that `cx.observe`s the workspace (17× `Context<`, 8× `Entity<`) | Re-host as a plain struct driven by reactor callbacks (no `Entity`/`observe`). Medium — the real work. | +| `okena-workspace` | residual after Phase 0: deferred hook chain (`Entity`/`Context`), `GlobalWorkspace` wrapper, and **`gpui::Point`/`gpui::Pixels` embedded in persistent data** (window bounds, panel widths). (133 `gpui::test` refs are test-only.) | Finish the `WorkspaceCx` migration; replace gpui geometry types in persisted data with plain types in `okena-state`. | +| `okena-app` | irreducibly gpui (it holds the views). `HeadlessApp` currently lives here. | The daemon host must **not** depend on `okena-app` — see step 4 (new crate). | + +**Steps:** +1. **Finish the `WorkspaceCx` migration.** The deferred hook-chain methods + (`project.rs::{add_project,delete_project}`, `worktree.rs` registration chain) + need `&App` for `HookMonitor`/`HookRunner` globals. Route them through a plain + service accessor on the reactor instead of a GPUI global. +2. **De-GPUI the observers.** Replace `cx.observe`-driven autosave + `state_version` + bump + git status + service sync with a plain reactor (tokio + `watch` + + callbacks). These live in the app/daemon layer, not the action layer (already + GPUI-free after Phase 0). +3. **Purge gpui types from data.** Replace `gpui::Point`/`gpui::Pixels` in persisted + `okena-state` types with plain types (the wire schema in `okena-core` already + avoids gpui — align on those). Move `GitStatusWatcher` out of the + `okena-views-git` views crate (`watcher.rs`) so the daemon never links a views + crate. +4. **Make gpui an optional feature** in `okena-workspace`, `okena-services`, + `okena-hooks`, `okena-remote-server`. The GPUI-backed impls — `Entity`/`Global` + wrappers, `impl WorkspaceCx for Context<'_, Workspace>`, any `gpui::*` geometry — + go behind `#[cfg(feature = "gpui")]`. The GUI builds these crates *with* the + feature; the daemon builds them with `default-features = false`. +5. **New gpui-free host crate** — `crates/okena-daemon-core` holding `WorkspaceCore`: + the reactor + the already-generic action layer + `PtyManager` + (de-GPUI'd) + services + hooks + the server. It depends on the daemon-tree crates with gpui + off, and **not** on `okena-app`. (`HeadlessApp`'s logic moves here; `okena-app` + keeps only GUI-client code.) + +**Gate (this is what makes the standalone binary real):** +- `cargo build -p okena-daemon-core --no-default-features` (or with gpui off) + succeeds, and `cargo tree -e features -i gpui -p okena-daemon-core` returns + **nothing**. +- Desktop (still a thin client) sees no change. `cargo test -p okena-workspace` + green throughout (the Phase 0 gate). Each feature-gated crate builds **both** with + and without the `gpui` feature (CI matrix). + +**Risk/reversibility:** two-way door by construction. If one piece resists de-GPUI, +that crate keeps its gpui-backed impl and the daemon temporarily keeps it gpui-on — +the standalone-binary gate just stays red for that crate until resolved, with no +user-visible impact on the already-shipped two-process architecture. + +--- + +### Phase F — `okena-daemon` binary + final cleanup *(Fáze 5b)* + +**Goal:** ship the standalone, GPUI-free daemon binary — smaller, faster to start, +no windowing libraries linked, runnable on a headless server. + +**Steps:** +- New `crates/okena-daemon` **binary** wrapping `okena-daemon-core` (gpui off). + Supports both deployment modes from § 1 (loopback UI-owned + standalone server). + `spawn_daemon()` switches from `current_exe --headless` to launching `okena-daemon`. +- Remove the now-dead headless-GPUI scaffolding from the main binary + (`run_headless`/`HeadlessApp` in the gpui app become unnecessary). +- Final pass: delete leftover dual-path conditionals, dead `cfg`s, unused wiring. + +**Gate:** +- `okena-daemon` is the spawned/served process; `cargo tree -i gpui -p okena-daemon` + returns nothing (the shippable-artifact gate). +- The standalone-server mode is verified end-to-end: run `okena-daemon` on a box + with no display, connect a desktop/mobile client remotely, exercise + projects/terminals/git/services. +- Main GUI binary links gpui only for the client; full `cargo build`/`cargo test`; + benchmark suite re-run. + +--- + +## 7. Performance plan (benchmark-gated, before the Phase D flip) + +The user de-prioritized performance as a *driver*, but the flip is gated on no +perceptible regression. + +- **Measure:** echo latency (keystroke→render) vs in-process; throughput flood + (`yes`, `cat 100MB`); snapshot churn under bursts; CPU under multi-MB/s streams. +- **Bar:** no perceptible regression for interactive use. +- **Escalation ladder if it fails:** + 1. Push-not-poll instead of the 8 ms remote-dirty loop + (`crates/okena-views-terminal/src/layout/terminal_pane/mod.rs:~202`). + 2. State deltas/coalescing instead of full snapshot on every `state_version` bump. + 3. UDS / named-pipe transport (TLS off on loopback) instead of loopback TCP. + 4. Lossless backpressure on the local socket instead of lossy resync. + +--- + +## 8. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| GPUI-free depth (the dominant unknown) | Resolved by Phase 0 spike (done, GO). Action layer is already generic; only services/observers remain, and they're done **after** the flip behind the seam. | +| Protocol gaps hidden until desktop-as-client | Phase B (flagged) surfaces them **before** anything irreversible; Phase C closes them. | +| Two writers / two PTY owners / two servers during strangler | §5 invariants: in client mode the GUI is inert for persistence/PTY/services/server. | +| Auto-surfacing local-daemon projects without "pairing" | Implicit trusted loopback connection from Phase A; resolve the view-wiring in Phase B. | +| Cross-platform local socket | Loopback TCP for MVP; UDS/named-pipe only if the benchmark demands it. | +| Killing a shared daemon | Only the **spawner** kills; attachers never do. | + +--- + +## 9. Falsifiability — what would change the plan + +- **Phase B reveals the protocol can't represent something essential** without a + major schema redesign → reconsider whether *all* local state belongs on the wire, + or keep a narrow in-process fast-path for that one concern. +- **Benchmark shows insurmountable interactive latency** even after push-not-poll + + deltas → reconsider "two processes always" for the single-machine desktop (the + decision memo's stated fallback). +- **GPUI-free (Phase E) hits a hard executor-ordering dependency** → daemon ships + headless-GPUI permanently; we still have the two-process architecture (this is why + E is last and behind the seam). + +--- + +## 10. Recommended next step + +**Phase A (Fáze 1c)** — `ensure_local_daemon()` orchestration + loopback attach at +desktop startup. It is additive, reversible, and unblocks Phase B. Phase B is the +first step that needs a **run-capable session** (the GPUI desktop can't be fully +verified headless), so it's the natural point to switch from "build" to "build + +manually verify in a running app." diff --git a/install.ps1 b/install.ps1 index c806641e7..77be78be2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -54,6 +54,12 @@ if (Test-Path $InstallDir) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null Copy-Item -Path $ExePath.FullName -Destination $InstallDir +# GPUI-free daemon sibling — the app prefers it over `okena --headless`. +$DaemonExe = Get-ChildItem -Path $TempDir -Recurse -Filter "okena-daemon.exe" | Select-Object -First 1 +if ($DaemonExe) { + Copy-Item -Path $DaemonExe.FullName -Destination $InstallDir +} + # Add to PATH (user scope) $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($UserPath -notlike "*$InstallDir*") { diff --git a/install.sh b/install.sh index 79a883e06..934bbd4dd 100755 --- a/install.sh +++ b/install.sh @@ -67,6 +67,7 @@ if [ "$OS" = "darwin" ]; then step "Clearing quarantine" chmod +x "/Applications/Okena.app/Contents/MacOS/okena" + chmod +x "/Applications/Okena.app/Contents/MacOS/okena-daemon" 2>/dev/null || true xattr -cr "/Applications/Okena.app" 2>/dev/null || true done_msg "Installed to /Applications/Okena.app" @@ -88,6 +89,11 @@ else step "Installing binary to $INSTALL_DIR" mv "$TMP_DIR/okena" "$INSTALL_DIR/" chmod +x "$INSTALL_DIR/okena" + # GPUI-free daemon sibling — the app prefers it over `okena --headless`. + if [ -f "$TMP_DIR/okena-daemon" ]; then + mv "$TMP_DIR/okena-daemon" "$INSTALL_DIR/" + chmod +x "$INSTALL_DIR/okena-daemon" + fi step "Installing icons and desktop entry" diff --git a/scripts/audit-daemon-client-gaps.sh b/scripts/audit-daemon-client-gaps.sh new file mode 100755 index 000000000..0ee5f0572 --- /dev/null +++ b/scripts/audit-daemon-client-gaps.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# audit-daemon-client-gaps.sh — find daemon/client wiring "holes". +# +# In the daemon-client architecture the desktop GUI (crates/okena-app + the +# gpui view crates) is a THIN CLIENT: its `Workspace` is a read-only MIRROR and +# the daemon is the single writer + owner of PTYs/services/git. Two recurring +# classes of bug ("X isn't transferred / X isn't wired") are statically +# detectable: +# +# CAT 1 — GUI does the daemon's job: a handler mutates the mirror Workspace, +# spawns/kills a local PTY, or writes persistence, instead of +# dispatching an ActionRequest to the daemon. (Silent no-op / wrong +# process write.) +# CAT 2 — daemon drops data: a field exists on a domain type but not on its +# wire `Api*` projection, so it never reaches the client. (Shows as +# empty/None — "unwired".) +# +# This is a heuristic linter, not a proof: CAT 1 uses a denylist of data-mutating +# calls (visual/presentation mutations are allowlisted); CAT 2 is a field-name +# diff. Triage each hit. Exit non-zero if any CAT 1 hit is found (CI-gateable). +# +# Usage: scripts/audit-daemon-client-gaps.sh +set -uo pipefail +cd "$(dirname "$0")/.." + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +bold() { printf '\033[1m%s\033[0m\n' "$*"; } + +# GUI client crates that must NOT mutate workspace data / run local exec. +GUI_GLOBS=(crates/okena-app/src crates/okena-views-sidebar/src \ + crates/okena-views-git/src crates/okena-views-services/src \ + crates/okena-views-terminal/src crates/okena-views-remote/src) + +bold "== CAT 1: GUI client doing the daemon's job (should be an ActionRequest) ==" +echo " (each hit: a mirror write / local PTY / local persistence in client code)" +echo + +# Data-mutating Workspace methods + local-exec + persistence that the client +# must route to the daemon instead. Visual/presentation mutators are NOT here on +# purpose (update_split_sizes, toggle_*minimized, set_fullscreen, exit_fullscreen, +# focus_*, set_folder_collapsed, set_active_tab, *_ui_only — all client-local-OK). +CAT1_PATTERNS=( + 'persistence::save_workspace' + # `ws.`-anchored so a view's own dispatch method (this.add_project / + # this.delete_project, which route to an ActionRequest) isn't a false positive. + '\bws\.add_project\(' + '\bws\.delete_project\(' + '\.create_worktree_project\(' + '\.remove_worktree_project\(' + '\.add_discovered_worktree\(' + '\.add_to_worktree_ids\(' + '\.replace_data\(' + '\.set_terminal_shell\(' + 'set_global\((crate::)?(workspace::)?hooks::HookRunner' + '\.backend\.create_terminal\(' + '\.backend\.kill\(' + 'ServiceManager::new\(' + 'okena_git::get_git_status\(' # local git read — returns None in client mode + # Sidebar folder/project/order/pin/color data mutations — all daemon-owned; + # the GUI must dispatch the matching ActionRequest, not write its mirror. + # (`toggle_worktree_visibility` is deliberately absent: it's per-window + # presentation state — `toggle_hidden(window_id, ...)` — that stays client-local.) + '\bws\.create_folder\(' + '\bws\.delete_folder\(' + '\bws\.rename_folder\(' + '\bws\.rename_project\(' + '\bws\.move_project\(' + '\bws\.move_project_to_folder\(' + '\bws\.move_item_in_order\(' + '\bws\.toggle_project_pinned\(' + '\bws\.reorder_worktree\(' + '\bws\.set_folder_color\(' + '\bws\.set_folder_item_color\(' + '\bws\.set_worktree_color_override\(' +) + +cat1_hits=0 +for pat in "${CAT1_PATTERNS[@]}"; do + # Exclude test modules, the snapshot reconciler, the action dispatcher, and + # the daemon/headless owners (legitimate writers). + matches=$(grep -rnE "$pat" "${GUI_GLOBS[@]}" 2>/dev/null \ + | grep -vE '(/tests?/|_test\.rs|#\[cfg\(test\)\]|remote_apply\.rs|action_dispatch\.rs|/app/headless\.rs)' \ + | grep -vE '^\s*//' ) + if [ -n "$matches" ]; then + red " ▸ $pat" + echo "$matches" | sed 's/^/ /' + cat1_hits=$((cat1_hits + $(echo "$matches" | grep -c .))) + echo + fi +done +[ "$cat1_hits" -eq 0 ] && green " none" && echo + +bold "== CAT 2: domain fields dropped from the wire (Domain -> Api*) ==" +echo " (each: a field on the domain struct with no same-named field on Api*)" +echo + +# Extract `pub :` names from a named struct block in a file. +struct_fields() { + local file="$1" struct="$2" + awk -v s="pub struct $struct" ' + $0 ~ s {inb=1} + inb && /^\}/ {inb=0} + inb && /^[[:space:]]*pub [a-z_]+:/ { + line=$0; sub(/^[[:space:]]*pub /,"",line); sub(/:.*/,"",line); print line + } + ' "$file" 2>/dev/null | sort -u +} + +# pairs: "Domain|domain_file|Api|api_file" +PAIRS=( + "GitStatus|crates/okena-git/src/lib.rs|ApiGitStatus|crates/okena-core/src/api.rs" + "ProjectData|crates/okena-state/src/workspace_data.rs|ApiProject|crates/okena-core/src/api.rs" + "FolderData|crates/okena-state/src/workspace_data.rs|ApiFolder|crates/okena-core/src/api.rs" + "WindowState|crates/okena-state/src/window_state.rs|ApiWindow|crates/okena-core/src/api.rs" +) + +# Domain fields that are intentionally NOT on the wire (client-local presentation +# or daemon-internal), so they don't count as gaps: +# connection_id / is_remote — set by the client's snapshot reconciler, not the +# daemon (the daemon's own projects are is_remote=false). +# service_terminals — daemon-internal persistence routing; the client +# gets live service terminal ids via ApiServiceInfo. +# hidden_terminals — dormant placeholder (`#[allow(dead_code)]`); actual +# per-terminal visibility flows through +# LayoutNode.minimized/detached, which ARE on the wire. +CAT2_IGNORE='^(version|service_panel_heights|hook_panel_heights|connection_id|is_remote|service_terminals|hidden_terminals)$' + +cat2_hits=0 +for pair in "${PAIRS[@]}"; do + IFS='|' read -r dname dfile aname afile <<< "$pair" + dfields=$(struct_fields "$dfile" "$dname") + afields=$(struct_fields "$afile" "$aname") + [ -z "$dfields" ] && { red " ! could not read $dname in $dfile (struct moved?)"; continue; } + missing="" + while IFS= read -r f; do + [ -z "$f" ] && continue + echo "$f" | grep -qE "$CAT2_IGNORE" && continue + # present if the same field name appears on the Api struct + echo "$afields" | grep -qxF "$f" || missing="$missing $f" + done <<< "$dfields" + if [ -n "$missing" ]; then + red " ▸ $dname -> $aname missing:$missing" + cat2_hits=$((cat2_hits + $(echo $missing | wc -w))) + else + green " ✓ $dname -> $aname" + fi +done +echo + +bold "== summary ==" +echo " CAT 1 (GUI doing daemon's job): $cat1_hits hit(s)" +echo " CAT 2 (fields dropped from wire): $cat2_hits field(s) — triage (some may be intentional client-local)" +echo +echo " Note: heuristic. CAT 2 false-positives are expected for genuinely" +echo " client-local/derived fields; add them to CAT2_IGNORE once confirmed." + +# Gate on CAT 1 only (CAT 2 needs human triage). +[ "$cat1_hits" -eq 0 ] diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index 8c2fd822b..5864c47af 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -57,8 +57,8 @@ echo " Version: $VERSION" # Build if not skipping if [[ "$SKIP_BUILD" == false ]]; then - echo "==> Building release binary..." - cargo build --release --target "$TARGET" + echo "==> Building release binaries..." + cargo build --release --target "$TARGET" -p okena -p okena-daemon fi # Verify binary exists (check target-specific path first, then default) @@ -76,6 +76,16 @@ if [[ ! -f "$BINARY_PATH" ]]; then echo " Using default release binary" fi +# The GPUI-free daemon binary is built as a sibling of `okena`. Bundle it next +# to `okena` so the app finds it (sibling lookup) instead of falling back to +# `okena --headless`. +DAEMON_BINARY_PATH="$(dirname "$BINARY_PATH")/okena-daemon" +if [[ ! -f "$DAEMON_BINARY_PATH" ]]; then + echo "Error: Daemon binary not found at $DAEMON_BINARY_PATH" + echo "Run without --skip-build or build first with: cargo build --release" + exit 1 +fi + # Setup paths DIST_DIR="$PROJECT_ROOT/dist" APP_BUNDLE="$DIST_DIR/$APP_NAME.app" @@ -89,10 +99,12 @@ rm -rf "$APP_BUNDLE" mkdir -p "$MACOS_DIR" mkdir -p "$RESOURCES_DIR" -# Copy binary -echo "==> Copying binary..." +# Copy binaries +echo "==> Copying binaries..." cp "$BINARY_PATH" "$MACOS_DIR/okena" chmod +x "$MACOS_DIR/okena" +cp "$DAEMON_BINARY_PATH" "$MACOS_DIR/okena-daemon" +chmod +x "$MACOS_DIR/okena-daemon" # Create Info.plist with version echo "==> Creating Info.plist..." @@ -130,6 +142,7 @@ rm -rf "$ICONSET_DIR" echo "APPL????" > "$CONTENTS_DIR/PkgInfo" echo "==> Ad-hoc code signing..." +codesign --force --sign - "$MACOS_DIR/okena-daemon" codesign --force --sign - "$MACOS_DIR/okena" codesign --force --sign - "$APP_BUNDLE" diff --git a/scripts/test_notifications.sh b/scripts/test_notifications.sh new file mode 100755 index 000000000..7aad88df4 --- /dev/null +++ b/scripts/test_notifications.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# +# Emit terminal notification/activity escape sequences for manual daemon/headless +# propagation testing. +# +# Run this inside an Okena terminal. For native desktop notification checks, run +# it in a background tab/pane or unfocused window: Okena intentionally suppresses +# OS notifications for the pane the user is actively looking at. +set -euo pipefail + +interval="1.25" +repeat=1 +quiet=0 + +usage() { + cat <<'EOF' +Usage: scripts/test_notifications.sh [options] + +Options: + --interval SECONDS Delay between events (default: 1.25) + --repeat COUNT Repeat the full sequence COUNT times (default: 1) + --fast Shortcut for --interval 0.15 + --quiet Do not print explanatory labels before events + -h, --help Show this help + +What this emits: + - BEL terminal bell + - OSC 9 plain notifications (BEL and ST terminated) + - OSC 777 rich notifications + - OSC 99 kitty notifications, including chunked and base64 payloads + - OSC 9;4 progress updates, which should NOT create notifications + - OSC 133;D command-finished activity edge + +Expected manual checks in daemon/headless mode: + - Background panes should raise native notifications when notification + settings are enabled. + - Focused panes should not raise native notifications, by design. + - Bell/OSC attention should reach the daemon-owned state and client sidebar. + - OSC 9;4 progress should update progress state, not produce a notification. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --interval) + if [ "$#" -lt 2 ]; then + printf 'error: --interval requires a value\n' >&2 + exit 2 + fi + interval="$2" + shift 2 + ;; + --repeat) + if [ "$#" -lt 2 ]; then + printf 'error: --repeat requires a value\n' >&2 + exit 2 + fi + repeat="$2" + shift 2 + ;; + --fast) + interval="0.15" + shift + ;; + --quiet) + quiet=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown option: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "$repeat" in + ''|*[!0-9]*) + printf 'error: --repeat must be a positive integer\n' >&2 + exit 2 + ;; + 0) + printf 'error: --repeat must be greater than zero\n' >&2 + exit 2 + ;; +esac + +ESC=$'\033' +BEL=$'\007' +ST=$'\033\\' + +pause() { + if [ "$interval" != "0" ] && [ "$interval" != "0.0" ]; then + sleep "$interval" + fi +} + +label() { + if [ "$quiet" -eq 0 ]; then + printf '\n[%s] %s\n' "$(date '+%H:%M:%S')" "$1" + fi +} + +osc_bel() { + printf '%s]%s%s' "$ESC" "$1" "$BEL" +} + +osc_st() { + printf '%s]%s%s' "$ESC" "$1" "$ST" +} + +emit_bell() { + printf '%s' "$BEL" +} + +if [ "$quiet" -eq 0 ]; then + cat <() - && let Err(e) = persistence::save_workspace(gw.0.read(cx).data()) { - log::error!("Failed to flush workspace on quit: {}", e); - } + // NOTE: do NOT save workspace.json here. The GUI is a daemon client and its + // Workspace is a read-only MIRROR (project/folder ids are prefixed + // `remote:local-daemon:…`). The daemon is the single writer (§5) and owns + // workspace.json; writing the mirror here clobbered it with prefixed-id / + // empty-extra_windows garbage (corrupting projects + wiping multi-window + // state on the next launch). cx.quit(); } @@ -291,7 +291,7 @@ struct GlobalHeadless(#[allow(dead_code)] Entity); impl Global for GlobalHeadless {} /// Run the application in headless mode (no GUI, remote server only). -fn run_headless(listen_addr: IpAddr) { +fn run_headless(listen_addr: Option) { println!("Starting Okena in headless mode..."); Application::with_platform(gpui_platform::current_platform(true)).run(move |cx: &mut App| { @@ -301,6 +301,13 @@ fn run_headless(listen_addr: IpAddr) { let settings_entity = settings::init_settings(cx); let app_settings = settings_entity.read(cx).get().clone(); + // Seed the process palette so headless terminals answer OSC color + // queries themselves (client mirrors deliberately don't answer, and no + // views push per-terminal palettes here). + okena_app::terminal::terminal::set_process_palette( + AppTheme::new(app_settings.theme_mode, true).colors, + ); + // Load or create workspace let workspace_data = persistence::load_workspace(app_settings.session_backend).unwrap_or_else(|e| { log::error!("Failed to load workspace: {}. A backup may have been saved to {:?}. Using default workspace.", e, persistence::get_workspace_path().with_extension("json.bak")); @@ -311,6 +318,11 @@ fn run_headless(listen_addr: IpAddr) { let (pty_manager, pty_events) = PtyManager::new(app_settings.session_backend); let pty_manager = Arc::new(pty_manager); + let listen_addrs = + okena_app::remote::local::resolve_daemon_listen_addrs(listen_addr, &app_settings); + let tls_enabled = listen_addrs.iter().any(|addr| !addr.is_loopback()) + && app_settings.remote_tls_enabled; + // Create the headless app entity (starts PTY loop, command loop, and remote server) // Must be stored in a global to keep the entity alive — dropping the handle // would release the entity and cancel all spawned tasks + drop RemoteServer. @@ -319,8 +331,8 @@ fn run_headless(listen_addr: IpAddr) { workspace_data, pty_manager, pty_events, - listen_addr, - app_settings.remote_tls_enabled, + listen_addrs, + tls_enabled, cx, ) }); @@ -414,6 +426,26 @@ fn main() { eprintln!("Warning: profile migration failed: {e}"); } + // Snapshot the existing config BEFORE anything loads/migrates it, so an + // upgrade can be reverted to an old-format config the previous binary reads. + // Must run before load_settings()/load_workspace(). + { + use okena_workspace::persistence::{SETTINGS_VERSION, WINDOW_LAYOUT_VERSION, WORKSPACE_VERSION}; + let schema_versions = [ + profiles::SchemaVersion { file: "workspace.json", current: WORKSPACE_VERSION }, + profiles::SchemaVersion { file: "settings.json", current: SETTINGS_VERSION }, + profiles::SchemaVersion { file: "window-layout.json", current: WINDOW_LAYOUT_VERSION }, + ]; + if let Err(e) = profiles::snapshot_configs_before_upgrade( + profiles::current(), + env!("CARGO_PKG_VERSION"), + &schema_versions, + ) { + eprintln!("Warning: config snapshot failed: {e}"); + } + profiles::record_app_version(profiles::current(), env!("CARGO_PKG_VERSION")); + } + // Handle CLI subcommands after profile is initialized so that helpers like // discover_server() read the right profile's remote.json. if let Some(exit_code) = okena_cli::try_handle_cli() { @@ -492,28 +524,41 @@ fn main() { let has_display = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok(); let headless = explicit_headless || (cfg!(target_os = "linux") && listen_addr.is_some() && !has_display); - // Acquire instance lock to prevent multiple Okena processes from - // clobbering each other's workspace.json. - let _instance_lock = match persistence::acquire_instance_lock() { - Ok(guard) => guard, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); + if headless { + // Self-restart handoff (single-binary `okena --headless` daemon): a + // daemon restarting itself spawns this process with `--await-pid ` + // (see okena_remote_server::routes::restart). Wait for the outgoing + // daemon to exit before acquiring the lock (fail-fast against a live PID) + // and binding a port. Bounded; on timeout we proceed and let the lock + // surface the real error. + if let Some(old_pid) = + okena_remote_server::local::parse_await_pid(std::env::args()) + { + log::info!("restart: waiting for outgoing daemon (pid {old_pid}) to exit"); + let _ = okena_remote_server::local::wait_for_pid_exit( + old_pid, + std::time::Duration::from_secs(10), + ); } - }; - if headless { - let Some(addr) = listen_addr else { - eprintln!("Headless mode requires --listen , e.g. --headless --listen 0.0.0.0"); - std::process::exit(1); + // The headless path IS the daemon — the single writer (§5). It owns the + // instance lock + workspace.json. (The GUI path below never locks: it is + // always a thin daemon-client and the daemon it spawns/attaches holds + // the lock.) Held for run_headless's lifetime. + let _instance_lock = match persistence::acquire_instance_lock() { + Ok(guard) => guard, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } }; - run_headless(addr); + run_headless(listen_addr); return; } if !has_display && cfg!(target_os = "linux") { eprintln!("No display server found (DISPLAY/WAYLAND_DISPLAY not set)."); - eprintln!("Use --headless --listen to run without a GUI."); + eprintln!("Use --headless [--listen ] to run without a GUI."); std::process::exit(1); } @@ -646,22 +691,22 @@ fn main() { let app_settings = settings_entity.read(cx).get().clone(); - // Load or create workspace - let workspace_data = persistence::load_workspace(app_settings.session_backend).unwrap_or_else(|e| { - log::error!("Failed to load workspace: {}. A backup may have been saved to {:?}. Using default workspace.", e, persistence::get_workspace_path().with_extension("json.bak")); - let backup_path = persistence::get_workspace_path().with_extension("json.bak"); - ToastManager::post( - Toast::error(format!( - "Workspace file was corrupted. A backup was saved to {}. \ - Starting with default workspace. Auto-save is disabled to protect your data — \ - restart the app after fixing the file.", - backup_path.display() - )) - .with_ttl(std::time::Duration::from_secs(30)), - cx, - ); - persistence::default_workspace() - }); + // The daemon owns the real workspace (it holds the instance lock + + // workspace.json); the GUI is always a thin client and starts empty — + // projects arrive via the mirror snapshot (apply_remote_snapshot) from + // the loopback daemon connection registered in Okena::new. + let mut workspace_data = workspace::state::WorkspaceData::empty(); + // Restore CLIENT-OWNED window layout (which windows are open + their OS + // bounds + per-window viewport). This is presentation the GUI owns + // locally — separate from the daemon's workspace.json. Populating it + // before the main window opens restores main bounds (read below) and + // lets the startup extras-observer in `Okena::new` reopen every extra + // window the user had. Snapshots don't clobber it (apply_remote_snapshot + // never overwrites main_window/extra_windows). + if let Some(layout) = persistence::load_window_layout() { + workspace_data.main_window = layout.main_window; + workspace_data.extra_windows = layout.extra_windows; + } // Create theme entity from settings, restoring custom theme if applicable let theme_entity = cx.new(|_cx| { @@ -698,9 +743,18 @@ fn main() { // NOTE: Terminal and git view settings are now served through // ExtensionSettingsStore (registered above) — no separate globals needed. - // Create PTY manager with session backend from settings - let (pty_manager, pty_events) = PtyManager::new(app_settings.session_backend); - let pty_manager = Arc::new(pty_manager); + // Discover-or-spawn the local headless daemon and mint a loopback token. + // The desktop is always a thin client of this daemon, so a failure here + // is fatal. Blocking (up to ~30s on a cold spawn). `Okena::new` registers + // the loopback connection and (if we spawned it) owns the daemon's + // lifecycle. + let local_daemon = match okena_remote_server::local::ensure_local_daemon() { + Ok(ensured) => ensured, + Err(e) => { + eprintln!("Failed to start local daemon: {e}"); + std::process::exit(1); + } + }; // Create the main window #[allow( @@ -810,7 +864,7 @@ fn main() { // Create the main app view wrapped in Root (required for gpui_component inputs) let okena = cx.new(|cx| { - Okena::new(workspace_data, pty_manager.clone(), pty_events, listen_addr, window, cx) + Okena::new(workspace_data, local_daemon, window, cx) }); cx.new(|cx| Root::new(okena, window, cx)) }, @@ -830,11 +884,9 @@ fn main() { gs.0.read(cx).flush_pending_save(); } - // Flush pending workspace save - if let Some(gw) = cx.try_global::() - && let Err(e) = persistence::save_workspace(gw.0.read(cx).data()) { - log::error!("Failed to flush workspace on quit: {}", e); - } + // NOTE: do NOT save workspace.json here — the daemon is the single + // writer (§5) and the GUI's Workspace is a read-only mirror. See the + // `quit` handler above for why writing it here corrupts the profile. async {} }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ede5655be..48d4de08f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -42,7 +42,7 @@ export async function getState(): Promise { return res.json(); } -export async function postAction(action: ActionRequest): Promise> { +export async function postAction(action: ActionRequest): Promise { const res = await fetch(`${baseUrl()}/v1/actions`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8d9cb9274..090323f98 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,6 +15,7 @@ export interface StateResponse { fullscreen_terminal: ApiFullscreen | null; project_order?: string[]; folders?: ApiFolder[]; + windows?: ApiWindow[]; } export interface ApiProject { @@ -26,6 +27,14 @@ export interface ApiProject { terminal_names: Record; git_status?: ApiGitStatus | null; folder_color?: string; + services?: ApiServiceInfo[]; + worktree_info?: ApiWorktreeMetadata | null; + worktree_ids?: string[]; + pinned?: boolean; + last_activity_at?: number | null; + default_shell?: ShellType | null; + hook_terminals?: ApiHookTerminalEntry[]; + hooks?: ApiHooksConfig; } export interface ApiFolder { @@ -75,13 +84,158 @@ export interface ApiGitStatus { ahead?: number | null; behind?: number | null; unpushed?: number | null; + review_base?: string | null; } -export type DiffMode = "working_tree" | "staged"; +export interface FileDiffSummary { + path: string; + added: number; + removed: number; + is_new: boolean; +} + +export interface DirectoryEntry { + name: string; + is_dir: boolean; +} + +export type DiffMode = + | "working_tree" + | "staged" + | { commit: string } + | { branch_compare: { base: string; head: string } }; + +export type FolderColor = + | "default" + | "red" + | "orange" + | "yellow" + | "lime" + | "green" + | "teal" + | "cyan" + | "blue" + | "indigo" + | "purple" + | "pink"; + +export type ShellType = + | { type: "Default" } + | { type: "Custom"; path: string; args?: string[] } + | { type: "Cmd" } + | { type: "PowerShell"; core?: boolean } + | { type: "Wsl"; distro?: string | null }; + +export interface ApiWindowBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface ApiWindow { + id: string; + kind: string; + active: boolean; + focused_project_id?: string | null; + focused_terminal_id?: string | null; + fullscreen?: ApiFullscreen | null; + visible_project_ids?: string[]; + folder_filter?: string | null; + bounds?: ApiWindowBounds | null; + sidebar_open?: boolean | null; +} + +export interface ApiServiceInfo { + name: string; + status: string; + terminal_id: string | null; + ports?: number[]; + exit_code?: number | null; + kind?: string; + is_extra?: boolean; +} + +export interface ApiWorktreeMetadata { + parent_project_id: string; + color_override?: FolderColor | null; +} + +export type ApiHookTerminalStatus = + | { state: "running" } + | { state: "succeeded" } + | { state: "failed"; exit_code: number }; + +export interface ApiHookTerminalEntry { + terminal_id: string; + label: string; + status: ApiHookTerminalStatus; + hook_type: string; + command: string; + cwd: string; +} + +export interface ApiProjectHooks { + on_open?: string | null; + on_close?: string | null; +} + +export interface ApiTerminalHooks { + on_create?: string | null; + on_close?: string | null; + shell_wrapper?: string | null; +} + +export interface ApiWorktreeHooks { + on_create?: string | null; + on_close?: string | null; + pre_merge?: string | null; + post_merge?: string | null; + before_remove?: string | null; + after_remove?: string | null; + on_rebase_conflict?: string | null; + on_dirty_close?: string | null; +} + +export interface ApiHooksConfig { + project?: ApiProjectHooks; + terminal?: ApiTerminalHooks; + worktree?: ApiWorktreeHooks; +} + +export interface ApiToastAction { + id: string; + label: string; + style: string; +} + +export interface ApiToast { + id: string; + level: string; + message: string; + detail?: string | null; + ttl_ms: number; + actions?: ApiToastAction[]; +} + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; // serde(tag = "type", rename_all = "lowercase") export type ApiLayoutNode = - | { type: "terminal"; terminal_id: string | null; minimized: boolean; detached: boolean } + | { + type: "terminal"; + terminal_id: string | null; + minimized: boolean; + detached: boolean; + cols?: number | null; + rows?: number | null; + } | { type: "split"; direction: SplitDirection; sizes: number[]; children: ApiLayoutNode[] } | { type: "tabs"; children: ApiLayoutNode[]; active_tab: number }; @@ -100,19 +254,126 @@ export type ActionRequest = | { action: "send_special_key"; terminal_id: string; key: SpecialKey } | { action: "split_terminal"; project_id: string; path: number[]; direction: SplitDirection } | { action: "close_terminal"; project_id: string; terminal_id: string } - | { action: "focus_terminal"; project_id: string; terminal_id: string } + | { action: "close_terminals"; project_id: string; terminal_ids: string[] } + | { action: "undo_soft_close"; terminal_id: string } + | { action: "close_terminal_now"; terminal_id: string } + | { action: "focus_terminal"; project_id: string; terminal_id: string; window?: string | null } | { action: "read_content"; terminal_id: string } + | { action: "export_buffer"; terminal_id: string } | { action: "resize"; terminal_id: string; cols: number; rows: number } | { action: "create_terminal"; project_id: string } | { action: "update_split_sizes"; project_id: string; path: number[]; sizes: number[] } + | { action: "toggle_minimized"; project_id: string; terminal_id: string } + | { action: "set_fullscreen"; project_id: string; terminal_id: string | null; window?: string | null } + | { action: "rename_terminal"; project_id: string; terminal_id: string; name: string } + | { action: "switch_terminal_shell"; project_id: string; terminal_id: string; shell: ShellType } + | { action: "add_tab"; project_id: string; path: number[]; in_group: boolean } + | { action: "set_active_tab"; project_id: string; path: number[]; index: number } + | { action: "move_tab"; project_id: string; path: number[]; from_index: number; to_index: number } + | { + action: "move_terminal_to_tab_group"; + project_id: string; + terminal_id: string; + target_path: number[]; + position?: number | null; + target_project_id?: string | null; + } + | { + action: "move_pane_to"; + project_id: string; + terminal_id: string; + target_project_id: string; + target_terminal_id: string; + zone: string; + } | { action: "git_status"; project_id: string } | { action: "git_diff_summary"; project_id: string } | { action: "git_diff"; project_id: string; mode?: DiffMode; ignore_whitespace?: boolean } | { action: "git_branches"; project_id: string } | { action: "git_file_contents"; project_id: string; file_path: string; mode?: DiffMode } + | { action: "git_commit_graph"; project_id: string; count: number; branch?: string | null } + | { action: "git_list_branches"; project_id: string } + | { action: "git_list_worktrees"; project_id: string } + | { action: "worktree_close_info"; project_id: string } + | { action: "generate_worktree_branch_name"; project_id: string } + | { action: "git_list_branches_classified"; project_id: string } + | { action: "git_checkout_local_branch"; project_id: string; branch: string } + | { action: "git_checkout_remote_branch"; project_id: string; remote_branch: string } + | { action: "git_create_and_checkout_branch"; project_id: string; new_name: string; start_point?: string | null } + | { action: "git_stage_file"; project_id: string; file_path: string } + | { action: "git_unstage_file"; project_id: string; file_path: string } + | { action: "git_discard_file"; project_id: string; file_path: string } + | { action: "git_blame"; project_id: string; relative_path: string } + | { action: "add_project"; name: string; path: string } | { action: "reorder_project_in_folder"; folder_id: string; project_id: string; new_index: number } - | { action: "set_project_color"; project_id: string; color: string } - | { action: "set_folder_color"; folder_id: string; color: string }; + | { action: "set_project_color"; project_id: string; color: FolderColor } + | { action: "set_folder_color"; folder_id: string; color: FolderColor } + | { action: "start_service"; project_id: string; service_name: string } + | { action: "stop_service"; project_id: string; service_name: string } + | { action: "restart_service"; project_id: string; service_name: string } + | { action: "start_all_services"; project_id: string } + | { action: "stop_all_services"; project_id: string } + | { action: "reload_services"; project_id: string } + | { action: "create_worktree"; project_id: string; branch: string; create_branch?: boolean } + | { action: "add_discovered_worktree"; parent_project_id: string; worktree_path: string; branch: string } + | { action: "rerun_hook"; project_id: string; terminal_id: string } + | { action: "list_files"; project_id: string; show_ignored?: boolean } + | { action: "list_directory"; project_id: string; relative_path?: string; show_ignored?: boolean } + | { action: "read_file"; project_id: string; relative_path: string } + | { action: "read_file_bytes"; project_id: string; relative_path: string } + | { action: "file_size"; project_id: string; relative_path: string } + | { + action: "search_content"; + project_id: string; + query: string; + case_sensitive?: boolean; + mode?: string; + max_results?: number; + file_glob?: string | null; + context_lines?: number; + } + | { action: "rename_file"; project_id: string; relative_path: string; new_name: string } + | { action: "delete_file"; project_id: string; relative_path: string } + | { action: "create_file"; project_id: string; relative_path: string } + | { action: "create_directory"; project_id: string; relative_path: string } + | { action: "rename_project"; project_id: string; name: string } + | { action: "update_project_hooks"; project_id: string; hooks: ApiHooksConfig } + | { action: "rename_project_directory"; project_id: string; new_name: string } + | { action: "delete_project"; project_id: string } + | { action: "set_project_show_in_overview"; project_id: string; show: boolean; window?: string | null } + | { action: "remove_worktree_project"; project_id: string; force?: boolean } + | { + action: "close_worktree"; + project_id: string; + merge?: boolean; + stash?: boolean; + fetch?: boolean; + push?: boolean; + delete_branch?: boolean; + } + | { action: "create_folder"; name: string } + | { action: "delete_folder"; folder_id: string } + | { action: "rename_folder"; folder_id: string; name: string } + | { action: "move_project_to_folder"; project_id: string; folder_id: string; position?: number | null } + | { action: "move_project_out_of_folder"; project_id: string; top_level_index: number } + | { action: "move_project"; project_id: string; new_index: number } + | { action: "move_item_in_order"; item_id: string; new_index: number } + | { action: "toggle_project_pinned"; project_id: string } + | { action: "reorder_worktree"; parent_id: string; worktree_id: string; new_index: number } + | { action: "set_worktree_color_override"; project_id: string; color?: FolderColor | null } + | { action: "load_session"; name: string } + | { action: "save_session"; name: string } + | { action: "import_workspace"; path: string } + | { action: "export_workspace"; path: string } + | { action: "get_settings" } + | { action: "get_settings_schema" } + | { action: "set_settings"; patch: JsonValue } + | { action: "get_themes" } + | { action: "get_theme"; id?: string | null } + | { action: "set_theme"; id: string } + | { action: "save_custom_theme"; id: string; config: JsonValue; activate?: boolean } + | { action: "list_actions" } + | { action: "invoke_action"; action_name: string; window?: string | null }; export interface PairRequest { code: string; @@ -143,17 +404,21 @@ export type WsInbound = export type WsOutbound = | { type: "auth_ok" } | { type: "auth_failed"; error: string } - | { type: "subscribed"; mappings: Record } + | { type: "subscribed"; mappings: Record; sizes?: Record } | { type: "state_changed"; state_version: number } | { type: "dropped"; count: number } | { type: "pong" } | { type: "error"; error: string } - | { type: "git_status_changed"; projects: Record }; + | { type: "git_status_changed"; projects: Record } + | { type: "toast"; id: string; level: string; message: string; detail?: string | null; ttl_ms: number; actions?: ApiToastAction[] } + | { type: "terminal_resized"; terminal_id: string; cols: number; rows: number; server_owns?: boolean }; // Default PascalCase serialization export type SpecialKey = | "Enter" | "Escape" + | "Backspace" + | "Delete" | "CtrlC" | "CtrlD" | "CtrlZ" @@ -165,7 +430,8 @@ export type SpecialKey = | "Home" | "End" | "PageUp" - | "PageDown"; + | "PageDown" + | { Ctrl: string }; // ── Binary frame protocol ─────────────────────────────────────────────────── diff --git a/web/src/components/FileViewerModal.tsx b/web/src/components/FileViewerModal.tsx new file mode 100644 index 000000000..1ca89ffd2 --- /dev/null +++ b/web/src/components/FileViewerModal.tsx @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { postAction } from "../api/client"; +import type { ApiProject, DirectoryEntry } from "../api/types"; + +type DirectoryState = + | { status: "idle"; entries: DirectoryEntry[] } + | { status: "loading"; entries: DirectoryEntry[] } + | { status: "error"; entries: DirectoryEntry[]; message: string }; + +type FileState = + | { status: "empty" } + | { status: "loading"; path: string } + | { status: "loaded"; path: string; content: string } + | { status: "error"; path: string; message: string }; + +export function FileViewerModal({ + project, + onClose, +}: { + project: ApiProject; + onClose: () => void; +}) { + const [directory, setDirectory] = useState(""); + const [directoryState, setDirectoryState] = useState({ status: "idle", entries: [] }); + const [fileState, setFileState] = useState({ status: "empty" }); + const [showIgnored, setShowIgnored] = useState(false); + + const sortedEntries = useMemo(() => { + return [...directoryState.entries].sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [directoryState.entries]); + + const loadDirectory = useCallback(async () => { + setDirectoryState((current) => ({ status: "loading", entries: current.entries })); + try { + const payload = await postAction({ + action: "list_directory", + project_id: project.id, + relative_path: directory, + show_ignored: showIgnored, + }); + setDirectoryState({ status: "idle", entries: parseDirectoryEntries(payload) }); + } catch (error) { + setDirectoryState({ + status: "error", + entries: [], + message: error instanceof Error ? error.message : "Failed to list directory", + }); + } + }, [project.id, directory, showIgnored]); + + const readFile = useCallback( + async (path: string) => { + setFileState({ status: "loading", path }); + try { + const payload = await postAction({ + action: "read_file", + project_id: project.id, + relative_path: path, + }); + setFileState({ status: "loaded", path, content: parseFileContent(payload) }); + } catch (error) { + setFileState({ + status: "error", + path, + message: error instanceof Error ? error.message : "Failed to read file", + }); + } + }, + [project.id], + ); + + useEffect(() => { + loadDirectory(); + }, [loadDirectory]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const openEntry = useCallback( + (entry: DirectoryEntry) => { + const nextPath = joinPath(directory, entry.name); + if (entry.is_dir) { + setDirectory(nextPath); + setFileState({ status: "empty" }); + } else { + readFile(nextPath); + } + }, + [directory, readFile], + ); + + const goUp = useCallback(() => { + setDirectory(parentPath(directory)); + setFileState({ status: "empty" }); + }, [directory]); + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={`Files for ${project.name}`} + > +
+
+
{project.name}
+
+ {directory || "."} +
+
+ + + + +
+ +
+ + +
+ +
+
+
+
+ ); +} + +function DirectoryListing({ + state, + entries, + onOpenEntry, +}: { + state: DirectoryState; + entries: DirectoryEntry[]; + onOpenEntry: (entry: DirectoryEntry) => void; +}) { + if (state.status === "error") { + return
{state.message}
; + } + if (entries.length === 0) { + return ( +
+ {state.status === "loading" ? "Loading..." : "Empty directory"} +
+ ); + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} + +function FilePreview({ state }: { state: FileState }) { + if (state.status === "empty") { + return ( +
+ Select a file +
+ ); + } + if (state.status === "loading") { + return
Loading {state.path}...
; + } + if (state.status === "error") { + return ( +
+
{state.path}
+
{state.message}
+
+ ); + } + + return ( +
+
+ {state.path} + + {state.content.split("\n").length} lines + +
+
+        {state.content}
+      
+
+ ); +} + +function parseDirectoryEntries(payload: unknown): DirectoryEntry[] { + if (!Array.isArray(payload)) return []; + return payload.flatMap((item) => { + if (!isRecord(item)) return []; + const name = item.name; + const isDir = item.is_dir; + if (typeof name !== "string" || typeof isDir !== "boolean") return []; + return [{ name, is_dir: isDir }]; + }); +} + +function parseFileContent(payload: unknown): string { + if (!isRecord(payload)) return ""; + return typeof payload.content === "string" ? payload.content : ""; +} + +function joinPath(base: string, name: string): string { + return base ? `${base}/${name}` : name; +} + +function parentPath(path: string): string { + const parts = path.split("/").filter(Boolean); + parts.pop(); + return parts.join("/"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/web/src/components/FilesPanel.tsx b/web/src/components/FilesPanel.tsx new file mode 100644 index 000000000..7d22de828 --- /dev/null +++ b/web/src/components/FilesPanel.tsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { postAction } from "../api/client"; +import type { ApiProject, DirectoryEntry } from "../api/types"; + +type DirectoryState = + | { status: "idle"; entries: DirectoryEntry[] } + | { status: "loading"; entries: DirectoryEntry[] } + | { status: "error"; entries: DirectoryEntry[]; message: string }; + +type FileState = + | { status: "empty" } + | { status: "loading"; path: string } + | { status: "loaded"; path: string; content: string } + | { status: "error"; path: string; message: string }; + +export function FilesPanel({ project }: { project: ApiProject }) { + const [directory, setDirectory] = useState(""); + const [directoryState, setDirectoryState] = useState({ status: "idle", entries: [] }); + const [fileState, setFileState] = useState({ status: "empty" }); + const [showIgnored, setShowIgnored] = useState(false); + + const sortedEntries = useMemo(() => directoryState.entries, [directoryState.entries]); + + const loadDirectory = useCallback(async () => { + setDirectoryState((current) => ({ status: "loading", entries: current.entries })); + try { + const payload = await postAction({ + action: "list_directory", + project_id: project.id, + relative_path: directory, + show_ignored: showIgnored, + }); + setDirectoryState({ status: "idle", entries: parseDirectoryEntries(payload) }); + } catch (error) { + setDirectoryState({ + status: "error", + entries: [], + message: error instanceof Error ? error.message : "Failed to list directory", + }); + } + }, [project.id, directory, showIgnored]); + + const readFile = useCallback( + async (path: string) => { + setFileState({ status: "loading", path }); + try { + const payload = await postAction({ + action: "read_file", + project_id: project.id, + relative_path: path, + }); + const content = parseFileContent(payload); + setFileState({ status: "loaded", path, content }); + } catch (error) { + setFileState({ + status: "error", + path, + message: error instanceof Error ? error.message : "Failed to read file", + }); + } + }, + [project.id], + ); + + useEffect(() => { + setDirectory(""); + setFileState({ status: "empty" }); + }, [project.id]); + + useEffect(() => { + loadDirectory(); + }, [loadDirectory]); + + const openEntry = useCallback( + (entry: DirectoryEntry) => { + const nextPath = joinPath(directory, entry.name); + if (entry.is_dir) { + setDirectory(nextPath); + setFileState({ status: "empty" }); + } else { + readFile(nextPath); + } + }, + [directory, readFile], + ); + + const goUp = useCallback(() => { + setDirectory(parentPath(directory)); + setFileState({ status: "empty" }); + }, [directory]); + + return ( + + ); +} + +function FilePreview({ state }: { state: FileState }) { + if (state.status === "empty") { + return
Select a file
; + } + if (state.status === "loading") { + return
Loading {state.path}...
; + } + if (state.status === "error") { + return ( +
+
{state.path}
+
{state.message}
+
+ ); + } + + return ( +
+
+ {state.path} +
+
+        {state.content}
+      
+
+ ); +} + +function parseDirectoryEntries(payload: unknown): DirectoryEntry[] { + if (!Array.isArray(payload)) return []; + return payload.flatMap((item) => { + if (!isRecord(item)) return []; + const name = item.name; + const isDir = item.is_dir; + if (typeof name !== "string" || typeof isDir !== "boolean") return []; + return [{ name, is_dir: isDir }]; + }); +} + +function parseFileContent(payload: unknown): string { + if (!isRecord(payload)) return ""; + return typeof payload.content === "string" ? payload.content : ""; +} + +function joinPath(base: string, name: string): string { + return base ? `${base}/${name}` : name; +} + +function parentPath(path: string): string { + const parts = path.split("/").filter(Boolean); + parts.pop(); + return parts.join("/"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/web/src/components/GitPanel.tsx b/web/src/components/GitPanel.tsx new file mode 100644 index 000000000..d38ed1a12 --- /dev/null +++ b/web/src/components/GitPanel.tsx @@ -0,0 +1,412 @@ +import { useCallback, useEffect, useState } from "react"; +import { postAction } from "../api/client"; +import type { ApiGitStatus, ApiProject, CiCheck, CiStatus, FileDiffSummary, PrState } from "../api/types"; + +const CHIP_CLASS = "inline-flex h-5 items-center gap-1 rounded-[3px] px-1.5 text-[11px] text-[var(--ok-text-secondary)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text)]"; + +type LoadState = + | { status: "idle"; files: FileDiffSummary[] } + | { status: "loading"; files: FileDiffSummary[] } + | { status: "error"; files: FileDiffSummary[]; message: string }; + +export function GitPanel({ project }: { project: ApiProject }) { + const git = project.git_status ?? null; + const [loadState, setLoadState] = useState({ status: "idle", files: [] }); + const [detailsOpen, setDetailsOpen] = useState(false); + + const hasChanges = Boolean(git && (git.lines_added > 0 || git.lines_removed > 0)); + + const loadSummary = useCallback(async () => { + if (!git) return; + setLoadState((current) => ({ status: "loading", files: current.files })); + try { + const payload = await postAction({ action: "git_diff_summary", project_id: project.id }); + setLoadState({ status: "idle", files: parseFileDiffSummaries(payload) }); + } catch (error) { + setLoadState({ + status: "error", + files: [], + message: error instanceof Error ? error.message : "Failed to load git changes", + }); + } + }, [project.id, git]); + + useEffect(() => { + setLoadState({ status: "idle", files: [] }); + setDetailsOpen(false); + }, [project.id]); + + useEffect(() => { + if (hasChanges) { + loadSummary(); + } + }, [hasChanges, loadSummary]); + + if (!git || !git.branch) { + return ( +
+ git unavailable +
+ ); + } + + return ( +
+
+ + + {git.pr_info && ( + + pr + #{git.pr_info.number} + + )} + + {git.ci_checks && ( + + )} + + {hasChanges && ( + + )} + + {aheadBehindParts(git).map((part) => ( + + ))} + + +
+ + {detailsOpen && ( + setDetailsOpen(false)} + /> + )} +
+ ); +} + +function GitStatusPopover({ + git, + loadState, + onRefresh, + onClose, +}: { + git: ApiGitStatus; + loadState: LoadState; + onRefresh: () => void; + onClose: () => void; +}) { + const checks = git.ci_checks?.checks ?? []; + + return ( +
+
+ git status + {git.branch ?? "detached"} + +
+ +
+
+ + + + + + +
+ + {git.pr_info && ( +
+
pull request
+ + {prStateLabel(git.pr_info.state)} + #{git.pr_info.number} + open + +
+ )} + + {git.ci_checks && ( +
+
+ checks + {ciTooltip(git.ci_checks)} +
+ {checks.length === 0 ? ( +
No checks reported
+ ) : ( +
+ {checks.map((check) => ( + + ))} +
+ )} +
+ )} + +
+
+ diff summary + +
+ +
+
+
+ ); +} + +function Metric({ + label, + value, + valueClassName, +}: { + label: string; + value: string; + valueClassName: string; +}) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function CiRow({ check }: { check: CiCheck }) { + const content = ( + <> + + {check.is_skipped ? "skip" : ciStatusLabel(check.status)} + + {check.name} + {check.workflow && {check.workflow}} + {formatElapsed(check.elapsed_ms)} + + ); + + if (check.link) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} + +function DiffSummary({ state }: { state: LoadState }) { + if (state.status === "error") { + return
{state.message}
; + } + if (state.files.length === 0) { + return ( +
+ {state.status === "loading" ? "Loading..." : "No file changes"} +
+ ); + } + + return ( +
+ {state.files.slice(0, 20).map((file) => ( + + ))} + {state.files.length > 20 && ( +
+{state.files.length - 20} more files
+ )} +
+ ); +} + +function FileRow({ file }: { file: FileDiffSummary }) { + return ( + <> +
{file.path}
+
+{file.added}
+
-{file.removed}
+
{file.is_new ? "new" : ""}
+ + ); +} + +function aheadBehindParts(git: ApiGitStatus): Array<{ key: string; label: string; className: string; title: string }> { + const ahead = git.ahead ?? 0; + const behind = git.behind ?? 0; + const unpushed = git.unpushed ?? 0; + const base = git.review_base ?? "base"; + const parts: Array<{ key: string; label: string; className: string; title: string }> = []; + + if (ahead > 0) { + parts.push({ + key: "ahead", + label: `up ${ahead}`, + className: "text-[var(--ok-green)]", + title: `${ahead} commit${ahead === 1 ? "" : "s"} ahead of ${base}`, + }); + } + if (behind > 0) { + parts.push({ + key: "behind", + label: `down ${behind}`, + className: "text-[var(--ok-yellow)]", + title: `${behind} commit${behind === 1 ? "" : "s"} behind ${base}`, + }); + } + if (unpushed > 0 && git.unpushed !== git.ahead) { + parts.push({ + key: "unpushed", + label: `push ${unpushed}`, + className: "text-[var(--ok-blue)]", + title: `${unpushed} commit${unpushed === 1 ? "" : "s"} not pushed to origin/`, + }); + } + + return parts; +} + +function ciTooltip(checks: { status: CiStatus; passed: number; failed: number; pending: number; total: number }): string { + switch (checks.status) { + case "Success": + return `${checks.passed}/${checks.total} checks passed`; + case "Failure": + return `${checks.failed} failed, ${checks.passed} passed of ${checks.total}`; + case "Pending": + return `${checks.pending} pending, ${checks.passed} passed of ${checks.total}`; + } +} + +function ciStatusLabel(status: CiStatus): string { + switch (status) { + case "Success": + return "ok"; + case "Failure": + return "fail"; + case "Pending": + return "run"; + } +} + +function ciClassName(status: CiStatus): string { + switch (status) { + case "Success": + return "text-[var(--ok-green)]"; + case "Failure": + return "text-[var(--ok-red)]"; + case "Pending": + return "text-[var(--ok-yellow)]"; + } +} + +function prStateLabel(state: PrState): string { + switch (state) { + case "Open": + return "Open"; + case "Draft": + return "Draft"; + case "Merged": + return "Merged"; + case "Closed": + return "Closed"; + } +} + +function prStateClass(state: PrState): string { + switch (state) { + case "Open": + return "text-[var(--ok-green)]"; + case "Draft": + return "text-[var(--ok-text-muted)]"; + case "Merged": + return "text-[#c678dd]"; + case "Closed": + return "text-[var(--ok-red)]"; + } +} + +function formatElapsed(ms: number | undefined): string { + if (!ms) return "-"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m${seconds % 60}s`; +} + +function parseFileDiffSummaries(payload: unknown): FileDiffSummary[] { + if (!Array.isArray(payload)) return []; + return payload.flatMap((item) => { + if (!isRecord(item)) return []; + const path = item.path; + const added = item.added; + const removed = item.removed; + const isNew = item.is_new; + if (typeof path !== "string" || typeof added !== "number" || typeof removed !== "number" || typeof isNew !== "boolean") { + return []; + } + return [{ path, added, removed, is_new: isNew }]; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/web/src/components/PairingScreen.tsx b/web/src/components/PairingScreen.tsx index f4f12f248..0d4961e74 100644 --- a/web/src/components/PairingScreen.tsx +++ b/web/src/components/PairingScreen.tsx @@ -27,16 +27,16 @@ export function PairingScreen({ onPaired }: { onPaired: () => void }) { }; return ( -
-
-
-

Okena

-

+

+
+
+

Okena

+

Enter the pairing code from the desktop app status bar

-
+ void }) { placeholder="XXXX-XXXX" maxLength={9} autoFocus - className="w-full px-4 py-3 text-center text-2xl tracking-[0.2em] font-mono - bg-zinc-900 border border-zinc-700 rounded-lg text-zinc-100 - placeholder:text-zinc-600 focus:outline-none focus:border-blue-500 + className="w-full border border-[var(--ok-border)] bg-[var(--ok-terminal)] px-4 py-3 text-center font-mono text-2xl tracking-[0.2em] + text-[var(--ok-text)] placeholder:text-[var(--ok-text-muted)] disabled:opacity-50" disabled={loading} /> {error && ( -

{error}

+

{error}

)} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index f0555e5f7..4a3116b69 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,25 +1,54 @@ import { useApp } from "../state/store"; import { SidebarProject } from "./SidebarProject"; +import { buildSidebarItems } from "../utils/sidebar"; export function Sidebar({ isMobile = false }: { isMobile?: boolean }) { const { state } = useApp(); - const projects = state.workspace?.projects ?? []; + const items = buildSidebarItems(state.workspace); return ( -
-
-

- Projects +
+
+
Okena
+
remote workspace
+
+
+

+ projects

- {projects.map((p) => ( - - ))} + {items.map((item) => { + if (item.type === "folder") { + return ( +
+
+ {item.folder.name} + {item.projects.length} +
+ {item.projects.map((node) => ( + + ))} +
+ ); + } + + return ( + + ); + })}
diff --git a/web/src/components/SidebarProject.tsx b/web/src/components/SidebarProject.tsx index 25fe9951e..2e723ff57 100644 --- a/web/src/components/SidebarProject.tsx +++ b/web/src/components/SidebarProject.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import type { ApiProject } from "../api/types"; +import type { ApiProject, ApiServiceInfo } from "../api/types"; import { useApp } from "../state/store"; import { postAction } from "../api/client"; import { collectTerminalIds } from "../utils/layout"; @@ -8,35 +8,54 @@ export function SidebarProject({ project, selected, isMobile, + worktrees = [], + depth = 0, }: { project: ApiProject; selected: boolean; isMobile: boolean; + worktrees?: ApiProject[]; + depth?: number; }) { const { state, dispatch } = useApp(); const [expanded, setExpanded] = useState(selected); const terminalIds = collectTerminalIds(project.layout); + const services = project.services ?? []; - const handleProjectClick = useCallback(() => { + const selectProject = useCallback(() => { dispatch({ type: "select_project", projectId: project.id }); setExpanded((prev) => !prev); }, [dispatch, project.id]); - const handleTerminalClick = useCallback( - (terminalId: string) => { - dispatch({ type: "select_project", projectId: project.id }); + const selectTerminal = useCallback( + (projectId: string, terminalId: string) => { + dispatch({ type: "select_project", projectId }); dispatch({ type: "select_terminal", terminalId }); if (isMobile) { dispatch({ type: "set_sidebar_open", open: false }); } else { - postAction({ action: "focus_terminal", project_id: project.id, terminal_id: terminalId }).catch(() => {}); + postAction({ action: "focus_terminal", project_id: projectId, terminal_id: terminalId }).catch(() => {}); } }, - [dispatch, project.id, isMobile], + [dispatch, isMobile], ); - const handleCreateTerminal = useCallback( + const selectWorktree = useCallback( + (worktree: ApiProject) => { + const firstTerminalId = collectTerminalIds(worktree.layout)[0] ?? null; + dispatch({ type: "select_project", projectId: worktree.id }); + dispatch({ type: "select_terminal", terminalId: firstTerminalId }); + if (isMobile) { + dispatch({ type: "set_sidebar_open", open: false }); + } else if (firstTerminalId) { + postAction({ action: "focus_terminal", project_id: worktree.id, terminal_id: firstTerminalId }).catch(() => {}); + } + }, + [dispatch, isMobile], + ); + + const createTerminal = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); postAction({ action: "create_terminal", project_id: project.id }).catch(() => {}); @@ -44,49 +63,203 @@ export function SidebarProject({ [project.id], ); + const togglePinned = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + postAction({ action: "toggle_project_pinned", project_id: project.id }).catch(() => {}); + }, + [project.id], + ); + + const toggleVisible = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + postAction({ + action: "set_project_show_in_overview", + project_id: project.id, + show: !project.show_in_overview, + }).catch(() => {}); + }, + [project.id, project.show_in_overview], + ); + + const runServiceAction = useCallback( + (service: ApiServiceInfo, action: "start_service" | "stop_service" | "restart_service") => { + postAction({ + action, + project_id: project.id, + service_name: service.name, + }).catch(() => {}); + }, + [project.id], + ); + + const reloadServices = useCallback(() => { + postAction({ action: "reload_services", project_id: project.id }).catch(() => {}); + }, [project.id]); + const isExpanded = expanded || selected; + const hasChildren = terminalIds.length > 0 || services.length > 0 || worktrees.length > 0; + const muted = !project.show_in_overview; return ( -
- - + + +
- {isExpanded && terminalIds.length > 0 && ( + {isExpanded && (
- {terminalIds.map((tid) => { - const name = project.terminal_names[tid] ?? "Terminal"; - const isSelected = tid === state.selectedTerminalId && selected; + {terminalIds.map((terminalId) => { + const name = project.terminal_names[terminalId] ?? "Terminal"; + const isSelected = terminalId === state.selectedTerminalId && selected; return ( ); })} + + {services.length > 0 && ( +
+
+ services + +
+
+ {services.map((service) => ( + selectTerminal(project.id, terminalId)} + onStart={() => runServiceAction(service, "start_service")} + onStop={() => runServiceAction(service, "stop_service")} + onRestart={() => runServiceAction(service, "restart_service")} + /> + ))} +
+
+ )} + + {worktrees.length > 0 && ( +
+
worktrees
+
+ {worktrees.map((worktree) => ( + + ))} +
+
+ )}
)}

); } + +function ServiceRow({ + service, + onOpenTerminal, + onStart, + onStop, + onRestart, +}: { + service: ApiServiceInfo; + onOpenTerminal: (terminalId: string) => void; + onStart: () => void; + onStop: () => void; + onRestart: () => void; +}) { + const status = service.status.toLowerCase(); + const canStart = status === "stopped" || status === "crashed"; + const canStop = status === "running" || status === "starting" || status === "restarting"; + const ports = service.ports?.length ? `:${service.ports.join(",")}` : ""; + const crash = service.exit_code != null ? ` exit ${service.exit_code}` : ""; + + return ( +
+ + {canStart ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/web/src/components/SplitLayout.tsx b/web/src/components/SplitLayout.tsx index ddee7a39f..0eae6be38 100644 --- a/web/src/components/SplitLayout.tsx +++ b/web/src/components/SplitLayout.tsx @@ -18,34 +18,42 @@ export function SplitLayout({ project: ApiProject; path: number[]; }) { - const isHorizontal = direction === "horizontal"; + const isHorizontalSplit = direction === "horizontal"; const containerRef = useRef(null); const [localSizes, setLocalSizes] = useState(null); + const [activeDivider, setActiveDivider] = useState(null); const draggingRef = useRef(false); + const liveSizesRef = useRef(serverSizes); const sizes = localSizes ?? serverSizes; const total = sizes.reduce((a, b) => a + b, 0) || 1; + liveSizesRef.current = sizes; - const handleMouseDown = useCallback( - (e: React.MouseEvent, dividerIndex: number) => { - e.preventDefault(); + const handlePointerDown = useCallback( + (event: React.PointerEvent, dividerIndex: number) => { + event.preventDefault(); + event.stopPropagation(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); - const containerSize = isHorizontal ? rect.width : rect.height; + const containerSize = isHorizontalSplit ? rect.height : rect.width; if (containerSize <= 0) return; - const startPos = isHorizontal ? e.clientX : e.clientY; + const startPos = isHorizontalSplit ? event.clientY : event.clientX; const currentSizes = [...(localSizes ?? serverSizes)]; const currentTotal = currentSizes.reduce((a, b) => a + b, 0) || 1; + const pointerId = event.pointerId; draggingRef.current = true; + setActiveDivider(dividerIndex); + event.currentTarget.setPointerCapture(pointerId); document.body.style.userSelect = "none"; - document.body.style.cursor = isHorizontal ? "col-resize" : "row-resize"; + document.body.style.cursor = isHorizontalSplit ? "row-resize" : "col-resize"; - const onMouseMove = (ev: MouseEvent) => { - const currentPos = isHorizontal ? ev.clientX : ev.clientY; + const onPointerMove = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + const currentPos = isHorizontalSplit ? ev.clientY : ev.clientX; const deltaPx = currentPos - startPos; const deltaPercent = (deltaPx / containerSize) * currentTotal; @@ -68,34 +76,33 @@ export function SplitLayout({ const next = [...currentSizes]; next[leftIdx] = newLeft; next[rightIdx] = newRight; + liveSizesRef.current = next; setLocalSizes(next); }; - const onMouseUp = () => { - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); + const onPointerUp = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); document.body.style.userSelect = ""; document.body.style.cursor = ""; draggingRef.current = false; - - // Persist to server - setLocalSizes((final_) => { - if (final_) { - postAction({ - action: "update_split_sizes", - project_id: project.id, - path, - sizes: final_, - }).catch(() => {}); - } - return final_; - }); + setActiveDivider(null); + + postAction({ + action: "update_split_sizes", + project_id: project.id, + path, + sizes: liveSizesRef.current, + }).catch(() => {}); }; - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); }, - [isHorizontal, serverSizes, localSizes, project.id, path], + [isHorizontalSplit, serverSizes, localSizes, project.id, path], ); // Sync local sizes with server when not dragging @@ -111,23 +118,28 @@ export function SplitLayout({ return (
{children.map((child, i) => (
{i > 0 && (
handleMouseDown(e, i - 1)} + className={`split-handle ${ + isHorizontalSplit ? "split-handle-vertical" : "split-handle-horizontal" + }`} + data-active={activeDivider === i - 1} + role="separator" + aria-orientation={isHorizontalSplit ? "horizontal" : "vertical"} + onPointerDown={(event) => handlePointerDown(event, i - 1)} /> )}
diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx index f889ebcaf..2163e5624 100644 --- a/web/src/components/StatusBar.tsx +++ b/web/src/components/StatusBar.tsx @@ -1,16 +1,16 @@ import { useApp } from "../state/store"; const STATUS_COLORS: Record = { - connected: "bg-green-500", - connecting: "bg-yellow-500", - disconnected: "bg-red-500", + connected: "bg-[var(--ok-green)]", + connecting: "bg-[var(--ok-yellow)]", + disconnected: "bg-[var(--ok-red)]", }; export function StatusBar() { const { state } = useApp(); return ( -
+
diff --git a/web/src/components/TabLayout.tsx b/web/src/components/TabLayout.tsx index 3dd551fc4..cdb135a02 100644 --- a/web/src/components/TabLayout.tsx +++ b/web/src/components/TabLayout.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { ApiLayoutNode, ApiProject } from "../api/types"; +import { postAction } from "../api/client"; import { LayoutRenderer } from "./TerminalArea"; export function TabLayout({ @@ -16,10 +17,35 @@ export function TabLayout({ const [activeIdx, setActiveIdx] = useState(initialActive); const clamped = Math.min(activeIdx, children.length - 1); + useEffect(() => { + setActiveIdx(initialActive); + }, [initialActive]); + + const selectTab = useCallback( + (index: number) => { + setActiveIdx(index); + postAction({ + action: "set_active_tab", + project_id: project.id, + path, + index, + }).catch(() => {}); + }, + [project.id, path], + ); + + const addTab = useCallback(() => { + postAction({ + action: "add_tab", + project_id: project.id, + path, + in_group: true, + }).catch(() => {}); + }, [project.id, path]); + return (
- {/* Tab bar */} -
+
{children.map((child, i) => { const label = child.type === "terminal" && child.terminal_id ? (project.terminal_names[child.terminal_id] ?? `Terminal ${i + 1}`) @@ -27,20 +53,27 @@ export function TabLayout({ return ( ); })} +
- {/* Active tab content */}
{children[clamped] && ( diff --git a/web/src/components/TerminalPane.tsx b/web/src/components/TerminalPane.tsx index 93bc828a5..b31875d67 100644 --- a/web/src/components/TerminalPane.tsx +++ b/web/src/components/TerminalPane.tsx @@ -88,6 +88,18 @@ export function TerminalPane({ postAction({ action: "close_terminal", project_id: projectId, terminal_id: terminalId }).catch(() => {}); }, [terminalId, projectId]); + const handleRename = useCallback(() => { + if (!terminalId) return; + const nextName = window.prompt("Rename terminal", name ?? "")?.trim(); + if (!nextName || nextName === name) return; + postAction({ + action: "rename_terminal", + project_id: projectId, + terminal_id: terminalId, + name: nextName, + }).catch(() => {}); + }, [terminalId, projectId, name]); + // Create xterm.js instance useEffect(() => { if (!containerRef.current) return; @@ -96,26 +108,26 @@ export function TerminalPane({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", theme: { - background: "#09090b", - foreground: "#e4e4e7", - cursor: "#e4e4e7", - selectionBackground: "#3f3f46", - black: "#18181b", - red: "#ef4444", - green: "#22c55e", - yellow: "#eab308", - blue: "#3b82f6", - magenta: "#a855f7", - cyan: "#06b6d4", - white: "#e4e4e7", - brightBlack: "#52525b", - brightRed: "#f87171", - brightGreen: "#4ade80", - brightYellow: "#facc15", - brightBlue: "#60a5fa", - brightMagenta: "#c084fc", - brightCyan: "#22d3ee", - brightWhite: "#fafafa", + background: "#1e1e1e", + foreground: "#cccccc", + cursor: "#aeafad", + selectionBackground: "#264f78", + black: "#000000", + red: "#cd3131", + green: "#0dbc79", + yellow: "#e5e510", + blue: "#2472c8", + magenta: "#bc3fbc", + cyan: "#11a8cd", + white: "#e5e5e5", + brightBlack: "#666666", + brightRed: "#f14c4c", + brightGreen: "#23d18b", + brightYellow: "#f5f543", + brightBlue: "#3b8eea", + brightMagenta: "#d670d6", + brightCyan: "#29b8db", + brightWhite: "#ffffff", }, allowProposedApi: true, scrollback: 5000, @@ -187,10 +199,9 @@ export function TerminalPane({ } return ( -
- {/* Header with name and action buttons */} -
- +
+
+ {name ?? "Terminal"}
@@ -198,24 +209,35 @@ export function TerminalPane({ <> )} + diff --git a/web/src/components/WorkspaceLayout.tsx b/web/src/components/WorkspaceLayout.tsx index 41e743f48..a46bed254 100644 --- a/web/src/components/WorkspaceLayout.tsx +++ b/web/src/components/WorkspaceLayout.tsx @@ -1,12 +1,15 @@ -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useApp } from "../state/store"; import { postAction } from "../api/client"; +import type { ApiProject } from "../api/types"; import { useIsMobile } from "../hooks/useIsMobile"; import { collectTerminalIds } from "../utils/layout"; import { Sidebar } from "./Sidebar"; import { TerminalArea } from "./TerminalArea"; import { TerminalPane } from "./TerminalPane"; import { StatusBar } from "./StatusBar"; +import { GitPanel } from "./GitPanel"; +import { FileViewerModal } from "./FileViewerModal"; export function WorkspaceLayout() { const isMobile = useIsMobile(); @@ -15,49 +18,251 @@ export function WorkspaceLayout() { function DesktopLayout() { const { state } = useApp(); - const project = state.workspace?.projects.find( - (p) => p.id === state.selectedProjectId, + const [fileViewerProjectId, setFileViewerProjectId] = useState(null); + const projects = resolveOverviewProjects( + state.workspace?.projects ?? [], + state.selectedProjectId, ); + const fileViewerProject = state.workspace?.projects.find((project) => project.id === fileViewerProjectId); + useRequestGitPollForVisibleProjects(projects, state.selectedProjectId); return ( -
+
-
+ {fileViewerProject && ( + setFileViewerProjectId(null)} + /> + )} +
+ ); +} + +function useRequestGitPollForVisibleProjects( + projects: readonly ApiProject[], + selectedProjectId: string | null, +): void { + const previousVisibleProjectIds = useRef>(new Set()); + + useEffect(() => { + const nextVisibleProjectIds = new Set(projects.map((project) => project.id)); + for (const project of projects) { + if (previousVisibleProjectIds.current.has(project.id)) { + continue; + } + requestGitPoll(project.id); + } + previousVisibleProjectIds.current = nextVisibleProjectIds; + }, [projects]); + + useEffect(() => { + if (selectedProjectId) { + requestGitPoll(selectedProjectId); + } + }, [selectedProjectId]); +} + +function requestGitPoll(projectId: string): void { + postAction({ action: "git_status", project_id: projectId }).catch(() => {}); +} + +function ProjectColumn({ + project, + selected, + onOpenFiles, +}: { + project: ApiProject; + selected: boolean; + onOpenFiles: () => void; +}) { + const { dispatch } = useApp(); + const accent = folderAccent(project.folder_color); + const terminalCount = collectTerminalIds(project.layout).length; + + return ( +
dispatch({ type: "select_project", projectId: project.id })} + > +
+
+
+
+ +

+ {project.name} +

+ {project.pinned && ( + + pin + + )} +
+
+ {compactPath(project.path)} + {terminalCount} term{terminalCount === 1 ? "" : "s"} +
+
+
+ + + +
+
+ +
+ {project.layout ? ( + + ) : ( + + )} +
+
+ ); +} + +function ProjectEmptyState({ project }: { project: ApiProject }) { + return ( +
+
+
empty project
+ +
); } +function OverviewEmptyState({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +function resolveOverviewProjects(projects: ApiProject[], selectedProjectId: string | null): ApiProject[] { + const visible = projects.filter((project) => project.show_in_overview); + if (!selectedProjectId) { + return visible.length > 0 ? visible : projects.slice(0, 1); + } + + const selected = projects.find((project) => project.id === selectedProjectId); + if (!selected) { + return visible.length > 0 ? visible : projects.slice(0, 1); + } + + if (visible.some((project) => project.id === selectedProjectId)) { + return visible; + } + + return [selected, ...visible]; +} + +function compactPath(path: string): string { + const parts = path.split("/").filter(Boolean); + if (parts.length <= 3) return path; + return `.../${parts.slice(-3).join("/")}`; +} + +function folderAccent(color: string | undefined): string { + switch (color) { + case "red": + return "#e06c75"; + case "orange": + return "#d19a66"; + case "yellow": + return "#e5c07b"; + case "lime": + return "#a3d955"; + case "green": + return "#98c379"; + case "teal": + return "#2fbda0"; + case "cyan": + return "#56d7e5"; + case "blue": + return "#61afef"; + case "indigo": + return "#818cf8"; + case "purple": + return "#c678dd"; + case "pink": + return "#e06c9f"; + default: + return "#8a9199"; + } +} + function MobileLayout() { const { state, dispatch } = useApp(); const project = state.workspace?.projects.find( (p) => p.id === state.selectedProjectId, ); + useRequestGitPollForVisibleProjects(project ? [project] : [], state.selectedProjectId); const terminalIds = project ? collectTerminalIds(project.layout) : []; diff --git a/web/src/index.css b/web/src/index.css index 33b85c955..4f7daa34a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,6 +1,44 @@ @import "tailwindcss"; @import "@xterm/xterm/css/xterm.css"; +@font-face { + font-family: "JetBrains Mono"; + src: url("../../assets/fonts/JetBrainsMono-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("../../assets/fonts/JetBrainsMono-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +:root { + --ok-canvas: #1e1e1e; + --ok-panel: #252526; + --ok-panel-raised: #2a2d2e; + --ok-header: #323233; + --ok-terminal: #1e1e1e; + --ok-terminal-muted: #252526; + --ok-border: #3c3c3c; + --ok-border-soft: rgba(255, 255, 255, 0.065); + --ok-border-strong: #007acc; + --ok-hover: #2a2d2e; + --ok-selection: #264f78; + --ok-text: #cccccc; + --ok-text-secondary: #9a9a9a; + --ok-text-muted: #6a6a6a; + --ok-blue: #007acc; + --ok-green: #4ec9b0; + --ok-yellow: #dcdcaa; + --ok-red: #f44747; + --ok-radius: 4px; +} + html, body, #root { @@ -8,10 +46,170 @@ body, margin: 0; } +body { + background: var(--ok-canvas); + color: var(--ok-text); + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 12px; +} + +button, +input { + font: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 1px solid var(--ok-border-strong); + outline-offset: -1px; +} + .xterm { height: 100%; } +.app-shell { + background: + linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px), + var(--ok-canvas); + background-size: 100% 34px; + color: var(--ok-text); +} + +.app-sidebar { + background: var(--ok-panel); + border-color: var(--ok-border); +} + +.project-overview { + background: var(--ok-canvas); +} + +.project-strip { + display: flex; + min-height: 0; + height: 100%; + overflow-x: auto; + overflow-y: hidden; +} + +.project-strip::-webkit-scrollbar { + height: 10px; +} + +.project-strip::-webkit-scrollbar-track { + background: var(--ok-canvas); +} + +.project-strip::-webkit-scrollbar-thumb { + background: #4a4a4a; + border: 2px solid var(--ok-canvas); +} + +.project-column { + min-width: 360px; + max-width: 920px; + flex: 1 0 clamp(390px, 42vw, 720px); + background: var(--ok-panel); + border-right: 1px solid var(--ok-border); +} + +.project-column:only-child { + max-width: none; +} + +.project-column[data-selected="true"] { + box-shadow: inset 0 0 0 1px rgba(0, 122, 204, 0.55); +} + +.project-header { + background: var(--ok-header); + border-color: var(--ok-border); +} + +.panel-rule { + border-color: var(--ok-border); +} + +.soft-rule { + border-color: var(--ok-border-soft); +} + +.terminal-pane { + background: var(--ok-terminal); +} + +.terminal-header { + min-height: 30px; + background: var(--ok-terminal-muted); + border-color: var(--ok-border); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--ok-radius); + color: var(--ok-text-muted); + transition: background-color 120ms ease, color 120ms ease; +} + +.icon-button:hover { + background: var(--ok-hover); + color: var(--ok-text); +} + +.icon-button-danger:hover { + color: var(--ok-red); +} + +.split-handle { + position: relative; + flex: none; + z-index: 5; + background: var(--ok-canvas); + touch-action: none; +} + +.split-handle::after { + content: ""; + position: absolute; + background: var(--ok-border); + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.split-handle:hover::after, +.split-handle[data-active="true"]::after { + background: var(--ok-blue); + box-shadow: 0 0 0 1px rgba(0, 122, 204, 0.28); +} + +.split-handle-horizontal { + width: 9px; + cursor: col-resize; +} + +.split-handle-horizontal::after { + top: 0; + bottom: 0; + left: 4px; + width: 1px; +} + +.split-handle-vertical { + height: 9px; + cursor: row-resize; +} + +.split-handle-vertical::after { + left: 0; + right: 0; + top: 4px; + height: 1px; +} + @keyframes slide-in-left { from { transform: translateX(-100%); diff --git a/web/src/utils/sidebar.ts b/web/src/utils/sidebar.ts new file mode 100644 index 000000000..31c81f417 --- /dev/null +++ b/web/src/utils/sidebar.ts @@ -0,0 +1,98 @@ +import type { ApiFolder, ApiProject, StateResponse } from "../api/types"; + +export type SidebarProjectNode = { + type: "project"; + project: ApiProject; + worktrees: ApiProject[]; +}; + +export type SidebarFolderNode = { + type: "folder"; + folder: ApiFolder; + projects: SidebarProjectNode[]; +}; + +export type SidebarItem = SidebarProjectNode | SidebarFolderNode; + +export function buildSidebarItems(workspace: StateResponse | null): SidebarItem[] { + if (!workspace) return []; + + const projectsById = new Map(workspace.projects.map((project) => [project.id, project])); + const foldersById = new Map((workspace.folders ?? []).map((folder) => [folder.id, folder])); + const folderProjectIds = new Set((workspace.folders ?? []).flatMap((folder) => folder.project_ids)); + const worktreeIds = new Set(); + + for (const project of workspace.projects) { + for (const id of project.worktree_ids ?? []) { + worktreeIds.add(id); + } + if (project.worktree_info) { + worktreeIds.add(project.id); + } + } + + const toProjectNode = (project: ApiProject): SidebarProjectNode => ({ + type: "project", + project, + worktrees: (project.worktree_ids ?? []) + .map((id) => projectsById.get(id)) + .filter((child): child is ApiProject => Boolean(child)), + }); + + const topLevelProject = (id: string): SidebarProjectNode | null => { + const project = projectsById.get(id); + if (!project || worktreeIds.has(project.id)) return null; + return toProjectNode(project); + }; + + const items: SidebarItem[] = []; + const consumed = new Set(); + const order = workspace.project_order?.length + ? workspace.project_order + : [ + ...(workspace.folders ?? []).map((folder) => folder.id), + ...workspace.projects.map((project) => project.id), + ]; + + for (const id of order) { + const folder = foldersById.get(id); + if (folder) { + const projects = folder.project_ids + .map(topLevelProject) + .filter((project): project is SidebarProjectNode => Boolean(project)); + items.push({ type: "folder", folder, projects }); + consumed.add(folder.id); + for (const project of projects) { + consumed.add(project.project.id); + } + continue; + } + + const project = topLevelProject(id); + if (project) { + items.push(project); + consumed.add(project.project.id); + } + } + + const unorderedProjects = workspace.projects + .filter((project) => !consumed.has(project.id)) + .filter((project) => !folderProjectIds.has(project.id)) + .filter((project) => !worktreeIds.has(project.id)) + .sort(compareProjectsByActivity) + .map(toProjectNode); + + return [...items, ...unorderedProjects]; +} + +function compareProjectsByActivity(a: ApiProject, b: ApiProject): number { + if (Boolean(a.pinned) !== Boolean(b.pinned)) { + return a.pinned ? -1 : 1; + } + const aActivity = a.last_activity_at ?? 0; + const bActivity = b.last_activity_at ?? 0; + if (aActivity !== bActivity) { + return bActivity - aActivity; + } + return a.name.localeCompare(b.name); +}