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..efae0f606 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" @@ -2464,6 +2489,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -6052,12 +6087,14 @@ dependencies = [ "okena-app", "okena-cli", "okena-core", + "okena-daemon-core", "okena-ext-claude", "okena-ext-codex", "okena-ext-github", "okena-ext-updater", "okena-extensions", "okena-files", + "okena-remote-server", "okena-terminal", "okena-ui", "okena-views-git", @@ -6071,7 +6108,7 @@ dependencies = [ [[package]] name = "okena-app" -version = "0.1.0" +version = "0.27.0" dependencies = [ "anyhow", "arc-swap", @@ -6119,12 +6156,14 @@ name = "okena-app-core" version = "0.1.0" dependencies = [ "alacritty_terminal", + "anyhow", "base64", "gpui", "log", "okena-core", "okena-files", "okena-git", + "okena-hooks", "okena-terminal", "okena-theme", "okena-workspace", @@ -6140,7 +6179,6 @@ dependencies = [ "base64", "clap", "dirs 5.0.1", - "libc", "okena-core", "okena-remote-server", "okena-transport", @@ -6167,6 +6205,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 +6312,9 @@ dependencies = [ "okena-transport", "okena-ui", "parking_lot", + "reqwest", "semver", + "serde", "serde_json", "sha2", "smol", @@ -6302,6 +6378,7 @@ dependencies = [ name = "okena-hooks" version = "0.1.0" dependencies = [ + "base64", "gpui", "libc", "log", @@ -6345,7 +6422,6 @@ dependencies = [ "okena-core", "okena-transport", "parking_lot", - "reqwest", "serde_json", "tokio", "uniffi", @@ -6366,26 +6442,28 @@ dependencies = [ "okena-workspace", "parking_lot", "reqwest", + "serde_json", "tokio", "uuid", ] [[package]] name = "okena-remote-server" -version = "0.1.0" +version = "0.27.0" dependencies = [ "anyhow", "async-channel 2.5.0", "axum", "base64", + "fs2", "futures", "gpui", "hmac", "hyper", "hyper-util", - "libc", "log", "okena-core", + "okena-ext-updater", "okena-terminal", "okena-transport", "okena-workspace", @@ -6400,6 +6478,7 @@ dependencies = [ "serde_json", "sha2", "subtle", + "sysinfo 0.33.1", "tempfile", "tokio", "tokio-rustls", @@ -6411,9 +6490,9 @@ dependencies = [ name = "okena-services" version = "0.1.0" dependencies = [ + "anyhow", "async-channel 2.5.0", "gpui", - "libc", "log", "okena-core", "okena-terminal", @@ -6422,6 +6501,7 @@ dependencies = [ "serde_yaml_ng", "smol", "thiserror 2.0.18", + "uuid", ] [[package]] @@ -6487,6 +6567,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" @@ -6575,7 +6674,6 @@ dependencies = [ "gpui-component", "log", "okena-core", - "okena-git", "okena-services", "okena-terminal", "okena-transport", @@ -6583,6 +6681,7 @@ dependencies = [ "okena-views-services", "okena-views-terminal", "okena-workspace", + "serde_json", "smol", ] @@ -6612,6 +6711,7 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs 5.0.1", + "fs2", "gpui", "libc", "log", @@ -8495,6 +8595,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 +9386,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..7e73f95e6 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,12 @@ 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" } +okena-daemon-core = { path = "crates/okena-daemon-core" } + # Extension system — registered at startup in main.rs. okena-extensions = { path = "crates/okena-extensions" } okena-ext-claude = { path = "crates/okena-ext-claude" } @@ -76,6 +85,10 @@ rust-embed = "8.5" default = ["jemalloc", "mimalloc"] mimalloc = ["dep:mimalloc"] +# Compile the terminal latency probe into both desktop and headless modes. +# Runtime collection still requires OKENA_TERMINAL_LATENCY_PROBE=1. +terminal-latency-probe = ["okena-core/terminal-latency-probe"] + # jemalloc: default on Unix. Shares a small fixed set of arenas across all # threads (narenas:N in src/main.rs) instead of mimalloc's per-thread heaps, so # okena's ~90 threads don't each pin their own segments — cut anon-heap RSS diff --git a/README.md b/README.md index e1cf8343d..3036d19d5 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Settings are stored in the platform's config directory (macOS: `~/Library/Applic > **This codebase has not been contaminated by human hands.** > -> Every line of code, every architectural decision, every meticulously placed semicolon — pure, unfiltered Claude Opus. +> Every line of code, every architectural decision, every meticulously placed semicolon — pure, unfiltered Claude Opus and OpenAI Codex, each confidently taking credit for the good parts. > The human's contribution was limited to typing vague requirements like "make it work" and then pressing `Enter` to approve tool calls with the mass-produced enthusiasm of a factory worker. > > If you find a bug, rest assured — it's not a bug. It's the AI testing whether you're paying attention. diff --git a/build.rs b/build.rs index 12793988d..4b87b4ca3 100644 --- a/build.rs +++ b/build.rs @@ -28,7 +28,10 @@ fn main() { eprintln!("Warning: Failed to set Windows icon: {}", e); } } else { - println!("cargo:warning=Icon file not found at {}, skipping Windows icon embedding", icon_path); + println!( + "cargo:warning=Icon file not found at {}, skipping Windows icon embedding", + icon_path + ); } } diff --git a/crates/okena-app-core/Cargo.toml b/crates/okena-app-core/Cargo.toml index ceb7487e9..f500a9dfc 100644 --- a/crates/okena-app-core/Cargo.toml +++ b/crates/okena-app-core/Cargo.toml @@ -4,15 +4,20 @@ 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-hooks = { path = "../okena-hooks", default-features = false } +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 +30,16 @@ 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"] } +anyhow = "1.0" 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..95956aaad --- /dev/null +++ b/crates/okena-app-core/src/remote_config.rs @@ -0,0 +1,507 @@ +//! 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, DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, + PASTEL_DARK_THEME, ThemeColors, ThemeMode, +}; +use okena_workspace::persistence::AppSettings; +use serde_json::{Value, json}; + +/// 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 new = match preview_settings_patch(¤t, patch) { + Ok(settings) => settings, + Err(error) => return CommandResult::Err(error), + }; + store_prevalidated_settings(b, &new) +} + +/// Merge and validate a settings patch without persisting or mutating live state. +pub fn preview_settings_patch(current: &AppSettings, patch: Value) -> Result { + let mut value = match serde_json::to_value(current) { + Ok(v) => v, + Err(e) => return Err(format!("failed to read settings: {e}")), + }; + merge_json(&mut value, patch); + serde_json::from_value(value).map_err(|e| format!("invalid settings: {e}")) +} + +/// Persist settings that have already passed [`preview_settings_patch`]. +pub fn store_prevalidated_settings( + b: &mut B, + new: &AppSettings, +) -> CommandResult { + 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 settings_preview_validates_without_mutating_current_settings() { + let current = AppSettings::default(); + let preview = preview_settings_patch( + ¤t, + json!({ "session_backend": "None", "font_size": 19.0 }), + ) + .expect("valid settings patch"); + + assert_eq!( + preview.session_backend, + okena_terminal::session_backend::SessionBackend::None + ); + assert_eq!(preview.font_size, 19.0); + assert_ne!(current.font_size, preview.font_size); + assert_eq!( + current.session_backend, + AppSettings::default().session_backend + ); + } + + #[test] + fn invalid_settings_preview_returns_before_store() { + let current = AppSettings::default(); + let error = preview_settings_patch(¤t, json!({ "font_size": "huge" })) + .expect_err("invalid settings patch"); + + assert!(error.starts_with("invalid settings:")); + assert_eq!(current.font_size, AppSettings::default().font_size); + } + + #[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..9d8a53f1d --- /dev/null +++ b/crates/okena-app-core/src/remote_snapshot.rs @@ -0,0 +1,196 @@ +//! 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, ApiHookExecution, 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(), + is_creating: p.is_creating, + is_closing: p.is_closing, + } +} + +/// 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. +#[allow(clippy::too_many_arguments)] // cohesive snapshot inputs, all already gathered by the caller +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, + hooks: 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, + hooks, + } +} diff --git a/crates/okena-app-core/src/settings.rs b/crates/okena-app-core/src/settings.rs index 0fc769d66..70b4f6050 100644 --- a/crates/okena-app-core/src/settings.rs +++ b/crates/okena-app-core/src/settings.rs @@ -1,27 +1,34 @@ //! Global observable settings module //! //! Provides app-wide access to settings through the GlobalSettings global. -//! Settings are automatically persisted to disk with debouncing. +//! The desktop client publishes edits for its daemon to persist. +#[cfg(feature = "gpui")] +use crate::workspace::persistence::AppSettings; +use crate::workspace::persistence::{get_settings_path, load_settings, save_settings}; +#[cfg(feature = "gpui")] +use gpui::*; +#[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 gpui::*; -use std::sync::atomic::{AtomicBool, Ordering}; -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, /// The worktree path template that was active when settings were loaded or last migrated. /// Used to detect meaningful changes and suggest worktree migration. worktree_template_baseline: String, @@ -29,7 +36,17 @@ pub struct SettingsState { template_migration_task: Option>, } -/// Macro to generate setter methods with clamping and auto-save +#[cfg(feature = "gpui")] +#[derive(Clone)] +pub enum SettingsEvent { + Changed(AppSettings), +} + +#[cfg(feature = "gpui")] +impl EventEmitter for SettingsState {} + +/// Macro to generate setter methods with clamping and daemon sync. +#[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,12 +78,12 @@ macro_rules! setting_setter { }; } +#[cfg(feature = "gpui")] impl SettingsState { pub fn new(settings: AppSettings) -> Self { let baseline = settings.worktree.path_template.clone(); Self { settings, - save_pending: Arc::new(AtomicBool::new(false)), worktree_template_baseline: baseline, template_migration_task: None, } @@ -83,23 +100,41 @@ impl SettingsState { setting_setter!(set_ui_font_size, ui_font_size, f32, 8.0, 24.0); setting_setter!(set_file_font_size, file_font_size, f32, 8.0, 24.0); /// Set the cursor style (Block, Bar, Underline) - pub fn set_cursor_style(&mut self, value: crate::workspace::settings::CursorShape, cx: &mut Context) { + pub fn set_cursor_style( + &mut self, + value: crate::workspace::settings::CursorShape, + cx: &mut Context, + ) { self.settings.cursor_style = value; self.save_and_notify(cx); } /// Set the project column header density (Compact, Comfortable) - pub fn set_header_density(&mut self, value: crate::workspace::settings::HeaderDensity, cx: &mut Context) { + pub fn set_header_density( + &mut self, + value: crate::workspace::settings::HeaderDensity, + cx: &mut Context, + ) { self.settings.header_density = value; self.save_and_notify(cx); } setting_setter!(set_cursor_blink, cursor_blink, bool); setting_setter!(set_scrollback_lines, scrollback_lines, u32, 100, 100000); - setting_setter!(set_terminal_close_grace_secs, terminal_close_grace_secs, u32, 0, 60); + setting_setter!( + set_terminal_close_grace_secs, + terminal_close_grace_secs, + u32, + 0, + 60 + ); setting_setter!(set_show_focused_border, show_focused_border, bool); setting_setter!(set_color_tinted_background, color_tinted_background, bool); - setting_setter!(set_detached_overlays_by_default, detached_overlays_by_default, bool); + setting_setter!( + set_detached_overlays_by_default, + detached_overlays_by_default, + bool + ); /// Persist the most recent detached overlay window bounds. pub fn set_detached_overlay_bounds( @@ -111,7 +146,11 @@ impl SettingsState { self.save_and_notify(cx); } setting_setter!(set_show_shell_selector, show_shell_selector, bool); - setting_setter!(set_terminal_ctrl_c_copies_selection, terminal_ctrl_c_copies_selection, bool); + setting_setter!( + set_terminal_ctrl_c_copies_selection, + terminal_ctrl_c_copies_selection, + bool + ); setting_setter!(set_blame_visible, blame_visible, bool); /// Master switch for native desktop notifications (opt-in). @@ -173,17 +212,30 @@ impl SettingsState { self.save_and_notify(cx); } - /// Set per-extension settings blob (opaque JSON value). - pub fn set_extension_setting(&mut self, extension_id: &str, value: serde_json::Value, cx: &mut Context) { - self.settings.extension_settings.insert(extension_id.to_string(), value); + pub fn set_extension_setting( + &mut self, + extension_id: &str, + value: serde_json::Value, + cx: &mut Context, + ) { + self.settings + .extension_settings + .insert(extension_id.to_string(), value); self.save_and_notify(cx); } /// Enable or disable an extension by ID. - pub fn set_extension_enabled(&mut self, extension_id: &str, enabled: bool, cx: &mut Context) { + pub fn set_extension_enabled( + &mut self, + extension_id: &str, + enabled: bool, + cx: &mut Context, + ) { if enabled { - self.settings.enabled_extensions.insert(extension_id.to_string()); + self.settings + .enabled_extensions + .insert(extension_id.to_string()); } else { self.settings.enabled_extensions.remove(extension_id); } @@ -204,7 +256,7 @@ impl SettingsState { /// Set sidebar width (clamped to min/max bounds) pub fn set_sidebar_width(&mut self, value: f32, cx: &mut Context) { - use crate::workspace::persistence::{MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH}; + use crate::workspace::persistence::{MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH}; self.settings.sidebar.width = value.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); self.save_and_notify(cx); } @@ -249,7 +301,11 @@ impl SettingsState { self.settings.hooks.terminal.on_close = value; self.save_and_notify(cx); } - pub fn set_hook_terminal_shell_wrapper(&mut self, value: Option, cx: &mut Context) { + pub fn set_hook_terminal_shell_wrapper( + &mut self, + value: Option, + cx: &mut Context, + ) { self.settings.hooks.terminal.shell_wrapper = value; self.save_and_notify(cx); } @@ -271,19 +327,35 @@ impl SettingsState { self.settings.hooks.worktree.post_merge = value; self.save_and_notify(cx); } - pub fn set_hook_worktree_before_remove(&mut self, value: Option, cx: &mut Context) { + pub fn set_hook_worktree_before_remove( + &mut self, + value: Option, + cx: &mut Context, + ) { self.settings.hooks.worktree.before_remove = value; self.save_and_notify(cx); } - pub fn set_hook_worktree_after_remove(&mut self, value: Option, cx: &mut Context) { + pub fn set_hook_worktree_after_remove( + &mut self, + value: Option, + cx: &mut Context, + ) { self.settings.hooks.worktree.after_remove = value; self.save_and_notify(cx); } - pub fn set_hook_worktree_on_rebase_conflict(&mut self, value: Option, cx: &mut Context) { + pub fn set_hook_worktree_on_rebase_conflict( + &mut self, + value: Option, + cx: &mut Context, + ) { self.settings.hooks.worktree.on_rebase_conflict = value; self.save_and_notify(cx); } - pub fn set_hook_worktree_on_dirty_close(&mut self, value: Option, cx: &mut Context) { + pub fn set_hook_worktree_on_dirty_close( + &mut self, + value: Option, + cx: &mut Context, + ) { self.settings.hooks.worktree.on_dirty_close = value; self.save_and_notify(cx); } @@ -345,57 +417,29 @@ impl SettingsState { self.save_and_notify(cx); } - /// Synchronously flush any pending settings save (called on quit) - pub fn flush_pending_save(&self) { - if self.save_pending.swap(false, Ordering::Relaxed) - && let Err(e) = save_settings(&self.settings) { - log::error!("Failed to flush settings on quit: {}", e); - } + /// Replace the local mirror without publishing the change back to the daemon. + pub fn replace_from_daemon(&mut self, mut settings: AppSettings, cx: &mut Context) { + settings.remote_connections = self.settings.remote_connections.clone(); + self.worktree_template_baseline = settings.worktree.path_template.clone(); + self.settings = settings; + cx.notify(); } - /// Save and notify - common logic for all setters. - /// Public so that the ExtensionSettingsStore setter callback can trigger persistence. + /// Publish and notify — the daemon is the sole settings writer. pub fn save_and_notify(&mut self, cx: &mut Context) { - self.save_debounced(cx); + cx.emit(SettingsEvent::Changed(self.settings.clone())); cx.notify(); } - - /// Save settings with debouncing to avoid excessive writes - fn save_debounced(&mut self, cx: &mut Context) { - self.save_pending.store(true, Ordering::Relaxed); - let save_pending = self.save_pending.clone(); - - cx.spawn(async move |this, cx| { - smol::Timer::after(std::time::Duration::from_millis(300)).await; - - if save_pending.swap(false, Ordering::Relaxed) { - let settings = cx.update(|cx| { - this.upgrade().map(|e| e.read(cx).settings.clone()) - }); - if let Some(settings) = settings { - // Run blocking fs IO off the main thread; settings.json - // also reads itself back to merge remote_connections, so - // the cost is two sync IO ops under SETTINGS_LOCK. - let save_result = smol::unblock(move || save_settings(&settings)).await; - if let Err(e) = save_result { - log::error!("Failed to save settings: {}", e); - cx.update(|cx| { - ToastManager::error(format!("Failed to save settings: {}", e), cx); - }); - } - } - } - }) - .detach(); - } } /// 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() } @@ -420,16 +464,20 @@ pub fn open_settings_file() { #[cfg(target_os = "linux")] { - let _ = okena_core::process::spawn_and_reap(okena_core::process::command("xdg-open").arg(&path)); + let _ = okena_core::process::spawn_and_reap( + okena_core::process::command("xdg-open").arg(&path), + ); } #[cfg(target_os = "windows")] { - let _ = okena_core::process::spawn_and_reap(okena_core::process::command("notepad").arg(&path)); + let _ = + okena_core::process::spawn_and_reap(okena_core::process::command("notepad").arg(&path)); } } /// 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..8b7dd8d7d 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/files.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/files.rs @@ -4,6 +4,12 @@ use super::{ ActionResult, Workspace, resolve_new_project_file, resolve_project_file, validate_leaf_name, }; +pub struct PreparedContentSearch { + project_path: std::path::PathBuf, + query: String, + config: okena_files::content_search::ContentSearchConfig, +} + pub(super) fn list_files(ws: &Workspace, project_id: String, show_ignored: bool) -> ActionResult { match ws.project(&project_id) { Some(p) => { @@ -11,14 +17,21 @@ 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); - ActionResult::Ok(Some(serde_json::to_value(files).expect("BUG: FileEntry must serialize"))) + 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)), } } -pub(super) fn list_directory(ws: &Workspace, project_id: String, relative_path: String, show_ignored: bool) -> ActionResult { +pub(super) fn list_directory( + ws: &Workspace, + project_id: String, + relative_path: String, + show_ignored: bool, +) -> ActionResult { match ws.project(&project_id) { Some(p) => { let path = match std::path::Path::new(&p.path).canonicalize() { @@ -59,7 +72,11 @@ pub(super) fn read_file(ws: &Workspace, project_id: String, relative_path: Strin /// resident multiple is roughly 3-4× the file size). const MAX_READ_FILE_BYTES: u64 = 20 * 1024 * 1024; -pub(super) fn read_file_bytes(ws: &Workspace, project_id: String, relative_path: String) -> ActionResult { +pub(super) fn read_file_bytes( + ws: &Workspace, + project_id: String, + relative_path: String, +) -> ActionResult { use base64::Engine as _; match ws.project(&project_id) { Some(p) => { @@ -108,7 +125,17 @@ pub(super) fn file_size(ws: &Workspace, project_id: String, relative_path: Strin Err(e) => return ActionResult::Err(e), }; match std::fs::metadata(&canonical) { - Ok(m) => ActionResult::Ok(Some(serde_json::json!({ "size": m.len() }))), + Ok(m) => { + let modified_at_millis = m + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|duration| u64::try_from(duration.as_millis()).ok()); + ActionResult::Ok(Some(serde_json::json!({ + "size": m.len(), + "modified_at_millis": modified_at_millis, + }))) + } Err(e) => ActionResult::Err(format!("Cannot read file: {}", e)), } } @@ -117,7 +144,7 @@ pub(super) fn file_size(ws: &Workspace, project_id: String, relative_path: Strin } #[allow(clippy::too_many_arguments)] -pub(super) fn search_content( +pub fn prepare_content_search( ws: &Workspace, project_id: String, query: String, @@ -126,17 +153,18 @@ pub(super) fn search_content( max_results: usize, file_glob: Option, context_lines: usize, -) -> ActionResult { + show_ignored: bool, +) -> Result { if let Some(ref glob) = file_glob - && (glob.contains("..") || glob.starts_with('/')) { - return ActionResult::Err("file_glob must not contain '..' or start with '/'".to_string()); - } + && (glob.contains("..") || glob.starts_with('/')) + { + return Err("file_glob must not contain '..' or start with '/'".to_string()); + } match ws.project(&project_id) { Some(p) => { - let path = match std::path::Path::new(&p.path).canonicalize() { - Ok(c) => c, - Err(e) => return ActionResult::Err(format!("Cannot resolve project path: {}", e)), - }; + let project_path = std::path::Path::new(&p.path) + .canonicalize() + .map_err(|error| format!("Cannot resolve project path: {error}"))?; let search_mode = match mode.as_str() { "regex" => okena_files::content_search::SearchMode::Regex, "fuzzy" => okena_files::content_search::SearchMode::Fuzzy, @@ -148,20 +176,77 @@ pub(super) fn search_content( max_results, file_glob, context_lines, - show_ignored: false, + show_ignored, }; - let cancelled = std::sync::atomic::AtomicBool::new(false); - let mut results = Vec::new(); - okena_files::content_search::search_content( - &path, &query, &config, &cancelled, &mut |result| results.push(result), - ); - ActionResult::Ok(Some(serde_json::to_value(results).expect("BUG: FileSearchResult must serialize"))) + Ok(PreparedContentSearch { + project_path, + query, + config, + }) } - None => ActionResult::Err(format!("project not found: {}", project_id)), + None => Err(format!("project not found: {project_id}")), } } -pub(super) fn rename_file(ws: &Workspace, project_id: String, relative_path: String, new_name: String) -> ActionResult { +pub fn execute_prepared_content_search(search: PreparedContentSearch) -> ActionResult { + let cancelled = std::sync::atomic::AtomicBool::new(false); + execute_prepared_content_search_with_cancellation(search, &cancelled) +} + +pub fn execute_prepared_content_search_with_cancellation( + search: PreparedContentSearch, + cancelled: &std::sync::atomic::AtomicBool, +) -> ActionResult { + let mut results = Vec::new(); + let search_result = okena_files::content_search::search_content( + &search.project_path, + &search.query, + &search.config, + cancelled, + &mut |result| results.push(result), + ); + match search_result { + Ok(()) => ActionResult::Ok(Some( + serde_json::to_value(results).expect("BUG: FileSearchResult must serialize"), + )), + Err(error) => ActionResult::Err(error), + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn search_content( + ws: &Workspace, + project_id: String, + query: String, + case_sensitive: bool, + mode: String, + max_results: usize, + file_glob: Option, + context_lines: usize, + show_ignored: bool, +) -> ActionResult { + match prepare_content_search( + ws, + project_id, + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + ) { + Ok(search) => execute_prepared_content_search(search), + Err(error) => ActionResult::Err(error), + } +} + +pub(super) fn rename_file( + ws: &Workspace, + project_id: String, + relative_path: String, + new_name: String, +) -> ActionResult { if let Err(e) = validate_leaf_name(&new_name) { return ActionResult::Err(e); } @@ -187,7 +272,11 @@ pub(super) fn rename_file(ws: &Workspace, project_id: String, relative_path: Str } } -pub(super) fn delete_file(ws: &Workspace, project_id: String, relative_path: String) -> ActionResult { +pub(super) fn delete_file( + ws: &Workspace, + project_id: String, + relative_path: String, +) -> ActionResult { let project_path = match ws.project(&project_id) { Some(p) => p.path.clone(), None => return ActionResult::Err(format!("project not found: {}", project_id)), @@ -214,7 +303,11 @@ pub(super) fn delete_file(ws: &Workspace, project_id: String, relative_path: Str } } -pub(super) fn create_file(ws: &Workspace, project_id: String, relative_path: String) -> ActionResult { +pub(super) fn create_file( + ws: &Workspace, + project_id: String, + relative_path: String, +) -> ActionResult { let project_path = match ws.project(&project_id) { Some(p) => p.path.clone(), None => return ActionResult::Err(format!("project not found: {}", project_id)), @@ -226,13 +319,21 @@ pub(super) fn create_file(ws: &Workspace, project_id: String, relative_path: Str if target.exists() { return ActionResult::Err("target already exists".to_string()); } - match std::fs::OpenOptions::new().write(true).create_new(true).open(&target) { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + { Ok(_) => ActionResult::Ok(None), Err(e) => ActionResult::Err(format!("Cannot create file: {}", e)), } } -pub(super) fn create_directory(ws: &Workspace, project_id: String, relative_path: String) -> ActionResult { +pub(super) fn create_directory( + ws: &Workspace, + project_id: String, + relative_path: String, +) -> ActionResult { let project_path = match ws.project(&project_id) { Some(p) => p.path.clone(), None => return 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..04e75831e 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/git.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/git.rs @@ -6,35 +6,68 @@ 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 { +pub(super) fn diff( + ws: &Workspace, + project_id: String, + mode: DiffMode, + ignore_whitespace: bool, +) -> ActionResult { match ws.project(&project_id) { Some(p) => { let path = p.path.clone(); - match okena_git::get_diff_with_options(std::path::Path::new(&path), mode, ignore_whitespace) { - Ok(diff) => ActionResult::Ok(Some(serde_json::to_value(diff).expect("BUG: DiffResult must serialize"))), + match okena_git::get_diff_with_options( + std::path::Path::new(&path), + mode, + ignore_whitespace, + ) { + Ok(diff) => ActionResult::Ok(Some( + serde_json::to_value(diff).expect("BUG: DiffResult must serialize"), + )), Err(e) => ActionResult::Err(e.to_string()), } } @@ -43,17 +76,32 @@ 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"))) + 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 list_pull_requests(ws: &Workspace, project_id: String, limit: usize) -> ActionResult { + with_project_path(ws, project_id, |path| { + match okena_git::list_pull_requests(path, limit) { + Ok(pull_requests) => ActionResult::Ok(Some( + serde_json::to_value(pull_requests) + .expect("BUG: WorktreePullRequest must serialize"), + )), + Err(error) => ActionResult::Err(error), } - None => ActionResult::Err(format!("project not found: {}", project_id)), - } + }) } -pub(super) fn file_contents(ws: &Workspace, project_id: String, file_path: String, mode: DiffMode) -> ActionResult { +pub(super) fn file_contents( + ws: &Workspace, + project_id: String, + file_path: String, + mode: DiffMode, +) -> ActionResult { match ws.project(&project_id) { Some(p) => { let repo_path = p.path.clone(); @@ -71,63 +119,143 @@ 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)), - } +pub(super) fn commit_graph( + ws: &Workspace, + project_id: String, + count: usize, + branch: Option, +) -> ActionResult { + 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); + let entries: Vec = worktrees + .iter() + .map(|(worktree_path, branch)| { + let normalized = + okena_git::repository::normalize_path(std::path::Path::new(worktree_path)); + okena_core::api::ApiWorktreeEntry { + worktree_path: worktree_path.clone(), + project_path: okena_git::repository::project_path_in_worktree( + worktree_path, + &subdir, + ), + branch: branch.clone(), + is_main: normalized == norm_git_root, + } + }) + .collect(); + ActionResult::Ok(Some(serde_json::json!({ + "git_root": norm_git_root.to_string_lossy(), + "subdir": subdir.to_string_lossy(), + "worktrees": worktrees, + "entries": entries, + }))) } 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 +264,24 @@ 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) => { @@ -144,21 +290,23 @@ pub(super) fn blame(ws: &Workspace, project_id: String, relative_path: String) - Ok(lines) => { let wire: Vec<_> = lines .into_iter() - .map(|l| serde_json::json!({ - "line_number": l.line_number, - "commit": { - "hash": l.commit.hash, - "short_hash": l.commit.short_hash, - "author": l.commit.author, - "author_email": l.commit.author_email, - "timestamp": l.commit.timestamp, - "summary": l.commit.summary, - }, - "kind": match l.kind { - okena_git::BlameKind::Committed => "Committed", - okena_git::BlameKind::Uncommitted => "Uncommitted", - }, - })) + .map(|l| { + serde_json::json!({ + "line_number": l.line_number, + "commit": { + "hash": l.commit.hash, + "short_hash": l.commit.short_hash, + "author": l.commit.author, + "author_email": l.commit.author_email, + "timestamp": l.commit.timestamp, + "summary": l.commit.summary, + }, + "kind": match l.kind { + okena_git::BlameKind::Committed => "Committed", + okena_git::BlameKind::Uncommitted => "Uncommitted", + }, + }) + }) .collect(); ActionResult::Ok(Some(serde_json::Value::Array(wire))) } 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..50c74f534 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/mod.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/mod.rs @@ -12,21 +12,41 @@ mod files; mod git; mod project; +mod session; mod tab; mod terminal; +mod terminal_batch; -use okena_core::api::{ActionRequest, CommandResult}; -use crate::settings::settings; -use okena_terminal::backend::TerminalBackend; -use okena_terminal::shell_config::ShellType; -use okena_terminal::terminal::{Terminal, TerminalSize}; -use okena_terminal::TerminalsRegistry; use crate::workspace::focus::FocusManager; use crate::workspace::hooks; +use crate::workspace::persistence::AppSettings; use crate::workspace::state::{LayoutNode, WindowId, Workspace}; -use gpui::*; +use okena_core::api::{ActionRequest, CommandResult}; +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::{TerminalBackend, TerminalLaunchPlan}; +use okena_terminal::shell_config::ShellType; +use okena_terminal::terminal::{Terminal, TerminalSize}; +use okena_workspace::context::WorkspaceCx; +use std::collections::HashMap; use std::sync::Arc; +pub use files::{ + PreparedContentSearch, execute_prepared_content_search, + execute_prepared_content_search_with_cancellation, prepare_content_search, +}; +pub use session::{ + apply_imported_workspace, apply_loaded_session, begin_workspace_replacement, + cleanup_stale_workspace_replacement, ensure_workspace_replacement_allowed, + fail_workspace_replacement, finish_workspace_replacement, import_workspace_data, + load_session_data, load_session_data_for_shell, materialize_workspace_replacement, + prepare_workspace_replacement, +}; +pub use terminal_batch::{ + PreparedTerminalLaunch, PreparedTerminalLaunchOutcome, PublishedTerminalOwners, + cleanup_stale_prepared_terminal_launches, materialize_prepared_terminal_launches, + publish_prepared_terminal_launches, +}; + /// Result of executing an action. pub enum ActionResult { /// Success with optional JSON payload. @@ -48,6 +68,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,139 +79,347 @@ 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) - } - ActionRequest::SplitTerminal { project_id, path, direction } => { - terminal::split(ws, focus_manager, project_id, path, direction, backend, terminals, cx) - } - ActionRequest::CloseTerminal { project_id, terminal_id } => { - terminal::close(ws, focus_manager, project_id, terminal_id, backend, terminals, cx) - } - ActionRequest::CloseTerminals { project_id, terminal_ids } => { - terminal::close_many(ws, focus_manager, project_id, terminal_ids, backend, terminals, cx) - } - ActionRequest::FocusTerminal { project_id, terminal_id, window: _ } => { + ActionRequest::CreateTerminal { project_id } => 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, + settings, + cx, + ), + ActionRequest::CloseTerminal { + project_id, + terminal_id, + } => terminal::close( + ws, + focus_manager, + project_id, + terminal_id, + backend, + terminals, + cx, + ), + ActionRequest::CloseTerminals { + project_id, + terminal_ids, + } => terminal::close_many( + ws, + focus_manager, + project_id, + terminal_ids, + backend, + terminals, + cx, + ), + ActionRequest::FocusTerminal { + project_id, + terminal_id, + window: _, + } => { // `window` was already consumed at the bridge to resolve the target // `window_id` (passed in above); the per-window FocusManager handed // to `execute_action` is already the right one. terminal::focus(ws, focus_manager, project_id, terminal_id, cx) } + ActionRequest::RecordProjectActivity { project_id } => { + if ws.project(&project_id).is_none() { + ActionResult::Err(format!("project not found: {project_id}")) + } else { + ws.bump_activity(&project_id, cx); + ActionResult::Ok(None) + } + } ActionRequest::SendText { terminal_id, text } => { - terminal::send_text(ws, terminal_id, text, backend, terminals) + terminal::send_text(ws, terminal_id, text, backend, terminals, settings) } - ActionRequest::RunCommand { terminal_id, command } => { - terminal::run_command(ws, terminal_id, command, backend, terminals) + ActionRequest::SendBytes { terminal_id, data } => { + terminal::send_bytes(ws, terminal_id, data, backend, terminals, settings) } + ActionRequest::RunCommand { + terminal_id, + command, + } => terminal::run_command(ws, terminal_id, command, backend, terminals, settings), ActionRequest::SendSpecialKey { terminal_id, key } => { - terminal::send_special_key(ws, terminal_id, key, backend, terminals) - } - ActionRequest::Resize { terminal_id, cols, rows } => { - terminal::resize(ws, terminal_id, cols, rows, backend, terminals) - } - ActionRequest::UpdateSplitSizes { project_id, path, sizes } => { - terminal::update_split_sizes(ws, project_id, path, sizes, cx) - } - ActionRequest::ToggleMinimized { project_id, terminal_id } => { - terminal::toggle_minimized(ws, project_id, terminal_id, cx) - } - ActionRequest::SetFullscreen { project_id, terminal_id, window: _ } => { - terminal::set_fullscreen(ws, focus_manager, project_id, terminal_id, cx) - } - ActionRequest::RenameTerminal { project_id, terminal_id, name } => { - terminal::rename(ws, project_id, terminal_id, name, cx) + terminal::send_special_key(ws, terminal_id, key, backend, terminals, settings) } + ActionRequest::Resize { + terminal_id, + cols, + rows, + } => terminal::resize(ws, terminal_id, cols, rows, backend, terminals, settings), + ActionRequest::UpdateSplitSizes { + project_id, + path, + sizes, + } => terminal::update_split_sizes(ws, project_id, path, sizes, cx), + ActionRequest::ToggleMinimized { + project_id, + terminal_id, + } => terminal::toggle_minimized(ws, project_id, terminal_id, cx), + ActionRequest::SetFullscreen { + project_id, + terminal_id, + window: _, + } => terminal::set_fullscreen(ws, focus_manager, project_id, terminal_id, cx), + 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::DismissHook { + project_id, + terminal_id, + } => project::dismiss_hook(ws, project_id, terminal_id, backend, terminals, cx), ActionRequest::ReadContent { terminal_id } => { - terminal::read_content(ws, terminal_id, backend, terminals) + terminal::read_content(ws, terminal_id, backend, terminals, settings) + } + 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) - } - ActionRequest::SetActiveTab { project_id, path, index } => { - tab::set_active_tab(ws, project_id, path, index, cx) - } - ActionRequest::MoveTab { project_id, path, from_index, to_index } => { - tab::move_tab(ws, project_id, path, from_index, to_index, cx) - } - ActionRequest::MoveTerminalToTabGroup { project_id, terminal_id, target_path, position, target_project_id } => { - tab::move_terminal_to_tab_group(ws, focus_manager, project_id, terminal_id, target_path, position, target_project_id, cx) - } - ActionRequest::MovePaneTo { project_id, terminal_id, target_project_id, target_terminal_id, zone } => { - tab::move_pane_to(ws, focus_manager, project_id, terminal_id, target_project_id, target_terminal_id, zone, cx) - } + ActionRequest::AddTab { + project_id, + path, + in_group, + } => 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), + ActionRequest::MoveTab { + project_id, + path, + from_index, + to_index, + } => tab::move_tab(ws, project_id, path, from_index, to_index, cx), + ActionRequest::MoveTerminalToTabGroup { + project_id, + terminal_id, + target_path, + position, + target_project_id, + } => tab::move_terminal_to_tab_group( + ws, + focus_manager, + project_id, + terminal_id, + target_path, + position, + target_project_id, + cx, + ), + ActionRequest::MovePaneTo { + project_id, + terminal_id, + target_project_id, + target_terminal_id, + zone, + } => tab::move_pane_to( + ws, + focus_manager, + project_id, + terminal_id, + target_project_id, + target_terminal_id, + zone, + cx, + ), // ── Git ops ────────────────────────────────────────────────── ActionRequest::GitStatus { project_id } => git::status(ws, project_id), ActionRequest::GitDiffSummary { project_id } => git::diff_summary(ws, project_id), - ActionRequest::GitDiff { project_id, mode, ignore_whitespace } => { - git::diff(ws, project_id, mode, ignore_whitespace) - } + ActionRequest::GitDiff { + project_id, + mode, + ignore_whitespace, + } => git::diff(ws, project_id, mode, ignore_whitespace), ActionRequest::GitBranches { project_id } => git::branches(ws, project_id), - ActionRequest::GitFileContents { project_id, file_path, mode } => { - git::file_contents(ws, project_id, file_path, mode) - } - ActionRequest::GitCommitGraph { project_id, count, branch } => { - git::commit_graph(ws, project_id, count, branch) + ActionRequest::GitListPullRequests { project_id, limit } => { + git::list_pull_requests(ws, project_id, limit) } + ActionRequest::GitFileContents { + project_id, + file_path, + mode, + } => git::file_contents(ws, project_id, file_path, mode), + ActionRequest::GitCommitGraph { + project_id, + count, + branch, + } => git::commit_graph(ws, project_id, count, branch), ActionRequest::GitListBranches { project_id } => git::list_branches(ws, project_id), - ActionRequest::GitStageFile { project_id, file_path } => { - git::stage_file(ws, project_id, file_path) - } - ActionRequest::GitUnstageFile { project_id, file_path } => { - git::unstage_file(ws, project_id, file_path) + 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::GitDiscardFile { project_id, file_path } => { - git::discard_file(ws, project_id, file_path) + ActionRequest::GitListBranchesClassified { project_id } => { + git::list_branches_classified(ws, project_id) } - ActionRequest::GitBlame { project_id, relative_path } => { - git::blame(ws, project_id, relative_path) + 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), + ActionRequest::GitUnstageFile { + project_id, + file_path, + } => git::unstage_file(ws, project_id, file_path), + ActionRequest::GitDiscardFile { + project_id, + file_path, + } => git::discard_file(ws, project_id, file_path), + ActionRequest::GitBlame { + project_id, + relative_path, + } => git::blame(ws, project_id, relative_path), // ── Filesystem ops ─────────────────────────────────────────── - ActionRequest::ListFiles { project_id, show_ignored } => { - files::list_files(ws, project_id, show_ignored) - } - ActionRequest::ListDirectory { project_id, relative_path, show_ignored } => { - files::list_directory(ws, project_id, relative_path, show_ignored) - } - ActionRequest::ReadFile { project_id, relative_path } => { - files::read_file(ws, project_id, relative_path) - } - ActionRequest::ReadFileBytes { project_id, relative_path } => { - files::read_file_bytes(ws, project_id, relative_path) - } - ActionRequest::FileSize { project_id, relative_path } => { - files::file_size(ws, project_id, relative_path) - } - ActionRequest::SearchContent { project_id, query, case_sensitive, mode, max_results, file_glob, context_lines } => { - files::search_content(ws, project_id, query, case_sensitive, mode, max_results, file_glob, context_lines) - } - ActionRequest::RenameFile { project_id, relative_path, new_name } => { - files::rename_file(ws, project_id, relative_path, new_name) - } - ActionRequest::DeleteFile { project_id, relative_path } => { - files::delete_file(ws, project_id, relative_path) - } - ActionRequest::CreateFile { project_id, relative_path } => { - files::create_file(ws, project_id, relative_path) - } - ActionRequest::CreateDirectory { project_id, relative_path } => { - files::create_directory(ws, project_id, relative_path) - } + ActionRequest::ListFiles { + project_id, + show_ignored, + } => files::list_files(ws, project_id, show_ignored), + ActionRequest::ListDirectory { + project_id, + relative_path, + show_ignored, + } => files::list_directory(ws, project_id, relative_path, show_ignored), + ActionRequest::ReadFile { + project_id, + relative_path, + } => files::read_file(ws, project_id, relative_path), + ActionRequest::ReadFileBytes { + project_id, + relative_path, + } => files::read_file_bytes(ws, project_id, relative_path), + ActionRequest::FileSize { + project_id, + relative_path, + } => files::file_size(ws, project_id, relative_path), + ActionRequest::SearchContent { + project_id, + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + } => files::search_content( + ws, + project_id, + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + ), + ActionRequest::RenameFile { + project_id, + relative_path, + new_name, + } => files::rename_file(ws, project_id, relative_path, new_name), + ActionRequest::DeleteFile { + project_id, + relative_path, + } => files::delete_file(ws, project_id, relative_path), + ActionRequest::CreateFile { + project_id, + relative_path, + } => files::create_file(ws, project_id, relative_path), + ActionRequest::CreateDirectory { + project_id, + relative_path, + } => files::create_directory(ws, project_id, relative_path), // ── Project / folder / worktree ops ────────────────────────── ActionRequest::AddProject { name, path } => { - project::add_project(ws, window_id, name, path, backend, terminals, cx) - } - ActionRequest::ReorderProjectInFolder { folder_id, project_id, new_index } => { - project::reorder_in_folder(ws, folder_id, project_id, new_index, 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), ActionRequest::SetProjectColor { project_id, color } => { project::set_project_color(ws, project_id, color, cx) } @@ -197,31 +429,118 @@ pub fn execute_action( ActionRequest::RenameProject { project_id, name } => { project::rename_project(ws, project_id, name, cx) } - ActionRequest::RenameProjectDirectory { project_id, new_name } => { - project::rename_project_directory(ws, project_id, new_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) - } - ActionRequest::SetProjectShowInOverview { project_id, show, window: _ } => { - project::set_show_in_overview(ws, focus_manager, window_id, project_id, show, 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), ActionRequest::RenameFolder { folder_id, name } => { project::rename_folder(ws, folder_id, name, cx) } - ActionRequest::MoveProjectToFolder { project_id, folder_id, position } => { - project::move_to_folder(ws, project_id, folder_id, position, cx) + ActionRequest::MoveProjectToFolder { + project_id, + folder_id, + position, + } => project::move_to_folder(ws, project_id, folder_id, position, cx), + 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, + settings, + cx, + ), + + // ── Sessions (whole-workspace; daemon owns session files + state) ── + ActionRequest::ListSessions => session::list_sessions_action(), + ActionRequest::LoadSession { name } => { + session::load_session_action(ws, focus_manager, name, backend, terminals, settings, cx) } - ActionRequest::MoveProjectOutOfFolder { project_id, top_level_index } => { - project::move_out_of_folder(ws, project_id, top_level_index, cx) + ActionRequest::SaveSession { name } => session::save_session_action(ws, name), + ActionRequest::RenameSession { old_name, new_name } => { + session::rename_session_action(old_name, new_name) } - ActionRequest::CreateWorktree { project_id, branch, create_branch } => { - project::create_worktree(ws, window_id, project_id, branch, create_branch, backend, terminals, cx) + ActionRequest::DeleteSession { name } => session::delete_session_action(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 @@ -258,25 +577,33 @@ pub fn ensure_terminal( terminals: &TerminalsRegistry, backend: &dyn TerminalBackend, ws: &Workspace, + settings: &AppSettings, ) -> Option> { // Fast path: already in registry if let Some(term) = terminals.lock().get(terminal_id).cloned() { return Some(term); } - // Find which project owns this terminal_id and get its path - let mut cwd = None; + // Find which project owns this terminal_id and preserve its configured shell. + // This is required for WSL reconnects: passing `None` routes the persistent + // attach through the Windows host backend instead of the WSL distro backend. + let mut reconnect = None; for project in &ws.data().projects { if let Some(layout) = &project.layout - && layout.find_terminal_path(terminal_id).is_some() { - cwd = Some(project.path.clone()); - break; - } + && let Some(path) = layout.find_terminal_path(terminal_id) + && let Some(LayoutNode::Terminal { shell_type, .. }) = layout.get_at_path(&path) + { + let shell = shell_type + .clone() + .resolve_default(project.default_shell.as_ref(), &settings.default_shell); + reconnect = Some((project.path.clone(), TerminalLaunchPlan::for_shell(shell))); + break; + } } - let cwd = cwd?; + let (cwd, plan) = reconnect?; // Spawn PTY via backend - match backend.reconnect_terminal(terminal_id, &cwd, None) { + match backend.reconnect_terminal_with_plan(terminal_id, &cwd, &plan) { Ok(_id) => { let terminal = Arc::new(Terminal::new( terminal_id.to_string(), @@ -297,16 +624,166 @@ pub fn ensure_terminal( } } +fn effective_terminal_launch( + shell_type: ShellType, + project_default_shell: Option<&ShellType>, + global_default_shell: &ShellType, + shell_wrapper: Option<&str>, + on_create: Option<&str>, + env: &HashMap, +) -> TerminalLaunchPlan { + let shell = shell_type.resolve_default(project_default_shell, global_default_shell); + hooks::terminal_launch_plan(shell, shell_wrapper, on_create, env) +} + +/// Reserve IDs and launch plans for runtime-recovery slots without touching the backend. +pub fn reserve_uninitialized_terminal_launches( + ws: &mut Workspace, + project_ids: &[String], + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> Result, String> { + let mut launches = Vec::new(); + for project_id in project_ids { + let project = ws + .project(project_id) + .ok_or_else(|| format!("project not found: {project_id}"))?; + if project.is_remote { + return Err(format!( + "remote project terminals cannot be materialized locally: {project_id}" + )); + } + let project_path = project.path.clone(); + let project_name = project.name.clone(); + let project_hooks = project.hooks.clone(); + let is_worktree = project.worktree_info.is_some(); + let parent_hooks = project + .worktree_info + .as_ref() + .and_then(|worktree| ws.project(&worktree.parent_project_id)) + .map(|parent| parent.hooks.clone()); + let project_default_shell = project.default_shell.clone(); + let mut uninitialized = Vec::new(); + if let Some(layout) = &project.layout { + collect_uninitialized_terminals_with_shell(layout, Vec::new(), &mut uninitialized); + } + let shell_wrapper = + hooks::resolve_shell_wrapper(&project_hooks, parent_hooks.as_ref(), &settings.hooks); + let on_create = hooks::resolve_terminal_on_create_simple( + &project_hooks, + parent_hooks.as_ref(), + &settings.hooks, + ); + let folder = ws.folder_for_project_or_parent(project_id); + let env = hooks::terminal_hook_env( + project_id, + &project_name, + &project_path, + is_worktree, + folder.map(|folder| folder.id.as_str()), + folder.map(|folder| folder.name.as_str()), + ); + for (path, shell_type) in uninitialized { + launches.push(PreparedTerminalLaunch::new( + project_id.clone(), + path, + uuid::Uuid::new_v4().to_string(), + project_path.clone(), + effective_terminal_launch( + shell_type, + project_default_shell.as_ref(), + &settings.default_shell, + shell_wrapper.as_deref(), + on_create.as_deref(), + &env, + ), + )); + } + } + + for launch in &launches { + ws.set_terminal_id( + launch.project_id(), + launch.layout_path(), + launch.terminal_id().to_string(), + cx, + ); + } + Ok(launches) +} + +/// Clear failed reservations without disturbing a terminal that replaced their ID. +pub fn clear_failed_terminal_launch_reservations( + ws: &mut Workspace, + launches: &[PreparedTerminalLaunch], + failed_terminal_ids: &[String], + cx: &mut impl WorkspaceCx, +) { + let failed: std::collections::HashSet<&str> = + failed_terminal_ids.iter().map(String::as_str).collect(); + for launch in launches { + if !failed.contains(launch.terminal_id()) { + continue; + } + ws.with_layout_node( + launch.project_id(), + launch.layout_path(), + cx, + |node| match node { + LayoutNode::Terminal { terminal_id, .. } + if terminal_id.as_deref() == Some(launch.terminal_id()) => + { + *terminal_id = None; + true + } + _ => false, + }, + ); + } +} + /// Spawn PTYs for any uninitialized terminals (`terminal_id: None`) in a project's layout. /// /// 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,10 +796,15 @@ 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(); - let parent_hooks = project.worktree_info.as_ref() + let parent_hooks = project + .worktree_info + .as_ref() .and_then(|wt| ws.project(&wt.parent_project_id)) .map(|p| p.hooks.clone()); let project_default_shell = project.default_shell.clone(); @@ -330,58 +812,61 @@ pub fn spawn_uninitialized_terminals( if let Some(layout) = &project.layout { collect_uninitialized_terminals_with_shell(layout, vec![], &mut uninitialized); } - log::info!("spawn_uninitialized_terminals: project={}, uninitialized_count={}", project_id, uninitialized.len()); + 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 shell_wrapper = + hooks::resolve_shell_wrapper(&project_hooks, parent_hooks.as_ref(), &global_hooks); + 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()); - let env = hooks::terminal_hook_env(project_id, &project_name, &project_path, is_worktree, folder_id, folder_name); + let env = hooks::terminal_hook_env( + project_id, + &project_name, + &project_path, + is_worktree, + folder_id, + folder_name, + ); let mut spawned_ids = Vec::new(); for (path, shell_type) in uninitialized { - let mut shell = match shell_type { - ShellType::Default => project_default_shell - .clone() - .unwrap_or_else(|| global_default.clone()), - other => other, - }; - - // Apply shell_wrapper if configured - if let Some(ref wrapper) = shell_wrapper { - shell = hooks::apply_shell_wrapper(&shell, wrapper, &env); - } - - // Apply on_create: wrap shell to run command first, then exec into shell - if let Some(ref cmd) = on_create_cmd { - shell = hooks::apply_on_create(&shell, cmd, &env); - } + let plan = effective_terminal_launch( + shell_type, + project_default_shell.as_ref(), + &global_default, + shell_wrapper.as_deref(), + on_create_cmd.as_deref(), + &env, + ); - match backend.create_terminal(&project_path, Some(&shell)) { + match backend.create_terminal_with_plan(&spawn_cwd, &plan) { 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); spawned_ids.push(terminal_id); } Err(e) => { - log::error!( - "Failed to spawn terminal for project {}: {}", - project_id, - e - ); + log::error!("Failed to spawn terminal for project {}: {}", project_id, e); return ActionResult::Err(format!("failed to spawn terminal: {}", e)); } } @@ -415,7 +900,10 @@ pub fn find_terminal_path( /// Canonicalize a relative path within a project directory and verify it doesn't /// escape the project root (path traversal protection). -fn resolve_project_file(project_path: &str, relative_path: &str) -> Result { +fn resolve_project_file( + project_path: &str, + relative_path: &str, +) -> Result { let full_path = std::path::Path::new(project_path).join(relative_path); let canonical = full_path .canonicalize() @@ -432,7 +920,10 @@ fn resolve_project_file(project_path: &str, relative_path: &str) -> Result Result { +fn resolve_new_project_file( + project_path: &str, + relative_path: &str, +) -> Result { if relative_path.is_empty() { return Err("relative_path must not be empty".to_string()); } @@ -565,3 +1056,312 @@ mod path_guard_tests { assert!(validate_leaf_name("a\\b").is_err()); } } + +#[cfg(test)] +mod reconnect_shell_tests { + #[cfg(windows)] + use super::spawn_uninitialized_terminals; + use super::{ + AppSettings, clear_failed_terminal_launch_reservations, ensure_terminal, + reserve_uninitialized_terminal_launches, + }; + use crate::workspace::settings::HooksConfig; + use crate::workspace::state::{LayoutNode, ProjectData, WindowState, Workspace, WorkspaceData}; + use okena_terminal::TerminalsRegistry; + use okena_terminal::backend::{TerminalBackend, TerminalLaunchPlan}; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use okena_workspace::context::WorkspaceCx; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + 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 + } + } + + #[derive(Default)] + struct RecordingBackend { + created_shells: Mutex>>, + reconnected_shells: Mutex>>, + plans: Mutex>, + } + + impl TerminalBackend for RecordingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal(&self, _cwd: &str, shell: Option<&ShellType>) -> anyhow::Result { + self.created_shells + .lock() + .expect("created shell lock") + .push(shell.cloned()); + Ok("terminal".to_string()) + } + + fn create_terminal_with_plan( + &self, + _cwd: &str, + plan: &TerminalLaunchPlan, + ) -> anyhow::Result { + self.plans.lock().expect("plan lock").push(plan.clone()); + let shell = plan.initial_command.as_ref().map_or_else( + || plan.route.clone(), + |command| ShellType::Custom { + path: command.program.clone(), + args: command.args.clone(), + }, + ); + self.created_shells + .lock() + .expect("created shell lock") + .push(Some(shell)); + Ok("terminal".to_string()) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + shell: Option<&ShellType>, + ) -> anyhow::Result { + self.reconnected_shells + .lock() + .expect("reconnected shell lock") + .push(shell.cloned()); + Ok(terminal_id.to_string()) + } + + fn reconnect_terminal_with_plan( + &self, + terminal_id: &str, + _cwd: &str, + plan: &TerminalLaunchPlan, + ) -> anyhow::Result { + self.plans.lock().expect("plan lock").push(plan.clone()); + self.reconnected_shells + .lock() + .expect("reconnected shell lock") + .push(Some(plan.route.clone())); + Ok(terminal_id.to_string()) + } + + 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() + } + } + + struct TestCx; + + impl WorkspaceCx for TestCx { + fn notify(&mut self) {} + fn refresh_views(&mut self) {} + fn hook_runner(&self) -> Option { + None + } + fn hook_monitor(&self) -> Option { + None + } + } + + fn workspace_with_terminal( + shell_type: ShellType, + default_shell: Option, + terminal_id: Option<&str>, + ) -> Workspace { + let project = ProjectData { + id: "project".into(), + name: "Project".into(), + path: "/project".into(), + layout: Some(LayoutNode::Terminal { + terminal_id: terminal_id.map(str::to_string), + shell_type, + minimized: false, + detached: false, + zoom_level: 1.0, + }), + terminal_names: HashMap::new(), + hidden_terminals: HashMap::new(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: HooksConfig::default(), + is_remote: false, + connection_id: None, + service_terminals: HashMap::new(), + default_shell, + hook_terminals: HashMap::new(), + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }; + Workspace::new(WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["project".into()], + folders: Vec::new(), + service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), + main_window: WindowState::default(), + extra_windows: Vec::new(), + }) + } + + fn workspace(shell_type: ShellType, default_shell: Option) -> Workspace { + workspace_with_terminal(shell_type, default_shell, Some("terminal")) + } + + #[test] + fn reconnect_passes_explicit_terminal_shell() { + let explicit = ShellType::Custom { + path: "/bin/explicit".into(), + args: vec!["--login".into()], + }; + let ws = workspace(explicit.clone(), None); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let backend = RecordingBackend::default(); + let settings = AppSettings::default(); + + assert!(ensure_terminal("terminal", &terminals, &backend, &ws, &settings).is_some()); + assert_eq!( + backend + .reconnected_shells + .lock() + .expect("shell lock") + .as_slice(), + &[Some(explicit)] + ); + } + + #[test] + fn reconnect_resolves_terminal_default_from_project() { + let project_default = ShellType::Custom { + path: "/bin/project-default".into(), + args: Vec::new(), + }; + let ws = workspace(ShellType::Default, Some(project_default.clone())); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let backend = RecordingBackend::default(); + let settings = AppSettings::default(); + + assert!(ensure_terminal("terminal", &terminals, &backend, &ws, &settings).is_some()); + assert_eq!( + backend + .reconnected_shells + .lock() + .expect("shell lock") + .as_slice(), + &[Some(project_default)] + ); + } + + #[test] + fn reconnect_passes_system_default_instead_of_none() { + let ws = workspace(ShellType::Default, None); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let backend = RecordingBackend::default(); + let settings = AppSettings::default(); + + assert!(ensure_terminal("terminal", &terminals, &backend, &ws, &settings).is_some()); + assert_eq!( + backend + .reconnected_shells + .lock() + .expect("shell lock") + .as_slice(), + &[Some(ShellType::Default)] + ); + } + + #[test] + fn failed_reservation_does_not_clear_a_replacement_terminal_id() { + let mut ws = workspace_with_terminal(ShellType::Default, None, None); + let mut cx = TestCx; + let launches = reserve_uninitialized_terminal_launches( + &mut ws, + &["project".to_string()], + &AppSettings::default(), + &mut cx, + ) + .expect("reserve terminal launch"); + assert_eq!(launches.len(), 1); + let reserved_id = launches[0].terminal_id().to_string(); + + ws.set_terminal_id("project", &[], "replacement".to_string(), &mut cx); + clear_failed_terminal_launch_reservations(&mut ws, &launches, &[reserved_id], &mut cx); + + let LayoutNode::Terminal { terminal_id, .. } = ws + .project("project") + .and_then(|project| project.layout.as_ref()) + .expect("project terminal layout") + else { + panic!("expected terminal layout"); + }; + assert_eq!(terminal_id.as_deref(), Some("replacement")); + } + + #[cfg(windows)] + #[test] + fn reconnect_keeps_base_wsl_route_and_does_not_replay_create_hook() { + let wsl = ShellType::Wsl { + distro: Some("Ubuntu".into()), + }; + let mut create_ws = workspace_with_terminal(ShellType::Default, None, None); + let create_terminals: TerminalsRegistry = Arc::new(Default::default()); + let backend = RecordingBackend::default(); + let mut settings = AppSettings::default(); + settings.default_shell = wsl.clone(); + settings.hooks.terminal.on_create = Some("echo ready".to_string()); + let mut cx = TestCx; + + let _ = spawn_uninitialized_terminals( + &mut create_ws, + "project", + &backend, + &create_terminals, + &settings, + None, + &mut cx, + ); + + let reconnect_ws = workspace(ShellType::Default, None); + let reconnect_terminals: TerminalsRegistry = Arc::new(Default::default()); + assert!( + ensure_terminal( + "terminal", + &reconnect_terminals, + &backend, + &reconnect_ws, + &settings, + ) + .is_some() + ); + + let plans = backend.plans.lock().expect("plan lock"); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].route, wsl); + assert!(plans[0].initial_command.is_some()); + assert_eq!(plans[1].route, wsl); + assert!(plans[1].initial_command.is_none()); + } +} 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..1bd4f0f7d 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,46 @@ #![allow(clippy::too_many_arguments)] use super::{ActionResult, find_first_terminal_id, spawn_uninitialized_terminals}; -use crate::settings::settings; -use okena_terminal::backend::TerminalBackend; use crate::workspace::focus::FocusManager; -use crate::workspace::state::{WindowId, Workspace}; -use gpui::*; +use crate::workspace::persistence::AppSettings; +use crate::workspace::state::{HookTerminalStatus, WindowId, Workspace}; use okena_core::theme::FolderColor; use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::terminal::{Terminal, TerminalSize}; +use okena_workspace::context::WorkspaceCx; +use okena_workspace::hook_monitor::HookStatus; +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 +54,19 @@ 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 = match ws.add_project(name, path, true, &settings.hooks, window_id, cx) { + Ok(project_id) => project_id, + Err(error) => return ActionResult::Err(error), + }; // 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 +87,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 +97,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 +107,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 +117,36 @@ pub(super) fn rename_project( ws: &mut Workspace, project_id: String, name: String, - cx: &mut Context, + cx: &mut impl WorkspaceCx, ) -> ActionResult { - if ws.project(&project_id).is_none() { + 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 { + 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 +154,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); @@ -108,27 +169,38 @@ pub(super) fn rename_project_directory( None => return ActionResult::Err("cannot determine parent directory".to_string()), }; let new_path = parent.join(&new_name); - if new_path.exists() { - return ActionResult::Err(format!("'{}' already exists", new_name)); - } - if let Err(e) = std::fs::rename(old_path, &new_path) { - return ActionResult::Err(format!("Failed to rename: {}", e)); - } let new_path_str = new_path.to_string_lossy().to_string(); - ws.rename_project_directory(&project_id, new_path_str, new_name, cx); - ActionResult::Ok(None) + match ws.rename_project_directory(&project_id, new_path_str, new_name, cx) { + Ok(()) => ActionResult::Ok(None), + Err(error) => ActionResult::Err(error), + } } 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)); + return project_not_found(&project_id); + } + // Reject while the worktree is still being created: the optimistic create + // registers the row and returns before its background `git worktree add` + // finishes, so dropping the row now would race the in-flight checkout and + // strand an orphaned, git-registered worktree with no workspace entry. + // Mirrors the `is_creating` guard on the close/removal routes + // (`close_worktree`, `begin_worktree_removal`). Guarded here, not inside + // `Workspace::delete_project` — its finalize/rollback callers invoke that + // AFTER their own guards. + if ws.is_creating_project(&project_id) { + return ActionResult::Err("worktree is still being created".to_string()); } - let global_hooks = settings(cx).hooks.clone(); + if ws.is_project_closing(&project_id) { + return ActionResult::Err("project is already being closed".to_string()); + } + let global_hooks = settings.hooks.clone(); ws.delete_project(focus_manager, &project_id, &global_hooks, cx); ActionResult::Ok(None) } @@ -139,7 +211,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 +230,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 +252,73 @@ 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)); + return project_not_found(&project_id); } - let global_hooks = settings(cx).hooks.clone(); + if ws.is_project_closing(&project_id) { + return ActionResult::Err("worktree is already closing".to_string()); + } + let global_hooks = settings.hooks.clone(); match ws.remove_worktree_project(focus_manager, &project_id, force, &global_hooks, cx) { Ok(()) => ActionResult::Ok(None), - Err(e) => ActionResult::Err(e), + Err(error) => ActionResult::Err(error), } } -pub(super) fn create_folder(ws: &mut Workspace, name: String, cx: &mut Context) -> ActionResult { +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 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 +328,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 +410,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,14 +419,34 @@ 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 (worktree_path, wt_project_path) = okena_git::compute_target_paths(&git_root, &subdir, &path_template, &branch); - let global_hooks = settings(cx).hooks.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.hooks.clone(); - match ws.create_worktree_project(&project_id, &branch, &git_root, &worktree_path, &wt_project_path, create_branch, &global_hooks, window_id, cx) { + 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 terminal_id = ws.project(&new_project_id) + 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); match result { @@ -273,11 +462,560 @@ pub(super) fn create_worktree( } } +/// 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) { + Ok(id) => id, + Err(error) => return ActionResult::Err(error), + }; + 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, hook_type, project_name, status) = + match ws.project(&project_id).and_then(|p| { + p.hook_terminals.get(&terminal_id).map(|entry| { + ( + entry.command.clone(), + entry.cwd.clone(), + entry.hook_type.clone(), + p.name.clone(), + entry.status.clone(), + ) + }) + }) { + Some(details) => details, + None => return ActionResult::Err(format!("hook terminal not found: {}", terminal_id)), + }; + if status == HookTerminalStatus::Running { + return ActionResult::Err("hook is still running".to_string()); + } + let monitor = cx.hook_monitor(); + let shell = okena_workspace::hooks::keep_alive_hook_shell(&command); + let new_id = match backend.create_terminal(&cwd, Some(&shell)) { + Ok(id) => id, + Err(e) => { + let message = format!("failed to spawn hook terminal: {e}"); + if let Some(monitor) = monitor.as_ref() { + let execution_id = + monitor.record_start_named(&hook_type, &command, &project_name, None); + monitor.record_finish( + execution_id, + HookStatus::SpawnError { + message: message.clone(), + }, + ); + } + return ActionResult::Err(message); + } + }; + backend.kill(&terminal_id); + if let Some(monitor) = monitor.as_ref() { + monitor.cancel_by_terminal_id(&terminal_id); + } + 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); + if let Some(monitor) = monitor { + monitor.record_start_named(&hook_type, &command, &project_name, Some(new_id.clone())); + } + ActionResult::Ok(Some(serde_json::json!({ "terminal_id": new_id }))) +} + +/// Stop and remove a lifecycle-hook terminal from authoritative daemon state. +pub(super) fn dismiss_hook( + ws: &mut Workspace, + project_id: String, + terminal_id: String, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + let exists = ws + .project(&project_id) + .is_some_and(|project| project.hook_terminals.contains_key(&terminal_id)); + if !exists { + return ActionResult::Err(format!("hook terminal not found: {terminal_id}")); + } + + backend.kill(&terminal_id); + if let Some(monitor) = cx.hook_monitor() { + monitor.cancel_by_terminal_id(&terminal_id); + } + ws.cancel_pending_worktree_close(&terminal_id); + ws.remove_hook_terminal(&terminal_id, cx); + terminals.lock().remove(&terminal_id); + ActionResult::Ok(None) +} + #[cfg(test)] +mod hook_action_tests { + use super::{ActionResult, delete_project, dismiss_hook, remove_worktree_project, rerun_hook}; + use crate::workspace::focus::FocusManager; + use crate::workspace::state::{ + HookTerminalEntry, HookTerminalStatus, ProjectData, WindowState, Workspace, WorkspaceData, + }; + use okena_core::theme::FolderColor; + use okena_terminal::TerminalsRegistry; + use okena_terminal::backend::TerminalBackend; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use okena_workspace::context::WorkspaceCx; + use okena_workspace::hook_monitor::{HookMonitor, HookStatus}; + use okena_workspace::hooks::HookRunner; + use okena_workspace::settings::{AppSettings, HooksConfig}; + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct RecordingTransport { + inputs: Mutex)>>, + } + + impl TerminalTransport for RecordingTransport { + fn send_input(&self, terminal_id: &str, data: &[u8]) { + self.inputs + .lock() + .unwrap() + .push((terminal_id.to_string(), data.to_vec())); + } + + fn resize(&self, _terminal_id: &str, _cols: u16, _rows: u16) {} + + fn uses_mouse_backend(&self) -> bool { + false + } + } + + #[derive(Default)] + struct RecordingBackend { + transport: Arc, + next_id: AtomicUsize, + shells: Mutex>>, + killed: Mutex>, + fail_create: AtomicBool, + } + + impl TerminalBackend for RecordingBackend { + fn transport(&self) -> Arc { + self.transport.clone() + } + + fn create_terminal(&self, _cwd: &str, shell: Option<&ShellType>) -> anyhow::Result { + self.shells.lock().unwrap().push(shell.cloned()); + if self.fail_create.load(Ordering::Relaxed) { + anyhow::bail!("spawn failed"); + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + Ok(format!("rerun-{id}")) + } + + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + unreachable!("tests do not reconnect terminals") + } + + fn kill(&self, terminal_id: &str) { + self.killed.lock().unwrap().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() + } + } + + struct TestCx { + monitor: HookMonitor, + } + + impl WorkspaceCx for TestCx { + fn notify(&mut self) {} + + fn refresh_views(&mut self) {} + + fn hook_runner(&self) -> Option { + None + } + + fn hook_monitor(&self) -> Option { + Some(self.monitor.clone()) + } + } + + fn workspace_with_hook_status(terminal_id: &str, status: HookTerminalStatus) -> Workspace { + let mut hook_terminals = HashMap::new(); + hook_terminals.insert( + terminal_id.to_string(), + HookTerminalEntry { + label: "on_project_open (Project p1)".to_string(), + status, + hook_type: "on_project_open".to_string(), + command: "export OKENA_PROJECT_ID='p1'; echo test".to_string(), + cwd: "/tmp/test".to_string(), + }, + ); + let project = ProjectData { + id: "p1".to_string(), + name: "Project p1".to_string(), + path: "/tmp/test".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, + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }; + Workspace::new(WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["p1".to_string()], + service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), + folders: Vec::new(), + main_window: WindowState::default(), + extra_windows: Vec::new(), + }) + } + + fn workspace_with_hook(terminal_id: &str) -> Workspace { + workspace_with_hook_status(terminal_id, HookTerminalStatus::Succeeded) + } + + fn workspace_with_worktree_hook(terminal_id: &str) -> Workspace { + let workspace = workspace_with_hook(terminal_id); + let mut data = workspace.data().clone(); + data.projects[0].worktree_info = Some(crate::workspace::state::WorktreeMetadata { + parent_project_id: "parent".to_string(), + color_override: None, + main_repo_path: "/tmp/main".to_string(), + worktree_path: "/tmp/test".to_string(), + branch_name: "topic".to_string(), + }); + Workspace::new(data) + } + + #[test] + fn rerun_hook_uses_completion_wrapper_and_records_fresh_execution() { + let mut workspace = workspace_with_hook("old-hook"); + let backend = RecordingBackend::default(); + let terminals: TerminalsRegistry = Default::default(); + let monitor = HookMonitor::new(); + monitor.record_start( + "on_project_open", + "echo test", + "Project p1", + Some("old-hook".to_string()), + ); + let mut cx = TestCx { + monitor: monitor.clone(), + }; + + let result = rerun_hook( + &mut workspace, + "p1".to_string(), + "old-hook".to_string(), + &backend, + &terminals, + &mut cx, + ); + + assert!(matches!(result, ActionResult::Ok(_))); + let entry = workspace + .project("p1") + .unwrap() + .hook_terminals + .get("rerun-0") + .unwrap(); + assert_eq!(entry.status, HookTerminalStatus::Running); + assert!( + !workspace + .project("p1") + .unwrap() + .hook_terminals + .contains_key("old-hook") + ); + assert!(backend.transport.inputs.lock().unwrap().is_empty()); + + let shells = backend.shells.lock().unwrap(); + let ShellType::Custom { args, .. } = shells[0].as_ref().unwrap() else { + panic!("rerun should use a completion-wrapped custom shell"); + }; + assert!(args.iter().any(|arg| arg.contains("__okena_hook_exit"))); + + let history = monitor.history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].terminal_id.as_deref(), Some("rerun-0")); + assert!(matches!(history[0].status, HookStatus::Running)); + assert!(monitor.drain_pending_toasts().is_empty()); + } + + #[test] + fn rerun_hook_rejects_a_running_authoritative_execution() { + let mut workspace = workspace_with_hook_status("running-hook", HookTerminalStatus::Running); + let backend = RecordingBackend::default(); + let terminals: TerminalsRegistry = Default::default(); + let mut cx = TestCx { + monitor: HookMonitor::new(), + }; + + let result = rerun_hook( + &mut workspace, + "p1".to_string(), + "running-hook".to_string(), + &backend, + &terminals, + &mut cx, + ); + + assert!(matches!( + result, + ActionResult::Err(message) if message == "hook is still running" + )); + assert!(backend.shells.lock().unwrap().is_empty()); + assert!(backend.killed.lock().unwrap().is_empty()); + assert!( + workspace + .project("p1") + .unwrap() + .hook_terminals + .contains_key("running-hook") + ); + } + + #[test] + fn dismiss_hook_cancels_running_execution_before_removing_owner() { + let mut workspace = workspace_with_hook("hook-1"); + let backend = RecordingBackend::default(); + let terminals: TerminalsRegistry = Default::default(); + let monitor = HookMonitor::new(); + monitor.record_start( + "on_project_open", + "echo test", + "Project p1", + Some("hook-1".to_string()), + ); + let mut cx = TestCx { + monitor: monitor.clone(), + }; + + let result = dismiss_hook( + &mut workspace, + "p1".to_string(), + "hook-1".to_string(), + &backend, + &terminals, + &mut cx, + ); + + assert!(matches!(result, ActionResult::Ok(None))); + assert!(workspace.project("p1").unwrap().hook_terminals.is_empty()); + assert!(monitor.history().is_empty()); + assert!(monitor.drain_pending_toasts().is_empty()); + assert_eq!(backend.killed.lock().unwrap().as_slice(), ["hook-1"]); + } + + #[test] + fn rerun_spawn_failure_preserves_the_existing_hook_owner() { + let mut workspace = workspace_with_hook("old-hook"); + let backend = RecordingBackend::default(); + backend.fail_create.store(true, Ordering::Relaxed); + let terminals: TerminalsRegistry = Default::default(); + let monitor = HookMonitor::new(); + monitor.record_start( + "on_project_open", + "echo test", + "Project p1", + Some("old-hook".to_string()), + ); + let mut cx = TestCx { + monitor: monitor.clone(), + }; + + let result = rerun_hook( + &mut workspace, + "p1".to_string(), + "old-hook".to_string(), + &backend, + &terminals, + &mut cx, + ); + + assert!(matches!(result, ActionResult::Err(message) if message.contains("spawn failed"))); + assert!( + workspace + .project("p1") + .unwrap() + .hook_terminals + .contains_key("old-hook") + ); + assert!(backend.killed.lock().unwrap().is_empty()); + let history = monitor.history(); + assert!(matches!(history[0].status, HookStatus::SpawnError { .. })); + assert!(matches!(history[1].status, HookStatus::Running)); + } + + #[test] + fn delete_project_rejects_an_authoritative_close_in_progress() { + let mut workspace = workspace_with_hook("hook-1"); + workspace.mark_closing_project_authoritative("p1"); + let mut focus_manager = FocusManager::default(); + let monitor = HookMonitor::new(); + let mut cx = TestCx { monitor }; + + let result = delete_project( + &mut workspace, + &mut focus_manager, + "p1".to_string(), + &AppSettings::default(), + &mut cx, + ); + + assert!(matches!( + result, + ActionResult::Err(message) if message == "project is already being closed" + )); + assert!(workspace.project("p1").is_some()); + } + + #[test] + fn remove_worktree_project_rejects_an_authoritative_close_in_progress() { + let mut workspace = workspace_with_worktree_hook("hook-1"); + workspace.mark_closing_project_authoritative("p1"); + let mut focus_manager = FocusManager::default(); + let mut cx = TestCx { + monitor: HookMonitor::new(), + }; + + let result = remove_worktree_project( + &mut workspace, + &mut focus_manager, + "p1".to_string(), + true, + &AppSettings::default(), + &mut cx, + ); + + assert!(matches!( + result, + ActionResult::Err(message) if message == "worktree is already closing" + )); + assert!(workspace.project("p1").is_some()); + } + + #[test] + fn project_delete_cancels_owned_hook_executions_before_removal() { + let mut workspace = workspace_with_hook("hook-1"); + let mut focus_manager = FocusManager::default(); + let monitor = HookMonitor::new(); + monitor.record_start( + "on_project_open", + "echo test", + "Project p1", + Some("hook-1".to_string()), + ); + let mut cx = TestCx { + monitor: monitor.clone(), + }; + + workspace.delete_project(&mut focus_manager, "p1", &HooksConfig::default(), &mut cx); + + assert!(workspace.project("p1").is_none()); + assert!(monitor.history().is_empty()); + assert!(monitor.drain_pending_toasts().is_empty()); + } +} + +#[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}; + use super::{ActionResult, apply_set_project_show_in_overview}; use crate::workspace::settings::HooksConfig; + use crate::workspace::state::{ProjectData, WindowId, WindowState, Workspace, WorkspaceData}; use gpui::AppContext as _; use okena_core::theme::FolderColor; use std::collections::HashMap; @@ -314,13 +1052,13 @@ mod set_show_in_overview_tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } #[gpui::test] - fn apply_set_project_show_in_overview_reads_hidden_set( - cx: &mut gpui::TestAppContext, - ) { + fn apply_set_project_show_in_overview_reads_hidden_set(cx: &mut gpui::TestAppContext) { // The action's visibility decision must read from the targeted // window's hidden_project_ids. This fixture starts with p1 hidden in // main; the action says `show: true`, so the helper toggles, @@ -347,14 +1085,18 @@ mod set_show_in_overview_tests { } #[gpui::test] - fn apply_set_project_show_in_overview_unknown_project_errs( - cx: &mut gpui::TestAppContext, - ) { + fn apply_set_project_show_in_overview_unknown_project_errs(cx: &mut gpui::TestAppContext) { let workspace = cx.new(|_cx| Workspace::new(make_workspace_data())); workspace.update(cx, |ws: &mut Workspace, cx| { let mut fm = crate::workspace::focus::FocusManager::new(); - let result = - apply_set_project_show_in_overview(ws, &mut fm, WindowId::Main, "missing", true, cx); + let result = apply_set_project_show_in_overview( + ws, + &mut fm, + WindowId::Main, + "missing", + true, + cx, + ); assert!(matches!(result, ActionResult::Err(_))); }); } 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..a8040151f --- /dev/null +++ b/crates/okena-app-core/src/workspace/actions/execute/session.rs @@ -0,0 +1,1556 @@ +//! 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, discard stale hook PTYs, respawn layout terminals, and run +//! the same project-open lifecycle as daemon boot. + +// 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, PreparedTerminalLaunch, PublishedTerminalOwners, + cleanup_stale_prepared_terminal_launches, effective_terminal_launch, ensure_terminal, + materialize_prepared_terminal_launches, publish_prepared_terminal_launches, + spawn_uninitialized_terminals, +}; +use crate::workspace::focus::FocusManager; +use crate::workspace::persistence::AppSettings; +use crate::workspace::persistence::{ + LoadedWorkspace, delete_session, export_workspace, import_workspace, list_sessions, + load_session_with_cleanup, load_session_with_cleanup_for_shell, rename_session, save_session, + session_exists, +}; +use crate::workspace::state::{Workspace, WorkspaceData}; +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::{TerminalBackend, TerminalLaunchPlan, TerminalSessionTeardown}; +use okena_workspace::context::WorkspaceCx; +use okena_workspace::state::{HookTerminalEntry, HookTerminalStatus, LayoutNode}; +use std::collections::HashSet; + +fn ordinary_terminal_ids(data: &WorkspaceData) -> HashSet { + data.projects + .iter() + .flat_map(|project| { + project + .layout + .as_ref() + .map_or_else(Vec::new, |layout| layout.collect_terminal_ids()) + }) + .collect() +} + +fn project_owned_terminal_ids(data: &WorkspaceData) -> HashSet { + data.projects + .iter() + .flat_map(|project| { + let mut ids = project + .layout + .as_ref() + .map_or_else(Vec::new, |layout| layout.collect_terminal_ids()); + ids.extend(project.service_terminals.values().cloned()); + ids.extend(project.hook_terminals.keys().cloned()); + ids + }) + .collect() +} + +fn workspace_replacement_conflict(ws: &Workspace) -> Option { + if let Some(project) = ws + .projects() + .iter() + .find(|project| ws.is_creating_project(&project.id)) + { + return Some(format!( + "cannot replace workspace while worktree '{}' is being created", + project.name + )); + } + ws.projects() + .iter() + .find(|project| ws.is_project_closing(&project.id)) + .map(|project| { + format!( + "cannot replace workspace while worktree '{}' is closing", + project.name + ) + }) +} + +/// Reject workspace replacement while an in-flight worktree owns its state. +pub fn ensure_workspace_replacement_allowed(ws: &Workspace) -> Result<(), String> { + match workspace_replacement_conflict(ws) { + Some(error) => Err(error), + None => Ok(()), + } +} + +/// Read and validate a named session without mutating the live workspace. +pub fn load_session_data( + name: &str, + backend: okena_terminal::session_backend::SessionBackend, +) -> Result { + load_session_with_cleanup(name, backend) + .map_err(|error| format!("failed to load session '{name}': {error}")) +} + +/// Read and validate a named session using the daemon's transient global shell. +pub fn load_session_data_for_shell( + name: &str, + backend: okena_terminal::session_backend::SessionBackend, + global_default_shell: &okena_terminal::shell_config::ShellType, +) -> Result { + load_session_with_cleanup_for_shell(name, backend, global_default_shell) + .map_err(|error| format!("failed to load session '{name}': {error}")) +} + +/// Read and validate an exported workspace without mutating the live workspace. +pub fn import_workspace_data(path: &str) -> Result { + import_workspace(std::path::Path::new(path)) + .map_err(|error| format!("failed to import '{path}': {error}")) +} + +/// Disk-loaded workspace data with every PTY launch resolved and reserved. +pub struct PreparedWorkspaceReplacement { + data: WorkspaceData, + ordinary: Vec, + hooks: Vec, +} + +/// Immutable replacement work published behind the workspace transition gate. +#[derive(Clone)] +pub struct WorkspaceReplacementPlan { + epoch: u64, + ordinary: Vec, + ordinary_owners: PublishedTerminalOwners, + hooks: Vec, + kill_ids: Vec, + stale_sessions: Vec, + terminals: TerminalsRegistry, + hook_runner: Option, + hook_monitor: Option, +} + +pub struct WorkspaceReplacementCompletion { + plan: WorkspaceReplacementPlan, + failed_ordinary: Vec, + failed_hooks: Vec, + errors: Vec, +} + +impl WorkspaceReplacementCompletion { + pub fn epoch(&self) -> u64 { + self.plan.epoch + } +} + +fn prepare_layout_terminals( + node: &mut LayoutNode, + project_id: &str, + cwd: &str, + project_default_shell: Option<&okena_terminal::shell_config::ShellType>, + settings: &AppSettings, + shell_wrapper: Option<&str>, + on_create: Option<&str>, + env: &std::collections::HashMap, + path: &mut Vec, + launches: &mut Vec, +) { + match node { + LayoutNode::Terminal { + terminal_id, + shell_type, + .. + } => { + let persisted = terminal_id.is_some(); + let id = terminal_id + .get_or_insert_with(|| uuid::Uuid::new_v4().to_string()) + .clone(); + let launch_plan = if persisted { + TerminalLaunchPlan::for_shell( + shell_type + .clone() + .resolve_default(project_default_shell, &settings.default_shell), + ) + } else { + effective_terminal_launch( + shell_type.clone(), + project_default_shell, + &settings.default_shell, + shell_wrapper, + on_create, + env, + ) + }; + launches.push(PreparedTerminalLaunch::new( + project_id.to_string(), + path.clone(), + id, + cwd.to_string(), + launch_plan, + )); + } + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + for (index, child) in children.iter_mut().enumerate() { + path.push(index); + prepare_layout_terminals( + child, + project_id, + cwd, + project_default_shell, + settings, + shell_wrapper, + on_create, + env, + path, + launches, + ); + path.pop(); + } + } + } +} + +/// Resolve terminal and project-open hook launches before taking the live lock. +pub fn prepare_workspace_replacement( + mut data: WorkspaceData, + settings: &AppSettings, +) -> PreparedWorkspaceReplacement { + for project in &mut data.projects { + let stale: HashSet = project.hook_terminals.keys().cloned().collect(); + if !stale.is_empty() { + let stale_refs: HashSet<&str> = stale.iter().map(String::as_str).collect(); + LayoutNode::remove_terminal_ids(&mut project.layout, &stale_refs); + project + .terminal_names + .retain(|terminal_id, _| !stale.contains(terminal_id)); + project + .hidden_terminals + .retain(|terminal_id, _| !stale.contains(terminal_id)); + project.hook_terminals.clear(); + } + } + + let lookup = Workspace::new(data.clone()); + let mut ordinary = Vec::new(); + let mut prepared_hooks = Vec::new(); + for project in &mut data.projects { + let parent_hooks = project + .worktree_info + .as_ref() + .and_then(|worktree| lookup.project(&worktree.parent_project_id)) + .map(|parent| parent.hooks.clone()); + let shell_wrapper = crate::workspace::hooks::resolve_shell_wrapper( + &project.hooks, + parent_hooks.as_ref(), + &settings.hooks, + ); + let on_create = crate::workspace::hooks::resolve_terminal_on_create_simple( + &project.hooks, + parent_hooks.as_ref(), + &settings.hooks, + ); + let folder = lookup.folder_for_project_or_parent(&project.id); + let env = crate::workspace::hooks::terminal_hook_env( + &project.id, + &project.name, + &project.path, + project.worktree_info.is_some(), + folder.map(|folder| folder.id.as_str()), + folder.map(|folder| folder.name.as_str()), + ); + if let Some(layout) = &mut project.layout { + prepare_layout_terminals( + layout, + &project.id, + &project.path, + project.default_shell.as_ref(), + settings, + shell_wrapper.as_deref(), + on_create.as_deref(), + &env, + &mut Vec::new(), + &mut ordinary, + ); + } + if let Some(prepared) = okena_hooks::prepare_project_open_hook( + uuid::Uuid::new_v4().to_string(), + &project.hooks, + &project.id, + &project.name, + &project.path, + folder.map(|folder| folder.id.as_str()), + folder.map(|folder| folder.name.as_str()), + &settings.hooks, + ) { + prepared_hooks.push(prepared); + } + } + ordinary.sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + PreparedWorkspaceReplacement { + data, + ordinary, + hooks: prepared_hooks, + } +} + +/// Atomically publish all incoming logical owners without notifying observers. +#[allow(clippy::too_many_arguments)] +pub fn begin_workspace_replacement( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + prepared: PreparedWorkspaceReplacement, + stale_terminal_ids: Vec, + terminals: &TerminalsRegistry, + backend: &dyn TerminalBackend, + cx: &mut impl WorkspaceCx, +) -> Result { + ensure_workspace_replacement_allowed(ws)?; + if ws.terminal_backend_migration_epoch().is_some() { + return Err("a terminal ownership transition is already in progress".to_string()); + } + + let incoming_ids: HashSet = prepared + .ordinary + .iter() + .map(|terminal| terminal.terminal_id.clone()) + .chain( + prepared + .data + .projects + .iter() + .flat_map(|project| project.service_terminals.values().cloned()), + ) + .collect(); + let outgoing_hook_ids: Vec = ws + .projects() + .iter() + .flat_map(|project| project.hook_terminals.keys().cloned()) + .collect(); + let mut kill_ids: HashSet = terminals.lock().keys().cloned().collect(); + kill_ids.extend(project_owned_terminal_ids(ws.data())); + kill_ids.retain(|id| !incoming_ids.contains(id)); + kill_ids.retain(|id| { + !stale_terminal_ids + .iter() + .any(|session| session.terminal_id == *id) + }); + let mut kill_ids: Vec = kill_ids.into_iter().collect(); + kill_ids.sort(); + + let hook_runner = cx.hook_runner(); + let hook_monitor = cx.hook_monitor(); + if let Some(monitor) = &hook_monitor { + for terminal_id in outgoing_hook_ids { + monitor.cancel_by_terminal_id(&terminal_id); + } + } + + terminals.lock().clear(); + let ordinary_owners = + publish_prepared_terminal_launches(&prepared.ordinary, terminals, backend)?; + let epoch = match ws.begin_workspace_replacement_transition(focus_manager, prepared.data) { + Ok(epoch) => epoch, + Err(error) => { + ordinary_owners.release_all(); + return Err(error); + } + }; + for prepared_hook in &prepared.hooks { + let result = prepared_hook.result(); + let Some(project) = ws + .data + .projects + .iter_mut() + .find(|project| project.id == result.project_id) + else { + continue; + }; + project.hook_terminals.insert( + result.terminal_id.clone(), + HookTerminalEntry { + label: result.label.clone(), + status: HookTerminalStatus::Running, + hook_type: result.hook_type.to_string(), + command: result.command.clone(), + cwd: result.cwd.clone(), + }, + ); + project + .terminal_names + .insert(result.terminal_id.clone(), result.label.clone()); + prepared_hook.publish_monitor(hook_monitor.as_ref()); + } + + if let Some(runner) = &hook_runner { + for prepared_hook in &prepared.hooks { + runner.publish_prepared_terminal(prepared_hook); + } + } + + Ok(WorkspaceReplacementPlan { + epoch, + ordinary: prepared.ordinary, + ordinary_owners, + hooks: prepared.hooks, + kill_ids, + stale_sessions: stale_terminal_ids, + terminals: terminals.clone(), + hook_runner, + hook_monitor, + }) +} + +/// Run all potentially blocking PTY operations without the workspace lock. +pub fn materialize_workspace_replacement( + plan: WorkspaceReplacementPlan, + backend: &dyn TerminalBackend, +) -> WorkspaceReplacementCompletion { + for terminal_id in &plan.kill_ids { + backend.kill(terminal_id); + } + for session in &plan.stale_sessions { + backend.kill_session(session); + } + backend.flush_teardown(); + + let ordinary_outcome = materialize_prepared_terminal_launches(&plan.ordinary, backend); + let failed_ordinary = ordinary_outcome.failed_terminal_ids; + let mut failed_hooks = Vec::new(); + let mut errors = ordinary_outcome.errors; + for prepared_hook in &plan.hooks { + let result = prepared_hook.result(); + let launch = plan + .hook_runner + .as_ref() + .ok_or_else(|| "hook runner unavailable".to_string()) + .and_then(|runner| runner.launch_prepared_terminal(prepared_hook)); + if let Err(error) = launch { + prepared_hook.finish_failed_monitor(plan.hook_monitor.as_ref()); + failed_hooks.push(result.terminal_id.clone()); + errors.push(format!("{}: {error}", result.project_id)); + } + } + WorkspaceReplacementCompletion { + plan, + failed_ordinary, + failed_hooks, + errors, + } +} + +/// Convert an aborted blocking task into a deterministic all-launches failure. +pub fn fail_workspace_replacement( + plan: WorkspaceReplacementPlan, + error: String, +) -> WorkspaceReplacementCompletion { + WorkspaceReplacementCompletion { + failed_ordinary: plan + .ordinary + .iter() + .map(|terminal| terminal.terminal_id.clone()) + .collect(), + failed_hooks: plan + .hooks + .iter() + .map(|hook| hook.result().terminal_id.clone()) + .collect(), + plan, + errors: vec![error], + } +} + +/// Tear down launches whose completion lost the workspace epoch. +pub fn cleanup_stale_workspace_replacement( + completion: WorkspaceReplacementCompletion, + backend: &dyn TerminalBackend, +) { + cleanup_stale_prepared_terminal_launches(&completion.plan.ordinary_owners, backend); + for hook in &completion.plan.hooks { + backend.kill(&hook.result().terminal_id); + hook.finish_failed_monitor(completion.plan.hook_monitor.as_ref()); + if let Some(runner) = &completion.plan.hook_runner { + runner.remove_prepared_terminal(hook); + } + } + backend.flush_teardown(); +} + +fn clear_layout_terminal_id(node: &mut LayoutNode, target: &str) -> bool { + match node { + LayoutNode::Terminal { terminal_id, .. } if terminal_id.as_deref() == Some(target) => { + *terminal_id = None; + true + } + LayoutNode::Terminal { .. } => false, + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => children + .iter_mut() + .any(|child| clear_layout_terminal_id(child, target)), + } +} + +/// Commit materialization results if the replacement still owns its epoch. +pub fn finish_workspace_replacement( + ws: &mut Workspace, + completion: WorkspaceReplacementCompletion, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + let plan = completion.plan; + if ws.terminal_backend_migration_epoch() != Some(plan.epoch) + || ws.data_replacement_epoch() != plan.epoch + { + plan.ordinary_owners.release_all(); + for hook in &plan.hooks { + if let Some(runner) = &plan.hook_runner { + runner.remove_prepared_terminal(hook); + } + } + return ActionResult::Err("stale workspace replacement completion".to_string()); + } + + for terminal_id in &completion.failed_ordinary { + for project in &mut ws.data.projects { + if project + .layout + .as_mut() + .is_some_and(|layout| clear_layout_terminal_id(layout, terminal_id)) + { + break; + } + } + plan.ordinary_owners + .release(std::slice::from_ref(terminal_id)); + } + for terminal_id in &completion.failed_hooks { + for project in &mut ws.data.projects { + if project.hook_terminals.remove(terminal_id).is_some() { + project.terminal_names.remove(terminal_id); + project.hidden_terminals.remove(terminal_id); + break; + } + } + plan.terminals.lock().remove(terminal_id); + } + ws.finish_workspace_replacement_transition(plan.epoch, cx); + if completion.errors.is_empty() { + ActionResult::Ok(None) + } else { + ActionResult::Err(format!( + "workspace loaded with terminal materialization errors: {}", + completion.errors.join("; ") + )) + } +} + +/// Kill outgoing-only PTYs, swap the workspace, then restore incoming terminals. +fn replace_workspace_with( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + data: WorkspaceData, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + stale_terminal_ids: &[TerminalSessionTeardown], + cx: &mut impl WorkspaceCx, +) -> ActionResult { + if let Some(error) = workspace_replacement_conflict(ws) { + return ActionResult::Err(error); + } + + let incoming_ordinary_ids = ordinary_terminal_ids(&data); + let mut incoming_persistent_ids = incoming_ordinary_ids.clone(); + incoming_persistent_ids.extend( + data.projects + .iter() + .flat_map(|project| project.service_terminals.values().cloned()), + ); + + let outgoing_hook_ids: HashSet = ws + .projects() + .iter() + .flat_map(|project| project.hook_terminals.keys().cloned()) + .collect(); + let mut ids_to_kill: HashSet = terminals.lock().keys().cloned().collect(); + ids_to_kill.extend(project_owned_terminal_ids(ws.data())); + // Incoming hooks are never reconnected; load clears them and fires fresh + // project-open lifecycle hooks instead. + ids_to_kill.extend( + data.projects + .iter() + .flat_map(|project| project.hook_terminals.keys().cloned()), + ); + ids_to_kill.retain(|id| !incoming_persistent_ids.contains(id)); + ids_to_kill.retain(|id| { + !stale_terminal_ids + .iter() + .any(|session| session.terminal_id == *id) + }); + let mut ids: Vec = ids_to_kill.into_iter().collect(); + ids.sort(); + if let Some(monitor) = cx.hook_monitor() { + for id in &outgoing_hook_ids { + monitor.cancel_by_terminal_id(id); + } + } + for id in &ids { + backend.kill(id); + } + for session in stale_terminal_ids { + if !incoming_persistent_ids.contains(&session.terminal_id) { + backend.kill_session(session); + } + } + terminals.lock().clear(); + + ws.replace_data(focus_manager, data, cx); + + let project_ids: Vec = ws.projects().iter().map(|p| p.id.clone()).collect(); + for pid in &project_ids { + ws.clear_stale_hook_terminals(pid, cx); + } + + let mut materialization_errors = Vec::new(); + let mut incoming_ordinary_ids: Vec = incoming_ordinary_ids.into_iter().collect(); + incoming_ordinary_ids.sort(); + for terminal_id in incoming_ordinary_ids { + if ensure_terminal(&terminal_id, terminals, backend, ws, settings).is_none() { + materialization_errors.push(format!( + "failed to reconnect persisted terminal {terminal_id}" + )); + } + } + for pid in &project_ids { + if let ActionResult::Err(e) = + spawn_uninitialized_terminals(ws, pid, backend, terminals, settings, None, cx) + { + materialization_errors.push(format!("{pid}: {e}")); + } + } + for pid in &project_ids { + ws.fire_project_open_hooks(pid, &settings.hooks, cx); + } + if materialization_errors.is_empty() { + ActionResult::Ok(None) + } else { + ActionResult::Err(format!( + "workspace loaded with terminal materialization errors: {}", + materialization_errors.join("; ") + )) + } +} + +/// Atomically apply data loaded by [`load_session_data`]. +#[allow(clippy::too_many_arguments)] +pub fn apply_loaded_session( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + loaded: LoadedWorkspace, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + replace_workspace_with( + ws, + focus_manager, + loaded.data, + backend, + terminals, + settings, + &loaded.stale_terminal_ids, + cx, + ) +} + +/// Atomically apply data loaded by [`import_workspace_data`]. +#[allow(clippy::too_many_arguments)] +pub fn apply_imported_workspace( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + data: WorkspaceData, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> ActionResult { + replace_workspace_with( + ws, + focus_manager, + data, + backend, + terminals, + settings, + &[], + cx, + ) +} + +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 { + load_session_action_with_loader(ws, focus_manager, backend, terminals, settings, cx, || { + load_session_data_for_shell(&name, settings.session_backend, &settings.default_shell) + }) +} + +fn load_session_action_with_loader( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, + loader: impl FnOnce() -> Result, +) -> ActionResult { + if let Err(error) = ensure_workspace_replacement_allowed(ws) { + return ActionResult::Err(error); + } + let loaded = match loader() { + Ok(loaded) => loaded, + Err(error) => return ActionResult::Err(error), + }; + apply_loaded_session(ws, focus_manager, loaded, backend, terminals, settings, cx) +} + +pub(super) fn list_sessions_action() -> ActionResult { + match list_sessions() { + Ok(sessions) => ActionResult::Ok(Some( + serde_json::to_value(sessions).expect("BUG: SessionInfo must serialize"), + )), + Err(e) => ActionResult::Err(format!("failed to list sessions: {e}")), + } +} + +pub(super) fn save_session_action(ws: &Workspace, name: String) -> ActionResult { + if session_exists(&name) { + return ActionResult::Err(format!("session '{name}' already exists")); + } + 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 rename_session_action(old_name: String, new_name: String) -> ActionResult { + match rename_session(&old_name, &new_name) { + Ok(()) => ActionResult::Ok(None), + Err(e) => ActionResult::Err(format!( + "failed to rename session '{old_name}' to '{new_name}': {e}" + )), + } +} + +pub(super) fn delete_session_action(name: String) -> ActionResult { + match delete_session(&name) { + Ok(()) => ActionResult::Ok(None), + Err(e) => ActionResult::Err(format!("failed to delete 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 { + import_workspace_action_with_loader(ws, focus_manager, backend, terminals, settings, cx, || { + import_workspace_data(&path) + }) +} + +fn import_workspace_action_with_loader( + ws: &mut Workspace, + focus_manager: &mut FocusManager, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, + loader: impl FnOnce() -> Result, +) -> ActionResult { + if let Err(error) = ensure_workspace_replacement_allowed(ws) { + return ActionResult::Err(error); + } + let data = match loader() { + Ok(d) => d, + Err(error) => return ActionResult::Err(error), + }; + apply_imported_workspace(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}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::hook_monitor::HookMonitor; + use crate::workspace::hooks::HookRunner; + use crate::workspace::settings::{HooksConfig, ProjectHooks}; + use crate::workspace::state::{ + HookTerminalEntry, HookTerminalStatus, LayoutNode, ProjectData, WindowState, + }; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::{Terminal, TerminalSize, TerminalTransport}; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + 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 + } + } + + struct RecordingBackend { + next_id: AtomicUsize, + killed: Mutex>, + reconnected: Mutex>, + fail_next_cwds: Mutex>, + } + + struct OwnershipCheckingBackend { + workspace: Arc>, + launches_with_owner: AtomicUsize, + } + + impl TerminalBackend for OwnershipCheckingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("reserved launches must reconnect by id") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + let workspace = self.workspace.lock().unwrap(); + if workspace.find_project_for_terminal(terminal_id).is_some() + || workspace.is_hook_terminal(terminal_id).is_some() + { + self.launches_with_owner.fetch_add(1, Ordering::Relaxed); + } + Ok(terminal_id.to_string()) + } + + fn kill(&self, _terminal_id: &str) {} + fn supports_buffer_capture(&self) -> bool { + false + } + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + 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() + } + } + + impl TerminalBackend for RecordingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal(&self, cwd: &str, _shell: Option<&ShellType>) -> anyhow::Result { + if self.fail_next_cwds.lock().unwrap().remove(cwd) { + anyhow::bail!("requested failure for {cwd}"); + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + Ok(format!("created-{id}")) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + if self.fail_next_cwds.lock().unwrap().remove(cwd) { + anyhow::bail!("requested failure for {cwd}"); + } + self.reconnected + .lock() + .unwrap() + .push((terminal_id.to_string(), cwd.to_string())); + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.killed.lock().unwrap().push(terminal_id.to_string()); + } + + fn supports_buffer_capture(&self) -> bool { + false + } + + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + + 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() + } + } + + struct TestCx { + runner: HookRunner, + monitor: HookMonitor, + } + + impl WorkspaceCx for TestCx { + fn notify(&mut self) {} + fn refresh_views(&mut self) {} + fn hook_runner(&self) -> Option { + Some(self.runner.clone()) + } + fn hook_monitor(&self) -> Option { + Some(self.monitor.clone()) + } + } + + fn project(id: &str, hook_id: &str, on_open: Option<&str>) -> ProjectData { + let mut hook_terminals = HashMap::new(); + hook_terminals.insert( + hook_id.to_string(), + HookTerminalEntry { + label: "old hook".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo old".to_string(), + cwd: "/tmp".to_string(), + }, + ); + ProjectData { + id: id.to_string(), + name: id.to_string(), + path: std::env::temp_dir().to_string_lossy().into_owned(), + layout: None, + terminal_names: HashMap::from([(hook_id.to_string(), "old hook".to_string())]), + hidden_terminals: HashMap::new(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: HooksConfig { + project: ProjectHooks { + on_open: on_open.map(str::to_string), + on_close: None, + }, + ..Default::default() + }, + is_remote: false, + connection_id: None, + service_terminals: HashMap::new(), + default_shell: None, + hook_terminals, + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + } + } + + fn data(project: ProjectData) -> WorkspaceData { + data_many(vec![project]) + } + + fn data_many(projects: Vec) -> WorkspaceData { + let project_order = projects.iter().map(|project| project.id.clone()).collect(); + WorkspaceData { + version: 1, + projects, + project_order, + service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), + folders: Vec::new(), + main_window: WindowState::default(), + extra_windows: Vec::new(), + } + } + + #[test] + fn prepared_replacement_publishes_reserved_ordinary_and_hook_owners_before_launch() { + let workspace = Arc::new(Mutex::new(Workspace::new(data(project( + "outgoing", + "outgoing-hook", + None, + ))))); + let backend = Arc::new(OwnershipCheckingBackend { + workspace: workspace.clone(), + launches_with_owner: AtomicUsize::new(0), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + monitor.record_start_named( + "on_project_open", + "sleep 10", + "outgoing", + Some("outgoing-hook".to_string()), + ); + let mut cx = TestCx { + runner, + monitor: monitor.clone(), + }; + let mut incoming = project("incoming", "stale-hook", Some("echo opened")); + incoming.layout = Some(LayoutNode::new_terminal()); + let prepared = prepare_workspace_replacement(data(incoming), &AppSettings::default()); + assert_eq!(prepared.ordinary.len(), 1); + assert_eq!(prepared.hooks.len(), 1); + let ordinary_id = prepared.ordinary[0].terminal_id.clone(); + let hook_id = prepared.hooks[0].result().terminal_id.clone(); + let mut focus = FocusManager::default(); + let plan = begin_workspace_replacement( + &mut workspace.lock().unwrap(), + &mut focus, + prepared, + Vec::new(), + &terminals, + backend.as_ref(), + &mut cx, + ) + .expect("begin replacement"); + + { + let workspace = workspace.lock().unwrap(); + assert!(workspace.terminal_backend_migration_epoch().is_some()); + assert!(workspace.find_project_for_terminal(&ordinary_id).is_some()); + assert!(workspace.is_hook_terminal(&hook_id).is_some()); + } + assert!(terminals.lock().contains_key(&ordinary_id)); + assert!(terminals.lock().contains_key(&hook_id)); + assert!( + monitor + .history() + .iter() + .any(|entry| entry.terminal_id.as_deref() == Some(&hook_id)) + ); + assert!( + monitor + .history() + .iter() + .all(|entry| entry.terminal_id.as_deref() != Some("outgoing-hook")) + ); + assert!(monitor.drain_pending_toasts().is_empty()); + + let completion = materialize_workspace_replacement(plan, backend.as_ref()); + let result = + finish_workspace_replacement(&mut workspace.lock().unwrap(), completion, &mut cx); + assert!(matches!(result, ActionResult::Ok(_))); + assert_eq!(backend.launches_with_owner.load(Ordering::Relaxed), 2); + assert_eq!( + workspace.lock().unwrap().terminal_backend_migration_epoch(), + None + ); + } + + #[test] + fn failed_reserved_launch_clears_provisional_owner_and_releases_gate() { + let failed_cwd = std::env::temp_dir().join("okena-prepared-session-failure"); + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::from([failed_cwd.to_string_lossy().into_owned()])), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let mut cx = TestCx { + runner, + monitor: HookMonitor::new(), + }; + let mut incoming = project("incoming", "stale-hook", None); + incoming.path = failed_cwd.to_string_lossy().into_owned(); + incoming.layout = Some(LayoutNode::new_terminal()); + let prepared = prepare_workspace_replacement(data(incoming), &AppSettings::default()); + let reserved_id = prepared.ordinary[0].terminal_id.clone(); + let mut workspace = Workspace::new(WorkspaceData::empty()); + let mut focus = FocusManager::default(); + let plan = begin_workspace_replacement( + &mut workspace, + &mut focus, + prepared, + Vec::new(), + &terminals, + backend.as_ref(), + &mut cx, + ) + .expect("begin replacement"); + + let completion = materialize_workspace_replacement(plan, backend.as_ref()); + let result = finish_workspace_replacement(&mut workspace, completion, &mut cx); + assert!(matches!(result, ActionResult::Err(_))); + assert_eq!(workspace.terminal_backend_migration_epoch(), None); + assert!(!terminals.lock().contains_key(&reserved_id)); + assert!(workspace.project("incoming").is_some_and(|project| { + matches!( + project.layout, + Some(LayoutNode::Terminal { + terminal_id: None, + .. + }) + ) + })); + } + + #[test] + fn replacement_kills_stale_hooks_and_runs_project_open_lifecycle() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { + runner, + monitor: monitor.clone(), + }; + let mut workspace = Workspace::new(data(project("old", "outgoing-hook", None))); + let mut focus = FocusManager::default(); + + let result = replace_workspace_with( + &mut workspace, + &mut focus, + data(project("new", "incoming-hook", Some("echo opened"))), + backend.as_ref(), + &terminals, + &AppSettings::default(), + &[], + &mut cx, + ); + + assert!(matches!(result, ActionResult::Ok(_))); + let killed = backend.killed.lock().unwrap(); + assert!(killed.contains(&"outgoing-hook".to_string())); + assert!(killed.contains(&"incoming-hook".to_string())); + drop(killed); + let loaded = workspace.project("new").unwrap(); + assert!(!loaded.hook_terminals.contains_key("incoming-hook")); + assert_eq!(loaded.hook_terminals.len(), 1); + assert!(loaded.hook_terminals.contains_key("created-1")); + let history = monitor.history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].hook_type, "on_project_open"); + } + + #[test] + fn persistent_replacement_preserves_and_reconnects_incoming_terminal_ids() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { runner, monitor }; + let terminal_node = |id: &str| LayoutNode::Terminal { + terminal_id: Some(id.to_string()), + shell_type: ShellType::Default, + minimized: false, + detached: false, + zoom_level: 1.0, + }; + + let mut outgoing = project("old", "outgoing-hook", None); + outgoing.layout = Some(LayoutNode::Tabs { + children: vec![terminal_node("shared"), terminal_node("outgoing-only")], + active_tab: 0, + }); + let mut workspace = Workspace::new(data(outgoing)); + let mut focus = FocusManager::default(); + + terminals.lock().insert( + "registry-only".to_string(), + Arc::new(Terminal::new( + "registry-only".to_string(), + TerminalSize::default(), + backend.transport(), + "/old".to_string(), + )), + ); + + let mut incoming = project("new", "incoming-hook", None); + let incoming_cwd = incoming.path.clone(); + incoming.layout = Some(LayoutNode::Tabs { + children: vec![terminal_node("shared"), terminal_node("incoming-only")], + active_tab: 0, + }); + incoming + .service_terminals + .insert("server".to_string(), "incoming-service".to_string()); + + let result = replace_workspace_with( + &mut workspace, + &mut focus, + data(incoming), + backend.as_ref(), + &terminals, + &AppSettings::default(), + &[TerminalSessionTeardown::host("discarded-stale".to_string())], + &mut cx, + ); + + assert!(matches!(result, ActionResult::Ok(_))); + let killed = backend.killed.lock().unwrap(); + assert!(killed.contains(&"outgoing-only".to_string())); + assert!(killed.contains(&"registry-only".to_string())); + assert!(killed.contains(&"outgoing-hook".to_string())); + assert!(killed.contains(&"incoming-hook".to_string())); + assert!(killed.contains(&"discarded-stale".to_string())); + assert!(!killed.contains(&"shared".to_string())); + assert!(!killed.contains(&"incoming-only".to_string())); + assert!(!killed.contains(&"incoming-service".to_string())); + drop(killed); + + let reconnected = backend.reconnected.lock().unwrap(); + assert_eq!( + *reconnected, + vec![ + ("incoming-only".to_string(), incoming_cwd.clone()), + ("shared".to_string(), incoming_cwd), + ] + ); + drop(reconnected); + let registry = terminals.lock(); + assert_eq!(registry.len(), 2); + assert!(registry.contains_key("shared")); + assert!(registry.contains_key("incoming-only")); + assert!(!registry.contains_key("registry-only")); + let loaded = workspace.project("new").expect("incoming project loaded"); + assert!(!loaded.hook_terminals.contains_key("incoming-hook")); + } + + #[test] + fn replacement_attempts_every_project_and_reports_aggregated_materialization_errors() { + let failed_cwd = std::env::temp_dir().join("okena-session-failed"); + let successful_cwd = std::env::temp_dir().join("okena-session-successful"); + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::from([failed_cwd.to_string_lossy().into_owned()])), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { + runner, + monitor: monitor.clone(), + }; + let mut workspace = Workspace::new(WorkspaceData::empty()); + let mut focus = FocusManager::default(); + + let mut failed = project("failed", "failed-old-hook", Some("echo failed-open")); + failed.path = failed_cwd.to_string_lossy().into_owned(); + failed.layout = Some(LayoutNode::Terminal { + terminal_id: None, + shell_type: ShellType::Default, + minimized: false, + detached: false, + zoom_level: 1.0, + }); + let mut successful = project( + "successful", + "successful-old-hook", + Some("echo successful-open"), + ); + successful.path = successful_cwd.to_string_lossy().into_owned(); + successful.layout = Some(LayoutNode::Terminal { + terminal_id: None, + shell_type: ShellType::Default, + minimized: false, + detached: false, + zoom_level: 1.0, + }); + + let result = replace_workspace_with( + &mut workspace, + &mut focus, + data_many(vec![failed, successful]), + backend.as_ref(), + &terminals, + &AppSettings::default(), + &[], + &mut cx, + ); + + let ActionResult::Err(error) = result else { + panic!("one project materialization should fail"); + }; + assert!(error.contains("failed: failed to spawn terminal")); + assert!(workspace.project("successful").is_some()); + assert!( + workspace + .project("successful") + .and_then(|project| project.layout.as_ref()) + .is_some_and(|layout| matches!( + layout, + LayoutNode::Terminal { + terminal_id: Some(_), + .. + } + )) + ); + let history = monitor.history(); + assert_eq!(history.len(), 2, "every project-open lifecycle must run"); + assert!(history.iter().any(|entry| entry.project_name == "failed")); + assert!( + history + .iter() + .any(|entry| entry.project_name == "successful") + ); + } + + #[test] + fn replacement_cancels_outgoing_hook_monitor_before_dropping_ownership() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + monitor.record_start_named( + "on_project_open", + "sleep 10", + "old", + Some("outgoing-hook".to_string()), + ); + let mut cx = TestCx { + runner, + monitor: monitor.clone(), + }; + let mut workspace = Workspace::new(data(project("old", "outgoing-hook", None))); + let mut focus = FocusManager::default(); + + let result = replace_workspace_with( + &mut workspace, + &mut focus, + WorkspaceData::empty(), + backend.as_ref(), + &terminals, + &AppSettings::default(), + &[], + &mut cx, + ); + + assert!(matches!(result, ActionResult::Ok(_))); + assert!(monitor.history().is_empty()); + assert!(monitor.drain_pending_toasts().is_empty()); + } + + #[test] + fn replacement_is_rejected_while_optimistic_worktree_create_is_active() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { runner, monitor }; + let mut workspace = Workspace::new(data(project("creating", "old-hook", None))); + workspace.mark_creating_project("creating"); + let original_epoch = workspace.data_replacement_epoch(); + let mut focus = FocusManager::default(); + + let result = replace_workspace_with( + &mut workspace, + &mut focus, + data(project("replacement", "new-hook", None)), + backend.as_ref(), + &terminals, + &AppSettings::default(), + &[], + &mut cx, + ); + + assert!(matches!( + result, + ActionResult::Err(ref error) + if error == "cannot replace workspace while worktree 'creating' is being created" + )); + assert!(workspace.project("creating").is_some()); + assert!(workspace.project("replacement").is_none()); + assert!(workspace.is_creating_project("creating")); + assert_eq!(workspace.data_replacement_epoch(), original_epoch); + assert!(backend.killed.lock().unwrap().is_empty()); + assert!(backend.reconnected.lock().unwrap().is_empty()); + } + + #[test] + fn replacement_is_rejected_while_worktree_close_or_merge_is_active() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { runner, monitor }; + let mut workspace = Workspace::new(data(project("closing", "old-hook", None))); + // Both background close routes set this authoritative flag before any + // non-merge removal or merge git work is dispatched. + workspace.mark_closing_project_authoritative("closing"); + let original_epoch = workspace.data_replacement_epoch(); + let mut focus = FocusManager::default(); + + let result = replace_workspace_with( + &mut workspace, + &mut focus, + data(project("replacement", "new-hook", None)), + backend.as_ref(), + &terminals, + &AppSettings::default(), + &[], + &mut cx, + ); + + assert!(matches!( + result, + ActionResult::Err(ref error) + if error == "cannot replace workspace while worktree 'closing' is closing" + )); + assert!(workspace.project("closing").is_some()); + assert!(workspace.project("replacement").is_none()); + assert!(workspace.is_project_closing("closing")); + assert_eq!(workspace.data_replacement_epoch(), original_epoch); + assert!(backend.killed.lock().unwrap().is_empty()); + assert!(backend.reconnected.lock().unwrap().is_empty()); + } + + #[test] + fn load_session_rejects_active_create_before_loading() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { runner, monitor }; + let mut workspace = Workspace::new(data(project("creating", "old-hook", None))); + workspace.mark_creating_project("creating"); + let mut focus = FocusManager::default(); + let loader_calls = AtomicUsize::new(0); + + let result = load_session_action_with_loader( + &mut workspace, + &mut focus, + backend.as_ref(), + &terminals, + &AppSettings::default(), + &mut cx, + || { + loader_calls.fetch_add(1, Ordering::Relaxed); + Ok(LoadedWorkspace { + data: WorkspaceData::empty(), + stale_terminal_ids: Vec::new(), + }) + }, + ); + + assert!(matches!( + result, + ActionResult::Err(ref error) + if error == "cannot replace workspace while worktree 'creating' is being created" + )); + assert_eq!(loader_calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn import_workspace_rejects_active_close_before_loading() { + let backend = Arc::new(RecordingBackend { + next_id: AtomicUsize::new(1), + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + fail_next_cwds: Mutex::new(HashSet::new()), + }); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let runner = HookRunner::new(backend.clone(), terminals.clone()); + let monitor = HookMonitor::new(); + let mut cx = TestCx { runner, monitor }; + let mut workspace = Workspace::new(data(project("closing", "old-hook", None))); + workspace.mark_closing_project_authoritative("closing"); + let mut focus = FocusManager::default(); + let loader_calls = AtomicUsize::new(0); + + let result = import_workspace_action_with_loader( + &mut workspace, + &mut focus, + backend.as_ref(), + &terminals, + &AppSettings::default(), + &mut cx, + || { + loader_calls.fetch_add(1, Ordering::Relaxed); + Ok(WorkspaceData::empty()) + }, + ); + + assert!(matches!( + result, + ActionResult::Err(ref error) + if error == "cannot replace workspace while worktree 'closing' is closing" + )); + assert_eq!(loader_calls.load(Ordering::Relaxed), 0); + } +} 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..2890da5fc 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/tab.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/tab.rs @@ -6,11 +6,12 @@ #![allow(clippy::too_many_arguments)] use super::{ActionResult, spawn_uninitialized_terminals}; -use okena_terminal::backend::TerminalBackend; use crate::workspace::focus::FocusManager; +use crate::workspace::persistence::AppSettings; use crate::workspace::state::{DropZone, Workspace}; -use gpui::*; use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +use okena_workspace::context::WorkspaceCx; pub(super) fn add_tab( ws: &mut Workspace, @@ -20,14 +21,26 @@ 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 +48,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 +60,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,10 +74,18 @@ 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); + ws.move_terminal_to_tab_group( + focus_manager, + &project_id, + &terminal_id, + target_pid, + &target_path, + position, + cx, + ); ActionResult::Ok(None) } @@ -76,7 +97,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, @@ -86,6 +107,14 @@ pub(super) fn move_pane_to( "center" => DropZone::Center, _ => return ActionResult::Err(format!("invalid drop zone: {}", zone)), }; - ws.move_pane(focus_manager, &project_id, &terminal_id, &target_project_id, &target_terminal_id, drop_zone, cx); + ws.move_pane( + focus_manager, + &project_id, + &terminal_id, + &target_project_id, + &target_terminal_id, + drop_zone, + cx, + ); ActionResult::Ok(None) } 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..046ee3b07 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/terminal.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/terminal.rs @@ -5,19 +5,34 @@ // more than it clarifies here. #![allow(clippy::too_many_arguments)] -use super::{ - ActionResult, ensure_terminal, find_terminal_path, spawn_uninitialized_terminals, -}; -use okena_terminal::backend::TerminalBackend; -use okena_terminal::terminal::TerminalSize; +use super::{ActionResult, ensure_terminal, find_terminal_path, spawn_uninitialized_terminals}; use crate::workspace::focus::FocusManager; +use crate::workspace::persistence::AppSettings; use crate::workspace::state::Workspace; use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line, Point}; -use gpui::*; use okena_core::keys::SpecialKey; use okena_core::types::SplitDirection; use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::shell_config::ShellType; +use okena_terminal::terminal::Terminal; +use okena_terminal::terminal::TerminalSize; +use okena_workspace::context::WorkspaceCx; + +fn with_ensured_terminal( + ws: &Workspace, + terminal_id: &str, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + f: impl FnOnce(&Terminal) -> ActionResult, +) -> ActionResult { + match ensure_terminal(terminal_id, terminals, backend, ws, settings) { + Some(term) => f(&term), + None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), + } +} pub(super) fn create( ws: &mut Workspace, @@ -25,10 +40,25 @@ 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 +69,58 @@ 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); + } + if terminals.lock().contains_key(&terminal_id) { + ws.remember_closing_terminal_owner(&project_id, &terminal_id); + } + 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,11 +130,14 @@ 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 { Some(path) => { + if terminals.lock().contains_key(&terminal_id) { + ws.remember_closing_terminal_owner(&project_id, &terminal_id); + } backend.kill(&terminal_id); terminals.lock().remove(&terminal_id); ws.close_terminal_and_focus_sibling(focus_manager, &project_id, &path, cx); @@ -73,13 +154,16 @@ 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 { let path = find_terminal_path(ws, &project_id, terminal_id); match path { Some(path) => { + if terminals.lock().contains_key(terminal_id) { + ws.remember_closing_terminal_owner(&project_id, terminal_id); + } backend.kill(terminal_id); terminals.lock().remove(terminal_id); ws.close_terminal_and_focus_sibling(focus_manager, &project_id, &path, cx); @@ -100,7 +184,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 { @@ -118,15 +202,26 @@ pub(super) fn send_text( text: String, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, + settings: &AppSettings, ) -> 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, settings, |term| { + term.send_input(&text); + ActionResult::Ok(None) + }) +} + +pub(super) fn send_bytes( + ws: &mut Workspace, + terminal_id: String, + data: Vec, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, +) -> ActionResult { + with_ensured_terminal(ws, &terminal_id, backend, terminals, settings, |term| { + term.send_bytes(&data); + ActionResult::Ok(None) + }) } pub(super) fn run_command( @@ -135,15 +230,12 @@ pub(super) fn run_command( command: String, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, + settings: &AppSettings, ) -> 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, settings, |term| { + term.send_input(&format!("{}\r", command)); + ActionResult::Ok(None) + }) } pub(super) fn send_special_key( @@ -152,15 +244,12 @@ pub(super) fn send_special_key( key: SpecialKey, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, + settings: &AppSettings, ) -> 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, settings, |term| { + term.send_bytes(&key.to_bytes()); + ActionResult::Ok(None) + }) } pub(super) fn resize( @@ -170,21 +259,18 @@ pub(super) fn resize( rows: u16, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, + settings: &AppSettings, ) -> 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, settings, |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 +278,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 +288,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 +299,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 +313,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) @@ -238,33 +324,47 @@ pub(super) fn read_content( terminal_id: String, backend: &dyn TerminalBackend, terminals: &TerminalsRegistry, + settings: &AppSettings, ) -> 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, settings, |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("buffer capture unavailable (requires a tmux session backend)".into()) } - None => ActionResult::Err(format!("terminal not found: {}", terminal_id)), } } diff --git a/crates/okena-app-core/src/workspace/actions/execute/terminal_batch.rs b/crates/okena-app-core/src/workspace/actions/execute/terminal_batch.rs new file mode 100644 index 000000000..0aeffa114 --- /dev/null +++ b/crates/okena-app-core/src/workspace/actions/execute/terminal_batch.rs @@ -0,0 +1,340 @@ +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::{TerminalBackend, TerminalLaunchPlan}; +use okena_terminal::terminal::{Terminal, TerminalSize}; +use std::collections::HashSet; +use std::sync::Arc; + +/// One reserved layout terminal that can be published before its PTY is launched. +#[derive(Clone)] +pub struct PreparedTerminalLaunch { + pub(super) project_id: String, + pub(super) layout_path: Vec, + pub(super) terminal_id: String, + pub(super) cwd: String, + pub(super) launch_plan: TerminalLaunchPlan, +} + +impl PreparedTerminalLaunch { + pub fn new( + project_id: String, + layout_path: Vec, + terminal_id: String, + cwd: String, + launch_plan: TerminalLaunchPlan, + ) -> Self { + Self { + project_id, + layout_path, + terminal_id, + cwd, + launch_plan, + } + } + + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn layout_path(&self) -> &[usize] { + &self.layout_path + } + + pub fn terminal_id(&self) -> &str { + &self.terminal_id + } + + pub fn cwd(&self) -> &str { + &self.cwd + } + + pub fn launch_plan(&self) -> &TerminalLaunchPlan { + &self.launch_plan + } +} + +#[derive(Clone)] +struct PublishedTerminalOwner { + terminal_id: String, + terminal: Arc, +} + +/// Exact registry owners published before a blocking materialization batch. +#[derive(Clone)] +pub struct PublishedTerminalOwners { + terminals: TerminalsRegistry, + owners: Vec, +} + +impl PublishedTerminalOwners { + /// Release only IDs that still point at this batch's exact registry owner. + pub fn release(&self, terminal_ids: &[String]) -> Vec { + let requested: HashSet<&str> = terminal_ids.iter().map(String::as_str).collect(); + let mut released = Vec::new(); + let mut registry = self.terminals.lock(); + for owner in &self.owners { + if !requested.contains(owner.terminal_id.as_str()) { + continue; + } + let still_owned = registry + .get(&owner.terminal_id) + .is_some_and(|terminal| Arc::ptr_eq(terminal, &owner.terminal)); + if still_owned { + registry.remove(&owner.terminal_id); + released.push(owner.terminal_id.clone()); + } + } + released + } + + pub fn release_all(&self) -> Vec { + let terminal_ids = self + .owners + .iter() + .map(|owner| owner.terminal_id.clone()) + .collect::>(); + self.release(&terminal_ids) + } +} + +#[derive(Default)] +pub struct PreparedTerminalLaunchOutcome { + pub failed_terminal_ids: Vec, + pub errors: Vec, +} + +/// Publish logical registry owners without launching a PTY. +pub fn publish_prepared_terminal_launches( + launches: &[PreparedTerminalLaunch], + terminals: &TerminalsRegistry, + backend: &dyn TerminalBackend, +) -> Result { + let mut owners: Vec = Vec::with_capacity(launches.len()); + let transport = backend.transport(); + let mut registry = terminals.lock(); + for launch in launches { + if registry.contains_key(&launch.terminal_id) { + for owner in &owners { + registry.remove(&owner.terminal_id); + } + return Err(format!( + "terminal reservation already exists: {}", + launch.terminal_id + )); + } + let terminal = Arc::new(Terminal::new( + launch.terminal_id.clone(), + TerminalSize::default(), + transport.clone(), + launch.cwd.clone(), + )); + registry.insert(launch.terminal_id.clone(), terminal.clone()); + owners.push(PublishedTerminalOwner { + terminal_id: launch.terminal_id.clone(), + terminal, + }); + } + drop(registry); + Ok(PublishedTerminalOwners { + terminals: terminals.clone(), + owners, + }) +} + +/// Run the blocking reconnect portion after logical owners are visible. +pub fn materialize_prepared_terminal_launches( + launches: &[PreparedTerminalLaunch], + backend: &dyn TerminalBackend, +) -> PreparedTerminalLaunchOutcome { + let mut outcome = PreparedTerminalLaunchOutcome::default(); + for launch in launches { + match backend.reconnect_terminal_with_plan( + &launch.terminal_id, + &launch.cwd, + &launch.launch_plan, + ) { + Ok(id) if id == launch.terminal_id => {} + Ok(id) => { + backend.kill(&id); + outcome.failed_terminal_ids.push(launch.terminal_id.clone()); + outcome.errors.push(format!( + "backend returned unexpected terminal id {id} for {}", + launch.terminal_id + )); + } + Err(error) => { + outcome.failed_terminal_ids.push(launch.terminal_id.clone()); + outcome.errors.push(format!( + "failed to materialize terminal {}: {error}", + launch.terminal_id + )); + } + } + } + outcome +} + +/// Tear down a stale completion without deleting a newer registry owner. +pub fn cleanup_stale_prepared_terminal_launches( + owners: &PublishedTerminalOwners, + backend: &dyn TerminalBackend, +) { + for terminal_id in owners.release_all() { + backend.kill(&terminal_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use std::sync::Mutex; + + struct TestTransport; + + impl TerminalTransport for TestTransport { + 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 + } + } + + struct TestBackend { + killed: Mutex>, + transport: Arc, + } + + impl TestBackend { + fn new() -> Self { + Self { + killed: Mutex::new(Vec::new()), + transport: Arc::new(TestTransport), + } + } + } + + impl TerminalBackend for TestBackend { + fn transport(&self) -> Arc { + self.transport.clone() + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + unreachable!("test uses reserved reconnects") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.killed + .lock() + .expect("killed terminals 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 launch(id: &str) -> PreparedTerminalLaunch { + PreparedTerminalLaunch::new( + "project".to_string(), + Vec::new(), + id.to_string(), + "/tmp".to_string(), + TerminalLaunchPlan::for_shell(ShellType::Default), + ) + } + + #[test] + fn publication_rolls_back_on_reservation_collision() { + let backend = TestBackend::new(); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let existing = Arc::new(Terminal::new( + "collision".to_string(), + TerminalSize::default(), + backend.transport(), + "/tmp".to_string(), + )); + terminals + .lock() + .insert("collision".to_string(), existing.clone()); + + let error = publish_prepared_terminal_launches( + &[launch("reserved"), launch("collision")], + &terminals, + &backend, + ) + .err() + .expect("collision rejects batch"); + + assert!(error.contains("already exists")); + assert!(!terminals.lock().contains_key("reserved")); + assert!(Arc::ptr_eq( + terminals.lock().get("collision").expect("existing owner"), + &existing + )); + } + + #[test] + fn stale_cleanup_does_not_remove_or_kill_replacement_owner() { + let backend = TestBackend::new(); + let terminals: TerminalsRegistry = Arc::new(Default::default()); + let owners = + publish_prepared_terminal_launches(&[launch("reserved")], &terminals, &backend) + .expect("publish owner"); + let replacement = Arc::new(Terminal::new( + "reserved".to_string(), + TerminalSize::default(), + backend.transport(), + "/replacement".to_string(), + )); + terminals + .lock() + .insert("reserved".to_string(), replacement.clone()); + + cleanup_stale_prepared_terminal_launches(&owners, &backend); + + assert!( + backend + .killed + .lock() + .expect("killed terminals lock") + .is_empty() + ); + assert!(Arc::ptr_eq( + terminals.lock().get("reserved").expect("replacement owner"), + &replacement + )); + } +} 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..342b44733 100644 --- a/crates/okena-app/src/action_dispatch.rs +++ b/crates/okena-app/src/action_dispatch.rs @@ -1,70 +1,120 @@ -//! 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 crate::workspace::state::{ProjectLayoutMode, WindowId, Workspace}; use okena_core::api::ActionRequest; use okena_transport::client::strip_prefix; use gpui::{AppContext, Entity}; -use std::sync::Arc; + +fn canonicalize_layout_action(action: ActionRequest, mode: ProjectLayoutMode) -> ActionRequest { + if !mode.is_rows() { + return action; + } + + match action { + ActionRequest::SplitTerminal { + project_id, + path, + direction, + } => ActionRequest::SplitTerminal { + project_id, + path, + direction: direction.flipped(), + }, + ActionRequest::MovePaneTo { + project_id, + terminal_id, + target_project_id, + target_terminal_id, + zone, + } => ActionRequest::MovePaneTo { + project_id, + terminal_id, + target_project_id, + target_terminal_id, + zone: match zone.as_str() { + "top" => "left".to_string(), + "bottom" => "right".to_string(), + "left" => "top".to_string(), + "right" => "bottom".to_string(), + _ => zone, + }, + }, + other => other, + } +} + +fn discovered_worktree_project_name(worktree_path: &str, branch: &str) -> String { + let directory_name = std::path::Path::new(worktree_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("worktree"); + format!("{directory_name} ({branch})") +} /// 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 +124,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 @@ -101,243 +139,217 @@ pub enum ActionDispatcher { } impl ActionDispatcher { - #[allow(dead_code)] - pub fn is_remote(&self) -> bool { - matches!(self, Self::Remote { .. }) + pub fn shares_local_filesystem(&self) -> bool { + let Self::Remote { connection_id, .. } = self; + connection_id == okena_transport::client::LOCAL_DAEMON_CONNECTION_ID + } + + 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 mode = workspace.read_with(cx, |ws, _cx| ws.project_layout_mode(*window_id)); + let action = canonicalize_layout_action(action, mode); - 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; - } - _ => {} - } - execute_action(action, ws, window_id, fm, &*backend, &terminals, 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; } - Self::Remote { - connection_id, - manager, - workspace, - focus_manager, - window_id, + ActionRequest::FocusTerminal { + project_id, + terminal_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); + let pid = project_id.clone(); + let activity_pid = pid.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(); + }); + let action = strip_remote_ids( + ActionRequest::RecordProjectActivity { + project_id: activity_pid, + }, + connection_id, + ); let cid = connection_id.clone(); manager.update(cx, |rm, cx| { rm.send_action(&cid, action, cx); }); + 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(); + 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 + } + ActionRequest::AddDiscoveredWorktree { + worktree_path, + branch, + .. + } => { + let window_id = *window_id; + let cid = connection_id.clone(); + let name = discovered_worktree_project_name(worktree_path, branch); + workspace.update(cx, |ws, _cx| { + ws.queue_pending_remote_project_visibility(window_id, &cid, &name, 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 +357,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 +375,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 +400,11 @@ 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(); @@ -432,6 +412,28 @@ impl ActionDispatcher { rm.upload_paste_image(&cid, &remote_terminal_id, &mime, bytes, cx); }); } + + pub fn upload_remote_paste_files( + &self, + terminal_id: &str, + files: Vec, + cx: &mut impl AppContext, + ) { + let Self::Remote { + connection_id, + manager, + .. + } = self; + let remote_terminal_id = strip_prefix(terminal_id, connection_id); + let cid = connection_id.clone(); + let files = files + .into_iter() + .map(|file| (file.extension, file.bytes)) + .collect(); + manager.update(cx, |rm, cx| { + rm.upload_paste_files(&cid, &remote_terminal_id, files, cx); + }); + } } impl okena_views_terminal::ActionDispatch for ActionDispatcher { @@ -439,8 +441,8 @@ impl okena_views_terminal::ActionDispatch for ActionDispatcher { self.dispatch(action, cx); } - fn is_remote(&self) -> bool { - self.is_remote() + fn shares_local_filesystem(&self) -> bool { + self.shares_local_filesystem() } fn split_terminal( @@ -453,13 +455,7 @@ impl okena_views_terminal::ActionDispatch for ActionDispatcher { self.split_terminal(project_id, layout_path, direction, cx); } - fn add_tab( - &self, - project_id: &str, - layout_path: &[usize], - in_group: bool, - cx: &mut gpui::App, - ) { + fn add_tab(&self, project_id: &str, layout_path: &[usize], in_group: bool, cx: &mut gpui::App) { self.add_tab(project_id, layout_path, in_group, cx); } @@ -472,6 +468,47 @@ impl okena_views_terminal::ActionDispatch for ActionDispatcher { ) { self.upload_remote_paste_image(terminal_id, mime, bytes, cx); } + + fn upload_remote_paste_files( + &self, + terminal_id: &str, + files: Vec, + cx: &mut gpui::App, + ) { + self.upload_remote_paste_files(terminal_id, files, 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) = { + 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::RemoteActionClient::new(config, token) + .post_action(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. @@ -482,6 +519,10 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest terminal_id: s(&terminal_id), text, }, + ActionRequest::SendBytes { terminal_id, data } => ActionRequest::SendBytes { + terminal_id: s(&terminal_id), + data, + }, ActionRequest::RunCommand { terminal_id, command, @@ -525,9 +566,23 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest terminal_id: s(&terminal_id), window, }, + ActionRequest::RecordProjectActivity { project_id } => { + ActionRequest::RecordProjectActivity { + project_id: s(&project_id), + } + } 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 +629,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, @@ -647,6 +711,12 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest ActionRequest::GitBranches { project_id } => ActionRequest::GitBranches { project_id: s(&project_id), }, + ActionRequest::GitListPullRequests { project_id, limit } => { + ActionRequest::GitListPullRequests { + project_id: s(&project_id), + limit, + } + } ActionRequest::GitFileContents { project_id, file_path, @@ -666,18 +736,14 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest project_id: s(&project_id), new_index, }, - ActionRequest::SetProjectColor { project_id, color } => { - ActionRequest::SetProjectColor { - project_id: s(&project_id), - color, - } - } - ActionRequest::SetFolderColor { folder_id, color } => { - ActionRequest::SetFolderColor { - folder_id: s(&folder_id), - color, - } - } + ActionRequest::SetProjectColor { project_id, color } => ActionRequest::SetProjectColor { + project_id: s(&project_id), + color, + }, + ActionRequest::SetFolderColor { folder_id, color } => ActionRequest::SetFolderColor { + folder_id: s(&folder_id), + color, + }, ActionRequest::StartService { project_id, service_name, @@ -717,7 +783,34 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest branch, create_branch, }, - ActionRequest::GitCommitGraph { project_id, count, branch } => ActionRequest::GitCommitGraph { + 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::DismissHook { + project_id, + terminal_id, + } => ActionRequest::DismissHook { + project_id: s(&project_id), + terminal_id: s(&terminal_id), + }, + ActionRequest::GitCommitGraph { + project_id, + count, + branch, + } => ActionRequest::GitCommitGraph { project_id: s(&project_id), count, branch, @@ -725,70 +818,155 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest ActionRequest::GitListBranches { project_id } => ActionRequest::GitListBranches { project_id: s(&project_id), }, - ActionRequest::GitStageFile { project_id, file_path } => ActionRequest::GitStageFile { + 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, }, - ActionRequest::GitUnstageFile { project_id, file_path } => ActionRequest::GitUnstageFile { + ActionRequest::GitUnstageFile { + project_id, + file_path, + } => ActionRequest::GitUnstageFile { project_id: s(&project_id), file_path, }, - ActionRequest::GitDiscardFile { project_id, file_path } => ActionRequest::GitDiscardFile { + ActionRequest::GitDiscardFile { + project_id, + file_path, + } => ActionRequest::GitDiscardFile { project_id: s(&project_id), file_path, }, - ActionRequest::GitBlame { project_id, relative_path } => ActionRequest::GitBlame { + ActionRequest::GitBlame { + project_id, + relative_path, + } => ActionRequest::GitBlame { project_id: s(&project_id), relative_path, }, - ActionRequest::ListFiles { project_id, show_ignored } => ActionRequest::ListFiles { + ActionRequest::ListFiles { + project_id, + show_ignored, + } => ActionRequest::ListFiles { project_id: s(&project_id), show_ignored, }, - ActionRequest::ListDirectory { project_id, relative_path, show_ignored } => { - ActionRequest::ListDirectory { - project_id: s(&project_id), - relative_path, - show_ignored, - } - } - ActionRequest::ReadFile { project_id, relative_path } => ActionRequest::ReadFile { + ActionRequest::ListDirectory { + project_id, + relative_path, + show_ignored, + } => ActionRequest::ListDirectory { project_id: s(&project_id), relative_path, + show_ignored, }, - ActionRequest::ReadFileBytes { project_id, relative_path } => ActionRequest::ReadFileBytes { + ActionRequest::ReadFile { + project_id, + relative_path, + } => ActionRequest::ReadFile { project_id: s(&project_id), relative_path, }, - ActionRequest::FileSize { project_id, relative_path } => ActionRequest::FileSize { + ActionRequest::ReadFileBytes { + project_id, + relative_path, + } => ActionRequest::ReadFileBytes { project_id: s(&project_id), relative_path, }, - ActionRequest::SearchContent { project_id, query, case_sensitive, mode, max_results, file_glob, context_lines } => { - ActionRequest::SearchContent { - project_id: s(&project_id), - query, - case_sensitive, - mode, - max_results, - file_glob, - context_lines, - } - } - ActionRequest::RenameFile { project_id, relative_path, new_name } => ActionRequest::RenameFile { + ActionRequest::FileSize { + project_id, + relative_path, + } => ActionRequest::FileSize { + project_id: s(&project_id), + relative_path, + }, + ActionRequest::SearchContent { + project_id, + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + } => ActionRequest::SearchContent { + project_id: s(&project_id), + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + }, + ActionRequest::RenameFile { + project_id, + relative_path, + new_name, + } => ActionRequest::RenameFile { project_id: s(&project_id), relative_path, new_name, }, - ActionRequest::DeleteFile { project_id, relative_path } => ActionRequest::DeleteFile { + ActionRequest::DeleteFile { + project_id, + relative_path, + } => ActionRequest::DeleteFile { project_id: s(&project_id), relative_path, }, - ActionRequest::CreateFile { project_id, relative_path } => ActionRequest::CreateFile { + ActionRequest::CreateFile { + project_id, + relative_path, + } => ActionRequest::CreateFile { project_id: s(&project_id), relative_path, }, - ActionRequest::CreateDirectory { project_id, relative_path } => ActionRequest::CreateDirectory { + ActionRequest::CreateDirectory { + project_id, + relative_path, + } => ActionRequest::CreateDirectory { project_id: s(&project_id), relative_path, }, @@ -796,36 +974,116 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest project_id: s(&project_id), name, }, - ActionRequest::RenameProjectDirectory { project_id, new_name } => ActionRequest::RenameProjectDirectory { + 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, }, ActionRequest::DeleteProject { project_id } => ActionRequest::DeleteProject { project_id: s(&project_id), }, - ActionRequest::SetProjectShowInOverview { project_id, show, window } => ActionRequest::SetProjectShowInOverview { + ActionRequest::SetProjectShowInOverview { + project_id, + show, + window, + } => ActionRequest::SetProjectShowInOverview { project_id: s(&project_id), show, window, }, - ActionRequest::RemoveWorktreeProject { project_id, force } => ActionRequest::RemoveWorktreeProject { + ActionRequest::RemoveWorktreeProject { project_id, force } => { + ActionRequest::RemoveWorktreeProject { + project_id: s(&project_id), + force, + } + } + ActionRequest::CloseWorktree { + project_id, + merge, + stash, + fetch, + push, + delete_branch, + } => ActionRequest::CloseWorktree { project_id: s(&project_id), - force, + 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::MoveProjectToFolder { project_id, folder_id, position } => ActionRequest::MoveProjectToFolder { - project_id: s(&project_id), + 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: s(&folder_id), + position, }, - ActionRequest::MoveProjectOutOfFolder { project_id, top_level_index } => ActionRequest::MoveProjectOutOfFolder { + 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::ListSessions + | ActionRequest::LoadSession { .. } + | ActionRequest::SaveSession { .. } + | ActionRequest::RenameSession { .. } + | ActionRequest::DeleteSession { .. } + | ActionRequest::ImportWorkspace { .. } + | ActionRequest::ExportWorkspace { .. } + | ActionRequest::GetSettings | ActionRequest::GetSettingsSchema | ActionRequest::SetSettings { .. } | ActionRequest::GetThemes @@ -836,3 +1094,60 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest | ActionRequest::InvokeAction { .. }) => a, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::state::SplitDirection; + + #[test] + fn rows_map_visual_split_axis_back_to_canonical_axis() { + let action = canonicalize_layout_action( + ActionRequest::SplitTerminal { + project_id: "project".to_string(), + path: vec![0], + direction: SplitDirection::Horizontal, + }, + ProjectLayoutMode::Rows, + ); + + assert!(matches!( + action, + ActionRequest::SplitTerminal { + direction: SplitDirection::Vertical, + .. + } + )); + } + + #[test] + fn rows_map_visual_drop_zone_back_to_canonical_edge() { + let action = canonicalize_layout_action( + ActionRequest::MovePaneTo { + project_id: "source".to_string(), + terminal_id: "one".to_string(), + target_project_id: "target".to_string(), + target_terminal_id: "two".to_string(), + zone: "left".to_string(), + }, + ProjectLayoutMode::Rows, + ); + + assert!(matches!( + action, + ActionRequest::MovePaneTo { zone, .. } if zone == "top" + )); + } + + #[test] + fn discovered_worktree_visibility_name_matches_daemon_project_name() { + assert_eq!( + discovered_worktree_project_name("/repo/worktrees/payments", "feature/payments"), + "payments (feature/payments)" + ); + assert_eq!( + discovered_worktree_project_name("/", "feature/fallback"), + "worktree (feature/fallback)" + ); + } +} diff --git a/crates/okena-app/src/app/CLAUDE.md b/crates/okena-app/src/app/CLAUDE.md index ba0f6cb79..3b00c7c34 100644 --- a/crates/okena-app/src/app/CLAUDE.md +++ b/crates/okena-app/src/app/CLAUDE.md @@ -1,18 +1,18 @@ # app/ — Main Application Entity -The `Okena` entity is the central coordinator that owns the top-level GPUI entities (WindowView, Workspace, RequestBroker, PtyManager) and routes events between them. +The `Okena` entity coordinates desktop-only GPUI entities and synchronizes them with the daemon-backed workspace. ## Files | File | Purpose | |------|---------| -| `mod.rs` | `Okena` struct — owns all top-level entities. Runs the PTY event loop (batched `async_channel` processing). Sets up workspace auto-save observer. | +| `mod.rs` | `Okena` struct — owns top-level desktop entities and daemon connection state. | | `detached_terminals.rs` | Opens separate OS windows for detached terminals. | -| `headless.rs` | Headless mode (no GUI). | -| `remote_commands.rs` | Bridge from remote server to GPUI thread — handles `RemoteCommand` variants by dispatching into Workspace/PtyManager. | +| `detached_overlays.rs` | Opens detached overlay windows. | +| `extras.rs` | Auxiliary `Okena` methods for desktop actions. | +| `notifications.rs` | Desktop notification integration. | ## Key Patterns -- **Batched PTY processing**: The PTY event loop reads all available events from the channel before notifying, to avoid per-byte UI updates. -- **`data_version` skip-save**: Workspace observer compares `data_version` to avoid saving when only transient state changed. -- **Remote bridge**: Remote commands arrive via `async_channel`, execute on the GPUI thread, and reply via `oneshot` channel. +- **Daemon authority**: Persistent workspace, settings, PTYs, services, and git state are daemon-owned. Do not add local desktop write paths for them. +- **Client presentation**: Window placement and other presentation-only state stay in the desktop process and `window-layout.json`. diff --git a/crates/okena-app/src/app/detached_overlays.rs b/crates/okena-app/src/app/detached_overlays.rs index 550aba85a..07b4d0e49 100644 --- a/crates/okena-app/src/app/detached_overlays.rs +++ b/crates/okena-app/src/app/detached_overlays.rs @@ -7,10 +7,10 @@ use crate::views::overlays::detached_overlay::DetachedOverlayView; use gpui::*; use okena_ui::overlay::CloseEvent; -#[cfg(not(target_os = "linux"))] -use gpui_component::Root; #[cfg(target_os = "linux")] use crate::simple_root::SimpleRoot as Root; +#[cfg(not(target_os = "linux"))] +use gpui_component::Root; /// Open the given overlay entity in a fresh OS window with a thin chrome bar. /// @@ -20,11 +20,8 @@ use crate::simple_root::SimpleRoot as Root; /// /// Window bounds (position + size) are restored from the last detached overlay /// the user opened, so the window doesn't reset to a tiny default every time. -pub fn open_detached_overlay( - title: impl Into, - content: Entity, - cx: &mut App, -) where +pub fn open_detached_overlay(title: impl Into, content: Entity, cx: &mut App) +where T: Render + Focusable + EventEmitter + 'static, E: CloseEvent + 'static, { @@ -80,9 +77,8 @@ pub fn open_detached_overlay( ..Default::default() }, move |window, cx| { - let view = cx.new(|cx| { - DetachedOverlayView::new(content.clone(), title.clone(), window, cx) - }); + let view = + cx.new(|cx| DetachedOverlayView::new(content.clone(), title.clone(), window, cx)); cx.new(|cx| Root::new(view, window, cx)) }, ) diff --git a/crates/okena-app/src/app/detached_terminals.rs b/crates/okena-app/src/app/detached_terminals.rs index 0025a908a..46740c28a 100644 --- a/crates/okena-app/src/app/detached_terminals.rs +++ b/crates/okena-app/src/app/detached_terminals.rs @@ -1,11 +1,13 @@ +#[cfg(target_os = "linux")] +use crate::simple_root::SimpleRoot as Root; +use crate::terminal::terminal::TerminalTransport; use crate::views::overlays::detached_terminal::DetachedTerminalView; use crate::workspace::state::Workspace; use gpui::*; #[cfg(not(target_os = "linux"))] 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,65 @@ 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 current_ids: HashSet = current + .iter() + .map(|(terminal_id, _)| terminal_id.clone()) .collect(); - let new_ids: Vec<_> = current_detached + 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..a3ebcb704 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,27 +15,72 @@ //! `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}; + +#[cfg(target_os = "linux")] +use crate::simple_root::SimpleRoot as Root; use crate::views::window::{WindowView, WindowViewEvent}; +use crate::workspace::state::{WindowBounds as PersistedWindowBounds, WindowId, WorkspaceData}; use gpui::*; #[cfg(not(target_os = "linux"))] use gpui_component::Root; -#[cfg(target_os = "linux")] -use crate::simple_root::SimpleRoot as Root; use std::collections::HashSet; +use std::sync::atomic::Ordering; +use std::time::Duration; use super::Okena; +/// Grace before an OS-level extra-window close is committed as a "forget" +/// (removed from the persisted layout). Quit flows deliver a close event to +/// every window (GNOME dock Quit, logout, Alt+F4-ing each window to exit); +/// committing the forget immediately made those quits wipe the multi-window +/// layout before the final quit-time save. Deliberate single-window closes +/// survive the delay: the app keeps running and the forget lands after it. +const EXTRA_FORGET_GRACE: Duration = Duration::from_secs(5); + +/// Deferred forgets for OS-closed extra windows. Every OS close re-arms the +/// whole pending set onto a fresh generation: a burst of closes is what +/// "quitting by closing every window" looks like, so earlier timers go stale +/// and only the last close's timer commits — after ITS full grace. Pure +/// bookkeeping (no GPUI) so the re-arm semantics are unit-testable. +#[derive(Default)] +pub(super) struct PendingExtraForgets { + pending: HashSet, + generation: u64, +} + +impl PendingExtraForgets { + /// Record an OS close for `id`, re-arming every pending forget. Returns + /// the generation the caller's grace timer must present to + /// [`take_due`](Self::take_due). + pub(super) fn note_close(&mut self, id: WindowId) -> u64 { + self.generation += 1; + self.pending.insert(id); + self.generation + } + + /// Drain every pending forget if `generation` is still the latest; a + /// stale generation (some later close re-armed the set) drains nothing — + /// the later close's timer owns the set now. Sorted for deterministic + /// commit order. + pub(super) fn take_due(&mut self, generation: u64) -> Vec { + if generation != self.generation { + return Vec::new(); + } + let mut due: Vec = self.pending.drain().collect(); + due.sort_by_key(window_sort_key); + due + } +} + +/// Stable sort key for window ids (uuid bytes; Main first). Ordering matters +/// when two pending closes touch shared state — see `extras_to_close`. +fn window_sort_key(id: &WindowId) -> [u8; 16] { + match id { + WindowId::Extra(uuid) => *uuid.as_bytes(), + WindowId::Main => [0u8; 16], + } +} + /// Compute which `WindowId::Extra` entries in `data.extra_windows` are NOT yet /// present in `opened`. Returned in `extra_windows` Vec order so the caller /// spawns OS windows in persistence order. @@ -44,10 +88,7 @@ use super::Okena; /// Pure function — separated from the observer body so the diff contract can /// be exercised without standing up the full `Okena` entity (whose /// construction pulls in PtyManager, settings, theme, remote, services, etc.). -pub(super) fn extras_to_open( - data: &WorkspaceData, - opened: &HashSet, -) -> Vec { +pub(super) fn extras_to_open(data: &WorkspaceData, opened: &HashSet) -> Vec { data.extra_windows .iter() .map(|w| WindowId::Extra(w.id)) @@ -62,10 +103,7 @@ pub(super) fn extras_to_open( /// even though `opened` is a `HashSet`. Iteration order matters when two /// pending closes touch shared state (e.g. the same terminal rendered in both /// windows); a flaky order would surface as intermittent test failures. -pub(super) fn extras_to_close( - data: &WorkspaceData, - opened: &HashSet, -) -> Vec { +pub(super) fn extras_to_close(data: &WorkspaceData, opened: &HashSet) -> Vec { let desired: HashSet = data .extra_windows .iter() @@ -77,12 +115,7 @@ pub(super) fn extras_to_close( .copied() .filter(|id| matches!(id, WindowId::Extra(_)) && !desired.contains(id)) .collect(); - result.sort_by_key(|id| match id { - WindowId::Extra(uuid) => *uuid.as_bytes(), - // Filter above guarantees Extra(_) only; the arm is unreachable in - // practice but keeps the match total without an unwrap. - WindowId::Main => [0u8; 16], - }); + result.sort_by_key(window_sort_key); result } @@ -113,37 +146,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 — @@ -161,196 +163,58 @@ impl Okena { self.extra_windows.remove(&window_id); } - for window_id in extras_to_open(&data, &opened) { + let to_open = extras_to_open(&data, &opened); + if !to_open.is_empty() { + log::info!("Opening {} extra window(s)", to_open.len()); + } + for window_id in to_open { self.open_extra_window(window_id, cx); } } - /// 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()), - }, + /// React to an OS-level close of an extra window: arm a deferred forget + /// instead of committing it now. If the app is quitting, do nothing at all + /// — the final quit-time save must keep the window so the next launch + /// restores it. While the forget is pending the window stays in + /// `WorkspaceData.extra_windows` AND in the Okena-side maps: dead handles + /// are tolerated by every consumer, and keeping `extra_windows` populated + /// stops `handle_extra_windows_changed` from reopening the entry. + pub(super) fn handle_extra_window_os_close( + &mut self, + window_id: WindowId, + cx: &mut Context, + ) { + if self.quitting.load(Ordering::SeqCst) { + return; } + let generation = self.pending_extra_forgets.note_close(window_id); + cx.spawn(async move |this, cx| { + smol::Timer::after(EXTRA_FORGET_GRACE).await; + let _ = this.update(cx, |this, cx| { + this.apply_due_extra_forgets(generation, cx); + }); + }) + .detach(); } - /// 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())), + /// Commit the deferred forgets armed on `generation`. Bails when the app + /// is quitting (quit-time closes must survive into the final save) or when + /// a later close re-armed the entries (that close's timer owns them). The + /// workspace mutation fires the extras observer, which sweeps the + /// forgotten ids out of `extra_windows` / `extra_window_handles`. + fn apply_due_extra_forgets(&mut self, generation: u64, cx: &mut Context) { + if self.quitting.load(Ordering::SeqCst) { + return; } - } - - /// 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(), + let due = self.pending_extra_forgets.take_due(generation); + if due.is_empty() { + return; } - } - - /// 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() + self.workspace.update(cx, |ws, cx| { + for window_id in due { + ws.close_extra_window(window_id, cx); + } + }); } /// Route a [`WindowViewEvent`] raised by any window to the right handler. @@ -364,6 +228,8 @@ impl Okena { WindowViewEvent::JumpToProject { origin, project_id } => { self.jump_to_project_terminal(*origin, project_id, cx); } + WindowViewEvent::RebuildLocal => self.rebuild_local(cx), + WindowViewEvent::RestartLocalBuild => self.restart_local_build(cx), } } @@ -406,7 +272,10 @@ impl Okena { // Resolve the target window's view + OS handle. let (view, handle) = match target { WindowId::Main => (self.main_window.clone(), self.main_window_handle), - id => match (self.extra_windows.get(&id), self.extra_window_handles.get(&id)) { + id => match ( + self.extra_windows.get(&id), + self.extra_window_handles.get(&id), + ) { (Some(v), Some(h)) => (v.clone(), *h), _ => return, }, @@ -458,7 +327,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(); @@ -489,15 +357,15 @@ impl Okena { width: f32::from(b.size.width), height: f32::from(b.size.height), }); - let window_bounds = resolve_extra_window_bounds(persisted, main_bounds).map( - |b: PersistedWindowBounds| { + let window_bounds = + resolve_extra_window_bounds(persisted, main_bounds).map(|b: PersistedWindowBounds| { WindowBounds::Windowed(Bounds { origin: point(px(b.origin_x), px(b.origin_y)), size: size(px(b.width), px(b.height)), }) - }, - ); + }); + let mut opened_view = None; let result = cx.open_window( WindowOptions { titlebar: if cfg!(target_os = "windows") { @@ -523,71 +391,33 @@ impl Okena { app_id: Some("okena".to_string()), ..Default::default() }, - move |window, cx| { + |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(); - // Defer the registration: this build closure runs while Okena - // is already leased (the workspace observer that triggered - // `open_extra_window` holds the Okena update lease for the - // duration of `flush_effects`). Calling `okena.update` here - // synchronously would double-lease and panic. `cx.defer` - // schedules the registration to run after the current effect - // flush completes, when the lease has released. - let okena_for_register = okena.clone(); - cx.defer(move |cx| { - okena_for_register.update(cx, |this, cx| { - this.extra_windows.insert(window_id, view_for_okena.clone()); - // Track the OS window handle so the remote-bridge - // command loop can resolve actions to whichever window - // is focused (PRD cri 13). The handle is removed on - // close below. - this.extra_window_handles.insert(window_id, handle); - - // Same cross-window event channel main is wired with, so - // 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 - // 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(); - 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); - }); - }); + opened_view = Some(view.clone()); + + window.on_next_frame(move |_window, _cx| { + log::info!("Extra window {window_id:?} received its first platform frame"); }); - // Slice 07 cri 3 close-flow: when the user closes this OS - // window, drop the entry from `WorkspaceData.extra_windows` - // (so persistence forgets it -- PRD user story 22) and from - // `Okena.extra_windows` + `extra_window_handles` (so the - // remote-bridge resolver and the spawn-side observer stop - // seeing it). Order matters: the workspace mutation runs - // FIRST so save/observers fire on a still-alive - // `Entity` (the strong handle in - // `Okena.extra_windows` keeps it alive until the second - // step). The Okena-side removes drop the strong handle so - // the entity then drops with the OS window. Returning - // `true` allows the OS close to proceed. - let workspace_for_close = workspace.clone(); + // Slice 07 cri 3 close-flow, quit-aware: an OS-level close + // (X button, Alt+F4, compositor quit-all) does NOT commit + // the "forget" (PRD user story 22) immediately — quit flows + // deliver a close to every window and an eager forget wiped + // the multi-window layout right before the quit-time save + // (the recurring restore-only-one-window bug). Instead the + // forget is deferred via `handle_extra_window_os_close`; + // the Okena-side maps stay populated until it commits so + // the extras observer can't mistake the still-persisted + // entry for a fresh one and reopen a zombie window. The + // explicit in-app CloseWindow action keeps its immediate + // forget (see views/window/render.rs). Returning `true` + // allows the OS close to proceed. let okena_for_close = okena.clone(); window.on_window_should_close(cx, move |_window, cx| { - workspace_for_close.update(cx, |ws, cx| { - ws.close_extra_window(window_id, cx); - }); - okena_for_close.update(cx, |this, _cx| { - this.extra_windows.remove(&window_id); - this.extra_window_handles.remove(&window_id); + okena_for_close.update(cx, |this, cx| { + this.handle_extra_window_os_close(window_id, cx); }); true }); @@ -596,16 +426,44 @@ impl Okena { }, ); - if let Err(e) = result { - log::error!("Failed to open extra window: {e}"); + // open_window is synchronous, so register before observers can run again. + match result { + Ok(handle) => { + let Some(view) = opened_view else { + log::error!("Extra window {window_id:?} opened without a WindowView"); + let _ = handle.update(cx, |_, window, _| window.remove_window()); + return; + }; + let handle: AnyWindowHandle = handle.into(); + self.extra_windows.insert(window_id, view.clone()); + self.extra_window_handles.insert(window_id, handle); + cx.subscribe(&view, Okena::handle_window_view_event) + .detach(); + + let remote_manager = self.remote_manager.clone(); + view.update(cx, |view, cx| { + view.set_remote_manager(remote_manager, cx); + }); + log::info!( + "Registered extra window {window_id:?} as GPUI window {}", + handle.window_id().as_u64() + ); + } + Err(error) => { + log::error!("Failed to open extra window {window_id:?}: {error}"); + } } } } #[cfg(test)] mod tests { - use super::{extras_to_close, extras_to_open, resolve_extra_window_bounds, resolve_focused_window_id}; - use crate::workspace::state::{WindowBounds as PersistedWindowBounds, WindowId, WindowState, WorkspaceData}; + use super::{ + PendingExtraForgets, 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; @@ -622,11 +480,15 @@ mod tests { } } + fn extras_not_opened(data: &WorkspaceData, opened: &HashSet) -> Vec { + extras_to_open(data, opened) + } + #[test] fn empty_extras_returns_empty() { let data = empty_workspace(); let opened = HashSet::new(); - assert!(extras_to_open(&data, &opened).is_empty()); + assert!(extras_not_opened(&data, &opened).is_empty()); } #[test] @@ -635,7 +497,7 @@ mod tests { let id1 = data.spawn_extra_window(None); let id2 = data.spawn_extra_window(None); let opened = HashSet::new(); - let pending = extras_to_open(&data, &opened); + let pending = extras_not_opened(&data, &opened); assert_eq!(pending, vec![id1, id2]); } @@ -646,7 +508,7 @@ mod tests { let id2 = data.spawn_extra_window(None); let mut opened = HashSet::new(); opened.insert(id1); - let pending = extras_to_open(&data, &opened); + let pending = extras_not_opened(&data, &opened); assert_eq!(pending, vec![id2]); } @@ -658,7 +520,7 @@ mod tests { let mut opened = HashSet::new(); opened.insert(id1); opened.insert(id2); - assert!(extras_to_open(&data, &opened).is_empty()); + assert!(extras_not_opened(&data, &opened).is_empty()); } #[test] @@ -698,7 +560,7 @@ mod tests { // if the caller mistakenly passes an empty `opened` set. let data = empty_workspace(); let opened = HashSet::new(); - let pending = extras_to_open(&data, &opened); + let pending = extras_not_opened(&data, &opened); assert!(!pending.contains(&WindowId::Main)); } @@ -710,7 +572,7 @@ mod tests { let mut data = empty_workspace(); let ids: Vec = (0..5).map(|_| data.spawn_extra_window(None)).collect(); let opened = HashSet::new(); - assert_eq!(extras_to_open(&data, &opened), ids); + assert_eq!(extras_not_opened(&data, &opened), ids); } #[test] @@ -740,7 +602,7 @@ mod tests { ); let opened = HashSet::new(); assert!( - extras_to_open(&data, &opened).is_empty(), + extras_not_opened(&data, &opened).is_empty(), "restore-at-launch kickoff must find no extras to open on the default workspace" ); } @@ -812,70 +674,72 @@ mod tests { // direction fails this test. let mut data = empty_workspace(); let ids: Vec = (0..25).map(|_| data.spawn_extra_window(None)).collect(); - assert_eq!(data.extra_windows.len(), 25, "data layer must accept every spawn"); + assert_eq!( + data.extra_windows.len(), + 25, + "data layer must accept every spawn" + ); let opened = HashSet::new(); - assert_eq!(extras_to_open(&data, &opened), ids, "helper must surface every pending entry"); + assert_eq!( + extras_not_opened(&data, &opened), + ids, + "helper must surface every pending entry" + ); } - // ── Focused-window routing ─────────────────────────────────────────── + // ── Deferred OS-close forgets ──────────────────────────────────────── #[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); + fn single_close_commits_on_its_own_generation() { + // The plain case: one extra is OS-closed, nothing else happens, its + // grace timer fires with the generation note_close returned — the + // forget commits. + let mut forgets = PendingExtraForgets::default(); + let id = WindowId::Extra(Uuid::new_v4()); + let generation = forgets.note_close(id); + assert_eq!(forgets.take_due(generation), vec![id]); + // Drained: a duplicate timer fire (or a retry) finds nothing. + assert!(forgets.take_due(generation).is_empty()); } #[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)]; + fn later_close_rearms_earlier_pending_forgets() { + // Quit-by-closing-windows: extra A closes, then extra B closes before + // A's grace expires. A's timer must find nothing (its generation went + // stale) and B's timer must commit BOTH — so a close burst always gets + // the full grace of its last close, during which the quit signal can + // arrive and cancel everything. + let mut forgets = PendingExtraForgets::default(); + let a = WindowId::Extra(Uuid::new_v4()); + let b = WindowId::Extra(Uuid::new_v4()); + let gen_a = forgets.note_close(a); + let gen_b = forgets.note_close(b); + + assert!( + forgets.take_due(gen_a).is_empty(), + "stale timer must not commit" + ); + let due: HashSet = forgets.take_due(gen_b).into_iter().collect(); assert_eq!( - resolve_focused_window_id(Some(main_handle), &handles), - WindowId::Main, + due, + HashSet::from([a, b]), + "last close owns every pending forget" ); } #[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); + fn commit_order_is_deterministic_by_uuid() { + // Mirrors extras_to_close's contract: shared-state teardown order must + // not depend on HashMap iteration order. + let mut forgets = PendingExtraForgets::default(); + let ids: Vec = (0..5).map(|_| WindowId::Extra(Uuid::new_v4())).collect(); + let mut generation = 0; + for id in &ids { + generation = forgets.note_close(*id); + } + let mut expected = ids.clone(); + expected.sort_by_key(super::window_sort_key); + assert_eq!(forgets.take_due(generation), expected); } // ── Restore-bounds resolver ────────────────────────────────────────── @@ -951,16 +815,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 deleted file mode 100644 index 82d77988a..000000000 --- a/crates/okena-app/src/app/headless.rs +++ /dev/null @@ -1,412 +0,0 @@ -use crate::git::watcher::GitStatusWatcher; -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 super::observe_project_services; -use crate::services::manager::ServiceManager; -use crate::terminal::backend::TerminalBackend; -use crate::terminal::pty_manager::{PtyEvent, PtyManager}; -use crate::views::window::TerminalsRegistry; -use crate::workspace::persistence; -use crate::workspace::state::{GlobalWorkspace, WindowId, Workspace, WorkspaceData}; -use async_channel::Receiver; -use gpui::*; -use okena_core::api::ApiGitStatus; -use parking_lot::Mutex; -use std::collections::{HashMap, HashSet}; -use std::net::IpAddr; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use tokio::sync::watch as tokio_watch; - -use crate::terminal::backend::LocalBackend; - -use super::remote_commands::{ - remote_command_loop, ActionDispatcher, FocusManagerResolver, WindowsResolver, -}; -use okena_core::api::ApiWindow; - -/// Headless application entity — runs workspace, PTY management, and remote -/// server without any GUI windows. Used when running over SSH or on machines -/// without a display server. -pub struct HeadlessApp { - #[allow(dead_code)] - workspace: Entity, - #[allow(dead_code)] - pty_manager: Arc, - terminals: TerminalsRegistry, - #[allow(dead_code)] - remote_server: Option, - auth_store: Arc, - pty_broadcaster: Arc, - state_version: Arc>, - git_status_tx: Arc>>, - remote_subscribed_terminals: Arc>>>, - next_remote_connection_id: Arc, - #[allow(dead_code)] - git_watcher: Entity, - #[allow(dead_code)] - save_pending: Arc, - #[allow(dead_code)] - service_manager: Entity, -} - -impl HeadlessApp { - pub fn new( - workspace_data: WorkspaceData, - pty_manager: Arc, - pty_events: Receiver, - listen_addr: IpAddr, - tls_enabled: bool, - cx: &mut Context, - ) -> Self { - // Create workspace entity - 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)); - 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| { - let current_version = _workspace.read(cx).data_version(); - if current_version == last_saved_version_for_observer.load(Ordering::Relaxed) { - return; - } - - 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()) - }); - 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); - } - } - } - }) - .detach(); - }) - .detach(); - - // Shared terminals registry - let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); - - // Remote control setup - let auth_store = Arc::new(AuthStore::new()); - let pty_broadcaster = Arc::new(PtyBroadcaster::new()); - 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(); - - // 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) - }); - - // Create service manager for project-scoped background processes - let local_backend_for_services: Arc = - Arc::new(LocalBackend::new(pty_manager.clone())); - let service_manager = cx.new(|_cx| { - ServiceManager::new(local_backend_for_services, terminals.clone()) - }); - - // Bump state_version on service manager changes - let sv = state_version.clone(); - cx.observe(&service_manager, move |_this, _sm, _cx| { - sv.send_modify(|v| *v += 1); - }) - .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); - 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(); - } - - // Create bridge channel - let (bridge_tx, bridge_rx) = bridge::bridge_channel(); - - let mut app = Self { - workspace: workspace.clone(), - pty_manager: pty_manager.clone(), - terminals: terminals.clone(), - remote_server: None, - auth_store: auth_store.clone(), - pty_broadcaster: pty_broadcaster.clone(), - state_version: state_version.clone(), - git_status_tx: git_status_tx.clone(), - remote_subscribed_terminals: remote_subscribed_terminals.clone(), - next_remote_connection_id: next_remote_connection_id.clone(), - git_watcher, - save_pending, - service_manager: service_manager.clone(), - }; - - // Start PTY event loop - app.start_pty_event_loop(pty_events, cx); - - // Start remote command bridge loop (shared with GUI) - let local_backend: Arc = - Arc::new(LocalBackend::new(pty_manager)); - // 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 - // is effectively dormant. The bridge loop's resolver is constant in - // headless: there's no focused window to consult, so it always - // returns the same dormant FocusManager paired with WindowId::Main - // (per-window data mutations land on the always-present main slot). - let focus_manager = cx.new(|_| crate::workspace::focus::FocusManager::new()); - let focus_manager_for_resolver = focus_manager.clone(); - // Headless has only the dormant main FocusManager: `None` (focused - // default) and `Some(Main)` both resolve to it; `Some(Extra(_))` has - // no window to land on, so it reports "not found". - let focus_manager_resolver: FocusManagerResolver = - Arc::new(move |_cx: &gpui::App, target: Option| match target { - None | Some(WindowId::Main) => { - Some((WindowId::Main, focus_manager_for_resolver.clone())) - } - Some(WindowId::Extra(_)) => None, - }); - // Headless exposes a single synthetic main window. There is 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 workspace_for_windows = workspace.clone(); - let windows_resolver: WindowsResolver = Arc::new(move |cx: &gpui::App| { - let ws = workspace_for_windows.read(cx); - let visible_project_ids: Vec = ws - .visible_projects(WindowId::Main, None, false) - .iter() - .map(|p| p.id.clone()) - .collect(); - vec![ApiWindow { - id: "main".to_string(), - kind: "main".to_string(), - active: true, - focused_project_id: None, - focused_terminal_id: None, - fullscreen: None, - visible_project_ids, - folder_filter: None, - bounds: None, - sidebar_open: None, - }] - }); - // No GUI windows in headless mode → the command palette can't dispatch. - let action_dispatcher: ActionDispatcher = Arc::new(|_cx, _target, _name| { - Err("command palette unavailable in headless mode".to_string()) - }); - 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(); - 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, - ).await; - } - }) - .detach(); - - // Start remote server - app.start_remote_server(bridge_tx, listen_addr, tls_enabled, &remote_info); - - app - } - - /// Start the remote HTTP/WS server. - fn start_remote_server( - &mut self, - bridge_tx: bridge::BridgeSender, - listen_addr: IpAddr, - tls_enabled: bool, - remote_info: &RemoteInfo, - ) { - match RemoteServer::start( - bridge_tx, - self.auth_store.clone(), - self.pty_broadcaster.clone(), - self.state_version.clone(), - listen_addr, - self.git_status_tx.clone(), - self.remote_subscribed_terminals.clone(), - self.next_remote_connection_id.clone(), - tls_enabled, - ) { - Ok(server) => { - let port = server.port(); - let fingerprint = server.cert_fingerprint(); - remote_info.set_active(port, self.auth_store.clone(), fingerprint.clone()); - 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)"); - if let Some(fp) = &fingerprint { - println!( - "TLS cert fingerprint (SHA-256): {}", - okena_transport::client::tls::format_fingerprint(fp) - ); - } - println!("Run `okena pair` anytime for a fresh code."); - - self.remote_server = Some(server); - } - Err(e) => { - log::error!("Failed to start remote server: {}", e); - eprintln!("Failed to start remote server: {e}"); - std::process::exit(1); - } - } - } - - /// PTY event loop — processes terminal data and broadcasts to web clients. - /// Handles service exit events via ServiceManager, matching the GUI version. - 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(); - let service_manager = self.service_manager.clone(); - let state_version = self.state_version.clone(); - - cx.spawn(async move |_this: WeakEntity, cx| { - loop { - let event = match pty_events.recv().await { - Ok(event) => event, - Err(_) => break, - }; - - // Collect exit events for service manager processing - let mut exit_events: Vec<(String, Option)> = Vec::new(); - - // Process first event (broadcasting handled by PtyOutputSink in reader threads) - match &event { - PtyEvent::Data { terminal_id, data } => { - let terminals_guard = terminals.lock(); - if let Some(terminal) = terminals_guard.get(terminal_id) { - terminal.process_output(data); - } - } - PtyEvent::Exit { terminal_id, exit_code } => { - pty_manager.cleanup_exited(terminal_id); - exit_events.push((terminal_id.clone(), *exit_code)); - } - } - - // Drain any additional pending events (batch processing) - while let Ok(event) = pty_events.try_recv() { - match &event { - PtyEvent::Data { terminal_id, data } => { - let terminals_guard = terminals.lock(); - if let Some(terminal) = terminals_guard.get(terminal_id) { - terminal.process_output(data); - } - } - PtyEvent::Exit { terminal_id, exit_code } => { - pty_manager.cleanup_exited(terminal_id); - exit_events.push((terminal_id.clone(), *exit_code)); - } - } - } - - if !exit_events.is_empty() { - cx.update(|cx| { - // Let service manager handle service terminals - let service_tids: HashSet = - service_manager.update(cx, |sm, cx| { - let mut handled = 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 - }); - - // Remove UI Terminals for non-service terminals - { - let mut reg = terminals.lock(); - for (terminal_id, _) in &exit_events { - if !service_tids.contains(terminal_id) { - reg.remove(terminal_id); - } - } - } - - state_version.send_modify(|v| *v += 1); - }); - } - } - }) - .detach(); - } -} diff --git a/crates/okena-app/src/app/local_build.rs b/crates/okena-app/src/app/local_build.rs new file mode 100644 index 000000000..5c464309d --- /dev/null +++ b/crates/okena-app/src/app/local_build.rs @@ -0,0 +1,199 @@ +use gpui::*; +use std::ffi::OsString; +use std::process::Command; + +use super::Okena; + +impl Okena { + pub(super) fn rebuild_local(&mut self, cx: &mut Context) { + let Some(state) = cx + .try_global::() + .map(|global| global.0.clone()) + else { + return; + }; + let Some(checkout) = state.update(cx, |state, cx| state.try_start_build(cx)) else { + return; + }; + + let root = checkout.root().to_path_buf(); + cx.spawn(async move |this, cx| { + let build_result = cx + .background_executor() + .spawn(async move { run_release_build(&root) }) + .await; + let _ = this.update(cx, |_this, cx| match build_result { + Ok(()) => state.update(cx, |state, cx| { + state.set_status(okena_ext_updater::LocalBuildStatus::ReadyToRestart, cx); + }), + Err(error) => { + state.update(cx, |state, cx| { + state.set_status( + okena_ext_updater::LocalBuildStatus::Failed { + error: error.clone(), + }, + cx, + ); + }); + crate::workspace::toast::ToastManager::error( + format!("Okena rebuild failed: {error}"), + cx, + ); + } + }); + }) + .detach(); + } + + pub(super) fn restart_local_build(&mut self, cx: &mut Context) { + let Some(state) = cx + .try_global::() + .map(|global| global.0.clone()) + else { + return; + }; + let Some(checkout) = state.update(cx, |state, cx| state.try_start_restart(cx)) else { + return; + }; + + let Some(daemon) = okena_remote_server::local::running_daemon() else { + state.update(cx, |state, cx| { + state.set_status( + okena_ext_updater::LocalBuildStatus::Failed { + error: "local daemon is unavailable".to_string(), + }, + cx, + ); + }); + return; + }; + if !daemon.ui_owned { + state.update(cx, |state, cx| { + state.set_daemon_ui_owned(false, cx); + state.set_status(okena_ext_updater::LocalBuildStatus::ReadyToRestart, cx); + }); + crate::workspace::toast::ToastManager::warning( + "The local daemon is externally managed; restart it manually", + cx, + ); + return; + } + + let release_executable = checkout.release_executable().to_path_buf(); + let daemon_host = daemon.host().to_string(); + let daemon_port = daemon.port; + let daemon_endpoint = daemon.local_endpoint.clone(); + let app_args: Vec = std::env::args_os().skip(1).collect(); + + cx.spawn(async move |this, cx| { + let restart_result = cx + .background_executor() + .spawn(async move { + okena_remote_server::local::restart_local_daemon( + &daemon_host, + daemon_port, + daemon_endpoint.as_ref(), + ) + }) + .await; + if let Err(error) = restart_result { + let _ = this.update(cx, |_this, cx| { + state.update(cx, |state, cx| { + state.set_status( + okena_ext_updater::LocalBuildStatus::Failed { + error: error.clone(), + }, + cx, + ); + }); + crate::workspace::toast::ToastManager::error( + format!("Daemon restart failed: {error}"), + cx, + ); + }); + return; + } + + state.update(cx, |state, cx| { + state.set_status(okena_ext_updater::LocalBuildStatus::RestartingApp, cx); + }); + let _ = this.update(cx, |this, cx| { + match Command::new(&release_executable) + .args(&app_args) + .env("OKENA_ACTIVATE", "1") + .spawn() + { + Ok(_) => { + this.preserve_daemon_on_quit = true; + this.quitting + .store(true, std::sync::atomic::Ordering::SeqCst); + log::info!("Restarting Okena from {}", release_executable.display()); + cx.quit(); + } + Err(error) => { + let message = format!("failed to launch rebuilt Okena: {error}"); + state.update(cx, |state, cx| { + state.set_status( + okena_ext_updater::LocalBuildStatus::Failed { + error: message.clone(), + }, + cx, + ); + }); + crate::workspace::toast::ToastManager::error(message, cx); + } + } + }); + }) + .detach(); + } +} + +fn run_release_build(root: &std::path::Path) -> Result<(), String> { + let output = Command::new("cargo") + .args(["build", "--release"]) + .current_dir(root) + .output() + .map_err(|error| format!("failed to start cargo: {error}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + log::info!("cargo build --release stdout:\n{stdout}"); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + if output.status.success() { + log::info!("cargo build --release stderr:\n{stderr}"); + } else { + log::error!("cargo build --release stderr:\n{stderr}"); + } + } + + if output.status.success() { + Ok(()) + } else { + Err(last_output_line(&stderr) + .unwrap_or_else(|| format!("cargo exited with {}", output.status))) + } +} + +fn last_output_line(output: &str) -> Option { + output + .lines() + .rev() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| line.chars().take(160).collect()) +} + +#[cfg(test)] +mod tests { + use super::last_output_line; + + #[test] + fn build_error_uses_last_non_empty_line_and_stays_short() { + let long = "x".repeat(200); + let output = format!("first\n\n{long}\n"); + assert_eq!(last_output_line(&output).unwrap().len(), 160); + } +} diff --git a/crates/okena-app/src/app/mod.rs b/crates/okena-app/src/app/mod.rs index 4c0bbaf1a..9945a9df5 100644 --- a/crates/okena-app/src/app/mod.rs +++ b/crates/okena-app/src/app/mod.rs @@ -1,155 +1,170 @@ mod detached_overlays; mod detached_terminals; mod extras; -pub mod headless; +mod local_build; mod notifications; -mod remote_commands; -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::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::remote_client::manager::{RemoteConnectionManager, RemoteManagerEvent}; +use crate::views::window::{ + TerminalsRegistry, WindowView, content_pane_registry, request_registered_content_pane_repaints, +}; use crate::workspace::state::{GlobalWorkspace, WindowId, Workspace, WorkspaceData}; -use async_channel::Receiver; use gpui::*; -use okena_core::api::ApiGitStatus; +use okena_ui::activity_repaint::ActivityRepaintBatch; 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; + +/// Repaint-only terminal activity is limited to one app-wide presentation frame. +/// A ~50 FPS terminal scene keeps interactive output responsive without allowing +/// independent TUI animations to drive the same OS window at aggregate rates. +/// Parsing, notifications, and OSC clipboard replies are intentionally not delayed. +const TERMINAL_ACTIVITY_REPAINT_INTERVAL: Duration = Duration::from_millis(20); + +#[derive(Default)] +struct WindowLayoutSaveTracker { + pending: parking_lot::Mutex, + drained: parking_lot::Condvar, } -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)]; +impl WindowLayoutSaveTracker { + fn start(self: &Arc) -> WindowLayoutSaveJob { + *self.pending.lock() += 1; + WindowLayoutSaveJob { + tracker: Arc::clone(self), + } } - // 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(); + fn flush(&self) { + let mut pending = self.pending.lock(); + while *pending != 0 { + self.drained.wait(&mut pending); + } } - - 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(), - )); +struct WindowLayoutSaveJob { + tracker: Arc, } -/// Set up an observer that loads/unloads service configs when projects change. -/// Handles deferred worktrees by skipping projects whose directory doesn't exist yet. -pub(crate) fn observe_project_services( - workspace: &Entity, - service_manager: &Entity, - cx: &mut Context, -) { - let service_manager = service_manager.clone(); - let known: Arc>> = - Arc::new(parking_lot::Mutex::new(HashSet::new())); - - // Initial load - { - let data = workspace.read(cx).data().clone(); - sync_services(&data, &mut known.lock(), &service_manager, cx); +impl Drop for WindowLayoutSaveJob { + fn drop(&mut self) { + let mut pending = self.tracker.pending.lock(); + *pending = pending.saturating_sub(1); + if *pending == 0 { + self.tracker.drained.notify_all(); + } } +} + +fn flush_window_layout_saves_before_final( + tracker: &WindowLayoutSaveTracker, + save_final: impl FnOnce() -> R, +) -> R { + tracker.flush(); + save_final() +} - let known_for_observer = known.clone(); - cx.observe(workspace, move |_this, workspace: Entity, cx| { - let data = workspace.read(cx).data().clone(); - sync_services(&data, &mut known_for_observer.lock(), &service_manager, cx); - }) - .detach(); +/// 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 sync_services( - data: &WorkspaceData, - known: &mut HashSet, - service_manager: &Entity, - cx: &mut impl AppContext, -) { - let current_ids: HashSet = data.projects.iter() - .filter(|p| !p.is_remote) - .map(|p| p.id.clone()) - .collect(); - - for p in &data.projects { - if p.is_remote || known.contains(&p.id) { - continue; +/// 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; + } + 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; } - // Skip projects whose directory doesn't exist yet (deferred worktrees). - if !std::path::Path::new(&p.path).exists() { - continue; + proc.kill(); + } +} + +fn hand_off_ui_owned_daemon(mut spawned_child: Option) { + let Some(daemon) = okena_remote_server::local::running_daemon() else { + if let Some(child) = spawned_child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); } - service_manager.update(cx, |sm, cx| { - sm.load_project_services(&p.id, &p.path, &p.service_terminals, cx); - }); - known.insert(p.id.clone()); + return; + }; + if !daemon.ui_owned { + log::info!("Leaving standalone local daemon pid {} running", daemon.pid); + return; } - let removed: Vec = known.difference(¤t_ids).cloned().collect(); - for id in &removed { - service_manager.update(cx, |sm, cx| { - sm.unload_project_services(id, cx); - }); - known.remove(id); + let owns_current_process = spawned_child + .as_ref() + .is_some_and(|child| child.id() == daemon.pid); + match okena_remote_server::local::request_local_shutdown(&daemon) { + Ok(outcome) if outcome.accepted && outcome.active_clients == 0 => { + if !okena_remote_server::local::wait_for_pid_exit(daemon.pid, Duration::from_secs(3)) { + if let Some(child) = spawned_child.as_mut().filter(|_| owns_current_process) { + let _ = child.kill(); + } else { + // No owned `Child` handle — e.g. a detached post-restart + // successor we only know by the pid it advertises. Reap it by + // pid, guarded by the is_okena_process recycle check, so a + // UI-owned daemon we own the lifecycle of never lingers. + log::info!( + "UI-owned daemon pid {} did not exit gracefully; reaping by pid", + daemon.pid + ); + kill_process_by_pid(daemon.pid); + } + } + if let Some(child) = spawned_child.as_mut().filter(|_| owns_current_process) { + let _ = child.wait(); + } + } + Ok(outcome) if outcome.accepted => { + log::info!( + "UI-owned daemon shutdown armed until {} other client(s) disconnect", + outcome.active_clients + ); + } + Ok(_) => { + log::info!("Local daemon declined UI lifecycle handoff"); + } + Err(error) => { + if let Some(child) = spawned_child.as_mut().filter(|_| owns_current_process) { + log::warn!("Shutdown request failed ({error}); killing owned daemon child"); + let _ = child.kill(); + let _ = child.wait(); + } else { + log::warn!( + "Shutdown request failed ({error}); refusing to kill daemon without a matching child handle" + ); + } + } } } @@ -166,8 +181,8 @@ pub struct Okena { /// Ephemeral extras spawned at runtime, keyed by `WindowId::Extra(uuid)`. /// Populated by the workspace observer in `handle_extra_windows_changed` /// when `WorkspaceData.extra_windows` gains a new entry; the matching - /// `Entity` is created and inserted as part of the - /// `cx.open_window` build closure (see `extras.rs`). + /// `Entity` is registered immediately after `cx.open_window` + /// succeeds (see `extras.rs`). extra_windows: HashMap>, /// OS window handles for extras, keyed by `WindowId::Extra(uuid)`. Populated /// alongside `extra_windows` in `extras.rs::open_extra_window`. Same @@ -175,122 +190,59 @@ 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). remote_manager: Entity, + /// Exact terminal panes dirtied during the current app-wide frame window. + /// The idle-to-active edge is presented immediately; sustained activity is + /// coalesced while parsing and notification handling remain immediate. + terminal_activity_repaints: ActivityRepaintBatch, /// Sender handed to desktop-notification threads. When a user clicks an /// 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 this GUI spawned, retained for owner-checked recovery fallback. + spawned_daemon: Option, + /// A rebuilt successor already owns the daemon; quitting must not shut it down. + preserve_daemon_on_quit: bool, + /// 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. + /// Also set from the main window's close handler (see main.rs) so pending + /// extra-window forgets never commit during app teardown. + quitting: Arc, + /// Deferred forgets for OS-closed extra windows — see + /// `extras.rs::handle_extra_window_os_close` for the quit-vs-close story. + pending_extra_forgets: extras::PendingExtraForgets, } impl Okena { pub fn new( workspace_data: WorkspaceData, - pty_manager: Arc, - pty_events: Receiver, - listen_addr: Option, + client_project_layouts: HashMap, + 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)) + // 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| { + let mut workspace = Workspace::new(workspace_data); + workspace.seed_client_project_layouts(client_project_layouts); + workspace }); - let remote_tls_enabled = cx - .global::() - .0 - .read(cx) - .get() - .remote_tls_enabled; - // Create workspace entity - 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 @@ -298,44 +250,54 @@ impl Okena { // already shown in main would create a NEW Terminal model and PTY // bytes (which feed the original Arc) would never reach // the extra's content pane. - let terminals: TerminalsRegistry = Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())); + 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" // from the Switch Project overlay). Okena is the only place that holds // 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(), - )); + cx.subscribe(&main_window, Self::handle_window_view_event) + .detach(); // Create remote connection manager and wire to main window - let remote_manager = cx.new(|cx| { - RemoteConnectionManager::new(terminals.clone(), cx) - }); + let remote_manager = cx.new(|cx| RemoteConnectionManager::new(terminals.clone(), cx)); 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. A spawned child handle is + // retained for bounded fallback cleanup if graceful UI lifecycle handoff + // fails; an attached daemon is never force-killed. 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 = ensured.daemon.connection_config(ensured.token.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 +309,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 +320,118 @@ 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(), + terminal_activity_repaints: ActivityRepaintBatch::default(), notification_jump_tx, + spawned_daemon, + preserve_daemon_on_quit: false, + recovering: Arc::new(AtomicBool::new(false)), + quitting: Arc::new(AtomicBool::new(false)), + pending_extra_forgets: extras::PendingExtraForgets::default(), }; - // 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. + { + let remote_manager = remote_manager.clone(); + let settings = crate::settings::settings_entity(cx); + cx.subscribe(&settings, move |_this, _settings, event, cx| { + let crate::settings::SettingsEvent::Changed(settings) = event; + match serde_json::to_value(settings) { + Ok(mut patch) => { + if let Some(object) = patch.as_object_mut() { + object.remove("remote_connections"); + } + remote_manager.update(cx, |manager, cx| { + manager.send_action( + okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, + okena_core::api::ActionRequest::SetSettings { patch }, + cx, + ); + }); + } + Err(error) => { + log::error!("Failed to encode settings update: {error}"); + } + } + }) + .detach(); + } + + cx.subscribe(&remote_manager, |this, _rm, event, cx| match event { + RemoteManagerEvent::TerminalActivity(terminal_ids) => { + if !terminal_ids.is_empty() { + for terminal_id in terminal_ids { + okena_core::latency_probe::client_activity_received(terminal_id); + } + // Parsing already happened in the manager pump. Present the + // first output immediately, then coalesce sustained output, + // while notification and clipboard semantics stay immediate. + this.queue_terminal_activity_repaints(terminal_ids, cx); + this.process_terminal_notifications(terminal_ids, cx); + this.process_clipboard_writes(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); + } + } + RemoteManagerEvent::TerminalFocusRequested { + project_id, + terminal_id, + window, + } => { + this.jump_to_terminal(project_id, terminal_id, window.as_deref(), 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); + } + RemoteManagerEvent::SettingsChanged(settings) => { + let settings = settings.as_ref().clone(); + let mode = settings.theme_mode; + let custom_id = settings.custom_theme_id.clone(); + crate::settings::settings_entity(cx).update(cx, |state, cx| { + state.replace_from_daemon(settings.clone(), cx); + }); + + if let Some(global_theme) = cx.try_global::() { + let theme = global_theme.0.clone(); + theme.update(cx, |theme, cx| { + if mode == crate::theme::ThemeMode::Custom + && let Some(custom_id) = custom_id.as_ref() + && let Some((_, colors)) = crate::theme::load_custom_themes() + .into_iter() + .find(|(info, _)| info.id == format!("custom:{custom_id}")) + { + theme.set_custom_colors(colors); + theme.set_mode(crate::theme::ThemeMode::Custom); + } else { + theme.set_mode(mode); + } + cx.notify(); + }); + } + } + }) + .detach(); // Kill orphaned terminals when projects are deleted cx.observe(&workspace, move |this, workspace, cx| { @@ -450,25 +439,23 @@ impl Okena { if !kills.is_empty() { let mut reg = this.terminals.lock(); for tid in &kills { - this.pty_manager.kill(tid); reg.remove(tid); } } }) .detach(); + let window_layout_saves = Arc::new(WindowLayoutSaveTracker::default()); + // Flush soft-closed terminals on quit. Their grace timer can't fire once // the app is gone, so tear the PTYs down here — otherwise a terminal // closed seconds before quitting would leak its persistent (dtach/tmux) // session. on_app_quit fires for every exit path. cx.on_app_quit(move |this: &mut Self, cx| { - let ids = this - .workspace - .update(cx, |ws, _| ws.drain_pending_closes()); + let ids = this.workspace.update(cx, |ws, _| ws.drain_pending_closes()); if !ids.is_empty() { let mut reg = this.terminals.lock(); for tid in &ids { - this.pty_manager.kill(tid); reg.remove(tid); } } @@ -476,6 +463,48 @@ impl Okena { }) .detach(); + // Hand UI-owned lifecycle to the daemon on quit. It exits after the + // final client disconnects; standalone daemons are left running. + let final_window_layout_saves = window_layout_saves.clone(); + 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); + let (final_layout, project_layouts) = { + let workspace = this.workspace.read(cx); + (workspace.data().clone(), workspace.client_project_layouts()) + }; + // Disconnect our own loopback connection before handing lifecycle + // to the daemon, so its live-client count excludes this GUI. + this.remote_manager.update(cx, |rm, cx| { + rm.remove_connection(okena_transport::client::LOCAL_DAEMON_CONNECTION_ID, cx); + }); + if this.preserve_daemon_on_quit { + if let Some(child) = this.spawned_daemon.as_mut() { + let _ = child.try_wait(); + } + this.spawned_daemon = None; + } else { + hand_off_ui_owned_daemon(this.spawned_daemon.take()); + } + let window_layout_saves = final_window_layout_saves.clone(); + async move { + if let Err(error) = smol::unblock(move || { + flush_window_layout_saves_before_final(&window_layout_saves, || { + crate::workspace::persistence::save_window_layout( + &final_layout, + project_layouts, + ) + }) + }) + .await + { + log::error!("Failed to save final window layout: {error}"); + } + } + }) + .detach(); + // Set up observer for detached terminals cx.observe(&workspace, move |this, workspace, cx| { this.handle_detached_terminals_changed(workspace, cx); @@ -491,6 +520,64 @@ 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(); + let window_layout_saves = window_layout_saves.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(); + let window_layout_saves = window_layout_saves.clone(); + cx.spawn(async move |_, cx| { + smol::Timer::after(Duration::from_millis(500)).await; + if save_pending.swap(false, Ordering::Relaxed) { + let (data, project_layouts, version) = cx.update(|cx| { + let ws = workspace.read(cx); + ( + ws.data().clone(), + ws.client_project_layouts(), + ws.data_version(), + ) + }); + // Register after the snapshot but before the first yield, + // so quit cannot miss a stale snapshot queued for blocking I/O. + let save_job = window_layout_saves.start(); + let save_result = smol::unblock(move || { + let _save_job = save_job; + crate::workspace::persistence::save_window_layout( + &data, + project_layouts, + ) + }) + .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 @@ -503,7 +590,8 @@ impl Okena { .iter() .map(|p| p.id.clone()) .collect(); - let mut fms: Vec> = Vec::with_capacity(1 + this.extra_windows.len()); + let mut fms: Vec> = + Vec::with_capacity(1 + this.extra_windows.len()); fms.push(this.main_window.read(cx).focus_manager()); for view in this.extra_windows.values() { fms.push(view.read(cx).focus_manager()); @@ -518,643 +606,513 @@ impl Okena { }) .detach(); - // Slice 07 cri 1: kick the extras observer once so persisted - // `WorkspaceData.extra_windows` entries reopen at launch. The observer - // above only fires when `workspace` notifies, but `Workspace::new` does - // not notify on construction — without an explicit kick, persisted - // extras would stay invisible until the user mutates the workspace. - // Deferred via `cx.spawn` because `open_extra_window` captures - // `cx.entity()` and calls `okena.update` inside `cx.open_window`'s - // build closure; running synchronously inside `Okena::new` would touch - // a half-constructed entity. By the time the spawned task body runs, - // the entity is fully wrapped and `update` is safe. - cx.spawn(async move |this: WeakEntity, cx| { - let _ = this.update(cx, |this, cx| { - this.handle_extra_windows_changed(cx); + // Linux starts its platform event loop only after the application + // startup callback returns. Use the foreground executor so restored + // Wayland windows are created on the first live event-loop turn, while + // retaining Okena strongly until the one-shot restore has completed. + let okena = cx.entity(); + cx.spawn(async move |_, cx| { + cx.update(|cx| { + okena.update(cx, |this, cx| { + this.handle_extra_windows_changed(cx); + }); }); }) .detach(); - // Observe workspace to load/unload service configs when projects change - observe_project_services(&workspace, &service_manager, cx); + // Note: updater is now handled by the okena-ext-updater extension. + // GlobalUpdateInfo is set in main.rs via okena_ext_updater::init(). - // 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(); + manager + } +} - workspace_for_svc.update(cx, |ws, cx| { - for (project_id, terminals) in terminal_maps { - ws.sync_service_terminals(&project_id, terminals, cx); - } - }); - }) - .detach(); - } +impl Okena { + fn queue_terminal_activity_repaints( + &mut self, + terminal_ids: &[String], + cx: &mut Context, + ) { + let input_response_ids: Vec<_> = { + let registry = self.terminals.lock(); + terminal_ids + .iter() + .filter(|terminal_id| { + registry + .get(*terminal_id) + .is_some_and(|terminal| terminal.take_input_repaint_request()) + }) + .cloned() + .collect() + }; - // 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()); + let decision = self + .terminal_activity_repaints + .queue_activity(terminal_ids.iter().cloned(), input_response_ids); + if !decision.immediate.is_empty() { + let immediate_ids: Vec<_> = decision.immediate.into_iter().collect(); + self.present_terminal_activity_repaints(&immediate_ids, cx); + } + if !decision.start_timer { + return; } - // 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()); + cx.spawn(async move |this: WeakEntity, cx| { + loop { + cx.background_executor() + .timer(TERMINAL_ACTIVITY_REPAINT_INTERVAL) + .await; + let keep_scheduled = this + .update(cx, |this, cx| { + let Some(terminal_ids) = this.terminal_activity_repaints.take_scheduled() + else { + return false; + }; + let terminal_ids: Vec<_> = terminal_ids.into_iter().collect(); + this.present_terminal_activity_repaints(&terminal_ids, cx); + true + }) + .unwrap_or(false); + if !keep_scheduled { + break; } } }) .detach(); + } - // Note: updater is now handled by the okena-ext-updater extension. - // GlobalUpdateInfo is set in main.rs via okena_ext_updater::init(). + fn present_terminal_activity_repaints( + &mut self, + terminal_ids: &[String], + cx: &mut Context, + ) { + for terminal_id in terminal_ids { + okena_core::latency_probe::client_repaint_dispatched(terminal_id); + } - manager - } + // Pane and sidebar notifications happen in one app update so GPUI can + // present one frame for all windows rather than racing per-view timers. + { + let mut registry = content_pane_registry().lock(); + request_registered_content_pane_repaints(&mut registry, terminal_ids, cx); + } - /// 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); - } + let mut window_views = Vec::with_capacity(1 + self.extra_windows.len()); + window_views.push(self.main_window.clone()); + window_views.extend(self.extra_windows.values().cloned()); + for window_view in window_views { + window_view.update(cx, |window_view, cx| { + window_view.request_sidebar_activity_repaint(cx); + }); } } - /// 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(); + /// Mark the app as quitting before the platform loop stops. Called from + /// the main window's close handler (main.rs) ahead of `cx.quit()` so + /// deferred extra-window forgets and daemon recovery bail immediately — + /// on_app_quit alone would set this only after close events for every + /// window have already been processed. + pub fn note_quitting(&self) { + self.quitting.store(true, Ordering::SeqCst); } +} - /// 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(); - - // 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; - - cx.spawn(async move |this: WeakEntity, cx| { +impl Render for Okena { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.main_window.clone()) + } +} + +/// 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, + owns_daemon: bool, +) -> bool { + failed_attempts >= RECOVERY_ESCALATE_AFTER_ATTEMPTS && live_unreachable_daemon && owns_daemon +} + +/// 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) +} + +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; + } + + 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); + .await; + if let Some(daemon) = stuck_daemon + { + let owns_daemon = this + .update(cx, |this, _cx| { + this.spawned_daemon + .as_ref() + .is_some_and(|child| child.id() == daemon.pid) + }) + .unwrap_or(false); + if should_escalate_recovery( + failed_attempts, + true, + owns_daemon, + ) { + log::warn!( + "Owned 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); + 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; + } + }); + } else { + log::warn!( + "Attached local daemon pid {} is unreachable; refusing to kill a process this GUI did not spawn", + daemon.pid + ); } } } - // 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; + // 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, + ); } - weaks.is_empty() - } else { - false - }; - if now_empty { - registry.remove(tid); - } - } - 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()); + }) + .is_ok(); + if !alive { + break; } + cx.background_executor() + .timer(recovery_backoff_delay(failed_attempts)) + .await; } - - // 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); - } - }); - } - } - - // 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, + ensured: okena_remote_server::local::EnsuredDaemon, 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); + ) { + // 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(); } - // Hook terminal persists — no auto-cleanup. User can dismiss manually or rerun. + return; } - 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, - 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 + if let Some(local_build) = cx + .try_global::() + .map(|global| global.0.clone()) { - let mut guard = self.terminals.lock(); - guard.remove(tid); - for hook_tid in &remaining_hook_tids { - guard.remove(hook_tid); - } + local_build.update(cx, |state, cx| { + state.set_daemon_ui_owned(ensured.daemon.ui_owned, cx); + }); } - // 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 = ensured.daemon.connection_config(ensured.token.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::{ + RECOVERY_ESCALATE_AFTER_ATTEMPTS, RECOVERY_TOAST_AFTER_ATTEMPTS, WindowLayoutSaveTracker, + flush_window_layout_saves_before_final, is_okena_process, recovery_backoff_delay, + should_escalate_recovery, + }; + use std::sync::{Arc, Mutex}; + 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 final_window_layout_save_waits_for_older_snapshot() { + let tracker = Arc::new(WindowLayoutSaveTracker::default()); + let old_job = tracker.start(); + let writes = Arc::new(Mutex::new(Vec::new())); + let (old_started_tx, old_started_rx) = std::sync::mpsc::channel(); + let (release_old_tx, release_old_rx) = std::sync::mpsc::channel(); + let (flush_started_tx, flush_started_rx) = std::sync::mpsc::channel(); + + std::thread::scope(|scope| { + let old_writes = writes.clone(); + scope.spawn(move || { + let _old_job = old_job; + old_started_tx.send(()).expect("signal old save"); + release_old_rx.recv().expect("release old save"); + old_writes.lock().expect("writes lock").push("old"); + }); + old_started_rx.recv().expect("old save started"); + + let tracker = tracker.clone(); + let final_writes = writes.clone(); + let final_thread = scope.spawn(move || { + flush_started_tx.send(()).expect("signal final flush"); + flush_window_layout_saves_before_final(&tracker, || { + assert_eq!( + *final_writes.lock().expect("writes lock"), + ["old"], + "the older snapshot must finish before the final write" + ); + final_writes.lock().expect("writes lock").push("final"); + }); + }); + + flush_started_rx.recv().expect("final flush started"); + assert!( + writes.lock().expect("writes lock").is_empty(), + "the final write must remain behind the older snapshot" + ); + release_old_tx.send(()).expect("release old save"); + final_thread.join().expect("final save thread"); + }); + + assert_eq!(*writes.lock().expect("writes lock"), ["old", "final"]); } #[test] - fn single_profile_keeps_parent_claude_config_dir() { - let custom_dir = std::env::temp_dir().join("okena-custom-claude-dir"); + 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)); + } - assert!(claude_pty_extra_env(&custom_dir, false, true).is_empty()); + #[test] + 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, true)); + assert!(!should_escalate_recovery( + RECOVERY_ESCALATE_AFTER_ATTEMPTS - 1, + true, + true, + )); + // At/after the threshold WITH a live-unreachable daemon: escalate. + assert!(should_escalate_recovery( + RECOVERY_ESCALATE_AFTER_ATTEMPTS, + true, + true, + )); + assert!(should_escalate_recovery( + RECOVERY_ESCALATE_AFTER_ATTEMPTS + 5, + true, + true, + )); + // No live-unreachable daemon (absent or healthy): never kill. + assert!(!should_escalate_recovery( + RECOVERY_ESCALATE_AFTER_ATTEMPTS + 5, + false, + true, + )); + // A daemon this GUI merely attached to is never killable. + assert!(!should_escalate_recovery( + RECOVERY_ESCALATE_AFTER_ATTEMPTS + 5, + true, + 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..5f9629f45 100644 --- a/crates/okena-app/src/app/notifications.rs +++ b/crates/okena-app/src/app/notifications.rs @@ -87,7 +87,7 @@ impl Okena { cx.spawn(async move |this: WeakEntity, cx| { while let Ok(jump) = rx.recv().await { let _ = this.update(cx, |this, cx| { - this.jump_to_terminal(&jump.project_id, &jump.terminal_id, cx); + this.jump_to_terminal(&jump.project_id, &jump.terminal_id, None, cx); }); } }) @@ -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,32 +201,31 @@ 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( + /// Apply OSC 52 clipboard *writes* queued by terminals that produced output. + /// + /// This side effect is intentionally drained from the immediate activity + /// handler rather than relying on `TerminalContent::render`: background-only + /// panes may remain inactive indefinitely, while clipboard semantics must not. + /// The render path keeps a fallback drain for non-remote terminal producers. + pub(super) fn process_clipboard_writes( &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 writes = { 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() + let mut writes = Vec::new(); + for terminal_id in dirty_terminal_ids { + if let Some(terminal) = reg.get(terminal_id) { + writes.extend(terminal.take_pending_clipboard_writes()); + } + } + writes }; - if finished.is_empty() { - return; + + for text in writes { + cx.write_to_clipboard(ClipboardItem::new_string(text)); } - self.bump_activity_for_terminals(finished.iter().map(|s| s.as_str()), cx); } /// Answer (or silently deny) OSC 52 clipboard *read* requests @@ -251,7 +250,10 @@ impl Okena { let reg = self.terminals.lock(); dirty_terminal_ids .iter() - .filter(|tid| reg.get(*tid).is_some_and(|t| t.has_pending_clipboard_reads())) + .filter(|tid| { + reg.get(*tid) + .is_some_and(|t| t.has_pending_clipboard_reads()) + }) .cloned() .collect() }; @@ -290,30 +292,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. @@ -349,48 +327,37 @@ impl Okena { false } - /// Focus the specific terminal that raised a notification, raising the - /// window it lives in. Mirrors `jump_to_project_terminal` but targets an - /// exact terminal id (activating its tab) rather than the first visible one. - /// - /// Two tiers: if the project is currently visible in some window, focus it - /// there without disturbing the view. If it's visible *nowhere* — hidden - /// column, folder filter, or a window zoomed into another project — fall - /// back to zooming into it in the active (or main) window, which pierces - /// all three so the click always lands on the terminal. - fn jump_to_terminal(&mut self, project_id: &str, terminal_id: &str, cx: &mut Context) { - // Tier 1: a window where the project is actually visible right now. - let mut order = vec![WindowId::Main]; - order.extend(self.extra_window_handles.keys().copied()); - let mut visible_in: Option = None; - for wid in order { - if let Some((view, _)) = self.window_view_and_handle(wid) - && self.project_visible_in(wid, &view, project_id, cx) - { - visible_in = Some(wid); - break; - } - } - - // Tier 2: visible nowhere → reveal (zoom) in the active or main window. - let (target, reveal) = match visible_in { - Some(wid) => (wid, false), - None => { - let active = cx.active_window(); - let mut handles = vec![(WindowId::Main, self.main_window_handle)]; - handles.extend(self.extra_window_handles.iter().map(|(id, h)| (*id, *h))); - let wid = handles - .into_iter() - .find(|(_, h)| Some(*h) == active) - .map(|(id, _)| id) - .unwrap_or(WindowId::Main); - (wid, true) + /// Focus an exact terminal in the requested window, or the active window. + pub(super) fn jump_to_terminal( + &mut self, + project_id: &str, + terminal_id: &str, + requested_window: Option<&str>, + cx: &mut Context, + ) { + let requested_window = match requested_window { + None => None, + Some("main") => Some(WindowId::Main), + Some(id) => { + let Ok(id) = uuid::Uuid::parse_str(id) else { + return; + }; + Some(WindowId::Extra(id)) } }; + let target = requested_window.unwrap_or_else(|| { + let active = cx.active_window(); + self.extra_window_handles + .iter() + .find(|(_, handle)| Some(**handle) == active) + .map(|(id, _)| *id) + .unwrap_or(WindowId::Main) + }); let Some((view, handle)) = self.window_view_and_handle(target) else { return; }; + let reveal = !self.project_visible_in(target, &view, project_id, cx); let workspace = self.workspace.clone(); let focus_manager = view.read(cx).focus_manager(); @@ -426,7 +393,10 @@ impl Okena { ) -> Option<(Entity, AnyWindowHandle)> { match window_id { WindowId::Main => Some((self.main_window.clone(), self.main_window_handle)), - id => match (self.extra_windows.get(&id), self.extra_window_handles.get(&id)) { + id => match ( + self.extra_windows.get(&id), + self.extra_window_handles.get(&id), + ) { (Some(v), Some(h)) => Some((v.clone(), *h)), _ => None, }, diff --git a/crates/okena-app/src/app/remote_commands.rs b/crates/okena-app/src/app/remote_commands.rs deleted file mode 100644 index 43699fb79..000000000 --- a/crates/okena-app/src/app/remote_commands.rs +++ /dev/null @@ -1,594 +0,0 @@ -// The `.expect("BUG: ... must serialize")` sites in this file serialize -// internal DTOs whose Serialize impls cannot fail in practice. -#![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::terminal::backend::TerminalBackend; -use crate::views::window::TerminalsRegistry; -use crate::workspace::actions::execute::{ensure_terminal, execute_action}; -use crate::workspace::state::{WindowId, Workspace}; -use gpui::*; -use okena_core::api::ApiGitStatus; -use std::collections::{HashMap, HashSet}; -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 -/// 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. -pub(crate) fn parse_window_id(s: &str) -> Option { - if s == "main" { - Some(WindowId::Main) - } else { - Uuid::parse_str(s).ok().map(WindowId::Extra) - } -} - -/// Resolver returning a window's `(WindowId, FocusManager)` for a -/// remote-bridge action. -/// -/// The `Option` argument selects the target: -/// - `None` → the focused/active window. In GUI mode the resolver consults -/// `cx.active_window()` and yields the focused window's per-window -/// `WindowId` + `FocusManager` (PRD cri 13), always returning `Some` -/// (falling back to main). In headless mode it returns -/// `Some((WindowId::Main, dormant FocusManager))` since there are no -/// windows to consult. -/// - `Some(id)` → that specific window's `(WindowId, FocusManager)`, or -/// `None` if no such window exists (so the caller can report "window not -/// found"). The `WindowId` lets per-window state mutations (e.g. -/// `SetProjectShowInOverview`) land on the addressed window. -pub(crate) type FocusManagerResolver = Arc< - dyn Fn(&App, Option) -> Option<(WindowId, Entity)> - + Send - + Sync, ->; - -/// Resolver returning the current set of open OS windows for `GET /v1/state`. -/// -/// GUI mode enumerates main + extras (stable order: main first, then -/// `extra_windows` Vec order); headless mode returns a single synthetic main -/// window. Lets the read side report exactly what the user sees. -pub(crate) type WindowsResolver = Arc Vec + Send + Sync>; - -/// Dispatches a named GUI command (command palette) into a window for -/// `ActionRequest::InvokeAction`. Args: `(cx, target_window, action_name)`. -/// `None` target → focused window. Returns `Err` if the window or action name -/// can't be resolved, or in headless mode where there is no GUI to dispatch to. -pub(crate) type ActionDispatcher = - Arc, &str) -> Result<(), String> + Send + Sync>; - -/// Shared remote command loop used by both GUI (`Okena`) and headless (`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`. -// Bridge loop: each param is a distinct channel/entity dependency. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn remote_command_loop( - bridge_rx: BridgeReceiver, - backend: Arc, - workspace: Entity, - focus_manager_resolver: FocusManagerResolver, - windows_resolver: WindowsResolver, - terminals: TerminalsRegistry, - state_version: Arc>, - git_status_tx: Arc>>, - service_manager: Entity, - action_dispatcher: ActionDispatcher, - cx: &mut AsyncApp, -) { - loop { - let msg: BridgeMessage = match bridge_rx.recv().await { - Ok(msg) => msg, - Err(_) => break, - }; - - let _slow = okena_core::timing::SlowGuard::new("remote_command_loop::iter"); - - let result = match msg.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)) - } - }) - }) - } - 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) - }) - }) - } - 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)) - } - }) - }) - } - 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)) - } - }) - }) - } - ActionRequest::StopAllServices { project_id } => { - cx.update(|cx| { - service_manager.update(cx, |sm, cx| { - sm.stop_all(&project_id, cx); - CommandResult::Ok(None) - }) - }) - } - 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)) - } - }) - }) - } - - // ── App-scoped: settings / theme / command palette ──── - ActionRequest::GetSettings => { - cx.update(|cx| super::remote_config::get_settings(cx)) - } - ActionRequest::GetSettingsSchema => { - cx.update(|_cx| super::remote_config::get_settings_schema()) - } - ActionRequest::SetSettings { patch } => { - cx.update(|cx| super::remote_config::set_settings(cx, patch)) - } - ActionRequest::GetThemes => { - cx.update(|cx| super::remote_config::get_themes(cx)) - } - ActionRequest::GetTheme { id } => { - cx.update(|cx| super::remote_config::get_theme(cx, id)) - } - ActionRequest::SetTheme { id } => { - cx.update(|cx| super::remote_config::set_theme(cx, id)) - } - ActionRequest::SaveCustomTheme { id, config, activate } => { - cx.update(|cx| { - super::remote_config::save_custom_theme(cx, id, config, activate) - }) - } - ActionRequest::ListActions => { - cx.update(|_cx| super::remote_config::list_actions()) - } - ActionRequest::InvokeAction { action_name, window } => { - cx.update(|cx| { - let target = match window.as_deref() { - None => None, - Some(s) => match parse_window_id(s) { - Some(w) => Some(w), - None => { - return CommandResult::Err(format!( - "invalid window id: {s}" - )); - } - }, - }; - match action_dispatcher(cx, target, &action_name) { - Ok(()) => CommandResult::Ok(None), - Err(e) => CommandResult::Err(e), - } - }) - } - - action => { - 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 - // on THAT window; `None` keeps the focused-window - // default. A malformed window id is rejected up - // front. - // - // The parsed `Option` is Copy, so it - // outlives the borrow of `action` from - // `target_window()`. We capture the malformed string - // into an owned `String` for the error path so - // nothing borrows `action` past this point. - 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()), - }, - }; - let parsed_target = match parsed_target { - Ok(t) => t, - Err(bad) => { - return CommandResult::Err(format!("invalid window id: {bad}")); - } - }; - // 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 - // data mutations (PRD cri 13 / CLI fallback). - match focus_manager_resolver(cx, parsed_target) { - None => CommandResult::Err(format!( - "window not found: {}", - match parsed_target { - Some(WindowId::Main) => "main".to_string(), - Some(WindowId::Extra(uuid)) => uuid.to_string(), - None => String::new(), - } - )), - 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) - .into_command_result() - }); - cx.notify(); - result - }) - } - } - }) - } - } - } - RemoteCommand::GetState => { - cx.update(|cx| { - let ws = workspace.read(cx); - let sm = service_manager.read(cx); - 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() - }; - - // 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(); - - // 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. - 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, - windows, - }; - - CommandResult::Ok(Some(serde_json::to_value(resp).expect("BUG: StateResponse must serialize"))) - }) - } - RemoteCommand::GetTerminalSizes { terminal_ids } => { - cx.update(|_cx| { - let terms = terminals.lock(); - let mut sizes = std::collections::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)); - } - } - let val = serde_json::to_value(sizes).expect("BUG: sizes must serialize"); - CommandResult::Ok(Some(val)) - }) - } - RemoteCommand::RenderSnapshot { terminal_id } => { - cx.update(|cx| { - let ws = workspace.read(cx); - match ensure_terminal(&terminal_id, &terminals, &*backend, ws) { - Some(term) => { - let snapshot = term.render_snapshot(); - CommandResult::OkBytes(snapshot) - } - None => CommandResult::Err(format!("terminal not found: {}", terminal_id)), - } - }) - } - RemoteCommand::PasteImage { terminal_id, path } => { - cx.update(|cx| { - let ws = workspace.read(cx); - 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); - } - } -} - -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; - use std::collections::HashSet; - - /// Regression: the wire-format visibility flag must derive from the - /// per-window hidden set. With the legacy - /// `ProjectData.show_in_overview` field removed entirely, this test - /// pins the post-deletion contract. - #[test] - fn api_project_visibility_reads_from_hidden_set() { - let hidden: HashSet = ["p1".to_string()].into_iter().collect(); - assert!( - !api_project_visibility("p1", &hidden), - "membership in hidden set must read as not-visible", - ); - assert!( - api_project_visibility("p2", &hidden), - "absent from hidden set must read as visible", - ); - } - - #[test] - fn api_project_visibility_empty_hidden_set_is_visible() { - let hidden: HashSet = HashSet::new(); - assert!(api_project_visibility("p1", &hidden)); - } -} - -#[cfg(test)] -mod parse_window_id_tests { - use super::parse_window_id; - use crate::workspace::state::WindowId; - use uuid::Uuid; - - #[test] - fn main_maps_to_main_variant() { - assert_eq!(parse_window_id("main"), Some(WindowId::Main)); - } - - #[test] - fn valid_uuid_maps_to_extra() { - let id = Uuid::new_v4(); - assert_eq!(parse_window_id(&id.to_string()), Some(WindowId::Extra(id))); - } - - #[test] - fn 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); - } -} diff --git a/crates/okena-app/src/app/remote_config.rs b/crates/okena-app/src/app/remote_config.rs deleted file mode 100644 index f047b18ce..000000000 --- a/crates/okena-app/src/app/remote_config.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! Remote-bridge handlers for app-scoped actions: settings, theme, and the -//! command-palette action list. These touch globals (`GlobalSettings`, -//! `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`). - -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::workspace::persistence::AppSettings; -use gpui::*; -use serde_json::{json, Value}; - -// ── 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()), - } -} - -/// 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}")), - } -} - -/// 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 { - 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, - } -} - -// ── 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, - })); - } - CommandResult::Ok(Some(json!({ "themes": themes }))) -} - -/// 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}")), - } -} - -/// 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}")), - } - } -} - -/// Write a custom theme JSON file (a full `CustomThemeConfig`) and, when -/// `activate`, switch to it. -pub(super) fn save_custom_theme( - cx: &mut App, - 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(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 { - 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", - } -} - -// ── Command palette ────────────────────────────────────────────────────────── - -/// List invokable GUI commands, sorted. `name` is the identifier `invoke_action` -/// expects (the registry key, e.g. "ToggleSidebar"); `label` is the human name. -pub(super) fn list_actions() -> CommandResult { - let descs = get_action_descriptions(); - let mut actions: Vec = descs - .iter() - .map(|(key, d)| { - json!({ - "name": *key, - "label": d.name, - "description": d.description, - "category": d.category, - }) - }) - .collect(); - actions.sort_by(|a, b| { - let ka = (a["category"].as_str().unwrap_or(""), a["name"].as_str().unwrap_or("")); - let kb = (b["category"].as_str().unwrap_or(""), b["name"].as_str().unwrap_or("")); - ka.cmp(&kb) - }); - CommandResult::Ok(Some(json!({ "actions": actions }))) -} diff --git a/crates/okena-app/src/elements/mod.rs b/crates/okena-app/src/elements/mod.rs index e69de29bb..8b1378917 100644 --- a/crates/okena-app/src/elements/mod.rs +++ b/crates/okena-app/src/elements/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/okena-app/src/git/mod.rs b/crates/okena-app/src/git/mod.rs index 7567ad8a1..2da183c1c 100644 --- a/crates/okena-app/src/git/mod.rs +++ b/crates/okena-app/src/git/mod.rs @@ -4,4 +4,3 @@ pub use okena_git::*; // Watcher re-exported from okena-views-git crate pub use okena_views_git::watcher; - diff --git a/crates/okena-app/src/keybindings/config.rs b/crates/okena-app/src/keybindings/config.rs index 8582a6b54..78a175258 100644 --- a/crates/okena-app/src/keybindings/config.rs +++ b/crates/okena-app/src/keybindings/config.rs @@ -139,9 +139,7 @@ impl KeybindingConfig { // Fullscreen keybindings bindings.insert( "ToggleFullscreen".to_string(), - vec![ - KeybindingEntry::new("shift-escape", Some("TerminalPane")), - ], + vec![KeybindingEntry::new("shift-escape", Some("TerminalPane"))], ); bindings.insert( "FullscreenNextTerminal".to_string(), @@ -378,16 +376,18 @@ impl KeybindingConfig { /// Update the keystroke for a specific binding entry pub fn update_binding(&mut self, action: &str, entry_index: usize, new_keystroke: String) { if let Some(entries) = self.bindings.get_mut(action) - && let Some(entry) = entries.get_mut(entry_index) { - entry.keystroke = new_keystroke; - } + && let Some(entry) = entries.get_mut(entry_index) + { + entry.keystroke = new_keystroke; + } } /// Reset a single action's bindings back to defaults pub fn reset_single_action(&mut self, action: &str) { let defaults = Self::defaults(); if let Some(default_entries) = defaults.bindings.get(action) { - self.bindings.insert(action.to_string(), default_entries.clone()); + self.bindings + .insert(action.to_string(), default_entries.clone()); } else { // Action doesn't exist in defaults — remove it self.bindings.remove(action); @@ -406,19 +406,21 @@ impl KeybindingConfig { /// Returns true if the entry was removed pub fn remove_binding(&mut self, action: &str, entry_index: usize) -> bool { if let Some(entries) = self.bindings.get_mut(action) - && entry_index < entries.len() { - entries.remove(entry_index); - return true; - } + && entry_index < entries.len() + { + entries.remove(entry_index); + return true; + } false } /// Toggle the enabled state of a specific binding entry pub fn toggle_binding(&mut self, action: &str, entry_index: usize) { if let Some(entries) = self.bindings.get_mut(action) - && let Some(entry) = entries.get_mut(entry_index) { - entry.enabled = !entry.enabled; - } + && let Some(entry) = entries.get_mut(entry_index) + { + entry.enabled = !entry.enabled; + } } /// Get all actions that have custom (non-default) bindings @@ -446,7 +448,6 @@ impl KeybindingConfig { customized } - } /// Get the keybindings configuration file path @@ -465,29 +466,30 @@ pub fn get_keybindings_path() -> PathBuf { pub fn load_keybindings() -> KeybindingConfig { let path = get_keybindings_path(); if path.exists() - && let Ok(content) = std::fs::read_to_string(&path) { - match serde_json::from_str::(&content) { - Ok(mut config) => { - // Merge in any new default actions missing from the saved config - let defaults = KeybindingConfig::defaults(); - for (action, entries) in &defaults.bindings { - if !config.bindings.contains_key(action) { - config.bindings.insert(action.clone(), entries.clone()); - } + && let Ok(content) = std::fs::read_to_string(&path) + { + match serde_json::from_str::(&content) { + Ok(mut config) => { + // Merge in any new default actions missing from the saved config + let defaults = KeybindingConfig::defaults(); + for (action, entries) in &defaults.bindings { + if !config.bindings.contains_key(action) { + config.bindings.insert(action.clone(), entries.clone()); } - - // Check for conflicts and log warnings - let conflicts = config.detect_conflicts(); - for conflict in &conflicts { - log::warn!("Keybinding conflict: {}", conflict); - } - return config; } - Err(e) => { - log::warn!("Failed to parse keybindings config: {}, using defaults", e); + + // Check for conflicts and log warnings + let conflicts = config.detect_conflicts(); + for conflict in &conflicts { + log::warn!("Keybinding conflict: {}", conflict); } + return config; + } + Err(e) => { + log::warn!("Failed to parse keybindings config: {}, using defaults", e); } } + } KeybindingConfig::defaults() } @@ -510,7 +512,10 @@ mod tests { fn test_default_config_has_no_conflicts() { let config = KeybindingConfig::defaults(); let conflicts = config.detect_conflicts(); - assert!(conflicts.is_empty(), "Default config should have no conflicts"); + assert!( + conflicts.is_empty(), + "Default config should have no conflicts" + ); } #[test] @@ -537,9 +542,15 @@ mod tests { "Action2".to_string(), vec![KeybindingEntry::new("cmd-d", Some("TerminalPane"))], // scoped ); - let config = KeybindingConfig { version: 1, bindings }; + let config = KeybindingConfig { + version: 1, + bindings, + }; let conflicts = config.detect_conflicts(); - assert!(conflicts.is_empty(), "Different contexts should not conflict"); + assert!( + conflicts.is_empty(), + "Different contexts should not conflict" + ); } #[test] @@ -551,11 +562,11 @@ mod tests { ); let mut disabled = KeybindingEntry::new("cmd-x", None); disabled.enabled = false; - bindings.insert( - "Action2".to_string(), - vec![disabled], - ); - let config = KeybindingConfig { version: 1, bindings }; + bindings.insert("Action2".to_string(), vec![disabled]); + let config = KeybindingConfig { + version: 1, + bindings, + }; let conflicts = config.detect_conflicts(); assert!(conflicts.is_empty(), "Disabled binding should not conflict"); } @@ -569,7 +580,10 @@ mod tests { vec![KeybindingEntry::new("ctrl-shift-b", None)], // changed from default ); let customized = config.get_customized_actions(); - assert!(customized.contains("ToggleSidebar"), "Modified action should be detected"); + assert!( + customized.contains("ToggleSidebar"), + "Modified action should be detected" + ); } #[test] diff --git a/crates/okena-app/src/keybindings/descriptions.rs b/crates/okena-app/src/keybindings/descriptions.rs index 486f118ff..3a8b6cf3e 100644 --- a/crates/okena-app/src/keybindings/descriptions.rs +++ b/crates/okena-app/src/keybindings/descriptions.rs @@ -2,16 +2,17 @@ use std::collections::HashMap; use super::types::ActionDescription; use super::{ - AddTab, Cancel, CheckForUpdates, ClearFocus, CloseSearch, CloseTerminal, Copy, - CreateWorktree, FocusActiveProject, FocusDown, FocusLeft, FocusNextTerminal, FocusPrevTerminal, FocusRight, - FocusSidebar, FocusUp, FullscreenNextTerminal, FullscreenPrevTerminal, InstallUpdate, - JumpToNextFailedCommand, JumpToNextPrompt, JumpToPreviousFailedCommand, JumpToPreviousPrompt, - MinimizeTerminal, NewProject, NewWindow, OpenSettingsFile, Paste, Quit, ResetZoom, ScrollDown, ScrollUp, - Search, SearchNext, SearchPrev, SendEscape, ShowCommandPalette, ShowDiffViewer, ReviewChanges, - ShowContentSearch, ShowFileSearch, ShowHookLog, ShowLogConsole, ShowKeybindings, ShowProjectSwitcher, ShowSessionManager, - ShowSettings, ShowThemeSelector, SplitHorizontal, SplitVertical, StartAllServices, - StopAllServices, ToggleFullscreen, TogglePaneSwitcher, ToggleSidebar, ToggleSidebarAutoHide, - ZoomIn, ZoomOut, EqualizeLayout, ToggleProjectLayout, ShowBranchSwitcher, ShowProfileManager, + AddTab, Cancel, CheckForUpdates, ClearFocus, CloseSearch, CloseTerminal, Copy, CreateWorktree, + EqualizeLayout, FocusActiveProject, FocusDown, FocusLeft, FocusNextTerminal, FocusPrevTerminal, + FocusRight, FocusSidebar, FocusUp, FullscreenNextTerminal, FullscreenPrevTerminal, + InstallUpdate, JumpToNextFailedCommand, JumpToNextPrompt, JumpToPreviousFailedCommand, + JumpToPreviousPrompt, MinimizeTerminal, NewProject, NewWindow, OpenSettingsFile, Paste, Quit, + ResetZoom, RestartDaemon, ReviewChanges, ScrollDown, ScrollUp, Search, SearchNext, SearchPrev, + SendEscape, ShowBranchSwitcher, ShowCommandPalette, ShowContentSearch, ShowDiffViewer, + ShowFileSearch, ShowHookLog, ShowKeybindings, ShowLogConsole, ShowProfileManager, + ShowProjectSwitcher, ShowSessionManager, ShowSettings, ShowThemeSelector, SplitHorizontal, + SplitVertical, StartAllServices, StopAllServices, ToggleFullscreen, TogglePaneSwitcher, + ToggleProjectLayout, ToggleSidebar, ToggleSidebarAutoHide, ZoomIn, ZoomOut, }; /// 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..99fd72c85 100644 --- a/crates/okena-app/src/keybindings/mod.rs +++ b/crates/okena-app/src/keybindings/mod.rs @@ -5,10 +5,7 @@ mod types; use gpui::*; use parking_lot::RwLock; -pub use config::{ - get_keybindings_path, load_keybindings, save_keybindings, - KeybindingConfig, -}; +pub use config::{KeybindingConfig, get_keybindings_path, load_keybindings, save_keybindings}; pub use descriptions::get_action_descriptions; #[allow(unused_imports)] pub use types::{ActionDescription, KeybindingConflict, KeybindingEntry}; @@ -56,24 +53,22 @@ actions!( ShowBranchSwitcher, ShowProfileManager, NewWindow, + RestartDaemon, ] ); // Terminal-specific actions (defined in okena-views-terminal crate) pub use okena_views_terminal::actions::{ - SendEscape, SplitVertical, SplitHorizontal, AddTab, CloseTerminal, - MinimizeTerminal, FocusNextTerminal, FocusPrevTerminal, - FocusLeft, FocusRight, FocusUp, FocusDown, - Copy, Paste, Search, SearchNext, SearchPrev, CloseSearch, - SendTab, SendBacktab, ZoomIn, ZoomOut, ResetZoom, - ToggleFullscreen, FullscreenNextTerminal, FullscreenPrevTerminal, - JumpToPreviousPrompt, JumpToNextPrompt, - JumpToPreviousFailedCommand, JumpToNextFailedCommand, + AddTab, CloseSearch, CloseTerminal, Copy, FocusDown, FocusLeft, FocusNextTerminal, + FocusPrevTerminal, FocusRight, FocusUp, FullscreenNextTerminal, FullscreenPrevTerminal, + JumpToNextFailedCommand, JumpToNextPrompt, JumpToPreviousFailedCommand, JumpToPreviousPrompt, + MinimizeTerminal, Paste, ResetZoom, Search, SearchNext, SearchPrev, SendBacktab, SendEscape, + SendTab, SplitHorizontal, SplitVertical, ToggleFullscreen, ZoomIn, ZoomOut, }; // Sidebar-specific actions (defined in okena-views-sidebar crate) pub use okena_views_sidebar::{ - SidebarUp, SidebarDown, SidebarConfirm, SidebarToggleExpand, SidebarEscape, + SidebarConfirm, SidebarDown, SidebarEscape, SidebarToggleExpand, SidebarUp, }; /// Global keybinding configuration (thread-safe) @@ -107,16 +102,18 @@ pub fn reset_to_defaults() -> anyhow::Result<()> { pub fn keystroke_to_config_string(keystroke: &gpui::Keystroke) -> String { let unparsed = keystroke.unparse(); // Normalize platform modifier names to "cmd-" for config consistency - unparsed - .replace("super-", "cmd-") - .replace("win-", "cmd-") + unparsed.replace("super-", "cmd-").replace("win-", "cmd-") } /// Reload keybindings: update global config, save to disk, and re-register with GPUI. /// Call this after modifying the config via get_config_mut() or update_config(). pub fn reload_keybindings(cx: &mut App) { let config = { - KEYBINDING_CONFIG.read().as_ref().cloned().unwrap_or_default() + KEYBINDING_CONFIG + .read() + .as_ref() + .cloned() + .unwrap_or_default() }; // Save to disk @@ -151,20 +148,64 @@ pub fn reload_keybindings(cx: &mut App) { KeyBinding::new("escape", Cancel, None), KeyBinding::new("escape", SendEscape, Some("TerminalPane")), KeyBinding::new("escape", CloseSearch, Some("SearchBar")), - KeyBinding::new("escape", okena_views_terminal::actions::Cancel, Some("TerminalRename")), - KeyBinding::new("escape", okena_files::file_search::Cancel, Some("FileSearchDialog")), - KeyBinding::new("escape", okena_files::file_search::Cancel, Some("FileViewer")), + KeyBinding::new( + "escape", + okena_views_terminal::actions::Cancel, + Some("TerminalRename"), + ), + KeyBinding::new( + "escape", + okena_files::file_search::Cancel, + Some("FileSearchDialog"), + ), + KeyBinding::new( + "escape", + okena_files::file_search::Cancel, + Some("FileViewer"), + ), KeyBinding::new("escape", okena_views_git::Cancel, Some("WorktreeDialog")), - KeyBinding::new("escape", okena_views_git::Cancel, Some("CloseWorktreeDialog")), - KeyBinding::new("escape", okena_views_git::diff_viewer::Cancel, Some("DiffViewer")), + KeyBinding::new( + "escape", + okena_views_git::Cancel, + Some("CloseWorktreeDialog"), + ), + KeyBinding::new( + "escape", + okena_views_git::diff_viewer::Cancel, + Some("DiffViewer"), + ), KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("ContextMenu")), - KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("FolderContextMenu")), - KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("RenameDirectoryDialog")), + KeyBinding::new( + "escape", + okena_views_sidebar::Cancel, + Some("FolderContextMenu"), + ), + KeyBinding::new( + "escape", + okena_views_sidebar::Cancel, + Some("RenameDirectoryDialog"), + ), KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("HookLog")), - KeyBinding::new("escape", okena_views_terminal::actions::Cancel, Some("ShellSelectorOverlay")), - KeyBinding::new("escape", okena_views_remote::Cancel, Some("RemoteConnectDialog")), - KeyBinding::new("escape", okena_views_remote::Cancel, Some("RemotePairDialog")), - KeyBinding::new("escape", okena_views_remote::Cancel, Some("RemoteContextMenu")), + KeyBinding::new( + "escape", + okena_views_terminal::actions::Cancel, + Some("ShellSelectorOverlay"), + ), + KeyBinding::new( + "escape", + okena_views_remote::Cancel, + Some("RemoteConnectDialog"), + ), + KeyBinding::new( + "escape", + okena_views_remote::Cancel, + Some("RemotePairDialog"), + ), + KeyBinding::new( + "escape", + okena_views_remote::Cancel, + Some("RemoteContextMenu"), + ), ]); } @@ -225,25 +266,69 @@ pub fn register_keybindings(cx: &mut App) { KeyBinding::new("escape", SendEscape, Some("TerminalPane")), KeyBinding::new("escape", CloseSearch, Some("SearchBar")), // Terminal rename uses the crate's Cancel action - KeyBinding::new("escape", okena_views_terminal::actions::Cancel, Some("TerminalRename")), + KeyBinding::new( + "escape", + okena_views_terminal::actions::Cancel, + Some("TerminalRename"), + ), // okena-files crate Cancel action for file search/viewer - KeyBinding::new("escape", okena_files::file_search::Cancel, Some("FileSearchDialog")), - KeyBinding::new("escape", okena_files::file_search::Cancel, Some("FileViewer")), + KeyBinding::new( + "escape", + okena_files::file_search::Cancel, + Some("FileSearchDialog"), + ), + KeyBinding::new( + "escape", + okena_files::file_search::Cancel, + Some("FileViewer"), + ), // okena-views-git crate Cancel actions for git overlays KeyBinding::new("escape", okena_views_git::Cancel, Some("WorktreeDialog")), - KeyBinding::new("escape", okena_views_git::Cancel, Some("CloseWorktreeDialog")), - KeyBinding::new("escape", okena_views_git::diff_viewer::Cancel, Some("DiffViewer")), + KeyBinding::new( + "escape", + okena_views_git::Cancel, + Some("CloseWorktreeDialog"), + ), + KeyBinding::new( + "escape", + okena_views_git::diff_viewer::Cancel, + Some("DiffViewer"), + ), // okena-views-sidebar crate Cancel actions for context menus KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("ContextMenu")), - KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("FolderContextMenu")), - KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("RenameDirectoryDialog")), + KeyBinding::new( + "escape", + okena_views_sidebar::Cancel, + Some("FolderContextMenu"), + ), + KeyBinding::new( + "escape", + okena_views_sidebar::Cancel, + Some("RenameDirectoryDialog"), + ), KeyBinding::new("escape", okena_views_sidebar::Cancel, Some("HookLog")), // okena-views-terminal crate Cancel for shell selector - KeyBinding::new("escape", okena_views_terminal::actions::Cancel, Some("ShellSelectorOverlay")), + KeyBinding::new( + "escape", + okena_views_terminal::actions::Cancel, + Some("ShellSelectorOverlay"), + ), // okena-views-remote crate Cancel actions - KeyBinding::new("escape", okena_views_remote::Cancel, Some("RemoteConnectDialog")), - KeyBinding::new("escape", okena_views_remote::Cancel, Some("RemotePairDialog")), - KeyBinding::new("escape", okena_views_remote::Cancel, Some("RemoteContextMenu")), + KeyBinding::new( + "escape", + okena_views_remote::Cancel, + Some("RemoteConnectDialog"), + ), + KeyBinding::new( + "escape", + okena_views_remote::Cancel, + Some("RemotePairDialog"), + ), + KeyBinding::new( + "escape", + okena_views_remote::Cancel, + Some("RemoteContextMenu"), + ), ]); } @@ -281,8 +366,12 @@ fn create_keybinding(action: &str, keystroke: &str, context: Option<&str>) -> Op "ToggleSidebar" => Some(KeyBinding::new(keystroke, ToggleSidebar, context)), "ToggleSidebarAutoHide" => Some(KeyBinding::new(keystroke, ToggleSidebarAutoHide, context)), "ToggleFullscreen" => Some(KeyBinding::new(keystroke, ToggleFullscreen, context)), - "FullscreenNextTerminal" => Some(KeyBinding::new(keystroke, FullscreenNextTerminal, context)), - "FullscreenPrevTerminal" => Some(KeyBinding::new(keystroke, FullscreenPrevTerminal, context)), + "FullscreenNextTerminal" => { + Some(KeyBinding::new(keystroke, FullscreenNextTerminal, context)) + } + "FullscreenPrevTerminal" => { + Some(KeyBinding::new(keystroke, FullscreenPrevTerminal, context)) + } "SplitVertical" => Some(KeyBinding::new(keystroke, SplitVertical, context)), "SplitHorizontal" => Some(KeyBinding::new(keystroke, SplitHorizontal, context)), "AddTab" => Some(KeyBinding::new(keystroke, AddTab, context)), @@ -307,8 +396,14 @@ fn create_keybinding(action: &str, keystroke: &str, context: Option<&str>) -> Op "SearchPrev" => Some(KeyBinding::new(keystroke, SearchPrev, context)), "JumpToPreviousPrompt" => Some(KeyBinding::new(keystroke, JumpToPreviousPrompt, context)), "JumpToNextPrompt" => Some(KeyBinding::new(keystroke, JumpToNextPrompt, context)), - "JumpToPreviousFailedCommand" => Some(KeyBinding::new(keystroke, JumpToPreviousFailedCommand, context)), - "JumpToNextFailedCommand" => Some(KeyBinding::new(keystroke, JumpToNextFailedCommand, context)), + "JumpToPreviousFailedCommand" => Some(KeyBinding::new( + keystroke, + JumpToPreviousFailedCommand, + context, + )), + "JumpToNextFailedCommand" => { + Some(KeyBinding::new(keystroke, JumpToNextFailedCommand, context)) + } "CloseSearch" => Some(KeyBinding::new(keystroke, CloseSearch, context)), "ShowKeybindings" => Some(KeyBinding::new(keystroke, ShowKeybindings, context)), "ShowSessionManager" => Some(KeyBinding::new(keystroke, ShowSessionManager, context)), diff --git a/crates/okena-app/src/keybindings/types.rs b/crates/okena-app/src/keybindings/types.rs index 9845bde71..9b06209bd 100644 --- a/crates/okena-app/src/keybindings/types.rs +++ b/crates/okena-app/src/keybindings/types.rs @@ -26,7 +26,6 @@ impl KeybindingEntry { enabled: true, } } - } /// Represents a conflict between two keybindings diff --git a/crates/okena-app/src/logging.rs b/crates/okena-app/src/logging.rs index 74a02ab9b..89b9be794 100644 --- a/crates/okena-app/src/logging.rs +++ b/crates/okena-app/src/logging.rs @@ -17,8 +17,8 @@ //! are not compiled out in release.) use std::collections::VecDeque; -use std::sync::{Mutex, OnceLock}; use std::sync::Arc; +use std::sync::{Mutex, OnceLock}; use arc_swap::ArcSwap; use env_filter::Filter; @@ -111,7 +111,10 @@ impl LogHub { /// The directive string currently in effect. pub fn directives(&self) -> String { - self.directives.lock().map(|d| d.clone()).unwrap_or_default() + self.directives + .lock() + .map(|d| d.clone()) + .unwrap_or_default() } /// Clone every buffered line with `seq >= since` (use `0` for the full @@ -120,7 +123,11 @@ impl LogHub { let Ok(ring) = self.ring.lock() else { return Vec::new(); }; - ring.buf.iter().filter(|l| l.seq >= since).cloned().collect() + ring.buf + .iter() + .filter(|l| l.seq >= since) + .cloned() + .collect() } /// Seq the next captured line will get — i.e. one past the newest line. @@ -220,7 +227,11 @@ mod tests { rec(&hub, log::Level::Trace, "okena::cmd", "after"); // The set_capture_filter info line is on target okena::log, which the // `okena::cmd=trace` directive does not admit, so only "after" lands. - let msgs: Vec = hub.snapshot_since(0).into_iter().map(|l| l.message).collect(); + let msgs: Vec = hub + .snapshot_since(0) + .into_iter() + .map(|l| l.message) + .collect(); assert!(msgs.contains(&"after".to_string())); assert!(!msgs.contains(&"before".to_string())); } @@ -232,7 +243,11 @@ mod tests { rec(&hub, log::Level::Info, "okena", "b"); let cursor = hub.next_seq(); rec(&hub, log::Level::Info, "okena", "c"); - let new: Vec = hub.snapshot_since(cursor).into_iter().map(|l| l.message).collect(); + let new: Vec = hub + .snapshot_since(cursor) + .into_iter() + .map(|l| l.message) + .collect(); assert_eq!(new, vec!["c"]); } diff --git a/crates/okena-app/src/simple_root.rs b/crates/okena-app/src/simple_root.rs index 4cd28d707..d6b935553 100644 --- a/crates/okena-app/src/simple_root.rs +++ b/crates/okena-app/src/simple_root.rs @@ -18,9 +18,7 @@ pub struct SimpleRoot { impl SimpleRoot { pub fn new(view: impl Into, _window: &mut Window, _cx: &mut Context) -> Self { - Self { - view: view.into(), - } + Self { view: view.into() } } } @@ -31,69 +29,108 @@ impl Render for SimpleRoot { div() .id("simple-root") .size_full() - .when(matches!(decorations, Decorations::Client { .. }), |div: gpui::Stateful| { - div.child( - canvas( - |_bounds, window, _cx| { - let size = window.window_bounds().get_bounds().size; - let e = RESIZE_EDGE_SIZE; - // Create 4 edge-only hitboxes (top, bottom, left, right strips) - [ - window.insert_hitbox( - Bounds::new(point(px(0.0), px(0.0)), Size { width: size.width, height: e }), - HitboxBehavior::Normal, - ), - window.insert_hitbox( - Bounds::new(point(px(0.0), size.height - e), Size { width: size.width, height: e }), - HitboxBehavior::Normal, - ), - window.insert_hitbox( - Bounds::new(point(px(0.0), e), Size { width: e, height: size.height - e * 2.0 }), - HitboxBehavior::Normal, - ), - window.insert_hitbox( - Bounds::new(point(size.width - e, e), Size { width: e, height: size.height - e * 2.0 }), - HitboxBehavior::Normal, - ), - ] - }, - move |_bounds, hitboxes: [Hitbox; 4], window, _cx| { - let mouse = window.mouse_position(); - let size = window.window_bounds().get_bounds().size; - if let Some(edge) = detect_resize_edge(mouse, size) { - let cursor = match edge { - ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown, - ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight, - ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight, - ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft, - }; - for hitbox in &hitboxes { - window.set_cursor_style(cursor, hitbox); - } - } - - // Handle mouse down on edge hitboxes for resize - let hitbox_ids: [_; 4] = std::array::from_fn(|i| hitboxes[i].id); - window.on_mouse_event(move |e: &MouseDownEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble || e.button != MouseButton::Left { - return; - } - // Only act if mouse is over one of the edge hitboxes - if !hitbox_ids.iter().any(|id| id.is_hovered(window)) { - return; - } + .when( + matches!(decorations, Decorations::Client { .. }), + |div: gpui::Stateful| { + div.child( + canvas( + |_bounds, window, _cx| { + let size = window.window_bounds().get_bounds().size; + let e = RESIZE_EDGE_SIZE; + // Create 4 edge-only hitboxes (top, bottom, left, right strips) + [ + window.insert_hitbox( + Bounds::new( + point(px(0.0), px(0.0)), + Size { + width: size.width, + height: e, + }, + ), + HitboxBehavior::Normal, + ), + window.insert_hitbox( + Bounds::new( + point(px(0.0), size.height - e), + Size { + width: size.width, + height: e, + }, + ), + HitboxBehavior::Normal, + ), + window.insert_hitbox( + Bounds::new( + point(px(0.0), e), + Size { + width: e, + height: size.height - e * 2.0, + }, + ), + HitboxBehavior::Normal, + ), + window.insert_hitbox( + Bounds::new( + point(size.width - e, e), + Size { + width: e, + height: size.height - e * 2.0, + }, + ), + HitboxBehavior::Normal, + ), + ] + }, + move |_bounds, hitboxes: [Hitbox; 4], window, _cx| { + let mouse = window.mouse_position(); let size = window.window_bounds().get_bounds().size; - if let Some(edge) = detect_resize_edge(e.position, size) { - window.start_window_resize(edge); - cx.stop_propagation(); + if let Some(edge) = detect_resize_edge(mouse, size) { + let cursor = match edge { + ResizeEdge::Top | ResizeEdge::Bottom => { + CursorStyle::ResizeUpDown + } + ResizeEdge::Left | ResizeEdge::Right => { + CursorStyle::ResizeLeftRight + } + ResizeEdge::TopLeft | ResizeEdge::BottomRight => { + CursorStyle::ResizeUpLeftDownRight + } + ResizeEdge::TopRight | ResizeEdge::BottomLeft => { + CursorStyle::ResizeUpRightDownLeft + } + }; + for hitbox in &hitboxes { + window.set_cursor_style(cursor, hitbox); + } } - }); - }, + + // Handle mouse down on edge hitboxes for resize + let hitbox_ids: [_; 4] = std::array::from_fn(|i| hitboxes[i].id); + window.on_mouse_event( + move |e: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble + || e.button != MouseButton::Left + { + return; + } + // Only act if mouse is over one of the edge hitboxes + if !hitbox_ids.iter().any(|id| id.is_hovered(window)) { + return; + } + let size = window.window_bounds().get_bounds().size; + if let Some(edge) = detect_resize_edge(e.position, size) { + window.start_window_resize(edge); + cx.stop_propagation(); + } + }, + ); + }, + ) + .size_full() + .absolute(), ) - .size_full() - .absolute(), - ) - }) + }, + ) // NOTE: do NOT wrap the view in `.cached()`. The root is the common // ancestor of every view, so any descendant invalidation marks it // dirty (GPUI ancestor-marking) — its cache would never hit. Worse, @@ -107,10 +144,7 @@ impl Render for SimpleRoot { } } -fn detect_resize_edge( - pos: Point, - size: Size, -) -> Option { +fn detect_resize_edge(pos: Point, size: Size) -> Option { let edge_size = RESIZE_EDGE_SIZE; let edge = if pos.y < edge_size && pos.x < edge_size { ResizeEdge::TopLeft diff --git a/crates/okena-app/src/soft_close.rs b/crates/okena-app/src/soft_close.rs index b248e5d5a..0a612f478 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::{ + SOFT_CLOSE_KILL_PREFIX as KILL_PREFIX, SOFT_CLOSE_UNDO_PREFIX as UNDO_PREFIX, decode_action, +}; + +/// 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/theme/mod.rs b/crates/okena-app/src/theme/mod.rs index ddb58fcc9..3fdfcd81c 100644 --- a/crates/okena-app/src/theme/mod.rs +++ b/crates/okena-app/src/theme/mod.rs @@ -3,11 +3,9 @@ // Re-export everything from okena-theme #[allow(unused_imports)] pub use okena_theme::{ - ThemeColors, ThemeInfo, ThemeMode, FolderColor, - DARK_THEME, LIGHT_THEME, PASTEL_DARK_THEME, HIGH_CONTRAST_THEME, - with_alpha, ansi_to_hsla, - AppTheme, GlobalTheme, theme_entity, - CustomThemeConfig, CustomThemeColors, get_themes_dir, load_custom_themes, + AppTheme, CustomThemeColors, CustomThemeConfig, DARK_THEME, FolderColor, GlobalTheme, + HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, ThemeColors, ThemeInfo, ThemeMode, + ansi_to_hsla, get_themes_dir, load_custom_themes, theme_entity, with_alpha, }; use gpui::*; diff --git a/crates/okena-app/src/views/chrome/header_buttons.rs b/crates/okena-app/src/views/chrome/header_buttons.rs index e69de29bb..8b1378917 100644 --- a/crates/okena-app/src/views/chrome/header_buttons.rs +++ b/crates/okena-app/src/views/chrome/header_buttons.rs @@ -0,0 +1 @@ + diff --git a/crates/okena-app/src/views/chrome/title_bar.rs b/crates/okena-app/src/views/chrome/title_bar.rs index bc692f3df..7a710077d 100644 --- a/crates/okena-app/src/views/chrome/title_bar.rs +++ b/crates/okena-app/src/views/chrome/title_bar.rs @@ -1,10 +1,13 @@ -use crate::keybindings::{CloseWindow, Quit, ShowCommandPalette, ShowKeybindings, ShowSettings, ShowThemeSelector, ToggleSidebar}; +use crate::keybindings::{ + CloseWindow, Quit, ShowCommandPalette, ShowKeybindings, ShowSettings, ShowThemeSelector, + ToggleSidebar, +}; use crate::theme::theme; use crate::ui::tokens::{ui_text, ui_text_sm, ui_text_xl}; use crate::views::components::menu_item; +use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; -use gpui::prelude::*; /// Window control button types #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,9 +29,7 @@ pub struct TitleBar { } impl TitleBar { - pub fn new( - title: impl Into, - ) -> Self { + pub fn new(title: impl Into) -> Self { Self { title: title.into(), menu_open: false, @@ -73,10 +74,13 @@ impl TitleBar { .id("app-menu-backdrop") .absolute() .inset_0() - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - cx.stop_propagation(); - this.close_menu(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + cx.stop_propagation(); + this.close_menu(cx); + }), + ) .on_mouse_move(|_, _, cx| { cx.stop_propagation(); }) @@ -107,43 +111,49 @@ impl TitleBar { ) // Theme .child( - menu_item("app-menu-theme", "icons/eye.svg", "Select Theme", &t) - .on_click(cx.listener(|this, _, window, cx| { + menu_item("app-menu-theme", "icons/eye.svg", "Select Theme", &t).on_click( + cx.listener(|this, _, window, cx| { this.close_menu(cx); window.dispatch_action(Box::new(ShowThemeSelector), cx); - })), + }), + ), ) // Command Palette .child( - menu_item("app-menu-command-palette", "icons/search.svg", "Command Palette", &t) - .on_click(cx.listener(|this, _, window, cx| { - this.close_menu(cx); - window.dispatch_action(Box::new(ShowCommandPalette), cx); - })), + menu_item( + "app-menu-command-palette", + "icons/search.svg", + "Command Palette", + &t, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.close_menu(cx); + window.dispatch_action(Box::new(ShowCommandPalette), cx); + })), ) // Keyboard Shortcuts .child( - menu_item("app-menu-keybindings", "icons/keyboard.svg", "Keyboard Shortcuts", &t) - .on_click(cx.listener(|this, _, window, cx| { - this.close_menu(cx); - window.dispatch_action(Box::new(ShowKeybindings), cx); - })), + menu_item( + "app-menu-keybindings", + "icons/keyboard.svg", + "Keyboard Shortcuts", + &t, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.close_menu(cx); + window.dispatch_action(Box::new(ShowKeybindings), cx); + })), ) // Separator - .child( - div() - .h(px(1.0)) - .mx(px(8.0)) - .my(px(4.0)) - .bg(rgb(t.border)), - ) + .child(div().h(px(1.0)).mx(px(8.0)).my(px(4.0)).bg(rgb(t.border))) // Exit .child( - menu_item("app-menu-exit", "icons/close.svg", "Exit", &t) - .on_click(cx.listener(|this, _, window, cx| { + menu_item("app-menu-exit", "icons/close.svg", "Exit", &t).on_click( + cx.listener(|this, _, window, cx| { this.close_menu(cx); window.dispatch_action(Box::new(Quit), cx); - })), + }), + ), ), ) } @@ -178,7 +188,9 @@ impl TitleBar { }; div() - .id(ElementId::Name(format!("window-control-{:?}", control_type).into())) + .id(ElementId::Name( + format!("window-control-{:?}", control_type).into(), + )) .cursor_pointer() .w(px(46.0)) // Windows standard caption button width .h(px(32.0)) // Match titlebar height @@ -190,33 +202,30 @@ impl TitleBar { .when(is_close, |d| { d.hover(|s| s.bg(rgb(0xE81123)).text_color(rgb(0xffffff))) }) - .when(!is_close, |d| { - d.hover(|s| s.bg(rgb(t.bg_hover))) - }) + .when(!is_close, |d| d.hover(|s| s.bg(rgb(t.bg_hover)))) .child(icon) .when_some(control_area, |d, area| { // occlude() prevents parent Drag hitbox from shadowing button hit tests d.occlude().window_control_area(area) }) .when(control_area.is_none(), |d| { - d - .on_mouse_down(MouseButton::Left, |_, _, cx| { + d.on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click({ + move |_, window, cx| { cx.stop_propagation(); - }) - .on_click({ - move |_, window, cx| { - cx.stop_propagation(); - match control_type { - WindowControlType::Minimize => window.minimize_window(), - WindowControlType::Maximize | WindowControlType::Restore => { - window.zoom_window(); - } - WindowControlType::Close => { - window.dispatch_action(Box::new(CloseWindow), cx); - } + match control_type { + WindowControlType::Minimize => window.minimize_window(), + WindowControlType::Maximize | WindowControlType::Restore => { + window.zoom_window(); + } + WindowControlType::Close => { + window.dispatch_action(Box::new(CloseWindow), cx); } } - }) + } + }) }) } } @@ -270,45 +279,64 @@ impl Render for TitleBar { // move from mouse movement after the initial press. Deferring the // move keeps double-click behavior available. .when(cfg!(any(target_os = "linux", target_os = "macos")), |d| { - d - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, _cx| { - #[cfg(any(target_os = "linux", target_os = "macos"))] - { this.should_move = true; } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { let _ = this; } - })) - // Clear a pending move if the press ends elsewhere (mouse-up - // missed off-element), so a later bare hover can't trigger a - // spurious window move. Mirrors Zed's own title bar. - .on_mouse_down_out(cx.listener(|this, _, _, _cx| { + d.on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, _cx| { #[cfg(any(target_os = "linux", target_os = "macos"))] - { this.should_move = false; } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { let _ = this; } - })) - .on_mouse_up(MouseButton::Left, cx.listener(|this, _, _, _cx| { - #[cfg(any(target_os = "linux", target_os = "macos"))] - { this.should_move = false; } + { + this.should_move = true; + } #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { let _ = this; } - })) - .on_mouse_move(cx.listener(|this, _, window, _cx| { + { + let _ = this; + } + }), + ) + // Clear a pending move if the press ends elsewhere (mouse-up + // missed off-element), so a later bare hover can't trigger a + // spurious window move. Mirrors Zed's own title bar. + .on_mouse_down_out(cx.listener(|this, _, _, _cx| { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + this.should_move = false; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = this; + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _cx| { #[cfg(any(target_os = "linux", target_os = "macos"))] - if this.should_move { + { this.should_move = false; - window.start_window_move(); } #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { let _ = (this, window); } - })) - .on_click(|event: &ClickEvent, window, _| { - if event.click_count() == 2 { - #[cfg(target_os = "macos")] - window.titlebar_double_click(); - #[cfg(target_os = "linux")] - window.zoom_window(); + { + let _ = this; } - }) + }), + ) + .on_mouse_move(cx.listener(|this, _, window, _cx| { + #[cfg(any(target_os = "linux", target_os = "macos"))] + if this.should_move { + this.should_move = false; + window.start_window_move(); + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (this, window); + } + })) + .on_click(|event: &ClickEvent, window, _| { + if event.click_count() == 2 { + #[cfg(target_os = "macos")] + window.titlebar_double_click(); + #[cfg(target_os = "linux")] + window.zoom_window(); + } + }) }) .child( // Left side - sidebar toggle + title @@ -384,26 +412,33 @@ impl Render for TitleBar { ) .child( // Center - spacer - div().flex_1() + div().flex_1(), ) .child( // Right side - window controls - h_flex() - .gap(px(8.0)) - .pr(px(4.0)) - .when(needs_controls, |d| { - d.child( - h_flex() - .gap(px(2.0)) - .child(self.render_window_control(WindowControlType::Minimize, window, cx)) - .child(if is_maximized { - self.render_window_control(WindowControlType::Restore, window, cx).into_any_element() - } else { - self.render_window_control(WindowControlType::Maximize, window, cx).into_any_element() - }) - .child(self.render_window_control(WindowControlType::Close, window, cx)) - ) - }), + h_flex().gap(px(8.0)).pr(px(4.0)).when(needs_controls, |d| { + d.child( + h_flex() + .gap(px(2.0)) + .child(self.render_window_control( + WindowControlType::Minimize, + window, + cx, + )) + .child(if is_maximized { + self.render_window_control(WindowControlType::Restore, window, cx) + .into_any_element() + } else { + self.render_window_control(WindowControlType::Maximize, window, cx) + .into_any_element() + }) + .child(self.render_window_control( + WindowControlType::Close, + window, + cx, + )), + ) + }), ) } } diff --git a/crates/okena-app/src/views/components/mod.rs b/crates/okena-app/src/views/components/mod.rs index 2966179a1..a66d48af9 100644 --- a/crates/okena-app/src/views/components/mod.rs +++ b/crates/okena-app/src/views/components/mod.rs @@ -19,10 +19,13 @@ pub mod ui_helpers; pub use dropdown::{dropdown_anchored_below, dropdown_button, dropdown_option, dropdown_overlay}; pub use list_overlay::{ - handle_list_overlay_key, substring_filter, ListOverlayAction, ListOverlayConfig, - ListOverlayState, + ListOverlayAction, ListOverlayConfig, ListOverlayState, handle_list_overlay_key, + substring_filter, }; pub use modal_backdrop::{modal_backdrop, modal_content, modal_header}; -pub use ui_helpers::{badge, button, input_container, keyboard_hints_footer, labeled_input, menu_item, search_input_area, search_input_area_selected}; pub use path_autocomplete::PathAutoCompleteState; pub use simple_input::{SimpleInput, SimpleInputState}; +pub use ui_helpers::{ + badge, button, input_container, keyboard_hints_footer, labeled_input, menu_item, + search_input_area, search_input_area_selected, +}; diff --git a/crates/okena-app/src/views/components/path_autocomplete.rs b/crates/okena-app/src/views/components/path_autocomplete.rs index 27779d98c..61a8ccba3 100644 --- a/crates/okena-app/src/views/components/path_autocomplete.rs +++ b/crates/okena-app/src/views/components/path_autocomplete.rs @@ -1,6 +1,6 @@ use crate::theme::theme; -use crate::views::components::simple_input::{InputChangedEvent, SimpleInput, SimpleInputState}; use crate::ui::tokens::ui_text_md; +use crate::views::components::simple_input::{InputChangedEvent, SimpleInput, SimpleInputState}; use gpui::prelude::*; use gpui::*; use std::path::PathBuf; @@ -29,22 +29,25 @@ pub struct PathAutoCompleteState { suggestions_scroll: ScrollHandle, /// When true, suppress the next InputChangedEvent from triggering suggestions suppress_suggestions: bool, + /// Local completion is valid only when the selected daemon shares this disk. + local_completion_enabled: bool, } impl PathAutoCompleteState { pub fn new(cx: &mut Context) -> Self { - let input = cx.new(|cx| { - SimpleInputState::new(cx) - .placeholder("Enter path...") - }); + let input = cx.new(|cx| SimpleInputState::new(cx).placeholder("Enter path...")); let focus_handle = cx.focus_handle(); // Subscribe to input changes to update suggestions let input_for_subscription = input.clone(); - cx.subscribe(&input_for_subscription, |this, _, _event: &InputChangedEvent, cx| { - this.on_input_changed(cx); - }).detach(); + cx.subscribe( + &input_for_subscription, + |this, _, _event: &InputChangedEvent, cx| { + this.on_input_changed(cx); + }, + ) + .detach(); Self { input, @@ -54,6 +57,19 @@ impl PathAutoCompleteState { focus_handle, suggestions_scroll: ScrollHandle::new(), suppress_suggestions: false, + local_completion_enabled: true, + } + } + + pub fn set_local_completion_enabled(&mut self, enabled: bool, cx: &mut Context) { + if self.local_completion_enabled == enabled { + return; + } + self.local_completion_enabled = enabled; + if enabled { + self.update_suggestions(cx); + } else { + self.hide_suggestions(cx); } } @@ -119,10 +135,11 @@ impl PathAutoCompleteState { /// Expand ~ to home directory fn expand_path(path: &str) -> String { if path.starts_with('~') - && let Some(home) = dirs::home_dir() { - let rest = path.strip_prefix('~').unwrap_or(""); - return format!("{}{}", home.display(), rest); - } + && let Some(home) = dirs::home_dir() + { + let rest = path.strip_prefix('~').unwrap_or(""); + return format!("{}{}", home.display(), rest); + } path.to_string() } @@ -139,7 +156,10 @@ impl PathAutoCompleteState { (path_buf, String::new()) } else { // Get parent directory and use filename as prefix filter - let parent = path_buf.parent().map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")); + let parent = path_buf + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); let prefix = path_buf .file_name() .and_then(|n| n.to_str()) @@ -158,6 +178,10 @@ impl PathAutoCompleteState { } fn update_suggestions(&mut self, cx: &mut Context) { + if !self.local_completion_enabled { + self.hide_suggestions(cx); + return; + } let current_value = self.input.read(cx).value().to_string(); // Don't show suggestions for empty input @@ -186,6 +210,9 @@ impl PathAutoCompleteState { if this.input.read(cx).value() != current_value { return; } + if !this.local_completion_enabled { + return; + } this.suggestions = new_suggestions; this.show_suggestions = !this.suggestions.is_empty(); this.selected_index = 0; @@ -197,7 +224,11 @@ impl PathAutoCompleteState { /// Build the suggestion list for `current_value`. Performs blocking filesystem /// IO (`read_dir`, `is_dir`), so this must run off the GPUI main thread. - fn compute_suggestions(dir_path: PathBuf, prefix: String, current_value: String) -> Vec { + fn compute_suggestions( + dir_path: PathBuf, + prefix: String, + current_value: String, + ) -> Vec { let mut new_suggestions = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir_path) { @@ -244,12 +275,13 @@ impl PathAutoCompleteState { } // Sort: directories first, then alphabetically - new_suggestions.sort_by(|a, b| { - match (a.is_directory, b.is_directory) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()), - } + new_suggestions.sort_by(|a, b| match (a.is_directory, b.is_directory) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a + .display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()), }); // Limit suggestions @@ -259,12 +291,15 @@ impl PathAutoCompleteState { let expanded = Self::expand_path(¤t_value); let expanded_path = PathBuf::from(&expanded); if expanded_path.is_dir() && !new_suggestions.is_empty() { - new_suggestions.insert(0, PathSuggestion { - display_name: "Select this folder".to_string(), - full_path: current_value.clone(), - is_directory: true, - is_select_current: true, - }); + new_suggestions.insert( + 0, + PathSuggestion { + display_name: "Select this folder".to_string(), + full_path: current_value.clone(), + is_directory: true, + is_select_current: true, + }, + ); } new_suggestions @@ -272,7 +307,9 @@ impl PathAutoCompleteState { fn complete_selected(&mut self, cx: &mut Context) { // Clone suggestion data before borrowing self mutably - let suggestion_data = self.suggestions.get(self.selected_index) + let suggestion_data = self + .suggestions + .get(self.selected_index) .map(|s| (s.full_path.clone(), s.is_directory, s.is_select_current)); if let Some((full_path, is_directory, is_select_current)) = suggestion_data { @@ -338,11 +375,13 @@ impl PathAutoCompleteState { // Scroll down if selected item is at or below visible area if selected_bottom > scroll_bottom { let new_scroll = selected_bottom - visible_height; - self.suggestions_scroll.set_offset(point(zero, zero - new_scroll)); + self.suggestions_scroll + .set_offset(point(zero, zero - new_scroll)); } // Scroll up if selected item is above visible area else if selected_top < scroll_top { - self.suggestions_scroll.set_offset(point(zero, zero - selected_top)); + self.suggestions_scroll + .set_offset(point(zero, zero - selected_top)); } } @@ -357,31 +396,26 @@ impl PathAutoCompleteState { let key = event.keystroke.key.as_str(); match key { - "tab" - if self.show_suggestions && !self.suggestions.is_empty() => { - self.complete_selected(cx); - return true; - } - "up" - if self.show_suggestions => { - self.select_previous(cx); - return true; - } - "down" - if self.show_suggestions => { - self.select_next(cx); - return true; - } - "escape" - if self.show_suggestions => { - self.hide_suggestions(cx); - return true; - } - "enter" - if self.show_suggestions && !self.suggestions.is_empty() => { - self.complete_selected(cx); - return true; - } + "tab" if self.show_suggestions && !self.suggestions.is_empty() => { + self.complete_selected(cx); + return true; + } + "up" if self.show_suggestions => { + self.select_previous(cx); + return true; + } + "down" if self.show_suggestions => { + self.select_next(cx); + return true; + } + "escape" if self.show_suggestions => { + self.hide_suggestions(cx); + return true; + } + "enter" if self.show_suggestions && !self.suggestions.is_empty() => { + self.complete_selected(cx); + return true; + } _ => {} } @@ -409,10 +443,7 @@ impl Render for PathAutoCompleteState { .border_1() .border_color(rgb(t.border)) .rounded(px(4.0)) - .child( - SimpleInput::new(&self.input) - .text_size(ui_text_md(cx)) - ) + .child(SimpleInput::new(&self.input).text_size(ui_text_md(cx))), ) } } diff --git a/crates/okena-app/src/views/components/ui_helpers.rs b/crates/okena-app/src/views/components/ui_helpers.rs index 5cd602418..8dc108e76 100644 --- a/crates/okena-app/src/views/components/ui_helpers.rs +++ b/crates/okena-app/src/views/components/ui_helpers.rs @@ -2,5 +2,7 @@ pub use okena_ui::badge::{badge, keyboard_hints_footer}; pub use okena_ui::button::button; -pub use okena_ui::input::{input_container, labeled_input, search_input_area, search_input_area_selected}; +pub use okena_ui::input::{ + input_container, labeled_input, search_input_area, search_input_area_selected, +}; pub use okena_ui::menu::menu_item; diff --git a/crates/okena-app/src/views/mod.rs b/crates/okena-app/src/views/mod.rs index 4cfa735ba..b4dbeb419 100644 --- a/crates/okena-app/src/views/mod.rs +++ b/crates/okena-app/src/views/mod.rs @@ -17,6 +17,6 @@ pub mod overlay_manager; pub mod overlays; pub mod panels; pub mod project_hover; +pub mod sidebar_controller; pub mod tips; pub mod window; -pub mod sidebar_controller; diff --git a/crates/okena-app/src/views/overlay_manager.rs b/crates/okena-app/src/views/overlay_manager.rs index cffd2d0f8..02031d437 100644 --- a/crates/okena-app/src/views/overlay_manager.rs +++ b/crates/okena-app/src/views/overlay_manager.rs @@ -5,41 +5,52 @@ use gpui::*; - +use crate::remote_client::manager::RemoteConnectionManager; use crate::terminal::shell_config::ShellType; -use crate::views::overlays::command_palette::{CommandPalette, CommandPaletteEvent}; -use crate::views::overlays::keybindings_help::{KeybindingsHelp, KeybindingsHelpEvent}; use crate::views::overlays::add_project_dialog::{AddProjectDialog, AddProjectDialogEvent}; -use crate::views::overlays::context_menu::{ContextMenu, ContextMenuEvent}; -use crate::views::overlays::folder_context_menu::{FolderContextMenu, FolderContextMenuEvent}; +use crate::views::overlays::close_worktree_dialog::{ + CloseWorktreeDialog, CloseWorktreeDialogEvent, +}; +use crate::views::overlays::command_palette::{CommandPalette, CommandPaletteEvent}; use crate::views::overlays::content_search::{ContentSearchDialog, ContentSearchDialogEvent}; -use crate::views::overlays::file_search::{FileSearchDialog, FileSearchDialogEvent}; +use crate::views::overlays::context_menu::{ContextMenu, ContextMenuEvent}; use crate::views::overlays::diff_viewer::{DiffViewer, DiffViewerEvent}; +use crate::views::overlays::file_search::{FileSearchDialog, FileSearchDialogEvent}; use crate::views::overlays::file_viewer::{FileViewer, FileViewerEvent}; -use crate::views::overlays::{ProjectSwitcher, ProjectSwitcherEvent, ShellSelectorOverlay, ShellSelectorOverlayEvent}; -use crate::views::overlays::session_manager::{SessionManager, SessionManagerEvent}; +use crate::views::overlays::folder_context_menu::{FolderContextMenu, FolderContextMenuEvent}; +use crate::views::overlays::hook_log::{HookLog, HookLogEvent}; +use crate::views::overlays::keybindings_help::{KeybindingsHelp, KeybindingsHelpEvent}; +use crate::views::overlays::log_console::{LogConsole, LogConsoleEvent}; +use crate::views::overlays::pairing_dialog::{PairingDialog, PairingDialogEvent, PairingEndpoint}; 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::remote_connect_dialog::{RemoteConnectDialog, RemoteConnectDialogEvent}; -use crate::views::overlays::remote_pair_dialog::{RemotePairDialog, RemotePairDialogEvent}; +use crate::views::overlays::remote_connect_dialog::{ + RemoteConnectDialog, RemoteConnectDialogEvent, +}; use crate::views::overlays::remote_context_menu::{RemoteContextMenu, RemoteContextMenuEvent}; +use crate::views::overlays::remote_pair_dialog::{RemotePairDialog, RemotePairDialogEvent}; +use crate::views::overlays::rename_directory_dialog::{ + RenameDirectoryDialog, RenameDirectoryDialogEvent, +}; +use crate::views::overlays::session_manager::{SessionManager, SessionManagerEvent}; +use crate::views::overlays::settings_panel::{SettingsPanel, SettingsPanelEvent}; use crate::views::overlays::tab_context_menu::{TabContextMenu, TabContextMenuEvent}; -use crate::views::overlays::terminal_context_menu::{TerminalContextMenu, TerminalContextMenuEvent}; -use crate::views::overlays::close_worktree_dialog::{CloseWorktreeDialog, CloseWorktreeDialogEvent}; -use crate::views::overlays::hook_log::{HookLog, HookLogEvent}; -use crate::views::overlays::log_console::{LogConsole, LogConsoleEvent}; -use crate::views::overlays::rename_directory_dialog::{RenameDirectoryDialog, RenameDirectoryDialogEvent}; +use crate::views::overlays::terminal_context_menu::{ + TerminalContextMenu, TerminalContextMenuEvent, +}; +use crate::views::overlays::theme_selector::{ThemeSelector, ThemeSelectorEvent}; use crate::views::overlays::worktree_dialog::{WorktreeDialog, WorktreeDialogEvent}; -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::views::overlays::{ + ProjectSwitcher, ProjectSwitcherEvent, ShellSelectorOverlay, ShellSelectorOverlayEvent, +}; 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::requests::{ + ContextMenuRequest, FolderContextMenuRequest, OverlayRequest, ProjectOverlay, + ProjectOverlayKind, SidebarRequest, +}; +use crate::workspace::state::{WindowId, Workspace}; +use okena_transport::client::RemoteConnectionConfig; +use okena_views_sidebar::{ColorPickerPopover, ColorPickerPopoverEvent, ColorPickerTarget}; +use okena_views_sidebar::{WorktreeListPopover, WorktreeListPopoverEvent}; // Re-export generic overlay utilities from okena-ui pub use okena_ui::overlay::{CloseEvent, OverlaySlot}; @@ -48,22 +59,34 @@ pub use okena_ui::{open_overlay, toggle_overlay}; // CloseEvent impls for overlay events defined in src/ (local types) impl CloseEvent for AddProjectDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl CloseEvent for KeybindingsHelpEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl CloseEvent for ThemeSelectorEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl CloseEvent for CommandPaletteEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl CloseEvent for SettingsPanelEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl CloseEvent for PairingDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } // ============================================================================ @@ -76,12 +99,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 { @@ -94,20 +132,54 @@ pub enum OverlayManagerEvent { AddTerminal { project_id: String }, /// Context menu: Create worktree from project - CreateWorktree { project_id: String, project_path: String }, + CreateWorktree { project_id: String }, /// Context menu: Rename project - RenameProject { project_id: String, project_name: String }, + RenameProject { + project_id: String, + project_name: String, + }, /// Context menu: Rename directory on disk - RenameDirectory { project_id: String, project_path: String }, + RenameDirectory { + project_id: String, + project_path: String, + }, + + /// 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 + /// Context menu: Close worktree project (opens the confirm dialog) CloseWorktree { project_id: String }, + /// Context menu: Open the daemon-backed worktree list. + ManageWorktrees { + project_id: String, + position: Point, + }, + + /// 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 }, @@ -115,7 +187,19 @@ pub enum OverlayManagerEvent { QuickCreateWorktree { project_id: String }, /// Color picker: project color was changed (for remote sync) - ProjectColorChanged { project_id: String, color: okena_core::theme::FolderColor }, + 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 }, @@ -134,18 +218,22 @@ pub enum OverlayManagerEvent { ToggleProjectVisibility(String), /// Remote connect dialog: connection paired and ready - RemoteConnected { - config: RemoteConnectionConfig, - }, + RemoteConnected { config: RemoteConnectionConfig }, /// Remote context menu: reconnect to a connection RemoteReconnect { connection_id: String }, /// Remote context menu: open pair dialog - RemotePair { connection_id: String, connection_name: String }, + RemotePair { + connection_id: String, + connection_name: String, + }, /// Remote context menu: flip the connection to TLS, then re-pair to pin the cert - RemoteUpgradeToTls { connection_id: String, connection_name: String }, + RemoteUpgradeToTls { + connection_id: String, + connection_name: String, + }, /// Remote pair dialog: user submitted a code RemotePaired { connection_id: String, code: String }, @@ -162,16 +250,35 @@ pub enum OverlayManagerEvent { /// Terminal context menu: select all TerminalSelectAll { terminal_id: String }, /// Terminal context menu: split - TerminalSplit { project_id: String, layout_path: Vec, direction: crate::workspace::state::SplitDirection }, + TerminalSplit { + project_id: String, + layout_path: Vec, + direction: crate::workspace::state::SplitDirection, + }, /// Terminal context menu: close terminal - TerminalClose { project_id: String, terminal_id: String }, + TerminalClose { + project_id: String, + terminal_id: String, + }, /// Tab context menu: close tab - TabClose { project_id: String, layout_path: Vec, tab_index: usize }, + TabClose { + project_id: String, + layout_path: Vec, + tab_index: usize, + }, /// Tab context menu: close other tabs - TabCloseOthers { project_id: String, layout_path: Vec, tab_index: usize }, + TabCloseOthers { + project_id: String, + layout_path: Vec, + tab_index: usize, + }, /// Tab context menu: close tabs to the right - TabCloseToRight { project_id: String, layout_path: Vec, tab_index: usize }, + TabCloseToRight { + project_id: String, + layout_path: Vec, + tab_index: usize, + }, /// File viewer blame click: open the named commit in the diff viewer. OpenCommitFromBlame { project_id: String, hash: String }, @@ -233,7 +340,12 @@ pub struct OverlayManager { impl OverlayManager { /// Create a new OverlayManager. - pub fn new(window_id: WindowId, workspace: Entity, focus_manager: Entity, request_broker: Entity) -> Self { + pub fn new( + window_id: WindowId, + workspace: Entity, + focus_manager: Entity, + request_broker: Entity, + ) -> Self { Self { window_id, workspace, @@ -334,8 +446,8 @@ impl OverlayManager { let title = title.into(); let entity_for_detach = entity.clone(); - self.detach_active_modal_fn = Some(Box::new( - move |this: &mut Self, cx: &mut Context| { + self.detach_active_modal_fn = + Some(Box::new(move |this: &mut Self, cx: &mut Context| { before_detach(this, &entity_for_detach, cx); // Clear modal slot — entity stays alive via the new window. this.active_modal = None; @@ -352,8 +464,7 @@ impl OverlayManager { cx, ); cx.notify(); - }, - )); + })); let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { @@ -439,23 +550,31 @@ impl OverlayManager { self.close_modal(cx); } else { let entity = cx.new(KeybindingsHelp::new); - cx.subscribe(&entity, |this, _, event: &KeybindingsHelpEvent, cx| { - match event { + cx.subscribe( + &entity, + |this, _, event: &KeybindingsHelpEvent, cx| match event { KeybindingsHelpEvent::Close => { this.close_modal(cx); } KeybindingsHelpEvent::ReloadBindings => { crate::keybindings::reload_keybindings(cx); } - } - }).detach(); + }, + ) + .detach(); self.open_modal(entity, cx); } } /// Toggle theme selector overlay. pub fn toggle_theme_selector(&mut self, cx: &mut Context) { - toggle_overlay!(self, cx, ThemeSelector, ThemeSelectorEvent, ThemeSelector::new); + toggle_overlay!( + self, + cx, + ThemeSelector, + ThemeSelectorEvent, + ThemeSelector::new + ); } /// Toggle command palette overlay. @@ -470,10 +589,32 @@ 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,17 +628,21 @@ 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); } - }).detach(); + }) + .detach(); self.open_modal(entity, cx); } } @@ -505,9 +650,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. @@ -518,8 +663,9 @@ impl OverlayManager { let workspace = self.workspace.clone(); let window_id = self.window_id; let entity = cx.new(|cx| ProjectSwitcher::new(window_id, workspace, cx)); - cx.subscribe(&entity, |this, _, event: &ProjectSwitcherEvent, cx| { - match event { + cx.subscribe( + &entity, + |this, _, event: &ProjectSwitcherEvent, cx| match event { ProjectSwitcherEvent::Close => { this.close_modal(cx); } @@ -532,11 +678,13 @@ impl OverlayManager { this.close_modal(cx); } ProjectSwitcherEvent::ToggleVisibility(project_id) => { - cx.emit(OverlayManagerEvent::ToggleProjectVisibility(project_id.clone())); + cx.emit(OverlayManagerEvent::ToggleProjectVisibility( + project_id.clone(), + )); cx.notify(); } - } - }) + }, + ) .detach(); self.open_modal(entity, cx); } @@ -547,19 +695,24 @@ impl OverlayManager { // ======================================================================== /// Toggle session manager overlay. - pub fn toggle_session_manager(&mut self, cx: &mut Context) { + pub fn toggle_session_manager( + &mut self, + client: okena_transport::remote_action::RemoteActionClient, + cx: &mut Context, + ) { 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(|cx| SessionManager::new(client, cx)); 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); } } @@ -579,8 +732,9 @@ impl OverlayManager { self.close_modal(cx); } else { let manager = cx.new(ProfileManager::new); - cx.subscribe(&manager, |this, _, event: &ProfileManagerEvent, cx| { - match event { + cx.subscribe( + &manager, + |this, _, event: &ProfileManagerEvent, cx| match event { ProfileManagerEvent::Close => { this.close_modal(cx); } @@ -588,8 +742,8 @@ impl OverlayManager { cx.emit(OverlayManagerEvent::SwitchProfile(id.clone())); this.close_modal(cx); } - } - }) + }, + ) .detach(); self.open_modal(manager, cx); } @@ -609,12 +763,16 @@ impl OverlayManager { ) { let context = Some((project_id.clone(), terminal_id.clone())); let entity = cx.new(|cx| ShellSelectorOverlay::new(current_shell, context, cx)); - cx.subscribe(&entity, move |this, _, event: &ShellSelectorOverlayEvent, cx| { - match event { + cx.subscribe( + &entity, + move |this, _, event: &ShellSelectorOverlayEvent, cx| match event { ShellSelectorOverlayEvent::Close => { this.close_modal(cx); } - ShellSelectorOverlayEvent::ShellSelected { shell_type, context } => { + ShellSelectorOverlayEvent::ShellSelected { + shell_type, + context, + } => { if let Some((project_id, terminal_id)) = context { cx.emit(OverlayManagerEvent::ShellSelected { shell_type: shell_type.clone(), @@ -624,8 +782,9 @@ impl OverlayManager { } this.close_modal(cx); } - } - }).detach(); + }, + ) + .detach(); self.open_modal(entity, cx); } @@ -637,26 +796,31 @@ impl OverlayManager { pub fn show_worktree_dialog( &mut self, project_id: String, - project_path: String, + params: (okena_transport::remote_action::RemoteActionClient, String), cx: &mut Context, ) { - let workspace = self.workspace.clone(); - let window_id = self.window_id; - let app_settings = crate::settings::settings(cx); - let dialog = cx.new(|cx| { - WorktreeDialog::new(workspace, project_id, project_path, app_settings.worktree, app_settings.hooks, window_id, cx) - }); - cx.subscribe(&dialog, |this, _, event: &WorktreeDialogEvent, cx| { - match event { + let (client, daemon_project_id) = params; + let dialog = cx.new(|cx| WorktreeDialog::new(client, daemon_project_id, project_id, cx)); + cx.subscribe( + &dialog, + |this, _, event: &WorktreeDialogEvent, cx| match event { 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); } - } - }) + }, + ) .detach(); self.open_modal(dialog, cx); } @@ -669,14 +833,35 @@ impl OverlayManager { pub fn show_close_worktree_dialog( &mut self, project_id: String, + params: (okena_transport::remote_action::RemoteActionClient, String), cx: &mut Context, ) { + let (client, daemon_id) = params; 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( + client, + 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 +875,27 @@ 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); } // ======================================================================== @@ -720,21 +922,26 @@ impl OverlayManager { project_id: project_id.clone(), }); } - ContextMenuEvent::CreateWorktree { project_id, project_path } => { + ContextMenuEvent::CreateWorktree { project_id } => { this.hide_context_menu(cx); cx.emit(OverlayManagerEvent::CreateWorktree { project_id: project_id.clone(), - project_path: project_path.clone(), }); } - ContextMenuEvent::RenameProject { project_id, project_name } => { + ContextMenuEvent::RenameProject { + project_id, + project_name, + } => { this.hide_context_menu(cx); cx.emit(OverlayManagerEvent::RenameProject { project_id: project_id.clone(), project_name: project_name.clone(), }); } - ContextMenuEvent::RenameDirectory { project_id, project_path } => { + ContextMenuEvent::RenameDirectory { + project_id, + project_path, + } => { this.hide_context_menu(cx); cx.emit(OverlayManagerEvent::RenameDirectory { project_id: project_id.clone(), @@ -753,6 +960,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 { @@ -765,9 +978,15 @@ impl OverlayManager { project_id: project_id.clone(), }); } - ContextMenuEvent::ManageWorktrees { project_id, position } => { + ContextMenuEvent::ManageWorktrees { + project_id, + position, + } => { this.hide_context_menu(cx); - this.show_worktree_list(project_id.clone(), *position, cx); + cx.emit(OverlayManagerEvent::ManageWorktrees { + project_id: project_id.clone(), + position: *position, + }); } ContextMenuEvent::ReloadServices { project_id } => { this.hide_context_menu(cx); @@ -821,7 +1040,9 @@ impl OverlayManager { } ContextMenuEvent::HideProject { project_id } => { this.hide_context_menu(cx); - cx.emit(OverlayManagerEvent::ToggleProjectVisibility(project_id.clone())); + cx.emit(OverlayManagerEvent::ToggleProjectVisibility( + project_id.clone(), + )); } } }) @@ -838,7 +1059,11 @@ impl OverlayManager { } /// Show folder context menu. - pub fn show_folder_context_menu(&mut self, request: FolderContextMenuRequest, cx: &mut Context) { + pub fn show_folder_context_menu( + &mut self, + request: FolderContextMenuRequest, + cx: &mut Context, + ) { self.close_modal(cx); self.close_all_context_menus(); @@ -846,24 +1071,31 @@ impl OverlayManager { let window_id = self.window_id; let menu = cx.new(|cx| FolderContextMenu::new(window_id, workspace.clone(), request, cx)); - cx.subscribe(&menu, |this, _, event: &FolderContextMenuEvent, cx| { - match event { + cx.subscribe( + &menu, + |this, _, event: &FolderContextMenuEvent, cx| match event { FolderContextMenuEvent::Close => { this.hide_folder_context_menu(cx); } - FolderContextMenuEvent::RenameFolder { folder_id, folder_name } => { + FolderContextMenuEvent::RenameFolder { + folder_id, + folder_name, + } => { this.hide_folder_context_menu(cx); this.request_broker.update(cx, |broker, cx| { - broker.push_sidebar_request(SidebarRequest::RenameFolder { - folder_id: folder_id.clone(), - folder_name: folder_name.clone(), - }, cx); + broker.push_sidebar_request( + SidebarRequest::RenameFolder { + folder_id: folder_id.clone(), + folder_name: folder_name.clone(), + }, + cx, + ); }); } 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 } => { @@ -878,8 +1110,8 @@ impl OverlayManager { cx.notify(); }); } - } - }) + }, + ) .detach(); self.folder_context_menu.set(menu); @@ -916,11 +1148,19 @@ impl OverlayManager { let conn_name = connection_name.clone(); let menu = cx.new(|cx| { - RemoteContextMenu::new(connection_id, connection_name, is_pairing, tls, position, cx) + RemoteContextMenu::new( + connection_id, + connection_name, + is_pairing, + tls, + position, + cx, + ) }); - cx.subscribe(&menu, move |this, _, event: &RemoteContextMenuEvent, cx| { - match event { + cx.subscribe( + &menu, + move |this, _, event: &RemoteContextMenuEvent, cx| match event { RemoteContextMenuEvent::Close => { this.hide_remote_context_menu(cx); } @@ -950,8 +1190,8 @@ impl OverlayManager { connection_id: connection_id.clone(), }); } - } - }) + }, + ) .detach(); self.remote_context_menu.set(menu); @@ -990,31 +1230,52 @@ impl OverlayManager { self.close_all_context_menus(); let menu = cx.new(|cx| { - TerminalContextMenu::new(terminal_id, project_id, layout_path, position, has_selection, link_url, cx) + TerminalContextMenu::new( + terminal_id, + project_id, + layout_path, + position, + has_selection, + link_url, + cx, + ) }); - cx.subscribe(&menu, |this, _, event: &TerminalContextMenuEvent, cx| { - match event { + cx.subscribe( + &menu, + |this, _, event: &TerminalContextMenuEvent, cx| match event { TerminalContextMenuEvent::Close => { this.hide_terminal_context_menu(cx); } TerminalContextMenuEvent::Copy { terminal_id } => { this.hide_terminal_context_menu(cx); - cx.emit(OverlayManagerEvent::TerminalCopy { terminal_id: terminal_id.clone() }); + cx.emit(OverlayManagerEvent::TerminalCopy { + terminal_id: terminal_id.clone(), + }); } TerminalContextMenuEvent::Paste { terminal_id } => { this.hide_terminal_context_menu(cx); - cx.emit(OverlayManagerEvent::TerminalPaste { terminal_id: terminal_id.clone() }); + cx.emit(OverlayManagerEvent::TerminalPaste { + terminal_id: terminal_id.clone(), + }); } TerminalContextMenuEvent::Clear { terminal_id } => { this.hide_terminal_context_menu(cx); - cx.emit(OverlayManagerEvent::TerminalClear { terminal_id: terminal_id.clone() }); + cx.emit(OverlayManagerEvent::TerminalClear { + terminal_id: terminal_id.clone(), + }); } TerminalContextMenuEvent::SelectAll { terminal_id } => { this.hide_terminal_context_menu(cx); - cx.emit(OverlayManagerEvent::TerminalSelectAll { terminal_id: terminal_id.clone() }); + cx.emit(OverlayManagerEvent::TerminalSelectAll { + terminal_id: terminal_id.clone(), + }); } - TerminalContextMenuEvent::Split { project_id, layout_path, direction } => { + TerminalContextMenuEvent::Split { + project_id, + layout_path, + direction, + } => { this.hide_terminal_context_menu(cx); cx.emit(OverlayManagerEvent::TerminalSplit { project_id: project_id.clone(), @@ -1022,7 +1283,10 @@ impl OverlayManager { direction: *direction, }); } - TerminalContextMenuEvent::CloseTerminal { project_id, terminal_id } => { + TerminalContextMenuEvent::CloseTerminal { + project_id, + terminal_id, + } => { this.hide_terminal_context_menu(cx); cx.emit(OverlayManagerEvent::TerminalClose { project_id: project_id.clone(), @@ -1037,8 +1301,8 @@ impl OverlayManager { this.hide_terminal_context_menu(cx); cx.write_to_clipboard(gpui::ClipboardItem::new_string(url.clone())); } - } - }) + }, + ) .detach(); self.terminal_context_menu.set(menu); @@ -1077,12 +1341,17 @@ impl OverlayManager { TabContextMenu::new(tab_index, num_tabs, project_id, layout_path, position, cx) }); - cx.subscribe(&menu, |this, _, event: &TabContextMenuEvent, cx| { - match event { + cx.subscribe( + &menu, + |this, _, event: &TabContextMenuEvent, cx| match event { TabContextMenuEvent::Close => { this.hide_tab_context_menu(cx); } - TabContextMenuEvent::CloseTab { project_id, layout_path, tab_index } => { + TabContextMenuEvent::CloseTab { + project_id, + layout_path, + tab_index, + } => { this.hide_tab_context_menu(cx); cx.emit(OverlayManagerEvent::TabClose { project_id: project_id.clone(), @@ -1090,7 +1359,11 @@ impl OverlayManager { tab_index: *tab_index, }); } - TabContextMenuEvent::CloseOtherTabs { project_id, layout_path, tab_index } => { + TabContextMenuEvent::CloseOtherTabs { + project_id, + layout_path, + tab_index, + } => { this.hide_tab_context_menu(cx); cx.emit(OverlayManagerEvent::TabCloseOthers { project_id: project_id.clone(), @@ -1098,7 +1371,11 @@ impl OverlayManager { tab_index: *tab_index, }); } - TabContextMenuEvent::CloseTabsToRight { project_id, layout_path, tab_index } => { + TabContextMenuEvent::CloseTabsToRight { + project_id, + layout_path, + tab_index, + } => { this.hide_tab_context_menu(cx); cx.emit(OverlayManagerEvent::TabCloseToRight { project_id: project_id.clone(), @@ -1106,8 +1383,8 @@ impl OverlayManager { tab_index: *tab_index, }); } - } - }) + }, + ) .detach(); self.tab_context_menu.set(menu); @@ -1135,20 +1412,48 @@ 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: (okena_transport::remote_action::RemoteActionClient, String), + cx: &mut Context, + ) { self.close_all_context_menus(); + let (client, daemon_id) = params; 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(client, daemon_id, workspace, project_id, position, cx) + }); - cx.subscribe(&popover, |this, _, event: &WorktreeListPopoverEvent, cx| { - if event.is_close() { - this.hide_worktree_list(cx); - } - }).detach(); + cx.subscribe( + &popover, + |this, _, event: &WorktreeListPopoverEvent, 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(); self.worktree_list.set(popover); cx.notify(); @@ -1175,7 +1480,12 @@ impl OverlayManager { } /// Show color picker popover. - pub fn show_color_picker(&mut self, target: ColorPickerTarget, position: Point, cx: &mut Context) { + pub fn show_color_picker( + &mut self, + target: ColorPickerTarget, + position: Point, + cx: &mut Context, + ) { self.close_all_context_menus(); let workspace = self.workspace.clone(); @@ -1193,8 +1503,20 @@ 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(); + }) + .detach(); self.color_picker.set(popover); cx.notify(); @@ -1239,19 +1561,23 @@ impl OverlayManager { let fs_for_viewer = fs.clone(); let blame_for_viewer = blame_provider.clone(); let settings = crate::settings::settings(cx).file_finder.clone(); - let dialog = cx.new(|cx| { - FileSearchDialog::new(fs, settings.show_ignored, cx) - }); + let dialog = cx.new(|cx| FileSearchDialog::new(fs, settings.show_ignored, cx)); - cx.subscribe(&dialog, move |this, _, event: &FileSearchDialogEvent, cx| { - match event { + cx.subscribe( + &dialog, + move |this, _, event: &FileSearchDialogEvent, cx| match event { FileSearchDialogEvent::Close => { this.close_modal(cx); } FileSearchDialogEvent::FileSelected(relative_path) => { let relative_path = relative_path.clone(); this.close_modal(cx); - this.show_file_viewer(relative_path, fs_for_viewer.clone(), blame_for_viewer.clone(), cx); + this.show_file_viewer( + relative_path, + fs_for_viewer.clone(), + blame_for_viewer.clone(), + cx, + ); } FileSearchDialogEvent::FiltersChanged { show_ignored } => { let show_ignored = *show_ignored; @@ -1259,8 +1585,8 @@ impl OverlayManager { state.set_file_finder_show_ignored(show_ignored, cx); }); } - } - }) + }, + ) .detach(); self.open_modal(dialog, cx); @@ -1297,18 +1623,27 @@ impl OverlayManager { let blame_for_viewer = blame_provider.clone(); let dialog = cx.new(|cx| ContentSearchDialog::new(fs, is_dark, cx)); - cx.subscribe(&dialog, move |this, _, event: &ContentSearchDialogEvent, cx| { - match event { + cx.subscribe( + &dialog, + move |this, _, event: &ContentSearchDialogEvent, cx| match event { ContentSearchDialogEvent::Close => { this.close_modal(cx); } - ContentSearchDialogEvent::FileSelected { relative_path, line: _ } => { + ContentSearchDialogEvent::FileSelected { + relative_path, + line: _, + } => { let relative_path = relative_path.clone(); this.close_modal(cx); - this.show_file_viewer(relative_path, fs_for_viewer.clone(), blame_for_viewer.clone(), cx); + this.show_file_viewer( + relative_path, + fs_for_viewer.clone(), + blame_for_viewer.clone(), + cx, + ); } - } - }) + }, + ) .detach(); self.open_modal(dialog, cx); @@ -1325,7 +1660,10 @@ impl OverlayManager { blame_provider: Option>, cx: &mut Context, ) { - let settings = crate::settings::settings_entity(cx).read(cx).settings.clone(); + let settings = crate::settings::settings_entity(cx) + .read(cx) + .settings + .clone(); let font_size = settings.file_font_size; let blame_visible = settings.blame_visible; let is_dark = crate::theme::theme(cx).is_dark(); @@ -1358,7 +1696,10 @@ impl OverlayManager { blame_provider: Option>, cx: &mut Context, ) { - let settings = crate::settings::settings_entity(cx).read(cx).settings.clone(); + let settings = crate::settings::settings_entity(cx) + .read(cx) + .settings + .clone(); let font_size = settings.file_font_size; let blame_visible = settings.blame_visible; let is_dark = crate::theme::theme(cx).is_dark(); @@ -1432,43 +1773,46 @@ impl OverlayManager { /// Detach moves it to a separate OS window, OpenCommit bubbles up to /// RootView, SendToTerminal routes to the focused terminal via the broker. fn subscribe_file_viewer(&mut self, viewer: &Entity, cx: &mut Context) { - cx.subscribe(viewer, |this, viewer_entity, event: &FileViewerEvent, cx| { - match event { - FileViewerEvent::Close => { - // Closing keeps the cached viewer alive (cache holds its own - // clone); only the modal slot is cleared. - this.close_modal(cx); - } - FileViewerEvent::Detach => { - this.detach_active_modal(cx); - } - FileViewerEvent::OpenCommit(hash) => { - // Look up which project this FileViewer belongs to so the - // host can pick the right GitProvider. - if let Some(project_id) = this - .cached_file_viewers - .iter() - .find(|(_, v)| **v == viewer_entity) - .map(|(k, _)| k.clone()) - { - cx.emit(OverlayManagerEvent::OpenCommitFromBlame { - project_id, - hash: hash.clone(), + cx.subscribe( + viewer, + |this, viewer_entity, event: &FileViewerEvent, cx| { + match event { + FileViewerEvent::Close => { + // Closing keeps the cached viewer alive (cache holds its own + // clone); only the modal slot is cleared. + this.close_modal(cx); + } + FileViewerEvent::Detach => { + this.detach_active_modal(cx); + } + FileViewerEvent::OpenCommit(hash) => { + // Look up which project this FileViewer belongs to so the + // host can pick the right GitProvider. + if let Some(project_id) = this + .cached_file_viewers + .iter() + .find(|(_, v)| **v == viewer_entity) + .map(|(k, _)| k.clone()) + { + cx.emit(OverlayManagerEvent::OpenCommitFromBlame { + project_id, + hash: hash.clone(), + }); + } + } + FileViewerEvent::BlamePreferenceChanged(visible) => { + crate::settings::settings_entity(cx).update(cx, |state, cx| { + state.set_blame_visible(*visible, cx); + }); + } + FileViewerEvent::SendToTerminal(payload) => { + this.request_broker.update(cx, |broker, cx| { + broker.push_send_to_terminal(payload.clone(), cx); }); } } - FileViewerEvent::BlamePreferenceChanged(visible) => { - crate::settings::settings_entity(cx).update(cx, |state, cx| { - state.set_blame_visible(*visible, cx); - }); - } - FileViewerEvent::SendToTerminal(payload) => { - this.request_broker.update(cx, |broker, cx| { - broker.push_send_to_terminal(payload.clone(), cx); - }); - } - } - }) + }, + ) .detach(); } @@ -1505,7 +1849,15 @@ impl OverlayManager { cx: &mut Context, ) { let viewer = cx.new(|cx| { - DiffViewer::new(provider, select_file, mode, commit_message, commits, commit_index, cx) + DiffViewer::new( + provider, + select_file, + mode, + commit_message, + commits, + commit_index, + cx, + ) }); cx.subscribe(&viewer, |this, _, event: &DiffViewerEvent, cx| { @@ -1551,8 +1903,9 @@ impl OverlayManager { self.close_modal(cx); } else { let entity = cx.new(|cx| RemoteConnectDialog::new(remote_manager, cx)); - cx.subscribe(&entity, |this, _, event: &RemoteConnectDialogEvent, cx| { - match event { + cx.subscribe( + &entity, + |this, _, event: &RemoteConnectDialogEvent, cx| match event { RemoteConnectDialogEvent::Close => { this.close_modal(cx); } @@ -1562,8 +1915,8 @@ impl OverlayManager { }); this.close_modal(cx); } - } - }) + }, + ) .detach(); self.open_modal(entity, cx); } @@ -1581,20 +1934,24 @@ impl OverlayManager { cx: &mut Context, ) { let entity = cx.new(|cx| RemotePairDialog::new(connection_id, connection_name, cx)); - cx.subscribe(&entity, |this, _, event: &RemotePairDialogEvent, cx| { - match event { + cx.subscribe( + &entity, + |this, _, event: &RemotePairDialogEvent, cx| match event { RemotePairDialogEvent::Close => { this.close_modal(cx); } - RemotePairDialogEvent::Pair { connection_id, code } => { + RemotePairDialogEvent::Pair { + connection_id, + code, + } => { cx.emit(OverlayManagerEvent::RemotePaired { connection_id: connection_id.clone(), code: code.clone(), }); this.close_modal(cx); } - } - }) + }, + ) .detach(); self.open_modal(entity, cx); } 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..0e99ded43 100644 --- a/crates/okena-app/src/views/overlays/add_project_dialog.rs +++ b/crates/okena-app/src/views/overlays/add_project_dialog.rs @@ -2,20 +2,19 @@ use crate::keybindings::Cancel; use crate::remote_client::manager::RemoteConnectionManager; -use crate::settings::settings; use crate::theme::theme; +use crate::ui::tokens::{ui_text_md, ui_text_ms}; use crate::views::components::{ - button, input_container, labeled_input, modal_backdrop, modal_content, - modal_header, PathAutoCompleteState, SimpleInput, SimpleInputState, + PathAutoCompleteState, SimpleInput, SimpleInputState, button, input_container, labeled_input, + modal_backdrop, modal_content, modal_header, }; -use okena_ui::dialog_actions::dialog_actions; use crate::workspace::state::{WindowId, Workspace}; -use crate::ui::tokens::{ui_text_md, ui_text_ms}; 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}; +use okena_ui::dialog_actions::dialog_actions; enum AddProjectTarget { Local, @@ -56,14 +55,21 @@ impl AddProjectDialog { window_id: WindowId, cx: &mut Context, ) -> Self { - let name_input = cx.new(|cx| SimpleInputState::new(cx).placeholder("Enter project name...")); + 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 +113,34 @@ 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, - ); - }); - } - } } } @@ -159,20 +157,21 @@ impl AddProjectDialog { cx.spawn_in(window, async move |this, cx| { if let Ok(Ok(Some(selected_paths))) = paths.await - && let Some(path) = selected_paths.first() { - let path_str = path.to_string_lossy().to_string(); - let name_str = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Project".to_string()); - - this.update(cx, |this, cx| { - this.pending_path_value = Some(path_str); - this.pending_name_value = Some(name_str); - cx.notify(); - }) - .ok(); - } + && let Some(path) = selected_paths.first() + { + let path_str = path.to_string_lossy().to_string(); + let name_str = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Project".to_string()); + + this.update(cx, |this, cx| { + this.pending_path_value = Some(path_str); + this.pending_name_value = Some(name_str); + cx.notify(); + }) + .ok(); + } }) .detach(); } @@ -200,8 +199,7 @@ impl AddProjectDialog { .rounded(px(4.0)) .cursor_pointer() .when(is_selected, |d| { - d.bg(rgb(t.border_active)) - .text_color(rgb(t.bg_primary)) + d.bg(rgb(t.border_active)).text_color(rgb(t.bg_primary)) }) .when(!is_selected, |d| { d.bg(rgb(t.bg_secondary)) @@ -211,6 +209,11 @@ impl AddProjectDialog { .child(label) .on_click(cx.listener(move |this, _, _window, cx| { this.selected_target = i; + let local_completion_enabled = + matches!(this.targets.get(i), Some(AddProjectTarget::Local)); + this.path_input.update(cx, |input, cx| { + input.set_local_completion_enabled(local_completion_enabled, cx); + }); cx.notify(); })) })) @@ -230,11 +233,7 @@ impl AddProjectDialog { } // Adjust top offset when target selector is visible - let top_offset = if self.targets.len() > 1 { - 210.0 - } else { - 180.0 - }; + let top_offset = if self.targets.len() > 1 { 210.0 } else { 180.0 }; div() .absolute() @@ -258,61 +257,54 @@ impl AddProjectDialog { cx.stop_propagation(); }) .child( - v_flex().children( - suggestions - .iter() - .enumerate() - .map(|(i, suggestion)| { - let is_selected = i == selected_index; - let path_input = path_input.clone(); - + v_flex().children(suggestions.iter().enumerate().map(|(i, suggestion)| { + let is_selected = i == selected_index; + let path_input = path_input.clone(); + + div() + .id(ElementId::Name(format!("path-suggestion-{}", i).into())) + .px(px(8.0)) + .py(px(6.0)) + .cursor_pointer() + .when(is_selected, |d| d.bg(rgb(t.bg_selection))) + .hover(|s| s.bg(rgb(t.bg_hover))) + .flex() + .items_center() + .gap(px(8.0)) + .child( + svg() + .path(if suggestion.is_select_current { + "icons/check.svg" + } else if suggestion.is_directory { + "icons/folder.svg" + } else { + "icons/file.svg" + }) + .size(px(14.0)) + .text_color( + if suggestion.is_select_current || suggestion.is_directory { + rgb(t.border_active) + } else { + rgb(t.text_muted) + }, + ), + ) + .child( div() - .id(ElementId::Name( - format!("path-suggestion-{}", i).into(), - )) - .px(px(8.0)) - .py(px(6.0)) - .cursor_pointer() - .when(is_selected, |d| d.bg(rgb(t.bg_selection))) - .hover(|s| s.bg(rgb(t.bg_hover))) - .flex() - .items_center() - .gap(px(8.0)) - .child( - svg() - .path(if suggestion.is_select_current { - "icons/check.svg" - } else if suggestion.is_directory { - "icons/folder.svg" - } else { - "icons/file.svg" - }) - .size(px(14.0)) - .text_color(if suggestion.is_select_current - || suggestion.is_directory - { - rgb(t.border_active) - } else { - rgb(t.text_muted) - }), - ) - .child( - div() - .text_size(ui_text_md(cx)) - .text_color(if suggestion.is_select_current { - rgb(t.border_active) - } else { - rgb(t.text_primary) - }) - .child(suggestion.display_name.clone()), - ) - .on_click(move |_, _window, cx| { - path_input.update(cx, |state, cx| { - state.select_and_complete(i, cx); - }); + .text_size(ui_text_md(cx)) + .text_color(if suggestion.is_select_current { + rgb(t.border_active) + } else { + rgb(t.text_primary) }) - }), - ), + .child(suggestion.display_name.clone()), + ) + .on_click(move |_, _window, cx| { + path_input.update(cx, |state, cx| { + state.select_and_complete(i, cx); + }); + }) + })), ) .into_any_element() } @@ -389,26 +381,20 @@ impl Render for AddProjectDialog { ) }) // Name input - .child( - labeled_input("Name:", &t).child( - input_container(&t, None).child( - SimpleInput::new(&self.name_input).text_size(ui_text_md(cx)), - ), + .child(labeled_input("Name:", &t).child( + input_container(&t, None).child( + SimpleInput::new(&self.name_input).text_size(ui_text_md(cx)), ), - ) + )) // Path input with auto-complete (or plain input for remote) .child( labeled_input(path_label, &t) - .when(!is_remote, |d| { - d.child(self.path_input.clone()) - }) + .when(!is_remote, |d| d.child(self.path_input.clone())) .when(is_remote, |d| { d.child( input_container(&t, None).child( - SimpleInput::new( - self.path_input.read(cx).input(), - ) - .text_size(ui_text_md(cx)), + SimpleInput::new(self.path_input.read(cx).input()) + .text_size(ui_text_md(cx)), ), ) }), @@ -427,15 +413,17 @@ impl Render for AddProjectDialog { ) }) // Action buttons - .child( - dialog_actions( - "Cancel", - cx.listener(|this, _, _window, cx| { this.close(cx); }), - "Add", - cx.listener(|this, _, window, cx| { this.add_project(window, cx); }), - &t, - ), - ), + .child(dialog_actions( + "Cancel", + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + "Add", + cx.listener(|this, _, window, cx| { + this.add_project(window, cx); + }), + &t, + )), ) // Path suggestions overlay (only for local target) .when(has_suggestions, |d| { diff --git a/crates/okena-app/src/views/overlays/command_palette.rs b/crates/okena-app/src/views/overlays/command_palette.rs index 6db6719af..2eb61cea9 100644 --- a/crates/okena-app/src/views/overlays/command_palette.rs +++ b/crates/okena-app/src/views/overlays/command_palette.rs @@ -1,13 +1,14 @@ -use crate::keybindings::{format_keystroke, get_action_descriptions, get_config, Cancel}; +use crate::keybindings::{Cancel, format_keystroke, get_action_descriptions, get_config}; use crate::theme::theme; +use crate::ui::tokens::{ui_text, ui_text_ms}; use crate::views::components::{ - badge, handle_list_overlay_key, keyboard_hints_footer, modal_backdrop, modal_content, - search_input_area_selected, substring_filter, ListOverlayAction, ListOverlayConfig, ListOverlayState, + ListOverlayAction, ListOverlayConfig, ListOverlayState, badge, handle_list_overlay_key, + keyboard_hints_footer, modal_backdrop, modal_content, search_input_area_selected, + substring_filter, }; -use crate::ui::tokens::{ui_text, ui_text_ms}; +use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; -use gpui::prelude::*; use okena_ui::empty_state::empty_state; use okena_ui::selectable_list::selectable_list_item; @@ -53,7 +54,12 @@ pub struct CommandPalette { } impl CommandPalette { - pub fn new(workspace: Entity, focus_manager: Entity, window_id: okena_workspace::state::WindowId, cx: &mut Context) -> Self { + pub fn new( + workspace: Entity, + focus_manager: Entity, + window_id: okena_workspace::state::WindowId, + cx: &mut Context, + ) -> Self { // Build command list from action descriptions let descriptions = get_action_descriptions(); let config_data = get_config(); @@ -80,7 +86,8 @@ impl CommandPalette { .collect(); // Restore from previous session - let (query, recent) = cx.try_global::() + let (query, recent) = cx + .try_global::() .map(|m| (m.query.clone(), m.recent.clone())) .unwrap_or_default(); let select_all = !query.is_empty(); @@ -110,7 +117,14 @@ impl CommandPalette { let state = ListOverlayState::new(commands, config, cx); let focus_handle = state.focus_handle.clone(); - let mut palette = Self { workspace, focus_manager, window_id, focus_handle, state, select_all }; + let mut palette = Self { + workspace, + focus_manager, + window_id, + focus_handle, + state, + select_all, + }; if !query.is_empty() { palette.state.search_query = query; @@ -121,7 +135,8 @@ impl CommandPalette { } fn save_memory(&self, cx: &mut Context) { - let recent = cx.try_global::() + let recent = cx + .try_global::() .map(|m| m.recent.clone()) .unwrap_or_default(); cx.set_global(CommandPaletteMemory { @@ -131,7 +146,8 @@ impl CommandPalette { } fn record_recent(&self, action_key: &'static str, cx: &mut Context) { - let mut recent = cx.try_global::() + let mut recent = cx + .try_global::() .map(|m| m.recent.clone()) .unwrap_or_default(); recent.retain(|k| *k != action_key); @@ -159,12 +175,12 @@ impl CommandPalette { // context-scoped actions (e.g. CloseTerminal on "TerminalPane") // are routed to the correct element. let pane_map = okena_views_terminal::layout::navigation::get_pane_map(self.window_id); - if let Some(focused) = self.focus_manager.read(cx) - .focused_terminal_state() + if let Some(focused) = self.focus_manager.read(cx).focused_terminal_state() && let Some(pane) = pane_map.find_pane(&focused.project_id, &focused.layout_path) - && let Some(ref fh) = pane.focus_handle { - window.focus(fh, cx); - } + && let Some(ref fh) = pane.focus_handle + { + window.focus(fh, cx); + } window.dispatch_action(action, cx); cx.emit(CommandPaletteEvent::Close); @@ -182,7 +198,12 @@ impl CommandPalette { self.state.set_filtered(filtered); } - fn render_command_row(&self, filtered_index: usize, cmd_index: usize, cx: &mut Context) -> impl IntoElement + use<> { + fn render_command_row( + &self, + filtered_index: usize, + cmd_index: usize, + cx: &mut Context, + ) -> impl IntoElement + use<> { let t = theme(cx); let command = &self.state.items[cmd_index]; let is_selected = filtered_index == self.state.selected_index; @@ -193,58 +214,57 @@ impl CommandPalette { let keybinding = command.keybinding.clone(); selectable_list_item( - ElementId::Name(format!("command-{}", filtered_index).into()), - is_selected, - &t, - ) - .justify_between() - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, window, cx| { - this.execute_command(filtered_index, window, cx); - }), - ) - .child( - // Left side: name + description + ElementId::Name(format!("command-{}", filtered_index).into()), + is_selected, + &t, + ) + .justify_between() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.execute_command(filtered_index, window, cx); + }), + ) + .child( + // Left side: name + description + div() + .flex_1() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + h_flex() + .gap(px(8.0)) + .child( + div() + .text_size(ui_text(13.0, cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_primary)) + .child(name), + ) + .child(badge(category, &t)), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(description), + ), + ) + .child( + // Right side: keybinding + h_flex().children(keybinding.map(|kb| { div() - .flex_1() - .flex() - .flex_col() - .gap(px(2.0)) - .child( - h_flex() - .gap(px(8.0)) - .child( - div() - .text_size(ui_text(13.0, cx)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(t.text_primary)) - .child(name), - ) - .child(badge(category, &t)), - ) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .child(description), - ), - ) - .child( - // Right side: keybinding - h_flex() - .children(keybinding.map(|kb| { - div() - .px(px(8.0)) - .py(px(2.0)) - .rounded(px(4.0)) - .bg(rgb(t.bg_secondary)) - .text_size(ui_text_ms(cx)) - .font_family("monospace") - .text_color(rgb(t.text_secondary)) - .child(kb) - })), - ) + .px(px(8.0)) + .py(px(2.0)) + .rounded(px(4.0)) + .bg(rgb(t.bg_secondary)) + .text_size(ui_text_ms(cx)) + .font_family("monospace") + .text_color(rgb(t.text_secondary)) + .child(kb) + })), + ) } } @@ -261,7 +281,12 @@ impl Render for CommandPalette { let search_query = self.state.search_query.clone(); let config_width = self.state.config.width; let config_max_height = self.state.config.max_height; - let search_placeholder = self.state.config.search_placeholder.clone().unwrap_or_default(); + let search_placeholder = self + .state + .config + .search_placeholder + .clone() + .unwrap_or_default(); let empty_message = self.state.config.empty_message.clone(); // Focus on first render @@ -293,7 +318,9 @@ impl Render for CommandPalette { let Some(ch) = k.chars().next() else { return; }; - if "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_./".contains(ch) { + if "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_./" + .contains(ch) + { this.state.search_query.clear(); this.select_all = false; // Fall through to handle_list_overlay_key which will push the char @@ -333,7 +360,12 @@ impl Render for CommandPalette { .w(px(config_width)) .max_h(px(config_max_height)) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(search_input_area_selected(&search_query, &search_placeholder, self.select_all, &t)) + .child(search_input_area_selected( + &search_query, + &search_placeholder, + self.select_all, + &t, + )) .child( // Command list div() @@ -341,17 +373,19 @@ impl Render for CommandPalette { .flex_1() .overflow_y_scroll() .track_scroll(&self.state.scroll_handle) - .children( - self.state.filtered - .iter() - .enumerate() - .map(|(i, filter_result)| self.render_command_row(i, filter_result.index, cx)), - ) + .children(self.state.filtered.iter().enumerate().map( + |(i, filter_result)| { + self.render_command_row(i, filter_result.index, cx) + }, + )) .when(self.state.is_empty(), |d| { d.child(empty_state(empty_message.clone(), &t, cx)) }), ) - .child(keyboard_hints_footer(&[("Enter", "to select"), ("Esc", "to close")], &t)), + .child(keyboard_hints_footer( + &[("Enter", "to select"), ("Esc", "to close")], + &t, + )), ) } } diff --git a/crates/okena-app/src/views/overlays/detached_overlay.rs b/crates/okena-app/src/views/overlays/detached_overlay.rs index b69bc27f2..c54f018d9 100644 --- a/crates/okena-app/src/views/overlays/detached_overlay.rs +++ b/crates/okena-app/src/views/overlays/detached_overlay.rs @@ -116,8 +116,7 @@ where .size_full() .bg(rgb(t.bg_primary)) .child( - AnyView::from(self.content.clone()) - .cached(StyleRefinement::default().size_full()), + AnyView::from(self.content.clone()).cached(StyleRefinement::default().size_full()), ) .into_any_element() } diff --git a/crates/okena-app/src/views/overlays/keybindings_help.rs b/crates/okena-app/src/views/overlays/keybindings_help.rs index 8b50c4a51..add1931f7 100644 --- a/crates/okena-app/src/views/overlays/keybindings_help.rs +++ b/crates/okena-app/src/views/overlays/keybindings_help.rs @@ -1,14 +1,13 @@ use crate::keybindings::{ - format_keystroke, get_action_descriptions, get_config, get_keybindings_path, - keystroke_to_config_string, reset_to_defaults, update_config, - Cancel, KeybindingEntry, ShowKeybindings, + Cancel, KeybindingEntry, ShowKeybindings, format_keystroke, get_action_descriptions, + get_config, get_keybindings_path, keystroke_to_config_string, reset_to_defaults, update_config, }; use crate::theme::theme; -use crate::views::components::{modal_backdrop, modal_content, modal_header, search_input_area}; use crate::ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; +use crate::views::components::{modal_backdrop, modal_content, modal_header, search_input_area}; +use gpui::prelude::*; use gpui::*; use gpui_component::{h_flex, v_flex}; -use gpui::prelude::*; /// Characters allowed in the keybinding search query. const SEARCH_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_+./"; @@ -133,14 +132,22 @@ impl KeybindingsHelp { } /// Handle a keystroke during recording - fn handle_recorded_keystroke(&mut self, keystroke: &Keystroke, window: &mut Window, cx: &mut Context) { + fn handle_recorded_keystroke( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) { let Some(editing) = self.editing.as_mut() else { return; }; // Ignore modifier-only keypresses let key = keystroke.key.as_str(); - if matches!(key, "shift" | "control" | "alt" | "platform" | "function" | "") { + if matches!( + key, + "shift" | "control" | "alt" | "platform" | "function" | "" + ) { return; } @@ -164,26 +171,43 @@ impl KeybindingsHelp { cx.spawn_in(window, async move |this, cx| { let timeout = smol::Timer::after(std::time::Duration::from_secs(1)); - smol::future::or(async { timeout.await; true }, async { let _ = cancel_rx.recv().await; false }).await; + smol::future::or( + async { + timeout.await; + true + }, + async { + let _ = cancel_rx.recv().await; + false + }, + ) + .await; // If we get here and still waiting, finalize with single keystroke let _ = cx.update(|window, cx| { let _ = this.update(cx, |this, cx| { if let Some(editing) = this.editing.as_mut() && editing.waiting_for_chord - && let Some(single) = editing.first_chord.take() { - this.finalize_recording(single, window, cx); - } + && let Some(single) = editing.first_chord.take() + { + this.finalize_recording(single, window, cx); + } }); }); - }).detach(); + }) + .detach(); cx.notify(); } } /// Finalize recording: save the new keystroke and check for conflicts - fn finalize_recording(&mut self, new_keystroke: String, _window: &mut Window, cx: &mut Context) { + fn finalize_recording( + &mut self, + new_keystroke: String, + _window: &mut Window, + cx: &mut Context, + ) { let Some(editing) = self.editing.take() else { return; }; @@ -211,7 +235,12 @@ impl KeybindingsHelp { } /// Add a new empty binding for an action - fn add_binding_for_action(&mut self, action: &str, context: Option, cx: &mut Context) { + fn add_binding_for_action( + &mut self, + action: &str, + context: Option, + cx: &mut Context, + ) { let entry = KeybindingEntry::new("unset", context.as_deref()); update_config(|config| { @@ -287,78 +316,108 @@ impl KeybindingsHelp { .rounded(px(6.0)) .border_1() .border_color(rgb(t.border)) - .children(bindings.iter().enumerate().map( - |(i, (action, entries))| { - let description = descriptions - .get(action.as_str()) - .map(|d| d.description) - .unwrap_or("Unknown action"); - let name = descriptions + .children(bindings.iter().enumerate().map(|(i, (action, entries))| { + let description = descriptions + .get(action.as_str()) + .map(|d| d.description) + .unwrap_or("Unknown action"); + let name = descriptions + .get(action.as_str()) + .map(|d| d.name) + .unwrap_or(action.as_str()); + + let is_action_customized = entries.iter().any(|(_, _, c, _)| *c); + let action_clone = action.clone(); + let action_for_reset = action.clone(); + // Determine context from first entry for "add" button + let entry_context = { + let config = get_config(); + config + .bindings .get(action.as_str()) - .map(|d| d.name) - .unwrap_or(action.as_str()); - - let is_action_customized = entries.iter().any(|(_, _, c, _)| *c); - let action_clone = action.clone(); - let action_for_reset = action.clone(); - // Determine context from first entry for "add" button - let entry_context = { - let config = get_config(); - config.bindings.get(action.as_str()) - .and_then(|entries| entries.first()) - .and_then(|e| e.context.clone()) - }; - - div() - .when(i > 0, |d| { - d.border_t_1().border_color(rgb(t.border)) - }) - .child( - // Action info row - h_flex() - .justify_between() - .px(px(12.0)) - .pt(px(8.0)) - .pb(px(4.0)) - .child( - v_flex() - .gap(px(2.0)) - .child( - h_flex() - .gap(px(8.0)) - .child( + .and_then(|entries| entries.first()) + .and_then(|e| e.context.clone()) + }; + + div() + .when(i > 0, |d| d.border_t_1().border_color(rgb(t.border))) + .child( + // Action info row + h_flex() + .justify_between() + .px(px(12.0)) + .pt(px(8.0)) + .pb(px(4.0)) + .child( + v_flex() + .gap(px(2.0)) + .child( + h_flex() + .gap(px(8.0)) + .child( + div() + .text_size(ui_text(13.0, cx)) + .text_color(rgb(t.text_primary)) + .child(name.to_string()), + ) + .when(is_action_customized, |d| { + d.child( div() - .text_size(ui_text(13.0, cx)) - .text_color(rgb(t.text_primary)) - .child(name.to_string()), + .text_size(ui_text_sm(cx)) + .px(px(4.0)) + .py(px(1.0)) + .rounded(px(3.0)) + .bg(rgb(t.border_active)) + .text_color(rgb(0xFFFFFF)) + .child("Custom"), ) - .when(is_action_customized, |d| { - d.child( - div() - .text_size(ui_text_sm(cx)) - .px(px(4.0)) - .py(px(1.0)) - .rounded(px(3.0)) - .bg(rgb(t.border_active)) - .text_color(rgb(0xFFFFFF)) - .child("Custom"), - ) - }), - ) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .child(description.to_string()), - ), - ) - .child( - h_flex() - .gap(px(4.0)) - // Add binding button - .child( + }), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(description.to_string()), + ), + ) + .child( + h_flex() + .gap(px(4.0)) + // Add binding button + .child( + div() + .id(SharedString::from(format!( + "add-{}", + action_clone + ))) + .cursor_pointer() + .px(px(6.0)) + .py(px(2.0)) + .rounded(px(3.0)) + .hover(|s| s.bg(rgb(t.bg_hover))) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child("+") + .on_mouse_down(MouseButton::Left, { + let action = action_clone.clone(); + let ctx = entry_context.clone(); + cx.listener(move |this, _, _window, cx| { + this.add_binding_for_action( + &action, + ctx.clone(), + cx, + ); + }) + }), + ) + // Reset single action button (only if customized) + .when(is_action_customized, |d| { + d.child( div() - .id(SharedString::from(format!("add-{}", action_clone))) + .id(SharedString::from(format!( + "reset-{}", + action_for_reset + ))) .cursor_pointer() .px(px(6.0)) .py(px(2.0)) @@ -366,40 +425,24 @@ impl KeybindingsHelp { .hover(|s| s.bg(rgb(t.bg_hover))) .text_size(ui_text_sm(cx)) .text_color(rgb(t.text_muted)) - .child("+") + .child("↺") .on_mouse_down(MouseButton::Left, { - let action = action_clone.clone(); - let ctx = entry_context.clone(); - cx.listener(move |this, _, _window, cx| { - this.add_binding_for_action(&action, ctx.clone(), cx); - }) + let action = action_for_reset.clone(); + cx.listener( + move |this, _, _window, cx| { + this.reset_single_action( + &action, cx, + ); + }, + ) }), ) - // Reset single action button (only if customized) - .when(is_action_customized, |d| { - d.child( - div() - .id(SharedString::from(format!("reset-{}", action_for_reset))) - .cursor_pointer() - .px(px(6.0)) - .py(px(2.0)) - .rounded(px(3.0)) - .hover(|s| s.bg(rgb(t.bg_hover))) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child("↺") - .on_mouse_down(MouseButton::Left, { - let action = action_for_reset.clone(); - cx.listener(move |this, _, _window, cx| { - this.reset_single_action(&action, cx); - }) - }), - ) - }), - ), - ) - // Individual binding entries - .children(entries.iter().map(|(keystroke, entry_idx, _is_customized, is_enabled)| { + }), + ), + ) + // Individual binding entries + .children(entries.iter().map( + |(keystroke, entry_idx, _is_customized, is_enabled)| { let action_name = action.clone(); let action_for_toggle = action.clone(); let action_for_remove = action.clone(); @@ -411,7 +454,11 @@ impl KeybindingsHelp { let is_recording = self.editing.as_ref().is_some_and(|e| { e.action == action_name && e.entry_index == idx }); - let is_waiting_chord = is_recording && self.editing.as_ref().is_some_and(|e| e.waiting_for_chord); + let is_waiting_chord = is_recording + && self + .editing + .as_ref() + .is_some_and(|e| e.waiting_for_chord); h_flex() .justify_between() @@ -421,7 +468,10 @@ impl KeybindingsHelp { .child( // Keystroke badge (clickable to record) div() - .id(SharedString::from(format!("ks-{}-{}", action_name, idx))) + .id(SharedString::from(format!( + "ks-{}-{}", + action_name, idx + ))) .cursor_pointer() .px(px(8.0)) .py(px(4.0)) @@ -440,10 +490,16 @@ impl KeybindingsHelp { }) .text_size(ui_text_md(cx)) .font_family("monospace") - .text_color(if is_recording { rgb(0xFFFFFF) } else { rgb(t.text_secondary) }) + .text_color(if is_recording { + rgb(0xFFFFFF) + } else { + rgb(t.text_secondary) + }) .child(if is_recording { if is_waiting_chord { - let first = self.editing.as_ref() + let first = self + .editing + .as_ref() .and_then(|e| e.first_chord.as_ref()) .map(|s| format_keystroke(s)) .unwrap_or_default(); @@ -457,10 +513,17 @@ impl KeybindingsHelp { .on_mouse_down(MouseButton::Left, { let action = action_name.clone(); cx.listener(move |this, _, _window, cx| { - if this.editing.as_ref().is_some_and(|e| e.action == action && e.entry_index == idx) { + if this.editing.as_ref().is_some_and(|e| { + e.action == action + && e.entry_index == idx + }) { this.cancel_recording(cx); } else { - this.start_recording(action.clone(), idx, cx); + this.start_recording( + action.clone(), + idx, + cx, + ); } }) }), @@ -471,26 +534,40 @@ impl KeybindingsHelp { // Toggle enabled/disabled .child( div() - .id(SharedString::from(format!("toggle-{}-{}", action_for_toggle, idx))) + .id(SharedString::from(format!( + "toggle-{}-{}", + action_for_toggle, idx + ))) .cursor_pointer() .px(px(6.0)) .py(px(2.0)) .rounded(px(3.0)) .hover(|s| s.bg(rgb(t.bg_hover))) .text_size(ui_text_sm(cx)) - .text_color(if enabled { rgb(t.text_muted) } else { rgb(t.error) }) + .text_color(if enabled { + rgb(t.text_muted) + } else { + rgb(t.error) + }) .child(if enabled { "●" } else { "○" }) .on_mouse_down(MouseButton::Left, { let action = action_for_toggle.clone(); - cx.listener(move |this, _, _window, cx| { - this.toggle_binding_entry(&action, idx, cx); - }) + cx.listener( + move |this, _, _window, cx| { + this.toggle_binding_entry( + &action, idx, cx, + ); + }, + ) }), ) // Remove binding .child( div() - .id(SharedString::from(format!("rm-{}-{}", action_for_remove, idx))) + .id(SharedString::from(format!( + "rm-{}-{}", + action_for_remove, idx + ))) .cursor_pointer() .px(px(6.0)) .py(px(2.0)) @@ -501,15 +578,19 @@ impl KeybindingsHelp { .child("×") .on_mouse_down(MouseButton::Left, { let action = action_for_remove.clone(); - cx.listener(move |this, _, _window, cx| { - this.remove_binding_entry(&action, idx, cx); - }) + cx.listener( + move |this, _, _window, cx| { + this.remove_binding_entry( + &action, idx, cx, + ); + }, + ) }), ), ) - })) - }, - )), + }, + )) + })), ) } } @@ -555,7 +636,9 @@ impl Render for KeybindingsHelp { if !query.is_empty() { let name = desc.map(|d| d.name).unwrap_or(""); let description = desc.map(|d| d.description).unwrap_or(""); - let keystrokes_match = entries.iter().any(|e| e.keystroke.to_lowercase().contains(&query)); + let keystrokes_match = entries + .iter() + .any(|e| e.keystroke.to_lowercase().contains(&query)); if !name.to_lowercase().contains(&query) && !description.to_lowercase().contains(&query) && !category.to_lowercase().contains(&query) @@ -569,9 +652,7 @@ impl Render for KeybindingsHelp { let entry_details: Vec<(String, usize, bool, bool)> = entries .iter() .enumerate() - .map(|(idx, entry)| { - (entry.keystroke.clone(), idx, is_customized, entry.enabled) - }) + .map(|(idx, entry)| (entry.keystroke.clone(), idx, is_customized, entry.enabled)) .collect(); if !entry_details.is_empty() { @@ -582,7 +663,18 @@ impl Render for KeybindingsHelp { } } - let category_order = ["Global", "Terminal", "Navigation", "Search", "Fullscreen", "Layout", "Project", "Services", "Git", "Other"]; + let category_order = [ + "Global", + "Terminal", + "Navigation", + "Search", + "Fullscreen", + "Layout", + "Project", + "Services", + "Git", + "Other", + ]; let focus_handle = self.focus_handle.clone(); let is_editing = self.editing.is_some(); @@ -611,10 +703,9 @@ impl Render for KeybindingsHelp { } let key = event.keystroke.key.as_str(); match key { - "backspace" - if this.search_query.pop().is_some() => { - cx.notify(); - } + "backspace" if this.search_query.pop().is_some() => { + cx.notify(); + } k if k.len() == 1 && !event.keystroke.modifiers.modified() => { let Some(ch) = k.chars().next() else { return; @@ -627,13 +718,16 @@ impl Render for KeybindingsHelp { _ => {} } })) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - if this.editing.is_some() { - this.cancel_recording(cx); - } else { - this.close(cx); - } - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + if this.editing.is_some() { + this.cancel_recording(cx); + } else { + this.close(cx); + } + }), + ) .child( modal_content("keybindings-modal", &t) .w(px(650.0)) @@ -642,59 +736,60 @@ impl Render for KeybindingsHelp { .on_mouse_down(MouseButton::Left, |_, _, _| {}) .child(modal_header( "Keyboard Shortcuts", - Some(if is_editing { "Press keys to record, ESC to cancel" } else { "Click a binding to change it" }), + Some(if is_editing { + "Press keys to record, ESC to cancel" + } else { + "Click a binding to change it" + }), &t, cx, cx.listener(|this, _, _window, cx| this.close(cx)), )) - .child(search_input_area(&self.search_query, "Search keybindings…", &t)) + .child(search_input_area( + &self.search_query, + "Search keybindings…", + &t, + )) // Conflict/info banners - .child( - div() - .when(!conflicts.is_empty(), |d| { - d.px(px(16.0)) - .py(px(8.0)) - .bg(rgb(t.warning)) - .border_b_1() - .border_color(rgb(t.border)) + .child(div().when(!conflicts.is_empty(), |d| { + d.px(px(16.0)) + .py(px(8.0)) + .bg(rgb(t.warning)) + .border_b_1() + .border_color(rgb(t.border)) + .child( + h_flex() + .gap(px(8.0)) + .child(div().text_size(ui_text_xl(cx)).child("⚠️")) .child( - h_flex() - .gap(px(8.0)) + v_flex() + .gap(px(2.0)) .child( div() - .text_size(ui_text_xl(cx)) - .child("⚠️"), + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_primary)) + .child(format!( + "{} keybinding conflict{}", + conflicts.len(), + if conflicts.len() == 1 { "" } else { "s" } + )), ) .child( - v_flex() - .gap(px(2.0)) - .child( - div() - .text_size(ui_text_md(cx)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(t.text_primary)) - .child(format!( - "{} keybinding conflict{}", - conflicts.len(), - if conflicts.len() == 1 { "" } else { "s" } - )), - ) + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_secondary)) - .child( - conflicts - .iter() - .map(|c| c.to_string()) - .collect::>() - .join("; "), - ), + conflicts + .iter() + .map(|c| c.to_string()) + .collect::>() + .join("; "), ), ), - ) - }), - ) + ), + ) + })) .child( // Scrollable content div() @@ -704,9 +799,9 @@ impl Render for KeybindingsHelp { .px(px(16.0)) .py(px(12.0)) .children(category_order.iter().filter_map(|category| { - categories.get(category).map(|bindings| { - self.render_category(category, bindings, cx) - }) + categories + .get(category) + .map(|bindings| self.render_category(category, bindings, cx)) })), ) .child( @@ -759,9 +854,12 @@ impl Render for KeybindingsHelp { .text_size(ui_text_md(cx)) .text_color(rgb(0xFFFFFF)) .child("Confirm") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.handle_reset_to_defaults(cx); - })), + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.handle_reset_to_defaults(cx); + }), + ), ) .child( div() @@ -775,9 +873,12 @@ impl Render for KeybindingsHelp { .text_size(ui_text_md(cx)) .text_color(rgb(t.text_primary)) .child("Cancel") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.cancel_reset(cx); - })), + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.cancel_reset(cx); + }), + ), ) }) .when(!self.show_reset_confirmation, |d| { @@ -793,9 +894,12 @@ impl Render for KeybindingsHelp { .text_size(ui_text_md(cx)) .text_color(rgb(t.text_primary)) .child("Reset to Defaults") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.handle_reset_to_defaults(cx); - })), + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.handle_reset_to_defaults(cx); + }), + ), ) }), ), diff --git a/crates/okena-app/src/views/overlays/log_console.rs b/crates/okena-app/src/views/overlays/log_console.rs index 2b531cd47..dfae7488a 100644 --- a/crates/okena-app/src/views/overlays/log_console.rs +++ b/crates/okena-app/src/views/overlays/log_console.rs @@ -70,9 +70,7 @@ impl LogConsole { cx.spawn(async move |this: WeakEntity, cx| { loop { smol::Timer::after(POLL_INTERVAL).await; - let alive = this - .update(cx, |this, cx| this.pull_new(cx)) - .is_ok(); + let alive = this.update(cx, |this, cx| this.pull_new(cx)).is_ok(); if !alive { break; } @@ -243,8 +241,7 @@ impl Render for LogConsole { let shown = visible.len(); if self.pending_scroll && shown > 0 { - self.scroll - .scroll_to_item(shown - 1, ScrollStrategy::Top); + self.scroll.scroll_to_item(shown - 1, ScrollStrategy::Top); self.pending_scroll = false; } @@ -252,9 +249,9 @@ impl Render for LogConsole { .track_focus(&focus_handle) .key_context("LogConsole") .on_action(cx.listener(|this, _: &Cancel, _window, cx| this.close(cx))) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - this.on_key(event, cx) - })) + .on_key_down( + cx.listener(|this, event: &KeyDownEvent, _window, cx| this.on_key(event, cx)), + ) .on_mouse_down( MouseButton::Left, cx.listener(|this, _, _window, cx| this.close(cx)), @@ -324,9 +321,8 @@ impl LogConsole { cx, )) .child( - h_flex() - .gap(px(4.0)) - .children([ + h_flex().gap(px(4.0)).children( + [ log::Level::Error, log::Level::Warn, log::Level::Info, @@ -336,7 +332,8 @@ impl LogConsole { .into_iter() .map(|lvl| { level_chip(lvl, self.min_level == lvl, t, cx).into_any_element() - })), + }), + ), ) .child(toggle_chip( "autoscroll", @@ -409,15 +406,11 @@ impl LogConsole { } let count = visible.len(); - uniform_list( - "log-console-list", - count, - move |range, _window, cx| { - range - .map(|i| render_row(&visible[i], &t, cx).into_any_element()) - .collect::>() - }, - ) + uniform_list("log-console-list", count, move |range, _window, cx| { + range + .map(|i| render_row(&visible[i], &t, cx).into_any_element()) + .collect::>() + }) .track_scroll(&self.scroll) .flex_1() .into_any_element() @@ -512,7 +505,9 @@ fn level_chip( ) -> impl IntoElement { let color = level_color(level, t); div() - .id(ElementId::Name(format!("lvl-{}", level_label(level)).into())) + .id(ElementId::Name( + format!("lvl-{}", level_label(level)).into(), + )) .px(px(6.0)) .py(px(2.0)) .rounded(px(4.0)) diff --git a/crates/okena-app/src/views/overlays/mod.rs b/crates/okena-app/src/views/overlays/mod.rs index 73f784b2b..766fd26a6 100644 --- a/crates/okena-app/src/views/overlays/mod.rs +++ b/crates/okena-app/src/views/overlays/mod.rs @@ -15,34 +15,34 @@ //! - Worktree dialog pub mod add_project_dialog; +pub mod close_worktree_dialog; pub mod command_palette; pub mod content_search; pub mod context_menu; -pub mod folder_context_menu; pub mod detached_overlay; pub mod detached_terminal; pub mod diff_viewer; pub mod file_search; pub mod file_viewer; +pub mod folder_context_menu; pub mod hook_log; pub mod keybindings_help; pub mod log_console; +pub mod pairing_dialog; +pub mod profile_manager; pub mod project_switcher; -pub mod session_manager; -pub mod settings_panel; -pub mod shell_selector_overlay; -pub mod terminal_overlay_utils; -pub mod theme_selector; pub mod remote_connect_dialog; pub mod remote_context_menu; pub mod remote_pair_dialog; +pub mod rename_directory_dialog; +pub mod session_manager; +pub mod settings_panel; +pub mod shell_selector_overlay; pub mod tab_context_menu; pub mod terminal_context_menu; -pub mod pairing_dialog; -pub mod close_worktree_dialog; -pub mod rename_directory_dialog; +pub mod terminal_overlay_utils; +pub mod theme_selector; pub mod worktree_dialog; -pub mod profile_manager; pub use project_switcher::{ProjectSwitcher, ProjectSwitcherEvent}; pub use shell_selector_overlay::{ShellSelectorOverlay, ShellSelectorOverlayEvent}; 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/profile_manager/actions.rs b/crates/okena-app/src/views/overlays/profile_manager/actions.rs index 12f658bb9..d81b67b69 100644 --- a/crates/okena-app/src/views/overlays/profile_manager/actions.rs +++ b/crates/okena-app/src/views/overlays/profile_manager/actions.rs @@ -45,7 +45,11 @@ impl ProfileManager { } pub(super) fn delete_profile(&mut self, id: &str, cx: &mut Context) { - match okena_core::profiles::delete_profile(id) { + match okena_core::profiles::delete_profile_with_cleanup(id, || { + okena_terminal::session_backend::reap_dtach_profile_sessions(id) + .map(|_| ()) + .map_err(anyhow::Error::from) + }) { Ok(()) => { self.show_delete_confirmation = None; self.refresh_profiles(); diff --git a/crates/okena-app/src/views/overlays/profile_manager/mod.rs b/crates/okena-app/src/views/overlays/profile_manager/mod.rs index 9e1db3433..5a6fcdea4 100644 --- a/crates/okena-app/src/views/overlays/profile_manager/mod.rs +++ b/crates/okena-app/src/views/overlays/profile_manager/mod.rs @@ -24,15 +24,13 @@ impl ProfileManager { let profiles = okena_core::profiles::all_profiles().unwrap_or_default(); - let default_profile_id = okena_core::profiles::ProfileIndex::load( - &okena_core::profiles::config_root(), - ) - .map(|idx| idx.default_profile) - .unwrap_or_else(|_| "default".to_string()); - - let new_profile_input = cx.new(|cx| { - SimpleInputState::new(cx).placeholder("New profile name...") - }); + let default_profile_id = + okena_core::profiles::ProfileIndex::load(&okena_core::profiles::config_root()) + .map(|idx| idx.default_profile) + .unwrap_or_else(|_| "default".to_string()); + + let new_profile_input = + cx.new(|cx| SimpleInputState::new(cx).placeholder("New profile name...")); Self { focus_handle, diff --git a/crates/okena-app/src/views/overlays/profile_manager/render.rs b/crates/okena-app/src/views/overlays/profile_manager/render.rs index acfde0b73..9c80afe2b 100644 --- a/crates/okena-app/src/views/overlays/profile_manager/render.rs +++ b/crates/okena-app/src/views/overlays/profile_manager/render.rs @@ -1,15 +1,19 @@ use crate::keybindings::Cancel; use crate::theme::{theme, with_alpha}; -use crate::ui::tokens::{ui_text, ui_text_sm, ui_text_md, ui_text_ms, ui_text_xl}; -use crate::views::components::{modal_backdrop, modal_content, modal_header, SimpleInput}; +use crate::ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; +use crate::views::components::{SimpleInput, modal_backdrop, modal_content, modal_header}; +use gpui::prelude::*; use gpui::*; use gpui_component::{h_flex, v_flex}; -use gpui::prelude::*; use super::ProfileManager; impl ProfileManager { - pub(super) fn render_profile_row(&self, entry: &okena_core::profiles::ProfileEntry, cx: &mut Context) -> impl IntoElement + use<> { + pub(super) fn render_profile_row( + &self, + entry: &okena_core::profiles::ProfileEntry, + cx: &mut Context, + ) -> impl IntoElement + use<> { let t = theme(cx); let id = entry.id.clone(); let display_name = entry.display_name.clone(); @@ -54,7 +58,9 @@ impl ProfileManager { .gap(px(8.0)) .child( div() - .id(SharedString::from(format!("profile-delete-confirm-{id}"))) + .id(SharedString::from(format!( + "profile-delete-confirm-{id}" + ))) .cursor_pointer() .px(px(10.0)) .py(px(4.0)) @@ -72,7 +78,9 @@ impl ProfileManager { ) .child( div() - .id(SharedString::from(format!("profile-delete-cancel-{id}"))) + .id(SharedString::from(format!( + "profile-delete-cancel-{id}" + ))) .cursor_pointer() .px(px(10.0)) .py(px(4.0)) @@ -138,7 +146,13 @@ impl ProfileManager { .py(px(4.0)) .rounded(px(4.0)) .bg(rgb(t.button_primary_bg)) - .hover(|s| if !is_active { s.bg(rgb(t.button_primary_hover)) } else { s }) + .hover(|s| { + if !is_active { + s.bg(rgb(t.button_primary_hover)) + } else { + s + } + }) .text_size(ui_text_md(cx)) .text_color(rgb(t.button_primary_fg)) .child("Switch") @@ -243,21 +257,15 @@ impl Render for ProfileManager { .flex_1() .overflow_y_scroll() .when(profiles.is_empty(), |d| { - d.flex() - .items_center() - .justify_center() - .p(px(32.0)) - .child( - div() - .text_size(ui_text_xl(cx)) - .text_color(rgb(t.text_muted)) - .child("No profiles found"), - ) + d.flex().items_center().justify_center().p(px(32.0)).child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_muted)) + .child("No profiles found"), + ) }) .when(!profiles.is_empty(), |d| { - d.children( - profiles.iter().map(|p| self.render_profile_row(p, cx)), - ) + d.children(profiles.iter().map(|p| self.render_profile_row(p, cx))) }), ) .child( @@ -278,16 +286,21 @@ impl Render for ProfileManager { .rounded(px(4.0)) .border_1() .border_color(rgb(t.border)) - .child(SimpleInput::new(&new_profile_input).text_size(ui_text(13.0, cx))) + .child( + SimpleInput::new(&new_profile_input) + .text_size(ui_text(13.0, cx)), + ) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - cx.stop_propagation(); - if event.keystroke.key.as_str() == "enter" { - this.create_profile(cx); - } - })), + .on_key_down(cx.listener( + |this, event: &KeyDownEvent, _window, cx| { + cx.stop_propagation(); + if event.keystroke.key.as_str() == "enter" { + this.create_profile(cx); + } + }, + )), ) .child( div() diff --git a/crates/okena-app/src/views/overlays/project_switcher.rs b/crates/okena-app/src/views/overlays/project_switcher.rs index bb30c2bcc..9436bd8e6 100644 --- a/crates/okena-app/src/views/overlays/project_switcher.rs +++ b/crates/okena-app/src/views/overlays/project_switcher.rs @@ -10,8 +10,8 @@ use crate::theme::{theme, with_alpha}; use crate::ui::tokens::{ui_text, ui_text_ms}; use crate::views::components::list_overlay::FilterResult; use crate::views::components::{ - badge, handle_list_overlay_key, keyboard_hints_footer, modal_backdrop, modal_content, - modal_header, search_input_area, ListOverlayAction, ListOverlayConfig, ListOverlayState, + ListOverlayAction, ListOverlayConfig, ListOverlayState, badge, handle_list_overlay_key, + keyboard_hints_footer, modal_backdrop, modal_content, modal_header, search_input_area, }; use crate::views::project_hover::set_hovered_project; use crate::workspace::state::{ProjectData, WindowId, Workspace}; @@ -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, } } @@ -129,7 +132,11 @@ impl ProjectSwitcher { /// an "open"; closed projects keep the modal open so the choice is obvious. fn jump_into_selected(&self, cx: &mut Context) { if let Some(project) = self.state.selected_item() { - let open = self.open_states.get(&project.id).copied().unwrap_or_default(); + let open = self + .open_states + .get(&project.id) + .copied() + .unwrap_or_default(); if open.open_here || open.open_elsewhere > 0 { cx.emit(ProjectSwitcherEvent::JumpToProject(project.id.clone())); } @@ -153,11 +160,19 @@ impl ProjectSwitcher { let project_id = project.id.clone(); let name = project.name.clone(); let path = project.path.clone(); - let open = self.open_states.get(&project.id).copied().unwrap_or_default(); + 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 @@ -323,7 +338,11 @@ impl ProjectSwitcher { .justify_center() .child( svg() - .path(if on { "icons/eye.svg" } else { "icons/eye-off.svg" }) + .path(if on { + "icons/eye.svg" + } else { + "icons/eye-off.svg" + }) .size(px(14.0)) .text_color(if on { rgb(t.border_active) @@ -491,11 +510,7 @@ impl Render for ProjectSwitcher { this.jump_into_selected(cx); })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - match handle_list_overlay_key( - &mut this.state, - event, - &[("space", "toggle")], - ) { + match handle_list_overlay_key(&mut this.state, event, &[("space", "toggle")]) { ListOverlayAction::Close => this.close(cx), ListOverlayAction::SelectPrev | ListOverlayAction::SelectNext => { this.state.scroll_to_selected(); @@ -589,6 +604,8 @@ mod tests { hook_terminals: HashMap::::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -621,10 +638,12 @@ mod tests { let here = WindowId::Main; let other = WindowId::Extra(uuid::Uuid::new_v4()); // main (here): p_other + p_closed hidden; other: p_here + p_closed hidden. - let main_hidden: HashSet = - ["p_other".to_string(), "p_closed".to_string()].into_iter().collect(); - let other_hidden: HashSet = - ["p_here".to_string(), "p_closed".to_string()].into_iter().collect(); + let main_hidden: HashSet = ["p_other".to_string(), "p_closed".to_string()] + .into_iter() + .collect(); + let other_hidden: HashSet = ["p_here".to_string(), "p_closed".to_string()] + .into_iter() + .collect(); let windows = vec![(here, &main_hidden), (other, &other_hidden)]; let here_only = compute_open_state("p_here", here, &windows); 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..b25891445 100644 --- a/crates/okena-app/src/views/overlays/session_manager/actions.rs +++ b/crates/okena-app/src/views/overlays/session_manager/actions.rs @@ -1,9 +1,6 @@ -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::SessionInfo; use gpui::*; +use okena_core::api::ActionRequest; use super::{SessionManager, SessionManagerEvent}; @@ -12,9 +9,36 @@ impl SessionManager { cx.emit(SessionManagerEvent::Close); } - pub(super) fn refresh_sessions(&mut self) { - self.sessions = crate::workspace::persistence::list_sessions().unwrap_or_default(); + pub(super) fn refresh_sessions(&mut self, cx: &mut Context) { + self.loading_sessions = true; self.error_message = None; + cx.notify(); + + let client = self.client.clone(); + cx.spawn(async move |this, cx| { + let result = smol::unblock(move || { + client + .post_action(ActionRequest::ListSessions) + .and_then(|value| value.ok_or_else(|| "Missing session list".to_string())) + .and_then(|value| { + serde_json::from_value::>(value) + .map_err(|error| format!("Invalid session list: {error}")) + }) + }) + .await; + + cx.update(|cx| { + let _ = this.update(cx, |this, cx| { + match result { + Ok(sessions) => this.sessions = sessions, + Err(error) => this.error_message = Some(error), + } + this.loading_sessions = false; + cx.notify(); + }); + }); + }) + .detach(); } pub(super) fn save_new_session(&mut self, cx: &mut Context) { @@ -25,41 +49,32 @@ impl SessionManager { return; } - if session_exists(&name) { + if self.sessions.iter().any(|session| session.name == name) { self.error_message = Some(format!("Session '{}' already exists", name)); cx.notify(); 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.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) { @@ -83,7 +98,8 @@ impl SessionManager { } pub(super) fn confirm_rename(&mut self, cx: &mut Context) { - let new_name = self.rename_input + let new_name = self + .rename_input .as_ref() .map(|input| input.read(cx).value().trim().to_string()) .unwrap_or_default(); @@ -96,7 +112,8 @@ impl SessionManager { return; } - if new_name != old_name && session_exists(&new_name) { + if new_name != old_name && self.sessions.iter().any(|session| session.name == new_name) + { self.error_message = Some(format!("Session '{}' already exists", new_name)); self.rename_input = None; cx.notify(); @@ -104,15 +121,27 @@ impl SessionManager { } if new_name != old_name { - match rename_session(&old_name, &new_name) { - Ok(()) => { - self.refresh_sessions(); - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Failed to rename session: {}", e)); - } - } + self.loading_sessions = true; + self.error_message = None; + let client = self.client.clone(); + cx.spawn(async move |this, cx| { + let result = smol::unblock(move || { + client.post_action(ActionRequest::RenameSession { old_name, new_name }) + }) + .await; + + cx.update(|cx| { + let _ = this.update(cx, |this, cx| match result { + Ok(_) => this.refresh_sessions(cx), + Err(error) => { + this.loading_sessions = false; + this.error_message = Some(error); + cx.notify(); + } + }); + }); + }) + .detach(); } } self.rename_input = None; @@ -130,17 +159,30 @@ impl SessionManager { } pub(super) fn delete_session(&mut self, name: &str, cx: &mut Context) { - match delete_session(name) { - Ok(()) => { - self.show_delete_confirmation = None; - self.refresh_sessions(); - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Failed to delete session: {}", e)); - } - } + self.show_delete_confirmation = None; + self.loading_sessions = true; + self.error_message = None; cx.notify(); + + let client = self.client.clone(); + let name = name.to_string(); + cx.spawn(async move |this, cx| { + let result = + smol::unblock(move || client.post_action(ActionRequest::DeleteSession { name })) + .await; + + cx.update(|cx| { + let _ = this.update(cx, |this, cx| match result { + Ok(_) => this.refresh_sessions(cx), + Err(error) => { + this.loading_sessions = false; + this.error_message = Some(error); + cx.notify(); + } + }); + }); + }) + .detach(); } pub(super) fn export_current(&mut self, cx: &mut Context) { @@ -151,17 +193,11 @@ 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 +209,11 @@ 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..9f632fe35 100644 --- a/crates/okena-app/src/views/overlays/session_manager/mod.rs +++ b/crates/okena-app/src/views/overlays/session_manager/mod.rs @@ -2,15 +2,19 @@ mod actions; mod render; use crate::views::components::SimpleInputState; -use crate::workspace::persistence::{list_sessions, SessionInfo}; -use crate::workspace::state::{Workspace, WorkspaceData}; +use crate::workspace::persistence::SessionInfo; 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) client: okena_transport::remote_action::RemoteActionClient, pub(crate) focus_handle: FocusHandle, pub(crate) sessions: Vec, + pub(crate) loading_sessions: bool, /// Input for new session name pub(crate) new_session_input: Entity, /// Input for renaming session (created when rename starts) @@ -32,18 +36,23 @@ pub(crate) enum SessionManagerTab { } impl SessionManager { - pub fn new(workspace: Entity, cx: &mut Context) -> Self { - let sessions = list_sessions().unwrap_or_default(); + pub fn new( + client: okena_transport::remote_action::RemoteActionClient, + cx: &mut Context, + ) -> Self { let focus_handle = cx.focus_handle(); // Default export path let default_export_path = dirs::home_dir() - .map(|p| p.join("workspace-export.json").to_string_lossy().to_string()) + .map(|p| { + p.join("workspace-export.json") + .to_string_lossy() + .to_string() + }) .unwrap_or_else(|| "workspace-export.json".to_string()); - let new_session_input = cx.new(|cx| { - SimpleInputState::new(cx).placeholder("Enter session name...") - }); + let new_session_input = + cx.new(|cx| SimpleInputState::new(cx).placeholder("Enter session name...")); let export_path_input = cx.new(|cx| { SimpleInputState::new(cx) @@ -51,14 +60,14 @@ impl SessionManager { .default_value(default_export_path) }); - let import_path_input = cx.new(|cx| { - SimpleInputState::new(cx).placeholder("Enter path to import...") - }); + let import_path_input = + cx.new(|cx| SimpleInputState::new(cx).placeholder("Enter path to import...")); - Self { - workspace, + let mut manager = Self { + client, focus_handle, - sessions, + sessions: Vec::new(), + loading_sessions: true, new_session_input, rename_input: None, renaming_session: None, @@ -67,14 +76,18 @@ impl SessionManager { export_path_input, import_path_input, active_tab: SessionManagerTab::Sessions, - } + }; + manager.refresh_sessions(cx); + manager } } 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/session_manager/render.rs b/crates/okena-app/src/views/overlays/session_manager/render.rs index ddfa0bc72..5d2418af1 100644 --- a/crates/okena-app/src/views/overlays/session_manager/render.rs +++ b/crates/okena-app/src/views/overlays/session_manager/render.rs @@ -1,11 +1,11 @@ use crate::keybindings::Cancel; use crate::theme::{theme, with_alpha}; -use crate::ui::tokens::{ui_text, ui_text_sm, ui_text_ms, ui_text_md, ui_text_xl}; -use crate::views::components::{modal_backdrop, modal_content, modal_header, SimpleInput}; -use crate::workspace::persistence::{config_dir, SessionInfo}; +use crate::ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; +use crate::views::components::{SimpleInput, modal_backdrop, modal_content, modal_header}; +use crate::workspace::persistence::SessionInfo; +use gpui::prelude::*; use gpui::*; use gpui_component::{h_flex, v_flex}; -use gpui::prelude::*; use super::{SessionManager, SessionManagerTab}; @@ -196,18 +196,22 @@ impl SessionManager { .rounded(px(4.0)) .border_1() .border_color(rgb(t.border_active)) - .child(SimpleInput::new(rename_input).text_size(ui_text(13.0, cx))) + .child( + SimpleInput::new(rename_input).text_size(ui_text(13.0, cx)), + ) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - cx.stop_propagation(); - match event.keystroke.key.as_str() { - "enter" => this.confirm_rename(cx), - "escape" => this.cancel_rename(cx), - _ => {} - } - })), + .on_key_down(cx.listener( + |this, event: &KeyDownEvent, _window, cx| { + cx.stop_propagation(); + match event.keystroke.key.as_str() { + "enter" => this.confirm_rename(cx), + "escape" => this.cancel_rename(cx), + _ => {} + } + }, + )), ) .child( h_flex() @@ -261,6 +265,7 @@ impl SessionManager { pub(super) fn render_sessions_tab(&self, cx: &mut Context) -> impl IntoElement { let t = theme(cx); let sessions = self.sessions.clone(); + let loading_sessions = self.loading_sessions; let new_session_input = self.new_session_input.clone(); v_flex() @@ -284,16 +289,21 @@ impl SessionManager { .rounded(px(4.0)) .border_1() .border_color(rgb(t.border)) - .child(SimpleInput::new(&new_session_input).text_size(ui_text(13.0, cx))) + .child( + SimpleInput::new(&new_session_input) + .text_size(ui_text(13.0, cx)), + ) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - cx.stop_propagation(); - if event.keystroke.key.as_str() == "enter" { - this.save_new_session(cx); - } - })), + .on_key_down(cx.listener( + |this, event: &KeyDownEvent, _window, cx| { + cx.stop_propagation(); + if event.keystroke.key.as_str() == "enter" { + this.save_new_session(cx); + } + }, + )), ) .child( div() @@ -322,18 +332,23 @@ impl SessionManager { .id("sessions-list") .flex_1() .overflow_y_scroll() - .when(sessions.is_empty(), |d| { - d.flex() - .items_center() - .justify_center() - .child( - div() - .text_size(ui_text_xl(cx)) - .text_color(rgb(t.text_muted)) - .child("No saved sessions"), - ) + .when(loading_sessions, |d| { + d.flex().items_center().justify_center().child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_muted)) + .child("Loading sessions…"), + ) }) - .when(!sessions.is_empty(), |d| { + .when(!loading_sessions && sessions.is_empty(), |d| { + d.flex().items_center().justify_center().child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_muted)) + .child("No saved sessions"), + ) + }) + .when(!loading_sessions && !sessions.is_empty(), |d| { d.children( sessions .iter() @@ -479,25 +494,13 @@ impl SessionManager { ), ) .child( - // Config directory info - v_flex() - .gap(px(4.0)) + div() .pt(px(16.0)) .border_t_1() .border_color(rgb(t.border)) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .child("Sessions are stored in:"), - ) - .child( - div() - .text_size(ui_text_sm(cx)) - .font_family("monospace") - .text_color(rgb(t.text_secondary)) - .child(config_dir().join("sessions").display().to_string()), - ), + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child("Sessions are stored by the local daemon."), ) } } @@ -622,7 +625,9 @@ impl Render for SessionManager { }) // Tab content .child(match active_tab { - SessionManagerTab::Sessions => self.render_sessions_tab(cx).into_any_element(), + SessionManagerTab::Sessions => { + self.render_sessions_tab(cx).into_any_element() + } SessionManagerTab::ExportImport => { self.render_export_import_tab(cx).into_any_element() } diff --git a/crates/okena-app/src/views/overlays/settings_panel/categories.rs b/crates/okena-app/src/views/overlays/settings_panel/categories.rs index 1a5a5d888..d96d937e6 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/categories.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/categories.rs @@ -26,7 +26,15 @@ impl SettingsCategory { } pub(super) fn all() -> &'static [SettingsCategory] { - &[Self::General, Self::Font, Self::Terminal, Self::Worktree, Self::Hooks, Self::Extensions, Self::PairedDevices] + &[ + Self::General, + Self::Font, + Self::Terminal, + Self::Worktree, + Self::Hooks, + Self::Extensions, + Self::PairedDevices, + ] } /// Categories available in project mode (only hooks for now) diff --git a/crates/okena-app/src/views/overlays/settings_panel/components.rs b/crates/okena-app/src/views/overlays/settings_panel/components.rs index 70013f09f..033831332 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/components.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/components.rs @@ -1,5 +1,5 @@ use crate::theme::ThemeColors; -use crate::ui::tokens::{ui_text, ui_text_sm, ui_text_md}; +use crate::ui::tokens::{ui_text, ui_text_md, ui_text_sm}; use crate::views::components::simple_input::{SimpleInput, SimpleInputState}; use gpui::*; use gpui_component::v_flex; @@ -80,5 +80,9 @@ pub(super) fn hook_input_row( /// Convert empty string to None, non-empty to Some pub(super) fn opt_string(s: &str) -> Option { - if s.is_empty() { None } else { Some(s.to_string()) } + if s.is_empty() { + None + } else { + Some(s.to_string()) + } } diff --git a/crates/okena-app/src/views/overlays/settings_panel/controls.rs b/crates/okena-app/src/views/overlays/settings_panel/controls.rs index 5a33241d8..6e67748ac 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/controls.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/controls.rs @@ -1,4 +1,4 @@ -use crate::settings::{settings_entity, SettingsState}; +use crate::settings::{SettingsState, settings_entity}; use crate::terminal::session_backend::SessionBackend; use crate::terminal::shell_config::ShellType; use crate::theme::theme; @@ -6,8 +6,8 @@ use crate::views::components::{dropdown_button, dropdown_option, dropdown_overla use gpui::*; use gpui_component::h_flex; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { // GPUI render helper: params are render inputs (value, bounds, callbacks). @@ -32,23 +32,32 @@ impl SettingsPanel { h_flex() .gap(px(4.0)) .child( - stepper_button(format!("{}-dec", id), "-", &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { + stepper_button(format!("{}-dec", id), "-", &t, cx).on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { let dec_fn = dec_fn.clone(); settings_entity(cx).update(cx, |state, cx| { dec_fn(state, value - step, cx); }); - })), + }), + ), ) - .child(value_display(format.replace("{}", &format!("{:.1}", value)), width, &t, cx)) + .child(value_display( + format.replace("{}", &format!("{:.1}", value)), + width, + &t, + cx, + )) .child( - stepper_button(format!("{}-inc", id), "+", &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { + stepper_button(format!("{}-inc", id), "+", &t, cx).on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { let inc_fn = inc_fn.clone(); settings_entity(cx).update(cx, |state, cx| { inc_fn(state, value + step, cx); }); - })), + }), + ), ), ) } @@ -74,23 +83,27 @@ impl SettingsPanel { h_flex() .gap(px(4.0)) .child( - stepper_button(format!("{}-dec", id), "-", &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { + stepper_button(format!("{}-dec", id), "-", &t, cx).on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { let dec_fn = dec_fn.clone(); settings_entity(cx).update(cx, |state, cx| { dec_fn(state, value.saturating_sub(step), cx); }); - })), + }), + ), ) .child(value_display(format!("{}", value), width, &t, cx)) .child( - stepper_button(format!("{}-inc", id), "+", &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { + stepper_button(format!("{}-inc", id), "+", &t, cx).on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { let inc_fn = inc_fn.clone(); settings_entity(cx).update(cx, |state, cx| { inc_fn(state, value + step, cx); }); - })), + }), + ), ), ) } @@ -107,78 +120,120 @@ impl SettingsPanel { let t = theme(cx); settings_row(id.to_string(), label, &t, cx, has_border).child( - toggle_switch(format!("{}-toggle", id), enabled, &t) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { + toggle_switch(format!("{}-toggle", id), enabled, &t).on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { let update_fn = update_fn.clone(); settings_entity(cx).update(cx, |state, cx| { update_fn(state, !enabled, cx); }); - })), + }), + ), ) } - pub(super) fn render_font_dropdown_row(&mut self, current_family: &str, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_font_dropdown_row( + &mut self, + current_family: &str, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); let font_bounds_setter = Self::bounds_setter(cx, |s, b| s.font_button_bounds = b); settings_row("font-family".to_string(), "Font Family", &t, cx, true).child( - dropdown_button("font-family-btn", current_family, self.font_dropdown_open, &t, cx, - font_bounds_setter) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { + dropdown_button( + "font-family-btn", + current_family, + self.font_dropdown_open, + &t, + cx, + font_bounds_setter, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { this.font_dropdown_open = !this.font_dropdown_open; this.shell_dropdown_open = false; this.session_backend_dropdown_open = false; this.project_dropdown_open = false; cx.notify(); - })), + }), + ), ) } - pub(super) fn render_font_dropdown_overlay(&self, current: &str, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_font_dropdown_overlay( + &self, + current: &str, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); - dropdown_overlay("font-family-dropdown-list", &t) - .children(FONT_FAMILIES.iter().map(|family| { + dropdown_overlay("font-family-dropdown-list", &t).children(FONT_FAMILIES.iter().map( + |family| { let is_selected = *family == current; let family_str = family.to_string(); dropdown_option(format!("font-opt-{}", family), family, is_selected, &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener({ - let family = family_str.clone(); - move |this, _, _, cx| { - let family = family.clone(); - settings_entity(cx).update(cx, |state, cx| { - state.set_font_family(family, cx); - }); - this.font_dropdown_open = false; - cx.notify(); - } - })) - })) + .on_mouse_down( + MouseButton::Left, + cx.listener({ + let family = family_str.clone(); + move |this, _, _, cx| { + let family = family.clone(); + settings_entity(cx).update(cx, |state, cx| { + state.set_font_family(family, cx); + }); + this.font_dropdown_open = false; + cx.notify(); + } + }), + ) + }, + )) } - pub(super) fn render_shell_dropdown_row(&mut self, current_shell: &ShellType, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_shell_dropdown_row( + &mut self, + current_shell: &ShellType, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); let display_name = current_shell.display_name(); let shell_bounds_setter = Self::bounds_setter(cx, |s, b| s.shell_button_bounds = b); settings_row("default-shell".to_string(), "Default Shell", &t, cx, true).child( - dropdown_button("default-shell-btn", &display_name, self.shell_dropdown_open, &t, cx, - shell_bounds_setter) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { + dropdown_button( + "default-shell-btn", + &display_name, + self.shell_dropdown_open, + &t, + cx, + shell_bounds_setter, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { this.shell_dropdown_open = !this.shell_dropdown_open; this.font_dropdown_open = false; this.session_backend_dropdown_open = false; this.project_dropdown_open = false; cx.notify(); - })), + }), + ), ) } - pub(super) fn render_shell_dropdown_overlay(&self, current_shell: &ShellType, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_shell_dropdown_overlay( + &self, + current_shell: &ShellType, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); - let available: Vec<_> = self.available_shells.iter() + let available: Vec<_> = self + .available_shells + .iter() .filter(|s| s.available) .collect(); @@ -191,39 +246,68 @@ impl SettingsPanel { let name = shell_info.name.clone(); dropdown_option(format!("shell-opt-{}", name), &name, is_selected, &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener({ - let shell_type = shell_type.clone(); - move |this, _, _, cx| { - let shell = shell_type.clone(); - settings_entity(cx).update(cx, |state, cx| { - state.set_default_shell(shell, cx); - }); - this.shell_dropdown_open = false; - cx.notify(); - } - })) + .on_mouse_down( + MouseButton::Left, + cx.listener({ + let shell_type = shell_type.clone(); + move |this, _, _, cx| { + let shell = shell_type.clone(); + settings_entity(cx).update(cx, |state, cx| { + state.set_default_shell(shell, cx); + }); + this.shell_dropdown_open = false; + cx.notify(); + } + }), + ) })) } - pub(super) fn render_session_backend_dropdown_row(&mut self, current_backend: &SessionBackend, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_session_backend_dropdown_row( + &mut self, + current_backend: &SessionBackend, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); let display_name = current_backend.display_name(); - let session_bounds_setter = Self::bounds_setter(cx, |s, b| s.session_backend_button_bounds = b); - settings_row_with_desc("session-backend".to_string(), "Session Backend", "Requires restart", &t, cx, true).child( - dropdown_button("session-backend-btn", display_name, self.session_backend_dropdown_open, &t, cx, - session_bounds_setter) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { + let session_bounds_setter = + Self::bounds_setter(cx, |s, b| s.session_backend_button_bounds = b); + settings_row_with_desc( + "session-backend".to_string(), + "Session Backend", + "Requires restart", + &t, + cx, + true, + ) + .child( + dropdown_button( + "session-backend-btn", + display_name, + self.session_backend_dropdown_open, + &t, + cx, + session_bounds_setter, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { this.session_backend_dropdown_open = !this.session_backend_dropdown_open; this.font_dropdown_open = false; this.shell_dropdown_open = false; this.project_dropdown_open = false; cx.notify(); - })), + }), + ), ) } - pub(super) fn render_session_backend_dropdown_overlay(&self, current_backend: &SessionBackend, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_session_backend_dropdown_overlay( + &self, + current_backend: &SessionBackend, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); dropdown_overlay("session-backend-dropdown-list", &t) @@ -233,14 +317,23 @@ impl SettingsPanel { let backend_copy = *backend; let name = backend.display_name(); - dropdown_option(format!("backend-opt-{:?}", backend), name, is_selected, &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| { + dropdown_option( + format!("backend-opt-{:?}", backend), + name, + is_selected, + &t, + cx, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { settings_entity(cx).update(cx, |state, cx| { state.set_session_backend(backend_copy, cx); }); this.session_backend_dropdown_open = false; cx.notify(); - })) + }), + ) })) } } diff --git a/crates/okena-app/src/views/overlays/settings_panel/header.rs b/crates/okena-app/src/views/overlays/settings_panel/header.rs index a6bde8d08..2d543c075 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/header.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/header.rs @@ -1,6 +1,6 @@ use crate::settings::open_settings_file; use crate::theme::theme; -use crate::ui::tokens::{ui_text_ms, ui_text_md, ui_text_xl}; +use crate::ui::tokens::{ui_text_md, ui_text_ms, ui_text_xl}; use crate::views::components::{dropdown_button, dropdown_option, dropdown_overlay}; use gpui::*; use gpui_component::h_flex; @@ -39,10 +39,13 @@ impl SettingsPanel { .text_size(ui_text_ms(cx)) .text_color(rgb(t.text_secondary)) .child("Edit in settings.json") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - open_settings_file(); - this.close(cx); - })), + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + open_settings_file(); + this.close(cx); + }), + ), ) .child( div() @@ -58,9 +61,12 @@ impl SettingsPanel { .text_size(ui_text_xl(cx)) .text_color(rgb(t.text_muted)) .child("\u{2715}") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - this.close(cx); - })), + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.close(cx); + }), + ), ), ) } @@ -70,11 +76,12 @@ impl SettingsPanel { let label = match &self.selected_project_id { None => "User".to_string(), - Some(pid) => { - self.workspace.read(cx).project(pid) - .map(|p| p.name.clone()) - .unwrap_or_else(|| "Unknown".to_string()) - } + Some(pid) => self + .workspace + .read(cx) + .project(pid) + .map(|p| p.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()), }; h_flex() @@ -94,23 +101,39 @@ impl SettingsPanel { ) .child( { - let project_bounds_setter = Self::bounds_setter(cx, |s, b| s.project_button_bounds = b); - dropdown_button("project-selector-btn", &label, self.project_dropdown_open, &t, cx, - project_bounds_setter) + let project_bounds_setter = + Self::bounds_setter(cx, |s, b| s.project_button_bounds = b); + dropdown_button( + "project-selector-btn", + &label, + self.project_dropdown_open, + &t, + cx, + project_bounds_setter, + ) } - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { this.project_dropdown_open = !this.project_dropdown_open; this.font_dropdown_open = false; this.shell_dropdown_open = false; this.session_backend_dropdown_open = false; cx.notify(); - })), + }), + ), ) } - pub(super) fn render_project_dropdown_overlay(&mut self, cx: &mut Context) -> impl IntoElement { + pub(super) fn render_project_dropdown_overlay( + &mut self, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); - let projects: Vec<(String, String)> = self.workspace.read(cx).projects() + let projects: Vec<(String, String)> = self + .workspace + .read(cx) + .projects() .iter() .map(|p| (p.id.clone(), p.name.clone())) .collect(); @@ -121,20 +144,32 @@ impl SettingsPanel { .min_w(px(180.0)) .max_h(px(250.0)) .child( - dropdown_option("project-opt-user", "User (Global)", is_user_selected, &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { + dropdown_option( + "project-opt-user", + "User (Global)", + is_user_selected, + &t, + cx, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { this.select_project(None, cx); - })) + }), + ), ) .children(projects.into_iter().map(|(id, name)| { let is_selected = self.selected_project_id.as_deref() == Some(&id); dropdown_option(format!("project-opt-{}", id), &name, is_selected, &t, cx) - .on_mouse_down(MouseButton::Left, cx.listener({ - let id = id.clone(); - move |this, _, _, cx| { - this.select_project(Some(id.clone()), cx); - } - })) + .on_mouse_down( + MouseButton::Left, + cx.listener({ + let id = id.clone(); + move |this, _, _, cx| { + this.select_project(Some(id.clone()), cx); + } + }), + ) })) } } 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..b0f65856d 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/mod.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/mod.rs @@ -21,16 +21,16 @@ use categories::SettingsCategory; use components::opt_string; use crate::keybindings::Cancel; -use crate::remote::auth::{AuthStore, TokenInfo}; use crate::remote::GlobalRemoteInfo; +use crate::remote::auth::{AuthStore, TokenInfo}; use crate::settings::settings_entity; -use crate::terminal::shell_config::{available_shells, AvailableShell}; +use crate::terminal::shell_config::{AvailableShell, available_shells}; use crate::theme::theme; -use crate::views::components::{dropdown_anchored_below, modal_backdrop, modal_content}; use crate::views::components::simple_input::{InputChangedEvent, SimpleInputState}; +use crate::views::components::{dropdown_anchored_below, modal_backdrop, modal_content}; use crate::workspace::state::Workspace; -use gpui::*; use gpui::prelude::*; +use gpui::*; use okena_extensions::ExtensionRegistry; use std::collections::HashMap; use std::sync::Arc; @@ -109,7 +109,12 @@ impl SettingsPanel { project_id: String, cx: &mut Context, ) -> Self { - Self::new_with_options(workspace, Some(project_id), Some(SettingsCategory::Hooks), cx) + Self::new_with_options( + workspace, + Some(project_id), + Some(SettingsCategory::Hooks), + cx, + ) } fn new_with_options( @@ -125,25 +130,37 @@ impl SettingsPanel { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. echo \"opened $OKENA_PROJECT_NAME\""); - match s.hooks.project.on_open { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.project.on_open { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_project_close = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. echo \"closed $OKENA_PROJECT_NAME\""); - match s.hooks.project.on_close { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.project.on_close { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_worktree_create = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. npm install"); - match s.hooks.worktree.on_create { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.on_create { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_worktree_close = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. cleanup script"); - match s.hooks.worktree.on_close { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.on_close { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); // Create new global hook inputs @@ -151,37 +168,55 @@ impl SettingsPanel { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. run linter before merge"); - match s.hooks.worktree.pre_merge { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.pre_merge { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_post_merge = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. notify team after merge"); - match s.hooks.worktree.post_merge { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.post_merge { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_before_worktree_remove = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. backup work before removal"); - match s.hooks.worktree.before_remove { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.before_remove { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_worktree_removed = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. cleanup after removal"); - match s.hooks.worktree.after_remove { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.after_remove { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_on_rebase_conflict = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. notify on rebase conflict"); - match s.hooks.worktree.on_rebase_conflict { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.on_rebase_conflict { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_on_dirty_worktree_close = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. backup uncommitted changes"); - match s.hooks.worktree.on_dirty_close { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.worktree.on_dirty_close { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); // Create global terminal hook inputs @@ -189,158 +224,366 @@ impl SettingsPanel { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. echo \"terminal created\""); - match s.hooks.terminal.on_create { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.terminal.on_create { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_terminal_on_close = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. echo \"terminal closed\""); - match s.hooks.terminal.on_close { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.terminal.on_close { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); let hook_terminal_shell_wrapper = cx.new(|cx| { let state = SimpleInputState::new(cx) .multiline() .placeholder("e.g. devcontainer exec -- {shell}"); - match s.hooks.terminal.shell_wrapper { Some(ref v) => state.default_value(v.clone()), None => state } + match s.hooks.terminal.shell_wrapper { + Some(ref v) => state.default_value(v.clone()), + None => state, + } }); // Subscribe to global hook input changes - cx.subscribe(&hook_project_open, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_project_on_open(val, cx)); - }).detach(); - cx.subscribe(&hook_project_close, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_project_on_close(val, cx)); - }).detach(); - cx.subscribe(&hook_worktree_create, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_on_create(val, cx)); - }).detach(); - cx.subscribe(&hook_worktree_close, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_on_close(val, cx)); - }).detach(); - cx.subscribe(&hook_pre_merge, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_pre_merge(val, cx)); - }).detach(); - cx.subscribe(&hook_post_merge, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_post_merge(val, cx)); - }).detach(); - cx.subscribe(&hook_before_worktree_remove, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_before_remove(val, cx)); - }).detach(); - cx.subscribe(&hook_worktree_removed, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_after_remove(val, cx)); - }).detach(); - cx.subscribe(&hook_on_rebase_conflict, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_on_rebase_conflict(val, cx)); - }).detach(); - cx.subscribe(&hook_on_dirty_worktree_close, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_worktree_on_dirty_close(val, cx)); - }).detach(); - cx.subscribe(&hook_terminal_on_create, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_terminal_on_create(val, cx)); - }).detach(); - cx.subscribe(&hook_terminal_on_close, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_terminal_on_close(val, cx)); - }).detach(); - cx.subscribe(&hook_terminal_shell_wrapper, |_this, entity, _: &InputChangedEvent, cx| { - let val = opt_string(entity.read(cx).value()); - settings_entity(cx).update(cx, |state, cx| state.set_hook_terminal_shell_wrapper(val, cx)); - }).detach(); + cx.subscribe( + &hook_project_open, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx).update(cx, |state, cx| state.set_hook_project_on_open(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_project_close, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_project_on_close(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_worktree_create, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_worktree_on_create(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_worktree_close, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_worktree_on_close(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_pre_merge, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_worktree_pre_merge(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_post_merge, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_worktree_post_merge(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_before_worktree_remove, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx).update(cx, |state, cx| { + state.set_hook_worktree_before_remove(val, cx) + }); + }, + ) + .detach(); + cx.subscribe( + &hook_worktree_removed, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx).update(cx, |state, cx| { + state.set_hook_worktree_after_remove(val, cx) + }); + }, + ) + .detach(); + cx.subscribe( + &hook_on_rebase_conflict, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx).update(cx, |state, cx| { + state.set_hook_worktree_on_rebase_conflict(val, cx) + }); + }, + ) + .detach(); + cx.subscribe( + &hook_on_dirty_worktree_close, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx).update(cx, |state, cx| { + state.set_hook_worktree_on_dirty_close(val, cx) + }); + }, + ) + .detach(); + cx.subscribe( + &hook_terminal_on_create, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_terminal_on_create(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_terminal_on_close, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx) + .update(cx, |state, cx| state.set_hook_terminal_on_close(val, cx)); + }, + ) + .detach(); + cx.subscribe( + &hook_terminal_shell_wrapper, + |_this, entity, _: &InputChangedEvent, cx| { + let val = opt_string(entity.read(cx).value()); + settings_entity(cx).update(cx, |state, cx| { + state.set_hook_terminal_shell_wrapper(val, cx) + }); + }, + ) + .detach(); // Create per-project hook inputs (initialized for selected project) - let project_hooks = project_id.as_ref().and_then(|pid| { - workspace.read(cx).project(pid).map(|p| p.hooks.clone()) - }); + let project_hooks = project_id + .as_ref() + .and_then(|pid| workspace.read(cx).project(pid).map(|p| p.hooks.clone())); let global_hooks = &s.hooks; let project_hook_project_open = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.project.on_open.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.project.on_open.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .project + .on_open + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.project.on_open.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_project_close = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.project.on_close.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.project.on_close.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .project + .on_close + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.project.on_close.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_worktree_create = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.on_create.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.on_create.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .on_create + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.on_create.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_worktree_close = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.on_close.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.on_close.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .on_close + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.on_close.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_pre_merge = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.pre_merge.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.pre_merge.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .pre_merge + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.pre_merge.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_post_merge = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.post_merge.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.post_merge.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .post_merge + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.post_merge.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_before_worktree_remove = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.before_remove.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.before_remove.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .before_remove + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.before_remove.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_worktree_removed = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.after_remove.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.after_remove.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .after_remove + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.after_remove.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_on_rebase_conflict = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.on_rebase_conflict.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.on_rebase_conflict.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .on_rebase_conflict + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.on_rebase_conflict.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_on_dirty_worktree_close = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.worktree.on_dirty_close.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.worktree.on_dirty_close.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .worktree + .on_dirty_close + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.worktree.on_dirty_close.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_terminal_on_create = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.terminal.on_create.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.terminal.on_create.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .terminal + .on_create + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.terminal.on_create.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_terminal_on_close = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.terminal.on_close.as_deref().unwrap_or("No global hook set")); - match project_hooks.as_ref().and_then(|h| h.terminal.on_close.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .terminal + .on_close + .as_deref() + .unwrap_or("No global hook set"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.terminal.on_close.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); let project_hook_terminal_shell_wrapper = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .multiline() - .placeholder(global_hooks.terminal.shell_wrapper.as_deref().unwrap_or("e.g. devcontainer exec -- {shell}")); - match project_hooks.as_ref().and_then(|h| h.terminal.shell_wrapper.as_ref()) { Some(v) => state.default_value(v.clone()), None => state } + let state = SimpleInputState::new(cx).multiline().placeholder( + global_hooks + .terminal + .shell_wrapper + .as_deref() + .unwrap_or("e.g. devcontainer exec -- {shell}"), + ); + match project_hooks + .as_ref() + .and_then(|h| h.terminal.shell_wrapper.as_ref()) + { + Some(v) => state.default_value(v.clone()), + None => state, + } }); // Subscribe to per-project hook input changes @@ -352,11 +595,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.project.on_open = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.project.on_open = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_project_close, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -364,11 +611,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.project.on_close = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.project.on_close = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_worktree_create, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -376,11 +627,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.on_create = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.on_create = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_worktree_close, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -388,11 +643,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.on_close = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.on_close = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_pre_merge, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -400,11 +659,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.pre_merge = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.pre_merge = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_post_merge, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -412,11 +675,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.post_merge = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.post_merge = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_before_worktree_remove, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -424,11 +691,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.before_remove = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.before_remove = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_worktree_removed, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -436,11 +707,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.after_remove = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.after_remove = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_on_rebase_conflict, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -448,11 +723,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.on_rebase_conflict = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.on_rebase_conflict = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_on_dirty_worktree_close, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -460,11 +739,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.worktree.on_dirty_close = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.worktree.on_dirty_close = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_terminal_on_create, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -472,11 +755,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.terminal.on_create = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.terminal.on_create = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_terminal_on_close, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -484,11 +771,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.terminal.on_close = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.terminal.on_close = val; + true + }); }); } } - }).detach(); + }) + .detach(); cx.subscribe(&project_hook_terminal_shell_wrapper, { let ws = ws.clone(); move |this, entity, _: &InputChangedEvent, cx| { @@ -496,11 +787,15 @@ impl SettingsPanel { let val = opt_string(entity.read(cx).value()); let pid = pid.clone(); ws.update(cx, |ws, cx| { - ws.with_project(&pid, cx, |p| { p.hooks.terminal.shell_wrapper = val; true }); + ws.with_project(&pid, cx, |p| { + p.hooks.terminal.shell_wrapper = val; + true + }); }); } } - }).detach(); + }) + .detach(); // Worktree path template input let worktree_dir_suffix_input = cx.new(|cx| { @@ -509,32 +804,52 @@ impl SettingsPanel { .highlight_vars() .default_value(s.worktree.path_template.clone()) }); - cx.subscribe(&worktree_dir_suffix_input, |_this, entity, _: &InputChangedEvent, cx| { - let val = entity.read(cx).value().to_string(); - settings_entity(cx).update(cx, |state, cx| state.set_worktree_path_template(val, cx)); - }).detach(); + cx.subscribe( + &worktree_dir_suffix_input, + |_this, entity, _: &InputChangedEvent, cx| { + let val = entity.read(cx).value().to_string(); + settings_entity(cx) + .update(cx, |state, cx| state.set_worktree_path_template(val, cx)); + }, + ) + .detach(); // File opener input let file_opener_input = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .placeholder("e.g. code, cursor, zed, vim"); - if !s.file_opener.is_empty() { state.default_value(s.file_opener.clone()) } else { state } + let state = SimpleInputState::new(cx).placeholder("e.g. code, cursor, zed, vim"); + if !s.file_opener.is_empty() { + state.default_value(s.file_opener.clone()) + } else { + state + } }); - cx.subscribe(&file_opener_input, |_this, entity, _: &InputChangedEvent, cx| { - let val = entity.read(cx).value().to_string(); - settings_entity(cx).update(cx, |state, cx| state.set_file_opener(val, cx)); - }).detach(); + cx.subscribe( + &file_opener_input, + |_this, entity, _: &InputChangedEvent, cx| { + let val = entity.read(cx).value().to_string(); + settings_entity(cx).update(cx, |state, cx| state.set_file_opener(val, cx)); + }, + ) + .detach(); // Remote listen address input let listen_address_input = cx.new(|cx| { - let state = SimpleInputState::new(cx) - .placeholder("e.g. 127.0.0.1, 0.0.0.0"); - if !s.remote_listen_address.is_empty() { state.default_value(s.remote_listen_address.clone()) } else { state } + let state = SimpleInputState::new(cx).placeholder("e.g. 127.0.0.1, 0.0.0.0"); + if !s.remote_listen_address.is_empty() { + state.default_value(s.remote_listen_address.clone()) + } else { + state + } }); - cx.subscribe(&listen_address_input, |_this, entity, _: &InputChangedEvent, cx| { - let val = entity.read(cx).value().to_string(); - settings_entity(cx).update(cx, |state, cx| state.set_remote_listen_address(val, cx)); - }).detach(); + cx.subscribe( + &listen_address_input, + |_this, entity, _: &InputChangedEvent, cx| { + let val = entity.read(cx).value().to_string(); + settings_entity(cx) + .update(cx, |state, cx| state.set_remote_listen_address(val, cx)); + }, + ) + .detach(); let (auth_store, paired_devices) = cx .try_global::() @@ -595,6 +910,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); } @@ -619,11 +936,16 @@ impl SettingsPanel { } fn has_open_dropdown(&self) -> bool { - self.font_dropdown_open || self.shell_dropdown_open || self.session_backend_dropdown_open || self.project_dropdown_open + self.font_dropdown_open + || self.shell_dropdown_open + || self.session_backend_dropdown_open + || self.project_dropdown_open } /// 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; @@ -644,74 +966,247 @@ impl SettingsPanel { fn reload_project_hook_inputs(&mut self, cx: &mut Context) { let global_hooks = settings_entity(cx).read(cx).settings.hooks.clone(); let project_hooks = self.selected_project_id.as_ref().and_then(|pid| { - self.workspace.read(cx).project(pid).map(|p| p.hooks.clone()) + self.workspace + .read(cx) + .project(pid) + .map(|p| p.hooks.clone()) }); // Update placeholders and values self.project_hook_project_open.update(cx, |state, cx| { - state.set_placeholder(global_hooks.project.on_open.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.project.on_open.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .project + .on_open + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.project.on_open.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); self.project_hook_project_close.update(cx, |state, cx| { - state.set_placeholder(global_hooks.project.on_close.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.project.on_close.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .project + .on_close + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.project.on_close.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); self.project_hook_worktree_create.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.on_create.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.on_create.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .worktree + .on_create + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.on_create.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); self.project_hook_worktree_close.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.on_close.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.on_close.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .worktree + .on_close + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.on_close.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); self.project_hook_pre_merge.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.pre_merge.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.pre_merge.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .worktree + .pre_merge + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.pre_merge.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); self.project_hook_post_merge.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.post_merge.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.post_merge.clone()).unwrap_or_default(); - state.set_value(val, cx); - }); - self.project_hook_before_worktree_remove.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.before_remove.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.before_remove.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .worktree + .post_merge + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.post_merge.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); + self.project_hook_before_worktree_remove + .update(cx, |state, cx| { + state.set_placeholder( + global_hooks + .worktree + .before_remove + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.before_remove.clone()) + .unwrap_or_default(); + state.set_value(val, cx); + }); self.project_hook_worktree_removed.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.after_remove.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.after_remove.clone()).unwrap_or_default(); - state.set_value(val, cx); - }); - self.project_hook_on_rebase_conflict.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.on_rebase_conflict.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.on_rebase_conflict.clone()).unwrap_or_default(); - state.set_value(val, cx); - }); - self.project_hook_on_dirty_worktree_close.update(cx, |state, cx| { - state.set_placeholder(global_hooks.worktree.on_dirty_close.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.worktree.on_dirty_close.clone()).unwrap_or_default(); - state.set_value(val, cx); - }); - self.project_hook_terminal_on_create.update(cx, |state, cx| { - state.set_placeholder(global_hooks.terminal.on_create.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.terminal.on_create.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .worktree + .after_remove + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.after_remove.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); + self.project_hook_on_rebase_conflict + .update(cx, |state, cx| { + state.set_placeholder( + global_hooks + .worktree + .on_rebase_conflict + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.on_rebase_conflict.clone()) + .unwrap_or_default(); + state.set_value(val, cx); + }); + self.project_hook_on_dirty_worktree_close + .update(cx, |state, cx| { + state.set_placeholder( + global_hooks + .worktree + .on_dirty_close + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.worktree.on_dirty_close.clone()) + .unwrap_or_default(); + state.set_value(val, cx); + }); + self.project_hook_terminal_on_create + .update(cx, |state, cx| { + state.set_placeholder( + global_hooks + .terminal + .on_create + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.terminal.on_create.clone()) + .unwrap_or_default(); + state.set_value(val, cx); + }); self.project_hook_terminal_on_close.update(cx, |state, cx| { - state.set_placeholder(global_hooks.terminal.on_close.as_deref().unwrap_or("No global hook set")); - let val = project_hooks.as_ref().and_then(|h| h.terminal.on_close.clone()).unwrap_or_default(); + state.set_placeholder( + global_hooks + .terminal + .on_close + .as_deref() + .unwrap_or("No global hook set"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.terminal.on_close.clone()) + .unwrap_or_default(); state.set_value(val, cx); }); - self.project_hook_terminal_shell_wrapper.update(cx, |state, cx| { - state.set_placeholder(global_hooks.terminal.shell_wrapper.as_deref().unwrap_or("e.g. devcontainer exec -- {shell}")); - let val = project_hooks.as_ref().and_then(|h| h.terminal.shell_wrapper.clone()).unwrap_or_default(); - state.set_value(val, cx); + self.project_hook_terminal_shell_wrapper + .update(cx, |state, cx| { + state.set_placeholder( + global_hooks + .terminal + .shell_wrapper + .as_deref() + .unwrap_or("e.g. devcontainer exec -- {shell}"), + ); + let val = project_hooks + .as_ref() + .and_then(|h| h.terminal.shell_wrapper.clone()) + .unwrap_or_default(); + state.set_value(val, cx); + }); + } + + /// 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), }); } @@ -741,15 +1236,13 @@ impl SettingsPanel { // Lazily create and cache the extension's settings view if !self.extension_views.contains_key(&ext_id) { // Clone the factory out to avoid holding a borrow on cx - let factory = cx - .try_global::() - .and_then(|registry| { - registry - .extensions() - .iter() - .find(|ext| ext.manifest.id == ext_id) - .and_then(|ext| ext.settings_view.clone()) - }); + let factory = cx.try_global::().and_then(|registry| { + registry + .extensions() + .iter() + .find(|ext| ext.manifest.id == ext_id) + .and_then(|ext| ext.settings_view.clone()) + }); if let Some(factory) = factory { let view = factory(cx); self.extension_views.insert(ext_id.clone(), view); @@ -766,6 +1259,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 {} @@ -791,14 +1293,17 @@ impl Render for SettingsPanel { this.close(cx); } })) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - if this.has_open_dropdown() { - this.close_all_dropdowns(); - cx.notify(); - } else { - this.close(cx); - } - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.has_open_dropdown() { + this.close_all_dropdowns(); + cx.notify(); + } else { + this.close(cx); + } + }), + ) .child( modal_content("settings-panel-modal", &t) .relative() @@ -825,36 +1330,62 @@ impl Render for SettingsPanel { .id("dropdown-backdrop") .absolute() .inset_0() - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - this.close_all_dropdowns(); - cx.notify(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.close_all_dropdowns(); + cx.notify(); + }), + ), ) }) // Dropdown overlays positioned below trigger button .when_some( - self.project_dropdown_open.then_some(self.project_button_bounds).flatten(), - |modal, bounds| modal.child(dropdown_anchored_below(bounds, self.render_project_dropdown_overlay(cx))), + self.project_dropdown_open + .then_some(self.project_button_bounds) + .flatten(), + |modal, bounds| { + modal.child(dropdown_anchored_below( + bounds, + self.render_project_dropdown_overlay(cx), + )) + }, ) .when_some( - self.font_dropdown_open.then_some(self.font_button_bounds).flatten(), + self.font_dropdown_open + .then_some(self.font_button_bounds) + .flatten(), |modal, bounds| { let current = settings_entity(cx).read(cx).settings.font_family.clone(); - modal.child(dropdown_anchored_below(bounds, self.render_font_dropdown_overlay(¤t, cx))) + modal.child(dropdown_anchored_below( + bounds, + self.render_font_dropdown_overlay(¤t, cx), + )) }, ) .when_some( - self.shell_dropdown_open.then_some(self.shell_button_bounds).flatten(), + self.shell_dropdown_open + .then_some(self.shell_button_bounds) + .flatten(), |modal, bounds| { - let current = settings_entity(cx).read(cx).settings.default_shell.clone(); - modal.child(dropdown_anchored_below(bounds, self.render_shell_dropdown_overlay(¤t, cx))) + let current = + settings_entity(cx).read(cx).settings.default_shell.clone(); + modal.child(dropdown_anchored_below( + bounds, + self.render_shell_dropdown_overlay(¤t, cx), + )) }, ) .when_some( - self.session_backend_dropdown_open.then_some(self.session_backend_button_bounds).flatten(), + self.session_backend_dropdown_open + .then_some(self.session_backend_button_bounds) + .flatten(), |modal, bounds| { let current = settings_entity(cx).read(cx).settings.session_backend; - modal.child(dropdown_anchored_below(bounds, self.render_session_backend_dropdown_overlay(¤t, cx))) + modal.child(dropdown_anchored_below( + bounds, + self.render_session_backend_dropdown_overlay(¤t, cx), + )) }, ), ) diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_extensions.rs b/crates/okena-app/src/views/overlays/settings_panel/render_extensions.rs index d3fc8a516..f2223d2be 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_extensions.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_extensions.rs @@ -3,8 +3,8 @@ use crate::theme::theme; use gpui::*; use okena_extensions::ExtensionRegistry; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_extensions(&mut self, cx: &mut Context) -> impl IntoElement { diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_font.rs b/crates/okena-app/src/views/overlays/settings_panel/render_font.rs index b2e077f65..3b3c74a21 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_font.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_font.rs @@ -2,35 +2,61 @@ use crate::settings::settings_entity; use crate::theme::theme; use gpui::*; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_font(&mut self, cx: &mut Context) -> impl IntoElement { let t = theme(cx); let s = settings_entity(cx).read(cx).settings.clone(); - div() - .child(section_header("Font", &t, cx)) - .child( - section_container(&t) - .child(self.render_number_stepper( - "font-size", "Font Size", s.font_size, "{}", 1.0, 50.0, true, - |state, val, cx| state.set_font_size(val, cx), cx, - )) - .child(self.render_font_dropdown_row(&s.font_family, cx)) - .child(self.render_number_stepper( - "line-height", "Line Height", s.line_height, "{}", 0.1, 50.0, true, - |state, val, cx| state.set_line_height(val, cx), cx, - )) - .child(self.render_number_stepper( - "ui-font-size", "UI Font Size", s.ui_font_size, "{}", 1.0, 50.0, true, - |state, val, cx| state.set_ui_font_size(val, cx), cx, - )) - .child(self.render_number_stepper( - "file-font-size", "File Font Size", s.file_font_size, "{}", 1.0, 50.0, false, - |state, val, cx| state.set_file_font_size(val, cx), cx, - )), - ) + div().child(section_header("Font", &t, cx)).child( + section_container(&t) + .child(self.render_number_stepper( + "font-size", + "Font Size", + s.font_size, + "{}", + 1.0, + 50.0, + true, + |state, val, cx| state.set_font_size(val, cx), + cx, + )) + .child(self.render_font_dropdown_row(&s.font_family, cx)) + .child(self.render_number_stepper( + "line-height", + "Line Height", + s.line_height, + "{}", + 0.1, + 50.0, + true, + |state, val, cx| state.set_line_height(val, cx), + cx, + )) + .child(self.render_number_stepper( + "ui-font-size", + "UI Font Size", + s.ui_font_size, + "{}", + 1.0, + 50.0, + true, + |state, val, cx| state.set_ui_font_size(val, cx), + cx, + )) + .child(self.render_number_stepper( + "file-font-size", + "File Font Size", + s.file_font_size, + "{}", + 1.0, + 50.0, + false, + |state, val, cx| state.set_file_font_size(val, cx), + cx, + )), + ) } } diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_general.rs b/crates/okena-app/src/views/overlays/settings_panel/render_general.rs index c1e60f24a..a7aed723f 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_general.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_general.rs @@ -1,16 +1,16 @@ use crate::remote::GlobalRemoteInfo; use crate::settings::settings_entity; use crate::theme::theme; -use crate::ui::tokens::{ui_text, ui_text_sm, ui_text_md}; +use crate::ui::tokens::{ui_text, ui_text_md, ui_text_sm}; use crate::views::components::simple_input::SimpleInput; use crate::workspace::settings::HeaderDensity; -use gpui::*; use gpui::prelude::*; +use gpui::*; use gpui_component::{h_flex, v_flex}; use okena_transport::client::tls::format_fingerprint; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_general(&mut self, cx: &mut Context) -> impl IntoElement { @@ -126,39 +126,43 @@ impl SettingsPanel { .child(section) .child(section_header("File Opener", &t, cx)) .child( - section_container(&t) - .child( - div() - .px(px(12.0)) - .py(px(8.0)) - .flex() - .flex_col() - .gap(px(6.0)) - .child( - v_flex() - .gap(px(2.0)) - .child( - div() - .text_size(ui_text(13.0, cx)) - .text_color(rgb(t.text_primary)) - .child("Editor Command"), - ) - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child("Command to open file paths (empty = system default)"), - ), - ) - .child( - div() - .bg(rgb(t.bg_secondary)) - .border_1() - .border_color(rgb(t.border)) - .rounded(px(4.0)) - .child(SimpleInput::new(&self.file_opener_input).text_size(ui_text_md(cx))), - ), - ), + section_container(&t).child( + div() + .px(px(12.0)) + .py(px(8.0)) + .flex() + .flex_col() + .gap(px(6.0)) + .child( + v_flex() + .gap(px(2.0)) + .child( + div() + .text_size(ui_text(13.0, cx)) + .text_color(rgb(t.text_primary)) + .child("Editor Command"), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child( + "Command to open file paths (empty = system default)", + ), + ), + ) + .child( + div() + .bg(rgb(t.bg_secondary)) + .border_1() + .border_color(rgb(t.border)) + .rounded(px(4.0)) + .child( + SimpleInput::new(&self.file_opener_input) + .text_size(ui_text_md(cx)), + ), + ), + ), ) .child(section_header("Notifications", &t, cx)) .child({ @@ -193,16 +197,14 @@ impl SettingsPanel { }) }) .child(section_header("Clipboard", &t, cx)) - .child( - section_container(&t).child(self.render_toggle( - "clipboard-read", - "Allow Clipboard Read (OSC 52)", - s.allow_clipboard_read, - false, - |state, val, cx| state.set_allow_clipboard_read(val, cx), - cx, - )), - ) + .child(section_container(&t).child(self.render_toggle( + "clipboard-read", + "Allow Clipboard Read (OSC 52)", + s.allow_clipboard_read, + false, + |state, val, cx| state.set_allow_clipboard_read(val, cx), + cx, + ))) } fn render_header_density_row( @@ -225,33 +227,39 @@ impl SettingsPanel { .rounded(px(4.0)) .bg(rgb(t.bg_secondary)) .p(px(2.0)) - .children(HeaderDensity::all_variants().iter().map(|&density: &HeaderDensity| { - let is_selected = density == current; - let hover_bg = t.bg_hover; - div() - .id(ElementId::Name( - format!("header-density-{:?}", density).into(), - )) - .cursor_pointer() - .px(px(8.0)) - .py(px(4.0)) - .rounded(px(3.0)) - .text_size(ui_text_md(cx)) - .when(is_selected, |el: Stateful
| { - el.bg(rgb(t.border_active)) - .text_color(rgb(t.text_primary)) - }) - .when(!is_selected, |el: Stateful
| { - el.text_color(rgb(t.text_muted)) - .hover(|s: StyleRefinement| s.bg(rgb(hover_bg))) - }) - .child(density.display_name().to_string()) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { - settings_entity(cx).update(cx, |state, cx| { - state.set_header_density(density, cx); - }); - })) - })), + .children( + HeaderDensity::all_variants() + .iter() + .map(|&density: &HeaderDensity| { + let is_selected = density == current; + let hover_bg = t.bg_hover; + div() + .id(ElementId::Name( + format!("header-density-{:?}", density).into(), + )) + .cursor_pointer() + .px(px(8.0)) + .py(px(4.0)) + .rounded(px(3.0)) + .text_size(ui_text_md(cx)) + .when(is_selected, |el: Stateful
| { + el.bg(rgb(t.border_active)).text_color(rgb(t.text_primary)) + }) + .when(!is_selected, |el: Stateful
| { + el.text_color(rgb(t.text_muted)) + .hover(|s: StyleRefinement| s.bg(rgb(hover_bg))) + }) + .child(density.display_name().to_string()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { + settings_entity(cx).update(cx, |state, cx| { + state.set_header_density(density, cx); + }); + }), + ) + }), + ), ) } } diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_hooks.rs b/crates/okena-app/src/views/overlays/settings_panel/render_hooks.rs index fdd6ff9b7..f8b11e2c0 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_hooks.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_hooks.rs @@ -2,8 +2,8 @@ use crate::theme::theme; use crate::ui::tokens::ui_text_sm; use gpui::*; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_hooks(&mut self, cx: &mut Context) -> impl IntoElement { @@ -44,9 +44,14 @@ impl SettingsPanel { ) }; - let scope_label = if is_project { "Project Hooks (override global)" } else { "Global Hooks" }; + let scope_label = if is_project { + "Project Hooks (override global)" + } else { + "Global Hooks" + }; let env_note = "Available env: $OKENA_PROJECT_ID, $OKENA_PROJECT_NAME, $OKENA_PROJECT_PATH"; - let merge_env_note = "Extra env: $OKENA_BRANCH, $OKENA_TARGET_BRANCH, $OKENA_MAIN_REPO_PATH"; + let merge_env_note = + "Extra env: $OKENA_BRANCH, $OKENA_TARGET_BRANCH, $OKENA_MAIN_REPO_PATH"; let multiline_hint = "Use multiple lines to chain actions. Prefix with terminal: to open in a terminal pane."; div() diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_paired_devices.rs b/crates/okena-app/src/views/overlays/settings_panel/render_paired_devices.rs index c6634d3d5..4363ea73f 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_paired_devices.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_paired_devices.rs @@ -1,10 +1,10 @@ use crate::theme::theme; -use crate::ui::tokens::{ui_text, ui_text_sm, ui_text_ms}; +use crate::ui::tokens::{ui_text, ui_text_ms, ui_text_sm}; use gpui::*; use okena_ui::empty_state::empty_state; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_paired_devices(&mut self, cx: &mut Context) -> impl IntoElement { @@ -15,13 +15,19 @@ impl SettingsPanel { if self.auth_store.is_none() { return content .child(section_header("Paired Devices", &t, cx)) - .child(section_container(&t).child(empty_state("Remote server is not running", &t, cx).py(px(16.0)))); + .child( + section_container(&t) + .child(empty_state("Remote server is not running", &t, cx).py(px(16.0))), + ); } if self.paired_devices.is_empty() { return content .child(section_header("Paired Devices", &t, cx)) - .child(section_container(&t).child(empty_state("No devices are currently paired", &t, cx).py(px(16.0)))); + .child( + section_container(&t) + .child(empty_state("No devices are currently paired", &t, cx).py(px(16.0))), + ); } let now_secs = std::time::SystemTime::now() diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_terminal.rs b/crates/okena-app/src/views/overlays/settings_panel/render_terminal.rs index c0741b320..b2ebf2b59 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_terminal.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_terminal.rs @@ -2,65 +2,107 @@ use crate::settings::settings_entity; use crate::theme::theme; use crate::ui::tokens::ui_text_md; use crate::workspace::settings::CursorShape; -use gpui::*; use gpui::prelude::FluentBuilder; +use gpui::*; use gpui_component::h_flex; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_terminal(&mut self, cx: &mut Context) -> impl IntoElement { let t = theme(cx); let s = settings_entity(cx).read(cx).settings.clone(); - div() - .child(section_header("Terminal", &t, cx)) - .child( - section_container(&t) - .child(self.render_shell_dropdown_row(&s.default_shell, cx)) - .child(self.render_session_backend_dropdown_row(&s.session_backend, cx)) - .child(self.render_toggle( - "show-shell-selector", "Show Shell Selector", s.show_shell_selector, true, - |state, val, cx| state.set_show_shell_selector(val, cx), cx, - )) - .child(self.render_cursor_style_row(s.cursor_style, cx)) - .child(self.render_toggle( - "cursor-blink", "Cursor Blink", s.cursor_blink, true, - |state, val, cx| state.set_cursor_blink(val, cx), cx, + div().child(section_header("Terminal", &t, cx)).child( + section_container(&t) + .child(self.render_shell_dropdown_row(&s.default_shell, cx)) + .child(self.render_session_backend_dropdown_row(&s.session_backend, cx)) + .child(self.render_toggle( + "show-shell-selector", + "Show Shell Selector", + s.show_shell_selector, + true, + |state, val, cx| state.set_show_shell_selector(val, cx), + cx, + )) + .child(self.render_cursor_style_row(s.cursor_style, cx)) + .child(self.render_toggle( + "cursor-blink", + "Cursor Blink", + s.cursor_blink, + true, + |state, val, cx| state.set_cursor_blink(val, cx), + cx, + )) + .child(self.render_integer_stepper( + "scrollback", + "Scrollback Lines", + s.scrollback_lines, + 1000, + 70.0, + true, + |state, val, cx| state.set_scrollback_lines(val, cx), + cx, + )) + .child(self.render_toggle( + "ctrl-c-copies", + "Ctrl+C Copies Selection", + s.terminal_ctrl_c_copies_selection, + true, + |state, val, cx| state.set_terminal_ctrl_c_copies_selection(val, cx), + cx, + )) + .child(self.render_toggle( + "idle-detection", + "Idle Detection", + s.idle_timeout_secs > 0, + true, + |state, val, cx| state.set_idle_timeout_secs(if val { 5 } else { 0 }, cx), + cx, + )) + .when(s.idle_timeout_secs > 0, |el| { + el.child(self.render_integer_stepper( + "idle-timeout", + "Idle Timeout (seconds)", + s.idle_timeout_secs, + 1, + 50.0, + false, + |state, val, cx| state.set_idle_timeout_secs(val, cx), + cx, )) - .child(self.render_integer_stepper( - "scrollback", "Scrollback Lines", s.scrollback_lines, 1000, 70.0, true, - |state, val, cx| state.set_scrollback_lines(val, cx), cx, + }) + .child(self.render_toggle( + "close-grace", + "Undo Close (busy terminals)", + s.terminal_close_grace_secs > 0, + true, + |state, val, cx| { + state.set_terminal_close_grace_secs(if val { 5 } else { 0 }, cx) + }, + cx, + )) + .when(s.terminal_close_grace_secs > 0, |el| { + el.child(self.render_integer_stepper( + "close-grace-secs", + "Undo Window (seconds)", + s.terminal_close_grace_secs, + 1, + 50.0, + false, + |state, val, cx| state.set_terminal_close_grace_secs(val, cx), + cx, )) - .child(self.render_toggle( - "ctrl-c-copies", "Ctrl+C Copies Selection", s.terminal_ctrl_c_copies_selection, true, - |state, val, cx| state.set_terminal_ctrl_c_copies_selection(val, cx), cx, - )) - .child(self.render_toggle( - "idle-detection", "Idle Detection", s.idle_timeout_secs > 0, true, - |state, val, cx| state.set_idle_timeout_secs(if val { 5 } else { 0 }, cx), cx, - )) - .when(s.idle_timeout_secs > 0, |el| { - el.child(self.render_integer_stepper( - "idle-timeout", "Idle Timeout (seconds)", s.idle_timeout_secs, 1, 50.0, false, - |state, val, cx| state.set_idle_timeout_secs(val, cx), cx, - )) - }) - .child(self.render_toggle( - "close-grace", "Undo Close (busy terminals)", s.terminal_close_grace_secs > 0, true, - |state, val, cx| state.set_terminal_close_grace_secs(if val { 5 } else { 0 }, cx), cx, - )) - .when(s.terminal_close_grace_secs > 0, |el| { - el.child(self.render_integer_stepper( - "close-grace-secs", "Undo Window (seconds)", s.terminal_close_grace_secs, 1, 50.0, false, - |state, val, cx| state.set_terminal_close_grace_secs(val, cx), cx, - )) - }), - ) + }), + ) } - fn render_cursor_style_row(&self, current: CursorShape, cx: &mut Context) -> impl IntoElement { + fn render_cursor_style_row( + &self, + current: CursorShape, + cx: &mut Context, + ) -> impl IntoElement { let t = theme(cx); settings_row("cursor-style".to_string(), "Cursor Style", &t, cx, true).child( @@ -69,31 +111,37 @@ impl SettingsPanel { .rounded(px(4.0)) .bg(rgb(t.bg_secondary)) .p(px(2.0)) - .children(CursorShape::all_variants().iter().map(|&style: &CursorShape| { - let is_selected = style == current; - let hover_bg = t.bg_hover; - div() - .id(ElementId::Name(format!("cursor-style-{:?}", style).into())) - .cursor_pointer() - .px(px(8.0)) - .py(px(4.0)) - .rounded(px(3.0)) - .text_size(ui_text_md(cx)) - .when(is_selected, |el: Stateful
| { - el.bg(rgb(t.border_active)) - .text_color(rgb(t.text_primary)) - }) - .when(!is_selected, |el: Stateful
| { - el.text_color(rgb(t.text_muted)) - .hover(|s: StyleRefinement| s.bg(rgb(hover_bg))) - }) - .child(style.display_name().to_string()) - .on_mouse_down(MouseButton::Left, cx.listener(move |_, _, _, cx| { - settings_entity(cx).update(cx, |state, cx| { - state.set_cursor_style(style, cx); - }); - })) - })), + .children( + CursorShape::all_variants() + .iter() + .map(|&style: &CursorShape| { + let is_selected = style == current; + let hover_bg = t.bg_hover; + div() + .id(ElementId::Name(format!("cursor-style-{:?}", style).into())) + .cursor_pointer() + .px(px(8.0)) + .py(px(4.0)) + .rounded(px(3.0)) + .text_size(ui_text_md(cx)) + .when(is_selected, |el: Stateful
| { + el.bg(rgb(t.border_active)).text_color(rgb(t.text_primary)) + }) + .when(!is_selected, |el: Stateful
| { + el.text_color(rgb(t.text_muted)) + .hover(|s: StyleRefinement| s.bg(rgb(hover_bg))) + }) + .child(style.display_name().to_string()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _, _, cx| { + settings_entity(cx).update(cx, |state, cx| { + state.set_cursor_style(style, cx); + }); + }), + ) + }), + ), ) } } diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_worktree.rs b/crates/okena-app/src/views/overlays/settings_panel/render_worktree.rs index 1a6b2e3ee..cbbcde5e1 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_worktree.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_worktree.rs @@ -2,8 +2,8 @@ use crate::settings::settings_entity; use crate::theme::theme; use gpui::*; -use super::components::*; use super::SettingsPanel; +use super::components::*; impl SettingsPanel { pub(super) fn render_worktree(&mut self, cx: &mut Context) -> impl IntoElement { @@ -13,21 +13,16 @@ impl SettingsPanel { div() .child(section_header("Path", &t, cx)) - .child( - section_container(&t) - .child( - hook_input_row( - "worktree-path-template", - "Path Template", - "relative to project dir. {repo} = repo name, {branch} = branch", - &self.worktree_dir_suffix_input, - "", - &t, - false, - cx, - ), - ), - ) + .child(section_container(&t).child(hook_input_row( + "worktree-path-template", + "Path Template", + "relative to project dir. {repo} = repo name, {branch} = branch", + &self.worktree_dir_suffix_input, + "", + &t, + false, + cx, + ))) .child(section_header("Close Defaults", &t, cx)) .child( section_container(&t) diff --git a/crates/okena-app/src/views/overlays/settings_panel/sidebar.rs b/crates/okena-app/src/views/overlays/settings_panel/sidebar.rs index 35ac8f73d..ba8744dcb 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/sidebar.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/sidebar.rs @@ -1,11 +1,11 @@ use crate::theme::theme; use crate::ui::tokens::ui_text_md; -use gpui::*; use gpui::prelude::*; +use gpui::*; use okena_extensions::ExtensionRegistry; -use super::categories::SettingsCategory; use super::SettingsPanel; +use super::categories::SettingsCategory; impl SettingsPanel { pub(super) fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { @@ -17,10 +17,14 @@ impl SettingsPanel { }; // Collect extension categories (extensions with settings_view that are enabled) - let ext_categories: Vec<(SettingsCategory, String)> = if self.selected_project_id.is_none() { + let ext_categories: Vec<(SettingsCategory, String)> = if self.selected_project_id.is_none() + { cx.try_global::() .map(|registry| { - let settings = crate::settings::settings_entity(cx).read(cx).settings.clone(); + let settings = crate::settings::settings_entity(cx) + .read(cx) + .settings + .clone(); registry .extensions() .iter() diff --git a/crates/okena-app/src/views/overlays/terminal_overlay_utils.rs b/crates/okena-app/src/views/overlays/terminal_overlay_utils.rs index e69de29bb..8b1378917 100644 --- a/crates/okena-app/src/views/overlays/terminal_overlay_utils.rs +++ b/crates/okena-app/src/views/overlays/terminal_overlay_utils.rs @@ -0,0 +1 @@ + diff --git a/crates/okena-app/src/views/overlays/theme_selector.rs b/crates/okena-app/src/views/overlays/theme_selector.rs index cf8fffdc1..57d6e05ac 100644 --- a/crates/okena-app/src/views/overlays/theme_selector.rs +++ b/crates/okena-app/src/views/overlays/theme_selector.rs @@ -1,17 +1,17 @@ use crate::keybindings::Cancel; use crate::settings::settings_entity; use crate::theme::{ - get_themes_dir, load_custom_themes, theme, theme_entity, ThemeColors, ThemeInfo, ThemeMode, - DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, + DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, ThemeColors, ThemeInfo, + ThemeMode, get_themes_dir, load_custom_themes, theme, theme_entity, }; +use crate::ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; use crate::views::components::{ - badge, handle_list_overlay_key, modal_backdrop, modal_content, modal_header, ListOverlayAction, - ListOverlayConfig, ListOverlayState, + ListOverlayAction, ListOverlayConfig, ListOverlayState, badge, handle_list_overlay_key, + modal_backdrop, modal_content, modal_header, }; -use crate::ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; +use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; -use gpui::prelude::*; use okena_ui::selectable_list::selectable_list_item; /// Theme selection entry with preview and info @@ -98,7 +98,10 @@ impl ThemeSelector { ThemeMode::HighContrast => 4, ThemeMode::Custom => { // Try to find matching custom theme - themes.iter().position(|t| t.info.id.starts_with("custom:")).unwrap_or(0) + themes + .iter() + .position(|t| t.info.id.starts_with("custom:")) + .unwrap_or(0) } }; @@ -111,7 +114,10 @@ impl ThemeSelector { let state = ListOverlayState::with_selected(themes, config, selected_index, cx); let focus_handle = state.focus_handle.clone(); - Self { focus_handle, state } + Self { + focus_handle, + state, + } } fn close(&self, cx: &mut Context) { @@ -151,10 +157,14 @@ impl ThemeSelector { cx.notify(); }); - // Save to settings via SettingsState (ensures in-memory and disk stay in sync) + // Publish through SettingsState; the daemon persists the preference. let custom_id = if mode == ThemeMode::Custom { // Extract file stem from "custom:stem" ID - theme_entry.info.id.strip_prefix("custom:").map(|s| s.to_string()) + theme_entry + .info + .id + .strip_prefix("custom:") + .map(|s| s.to_string()) } else { None }; @@ -229,9 +239,27 @@ impl ThemeSelector { .items_center() .gap(px(2.0)) .px(px(2.0)) - .child(div().w(px(4.0)).h(px(4.0)).rounded_full().bg(rgb(colors.term_red))) - .child(div().w(px(4.0)).h(px(4.0)).rounded_full().bg(rgb(colors.term_yellow))) - .child(div().w(px(4.0)).h(px(4.0)).rounded_full().bg(rgb(colors.term_green))), + .child( + div() + .w(px(4.0)) + .h(px(4.0)) + .rounded_full() + .bg(rgb(colors.term_red)), + ) + .child( + div() + .w(px(4.0)) + .h(px(4.0)) + .rounded_full() + .bg(rgb(colors.term_yellow)), + ) + .child( + div() + .w(px(4.0)) + .h(px(4.0)) + .rounded_full() + .bg(rgb(colors.term_green)), + ), ) .child( // Fake terminal content @@ -276,7 +304,12 @@ impl ThemeSelector { ) } - fn render_theme_row(&self, index: usize, entry: &ThemeEntry, cx: &mut Context) -> impl IntoElement + use<> { + fn render_theme_row( + &self, + index: usize, + entry: &ThemeEntry, + cx: &mut Context, + ) -> impl IntoElement + use<> { let t = theme(cx); let is_selected = index == self.state.selected_index; let colors = entry.colors; @@ -285,58 +318,56 @@ impl ThemeSelector { let is_custom = entry.info.id.starts_with("custom:"); selectable_list_item( - ElementId::Name(format!("theme-{}", index).into()), - is_selected, - &t, - ) - .gap(px(12.0)) - .py(px(10.0)) - .border_b_1() - .border_color(rgb(t.border)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, _window, cx| { - // Preview on click before selection - this.preview_theme(index, cx); - this.select_theme(index, cx); - }), - ) - .child(self.render_theme_preview(&colors, cx)) - .child( - div() - .flex_1() - .flex() - .flex_col() - .gap(px(2.0)) - .child( - h_flex() - .gap(px(8.0)) - .child( + ElementId::Name(format!("theme-{}", index).into()), + is_selected, + &t, + ) + .gap(px(12.0)) + .py(px(10.0)) + .border_b_1() + .border_color(rgb(t.border)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _window, cx| { + // Preview on click before selection + this.preview_theme(index, cx); + this.select_theme(index, cx); + }), + ) + .child(self.render_theme_preview(&colors, cx)) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + h_flex() + .gap(px(8.0)) + .child( + div() + .text_size(ui_text_xl(cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_primary)) + .child(name), + ) + .when(is_custom, |d| d.child(badge("Custom", &t))) + .when(is_selected, |d| { + d.child( div() - .text_size(ui_text_xl(cx)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(t.text_primary)) - .child(name), + .text_size(ui_text_md(cx)) + .text_color(rgb(t.border_active)) + .child("✓"), ) - .when(is_custom, |d| { - d.child(badge("Custom", &t)) - }) - .when(is_selected, |d| { - d.child( - div() - .text_size(ui_text_md(cx)) - .text_color(rgb(t.border_active)) - .child("✓"), - ) - }), - ) - .child( - div() - .text_size(ui_text_md(cx)) - .text_color(rgb(t.text_muted)) - .child(description), - ), - ) + }), + ) + .child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_muted)) + .child(description), + ), + ) } } @@ -381,9 +412,12 @@ impl Render for ThemeSelector { _ => {} } })) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child( modal_content("theme-selector-modal", &t) .w(px(config_width)) @@ -401,12 +435,12 @@ impl Render for ThemeSelector { .id("theme-list") .flex_1() .overflow_y_scroll() - .children( - self.state.filtered.iter().enumerate().map(|(i, filter_result)| { + .children(self.state.filtered.iter().enumerate().map( + |(i, filter_result)| { let entry = &self.state.items[filter_result.index]; self.render_theme_row(i, entry, cx) - }), - ), + }, + )), ) .child( // Footer - custom themes info diff --git a/crates/okena-app/src/views/panels/hook_panel.rs b/crates/okena-app/src/views/panels/hook_panel.rs index 8ccd14a47..11b38aadb 100644 --- a/crates/okena-app/src/views/panels/hook_panel.rs +++ b/crates/okena-app/src/views/panels/hook_panel.rs @@ -20,6 +20,7 @@ use okena_views_terminal::elements::resize_handle::ResizeHandle; use okena_views_terminal::layout::split_pane::{ActiveDrag, DragState}; use okena_views_terminal::layout::terminal_pane::TerminalPane; +use std::collections::HashSet; use std::sync::Arc; /// Per-project hook terminal panel entity. @@ -44,8 +45,15 @@ pub struct HookPanel { terminal_pane: Option>>, /// Height of the hook panel in pixels. panel_height: f32, - /// Number of hook terminals last observed (for auto-open on new hooks). - last_hook_count: usize, + /// Hook-terminal ids already observed. Seeded at construction with the + /// terminals present at that moment, so auto-open fires only for hooks that + /// appear AFTER the panel is built — a fresh create firing its hook while + /// we're attached — not stale hook terminals a running daemon still holds + /// and mirrors in on attach/relaunch. + seen_hook_ids: HashSet, + /// 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 { @@ -63,44 +71,66 @@ impl HookPanel { initial_height: f32, cx: &mut Context, ) -> Self { - let initial_count = workspace.read(cx).project(&project_id) - .map(|p| p.hook_terminals.len()) - .unwrap_or(0); + // Snapshot the hook terminals already present so they never auto-open: + // on attach/relaunch a running daemon mirrors its old hook terminals in + // at construction, and only genuinely new ones (a fresh create's hook) + // should pop the panel. + let seen_hook_ids: HashSet = workspace + .read(cx) + .project(&project_id) + .map(|p| p.hook_terminals.keys().cloned().collect()) + .unwrap_or_default(); // Observe workspace — auto-open when new hook terminals appear, // auto-close when all hooks finish. cx.observe(&workspace, |this: &mut Self, ws, cx| { - let project = ws.read(cx).project(&this.project_id); - let current_count = project.map(|p| p.hook_terminals.len()).unwrap_or(0); - - if current_count > this.last_hook_count { - // New hook terminal(s) appeared — auto-open with the latest one - if let Some(newest_tid) = project - .and_then(|p| { - p.hook_terminals.keys() - .find(|k| this.active_terminal_id.as_ref().is_none_or(|a| a != *k)) - .or_else(|| p.hook_terminals.keys().last()) - .cloned() - }) - { - this.auto_opened = true; - this.show_hook(&newest_tid, cx); - } - } else if this.panel_open && this.auto_opened && current_count > 0 { - // Auto-close when all hooks succeeded (stay open on failures) + // Read everything off the borrowed project up front into owned + // values, so the later show_hook/close mutations don't clash with + // the workspace read borrow. + let (current_count, current_ids, new_tid, all_succeeded) = { + let project = ws.read(cx).project(&this.project_id); + let current_ids: HashSet = project + .map(|p| p.hook_terminals.keys().cloned().collect()) + .unwrap_or_default(); + // Auto-open only for a hook terminal that appeared AFTER this + // panel was constructed (not in `seen_hook_ids`) — a fresh + // create firing its hook while we're attached. Hooks already + // present at construction are seeded into the set, so + // attaching/relaunching against a daemon that still holds old + // hook terminals does not reopen their panels. + let new_tid = current_ids + .iter() + .find(|k| !this.seen_hook_ids.contains(*k)) + .cloned(); let all_succeeded = project - .map(|p| p.hook_terminals.values() - .all(|e| e.status == HookTerminalStatus::Succeeded)) + .map(|p| { + p.hook_terminals + .values() + .all(|e| e.status == HookTerminalStatus::Succeeded) + }) .unwrap_or(false); - if all_succeeded { - this.close(cx); - } + (current_ids.len(), current_ids, new_tid, all_succeeded) + }; + + if let Some(new_tid) = new_tid { + this.auto_opened = true; + this.show_hook(&new_tid, cx); + } else if this.panel_open && this.auto_opened && current_count > 0 && all_succeeded { + // Auto-close when all hooks succeeded (stay open on failures) + this.close(cx); } - this.last_hook_count = current_count; + // Record the current id set so already-seen hooks never re-trigger + // auto-open on a later notify (and dismissed ids drop out). + this.seen_hook_ids = current_ids; cx.notify(); - }).detach(); + }) + .detach(); + // No construction-time auto-open: hooks already present here belong to + // the seeded `seen_hook_ids` set (attach/relaunch mirrors old hook + // terminals in). A fresh create fires its hook AFTER the panel is built, + // so the observe above catches the new id and opens the panel then. Self { project_id, workspace, @@ -115,10 +145,16 @@ impl HookPanel { active_terminal_id: None, terminal_pane: None, panel_height: initial_height, - last_hook_count: initial_count, + seen_hook_ids, + 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 { @@ -130,7 +166,10 @@ impl HookPanel { self.active_terminal_id = Some(terminal_id.to_string()); self.panel_open = true; - let project_path = self.workspace.read(cx).project(&self.project_id) + let project_path = self + .workspace + .read(cx) + .project(&self.project_id) .map(|p| p.path.clone()) .unwrap_or_default(); @@ -172,7 +211,10 @@ impl HookPanel { self.close(cx); } else { // Open with the first hook terminal, or just open empty - let first_tid = self.workspace.read(cx).project(&self.project_id) + let first_tid = self + .workspace + .read(cx) + .project(&self.project_id) .and_then(|p| p.hook_terminals.keys().next().cloned()); if let Some(tid) = first_tid { self.show_hook(&tid, cx); @@ -205,77 +247,59 @@ impl HookPanel { /// Get the hook terminals for this project. fn get_hook_list(&self, cx: &Context) -> Vec<(String, HookTerminalEntry)> { - self.workspace.read(cx).project(&self.project_id) + self.workspace + .read(cx) + .project(&self.project_id) .map(|p| { - p.hook_terminals.iter() + p.hook_terminals + .iter() .map(|(id, entry)| (id.clone(), entry.clone())) .collect() }) .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"); } } - /// Dismiss a hook terminal. + /// Ask the daemon to stop and remove a hook terminal. fn dismiss_hook(&mut self, terminal_id: &str, cx: &mut Context) { - if let Some(monitor) = cx.try_global::() { - monitor.notify_exit(terminal_id, None); + let next = self + .get_hook_list(cx) + .into_iter() + .find(|(id, _)| id != terminal_id) + .map(|(id, _)| id); + + if let Some(ref dispatcher) = self.action_dispatcher { + dispatcher.dispatch( + okena_core::api::ActionRequest::DismissHook { + project_id: self.project_id.clone(), + terminal_id: terminal_id.to_string(), + }, + cx, + ); + } else { + log::warn!("Cannot dismiss hook: no action dispatcher wired"); + return; } - self.workspace.update(cx, |ws, cx| { - ws.cancel_pending_worktree_close(terminal_id); - ws.remove_hook_terminal(terminal_id, cx); - }); - self.terminals.lock().remove(terminal_id); // If we just dismissed the active terminal, switch to another or close if self.active_terminal_id.as_deref() == Some(terminal_id) { - let next = self.workspace.read(cx).project(&self.project_id) - .and_then(|p| p.hook_terminals.keys().next().cloned()); if let Some(next_tid) = next { self.show_hook(&next_tid, cx); } else { @@ -286,7 +310,8 @@ impl HookPanel { /// Dismiss all hooks that are not currently running. fn dismiss_finished_hooks(&mut self, cx: &mut Context) { - let finished: Vec = self.get_hook_list(cx) + let finished: Vec = self + .get_hook_list(cx) .into_iter() .filter(|(_, e)| e.status != HookTerminalStatus::Running) .map(|(id, _)| id) @@ -309,9 +334,15 @@ impl HookPanel { } // Compute aggregate status color - let has_failed = hooks.iter().any(|(_, e)| matches!(e.status, HookTerminalStatus::Failed { .. })); - let has_running = hooks.iter().any(|(_, e)| e.status == HookTerminalStatus::Running); - let all_succeeded = hooks.iter().all(|(_, e)| e.status == HookTerminalStatus::Succeeded); + let has_failed = hooks + .iter() + .any(|(_, e)| matches!(e.status, HookTerminalStatus::Failed { .. })); + let has_running = hooks + .iter() + .any(|(_, e)| e.status == HookTerminalStatus::Running); + let all_succeeded = hooks + .iter() + .all(|(_, e)| e.status == HookTerminalStatus::Succeeded); let dot_color = if has_failed { t.term_red @@ -323,7 +354,11 @@ impl HookPanel { t.text_muted }; - let tooltip_text = format!("{} hook{}", hooks.len(), if hooks.len() == 1 { "" } else { "s" }); + let tooltip_text = format!( + "{} hook{}", + hooks.len(), + if hooks.len() == 1 { "" } else { "s" } + ); let entity = cx.entity().downgrade(); div() @@ -382,44 +417,40 @@ impl HookPanel { .h(px(panel_height)) .flex_shrink_0() // Resize handle - .child( - ResizeHandle::new( - true, - t.border, - t.border_active, - move |mouse_pos, _cx| { - *active_drag.borrow_mut() = Some(DragState::HookPanel { - project_id: project_id.clone(), - initial_mouse_y: f32::from(mouse_pos.y), - initial_height: panel_height, - }); - }, - ), - ) + .child(ResizeHandle::new( + true, + t.border, + t.border_active, + move |mouse_pos, _cx| { + *active_drag.borrow_mut() = Some(DragState::HookPanel { + project_id: project_id.clone(), + initial_mouse_y: f32::from(mouse_pos.y), + initial_height: panel_height, + }); + }, + )) // Tab header .child(self.render_header(&hooks, active_tid.as_deref(), t, cx)) // Content - .child( - if self.terminal_pane.is_some() { - div() - .flex_1() - .min_h_0() - .min_w_0() - .overflow_hidden() - .children(self.terminal_pane.clone()) - .into_any_element() - } else { - div() - .flex_1() - .flex() - .items_center() - .justify_center() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .child("Select a hook to view its output") - .into_any_element() - }, - ) + .child(if self.terminal_pane.is_some() { + div() + .flex_1() + .min_h_0() + .min_w_0() + .overflow_hidden() + .children(self.terminal_pane.clone()) + .into_any_element() + } else { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child("Select a hook to view its output") + .into_any_element() + }) .into_any_element() } @@ -449,58 +480,59 @@ impl HookPanel { .min_w_0() .flex() .overflow_x_scroll() - .children( - hooks.iter().map(|(tid, entry)| { - let is_active = active_tid == Some(tid.as_str()); - let status_color = match &entry.status { - HookTerminalStatus::Running => t.term_yellow, - HookTerminalStatus::Succeeded => t.success, - HookTerminalStatus::Failed { .. } => t.error, - }; - - let tid_for_click = tid.clone(); - let entity_for_click = entity.clone(); + .children(hooks.iter().map(|(tid, entry)| { + let is_active = active_tid == Some(tid.as_str()); + let status_color = match &entry.status { + HookTerminalStatus::Running => t.term_yellow, + HookTerminalStatus::Succeeded => t.success, + HookTerminalStatus::Failed { .. } => t.error, + }; - div() - .id(ElementId::Name(format!("hook-tab-{}", tid).into())) - .cursor_pointer() - .h(px(34.0)) - .px(px(12.0)) - .flex() - .items_center() - .flex_shrink_0() - .gap(px(6.0)) - .text_size(ui_text_md(cx)) - .when(is_active, |d| { - d.bg(rgb(t.bg_primary)) - .text_color(rgb(t.text_primary)) - }) - .when(!is_active, |d| { - d.text_color(rgb(t.text_secondary)) - .hover(|s| s.bg(rgb(t.bg_hover))) - }) - .child( - div() - .flex_shrink_0() - .w(px(7.0)) - .h(px(7.0)) - .rounded(px(3.5)) - .bg(rgb(status_color)), - ) - .child(entry.label.clone()) - .on_click(move |_, _window, cx| { - if let Some(e) = entity_for_click.upgrade() { - e.update(cx, |this, cx| { - this.show_hook(&tid_for_click, cx); - }); - } - }) - }), - ), + let tid_for_click = tid.clone(); + let entity_for_click = entity.clone(); + + div() + .id(ElementId::Name(format!("hook-tab-{}", tid).into())) + .cursor_pointer() + .h(px(34.0)) + .px(px(12.0)) + .flex() + .items_center() + .flex_shrink_0() + .gap(px(6.0)) + .text_size(ui_text_md(cx)) + .when(is_active, |d| { + d.bg(rgb(t.bg_primary)).text_color(rgb(t.text_primary)) + }) + .when(!is_active, |d| { + d.text_color(rgb(t.text_secondary)) + .hover(|s| s.bg(rgb(t.bg_hover))) + }) + .child( + div() + .flex_shrink_0() + .w(px(7.0)) + .h(px(7.0)) + .rounded(px(3.5)) + .bg(rgb(status_color)), + ) + .child(entry.label.clone()) + .on_click(move |_, _window, cx| { + if let Some(e) = entity_for_click.upgrade() { + e.update(cx, |this, cx| { + this.show_hook(&tid_for_click, cx); + }); + } + }) + })), ) // "Clear finished" link (when 2+ hooks are done) .when( - hooks.iter().filter(|(_, e)| e.status != HookTerminalStatus::Running).count() >= 2, + hooks + .iter() + .filter(|(_, e)| e.status != HookTerminalStatus::Running) + .count() + >= 2, |d| { let entity_clear = entity.clone(); d.child( @@ -530,7 +562,10 @@ impl HookPanel { // Action buttons for active hook .child({ let active_entry = active_tid.and_then(|tid| { - hooks.iter().find(|(id, _)| id == tid).map(|(id, e)| (id.clone(), e.clone())) + hooks + .iter() + .find(|(id, _)| id == tid) + .map(|(id, e)| (id.clone(), e.clone())) }); let mut actions = div() @@ -563,32 +598,28 @@ 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, - ); + let mut rerun_btn = + icon_button_sized("hook-panel-rerun", "icons/refresh.svg", 22.0, 12.0, t); if is_running { - rerun_btn = rerun_btn - .opacity(0.3) - .cursor_default(); + rerun_btn = rerun_btn.opacity(0.3).cursor_default(); } else { - rerun_btn = rerun_btn - .on_click(move |_, _window, cx| { - cx.stop_propagation(); - if let Some(e) = entity_rerun.upgrade() { - e.update(cx, |this, cx| { - this.rerun_hook(&tid_rerun, &command, &cwd, cx); - }); - } - }); + rerun_btn = rerun_btn.on_click(move |_, _window, cx| { + cx.stop_propagation(); + if let Some(e) = entity_rerun.upgrade() { + e.update(cx, |this, cx| { + this.rerun_hook(&tid_rerun, cx); + }); + } + }); } - actions = actions.child( - rerun_btn.tooltip(move |_window, cx| { - Tooltip::new(if is_running { "Running\u{2026}" } else { "Rerun Hook" }) - .build(_window, cx) - }), - ); + actions = actions.child(rerun_btn.tooltip(move |_window, cx| { + Tooltip::new(if is_running { + "Running\u{2026}" + } else { + "Rerun Hook" + }) + .build(_window, cx) + })); // Dismiss button (trash icon, red) let entity_dismiss = entity.clone(); @@ -619,9 +650,7 @@ impl HookPanel { }); } }) - .tooltip(|_window, cx| { - Tooltip::new("Dismiss Hook").build(_window, cx) - }), + .tooltip(|_window, cx| Tooltip::new("Dismiss Hook").build(_window, cx)), ); } @@ -658,9 +687,7 @@ impl HookPanel { e.update(cx, |this, cx| this.close(cx)); } }) - .tooltip(|_window, cx| { - Tooltip::new("Hide Panel").build(_window, cx) - }) + .tooltip(|_window, cx| Tooltip::new("Hide Panel").build(_window, 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..06f6c3ab4 100644 --- a/crates/okena-app/src/views/panels/project_column.rs +++ b/crates/okena-app/src/views/panels/project_column.rs @@ -1,26 +1,77 @@ +use crate::action_dispatch::ActionDispatcher; use crate::git; use crate::git::watcher::GitStatusWatcher; -use crate::action_dispatch::ActionDispatcher; -use okena_views_git::git_header::GitHeader; use crate::services::manager::ServiceManager; use crate::terminal::backend::TerminalBackend; -use crate::theme::{theme, ThemeColors}; +use crate::theme::{ThemeColors, theme}; +use crate::ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; use crate::views::layout::layout_container::LayoutContainer; use crate::views::layout::split_pane::ActiveDrag; use crate::workspace::request_broker::RequestBroker; use crate::workspace::state::{ProjectData, WindowId, Workspace}; -use crate::ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; use gpui::prelude::*; use gpui::*; use gpui_component::tooltip::Tooltip; use gpui_component::{h_flex, v_flex}; +use okena_views_git::git_header::GitHeader; use std::sync::Arc; -use okena_core::api::ActionRequest; -use okena_workspace::requests::{OverlayRequest, ProjectOverlay, ProjectOverlayKind}; -use okena_views_services::service_panel::ServicePanel; use crate::views::panels::hook_panel::HookPanel; use crate::views::window::TerminalsRegistry; +use okena_core::api::ActionRequest; +use okena_views_services::service_panel::ServicePanel; +use okena_workspace::requests::{OverlayRequest, ProjectOverlay, ProjectOverlayKind}; + +fn project_header_display_name(project: &ProjectData) -> String { + project.name.clone() +} + +/// What the project column paints in its main content area. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ColumnContent { + /// Worktree teardown is underway and its terminals are already gone. + Closing, + /// Normal case: render the pane tree. + Layout, + /// Worktree checkout is still being created. + Creating, + /// Bookmark project with no terminal attached. + Empty, +} + +/// Pick the content branch for a project column. +/// +/// `Closing` wins over `Layout` because a closing worktree keeps `layout: Some` +/// — `prepare_background_worktree_removal` nulls every leaf's `terminal_id` but +/// leaves the tree shape standing so `ensure_terminal` can't resurrect a PTY +/// inside the doomed checkout. Without this branch those id-less panes each +/// render the "Starting terminal…" placeholder for the whole removal window, +/// which is seconds on a real checkout. +/// +/// Deliberately gated on the terminals being *gone*, not on `closing` alone: +/// the merge/`before_remove`-hook phases also set the closing flag while the +/// terminals are still live and useful, and those must keep painting. +fn column_content(project: &ProjectData, closing: bool) -> ColumnContent { + let has_terminals = project + .layout + .as_ref() + .is_some_and(|layout| layout.has_terminal_ids()); + + if closing && !has_terminals { + ColumnContent::Closing + } else if project.layout.is_some() { + ColumnContent::Layout + } else if project.is_creating { + // Explicit mid-create marker set by the daemon while the git checkout + // runs and mirrored over the wire. NOT derived from a missing layout: a + // worktree whose last terminal the user closed is a legitimate bookmark + // (layout None) and must fall through to the empty state with its Start + // Terminal button, not the creating placeholder. + ColumnContent::Creating + } else { + ColumnContent::Empty + } +} /// A single project column with header and layout pub struct ProjectColumn { @@ -84,13 +135,33 @@ 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(); } - let initial_service_height = workspace.read(cx).data.service_panel_heights - .get(&project_id).copied().unwrap_or(200.0); + // 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); let git_header = { let pid = project_id.clone(); @@ -112,14 +183,30 @@ impl ProjectColumn { let ts = terminals.clone(); let ad = active_drag.clone(); cx.new(move |cx| { - ServicePanel::new(pid, ws, fm, rb, be, ts, ad, window_id, initial_service_height, cx) + ServicePanel::new( + pid, + ws, + fm, + rb, + be, + ts, + ad, + window_id, + initial_service_height, + cx, + ) }) }; // Observe service_panel so ProjectColumn re-renders when panel state changes cx.observe(&service_panel, |_, _, cx| cx.notify()).detach(); - let initial_hook_height = workspace.read(cx).data.hook_panel_heights - .get(&project_id).copied().unwrap_or(200.0); + let initial_hook_height = workspace + .read(cx) + .data + .hook_panel_heights + .get(&project_id) + .copied() + .unwrap_or(200.0); let hook_panel = { let pid = project_id.clone(); @@ -130,7 +217,18 @@ impl ProjectColumn { let ts = terminals.clone(); let ad = active_drag.clone(); cx.new(move |cx| { - HookPanel::new(pid, ws, fm, rb, be, ts, ad, window_id, initial_hook_height, cx) + HookPanel::new( + pid, + ws, + fm, + rb, + be, + ts, + ad, + window_id, + initial_hook_height, + cx, + ) }) }; cx.observe(&hook_panel, |_, _, cx| cx.notify()).detach(); @@ -189,14 +287,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); }); @@ -233,13 +338,15 @@ impl ProjectColumn { provider: Arc, cx: &mut Context, ) { - self.git_header.update(cx, |gh, cx| gh.set_git_provider(provider, cx)); + self.git_header + .update(cx, |gh, cx| gh.set_git_provider(provider, cx)); } /// Open the branch switcher popover for this project's header. /// No-op when the provider is read-only (remote-mirrored project). pub fn show_branch_picker(&mut self, window: &mut Window, cx: &mut Context) { - self.git_header.update(cx, |gh, cx| gh.show_branch_picker(window, cx)); + self.git_header + .update(cx, |gh, cx| gh.show_branch_picker(window, cx)); } /// Show a hook terminal in the hook panel. @@ -258,9 +365,16 @@ 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) + pub fn observe_remote_services( + &mut self, + workspace: Entity, + cx: &mut Context, + ) { + // 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); }); @@ -305,11 +419,20 @@ impl ProjectColumn { workspace.project(&self.project_id) } - fn render_hidden_taskbar(&self, project: &ProjectData, t: ThemeColors, cx: &App) -> impl IntoElement { - let minimized_terminals = project.layout.as_ref() + fn render_hidden_taskbar( + &self, + project: &ProjectData, + t: ThemeColors, + cx: &App, + ) -> impl IntoElement { + let minimized_terminals = project + .layout + .as_ref() .map(|l| l.collect_minimized_terminals()) .unwrap_or_default(); - let detached_terminals = project.layout.as_ref() + let detached_terminals = project + .layout + .as_ref() .map(|l| l.collect_detached_terminals()) .unwrap_or_default(); @@ -321,78 +444,120 @@ impl ProjectColumn { h_flex() // Minimized terminals .children( - minimized_terminals.into_iter().map(|(terminal_id, layout_path)| { - let workspace = self.workspace.clone(); - let project_id = self.project_id.clone(); - - let terminal_name = { - let osc_title = self.terminals.lock().get(&terminal_id).and_then(|t| t.title()); - project.terminal_display_name(&terminal_id, osc_title) - }; - - div() - .id(ElementId::Name(format!("minimized-{}", terminal_id).into())) - .cursor_pointer() - .px(px(8.0)) - .py(px(4.0)) - .border_l_1() - .border_color(rgb(t.border)) - .hover(|s| s.bg(rgb(t.bg_hover))) - .flex() - .items_center() - .gap(px(4.0)) - .text_size(ui_text_sm(cx)) - .child( - svg() - .path("icons/terminal-minimized.svg") - .size(px(10.0)) - .text_color(rgb(t.text_muted)) - ) - .child( - div() - .text_color(rgb(t.text_primary)) - .child(terminal_name) - ) - .on_click(move |_, _window, cx| { - workspace.update(cx, |ws, cx| { - ws.restore_terminal(&project_id, &layout_path, cx); - }); - }) - }) + minimized_terminals + .into_iter() + .map(|(terminal_id, layout_path)| { + let workspace = self.workspace.clone(); + let project_id = self.project_id.clone(); + + let terminal_name = { + let osc_title = self + .terminals + .lock() + .get(&terminal_id) + .and_then(|t| t.title()); + project.terminal_display_name(&terminal_id, osc_title) + }; + + div() + .id(ElementId::Name(format!("minimized-{}", terminal_id).into())) + .cursor_pointer() + .px(px(8.0)) + .py(px(4.0)) + .border_l_1() + .border_color(rgb(t.border)) + .hover(|s| s.bg(rgb(t.bg_hover))) + .flex() + .items_center() + .gap(px(4.0)) + .text_size(ui_text_sm(cx)) + .child( + svg() + .path("icons/terminal-minimized.svg") + .size(px(10.0)) + .text_color(rgb(t.text_muted)), + ) + .child(div().text_color(rgb(t.text_primary)).child(terminal_name)) + .on_click(move |_, _window, cx| { + workspace.update(cx, |ws, cx| { + ws.restore_terminal(&project_id, &layout_path, cx); + }); + }) + }), ) // Detached terminals (with different styling) .children( - detached_terminals.into_iter().map(|(terminal_id, _layout_path)| { - let workspace = self.workspace.clone(); - let terminal_id_for_click = terminal_id.clone(); - - let terminal_name = { - let osc_title = self.terminals.lock().get(&terminal_id).and_then(|t| t.title()); - project.terminal_display_name(&terminal_id, osc_title) - }; - - div() - .id(ElementId::Name(format!("detached-{}", terminal_id).into())) - .cursor_pointer() - .px(px(8.0)) - .py(px(4.0)) - .border_l_1() - .border_color(rgb(t.border)) - .bg(rgb(t.bg_hover)) - .hover(|s| s.bg(rgb(t.bg_selection))) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_primary)) - .child(format!("\u{2197} {}", terminal_name)) - .on_click(move |_, _window, cx| { - workspace.update(cx, |ws, cx| { - ws.attach_terminal(&terminal_id_for_click, cx); - }); - }) - }) + detached_terminals + .into_iter() + .map(|(terminal_id, _layout_path)| { + let workspace = self.workspace.clone(); + let terminal_id_for_click = terminal_id.clone(); + + let terminal_name = { + let osc_title = self + .terminals + .lock() + .get(&terminal_id) + .and_then(|t| t.title()); + project.terminal_display_name(&terminal_id, osc_title) + }; + + div() + .id(ElementId::Name(format!("detached-{}", terminal_id).into())) + .cursor_pointer() + .px(px(8.0)) + .py(px(4.0)) + .border_l_1() + .border_color(rgb(t.border)) + .bg(rgb(t.bg_hover)) + .hover(|s| s.bg(rgb(t.bg_selection))) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_primary)) + .child(format!("\u{2197} {}", terminal_name)) + .on_click(move |_, _window, cx| { + workspace.update(cx, |ws, cx| { + ws.attach_terminal(&terminal_id_for_click, cx); + }); + }) + }), ) .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(); @@ -408,31 +573,18 @@ impl ProjectColumn { // In the rows layout each project is short, so vertical space is // precious: collapse the comfortable two-row header back to a single // row (git info still shows, just inline) when the grid is stacked. - let is_rows = self.workspace.read(cx).project_layout_mode(self.window_id).is_rows(); + let is_rows = self + .workspace + .read(cx) + .project_layout_mode(self.window_id) + .is_rows(); 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 +607,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(); @@ -567,7 +712,10 @@ impl ProjectColumn { focus_manager_for_hide.update(cx, |fm, cx| { workspace_for_hide.update(cx, |ws, cx| { ws.toggle_project_overview_visibility( - fm, window_id_for_hide, &project_id_for_hide, cx, + fm, + window_id_for_hide, + &project_id_for_hide, + cx, ); }); }); @@ -603,7 +751,8 @@ impl ProjectColumn { workspace.update(cx, |ws, cx| { // Toggle: when already focused, clear // focus to return to the overview. - let target = if is_focused_view { None } else { Some(pid) }; + let target = + if is_focused_view { None } else { Some(pid) }; ws.set_focused_project(fm, target, cx); }); cx.notify(); @@ -621,21 +770,22 @@ impl ProjectColumn { ) }) .child({ - self.hook_panel.update(cx, |hp, cx| { - hp.render_hook_indicator(&t, cx) - }) + self.hook_panel + .update(cx, |hp, cx| hp.render_hook_indicator(&t, cx)) }) .child({ - self.service_panel.update(cx, |sp, cx| { - sp.render_service_indicator(&t, cx) - }) + self.service_panel + .update(cx, |sp, cx| sp.render_service_indicator(&t, cx)) }), ); let git_status_el = self.git_header.update(cx, |gh, cx| { gh.render_git_status(git_status.clone(), &t, cx) }); - let has_git = git_status.as_ref().and_then(|g| g.branch.as_ref()).is_some(); + let has_git = git_status + .as_ref() + .and_then(|g| g.branch.as_ref()) + .is_some(); let context_menu_handler = { let request_broker = self.request_broker.clone(); @@ -746,6 +896,40 @@ impl ProjectColumn { .child(header_body) } + /// Render the closing state shown while a worktree is being torn down. + fn render_closing_state(&self, cx: &mut Context) -> impl IntoElement { + let t = theme(cx); + v_flex() + .items_center() + .justify_center() + .size_full() + .gap(px(12.0)) + .bg(rgb(t.bg_primary)) + .child( + svg() + .path("icons/git-branch.svg") + .size(px(48.0)) + .text_color(rgb(t.text_muted)), + ) + .child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_secondary)) + .child("Closing worktree\u{2026}"), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .max_w(px(240.0)) + .text_center() + // Deliberately not "removing the checkout": the before_remove + // hook phase reaches this screen too, and that phase is still + // abortable — nothing has been deleted yet. + .child("This project disappears once the worktree is gone."), + ) + } + /// Render empty state for bookmark projects (no terminal) fn render_creating_state(&self, cx: &mut Context) -> impl IntoElement { let t = theme(cx); @@ -791,13 +975,13 @@ impl ProjectColumn { svg() .path("icons/folder.svg") .size(px(48.0)) - .text_color(rgb(t.text_muted)) + .text_color(rgb(t.text_muted)), ) .child( div() .text_size(ui_text_xl(cx)) .text_color(rgb(t.text_muted)) - .child("No terminal attached") + .child("No terminal attached"), ) .child( div() @@ -815,14 +999,14 @@ impl ProjectColumn { svg() .path("icons/terminal.svg") .size(px(14.0)) - .text_color(rgb(t.button_primary_fg)) + .text_color(rgb(t.button_primary_fg)), ) .child( div() .text_size(ui_text_md(cx)) .font_weight(FontWeight::MEDIUM) .text_color(rgb(t.button_primary_fg)) - .child("Start Terminal") + .child("Start Terminal"), ) .on_click({ let dispatcher = self.action_dispatcher.clone(); @@ -836,7 +1020,7 @@ impl ProjectColumn { ); } } - }) + }), ) .child(self.render_tip(cx)) } @@ -915,11 +1099,7 @@ impl ProjectColumn { .size(px(11.0)) .text_color(rgb(t.text_muted)), ) - .child( - div() - .text_size(ui_text_sm(cx)) - .child("Another tip"), - ) + .child(div().text_size(ui_text_sm(cx)).child("Another tip")) .on_click(cx.listener(|this, _ev, _window, cx| { this.tip_index = this.tip_index.wrapping_add(1); cx.notify(); @@ -936,15 +1116,21 @@ impl Render for ProjectColumn { match project { Some(project) => { - let has_layout = project.layout.is_some(); - - let is_creating = workspace.is_creating_project(&self.project_id); + // Daemon-authoritative flag mirrored over the wire, OR'd with the + // initiating client's optimistic tracker — same pair the sidebar + // row reads for its "Closing…" label. + let is_closing = project.is_closing || workspace.is_project_closing(&project.id); + let content_kind = column_content(&project, is_closing); // Soft tinted background based on folder color (when enabled) let bg_color = if crate::settings::settings(cx).color_tinted_background { let color = workspace.effective_folder_color(&project); if color != crate::theme::FolderColor::Default { - rgb(crate::ui::tint_color(t.bg_primary, t.get_folder_color(color), 0.025)) + rgb(crate::ui::tint_color( + t.bg_primary, + t.get_folder_color(color), + 0.025, + )) } else { rgb(t.bg_primary) } @@ -952,31 +1138,33 @@ impl Render for ProjectColumn { rgb(t.bg_primary) }; - // Content: layout, creating state, or empty bookmark state - let content = if has_layout { - self.ensure_layout_container(project.path.clone(), cx); + // Content: layout, closing/creating placeholder, or empty bookmark state + let content = match content_kind { + ColumnContent::Layout => { + self.ensure_layout_container(project.path.clone(), cx); - div() - .id("project-column-content") - .flex_1() - .min_h_0() - .overflow_hidden() - .when_some(self.layout_container.clone(), |d, container| { - d.child(AnyView::from(container).cached( - StyleRefinement::default().size_full(), - )) - }) - .into_any_element() - } else if is_creating { - self.render_creating_state(cx).into_any_element() - } else { - self.render_empty_state(cx).into_any_element() + div() + .id("project-column-content") + .flex_1() + .min_h_0() + .overflow_hidden() + .when_some(self.layout_container.clone(), |d, container| { + d.child( + AnyView::from(container) + .cached(StyleRefinement::default().size_full()), + ) + }) + .into_any_element() + } + ColumnContent::Closing => self.render_closing_state(cx).into_any_element(), + ColumnContent::Creating => self.render_creating_state(cx).into_any_element(), + ColumnContent::Empty => 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,22 +1180,16 @@ 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| { - sp.render_panel(&t, cx) - }) + self.service_panel + .update(cx, |sp, cx| sp.render_panel(&t, cx)) }) // Diff popover (delegated to GitHeader entity) .child({ - self.git_header.update(cx, |gh, cx| { - gh.render_diff_popover(&t, cx) - }) + self.git_header + .update(cx, |gh, cx| gh.render_diff_popover(&t, cx)) }) // Commit log popover (delegated to GitHeader entity) .child({ @@ -1017,18 +1199,25 @@ impl Render for ProjectColumn { }) // Branch picker popover (delegated to GitHeader entity) .child({ - self.git_header.update(cx, |gh, cx| { - gh.render_branch_picker(window, &t, cx) - }) + self.git_header + .update(cx, |gh, cx| 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| { - gh.render_ci_checks_popover(ci_checks.as_ref(), pr_info.as_ref(), &t, cx) + gh.render_ci_checks_popover( + ci_checks.as_ref(), + pr_info.as_ref(), + &t, + cx, + ) }) }) .into_any_element() @@ -1045,3 +1234,136 @@ impl Render for ProjectColumn { } } } + +#[cfg(test)] +mod tests { + use super::{ColumnContent, column_content, project_header_display_name}; + use crate::workspace::settings::HooksConfig; + use crate::workspace::state::{LayoutNode, ProjectData, SplitDirection, 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, + is_creating: false, + is_closing: false, + } + } + + /// A layout tree in the exact shape `prepare_background_worktree_removal` + /// leaves behind: structure intact, every leaf's terminal id nulled. + fn split_layout(terminal_ids: [Option<&str>; 2]) -> LayoutNode { + let children = terminal_ids + .into_iter() + .map(|id| { + let mut node = LayoutNode::new_terminal(); + if let LayoutNode::Terminal { terminal_id, .. } = &mut node { + *terminal_id = id.map(str::to_string); + } + node + }) + .collect(); + + LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![0.5, 0.5], + children, + } + } + + #[test] + fn closing_worktree_with_nulled_terminal_ids_shows_closing_state() { + let mut project = project_with_name("feature-login"); + project.layout = Some(split_layout([None, None])); + project.is_closing = true; + + assert_eq!( + column_content(&project, true), + ColumnContent::Closing, + "id-less panes mid-teardown must not fall through to \"Starting terminal…\"", + ); + } + + #[test] + fn closing_worktree_keeps_painting_live_terminals() { + let mut project = project_with_name("feature-login"); + project.layout = Some(split_layout([Some("t1"), None])); + project.is_closing = true; + + assert_eq!( + column_content(&project, true), + ColumnContent::Layout, + "merge and before_remove-hook phases keep the terminals alive and visible", + ); + } + + #[test] + fn closing_bookmark_without_layout_shows_closing_not_empty_state() { + let mut project = project_with_name("feature-login"); + project.is_closing = true; + + assert_eq!( + column_content(&project, true), + ColumnContent::Closing, + "a closing project must not offer a Start Terminal button", + ); + } + + #[test] + fn optimistic_client_closing_flag_alone_triggers_closing_state() { + let project = project_with_name("feature-login"); + + assert_eq!( + column_content(&project, true), + ColumnContent::Closing, + "the initiating client marks closing in its tracker before the mirror catches up", + ); + } + + #[test] + fn non_closing_project_branches_are_unchanged() { + let mut project = project_with_name("feature-login"); + assert_eq!(column_content(&project, false), ColumnContent::Empty); + + project.is_creating = true; + assert_eq!(column_content(&project, false), ColumnContent::Creating); + + project.layout = Some(split_layout([None, None])); + assert_eq!( + column_content(&project, false), + ColumnContent::Layout, + "a fresh worktree's uninitialized slots still render panes, not a placeholder", + ); + } + + #[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..bf03dc290 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 crate::workspace::state::Workspace; 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, @@ -48,9 +65,13 @@ impl SystemInfoCache { self.system.refresh_memory(); // Calculate average CPU usage across all cores - let cpu_usage = self.system.cpus().iter() + let cpu_usage = self + .system + .cpus() + .iter() .map(|cpu| cpu.cpu_usage()) - .sum::() / self.system.cpus().len().max(1) as f32; + .sum::() + / self.system.cpus().len().max(1) as f32; let memory_used = self.system.used_memory() as f64 / 1_073_741_824.0; // bytes to GB let memory_total = self.system.total_memory() as f64 / 1_073_741_824.0; @@ -78,10 +99,17 @@ 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 { - pub fn new(workspace: Entity, focus_manager: Entity, cx: &mut Context) -> Self { + pub fn new( + workspace: Entity, + focus_manager: Entity, + cx: &mut Context, + ) -> Self { let cache = Arc::new(Mutex::new(SystemInfoCache::new())); // Initial refresh @@ -105,19 +133,27 @@ impl StatusBar { break; // View was dropped } } - }).detach(); + }) + .detach(); // Clone activate functions from the global registry. - let activate_fns: Vec<_> = cx.try_global::() + let activate_fns: Vec<_> = cx + .try_global::() .map(|registry| { - registry.extensions().iter() + registry + .extensions() + .iter() .map(|ext| (ext.manifest.id.to_string(), ext.activate.clone())) .collect() }) .unwrap_or_default(); // Activate initially enabled extensions - let enabled = settings_entity(cx).read(cx).settings.enabled_extensions.clone(); + let enabled = settings_entity(cx) + .read(cx) + .settings + .enabled_extensions + .clone(); let active_extensions = Self::activate_extensions(&activate_fns, &enabled, cx); // Observe settings to sync extensions when enabled_extensions changes @@ -125,7 +161,8 @@ impl StatusBar { cx.observe(&settings, |this, entity, cx| { let enabled = entity.read(cx).settings.enabled_extensions.clone(); this.sync_extensions(&enabled, cx); - }).detach(); + }) + .detach(); // Re-render when workspace changes (for focused project updates) cx.observe(&workspace, |_, _, cx| cx.notify()).detach(); @@ -133,7 +170,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, } } @@ -143,7 +188,8 @@ impl StatusBar { enabled: &HashSet, cx: &mut App, ) -> HashMap { - activate_fns.iter() + activate_fns + .iter() .filter(|(id, _)| enabled.contains(id.as_str())) .map(|(id, activate)| (id.clone(), activate(cx))) .collect() @@ -154,7 +200,8 @@ impl StatusBar { /// (dropping the instance cancels background tasks and releases views). fn sync_extensions(&mut self, enabled: &HashSet, cx: &mut Context) { // Deactivate disabled (drop instances → cancel tasks) - self.active_extensions.retain(|id, _| enabled.contains(id.as_str())); + self.active_extensions + .retain(|id, _| enabled.contains(id.as_str())); // Activate newly enabled for (id, activate) in &self.activate_fns { @@ -173,6 +220,15 @@ 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,18 +239,381 @@ 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(); // Format memory - let memory_str = format!("{:.1}/{:.1} GB", stats.memory_used_gb, stats.memory_total_gb); + let memory_str = format!( + "{:.1}/{:.1} GB", + stats.memory_used_gb, stats.memory_total_gb + ); let memory_percent = if stats.memory_total_gb > 0.0 { (stats.memory_used_gb / stats.memory_total_gb * 100.0) as u32 } else { @@ -218,12 +637,16 @@ impl Render for StatusBar { }; // Collect widgets in stable registry order from active extensions - let left_widgets: Vec<&Vec> = self.activate_fns.iter() + let left_widgets: Vec<&Vec> = self + .activate_fns + .iter() .filter_map(|(id, _)| self.active_extensions.get(id)) .map(|inst| &inst.status_bar_widgets) .filter(|w| !w.is_empty()) .collect(); - let right_widgets: Vec<&Vec> = self.activate_fns.iter() + let right_widgets: Vec<&Vec> = self + .activate_fns + .iter() .filter_map(|(id, _)| self.active_extensions.get(id)) .map(|inst| &inst.status_bar_right_widgets) .filter(|w| !w.is_empty()) @@ -242,7 +665,8 @@ impl Render for StatusBar { .text_size(ui_text_ms(cx)) // Left side - sidebar toggle (macOS only) + system stats .child({ - let mut left = h_flex().gap(px(16.0)) + let mut left = h_flex() + .gap(px(16.0)) // On macOS, sidebar toggle lives in the status bar footer .when(cfg!(target_os = "macos"), |d| { d.child( @@ -269,31 +693,19 @@ impl Render for StatusBar { .child( h_flex() .gap(px(4.0)) - .child( - div() - .text_color(rgb(t.text_muted)) - .child("CPU") - ) + .child(div().text_color(rgb(t.text_muted)).child("CPU")) .child( div() .text_color(rgb(cpu_color)) - .child(format!("{:02.0}%", stats.cpu_usage)) - ) + .child(format!("{:02.0}%", stats.cpu_usage)), + ), ) // Memory .child( h_flex() .gap(px(4.0)) - .child( - div() - .text_color(rgb(t.text_muted)) - .child("MEM") - ) - .child( - div() - .text_color(rgb(mem_color)) - .child(memory_str) - ) + .child(div().text_color(rgb(t.text_muted)).child("MEM")) + .child(div().text_color(rgb(mem_color)).child(memory_str)), ); // Left-side extension widgets @@ -307,8 +719,7 @@ impl Render for StatusBar { }) // Right side - remote info + version + time .child({ - let mut right = h_flex() - .gap(px(8.0)); + let mut right = h_flex().gap(px(8.0)); // Right-side extension widgets for widgets in &right_widgets { @@ -317,41 +728,106 @@ 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 = { @@ -402,7 +878,7 @@ impl Render for StatusBar { cx.notify(); }); }), - ) + ), ); } @@ -411,14 +887,11 @@ impl Render for StatusBar { el.child( div() .text_color(rgb(t.text_muted)) - .child(format!("v{}", env!("CARGO_PKG_VERSION"))) + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), ) }) - .child( - div() - .text_color(rgb(t.text_secondary)) - .child(time_str) - ) + .child(div().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..6f0d08cc2 100644 --- a/crates/okena-app/src/views/panels/toast.rs +++ b/crates/okena-app/src/views/panels/toast.rs @@ -2,7 +2,9 @@ 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::{ + ICON_SM, RADIUS_STD, SPACE_LG, SPACE_MD, SPACE_SM, SPACE_XS, ui_text_ms, ui_text_xs, +}; use gpui::prelude::FluentBuilder; use gpui::*; use std::time::Duration; @@ -24,21 +26,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", } } @@ -77,7 +79,9 @@ impl ToastOverlay { let result = this.update(cx, |this, cx| { // Drain pending toasts from HookMonitor into ToastManager - if let Some(monitor) = cx.try_global::() { + if let Some(monitor) = + cx.try_global::() + { let hook_toasts = monitor.drain_pending_toasts(); ToastManager::post_batch(hook_toasts, cx); } @@ -111,7 +115,6 @@ impl ToastOverlay { } } - impl EventEmitter for ToastOverlay {} impl Render for ToastOverlay { @@ -143,92 +146,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 +265,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 +306,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,19 +321,25 @@ 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, }; div() - .id(SharedString::from(format!("toast-action-{}-{}", toast_id, action.id))) + .id(SharedString::from(format!( + "toast-action-{}-{}", + toast_id, action.id + ))) .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/tips.rs b/crates/okena-app/src/views/tips.rs index 8bdee6c30..a7fd2148d 100644 --- a/crates/okena-app/src/views/tips.rs +++ b/crates/okena-app/src/views/tips.rs @@ -27,80 +27,198 @@ pub struct Tip { /// Tip triggered by a keybinding — its chip shows the live shortcut for `action`. const fn key(text: &'static str, action: &'static str) -> Tip { - Tip { text, action: Some(action), hint: None } + Tip { + text, + action: Some(action), + hint: None, + } } /// Tip triggered some other way — its chip shows the static `hint`. const fn hint(text: &'static str, hint: &'static str) -> Tip { - Tip { text, action: None, hint: Some(hint) } + Tip { + text, + action: None, + hint: Some(hint), + } } /// Tip with no chip (an ambient behavior, or keys are already in the text). const fn plain(text: &'static str) -> Tip { - Tip { text, action: None, hint: None } + Tip { + text, + action: None, + hint: None, + } } /// The curated tip pool. pub static TIPS: &[Tip] = &[ // Windows, projects & layout - key("Open a second window onto the same workspace — each window keeps its own set of visible projects.", "NewWindow"), - key("Overlay numbers on every pane, then press the digit to jump straight to it.", "TogglePaneSwitcher"), - key("Zoom the focused terminal full-screen, then cycle the rest with ⌘] / ⌘[.", "ToggleFullscreen"), - key("Resize every column and pane to match in a single keystroke.", "EqualizeLayout"), - key("Switch your project grid between columns and rows — handy on a portrait or vertical monitor.", "ToggleProjectLayout"), - key("Minimize a busy terminal instead of closing it — it keeps running and comes back later.", "MinimizeTerminal"), - key("Zoom to just the project holding the active terminal; press ⌘0 to show them all again.", "FocusActiveProject"), - hint("Give each project one of 12 colors so its column is easy to spot.", "right-click a project"), - hint("Group projects into folders, drag to reorder, and \"Show Only This Folder\" to focus one group.", "drag / right-click"), - hint("Hide a project from a window without deleting it — it stays in your other windows.", "right-click"), - + key( + "Open a second window onto the same workspace — each window keeps its own set of visible projects.", + "NewWindow", + ), + key( + "Overlay numbers on every pane, then press the digit to jump straight to it.", + "TogglePaneSwitcher", + ), + key( + "Zoom the focused terminal full-screen, then cycle the rest with ⌘] / ⌘[.", + "ToggleFullscreen", + ), + key( + "Resize every column and pane to match in a single keystroke.", + "EqualizeLayout", + ), + key( + "Switch your project grid between columns and rows — handy on a portrait or vertical monitor.", + "ToggleProjectLayout", + ), + key( + "Minimize a busy terminal instead of closing it — it keeps running and comes back later.", + "MinimizeTerminal", + ), + key( + "Zoom to just the project holding the active terminal; press ⌘0 to show them all again.", + "FocusActiveProject", + ), + hint( + "Give each project one of 12 colors so its column is easy to spot.", + "right-click a project", + ), + hint( + "Group projects into folders, drag to reorder, and \"Show Only This Folder\" to focus one group.", + "drag / right-click", + ), + hint( + "Hide a project from a window without deleting it — it stays in your other windows.", + "right-click", + ), // Splits, tabs & navigation - key("Split a terminal into side-by-side or stacked panes.", "SplitVertical"), - key("Add tabs inside a pane — double-click a tab to rename, middle-click to close.", "AddTab"), + key( + "Split a terminal into side-by-side or stacked panes.", + "SplitVertical", + ), + key( + "Add tabs inside a pane — double-click a tab to rename, middle-click to close.", + "AddTab", + ), plain("Move focus between panes by direction with ⌘⌥ (Ctrl+Alt) + the arrow keys."), - // Find anything - key("Open the command palette to find any action — and see its shortcut.", "ShowCommandPalette"), - key("Jump between projects fast — Enter to focus, Space to show/hide in this window.", "ShowProjectSwitcher"), + key( + "Open the command palette to find any action — and see its shortcut.", + "ShowCommandPalette", + ), + key( + "Jump between projects fast — Enter to focus, Space to show/hide in this window.", + "ShowProjectSwitcher", + ), key("Fuzzy-find any file in the project.", "ShowFileSearch"), - key("Search file contents across the project with literal, regex, or fuzzy matching.", "ShowContentSearch"), - + key( + "Search file contents across the project with literal, regex, or fuzzy matching.", + "ShowContentSearch", + ), // Terminal - key("Jump to the previous or next shell prompt instantly (needs OSC 133 shell integration).", "JumpToPreviousPrompt"), - hint("⌘/Ctrl-click a file path (file:line:col) or a URL in the terminal to open it in your editor or browser.", "⌘/Ctrl + click"), - key("Search the terminal scrollback with regex and a live match count.", "Search"), - hint("Press Shift+Enter to insert a newline for multi-line input without submitting.", "⇧Enter"), - key("Zoom the font size of just one terminal; press ⌘0 to reset it.", "ZoomIn"), - + key( + "Jump to the previous or next shell prompt instantly (needs OSC 133 shell integration).", + "JumpToPreviousPrompt", + ), + hint( + "⌘/Ctrl-click a file path (file:line:col) or a URL in the terminal to open it in your editor or browser.", + "⌘/Ctrl + click", + ), + key( + "Search the terminal scrollback with regex and a live match count.", + "Search", + ), + hint( + "Press Shift+Enter to insert a newline for multi-line input without submitting.", + "⇧Enter", + ), + key( + "Zoom the font size of just one terminal; press ⌘0 to reset it.", + "ZoomIn", + ), // Git & worktrees - key("Filter branches or create a new one from HEAD, right inside the branch switcher.", "ShowBranchSwitcher"), - hint("Spin up a git worktree as its own column — even straight from a GitHub PR (needs gh).", "right-click a repo"), + key( + "Filter branches or create a new one from HEAD, right inside the branch switcher.", + "ShowBranchSwitcher", + ), + hint( + "Spin up a git worktree as its own column — even straight from a GitHub PR (needs gh).", + "right-click a repo", + ), plain("New worktrees appear automatically and stale ones are cleaned up in the background."), - plain("In the diff viewer: S toggles side-by-side, W ignores whitespace, Tab switches staged/unstaged."), - + plain( + "In the diff viewer: S toggles side-by-side, W ignores whitespace, Tab switches staged/unstaged.", + ), // Services & ports - hint("Running services show port badges — click one to open it in your browser.", "click a port"), - hint("Define project services in okena.yaml; Docker Compose is auto-detected too.", "okena.yaml"), - hint("Services can auto-start on project open and auto-restart on crash, with automatic port detection.", "okena.yaml"), - + hint( + "Running services show port badges — click one to open it in your browser.", + "click a port", + ), + hint( + "Define project services in okena.yaml; Docker Compose is auto-detected too.", + "okena.yaml", + ), + hint( + "Services can auto-start on project open and auto-restart on crash, with automatic port detection.", + "okena.yaml", + ), // Sessions & safety - plain("With dtach, tmux, or screen installed, your terminals keep running across app restarts."), - plain("Closed a busy terminal by accident? You get a few seconds to undo before the process is killed."), - key("Save, load, and export entire workspace layouts from the session manager.", "ShowSessionManager"), - + plain( + "With dtach, tmux, or screen installed, your terminals keep running across app restarts.", + ), + plain( + "Closed a busy terminal by accident? You get a few seconds to undo before the process is killed.", + ), + key( + "Save, load, and export entire workspace layouts from the session manager.", + "ShowSessionManager", + ), // Remote, CLI & web - hint("Drive any terminal from the shell or an agent: okena run, okena key, okena read, okena ls.", "CLI"), - hint("Target a specific window from the CLI with --window (or --window main).", "CLI"), - hint("Enable the remote server and open your terminal in a browser from any device via a pairing code.", "remote"), - hint("Run okena headless on a server: okena --headless --listen serves the API with no GUI.", "CLI"), - + hint( + "Drive any terminal from the shell or an agent: okena run, okena key, okena read, okena ls.", + "CLI", + ), + hint( + "Target a specific window from the CLI with --window (or --window main).", + "CLI", + ), + hint( + "Enable the remote server and open your terminal in a browser from any device via a pairing code.", + "remote", + ), + hint( + "Run okena headless on a server: okena --headless --listen serves the API with no GUI.", + "CLI", + ), // Customize - key("Switch themes live — Auto, Dark, Light, Pastel Dark, High Contrast, or your own JSON.", "ShowThemeSelector"), - hint("Run commands automatically on project, terminal, and worktree events; prefix with \"terminal:\" to open them in a pane.", "settings"), - hint("Set a {shell} wrapper to auto-enter a devcontainer or nix shell for every new terminal.", "settings"), - hint("Keep separate settings, keybindings, themes, and sessions per profile (work vs. personal).", "command palette"), - key("Every shortcut is editable — open the keybindings editor to record your own.", "ShowKeybindings"), - hint("Get notified on bell or OSC alerts from background terminals.", "settings"), + key( + "Switch themes live — Auto, Dark, Light, Pastel Dark, High Contrast, or your own JSON.", + "ShowThemeSelector", + ), + hint( + "Run commands automatically on project, terminal, and worktree events; prefix with \"terminal:\" to open them in a pane.", + "settings", + ), + hint( + "Set a {shell} wrapper to auto-enter a devcontainer or nix shell for every new terminal.", + "settings", + ), + hint( + "Keep separate settings, keybindings, themes, and sessions per profile (work vs. personal).", + "command palette", + ), + key( + "Every shortcut is editable — open the keybindings editor to record your own.", + "ShowKeybindings", + ), + hint( + "Get notified on bell or OSC alerts from background terminals.", + "settings", + ), ]; /// Returns the tip at `index`, wrapping around the pool. diff --git a/crates/okena-app/src/views/window/handlers.rs b/crates/okena-app/src/views/window/handlers.rs index 40aaaca60..147cda644 100644 --- a/crates/okena-app/src/views/window/handlers.rs +++ b/crates/okena-app/src/views/window/handlers.rs @@ -1,12 +1,10 @@ use crate::action_dispatch::ActionDispatcher; -use crate::settings::{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,63 +12,105 @@ 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()); + pub(super) fn local_daemon_action_client( + &self, + cx: &Context, + ) -> Result { + let manager = self + .remote_manager + .as_ref() + .ok_or_else(|| "Local daemon connection is unavailable".to_string())? + .read(cx); + let config = manager + .connections() + .into_iter() + .find(|(config, _, _)| config.id == okena_transport::client::LOCAL_DAEMON_CONNECTION_ID) + .map(|(config, _, _)| config.clone()) + .ok_or_else(|| "Local daemon connection is unavailable".to_string())?; + let token = config + .effective_auth_token() + .ok_or_else(|| "Local daemon authentication is unavailable".to_string())?; + Ok(okena_transport::remote_action::RemoteActionClient::new( + config, token, + )) + } + + /// 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, - }) + ) } - /// Resolve remote connection parameters for a remote project. - /// Returns (host, port, token, actual_project_id) or None if unavailable. - fn remote_params( + /// 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 the shared action client and daemon-side id for a project. + pub(super) fn remote_params( &self, project_id: &str, connection_id: &str, cx: &Context, - ) -> Option<(String, u16, String, String)> { + ) -> Option<(okena_transport::remote_action::RemoteActionClient, 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 (config, _, _) = connections + .into_iter() + .find(|(config, _, _)| config.id == connection_id)?; + 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)) + let client = okena_transport::remote_action::RemoteActionClient::new(config.clone(), token); + Some((client, 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}; + ) -> Option> + { + 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 (client, actual_id) = self.remote_params(project_id, conn_id, cx)?; + Some(std::sync::Arc::new(RemoteGitProvider::new( + client, + actual_id, + project.path.clone(), + ))) } /// Resolve the focused terminal_id from this window's focus_manager and the @@ -82,7 +122,11 @@ impl WindowView { let project = ws.project(&state.project_id)?; let layout = project.layout.as_ref()?; let node = layout.get_at_path(&state.layout_path)?; - if let LayoutNode::Terminal { terminal_id: Some(id), .. } = node { + if let LayoutNode::Terminal { + terminal_id: Some(id), + .. + } = node + { Some((state.project_id, id.clone())) } else { None @@ -120,7 +164,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 +172,16 @@ 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( + let conn_id = project.connection_id.as_ref()?; + let (client, actual_id) = self.remote_params(project_id, conn_id, cx)?; + Some(std::sync::Arc::new( + okena_files::project_fs::RemoteProjectFs::new( + client, + actual_id, + project.name.clone(), project.path.clone(), - ))) - } + ), + )) } /// Evict cached file viewers for projects that no longer exist. @@ -165,9 +208,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 +218,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 (client, actual_id) = self.remote_params(project_id, conn_id, cx)?; + Some(std::sync::Arc::new( + okena_views_git::blame::RemoteBlameProvider::new(client, actual_id), + )) } } @@ -198,30 +235,39 @@ 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::{ + KILL_PREFIX, RESTART_DAEMON_CANCEL_PREFIX, RESTART_DAEMON_CONFIRM_PREFIX, UNDO_PREFIX, + decode_action, + }; 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)) = - decode_action(&event.action_id, KILL_PREFIX) + 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,57 +279,194 @@ 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 } => { + 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| { - om.show_worktree_dialog(project_id.clone(), project_path.clone(), cx); - }); + OverlayManagerEvent::CreateWorktree { project_id } => { + let params = self + .workspace + .read(cx) + .project(project_id) + .and_then(|project| project.connection_id.clone()) + .and_then(|connection_id| self.remote_params(project_id, &connection_id, cx)); + if let Some(params) = params { + self.overlay_manager.update(cx, |om, cx| { + om.show_worktree_dialog(project_id.clone(), params, cx); + }); + } } - OverlayManagerEvent::RenameProject { project_id, project_name } => { + OverlayManagerEvent::RenameProject { + project_id, + project_name, + } => { self.request_broker.update(cx, |broker, cx| { - broker.push_sidebar_request(SidebarRequest::RenameProject { - project_id: project_id.clone(), - project_name: project_name.clone(), - }, cx); + broker.push_sidebar_request( + SidebarRequest::RenameProject { + project_id: project_id.clone(), + project_name: project_name.clone(), + }, + cx, + ); }); } - OverlayManagerEvent::RenameDirectory { project_id, project_path } => { + OverlayManagerEvent::RenameDirectory { + project_id, + project_path, + } => { self.overlay_manager.update(cx, |om, cx| { om.show_rename_directory_dialog(project_id.clone(), project_path.clone(), cx); }); } OverlayManagerEvent::CloseWorktree { project_id } => { - self.overlay_manager.update(cx, |om, cx| { - om.show_close_worktree_dialog(project_id.clone(), cx); - }); + 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)); + if let Some(params) = params { + self.overlay_manager.update(cx, |om, 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); + OverlayManagerEvent::ManageWorktrees { + project_id, + 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)); + if let Some(params) = params { + self.overlay_manager.update(cx, |om, cx| { + om.show_worktree_list(project_id.clone(), *position, params, cx); }); - cx.notify(); - }); - for tid in hook_tids { - self.terminals.lock().remove(&tid); + } + } + OverlayManagerEvent::DeleteProject { project_id } => { + // 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,16 +475,23 @@ 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| { - broker.push_sidebar_request(crate::workspace::requests::SidebarRequest::QuickCreateWorktree { - project_id: project_id.clone(), - }, cx); + broker.push_sidebar_request( + crate::workspace::requests::SidebarRequest::QuickCreateWorktree { + project_id: project_id.clone(), + }, + cx, + ); }); } OverlayManagerEvent::ProjectColorChanged { project_id, color } => { @@ -309,8 +499,36 @@ 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) + let parent_id = self + .workspace + .read(cx) .project(project_id) .and_then(|p| p.worktree_info.as_ref()) .map(|wt| wt.parent_project_id.clone()); @@ -361,12 +579,18 @@ impl WindowView { }); } } - OverlayManagerEvent::RemotePair { connection_id, connection_name } => { + OverlayManagerEvent::RemotePair { + connection_id, + connection_name, + } => { self.overlay_manager.update(cx, |om, cx| { om.show_remote_pair_dialog(connection_id.clone(), connection_name.clone(), cx); }); } - OverlayManagerEvent::RemoteUpgradeToTls { connection_id, connection_name } => { + OverlayManagerEvent::RemoteUpgradeToTls { + connection_id, + connection_name, + } => { // Flip the saved connection to TLS, then open the pair dialog: the // re-pair runs over TLS and pins the server cert (TOFU). if let Some(ref rm) = self.remote_manager { @@ -378,7 +602,10 @@ impl WindowView { om.show_remote_pair_dialog(connection_id.clone(), connection_name.clone(), cx); }); } - OverlayManagerEvent::RemotePaired { connection_id, code } => { + OverlayManagerEvent::RemotePaired { + connection_id, + code, + } => { if let Some(ref rm) = self.remote_manager { rm.update(cx, |rm, cx| { rm.pair(connection_id, code, cx); @@ -395,12 +622,14 @@ impl WindowView { OverlayManagerEvent::TerminalCopy { terminal_id } => { let terminals = self.terminals.lock(); if let Some(terminal) = terminals.get(terminal_id) - && let Some(text) = terminal.get_selected_text() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } + && let Some(text) = terminal.get_selected_text() + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } } OverlayManagerEvent::TerminalPaste { terminal_id } => { - let text = cx.read_from_clipboard() + let text = cx + .read_from_clipboard() .and_then(|item| item.text().map(|t| t.to_string())); if let Some(text) = text { let terminals = self.terminals.lock(); @@ -422,54 +651,98 @@ 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); + OverlayManagerEvent::TerminalSplit { + project_id, + layout_path, + direction, + } => { + 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); - } - 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); - dispatcher.dispatch(ActionRequest::CloseTerminal { - project_id: project_id.clone(), - terminal_id: tid, - }, cx); - } - } - OverlayManagerEvent::TabCloseOthers { 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().enumerate() + OverlayManagerEvent::TerminalClose { + project_id, + terminal_id, + } => { + 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 Some(dispatcher) = self.dispatcher_for_project(project_id, cx) + { + dispatcher.dispatch( + ActionRequest::CloseTerminal { + project_id: project_id.clone(), + terminal_id: tid, + }, + cx, + ); + } + } + OverlayManagerEvent::TabCloseOthers { + 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() + .enumerate() .filter(|(i, _)| *i != *tab_index) .map(|(_, id)| id) .collect(); - if !to_close.is_empty() { - let dispatcher = self.dispatcher_for_project(project_id, cx); - dispatcher.dispatch(ActionRequest::CloseTerminals { - project_id: project_id.clone(), - terminal_ids: to_close, - }, 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, + }, + cx, + ); } } - OverlayManagerEvent::TabCloseToRight { project_id, layout_path, tab_index } => { - let terminal_ids = collect_tab_terminal_ids(&self.workspace, project_id, layout_path, cx); + 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); - dispatcher.dispatch(ActionRequest::CloseTerminals { - project_id: project_id.clone(), - terminal_ids: to_close, - }, 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, + }, + cx, + ); } } OverlayManagerEvent::OpenCommitFromBlame { project_id, hash } => { @@ -494,9 +767,7 @@ impl WindowView { OverlayManagerEvent::RemoteConnected { config } => { if let Some(ref rm) = self.remote_manager { let config_clone = config.clone(); - let result = rm.update(cx, |rm, cx| { - rm.add_connection(config.clone(), cx) - }); + let result = rm.update(cx, |rm, cx| rm.add_connection(config.clone(), cx)); if let Err(msg) = result { crate::views::panels::toast::ToastManager::warning(msg, cx); return; @@ -512,52 +783,154 @@ 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); + } + + /// 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; + }; - // Sync project columns for new data - self.sync_project_columns(cx); + ToastManager::info("Restarting daemon…", cx); - cx.notify(); + 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 = daemon.connection_config(token.clone()); + 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. + /// Spawn a new Okena process for `id`, then quit. /// The spawned child is dropped immediately and survives as an orphan (Unix) /// or independent process (Windows) — same pattern as the updater's restart_app. pub(super) fn handle_switch_profile(&self, id: String, cx: &mut Context) { - // 1. Flush settings - if let Some(gs) = cx.try_global::() { - gs.0.read(cx).flush_pending_save(); - } + // 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. - // 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}"); - } - - // 3. Spawn current_exe with --profile . Strip any existing --profile arg - // so we don't double-pass it. + // Spawn current_exe with --profile . Strip any existing --profile arg + // so we don't double-pass it. match std::env::current_exe() { Ok(exe) => { let mut args: Vec = std::env::args().skip(1).collect(); @@ -582,9 +955,9 @@ impl WindowView { /// Drains the overlay request queue and dispatches each request to the /// OverlayManager. Requests for already-open overlays are silently dropped. pub(super) fn process_pending_requests(&mut self, cx: &mut Context) { - let requests: Vec<_> = self.request_broker.update(cx, |broker, _cx| { - broker.drain_overlay_requests() - }); + let requests: Vec<_> = self + .request_broker + .update(cx, |broker, _cx| broker.drain_overlay_requests()); for request in requests { match request { @@ -593,32 +966,78 @@ impl WindowView { if !self.overlay_manager.read(cx).has_context_menu() { self.overlay_manager.update(cx, |om, cx| { om.show_context_menu( - crate::workspace::requests::ContextMenuRequest { project_id, position }, + crate::workspace::requests::ContextMenuRequest { + project_id, + position, + }, cx, ); }); } } - ProjectOverlayKind::ShellSelector { terminal_id, current_shell } => { + ProjectOverlayKind::ShellSelector { + terminal_id, + current_shell, + } => { self.overlay_manager.update(cx, |om, cx| { om.show_shell_selector(current_shell, project_id, terminal_id, cx); }); } - ProjectOverlayKind::DiffViewer { file, mode, commit_message, commits, commit_index } => { + ProjectOverlayKind::DiffViewer { + file, + mode, + commit_message, + commits, + commit_index, + } => { if let Some(provider) = self.build_git_provider(&project_id, cx) { self.overlay_manager.update(cx, |om, cx| { - om.show_diff_viewer(provider, file, mode, commit_message, commits, commit_index, cx); + om.show_diff_viewer( + provider, + file, + mode, + commit_message, + commits, + commit_index, + cx, + ); }); } } - ProjectOverlayKind::TerminalContextMenu { terminal_id, layout_path, position, has_selection, link_url } => { + ProjectOverlayKind::TerminalContextMenu { + terminal_id, + layout_path, + position, + has_selection, + link_url, + } => { self.overlay_manager.update(cx, |om, cx| { - om.show_terminal_context_menu(terminal_id, project_id, layout_path, position, has_selection, link_url, cx); + om.show_terminal_context_menu( + terminal_id, + project_id, + layout_path, + position, + has_selection, + link_url, + cx, + ); }); } - ProjectOverlayKind::TabContextMenu { tab_index, num_tabs, layout_path, position } => { + ProjectOverlayKind::TabContextMenu { + tab_index, + num_tabs, + layout_path, + position, + } => { self.overlay_manager.update(cx, |om, cx| { - om.show_tab_context_menu(tab_index, num_tabs, project_id, layout_path, position, cx); + om.show_tab_context_menu( + tab_index, + num_tabs, + project_id, + layout_path, + position, + cx, + ); }); } ProjectOverlayKind::ShowServiceLog { service_name } => { @@ -656,6 +1075,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,17 +1093,32 @@ impl WindowView { }); } ProjectOverlayKind::WorktreeList { position } => { - self.overlay_manager.update(cx, |om, cx| { - om.show_worktree_list(project_id, position, cx); - }); + 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)); + if let Some(params) = params { + self.overlay_manager.update(cx, |om, cx| { + om.show_worktree_list(project_id, position, params, cx); + }); + } } }, OverlayRequest::Folder(FolderOverlay { folder_id, kind }) => match kind { - FolderOverlayKind::ContextMenu { folder_name, position } => { + FolderOverlayKind::ContextMenu { + folder_name, + position, + } => { if !self.overlay_manager.read(cx).has_folder_context_menu() { self.overlay_manager.update(cx, |om, cx| { om.show_folder_context_menu( - crate::workspace::requests::FolderContextMenuRequest { folder_id, folder_name, position }, + crate::workspace::requests::FolderContextMenuRequest { + folder_id, + folder_name, + position, + }, cx, ); }); @@ -706,10 +1148,23 @@ impl WindowView { }); } } - OverlayRequest::RemoteConnectionContextMenu { connection_id, connection_name, is_pairing, tls, position } => { + OverlayRequest::RemoteConnectionContextMenu { + connection_id, + connection_name, + is_pairing, + tls, + position, + } => { if !self.overlay_manager.read(cx).has_remote_context_menu() { self.overlay_manager.update(cx, |om, cx| { - om.show_remote_context_menu(connection_id, connection_name, is_pairing, tls, position, cx); + om.show_remote_context_menu( + connection_id, + connection_name, + is_pairing, + tls, + position, + cx, + ); }); } } @@ -721,9 +1176,9 @@ impl WindowView { /// the currently focused terminal. Resolves the terminal's CWD per call so /// queued payloads sent while the user navigates use the latest known cwd. pub(super) fn process_pending_send_to_terminal(&mut self, cx: &mut Context) { - let payloads = self.request_broker.update(cx, |broker, _cx| { - broker.drain_send_to_terminal() - }); + let payloads = self + .request_broker + .update(cx, |broker, _cx| broker.drain_send_to_terminal()); for payload in payloads { self.send_payload_to_active_terminal(payload, cx); } @@ -766,15 +1221,16 @@ fn collect_tab_terminal_ids( }; match node { LayoutNode::Tabs { children, .. } => { - children.iter().filter_map(|child| { - // For simple Terminal children, get the ID directly. - // For nested structures, get the first terminal ID. - child.collect_terminal_ids().into_iter().next() - }).collect() - } - LayoutNode::Terminal { terminal_id, .. } => { - terminal_id.iter().cloned().collect() + children + .iter() + .filter_map(|child| { + // For simple Terminal children, get the ID directly. + // For nested structures, get the first terminal ID. + child.collect_terminal_ids().into_iter().next() + }) + .collect() } + LayoutNode::Terminal { terminal_id, .. } => terminal_id.iter().cloned().collect(), _ => Vec::new(), } } diff --git a/crates/okena-app/src/views/window/mod.rs b/crates/okena-app/src/views/window/mod.rs index 205db8303..24a2f3b1a 100644 --- a/crates/okena-app/src/views/window/mod.rs +++ b/crates/okena-app/src/views/window/mod.rs @@ -4,20 +4,17 @@ mod render; mod sidebar; mod terminal_actions; -use crate::git::watcher::GitStatusWatcher; -use crate::remote_client::manager::{RemoteConnectionManager, RemoteManagerEvent}; +use crate::remote_client::manager::RemoteConnectionManager; use crate::services::manager::ServiceManager; -use crate::terminal::backend::{TerminalBackend, LocalBackend}; -use crate::terminal::pty_manager::PtyManager; +use crate::settings::settings; +use crate::views::chrome::title_bar::TitleBar; +use crate::views::layout::split_pane::{ActiveDrag, new_active_drag}; use crate::views::overlay_manager::OverlayManager; use crate::views::panels::project_column::ProjectColumn; -use crate::views::sidebar_controller::SidebarController; use crate::views::panels::sidebar::Sidebar; -use crate::views::layout::split_pane::{new_active_drag, ActiveDrag}; use crate::views::panels::status_bar::StatusBar; use crate::views::panels::toast::ToastOverlay; -use crate::views::chrome::title_bar::TitleBar; -use crate::settings::settings; +use crate::views::sidebar_controller::SidebarController; use crate::workspace::focus::FocusManager; use crate::workspace::request_broker::RequestBroker; use crate::workspace::state::{WindowBounds as PersistedWindowBounds, WindowId, Workspace}; @@ -36,7 +33,8 @@ pub use okena_terminal::TerminalsRegistry; /// in N project-column instances simultaneously (one per window whose visible /// set includes the host project), so the PTY notify path must fan out to /// every live entry. Dead weaks are pruned lazily on iteration. -pub type ContentPaneRegistry = Arc>>>>; +pub type ContentPaneRegistry = + Arc>>>>; /// Global content pane registry instance. static CONTENT_PANE_REGISTRY: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -51,12 +49,13 @@ pub fn content_pane_registry() -> &'static ContentPaneRegistry { /// whether any UI update was actually triggered). Generic over the target /// type so the same helper services the multi-window terminal fan-out and is /// testable without standing up a `TerminalContent`. -pub fn notify_pane_weaks( +fn update_pane_weaks( weaks: &mut Vec>, cx: &mut App, + mut update: impl FnMut(&mut T, &mut Context), ) -> bool { let mut any_alive = false; - weaks.retain(|w| match w.update(cx, |_, cx| cx.notify()) { + weaks.retain(|weak| match weak.update(cx, |pane, cx| update(pane, cx)) { Ok(_) => { any_alive = true; true @@ -66,6 +65,59 @@ pub fn notify_pane_weaks( any_alive } +pub fn notify_pane_weaks(weaks: &mut Vec>, cx: &mut App) -> bool { + update_pane_weaks(weaks, cx, |_, cx| cx.notify()) +} + +/// Notify panes for terminals whose remote content actually advanced. Empty +/// registrations are removed so repeated activity cannot grow stale weak lists. +pub fn notify_registered_panes( + registry: &mut HashMap>>, + terminal_ids: &[String], + cx: &mut App, +) -> usize { + update_registered_panes(registry, terminal_ids, cx, notify_pane_weaks) +} + +/// Request activity repaints from concrete terminal-content panes. Calls are +/// already limited by the app-wide presentation cadence, so every visible copy +/// remains live without each stream driving an independent clock. +pub fn request_registered_content_pane_repaints( + registry: &mut HashMap>>, + terminal_ids: &[String], + cx: &mut App, +) -> usize { + update_registered_panes(registry, terminal_ids, cx, |weaks, cx| { + update_pane_weaks(weaks, cx, |pane, cx| { + pane.request_activity_repaint(cx); + }) + }) +} + +fn update_registered_panes( + registry: &mut HashMap>>, + terminal_ids: &[String], + cx: &mut App, + mut update: impl FnMut(&mut Vec>, &mut App) -> bool, +) -> usize { + let mut notified = 0; + let mut empty = Vec::new(); + for terminal_id in terminal_ids { + if let Some(weaks) = registry.get_mut(terminal_id) { + if update(weaks, cx) { + notified += 1; + } + if weaks.is_empty() { + empty.push(terminal_id.clone()); + } + } + } + for terminal_id in empty { + registry.remove(&terminal_id); + } + notified +} + /// Per-window view of the application: one instance per OS window. /// /// Owns the per-window UI state (sidebar, overlays, toasts, scroll handles, @@ -84,6 +136,10 @@ pub enum WindowViewEvent { origin: WindowId, project_id: String, }, + /// Rebuild the checkout release binary. + RebuildLocal, + /// Restart the UI-owned daemon and app with the rebuilt release binary. + RestartLocalBuild, } pub struct WindowView { @@ -107,7 +163,6 @@ pub struct WindowView { focus_manager: Entity, workspace: Entity, request_broker: Entity, - backend: Arc, terminals: TerminalsRegistry, sidebar: Entity, /// Sidebar state controller @@ -135,8 +190,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,12 +223,10 @@ impl WindowView { pub fn new( window_id: WindowId, workspace: Entity, - pty_manager: Arc, terminals: TerminalsRegistry, window: &mut Window, cx: &mut Context, ) -> Self { - // Per-window UI request broker. Each window (slice 05 onward) owns its // own queue so overlay/sidebar requests stay scoped to the window that // produced them; closes slice 03 acceptance criterion that per-window @@ -210,7 +261,16 @@ impl WindowView { } // Create sidebar entity once to preserve state - let sidebar = cx.new(|cx| Sidebar::new(window_id, workspace.clone(), focus_manager.clone(), request_broker.clone(), terminals.clone(), cx)); + let sidebar = cx.new(|cx| { + Sidebar::new( + window_id, + workspace.clone(), + focus_manager.clone(), + request_broker.clone(), + terminals.clone(), + cx, + ) + }); // Create title bar entity (sync initial sidebar state) let sidebar_initially_open = sidebar_ctrl.is_open(); @@ -230,16 +290,25 @@ impl WindowView { }); // Create overlay manager - let overlay_manager = cx.new(|_cx| OverlayManager::new(window_id, workspace.clone(), focus_manager.clone(), request_broker.clone())); + let overlay_manager = cx.new(|_cx| { + OverlayManager::new( + window_id, + workspace.clone(), + focus_manager.clone(), + request_broker.clone(), + ) + }); // Create toast overlay let toast_overlay = cx.new(ToastOverlay::new); // Subscribe to overlay manager events - cx.subscribe(&overlay_manager, Self::handle_overlay_manager_event).detach(); + cx.subscribe(&overlay_manager, Self::handle_overlay_manager_event) + .detach(); // Subscribe to toast action clicks (soft-close undo / close-now). - cx.subscribe(&toast_overlay, Self::handle_toast_action).detach(); + cx.subscribe(&toast_overlay, Self::handle_toast_action) + .detach(); // Observe RequestBroker to process overlay + terminal-send requests // outside of render(). @@ -253,7 +322,8 @@ impl WindowView { if has_send { this.process_pending_send_to_terminal(cx); } - }).detach(); + }) + .detach(); // Observe the shared project-hover state so this window re-renders its // project panels when the hovered project changes — including hovers @@ -271,15 +341,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 +353,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 +367,6 @@ impl WindowView { focus_manager, workspace, request_broker, - backend, terminals, sidebar, sidebar_ctrl, @@ -328,13 +380,15 @@ impl WindowView { projects_scroll_handle: ScrollHandle::new(), projects_grid_bounds: Rc::new(RefCell::new(Bounds { origin: Point::default(), - size: Size { width: px(800.0), height: px(600.0) }, + size: Size { + width: px(800.0), + height: px(600.0), + }, })), hscroll_dragging: false, 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, @@ -357,6 +411,13 @@ impl WindowView { // path coalesce. Conversion mirrors the inverse path in // `src/app/extras.rs::open_extra_window` (gpui `Bounds` -> // `PersistedWindowBounds` via four `f32::from(...)` calls). + cx.observe_window_activation(window, |_this, _window, cx| { + // Refresh key-window chrome while activity-driven presentation stays + // live in every visible window. + cx.notify(); + }) + .detach(); + cx.observe_window_bounds(window, |this, window, cx| { let bounds = window.window_bounds().get_bounds(); let persisted = PersistedWindowBounds { @@ -378,9 +439,8 @@ impl WindowView { cx.observe(&view.focus_manager, |this, fm, cx| { let fm = fm.read(cx); let is_project_focused = fm.focused_project_id().is_some(); - let focused_terminal_project = fm - .focused_terminal_state() - .map(|f| f.project_id.clone()); + let focused_terminal_project = + fm.focused_terminal_state().map(|f| f.project_id.clone()); // Consumed once per observation: an explicit jump (switcher Tab) // centers the target; other switches just ensure it's visible. let center_navigation = std::mem::take(&mut this.center_next_navigation); @@ -399,14 +459,21 @@ impl WindowView { this.pending_center_scroll = focused_terminal_project; } // When the active terminal changes project, ensure it's visible - else if focused_terminal_project != this.last_scroll_project && focused_terminal_project.is_some() { + else if focused_terminal_project != this.last_scroll_project + && focused_terminal_project.is_some() + { this.last_scroll_project = focused_terminal_project.clone(); - this.scroll_to_focused_project(focused_terminal_project.as_deref(), center_navigation, cx); + this.scroll_to_focused_project( + focused_terminal_project.as_deref(), + center_navigation, + cx, + ); } this.was_project_focused = is_project_focused; cx.notify(); - }).detach(); + }) + .detach(); // Observe workspace data changes so project path renames refresh // cached git providers / service paths. @@ -424,7 +491,8 @@ impl WindowView { } this.refresh_for_project_path_changes(cx); this.prune_file_viewer_cache(cx); - }).detach(); + }) + .detach(); // Initialize project columns view.sync_project_columns(cx); @@ -472,16 +540,12 @@ 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) { + pub fn set_remote_manager( + &mut self, + manager: Entity, + cx: &mut Context, + ) { // Observe remote manager and sync remote projects into workspace let workspace = self.workspace.clone(); let focus_manager = self.focus_manager.clone(); @@ -496,7 +560,8 @@ impl WindowView { ); this.sync_project_columns(cx); cx.notify(); - }).detach(); + }) + .detach(); // Wire up remote callbacks on sidebar { @@ -506,12 +571,17 @@ impl WindowView { self.sidebar.update(cx, |sidebar, _cx| { // Get remote connections callback sidebar.set_remote_connections(Box::new(move |cx| { - rm_for_connections.read(cx).connections().iter().map(|(config, status, _state)| { - okena_views_sidebar::RemoteConnectionSnapshot { - config: (*config).clone(), - status: (*status).clone(), - } - }).collect() + rm_for_connections + .read(cx) + .connections() + .iter() + .map(|(config, status, _state)| { + okena_views_sidebar::RemoteConnectionSnapshot { + config: (*config).clone(), + status: (*status).clone(), + } + }) + .collect() })); // Send remote action callback @@ -523,36 +593,36 @@ impl WindowView { // Get remote folder callback sidebar.set_get_remote_folder(Box::new(move |conn_id, prefixed_project_id, cx| { - let server_project_id = okena_transport::client::strip_prefix(prefixed_project_id, conn_id); - rm_for_folder.read(cx).connections().iter() + let server_project_id = + okena_transport::client::strip_prefix(prefixed_project_id, conn_id); + rm_for_folder + .read(cx) + .connections() + .iter() .find(|(config, _, _)| config.id == conn_id) .and_then(|(_, _, state)| state.as_ref()) .and_then(|state| { - state.folders.iter().find(|f| f.project_ids.contains(&server_project_id)) + state + .folders + .iter() + .find(|f| f.project_ids.contains(&server_project_id)) .map(|f| f.id.clone()) }) })); }); + 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()); - }).detach(); - - // Repaint the sidebar on remote terminal activity (bell / idle). - // The sidebar reads these flags straight from the TerminalsRegistry, - // which GPUI's dependency tracking can't see, so incoming server - // output would otherwise only surface on local input (issue #128). - // This rides a dedicated event rather than cx.notify() so the - // high-frequency output cadence doesn't trigger the project-sync - // observer above. - let sidebar_for_activity = self.sidebar.clone(); - cx.subscribe(&manager, move |_this, _rm, event, cx| match event { - RemoteManagerEvent::TerminalActivity => { - sidebar_for_activity.update(cx, |_, cx| cx.notify()); - } - }).detach(); + status_bar_for_observe.update(cx, |_, cx| cx.notify()); + }) + .detach(); } self.remote_manager = Some(manager); @@ -561,11 +631,19 @@ impl WindowView { self.rebuild_sidebar_dispatch(cx); } + /// Request the sidebar half of the app-wide terminal activity frame. + /// Parsing and notifications already ran; this only presents current bell / + /// idle state at the shared capped cadence. + pub(crate) fn request_sidebar_activity_repaint(&mut self, cx: &mut Context) { + self.sidebar.update(cx, |_, cx| cx.notify()); + } + /// Set the service manager entity (called by Okena after creation). pub fn set_service_manager(&mut self, manager: Entity, cx: &mut Context) { cx.observe(&manager, |_this, _sm, cx| { cx.notify(); - }).detach(); + }) + .detach(); self.sidebar.update(cx, |sidebar, cx| { sidebar.set_service_manager(manager.clone(), cx); @@ -584,15 +662,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 +679,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); + } + })); }); } @@ -630,22 +720,50 @@ impl WindowView { // Snapshot all connection data into owned structures to release the borrow on cx let snapshots: Vec = { let rm_read = rm.read(cx); - rm_read.connections().iter().map(|(config, _status, state)| { - RemoteSnapshot { + rm_read + .connections() + .iter() + .map(|(config, _status, state)| RemoteSnapshot { config: (*config).clone(), state: state.cloned(), - } - }).collect() + }) + .collect() }; focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| ws.apply_remote_snapshot(&snapshots, window_id, fm, cx)); + workspace.update(cx, |ws, cx| { + ws.apply_remote_snapshot(&snapshots, window_id, fm, cx) + }); }); + + // Mirror the local daemon's hook execution history into the client-side + // `HookMonitor` global so the Hook Log overlay reflects hooks that ran on + // the daemon. Only the local daemon connection feeds this global — remote + // connections carry their own, unrelated hook state — and the hooks run + // remotely, so without this the client's monitor would stay empty. + if let Some(local) = snapshots + .iter() + .find(|s| s.config.id == okena_transport::client::LOCAL_DAEMON_CONNECTION_ID) + && let Some(state) = local.state.as_ref() + && let Some(monitor) = cx.try_global::() + { + let monitor = monitor.clone(); + monitor.replace_history( + state + .hooks + .iter() + .map(okena_workspace::hook_monitor::HookExecution::from_api) + .collect(), + ); + } } /// Snapshot current on-disk paths for local projects (keyed by project_id). fn snapshot_local_project_paths(&self, cx: &Context) -> HashMap { - self.workspace.read(cx).projects().iter() + self.workspace + .read(cx) + .projects() + .iter() .filter(|p| !p.is_remote) .map(|p| (p.id.clone(), p.path.clone())) .collect() @@ -656,32 +774,38 @@ impl WindowView { fn refresh_for_project_path_changes(&mut self, cx: &mut Context) { let current = self.snapshot_local_project_paths(cx); - let changed: Vec<(String, String)> = current.iter() + let changed: Vec<(String, String)> = current + .iter() .filter(|(id, path)| self.last_project_paths.get(id.as_str()) != Some(*path)) .map(|(id, path)| (id.clone(), path.clone())) .collect(); if changed.is_empty() { - // Still drop entries for projects that no longer exist - if current.len() != self.last_project_paths.len() { - self.last_project_paths = current; - } + self.last_project_paths + .retain(|id, _| current.contains_key(id)); return; } for (id, new_path) in &changed { if let Some(column) = self.project_columns.get(id).cloned() - && let Some(provider) = self.build_git_provider(id, cx) { - column.update(cx, |col, cx| col.set_git_provider(provider, cx)); - } - if let Some(sm) = self.service_manager.clone() { + && let Some(provider) = self.build_git_provider(id, cx) + { + column.update(cx, |col, cx| col.set_git_provider(provider, cx)); + } + let services_converged = if let Some(sm) = self.service_manager.clone() { let id = id.clone(); let new_path = new_path.clone(); - sm.update(cx, move |sm, _cx| sm.update_project_path(&id, &new_path)); + sm.update(cx, move |sm, cx| sm.update_project_path(&id, &new_path, cx)) + } else { + true + }; + if services_converged { + self.last_project_paths.insert(id.clone(), new_path.clone()); } } - self.last_project_paths = current; + self.last_project_paths + .retain(|id, _| current.contains_key(id)); } /// Ensure project columns exist for all visible projects @@ -689,32 +813,61 @@ impl WindowView { let visible_projects: Vec<(String, bool, Option)> = { let ws = self.workspace.read(cx); let fm = self.focus_manager.read(cx); - ws.visible_projects(self.window_id, fm.focused_project_id(), fm.is_focus_individual()).iter().map(|p| { - (p.id.clone(), p.is_remote, p.connection_id.clone()) - }).collect() + ws.visible_projects( + self.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .iter() + .map(|p| (p.id.clone(), p.is_remote, p.connection_id.clone())) + .collect() }; // Clean up columns for projects that no longer exist - let visible_ids: std::collections::HashSet<&str> = visible_projects.iter() + let visible_ids: std::collections::HashSet<&str> = visible_projects + .iter() .map(|(id, _, _)| id.as_str()) .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 { + self.project_columns + .retain(|id, _| visible_ids.contains(id.as_str())); + + // 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, @@ -723,7 +876,9 @@ impl WindowView { cx: &mut Context, ) -> Option> { let conn_id = connection_id?; - let backend = self.remote_manager.as_ref() + let backend = self + .remote_manager + .as_ref() .and_then(|rm| rm.read(cx).backend_for(conn_id))?; let workspace_clone = self.workspace.clone(); @@ -769,69 +924,6 @@ impl WindowView { col })) } - - /// 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); @@ -840,11 +932,43 @@ impl EventEmitter for WindowView {} #[cfg(test)] mod tests { - use super::notify_pane_weaks; + use super::{notify_pane_weaks, notify_registered_panes}; use gpui::AppContext as _; + use std::collections::HashMap; struct Stub; + #[gpui::test] + fn activity_notifies_only_registered_terminal_panes(cx: &mut gpui::TestAppContext) { + let (target, other, mut registry) = cx.update(|cx| { + let target = cx.new(|_| Stub); + let other = cx.new(|_| Stub); + let registry = HashMap::from([ + ("target".to_string(), vec![target.downgrade()]), + ("other".to_string(), vec![other.downgrade()]), + ]); + (target, other, registry) + }); + + cx.update(|cx| { + assert_eq!( + notify_registered_panes(&mut registry, &["target".to_string()], cx), + 1 + ); + assert_eq!(registry.len(), 2, "unrelated registrations stay intact"); + }); + + drop(target); + cx.update(|cx| { + assert_eq!( + notify_registered_panes(&mut registry, &["target".to_string()], cx), + 0 + ); + assert!(!registry.contains_key("target"), "dead pane key is pruned"); + }); + drop(other); + } + #[gpui::test] fn fans_out_to_every_alive_weak_and_prunes_dead(cx: &mut gpui::TestAppContext) { let (a, b, mut weaks) = cx.update(|cx| { diff --git a/crates/okena-app/src/views/window/pane_switcher.rs b/crates/okena-app/src/views/window/pane_switcher.rs index 18b05a2dc..a3c045aa2 100644 --- a/crates/okena-app/src/views/window/pane_switcher.rs +++ b/crates/okena-app/src/views/window/pane_switcher.rs @@ -1,8 +1,8 @@ use crate::theme::theme; +use crate::ui::tokens::ui_text; use crate::views::layout::navigation::PaneMap; use crate::workspace::focus::FocusManager; use crate::workspace::state::Workspace; -use crate::ui::tokens::ui_text; use gpui::*; use super::WindowView; @@ -41,7 +41,12 @@ pub(super) struct PaneSwitcher { } impl PaneSwitcher { - pub fn new(workspace: Entity, focus_manager: Entity, pane_map: &PaneMap, cx: &mut Context) -> Self { + pub fn new( + workspace: Entity, + focus_manager: Entity, + pane_map: &PaneMap, + cx: &mut Context, + ) -> Self { let panes = pane_map .sorted_by_reading_order() .into_iter() @@ -114,19 +119,20 @@ impl Render for PaneSwitcher { // Try to map key to pane index (0-9, a-z) if let Some(index) = key_to_pane_index(key) - && let Some((project_id, layout_path, _)) = this.panes.get(index) { - let pid = project_id.clone(); - let lp = layout_path.clone(); - let workspace = this.workspace.clone(); - this.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.set_focused_terminal(fm, pid, lp, cx); - }); - cx.notify(); + && let Some((project_id, layout_path, _)) = this.panes.get(index) + { + let pid = project_id.clone(); + let lp = layout_path.clone(); + let workspace = this.workspace.clone(); + this.focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + ws.set_focused_terminal(fm, pid, lp, cx); }); - cx.emit(PaneSwitcherEvent::Close); - return; - } + cx.notify(); + }); + cx.emit(PaneSwitcherEvent::Close); + return; + } // Any other key deactivates without switching cx.emit(PaneSwitcherEvent::Close); @@ -163,8 +169,9 @@ impl WindowView { let focus_manager = self.focus_manager.clone(); let entity = cx.new(|cx| PaneSwitcher::new(workspace, focus_manager, &pane_map, cx)); - cx.subscribe(&entity, |this, _, event: &PaneSwitcherEvent, cx| { - match event { + cx.subscribe( + &entity, + |this, _, event: &PaneSwitcherEvent, cx| match event { PaneSwitcherEvent::Close => { this.pane_switch_active = false; this.pane_switcher_entity = None; @@ -175,8 +182,8 @@ impl WindowView { }); cx.notify(); } - } - }) + }, + ) .detach(); self.pane_switcher_entity = Some(entity); diff --git a/crates/okena-app/src/views/window/render.rs b/crates/okena-app/src/views/window/render.rs index ec98e828e..e5fed803e 100644 --- a/crates/okena-app/src/views/window/render.rs +++ b/crates/okena-app/src/views/window/render.rs @@ -1,12 +1,23 @@ -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::{ + CheckForUpdates, ClearFocus, CloseWindow, CreateWorktree, EqualizeLayout, FocusActiveProject, + FocusSidebar, InstallUpdate, NewProject, NewWindow, OpenSettingsFile, RestartDaemon, + ReviewChanges, ShowBranchSwitcher, ShowCommandPalette, ShowContentSearch, ShowDiffViewer, + ShowFileSearch, ShowHookLog, ShowKeybindings, ShowLogConsole, ShowPairingDialog, + ShowProfileManager, ShowProjectSwitcher, ShowSessionManager, ShowSettings, ShowThemeSelector, + StartAllServices, StopAllServices, TogglePaneSwitcher, ToggleProjectLayout, ToggleSidebar, + ToggleSidebarAutoHide, +}; use crate::settings::{open_settings_file, settings_entity}; use crate::theme::theme; +use crate::ui::tokens::{ui_text_md, ui_text_xl}; 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::layout::split_pane::{ + DragState, compute_resize, render_project_divider, render_sidebar_divider, +}; +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::*; use gpui::prelude::*; +use gpui::*; use super::WindowView; @@ -26,7 +37,8 @@ impl WindowView { fn to_pixel_widths(widths: &[f32], container_width: f32, min_col_width: f32) -> Vec { let num_dividers = widths.len().saturating_sub(1) as f32; let available_width = (container_width - num_dividers * 1.0).max(0.0); - widths.iter() + widths + .iter() .map(|w| (available_width * w / 100.0).max(min_col_width)) .collect() } @@ -39,7 +51,12 @@ impl WindowView { /// project) so it lands in the middle rather than flush against an edge. /// `center: false` scrolls the minimum needed and is a no-op when the /// project is already fully visible. - pub(super) fn scroll_to_focused_project(&self, focused_id: Option<&str>, center: bool, cx: &Context) { + pub(super) fn scroll_to_focused_project( + &self, + focused_id: Option<&str>, + center: bool, + cx: &Context, + ) { let focused_id = match focused_id { Some(id) => id, None => return, @@ -53,8 +70,15 @@ impl WindowView { return; } - let visible_projects: Vec = workspace.visible_projects(self.window_id, fm.focused_project_id(), fm.is_focus_individual()) - .iter().map(|p| p.id.clone()).collect(); + let visible_projects: Vec = workspace + .visible_projects( + self.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .iter() + .map(|p| p.id.clone()) + .collect(); let num_projects = visible_projects.len(); if num_projects <= 1 { return; @@ -73,11 +97,13 @@ impl WindowView { f32::from(if is_rows { b.size.height } else { b.size.width }) }; - let raw_widths: Vec = visible_projects.iter() + let raw_widths: Vec = visible_projects + .iter() .map(|id| workspace.get_project_width(self.window_id, id, num_projects)) .collect(); let widths = Self::normalize_widths(&raw_widths); - let pixel_widths = Self::to_pixel_widths(&widths, container_size, settings.min_column_width); + let pixel_widths = + Self::to_pixel_widths(&widths, container_size, settings.min_column_width); // Compute the leading edge (along the grid axis) of the focused project let mut col_lead: f32 = 0.0; @@ -111,10 +137,12 @@ impl WindowView { let max_offset = self.projects_scroll_handle.max_offset(); if is_rows { let clamped = new_offset.clamp(-f32::from(max_offset.y), 0.0); - self.projects_scroll_handle.set_offset(point(px(0.0), px(clamped))); + self.projects_scroll_handle + .set_offset(point(px(0.0), px(clamped))); } else { let clamped = new_offset.clamp(-f32::from(max_offset.x), 0.0); - self.projects_scroll_handle.set_offset(point(px(clamped), px(0.0))); + self.projects_scroll_handle + .set_offset(point(px(clamped), px(0.0))); } } @@ -127,7 +155,13 @@ impl WindowView { if let Some(project_id) = self.pending_center_scroll.take() { let workspace = self.workspace.read(cx); let fm = self.focus_manager.read(cx); - let num_visible = workspace.visible_projects(self.window_id, fm.focused_project_id(), fm.is_focus_individual()).len(); + let num_visible = workspace + .visible_projects( + self.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .len(); let is_zoomed = fm.focused_project_id().is_some(); let is_rows = workspace.project_layout_mode(self.window_id).is_rows(); @@ -166,7 +200,15 @@ impl WindowView { if let Some(pid) = fm.fullscreen_project_id() { vec![pid.to_string()] } else { - workspace.visible_projects(self.window_id, fm.focused_project_id(), fm.is_focus_individual()).iter().map(|p| p.id.clone()).collect() + workspace + .visible_projects( + self.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .iter() + .map(|p| p.id.clone()) + .collect() } }; @@ -175,14 +217,18 @@ impl WindowView { // Evict stale pane map entries for projects no longer rendered // (e.g. worktree columns hidden in overview mode) { - let visible_ids: std::collections::HashSet<&str> = visible_projects.iter() - .map(|s| s.as_str()).collect(); + let visible_ids: std::collections::HashSet<&str> = + visible_projects.iter().map(|s| s.as_str()).collect(); prune_pane_map(self.window_id, &visible_ids); } // Empty state when folder filter yields no results if num_projects == 0 { - let has_folder_filter = self.workspace.read(cx).active_folder_filter(self.window_id).is_some(); + let has_folder_filter = self + .workspace + .read(cx) + .active_folder_filter(self.window_id) + .is_some(); if has_folder_filter { let t = theme(cx); let workspace = self.workspace.clone(); @@ -253,13 +299,18 @@ impl WindowView { let settings = settings_entity(cx).read(cx).settings.clone(); // Per-window orientation: columns (side by side) vs rows (stacked). - let is_rows = self.workspace.read(cx).project_layout_mode(self.window_id).is_rows(); + let is_rows = self + .workspace + .read(cx) + .project_layout_mode(self.window_id) + .is_rows(); let widths: Vec = if num_projects <= 1 { vec![100.0; num_projects] } else { let workspace = self.workspace.read(cx); - let raw_widths: Vec = visible_projects.iter() + let raw_widths: Vec = visible_projects + .iter() .map(|id| workspace.get_project_width(self.window_id, id, num_projects)) .collect(); Self::normalize_widths(&raw_widths) @@ -274,7 +325,8 @@ impl WindowView { let b = container_bounds.borrow(); f32::from(if is_rows { b.size.height } else { b.size.width }) }; - let pixel_widths = Self::to_pixel_widths(&widths, container_size, settings.min_column_width); + let pixel_widths = + Self::to_pixel_widths(&widths, container_size, settings.min_column_width); // Project currently hovered in the Switch Project overlay (any window). // Its panel gets an accent ring here so a hover also reveals where the @@ -296,9 +348,7 @@ impl WindowView { .when(!is_rows, |d| d.w(px(pixel_size)).h_full()) .flex_shrink_0() .relative() - .child(AnyView::from(col).cached( - StyleRefinement::default().size_full() - )) + .child(AnyView::from(col).cached(StyleRefinement::default().size_full())) // Accent ring drawn as a non-occluding overlay so it adds no // layout shift and does not intercept clicks on the panel. .when(is_hovered, |d| { @@ -353,33 +403,35 @@ impl WindowView { // Scroll the project grid along its axis. Columns scroll // horizontally (shift+wheel or native horizontal wheel); rows // scroll vertically with the natural wheel. - .on_scroll_wheel(cx.listener(move |_this, event: &ScrollWheelEvent, _window, cx| { - let delta = event.delta.pixel_delta(px(17.0)); - let max_offset = scroll_handle_for_wheel.max_offset(); - let current = scroll_handle_for_wheel.offset(); - if is_rows { - let amount = if !delta.y.is_zero() { delta.y } else { delta.x }; - if amount.is_zero() || max_offset.y <= px(2.0) { - return; - } - let new_y = (current.y + amount).clamp(-max_offset.y, px(0.0)); - scroll_handle_for_wheel.set_offset(point(current.x, new_y)); - } else { - let amount = if event.modifiers.shift { - if !delta.x.is_zero() { delta.x } else { delta.y } - } else if !delta.x.is_zero() { - delta.x + .on_scroll_wheel( + cx.listener(move |_this, event: &ScrollWheelEvent, _window, cx| { + let delta = event.delta.pixel_delta(px(17.0)); + let max_offset = scroll_handle_for_wheel.max_offset(); + let current = scroll_handle_for_wheel.offset(); + if is_rows { + let amount = if !delta.y.is_zero() { delta.y } else { delta.x }; + if amount.is_zero() || max_offset.y <= px(2.0) { + return; + } + let new_y = (current.y + amount).clamp(-max_offset.y, px(0.0)); + scroll_handle_for_wheel.set_offset(point(current.x, new_y)); } else { - return; - }; - if max_offset.x <= px(2.0) { - return; + let amount = if event.modifiers.shift { + if !delta.x.is_zero() { delta.x } else { delta.y } + } else if !delta.x.is_zero() { + delta.x + } else { + return; + }; + if max_offset.x <= px(2.0) { + return; + } + let new_x = (current.x + amount).clamp(-max_offset.x, px(0.0)); + scroll_handle_for_wheel.set_offset(point(new_x, current.y)); } - let new_x = (current.x + amount).clamp(-max_offset.x, px(0.0)); - scroll_handle_for_wheel.set_offset(point(new_x, current.y)); - } - cx.notify(); - })) + cx.notify(); + }), + ) .child( div() .id("projects-grid") @@ -389,17 +441,21 @@ impl WindowView { .when(!is_rows, |d| d.overflow_x_hidden()) .track_scroll(&self.projects_scroll_handle) // Canvas to capture container bounds (updates persistent bounds for next render) - .child(canvas( - { - let container_bounds = container_bounds.clone(); - move |bounds, _window, _cx| { - *container_bounds.borrow_mut() = bounds; - } - }, - |_bounds, _prepaint, _window, _cx| {}, - ).absolute().size_full()) + .child( + canvas( + { + let container_bounds = container_bounds.clone(); + move |bounds, _window, _cx| { + *container_bounds.borrow_mut() = bounds; + } + }, + |_bounds, _prepaint, _window, _cx| {}, + ) + .absolute() + .size_full(), + ) // Mouse handlers are on root div - no need to duplicate here - .children(elements) + .children(elements), ) // Scrollbar overlay: along the bottom for columns, along the right // edge for rows. Drag state (`hscroll_*`) is axis-agnostic since @@ -424,40 +480,66 @@ impl WindowView { // Jump to clicked position if let Some(bounds) = *this.hscroll_bounds.borrow() { let (track, origin, pos) = if is_rows { - (f32::from(bounds.size.height), f32::from(bounds.origin.y), f32::from(event.position.y)) + ( + f32::from(bounds.size.height), + f32::from(bounds.origin.y), + f32::from(event.position.y), + ) } else { - (f32::from(bounds.size.width), f32::from(bounds.origin.x), f32::from(event.position.x)) + ( + f32::from(bounds.size.width), + f32::from(bounds.origin.x), + f32::from(event.position.x), + ) }; let ratio = ((pos - origin) / track).clamp(0.0, 1.0); let new = -ratio * f32::from(max); - let off = if is_rows { point(px(0.0), px(new)) } else { point(px(new), px(0.0)) }; + let off = if is_rows { + point(px(0.0), px(new)) + } else { + point(px(new), px(0.0)) + }; + this.projects_scroll_handle.set_offset(off); + } + cx.notify(); + }), + ) + .on_mouse_move( + cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { + if !this.hscroll_dragging { + return; + } + let max_offset = this.projects_scroll_handle.max_offset(); + let max = if is_rows { max_offset.y } else { max_offset.x }; + if max <= px(2.0) { + return; + } + if let Some(bounds) = *this.hscroll_bounds.borrow() { + let (track, origin, pos) = if is_rows { + ( + f32::from(bounds.size.height), + f32::from(bounds.origin.y), + f32::from(event.position.y), + ) + } else { + ( + f32::from(bounds.size.width), + f32::from(bounds.origin.x), + f32::from(event.position.x), + ) + }; + let ratio = ((pos - origin) / track).clamp(0.0, 1.0); + let new = -ratio * f32::from(max); + let off = if is_rows { + point(px(0.0), px(new)) + } else { + point(px(new), px(0.0)) + }; this.projects_scroll_handle.set_offset(off); } cx.notify(); }), ) - .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { - if !this.hscroll_dragging { - return; - } - let max_offset = this.projects_scroll_handle.max_offset(); - let max = if is_rows { max_offset.y } else { max_offset.x }; - if max <= px(2.0) { - return; - } - if let Some(bounds) = *this.hscroll_bounds.borrow() { - let (track, origin, pos) = if is_rows { - (f32::from(bounds.size.height), f32::from(bounds.origin.y), f32::from(event.position.y)) - } else { - (f32::from(bounds.size.width), f32::from(bounds.origin.x), f32::from(event.position.x)) - }; - let ratio = ((pos - origin) / track).clamp(0.0, 1.0); - let new = -ratio * f32::from(max); - let off = if is_rows { point(px(0.0), px(new)) } else { point(px(new), px(0.0)) }; - this.projects_scroll_handle.set_offset(off); - } - cx.notify(); - })) .on_mouse_up( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { @@ -467,49 +549,59 @@ impl WindowView { } }), ) - .child(canvas( - { - let hscroll_bounds = hscroll_bounds.clone(); - move |bounds, _window, _cx| { - *hscroll_bounds.borrow_mut() = Some(bounds); - } - }, - move |bounds, _, window, _cx| { - let max_scroll = scroll_handle.max_offset(); - let max = if is_rows { max_scroll.y } else { max_scroll.x }; - if max <= px(2.0) { - return; - } - let offset = scroll_handle.offset(); - let off = if is_rows { offset.y } else { offset.x }; - let track = if is_rows { - f32::from(bounds.size.height) - } else { - f32::from(bounds.size.width) - }; - let content = track + f32::from(max); - let thumb = (track / content * track).max(30.0); - let scroll_ratio = f32::from(-off) / f32::from(max); - let thumb_pos = scroll_ratio * (track - thumb); - - let thumb_bounds = if is_rows { - Bounds { - origin: point(bounds.origin.x + px(1.0), bounds.origin.y + px(thumb_pos)), - size: size(px(4.0), px(thumb)), + .child( + canvas( + { + let hscroll_bounds = hscroll_bounds.clone(); + move |bounds, _window, _cx| { + *hscroll_bounds.borrow_mut() = Some(bounds); } - } else { - Bounds { - origin: point(bounds.origin.x + px(thumb_pos), bounds.origin.y + px(1.0)), - size: size(px(thumb), px(4.0)), + }, + move |bounds, _, window, _cx| { + let max_scroll = scroll_handle.max_offset(); + let max = if is_rows { max_scroll.y } else { max_scroll.x }; + if max <= px(2.0) { + return; } - }; - window.paint_quad(fill(thumb_bounds, scrollbar_color).corner_radii(px(2.0))); - }, - ).size_full()) + let offset = scroll_handle.offset(); + let off = if is_rows { offset.y } else { offset.x }; + let track = if is_rows { + f32::from(bounds.size.height) + } else { + f32::from(bounds.size.width) + }; + let content = track + f32::from(max); + let thumb = (track / content * track).max(30.0); + let scroll_ratio = f32::from(-off) / f32::from(max); + let thumb_pos = scroll_ratio * (track - thumb); + + let thumb_bounds = if is_rows { + Bounds { + origin: point( + bounds.origin.x + px(1.0), + bounds.origin.y + px(thumb_pos), + ), + size: size(px(4.0), px(thumb)), + } + } else { + Bounds { + origin: point( + bounds.origin.x + px(thumb_pos), + bounds.origin.y + px(1.0), + ), + size: size(px(thumb), px(4.0)), + } + }; + window.paint_quad( + fill(thumb_bounds, scrollbar_color).corner_radii(px(2.0)), + ); + }, + ) + .size_full(), + ) }) .into_any_element() } - } impl Render for WindowView { @@ -566,10 +658,15 @@ impl Render for WindowView { this.sidebar_ctrl.set_width(new_width); // Persist through global SettingsState (debounced) let width = this.sidebar_ctrl.width(); - settings_entity(cx).update(cx, |s, cx| s.set_sidebar_width(width, cx)); + settings_entity(cx) + .update(cx, |s, cx| s.set_sidebar_width(width, cx)); cx.notify(); } - DragState::ServicePanel { project_id, initial_mouse_y, initial_height } => { + DragState::ServicePanel { + project_id, + initial_mouse_y, + initial_height, + } => { // Dragging up increases height, dragging down decreases let delta = initial_mouse_y - f32::from(event.position.y); let new_height = initial_height + delta; @@ -580,7 +677,11 @@ impl Render for WindowView { }); } } - DragState::HookPanel { project_id, initial_mouse_y, initial_height } => { + DragState::HookPanel { + project_id, + initial_mouse_y, + initial_height, + } => { let delta = initial_mouse_y - f32::from(event.position.y); let new_height = initial_height + delta; let project_id = project_id.clone(); @@ -592,7 +693,13 @@ impl Render for WindowView { } _ => { // Handle split and project column resize - compute_resize(this.window_id, event.position, state, &workspace, cx); + compute_resize( + this.window_id, + event.position, + state, + &workspace, + cx, + ); // Bypass all .cached() views so terminal elements // repaint with new bounds during drag. window.refresh(); @@ -612,22 +719,33 @@ impl Render for WindowView { })) // Global mouse up handler to end resize (registered via window event // to reliably fire regardless of which child element the cursor is over) - .child(canvas( - |_bounds, _window, _cx| {}, - { + .child( + canvas(|_bounds, _window, _cx| {}, { 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,17 +756,50 @@ 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, + ); + } } } }); } - }, - ).absolute().size_full()) + }) + .absolute() + .size_full(), + ) // Handle sidebar toggle action from title bar .on_action(cx.listener(|this, _: &ToggleSidebar, _window, cx| { this.toggle_sidebar(cx); @@ -683,7 +834,9 @@ impl Render for WindowView { cx.notify(); }); } else { - let project_id = this.focus_manager.read(cx) + let project_id = this + .focus_manager + .read(cx) .focused_terminal_state() .map(|state| state.project_id); if let Some(project_id) = project_id { @@ -709,9 +862,10 @@ impl Render for WindowView { .or_else(|| fm.focused_project_id().map(String::from)) }; if let Some(project_id) = project_id - && let Some(col) = this.project_columns.get(&project_id).cloned() { - col.update(cx, |col, cx| col.show_branch_picker(window, cx)); - } + && let Some(col) = this.project_columns.get(&project_id).cloned() + { + col.update(cx, |col, cx| col.show_branch_picker(window, cx)); + } })) // Handle equalize layout action .on_action(cx.listener(|this, _: &EqualizeLayout, _window, cx| { @@ -760,15 +914,17 @@ impl Render for WindowView { ws.spawn_extra_window(Some(spawning_bounds), cx); }); })) - .on_action(cx.listener(|this, _: &CloseWindow, window, cx| match this.window_id { - crate::workspace::state::WindowId::Main => cx.quit(), - extra_id @ crate::workspace::state::WindowId::Extra(_) => { - this.workspace.update(cx, |ws, cx| { - ws.close_extra_window(extra_id, cx); - }); - window.remove_window(); - } - })) + .on_action( + cx.listener(|this, _: &CloseWindow, window, cx| match this.window_id { + crate::workspace::state::WindowId::Main => cx.quit(), + extra_id @ crate::workspace::state::WindowId::Extra(_) => { + this.workspace.update(cx, |ws, cx| { + ws.close_extra_window(extra_id, cx); + }); + window.remove_window(); + } + }), + ) // Handle focus sidebar action (keyboard navigation) .on_action(cx.listener(|this, _: &FocusSidebar, window, cx| { // Ensure sidebar is visible @@ -793,8 +949,15 @@ impl Render for WindowView { // Handle show session manager action .on_action(cx.listener({ let overlay_manager = overlay_manager.clone(); - move |_this, _: &ShowSessionManager, _window, cx| { - overlay_manager.update(cx, |om, cx| om.toggle_session_manager(cx)); + move |this, _: &ShowSessionManager, _window, cx| match this + .local_daemon_action_client(cx) + { + Ok(client) => { + overlay_manager.update(cx, |om, cx| om.toggle_session_manager(client, cx)) + } + Err(error) => { + crate::views::panels::toast::ToastManager::error(error, cx); + } } })) // Handle show theme selector action @@ -839,11 +1002,44 @@ 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); + })) + .on_action( + cx.listener(|_this, _: &okena_ext_updater::RebuildLocal, _window, cx| { + cx.emit(super::WindowViewEvent::RebuildLocal); + }), + ) + .on_action(cx.listener( + |_this, _: &okena_ext_updater::RestartLocalBuild, _window, cx| { + cx.emit(super::WindowViewEvent::RestartLocalBuild); + }, + )) // 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 +1058,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 +1076,17 @@ 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 @@ -928,7 +1108,9 @@ impl Render for WindowView { // Handle start all services action .on_action(cx.listener(|this, _: &StartAllServices, _window, cx| { if let Some(ref sm) = this.service_manager { - let project_id = this.focus_manager.read(cx) + let project_id = this + .focus_manager + .read(cx) .focused_terminal_state() .map(|f| f.project_id.clone()); if let Some(pid) = project_id { @@ -942,7 +1124,9 @@ impl Render for WindowView { // Handle stop all services action .on_action(cx.listener(|this, _: &StopAllServices, _window, cx| { if let Some(ref sm) = this.service_manager { - let project_id = this.focus_manager.read(cx) + let project_id = this + .focus_manager + .read(cx) .focused_terminal_state() .map(|f| f.project_id.clone()); if let Some(pid) = project_id { @@ -954,18 +1138,26 @@ impl Render for WindowView { .on_action(cx.listener(|this, _: &ShowFileSearch, _window, cx| { let fm = this.focus_manager.read(cx); let ws = this.workspace.read(cx); - let project_id = fm.focused_terminal_state() + let project_id = fm + .focused_terminal_state() .map(|f| f.project_id.clone()) .or_else(|| { - ws.visible_projects(this.window_id, fm.focused_project_id(), fm.is_focus_individual()) - .first() - .map(|p| p.id.clone()) + ws.visible_projects( + this.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .first() + .map(|p| p.id.clone()) }); if let Some(project_id) = project_id { this.request_broker.update(cx, |broker, cx| { broker.push_overlay_request( - OverlayRequest::Project(ProjectOverlay { project_id, kind: ProjectOverlayKind::FileSearch }), + OverlayRequest::Project(ProjectOverlay { + project_id, + kind: ProjectOverlayKind::FileSearch, + }), cx, ); }); @@ -975,18 +1167,26 @@ impl Render for WindowView { .on_action(cx.listener(|this, _: &ShowContentSearch, _window, cx| { let fm = this.focus_manager.read(cx); let ws = this.workspace.read(cx); - let project_id = fm.focused_terminal_state() + let project_id = fm + .focused_terminal_state() .map(|f| f.project_id.clone()) .or_else(|| { - ws.visible_projects(this.window_id, fm.focused_project_id(), fm.is_focus_individual()) - .first() - .map(|p| p.id.clone()) + ws.visible_projects( + this.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .first() + .map(|p| p.id.clone()) }); if let Some(project_id) = project_id { this.request_broker.update(cx, |broker, cx| { broker.push_overlay_request( - OverlayRequest::Project(ProjectOverlay { project_id, kind: ProjectOverlayKind::ContentSearch }), + OverlayRequest::Project(ProjectOverlay { + project_id, + kind: ProjectOverlayKind::ContentSearch, + }), cx, ); }); @@ -1003,22 +1203,34 @@ impl Render for WindowView { .on_action(cx.listener(|this, _: &ShowDiffViewer, _window, cx| { let fm = this.focus_manager.read(cx); let ws = this.workspace.read(cx); - let project_id = fm.focused_terminal_state() + let project_id = fm + .focused_terminal_state() .map(|f| f.project_id.clone()) .or_else(|| { - ws.visible_projects(this.window_id, fm.focused_project_id(), fm.is_focus_individual()) - .first() - .map(|p| p.id.clone()) + ws.visible_projects( + this.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .first() + .map(|p| p.id.clone()) }); if let Some(project_id) = project_id { this.request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(OverlayRequest::Project(ProjectOverlay { - project_id, - kind: ProjectOverlayKind::DiffViewer { - file: None, mode: None, commit_message: None, commits: None, commit_index: None, - }, - }), cx); + broker.push_overlay_request( + OverlayRequest::Project(ProjectOverlay { + project_id, + kind: ProjectOverlayKind::DiffViewer { + file: None, + mode: None, + commit_message: None, + commits: None, + commit_index: None, + }, + }), + cx, + ); }); } })) @@ -1029,42 +1241,62 @@ impl Render for WindowView { let target = { let fm = this.focus_manager.read(cx); let ws = this.workspace.read(cx); - let project_id = fm.focused_terminal_state() + let project_id = fm + .focused_terminal_state() .map(|f| f.project_id.clone()) .or_else(|| { - ws.visible_projects(this.window_id, fm.focused_project_id(), fm.is_focus_individual()) - .first() - .map(|p| p.id.clone()) + ws.visible_projects( + this.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .first() + .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 { + crate::git::resolve_review_base(std::path::Path::new(&project_path)).map( + |base| crate::git::DiffMode::BranchCompare { base, head: "HEAD".to_string(), - }) + }, + ) }; this.request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(OverlayRequest::Project(ProjectOverlay { - project_id, - kind: ProjectOverlayKind::DiffViewer { - file: None, mode, commit_message: None, commits: None, commit_index: None, - }, - }), cx); + broker.push_overlay_request( + OverlayRequest::Project(ProjectOverlay { + project_id, + kind: ProjectOverlayKind::DiffViewer { + file: None, + mode, + commit_message: None, + commits: None, + commit_index: None, + }, + }), + cx, + ); }); })) // Title bar at the top (with window controls) @@ -1084,24 +1316,32 @@ impl Render for WindowView { .min_w_0() .relative() // Auto-hide hover zone (invisible strip on the left edge) - .when(self.sidebar_ctrl.is_auto_hide() && !self.sidebar_ctrl.is_open() && !self.sidebar_ctrl.is_hover_shown(), |d| { - d.child( - div() - .id("sidebar-hover-zone") - .absolute() - .left_0() - .top_0() - .h_full() - .w(px(8.0)) - .hover(|s| s.cursor_pointer()) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.show_sidebar_on_hover(cx); - })) - .on_mouse_move(cx.listener(|this, _, _window, cx| { - this.show_sidebar_on_hover(cx); - })) - ) - }) + .when( + self.sidebar_ctrl.is_auto_hide() + && !self.sidebar_ctrl.is_open() + && !self.sidebar_ctrl.is_hover_shown(), + |d| { + d.child( + div() + .id("sidebar-hover-zone") + .absolute() + .left_0() + .top_0() + .h_full() + .w(px(8.0)) + .hover(|s| s.cursor_pointer()) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.show_sidebar_on_hover(cx); + }), + ) + .on_mouse_move(cx.listener(|this, _, _window, cx| { + this.show_sidebar_on_hover(cx); + })), + ) + }, + ) .child( // Sidebar container - animated width { @@ -1118,15 +1358,13 @@ impl Render for WindowView { .when(show_sidebar, |d| { d.child( // Inner wrapper to maintain sidebar at full width for clipping effect - div() - .w(px(configured_width)) - .h_full() - .child(AnyView::from(self.sidebar.clone()).cached( - StyleRefinement::default().size_full() - )) + div().w(px(configured_width)).h_full().child( + AnyView::from(self.sidebar.clone()) + .cached(StyleRefinement::default().size_full()), + ), ) }) - } + }, ) // Sidebar resize divider (only when sidebar is visible) .when(self.sidebar_ctrl.should_render(), |d| { @@ -1155,9 +1393,10 @@ impl Render for WindowView { // Status bar at the bottom .child(self.status_bar.clone()) // App menu dropdown (renders on top of everything, not on macOS where native menu is used) - .when(!cfg!(target_os = "macos") && self.title_bar.read(cx).is_menu_open(), |d| { - d.child(self.title_bar.update(cx, |tb, cx| tb.render_menu(cx))) - }) + .when( + !cfg!(target_os = "macos") && self.title_bar.read(cx).is_menu_open(), + |d| d.child(self.title_bar.update(cx, |tb, cx| tb.render_menu(cx))), + ) // Color picker popover (positioned popup, rendered at root for full-window backdrop) .when(has_color_picker, |d| { d.children(self.overlay_manager.read(cx).render_color_picker()) diff --git a/crates/okena-app/src/views/window/sidebar.rs b/crates/okena-app/src/views/window/sidebar.rs index 99ad666df..e3ed2471d 100644 --- a/crates/okena-app/src/views/window/sidebar.rs +++ b/crates/okena-app/src/views/window/sidebar.rs @@ -1,5 +1,5 @@ use crate::settings::settings_entity; -use crate::views::sidebar_controller::{AnimationTarget, SidebarController, FRAME_TIME_MS}; +use crate::views::sidebar_controller::{AnimationTarget, FRAME_TIME_MS, SidebarController}; use gpui::*; use super::WindowView; @@ -15,7 +15,8 @@ impl WindowView { // window (which uses the global as its first-launch default before // its own WindowState entry exists) opens with the user's most // recent preference. - self.workspace.update(cx, |ws, cx| ws.set_sidebar_open(window_id, open, cx)); + self.workspace + .update(cx, |ws, cx| ws.set_sidebar_open(window_id, open, cx)); settings_entity(cx).update(cx, |s, cx| s.set_sidebar_open(open, cx)); self.sync_status_bar_sidebar_state(cx); self.animate_sidebar_to(target, cx); @@ -38,7 +39,8 @@ impl WindowView { let open = self.sidebar_ctrl.is_open(); let window_id = self.window_id; let auto_hide = self.sidebar_ctrl.is_auto_hide(); - self.workspace.update(cx, |ws, cx| ws.set_sidebar_open(window_id, open, cx)); + self.workspace + .update(cx, |ws, cx| ws.set_sidebar_open(window_id, open, cx)); settings_entity(cx).update(cx, |s, cx| { s.set_sidebar_auto_hide(auto_hide, cx); s.set_sidebar_open(open, cx); @@ -101,6 +103,7 @@ impl WindowView { this.sidebar_ctrl.set_animation(target); cx.notify(); }); - }).detach(); + }) + .detach(); } } diff --git a/crates/okena-app/src/views/window/terminal_actions.rs b/crates/okena-app/src/views/window/terminal_actions.rs index 7d720ac7c..f9c5c3b7d 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, + ); } } @@ -134,32 +32,46 @@ impl WindowView { let project_info = { let ws = self.workspace.read(cx); let fm = self.focus_manager.read(cx); - let project_id = fm.focused_terminal_state() + let project_id = fm + .focused_terminal_state() .map(|f| f.project_id.clone()) .or_else(|| { // Fallback: use the first visible project - ws.visible_projects(self.window_id, fm.focused_project_id(), fm.is_focus_individual()) - .first() - .map(|p| p.id.clone()) + ws.visible_projects( + self.window_id, + fm.focused_project_id(), + fm.is_focus_individual(), + ) + .first() + .map(|p| p.id.clone()) }); project_id.and_then(|id| { 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)); - (id, project_path, is_git, is_worktree) + let is_git = ws + .remote_snapshot(&id) + .and_then(|s| s.git_status.as_ref()) + .is_some(); + let connection_id = p.connection_id.clone(); + (id, connection_id, is_git, is_worktree) }) }) }; - if let Some((project_id, project_path, is_git, is_worktree)) = project_info { + if let Some((project_id, connection_id, is_git, is_worktree)) = project_info { if is_git && !is_worktree { - self.overlay_manager.update(cx, |om, cx| { - om.show_worktree_dialog(project_id, project_path, cx); - }); + let params = connection_id + .and_then(|connection_id| self.remote_params(&project_id, &connection_id, cx)); + if let Some(params) = params { + self.overlay_manager.update(cx, |om, cx| { + om.show_worktree_dialog(project_id, params, cx); + }); + } } else { - log::info!("Cannot create worktree: project is not a git repo or is already a worktree"); + log::info!( + "Cannot create worktree: project is not a git repo or is already a worktree" + ); } } } diff --git a/crates/okena-cli/Cargo.toml b/crates/okena-cli/Cargo.toml index 3ccf95c4a..8814f920e 100644 --- a/crates/okena-cli/Cargo.toml +++ b/crates/okena-cli/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" [dependencies] okena-core = { path = "../okena-core" } -okena-transport = { path = "../okena-transport", features = ["client"] } +okena-transport = { path = "../okena-transport", features = ["client", "blocking-http"] } okena-workspace = { path = "../okena-workspace" } okena-remote-server = { path = "../okena-remote-server" } @@ -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..703f11701 100644 --- a/crates/okena-cli/src/commands.rs +++ b/crates/okena-cli/src/commands.rs @@ -1,7 +1,7 @@ use crate::resolve; -use crate::{api_get, api_post, discover_server, ensure_token}; -use okena_remote_server::auth::{generate_pairing_code, pair_code_path}; +use crate::{api_action, api_get, discover_server, ensure_token}; use okena_core::api::{ApiProject, StateResponse}; +use okena_remote_server::auth::{generate_pairing_code, pair_code_path}; /// The agent skill, embedded so `skill show`/`install` always match this build. const SKILL_MD: &str = include_str!("skill.md"); @@ -15,10 +15,11 @@ pub fn cli_pair() -> i32 { let path = pair_code_path(); if let Some(parent) = path.parent() - && let Err(e) = std::fs::create_dir_all(parent) { - eprintln!("Failed to create config directory: {e}"); - return 1; - } + && let Err(e) = std::fs::create_dir_all(parent) + { + eprintln!("Failed to create config directory: {e}"); + return 1; + } if let Err(e) = std::fs::write(&path, &code) { eprintln!("Failed to write pairing code: {e}"); @@ -45,14 +46,17 @@ pub fn cli_pair() -> i32 { eprintln!( "TLS certificate fingerprint (SHA-256) — verify it matches the connecting client:" ); - eprintln!(" {}", okena_transport::client::tls::format_fingerprint(&fp)); + eprintln!( + " {}", + okena_transport::client::tls::format_fingerprint(&fp) + ); } eprintln!("Expires in 60s — run `okena pair` again for a fresh code."); 0 } 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 +64,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() @@ -87,8 +97,12 @@ pub fn cli_health(json_mode: bool) -> i32 { // tab-separated: status version uptime println!( "{}\t{}\t{}", - v.get("status").and_then(|s| s.as_str()).unwrap_or("unknown"), - v.get("version").and_then(|s| s.as_str()).unwrap_or("unknown"), + v.get("status") + .and_then(|s| s.as_str()) + .unwrap_or("unknown"), + v.get("version") + .and_then(|s| s.as_str()) + .unwrap_or("unknown"), v.get("uptime_secs").and_then(|s| s.as_u64()).unwrap_or(0), ); } else { @@ -133,7 +147,7 @@ pub fn cli_action(json: &str) -> i32 { } }; - match api_post("/v1/actions", &token, json) { + match api_action(&token, json) { Ok(body) => { if !body.is_empty() { println!("{body}"); @@ -288,7 +302,10 @@ pub fn cli_service( if let Some(project) = state.projects.iter().find(|p| p.id == project_id) && !project.services.iter().any(|s| s.name == service_name) { - eprintln!("No service named '{service_name}' in project '{}'.", project.name); + eprintln!( + "No service named '{service_name}' in project '{}'.", + project.name + ); let available: Vec<&str> = project.services.iter().map(|s| s.name.as_str()).collect(); if available.is_empty() { eprintln!("That project has no services."); @@ -305,7 +322,7 @@ pub fn cli_service( "service_name": service_name, }); - if let Err(e) = api_post("/v1/actions", &token, &body.to_string()) { + if let Err(e) = api_action(&token, &body.to_string()) { eprintln!("{e}"); return 1; } @@ -423,10 +440,7 @@ pub fn cli_whoami(json_mode: bool) -> i32 { Ok(t) => t, Err(e) => { if json_mode { - println!( - "{}", - serde_json::json!({ "terminal_id": terminal_id }) - ); + println!("{}", serde_json::json!({ "terminal_id": terminal_id })); } else { println!("{terminal_id}"); } @@ -467,10 +481,7 @@ pub fn cli_whoami(json_mode: bool) -> i32 { // Terminal not found in any project if json_mode { - println!( - "{}", - serde_json::json!({ "terminal_id": terminal_id }) - ); + println!("{}", serde_json::json!({ "terminal_id": terminal_id })); } else { println!("{terminal_id}"); } @@ -478,10 +489,7 @@ pub fn cli_whoami(json_mode: bool) -> i32 { } Err(e) => { if json_mode { - println!( - "{}", - serde_json::json!({ "terminal_id": terminal_id }) - ); + println!("{}", serde_json::json!({ "terminal_id": terminal_id })); } else { println!("{terminal_id}"); } @@ -592,7 +600,7 @@ where /// POST an action body and print any non-empty response on stdout. fn post_action(token: &str, body: &serde_json::Value) -> i32 { - match api_post("/v1/actions", token, &body.to_string()) { + match api_action(token, &body.to_string()) { Ok(resp) => { if !resp.trim().is_empty() { println!("{}", resp.trim()); @@ -610,7 +618,7 @@ fn post_action(token: &str, body: &serde_json::Value) -> i32 { /// `id_fields` are response keys to print (each on its own line) — used for /// commands that return new ids (e.g. `project_id`, `terminal_ids`). fn post_action_print_ids(token: &str, body: &serde_json::Value, id_fields: &[&str]) -> i32 { - match api_post("/v1/actions", token, &body.to_string()) { + match api_action(token, &body.to_string()) { Ok(resp) => { print_response_ids(&resp, id_fields); 0 @@ -1126,7 +1134,7 @@ pub fn cli_project_add( "name": project_name, "path": abs_str, }); - let resp = match api_post("/v1/actions", &token, &body.to_string()) { + let resp = match api_action(&token, &body.to_string()) { Ok(r) => r, Err(e) => { eprintln!("{e}"); @@ -1156,7 +1164,7 @@ pub fn cli_project_add( eprintln!("{e}"); return 1; } - if let Err(e) = api_post("/v1/actions", &token, &hide_body.to_string()) { + if let Err(e) = api_action(&token, &hide_body.to_string()) { eprintln!("Warning: failed to hide project: {e}"); return 1; } @@ -1183,7 +1191,7 @@ pub fn cli_project_add( "project_id": project_id, "folder_id": folder_id, }); - if let Err(e) = api_post("/v1/actions", &token, &move_body.to_string()) { + if let Err(e) = api_action(&token, &move_body.to_string()) { eprintln!("Warning: failed to move project into folder: {e}"); return 1; } @@ -1315,10 +1323,24 @@ pub fn cli_worktree_add(project: &str, branch: &str, new_branch: bool) -> i32 { "branch": branch, "create_branch": new_branch, }); - match api_post("/v1/actions", &token, &body.to_string()) { + match api_action(&token, &body.to_string()) { Ok(resp) => { - // Returns {project_id, terminal_id, path} — print project_id and path. + // Returns {project_id, path, pending} — print project_id and path to + // stdout (scripting). The create is OPTIMISTIC: `pending: true` means + // the checkout is still running in the background, so `path` does not + // exist on disk yet — warn on stderr so a script does not `cd` into it + // immediately. On a later background failure the row is removed from + // state (observable via `okena ls` / `okena state`) plus a toast. print_response_ids(&resp, &["project_id", "path"]); + let pending = serde_json::from_str::(&resp) + .ok() + .and_then(|v| v.get("pending").and_then(|p| p.as_bool())) + .unwrap_or(false); + if pending { + eprintln!( + "worktree creation started in the background; the path will exist once the checkout completes" + ); + } 0 } Err(e) => { @@ -1639,7 +1661,7 @@ pub fn cli_run(terminal: &str, command: &[String], wait: bool, timeout_secs: u64 "terminal_id": terminal_id, "command": full, }); - if let Err(e) = api_post("/v1/actions", &token, &body.to_string()) { + if let Err(e) = api_action(&token, &body.to_string()) { eprintln!("{e}"); return 1; } @@ -1674,11 +1696,10 @@ pub fn cli_run(terminal: &str, command: &[String], wait: bool, timeout_secs: u64 /// Post a `read_content` action and return the terminal's visible content. fn fetch_terminal_content(token: &str, terminal_id: &str) -> Result { let body = serde_json::json!({ "action": "read_content", "terminal_id": terminal_id }); - let resp = api_post("/v1/actions", token, &body.to_string())?; + let resp = api_action(token, &body.to_string())?; let v: serde_json::Value = serde_json::from_str(&resp).map_err(|e| format!("bad read response: {e}"))?; - Ok(v - .get("content") + Ok(v.get("content") .and_then(|c| c.as_str()) .unwrap_or("") .to_string()) @@ -1739,14 +1760,17 @@ pub fn cli_skill_install(_user: bool, project: bool) -> i32 { /// Authenticate and POST an action body, returning the raw response. fn post_action_body(body: &serde_json::Value) -> Result { let token = ensure_token()?; - api_post("/v1/actions", &token, &body.to_string()) + api_action(&token, &body.to_string()) } /// Pretty-print a JSON response (raw fallback on parse failure). fn print_json_pretty(raw: &str) { match serde_json::from_str::(raw) { Ok(v) => { - println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| raw.to_string())) + println!( + "{}", + serde_json::to_string_pretty(&v).unwrap_or_else(|_| raw.to_string()) + ) } Err(_) => println!("{}", raw.trim()), } @@ -1809,7 +1833,10 @@ pub fn cli_settings_show(key: Option<&str>) -> i32 { }; match navigate_json(&v, k) { Some(found) => { - println!("{}", serde_json::to_string_pretty(found).unwrap_or_default()); + println!( + "{}", + serde_json::to_string_pretty(found).unwrap_or_default() + ); 0 } None => { @@ -1873,7 +1900,14 @@ pub fn cli_theme_list(json_mode: bool) -> i32 { "" }; // id \t name \t kind \t is_dark \t active - println!("{}\t{}\t{}\t{}\t{}", s("id"), s("name"), s("kind"), s("is_dark"), active); + println!( + "{}\t{}\t{}\t{}\t{}", + s("id"), + s("name"), + s("kind"), + s("is_dark"), + active + ); } } 0 @@ -1938,6 +1972,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 @@ -2008,7 +2052,7 @@ pub fn cli_read(terminal: &str, json_mode: bool) -> i32 { } }; let body = serde_json::json!({ "action": "read_content", "terminal_id": terminal_id }); - match api_post("/v1/actions", &token, &body.to_string()) { + match api_action(&token, &body.to_string()) { Ok(resp) => { if json_mode { println!("{}", resp.trim()); @@ -2041,7 +2085,10 @@ mod tests { assert_eq!(parse_done_marker(echo, tag), None); // The actual printf output carries digits — matches. assert_eq!(parse_done_marker("OKENADONE_42:0:END", tag), Some(0)); - assert_eq!(parse_done_marker("noise\nOKENADONE_42:130:END\n$", tag), Some(130)); + assert_eq!( + parse_done_marker("noise\nOKENADONE_42:130:END\n$", tag), + Some(130) + ); // Echo line followed by the real result line: still resolves the result. let both = format!("{echo}\nOKENADONE_42:7:END\nokena $ "); assert_eq!(parse_done_marker(&both, tag), Some(7)); diff --git a/crates/okena-cli/src/lib.rs b/crates/okena-cli/src/lib.rs index 6658508d0..d73873330 100644 --- a/crates/okena-cli/src/lib.rs +++ b/crates/okena-cli/src/lib.rs @@ -3,8 +3,10 @@ pub mod parser; pub mod register; pub mod resolve; -use okena_workspace::persistence::config_dir; use clap::Parser as _; +use okena_core::process::is_process_alive; +use okena_transport::client::{LocalEndpoint, RemoteConnectionConfig}; +use okena_workspace::persistence::config_dir; use parser::{ Cli, Command, FolderCmd, PaletteCmd, ProjectCmd, ServiceCmd, SettingsCmd, SkillCmd, TermCmd, ThemeCmd, WorktreeCmd, @@ -32,8 +34,7 @@ pub fn try_handle_cli() -> Option { let first = args.get(1)?.as_str(); // Explicit top-level help / version request → let clap render it. - let is_help_or_version = - matches!(first, "-h" | "--help" | "help" | "-V" | "--version"); + let is_help_or_version = matches!(first, "-h" | "--help" | "help" | "-V" | "--version"); // Only claim the args if the first token is one of our subcommands. if !is_help_or_version && !parser::subcommand_names().contains(&first) { @@ -47,8 +48,7 @@ pub fn try_handle_cli() -> Option { // use exit code 2 for genuine parse errors (0 for --help/--version). let _ = e.print(); let code = match e.kind() { - clap::error::ErrorKind::DisplayHelp - | clap::error::ErrorKind::DisplayVersion => 0, + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => 0, _ => 2, }; Some(code) @@ -90,19 +90,23 @@ fn dispatch(cli: Cli) -> i32 { Command::Health { json } => commands::cli_health(json), Command::State => commands::cli_state(), Command::Action { json } => commands::cli_action(&json), - Command::Services { project, json } => { - commands::cli_services(project.as_deref(), json) - } + Command::Services { project, json } => commands::cli_services(project.as_deref(), json), Command::Service { cmd } => match cmd { - ServiceCmd::Start { name, project, json } => { - commands::cli_service("start", &name, project.as_deref(), json) - } - ServiceCmd::Stop { name, project, json } => { - commands::cli_service("stop", &name, project.as_deref(), json) - } - ServiceCmd::Restart { name, project, json } => { - commands::cli_service("restart", &name, project.as_deref(), json) - } + ServiceCmd::Start { + name, + project, + json, + } => commands::cli_service("start", &name, project.as_deref(), json), + ServiceCmd::Stop { + name, + project, + json, + } => commands::cli_service("stop", &name, project.as_deref(), json), + ServiceCmd::Restart { + name, + project, + json, + } => commands::cli_service("restart", &name, project.as_deref(), json), }, Command::Whoami { json } => commands::cli_whoami(json), Command::Ls { json } => commands::cli_ls(json), @@ -113,22 +117,14 @@ fn dispatch(cli: Cli) -> i32 { name, hidden, folder, - } => commands::cli_project_add( - &path, - name.as_deref(), - hidden, - folder.as_deref(), - window, - ), + } => { + commands::cli_project_add(&path, name.as_deref(), hidden, folder.as_deref(), window) + } ProjectCmd::Rm { project } => commands::cli_project_rm(&project), ProjectCmd::Show { project } => commands::cli_project_show(&project, true, window), ProjectCmd::Hide { project } => commands::cli_project_show(&project, false, window), - ProjectCmd::Rename { project, name } => { - commands::cli_project_rename(&project, &name) - } - ProjectCmd::Color { project, color } => { - commands::cli_project_color(&project, &color) - } + ProjectCmd::Rename { project, name } => commands::cli_project_rename(&project, &name), + ProjectCmd::Color { project, color } => commands::cli_project_color(&project, &color), ProjectCmd::Focus { project } => commands::cli_project_focus(&project, window), }, @@ -153,9 +149,10 @@ fn dispatch(cli: Cli) -> i32 { TermCmd::Close { terminal } => commands::cli_term_close(&terminal), TermCmd::Focus { terminal } => commands::cli_term_focus(&terminal, window), TermCmd::Rename { terminal, name } => commands::cli_term_rename(&terminal, &name), - TermCmd::Split { terminal, direction } => { - commands::cli_term_split(&terminal, &direction) - } + TermCmd::Split { + terminal, + direction, + } => commands::cli_term_split(&terminal, &direction), TermCmd::Tab { terminal } => commands::cli_term_tab(&terminal), TermCmd::Minimize { terminal } => commands::cli_term_minimize(&terminal), TermCmd::Fullscreen { terminal, off } => { @@ -187,9 +184,11 @@ fn dispatch(cli: Cli) -> i32 { ThemeCmd::List { json } => commands::cli_theme_list(json), ThemeCmd::Show { id } => commands::cli_theme_show(id.as_deref()), ThemeCmd::Set { id } => commands::cli_theme_set(&id), - ThemeCmd::Save { id, json, no_activate } => { - commands::cli_theme_save(&id, json.as_deref(), !no_activate) - } + ThemeCmd::Save { + id, + json, + no_activate, + } => commands::cli_theme_save(&id, json.as_deref(), !no_activate), }, Command::Cmd { cmd } => match cmd { PaletteCmd::List { json } => commands::cli_command_list(json), @@ -216,8 +215,7 @@ fn save_cli_config(config: &CliConfig) -> Result<(), String> { } let json = serde_json::to_string_pretty(config).map_err(|e| format!("Failed to serialize: {e}"))?; - std::fs::write(&path, json.as_bytes()) - .map_err(|e| format!("Failed to write cli.json: {e}"))?; + std::fs::write(&path, json.as_bytes()).map_err(|e| format!("Failed to write cli.json: {e}"))?; #[cfg(unix)] { @@ -229,9 +227,44 @@ 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, + tls: bool, + 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}"))); + } + + let client = okena_transport::client::tls::build_blocking_reqwest_client( + self.tls, + None, + okena_transport::client::tls::new_observed(), + std::time::Duration::from_secs(10), + )?; + let scheme = if self.tls { "https" } else { "http" }; + Ok(( + client, + format!("{scheme}://{}:{}{path}", self.host, self.port), + )) + } +} + /// 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 +274,33 @@ 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 tls = json + .get("tls") + .and_then(|value| value.as_bool()) + .unwrap_or(false); 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, + tls, + local_endpoint, + }) } /// Ensure we have a valid token, auto-registering if needed. @@ -269,17 +309,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 +327,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)) @@ -308,27 +346,27 @@ fn api_get(path: &str, token: &str) -> Result { resp.text().map_err(|e| format!("Failed to read body: {e}")) } -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 resp = client - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Content-Type", "application/json") - .body(body.to_string()) - .timeout(std::time::Duration::from_secs(10)) - .send() - .map_err(|e| format!("Request failed: {e}"))?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err("Token expired or revoked. Delete ~/.config/okena/cli.json and retry.".into()); - } - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().unwrap_or_default(); - return Err(format!("Server returned {}: {}", status, body)); +fn api_action(token: &str, body: &str) -> Result { + let server = discover_server()?; + let action: okena_core::api::ActionRequest = + serde_json::from_str(body).map_err(|e| format!("Invalid action: {e}"))?; + let config = RemoteConnectionConfig { + id: okena_transport::client::LOCAL_DAEMON_CONNECTION_ID.to_string(), + name: "Local daemon".to_string(), + host: server.host, + port: server.port, + saved_token: Some(token.to_string()), + token_obtained_at: None, + tls: server.tls, + pinned_cert_sha256: None, + local_endpoint: server.local_endpoint, + }; + match okena_transport::remote_action::RemoteActionClient::new(config, token.to_string()) + .post_action(action)? + { + Some(value) => { + serde_json::to_string(&value).map_err(|e| format!("Failed to serialize response: {e}")) + } + None => Ok(String::new()), } - - resp.text().map_err(|e| format!("Failed to read body: {e}")) } diff --git a/crates/okena-cli/src/parser.rs b/crates/okena-cli/src/parser.rs index d26cc3437..4d010e155 100644 --- a/crates/okena-cli/src/parser.rs +++ b/crates/okena-cli/src/parser.rs @@ -486,7 +486,8 @@ mod tests { // `run --wait` flags precede the trailing command (trailing_var_arg). assert!(Cli::try_parse_from(["okena", "run", "--wait", "t1", "make"]).is_ok()); assert!( - Cli::try_parse_from(["okena", "run", "--wait", "--timeout", "60", "t1", "make"]).is_ok() + Cli::try_parse_from(["okena", "run", "--wait", "--timeout", "60", "t1", "make"]) + .is_ok() ); assert!(Cli::try_parse_from(["okena", "skill", "show"]).is_ok()); assert!(Cli::try_parse_from(["okena", "skill", "install", "--project"]).is_ok()); @@ -498,13 +499,18 @@ mod tests { assert!(Cli::try_parse_from(["okena", "theme", "save", "mine", "--no-activate"]).is_ok()); assert!(Cli::try_parse_from(["okena", "command", "list"]).is_ok()); assert!( - Cli::try_parse_from(["okena", "command", "run", "ToggleSidebar", "--window", "main"]) - .is_ok() + Cli::try_parse_from([ + "okena", + "command", + "run", + "ToggleSidebar", + "--window", + "main" + ]) + .is_ok() ); // --user and --project are mutually exclusive. - assert!( - Cli::try_parse_from(["okena", "skill", "install", "--user", "--project"]).is_err() - ); + assert!(Cli::try_parse_from(["okena", "skill", "install", "--user", "--project"]).is_err()); // Missing required positional → error. assert!(Cli::try_parse_from(["okena", "term", "split", "p/sh"]).is_err()); } diff --git a/crates/okena-cli/src/register.rs b/crates/okena-cli/src/register.rs index e364d561e..5a7a4f631 100644 --- a/crates/okena-cli/src/register.rs +++ b/crates/okena-cli/src/register.rs @@ -1,90 +1,35 @@ 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..0164b31af 100644 --- a/crates/okena-cli/src/resolve.rs +++ b/crates/okena-cli/src/resolve.rs @@ -107,7 +107,10 @@ fn collect_terminals(node: &ApiLayoutNode, project: &ApiProject, out: &mut Vec Option { - project_terminals(project).into_iter().next().map(|t| t.terminal_id) + project_terminals(project) + .into_iter() + .next() + .map(|t| t.terminal_id) } /// Resolve a terminal address into `(project_id, terminal_id)`. @@ -118,10 +121,7 @@ pub fn first_terminal_id(project: &ApiProject) -> Option { /// `terminal_names` value equals `` (case-insensitive); /// - `":"` — the Nth terminal (0-based) in that project's /// layout, in DFS order. -pub fn resolve_terminal( - state: &StateResponse, - filter: &str, -) -> Result<(String, String), String> { +pub fn resolve_terminal(state: &StateResponse, filter: &str) -> Result<(String, String), String> { // Form: : if let Some((proj_part, idx_part)) = filter.rsplit_once(':') && let Ok(index) = idx_part.parse::() @@ -174,7 +174,11 @@ pub fn resolve_terminal( } fn terminal_names_list(project: &ApiProject) -> String { - let mut names: Vec<&str> = project.terminal_names.values().map(|s| s.as_str()).collect(); + let mut names: Vec<&str> = project + .terminal_names + .values() + .map(|s| s.as_str()) + .collect(); names.sort_unstable(); if names.is_empty() { "(none)".to_string() @@ -284,6 +288,7 @@ mod tests { terminal_id: Some(id.into()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, } @@ -306,6 +311,13 @@ 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(), + is_creating: false, + is_closing: false, } } @@ -347,13 +359,18 @@ mod tests { project_order: vec![], folders: vec![], windows, + hooks: Vec::new(), } } fn window(id: &str, active: bool) -> ApiWindow { ApiWindow { id: id.into(), - kind: if id == "main" { "main".into() } else { "extra".into() }, + kind: if id == "main" { + "main".into() + } else { + "extra".into() + }, active, focused_project_id: None, focused_terminal_id: None, @@ -464,7 +481,10 @@ mod tests { // Unique prefix. assert_eq!(resolve_window(&state, "def").unwrap(), "def00000-cccc"); // Full id. - assert_eq!(resolve_window(&state, "abc12345-aaaa").unwrap(), "abc12345-aaaa"); + assert_eq!( + resolve_window(&state, "abc12345-aaaa").unwrap(), + "abc12345-aaaa" + ); // Ambiguous prefix. assert!(resolve_window(&state, "abc").is_err()); // Unknown. diff --git a/crates/okena-cli/src/skill.md b/crates/okena-cli/src/skill.md index 6e2410a83..5fb04f218 100644 --- a/crates/okena-cli/src/skill.md +++ b/crates/okena-cli/src/skill.md @@ -66,6 +66,10 @@ Commands that create things (`term new/split/tab`, `project add`, `worktree add` - **`run --wait` assumes a POSIX-ish shell** (bash/zsh/sh) and a non-interactive command (it appends a completion marker). Don't use it for vim/REPLs. - **A bare `run` reports no completion or exit code** — only `run --wait` does. +- **`worktree add` is optimistic**: it prints the id + path and returns before the + checkout exists on disk (the reply carries `pending: true`). Don't `cd` into the + path immediately — poll `okena ls`/`okena state` until the worktree's terminal + appears; if creation fails the row disappears from state. - **`--window` is honored only by** `project add/show/hide/focus` and `term focus/fullscreen`, and must come AFTER the subcommand; others just warn. - Default output is tab-separated (grep/awk friendly); add `--json` for structured. diff --git a/crates/okena-core/Cargo.toml b/crates/okena-core/Cargo.toml index f9ea4ad82..df639c75c 100644 --- a/crates/okena-core/Cargo.toml +++ b/crates/okena-core/Cargo.toml @@ -8,6 +8,8 @@ license = "MIT" default = [] # Exposes `process::testing` (command bus mock) to downstream crates' tests. test-support = [] +# Compiles opt-in terminal input latency instrumentation. +terminal-latency-probe = [] [dependencies] serde = { version = "1.0", features = ["derive"] } @@ -21,7 +23,7 @@ anyhow = "1.0" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp", "Win32_Foundation", "Win32_Security"] } [dev-dependencies] tempfile = "3" diff --git a/crates/okena-core/src/api.rs b/crates/okena-core/src/api.rs index 76669fcf2..7871ef496 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 { @@ -26,6 +35,12 @@ pub struct StateResponse { pub folders: Vec, #[serde(default)] pub windows: Vec, + /// Recent hook execution history (newest first) from the daemon's + /// `HookMonitor`, so thin clients can render the hook log / status even + /// though the hooks ran remotely. `#[serde(default)]` keeps snapshots from + /// older servers (which omit the field) deserializable. + #[serde(default)] + pub hooks: Vec, } /// OS window bounds in screen pixels. @@ -173,8 +188,14 @@ impl CiCheckSummary { pub fn tooltip_text(&self) -> String { match self.status { CiStatus::Success => format!("{}/{} checks passed", self.passed, self.total), - CiStatus::Failure => format!("{} failed, {} passed of {} checks", self.failed, self.passed, self.total), - CiStatus::Pending => format!("{} pending, {} passed of {} checks", self.pending, self.passed, self.total), + CiStatus::Failure => format!( + "{} failed, {} passed of {} checks", + self.failed, self.passed, self.total + ), + CiStatus::Pending => format!( + "{} pending, {} passed of {} checks", + self.pending, self.passed, self.total + ), } } } @@ -192,6 +213,23 @@ pub struct PrInfo { pub base: Option, } +/// Open pull request offered as a worktree source. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreePullRequest { + pub number: u32, + pub title: String, + pub branch: String, +} + +/// Daemon-resolved worktree paths for the worktree management popover. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiWorktreeEntry { + pub worktree_path: String, + pub project_path: String, + pub branch: String, + pub is_main: bool, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct ApiGitStatus { pub branch: Option, @@ -214,6 +252,64 @@ 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, +} + +/// One-shot desktop presentation request emitted after an external +/// `FocusTerminal` action succeeds on the daemon. Unlike workspace state, this +/// is intentionally transient: connected desktop clients focus and raise the +/// requested pane once without repeatedly stealing focus on later state syncs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiTerminalFocusRequest { + pub project_id: String, + pub terminal_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -235,6 +331,170 @@ 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, + /// Whether the worktree backing this project is still being checked out on + /// disk (optimistic create in flight). Clients render the "Setting up + /// worktree…" placeholder while true; serde-defaulted so older peers that + /// omit the field decode as "not creating" (a legitimate bookmark, not a + /// perpetual placeholder). + #[serde(default)] + pub is_creating: bool, + /// Whether a `before_worktree_remove` hook-gated close is in progress on the + /// daemon. Clients render the dimmed "Closing…" row while true and heal their + /// client-local optimistic flag when this arrives false (close aborted). + /// serde-defaulted so older peers that omit the field decode as "not + /// closing". + #[serde(default)] + pub is_closing: bool, +} + +/// 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_hooks::HookStatus`. Durations are carried as whole +/// milliseconds (the domain `Duration`/`Instant` types don't cross the wire). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ApiHookStatus { + Running, + Succeeded { + duration_ms: u64, + }, + Failed { + duration_ms: u64, + exit_code: i32, + stderr: String, + }, + SpawnError { + message: String, + }, +} + +/// Wire mirror of `okena_hooks::HookExecution` — one row in the hook log. +/// +/// The domain type keeps `started_at: Instant`, which is process-local and +/// cannot be serialized; the client reconstructs a fresh `Instant` on ingest +/// (only the still-`Running` elapsed readout depends on it, and that +/// self-corrects once the hook finishes and carries a concrete duration). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ApiHookExecution { + pub id: u64, + pub hook_type: String, + pub command: String, + pub project_name: String, + pub status: ApiHookStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_id: Option, +} + +/// 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)] @@ -282,6 +542,8 @@ pub enum ApiLayoutNode { terminal_id: Option, minimized: bool, detached: bool, + #[serde(default)] + shell_type: ShellType, #[serde(default, skip_serializing_if = "Option::is_none")] cols: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -305,13 +567,17 @@ 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 { terminal_id: String, text: String, }, + SendBytes { + terminal_id: String, + data: Vec, + }, RunCommand { terminal_id: String, command: String, @@ -333,6 +599,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, @@ -340,9 +616,20 @@ pub enum ActionRequest { #[serde(default)] window: Option, }, + /// Record project recency without changing daemon-side focus presentation. + RecordProjectActivity { + project_id: String, + }, 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 +659,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, @@ -419,6 +715,11 @@ pub enum ActionRequest { GitBranches { project_id: String, }, + GitListPullRequests { + project_id: String, + #[serde(default = "default_pull_request_limit")] + limit: usize, + }, GitFileContents { project_id: String, file_path: String, @@ -434,6 +735,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 +821,26 @@ 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, + }, + /// Stop and remove a lifecycle-hook terminal on the daemon. + DismissHook { + project_id: String, + terminal_id: String, + }, ListFiles { project_id: String, #[serde(default)] @@ -531,6 +878,8 @@ pub enum ActionRequest { file_glob: Option, #[serde(default)] context_lines: usize, + #[serde(default)] + show_ignored: bool, }, RenameFile { project_id: String, @@ -553,6 +902,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 +930,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 +963,64 @@ 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) ── + /// List saved sessions from the daemon's profile directory. + ListSessions, + /// 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, + }, + /// Rename a saved session in the daemon's profile directory. + RenameSession { + old_name: String, + new_name: String, + }, + /// Delete a saved session from the daemon's profile directory. + DeleteSession { + 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. @@ -652,6 +1081,8 @@ pub enum CommandResult { Ok(Option), /// Success with raw bytes (e.g., terminal snapshots). OkBytes(Vec), + /// Terminal snapshot plus the last PTY event incorporated into its grid. + OkSnapshot { data: Vec, sequence: u64 }, /// Error with a human-readable message. Err(String), } @@ -670,8 +1101,16 @@ impl ActionRequest { } } -fn default_search_mode() -> String { "literal".to_string() } -fn default_max_results() -> usize { 1000 } +fn default_search_mode() -> String { + "literal".to_string() +} +fn default_max_results() -> usize { + 1000 +} + +fn default_pull_request_limit() -> usize { + 20 +} /// POST /v1/pair request #[derive(Serialize, Deserialize)] @@ -740,6 +1179,7 @@ mod tests { terminal_id: Some("t1".into()), minimized: false, detached: false, + shell_type: ShellType::Default, cols: None, rows: None, }, @@ -749,6 +1189,7 @@ mod tests { terminal_id: Some("t2".into()), minimized: true, detached: true, + shell_type: ShellType::Default, cols: None, rows: None, }], @@ -761,6 +1202,30 @@ 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(), + }, + is_creating: false, + is_closing: false, }], focused_project_id: Some("p1".into()), fullscreen_terminal: None, @@ -791,6 +1256,7 @@ mod tests { }), sidebar_open: Some(true), }], + hooks: Vec::new(), }; let json = serde_json::to_string(&resp).unwrap(); let parsed: StateResponse = serde_json::from_str(&json).unwrap(); @@ -798,6 +1264,28 @@ 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); @@ -813,12 +1301,15 @@ mod tests { assert_eq!(win.fullscreen.as_ref().unwrap().terminal_id, "t1"); assert_eq!(win.visible_project_ids, vec!["p1", "p2"]); assert_eq!(win.folder_filter.as_deref(), Some("folder1")); - assert_eq!(win.bounds, Some(ApiWindowBounds { - x: 10.0, - y: 20.0, - width: 800.0, - height: 600.0, - })); + assert_eq!( + win.bounds, + Some(ApiWindowBounds { + x: 10.0, + y: 20.0, + width: 800.0, + height: 600.0, + }) + ); assert_eq!(win.sidebar_open, Some(true)); } @@ -830,7 +1321,10 @@ mod tests { assert_eq!(parsed.project_order.len(), 0); assert_eq!(parsed.folders.len(), 0); assert!(parsed.windows.is_empty()); - assert!(matches!(parsed.projects[0].folder_color, FolderColor::Default)); + assert!(matches!( + parsed.projects[0].folder_color, + FolderColor::Default + )); } #[test] @@ -864,6 +1358,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,8 +1367,13 @@ 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"); + assert_eq!( + parsed.ci_checks.as_ref().unwrap().checks[0].elapsed_label(), + "1m5s" + ); } #[test] @@ -894,6 +1395,10 @@ mod tests { terminal_id: "t1".into(), text: "hello".into(), }, + ActionRequest::SendBytes { + terminal_id: "t1".into(), + data: vec![0x1b, 0xff], + }, ActionRequest::RunCommand { terminal_id: "t1".into(), command: "ls".into(), @@ -920,6 +1425,9 @@ mod tests { terminal_id: "t1".into(), window: Some("main".into()), }, + ActionRequest::RecordProjectActivity { + project_id: "p1".into(), + }, ActionRequest::ReadContent { terminal_id: "t1".into(), }, @@ -955,6 +1463,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], @@ -999,6 +1512,10 @@ mod tests { ActionRequest::GitBranches { project_id: "p1".into(), }, + ActionRequest::GitListPullRequests { + project_id: "p1".into(), + limit: 20, + }, ActionRequest::GitFileContents { project_id: "p1".into(), file_path: "src/main.rs".into(), @@ -1095,6 +1612,19 @@ 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::DismissHook { + project_id: "p1".into(), + terminal_id: "h1".into(), + }, ActionRequest::CreateFolder { name: "My Folder".into(), }, @@ -1114,6 +1644,46 @@ 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::ListSessions, + ActionRequest::LoadSession { + name: "work".into(), + }, + ActionRequest::SaveSession { + name: "work".into(), + }, + ActionRequest::RenameSession { + old_name: "work".into(), + new_name: "renamed".into(), + }, + ActionRequest::DeleteSession { + 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(); @@ -1131,6 +1701,7 @@ mod tests { terminal_id: Some("t1".into()), minimized: false, detached: false, + shell_type: ShellType::Default, cols: None, rows: None, }, @@ -1141,6 +1712,7 @@ mod tests { terminal_id: Some("t2".into()), minimized: false, detached: false, + shell_type: ShellType::Default, cols: None, rows: None, }, @@ -1148,6 +1720,7 @@ mod tests { terminal_id: None, minimized: false, detached: false, + shell_type: ShellType::Default, cols: None, rows: None, }, @@ -1155,6 +1728,7 @@ mod tests { terminal_id: Some("t3".into()), minimized: false, detached: true, + shell_type: ShellType::Default, cols: None, rows: None, }, @@ -1166,6 +1740,16 @@ mod tests { assert_eq!(ids, vec!["t1", "t2", "t3"]); } + #[test] + fn api_layout_node_defaults_shell_for_older_servers() { + let json = r#"{"type":"terminal","terminal_id":"t1","minimized":false,"detached":false}"#; + let node: ApiLayoutNode = serde_json::from_str(json).unwrap(); + let ApiLayoutNode::Terminal { shell_type, .. } = node else { + panic!("expected terminal"); + }; + assert_eq!(shell_type, ShellType::Default); + } + #[test] fn api_service_info_ports_round_trip() { let svc = ApiServiceInfo { @@ -1260,4 +1844,33 @@ mod tests { }; assert_eq!(show.target_window(), None); } + + #[test] + fn hook_execution_wire_round_trips() { + let api = ApiHookExecution { + id: 3, + hook_type: "worktree_removed".into(), + command: "echo x".into(), + project_name: "proj".into(), + status: ApiHookStatus::Failed { + duration_ms: 12, + exit_code: 1, + stderr: "e".into(), + }, + terminal_id: Some("t1".into()), + }; + let json = serde_json::to_string(&api).unwrap(); + // Status is internally tagged by "state". + assert!(json.contains("\"state\":\"failed\"")); + let back: ApiHookExecution = serde_json::from_str(&json).unwrap(); + assert_eq!(back, api); + } + + #[test] + fn state_response_defaults_hooks_when_absent() { + // Snapshots from an older server omit `hooks`; it must default to empty. + let json = r#"{"state_version":1,"projects":[],"focused_project_id":null,"fullscreen_terminal":null}"#; + let parsed: StateResponse = serde_json::from_str(json).unwrap(); + assert!(parsed.hooks.is_empty()); + } } diff --git a/crates/okena-core/src/git_poll.rs b/crates/okena-core/src/git_poll.rs new file mode 100644 index 000000000..f132a5c0d --- /dev/null +++ b/crates/okena-core/src/git_poll.rs @@ -0,0 +1,125 @@ +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 head_change(project_id: String) -> Self { + Self { + project_id: Some(project_id), + poll_github: false, + invalidate_github: false, + } + } + + 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 daemon entry points so the same actions always drive the same +/// immediate refresh. Both `okena-daemon` and `okena --headless` run +/// `DaemonCore` and 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 head_change_only_wakes_local_git_poll() { + let trigger = GitPollTrigger::head_change("p1".to_string()); + assert_eq!(trigger.project_id.as_deref(), Some("p1")); + assert!(!trigger.poll_github); + assert!(!trigger.invalidate_github); + } + + #[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/keys.rs b/crates/okena-core/src/keys.rs index 7067c1335..3da326453 100644 --- a/crates/okena-core/src/keys.rs +++ b/crates/okena-core/src/keys.rs @@ -117,7 +117,10 @@ mod tests { SpecialKey::Ctrl('l').to_bytes() ); // Equivalent to the named CtrlC variant. - assert_eq!(SpecialKey::Ctrl('c').to_bytes(), SpecialKey::CtrlC.to_bytes()); + assert_eq!( + SpecialKey::Ctrl('c').to_bytes(), + SpecialKey::CtrlC.to_bytes() + ); // Wire form: {"Ctrl":"l"} round-trips. let json = serde_json::to_string(&SpecialKey::Ctrl('l')).unwrap(); diff --git a/crates/okena-core/src/latency_probe.rs b/crates/okena-core/src/latency_probe.rs new file mode 100644 index 000000000..605a8ce30 --- /dev/null +++ b/crates/okena-core/src/latency_probe.rs @@ -0,0 +1,541 @@ +//! Opt-in terminal input latency instrumentation. +//! +//! Build with `terminal-latency-probe`, then set +//! `OKENA_TERMINAL_LATENCY_PROBE=1` before starting Okena. The desktop and its +//! daemon then record matching per-terminal samples. The controlled test must +//! keep one input in flight and avoid unrelated terminal output. + +#[cfg(feature = "terminal-latency-probe")] +mod imp { + use std::collections::{HashMap, VecDeque}; + use std::sync::{Mutex, OnceLock}; + use std::time::{SystemTime, UNIX_EPOCH}; + + const MAX_PENDING_PER_TERMINAL: usize = 512; + + #[derive(Debug, Clone)] + struct ClientProbe { + sample: u64, + viewer: u64, + input_len: usize, + input_hash: u64, + input_us: u64, + output_receive_us: Option, + parsed_us: Option, + activity_emit_us: Option, + activity_receive_us: Option, + throttle_fire_us: Option, + notify_us: Option, + paint_us: Option, + } + + #[derive(Debug, Clone)] + struct DaemonProbe { + sample: u64, + input_len: usize, + input_hash: u64, + ws_receive_us: u64, + bridge_us: Option, + pty_queue_us: Option, + pty_write_start_us: Option, + pty_write_end_us: Option, + pty_output_us: Option, + stream_us: Option, + } + + #[derive(Default)] + struct ProbeBook { + next_client_sample: HashMap, + next_daemon_sample: HashMap, + client: HashMap>, + daemon: HashMap>, + } + + impl ProbeBook { + fn start_client( + &mut self, + terminal_id: &str, + viewer: u64, + data: &[u8], + now_us: u64, + ) -> u64 { + let sample = next_sample(&mut self.next_client_sample, terminal_id); + let pending = self.client.entry(terminal_id.to_string()).or_default(); + trim_pending(pending); + pending.push_back(ClientProbe { + sample, + viewer, + input_len: data.len(), + input_hash: input_hash(data), + input_us: now_us, + output_receive_us: None, + parsed_us: None, + activity_emit_us: None, + activity_receive_us: None, + throttle_fire_us: None, + notify_us: None, + paint_us: None, + }); + sample + } + + fn start_daemon(&mut self, terminal_id: &str, data: &[u8], now_us: u64) -> u64 { + let sample = next_sample(&mut self.next_daemon_sample, terminal_id); + let pending = self.daemon.entry(terminal_id.to_string()).or_default(); + trim_pending(pending); + pending.push_back(DaemonProbe { + sample, + input_len: data.len(), + input_hash: input_hash(data), + ws_receive_us: now_us, + bridge_us: None, + pty_queue_us: None, + pty_write_start_us: None, + pty_write_end_us: None, + pty_output_us: None, + stream_us: None, + }); + sample + } + + fn mark_client( + &mut self, + terminal_id: &str, + now_us: u64, + update: impl Fn(&mut ClientProbe, u64), + ) { + if let Some(pending) = self.client.get_mut(terminal_id) { + for probe in pending { + update(probe, now_us); + } + } + } + + fn mark_daemon( + &mut self, + terminal_id: &str, + now_us: u64, + update: impl Fn(&mut DaemonProbe, u64), + ) { + if let Some(pending) = self.daemon.get_mut(terminal_id) { + for probe in pending { + update(probe, now_us); + } + } + } + + fn client_painted(&mut self, terminal_id: &str, viewer: u64, now_us: u64) -> Vec { + let Some(pending) = self.client.get_mut(terminal_id) else { + return Vec::new(); + }; + let mut painted = Vec::new(); + for probe in pending { + if probe.viewer == viewer && probe.parsed_us.is_some() && probe.paint_us.is_none() { + probe.paint_us = Some(now_us); + painted.push(probe.sample); + } + } + painted + } + + fn client_framed( + &mut self, + terminal_id: &str, + viewer: u64, + samples: &[u64], + ) -> Vec { + let Some(mut pending) = self.client.remove(terminal_id) else { + return Vec::new(); + }; + let mut completed = Vec::new(); + pending.retain(|probe| { + if probe.viewer == viewer && samples.contains(&probe.sample) { + completed.push(probe.clone()); + false + } else { + true + } + }); + if !pending.is_empty() { + self.client.insert(terminal_id.to_string(), pending); + } + completed + } + + fn daemon_streamed(&mut self, terminal_id: &str) -> Vec { + let Some(mut pending) = self.daemon.remove(terminal_id) else { + return Vec::new(); + }; + let mut completed = Vec::new(); + pending.retain(|probe| { + if probe.pty_write_end_us.is_some() + && probe.pty_output_us.is_some() + && probe.stream_us.is_some() + { + completed.push(probe.clone()); + false + } else { + true + } + }); + if !pending.is_empty() { + self.daemon.insert(terminal_id.to_string(), pending); + } + completed + } + } + + static ENABLED: OnceLock = OnceLock::new(); + static PROBES: OnceLock> = OnceLock::new(); + + pub fn enabled() -> bool { + *ENABLED.get_or_init(|| { + std::env::var("OKENA_TERMINAL_LATENCY_PROBE") + .ok() + .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "off")) + }) + } + + pub fn client_start(terminal_id: &str, viewer: u64, data: &[u8]) { + with_book(|book| { + let sample = book.start_client(terminal_id, viewer, data, now_us()); + log::info!( + target: "okena::terminal_latency", + "terminal_latency_client_start terminal={terminal_id} sample={sample} viewer={viewer}" + ); + }); + } + + pub fn client_output_received(terminal_id: &str) { + mark_client(terminal_id, |probe, now| { + probe.output_receive_us.get_or_insert(now); + }); + } + + pub fn client_output_parsed(terminal_id: &str) { + mark_client(terminal_id, |probe, now| { + if probe.output_receive_us.is_some() { + probe.parsed_us.get_or_insert(now); + } + }); + } + + pub fn client_activity_emitted(terminal_id: &str) { + mark_client(terminal_id, |probe, now| { + if probe.parsed_us.is_some() { + probe.activity_emit_us.get_or_insert(now); + } + }); + } + + pub fn client_activity_received(terminal_id: &str) { + mark_client(terminal_id, |probe, now| { + if probe.activity_emit_us.is_some() { + probe.activity_receive_us.get_or_insert(now); + } + }); + } + + pub fn client_repaint_dispatched(terminal_id: &str) { + mark_client(terminal_id, |probe, now| { + if probe.activity_receive_us.is_some() { + probe.throttle_fire_us.get_or_insert(now); + } + }); + } + + pub fn client_notify_requested(terminal_id: &str, viewer: u64) { + mark_client(terminal_id, |probe, now| { + if probe.viewer == viewer && probe.throttle_fire_us.is_some() { + probe.notify_us.get_or_insert(now); + } + }); + } + + pub fn client_painted(terminal_id: &str, viewer: u64) -> Vec { + if !enabled() { + return Vec::new(); + } + lock_book() + .map(|mut book| book.client_painted(terminal_id, viewer, now_us())) + .unwrap_or_default() + } + + pub fn client_frame_completed(terminal_id: &str, viewer: u64, samples: &[u64]) { + if !enabled() { + return; + } + let frame_us = now_us(); + let completed = lock_book() + .map(|mut book| book.client_framed(terminal_id, viewer, samples)) + .unwrap_or_default(); + for probe in completed { + log::info!( + target: "okena::terminal_latency", + "terminal_latency_client terminal={terminal_id} sample={} viewer={} input_len={} input_hash={} input_us={} output_receive_us={} parsed_us={} activity_emit_us={} activity_receive_us={} throttle_fire_us={} notify_us={} paint_us={} frame_us={frame_us}", + probe.sample, + probe.viewer, + probe.input_len, + probe.input_hash, + probe.input_us, + value(probe.output_receive_us), + value(probe.parsed_us), + value(probe.activity_emit_us), + value(probe.activity_receive_us), + value(probe.throttle_fire_us), + value(probe.notify_us), + value(probe.paint_us), + ); + } + } + + pub fn daemon_input_received(terminal_id: &str, data: &[u8]) { + with_book(|book| { + book.start_daemon(terminal_id, data, now_us()); + }); + } + + pub fn daemon_bridge_received(terminal_id: &str) { + mark_daemon(terminal_id, |probe, now| { + probe.bridge_us.get_or_insert(now); + }); + } + + pub fn daemon_pty_queued(terminal_id: &str) { + mark_daemon(terminal_id, |probe, now| { + if probe.bridge_us.is_some() { + probe.pty_queue_us.get_or_insert(now); + } + }); + } + + pub fn daemon_pty_write_started(terminal_id: &str) { + mark_daemon(terminal_id, |probe, now| { + if probe.pty_queue_us.is_some() { + probe.pty_write_start_us.get_or_insert(now); + } + }); + } + + pub fn daemon_pty_write_completed(terminal_id: &str) { + mark_daemon(terminal_id, |probe, now| { + if probe.pty_write_start_us.is_some() { + probe.pty_write_end_us.get_or_insert(now); + } + }); + log_completed_daemon(terminal_id); + } + + pub fn daemon_pty_output_received(terminal_id: &str) { + mark_daemon(terminal_id, |probe, now| { + if probe.pty_write_start_us.is_some() { + probe.pty_output_us.get_or_insert(now); + } + }); + } + + pub fn daemon_stream_queued(terminal_id: &str) { + mark_daemon(terminal_id, |probe, now| { + if probe.pty_output_us.is_some() { + probe.stream_us.get_or_insert(now); + } + }); + log_completed_daemon(terminal_id); + } + + fn log_completed_daemon(terminal_id: &str) { + if !enabled() { + return; + } + let completed = lock_book() + .map(|mut book| book.daemon_streamed(terminal_id)) + .unwrap_or_default(); + for probe in completed { + log::info!( + target: "okena::terminal_latency", + "terminal_latency_daemon terminal={terminal_id} sample={} input_len={} input_hash={} ws_receive_us={} bridge_us={} pty_queue_us={} pty_write_start_us={} pty_write_end_us={} pty_output_us={} stream_us={}", + probe.sample, + probe.input_len, + probe.input_hash, + probe.ws_receive_us, + value(probe.bridge_us), + value(probe.pty_queue_us), + value(probe.pty_write_start_us), + value(probe.pty_write_end_us), + value(probe.pty_output_us), + value(probe.stream_us), + ); + } + } + + fn mark_client(terminal_id: &str, update: impl Fn(&mut ClientProbe, u64)) { + with_book(|book| book.mark_client(terminal_id, now_us(), update)); + } + + fn mark_daemon(terminal_id: &str, update: impl Fn(&mut DaemonProbe, u64)) { + with_book(|book| book.mark_daemon(terminal_id, now_us(), update)); + } + + fn with_book(update: impl FnOnce(&mut ProbeBook)) { + if !enabled() { + return; + } + if let Some(mut book) = lock_book() { + update(&mut book); + } + } + + fn lock_book() -> Option> { + PROBES + .get_or_init(|| Mutex::new(ProbeBook::default())) + .lock() + .ok() + } + + fn now_us() -> u64 { + let micros = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros(); + u64::try_from(micros).unwrap_or(u64::MAX) + } + + fn next_sample(counters: &mut HashMap, terminal_id: &str) -> u64 { + let next = counters.entry(terminal_id.to_string()).or_insert(1); + let sample = *next; + *next = next.saturating_add(1); + sample + } + + fn trim_pending(pending: &mut VecDeque) { + if pending.len() >= MAX_PENDING_PER_TERMINAL { + pending.pop_front(); + } + } + + fn input_hash(data: &[u8]) -> u64 { + data.iter().fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }) + } + + fn value(timestamp: Option) -> u64 { + timestamp.unwrap_or(0) + } + + #[cfg(test)] + mod tests { + use super::ProbeBook; + + #[test] + fn client_probe_completes_only_in_originating_viewer() { + let mut book = ProbeBook::default(); + let sample = book.start_client("remote:local:t1", 7, b"x", 100); + book.mark_client("remote:local:t1", 120, |probe, now| { + probe.output_receive_us = Some(now); + }); + book.mark_client("remote:local:t1", 125, |probe, now| { + probe.parsed_us = Some(now); + }); + + assert!(book.client_painted("remote:local:t1", 8, 160).is_empty()); + assert_eq!(book.client_painted("remote:local:t1", 7, 160), vec![sample]); + assert!( + book.client_framed("remote:local:t1", 8, &[sample]) + .is_empty() + ); + let completed = book.client_framed("remote:local:t1", 7, &[sample]); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].input_us, 100); + assert_eq!(completed[0].paint_us, Some(160)); + } + + #[test] + fn daemon_probe_waits_for_pty_output_before_stream_completion() { + let mut book = ProbeBook::default(); + let sample = book.start_daemon("t1", b"x", 200); + + assert!(book.daemon_streamed("t1").is_empty()); + book.mark_daemon("t1", 230, |probe, now| { + probe.pty_output_us = Some(now); + probe.pty_write_end_us = Some(now - 1); + probe.stream_us = Some(now + 1); + }); + let completed = book.daemon_streamed("t1"); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].sample, sample); + assert_eq!(completed[0].pty_output_us, Some(230)); + } + + #[test] + fn input_hash_and_sample_order_match_for_repeated_bytes() { + let mut book = ProbeBook::default(); + let first = book.start_client("t1", 1, b"a", 1); + let second = book.start_client("t1", 1, b"a", 2); + let pending = book.client.get("t1").expect("client probes"); + + assert_eq!((first, second), (1, 2)); + assert_eq!(pending[0].input_hash, pending[1].input_hash); + } + } +} + +#[cfg(not(feature = "terminal-latency-probe"))] +mod imp { + #[inline(always)] + pub fn enabled() -> bool { + false + } + + #[inline(always)] + pub fn client_start(_terminal_id: &str, _viewer: u64, _data: &[u8]) {} + + #[inline(always)] + pub fn client_output_received(_terminal_id: &str) {} + + #[inline(always)] + pub fn client_output_parsed(_terminal_id: &str) {} + + #[inline(always)] + pub fn client_activity_emitted(_terminal_id: &str) {} + + #[inline(always)] + pub fn client_activity_received(_terminal_id: &str) {} + + #[inline(always)] + pub fn client_repaint_dispatched(_terminal_id: &str) {} + + #[inline(always)] + pub fn client_notify_requested(_terminal_id: &str, _viewer: u64) {} + + #[inline(always)] + pub fn client_painted(_terminal_id: &str, _viewer: u64) -> Vec { + Vec::new() + } + + #[inline(always)] + pub fn client_frame_completed(_terminal_id: &str, _viewer: u64, _samples: &[u64]) {} + + #[inline(always)] + pub fn daemon_input_received(_terminal_id: &str, _data: &[u8]) {} + + #[inline(always)] + pub fn daemon_bridge_received(_terminal_id: &str) {} + + #[inline(always)] + pub fn daemon_pty_queued(_terminal_id: &str) {} + + #[inline(always)] + pub fn daemon_pty_write_started(_terminal_id: &str) {} + + #[inline(always)] + pub fn daemon_pty_write_completed(_terminal_id: &str) {} + + #[inline(always)] + pub fn daemon_pty_output_received(_terminal_id: &str) {} + + #[inline(always)] + pub fn daemon_stream_queued(_terminal_id: &str) {} +} + +pub use imp::*; diff --git a/crates/okena-core/src/lib.rs b/crates/okena-core/src/lib.rs index 815497c34..60e4f9a8b 100644 --- a/crates/okena-core/src/lib.rs +++ b/crates/okena-core/src/lib.rs @@ -1,12 +1,15 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod api; +pub mod git_poll; pub mod keys; +pub mod latency_probe; 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/bus.rs b/crates/okena-core/src/process/bus.rs index 6bed5d5c9..382db4911 100644 --- a/crates/okena-core/src/process/bus.rs +++ b/crates/okena-core/src/process/bus.rs @@ -199,7 +199,12 @@ impl CommandSpec { let env = cmd .get_envs() .filter_map(|(k, v)| { - v.map(|v| (k.to_string_lossy().into_owned(), v.to_string_lossy().into_owned())) + v.map(|v| { + ( + k.to_string_lossy().into_owned(), + v.to_string_lossy().into_owned(), + ) + }) }) .collect(); Self { @@ -253,20 +258,31 @@ impl CommandSpec { /// it can be killed mid-flight. struct JobControl { cancelled: AtomicBool, - /// Set by the worker once the child is spawned; taken to kill it. + /// Set by the worker once the process tree is owned; taken to kill it. kill: Mutex>, } -/// A minimal handle that can kill a spawned child from another thread. +/// A minimal handle that can kill a spawned process tree from another thread. struct KillHandle { - inner: Arc>, + tree: Arc, } impl KillHandle { fn kill(&self) { - if let Ok(mut child) = self.inner.lock() { - let _ = child.kill(); - } + self.tree.terminate(); + } +} + +/// Clears the externally reachable kill handle before the owned process-group +/// identifier or job handle can become stale. +struct KillRegistration<'a> { + control: &'a JobControl, +} + +impl Drop for KillRegistration<'_> { + fn drop(&mut self) { + let mut guard = self.control.kill.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; } } @@ -290,6 +306,12 @@ impl JobControl { fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::SeqCst) } + + fn register(&self, tree: Arc) -> KillRegistration<'_> { + let mut guard = self.kill.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(KillHandle { tree }); + KillRegistration { control: self } + } } struct Job { @@ -477,6 +499,9 @@ fn worker_loop(queue: &LaneQueue) { /// latency-sensitive UI handler, then back off so long commands don't spin. const POLL_MIN: Duration = Duration::from_millis(1); const POLL_MAX: Duration = Duration::from_millis(20); +/// Give reader threads a brief chance to collect bytes already in the pipes +/// before treating open pipe handles as descendants left behind by the parent. +const POST_EXIT_DRAIN: Duration = Duration::from_millis(100); fn run_job(spec: &CommandSpec, ctl: &Arc) -> std::io::Result { // Cancelled before we even started. @@ -523,7 +548,7 @@ fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Resu .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - let mut child = cmd.spawn()?; + let (mut child, tree) = ProcessTree::spawn(&mut cmd)?; // Drain stdout/stderr on dedicated threads, concurrently with the wait loop // below. Otherwise a child that writes more than the OS pipe buffer (~64KB) @@ -532,17 +557,13 @@ fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Resu let out_reader = spawn_pipe_reader(child.stdout.take()); let err_reader = spawn_pipe_reader(child.stderr.take()); - let child = Arc::new(Mutex::new(child)); - - // Publish the kill handle so cancel()/cancel_scope() can reach this child. - if let Ok(mut guard) = ctl.kill.lock() { - *guard = Some(KillHandle { - inner: child.clone(), - }); - } + // Publish the kill handle so cancel()/cancel_scope() can reach the whole + // process tree. The registration clears it before the OS identity can be + // reused for an unrelated process. + let _registration = ctl.register(tree.clone()); // Lost a cancellation race between the check above and registering: honor it. if ctl.is_cancelled() { - kill_and_reap(&child); + terminate_and_reap(&tree, &mut child); let _ = out_reader.join(); let _ = err_reader.join(); return Err(cancelled_err()); @@ -555,22 +576,24 @@ fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Resu // Check cancellation before reaping: a killed child exits, and we must // report that as cancelled rather than as a (signal) success. if ctl.is_cancelled() { - kill_and_reap(&child); + terminate_and_reap(&tree, &mut child); let _ = out_reader.join(); let _ = err_reader.join(); return Err(cancelled_err()); } - let status = { - let mut c = child.lock().unwrap_or_else(|e| e.into_inner()); - c.try_wait()? - }; - - if let Some(status) = status { - // Child exited → its write ends are closed, so the reader threads - // hit EOF and finish; join collects everything they buffered. + if process_exited(&mut child)? { + // A direct parent may exit while a background descendant still + // owns the inherited pipes. Bound the drain, then terminate the + // owned tree so joining the readers cannot hang forever. + let _ = wait_for_readers(&out_reader, &err_reader, POST_EXIT_DRAIN); + tree.terminate(); + let status = child.wait()?; let stdout = out_reader.join().unwrap_or_default(); let stderr = err_reader.join().unwrap_or_default(); + if ctl.is_cancelled() { + return Err(cancelled_err()); + } return Ok(Output { status, stdout, @@ -581,7 +604,7 @@ fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Resu if let Some(deadline) = deadline && Instant::now() >= deadline { - kill_and_reap(&child); + terminate_and_reap(&tree, &mut child); let _ = out_reader.join(); let _ = err_reader.join(); return Err(std::io::Error::new( @@ -595,6 +618,35 @@ fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Resu } } +/// Detect exit without reaping on supported Unix hosts. Keeping the group +/// leader as a zombie until tree cleanup prevents its process-group ID from +/// being reused for an unrelated process before the group signal is sent. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn process_exited(child: &mut std::process::Child) -> std::io::Result { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: info points to writable siginfo storage. WNOWAIT observes only + // this owned child and deliberately leaves it waitable by Child::wait. + if unsafe { + libc::waitid( + libc::P_PID, + child.id(), + info.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + } != 0 + { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: waitid initialized the siginfo structure on success; si_pid is + // zero for the WNOHANG case where the child has not exited. + Ok(unsafe { info.assume_init().si_pid() } != 0) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn process_exited(child: &mut std::process::Child) -> std::io::Result { + child.try_wait().map(|status| status.is_some()) +} + /// Spawn a thread that reads a child pipe to EOF into a buffer. Reading /// concurrently with the wait loop prevents a full-pipe write deadlock. fn spawn_pipe_reader( @@ -609,11 +661,205 @@ fn spawn_pipe_reader( }) } -fn kill_and_reap(child: &Arc>) { - if let Ok(mut c) = child.lock() { - let _ = c.kill(); - let _ = c.wait(); +fn wait_for_readers( + stdout: &std::thread::JoinHandle>, + stderr: &std::thread::JoinHandle>, + timeout: Duration, +) -> bool { + let deadline = Instant::now() + timeout; + while !(stdout.is_finished() && stderr.is_finished()) { + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(POLL_MIN); + } + true +} + +fn terminate_and_reap(tree: &ProcessTree, child: &mut std::process::Child) { + tree.terminate(); + // The direct kill is an exact Child handle fallback if platform tree + // setup succeeded but group/job termination itself was denied. + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(unix)] +struct ProcessTree { + process_group: libc::pid_t, + terminated: AtomicBool, +} + +#[cfg(unix)] +impl ProcessTree { + fn spawn(cmd: &mut std::process::Command) -> std::io::Result<(std::process::Child, Arc)> { + use std::os::unix::process::CommandExt; + + cmd.process_group(0); + let mut child = cmd.spawn()?; + let process_group = match libc::pid_t::try_from(child.id()) { + Ok(process_group) if process_group > 0 => process_group, + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err(std::io::Error::other( + "spawned process has an invalid process group", + )); + } + }; + Ok(( + child, + Arc::new(Self { + process_group, + terminated: AtomicBool::new(false), + }), + )) + } + + fn terminate(&self) { + if self.terminated.swap(true, Ordering::SeqCst) { + return; + } + let Some(group_target) = self.process_group.checked_neg() else { + return; + }; + // SAFETY: process_group is the positive PID returned for a child that + // was atomically placed in its own group before exec. Registration is + // cleared before this identity can be reused by an unrelated process. + let _ = unsafe { libc::kill(group_target, libc::SIGKILL) }; + } +} + +#[cfg(windows)] +struct ProcessTree { + job: std::os::windows::io::OwnedHandle, + terminated: AtomicBool, +} + +#[cfg(windows)] +impl ProcessTree { + fn spawn(cmd: &mut std::process::Command) -> std::io::Result<(std::process::Child, Arc)> { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }; + + // Assign the process before any user code can create descendants that + // would escape the job. This repeats CREATE_NO_WINDOW from command(). + const CREATE_SUSPENDED: u32 = 0x0000_0004; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW); + + // SAFETY: null security/name pointers request a private unnamed job. + let raw_job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if raw_job.is_null() { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: CreateJobObjectW returned a unique owned handle on success. + let job = unsafe { OwnedHandle::from_raw_handle(raw_job) }; + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let limits_size = u32::try_from(std::mem::size_of_val(&limits)) + .map_err(|_| std::io::Error::other("job limits structure is too large"))?; + // SAFETY: limits points to the structure required by this information + // class and remains valid for the duration of the call. + if unsafe { + SetInformationJobObject( + job.as_raw_handle(), + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&limits).cast(), + limits_size, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + + let mut child = cmd.spawn()?; + // SAFETY: both handles are live and owned for the duration of the call. + if unsafe { AssignProcessToJobObject(job.as_raw_handle(), child.as_raw_handle()) } == 0 { + let error = std::io::Error::last_os_error(); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + if let Err(error) = resume_suspended_process(child.id()) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + + Ok(( + child, + Arc::new(Self { + job, + terminated: AtomicBool::new(false), + }), + )) + } + + fn terminate(&self) { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + if self.terminated.swap(true, Ordering::SeqCst) { + return; + } + // SAFETY: the owned job handle remains live for this call. + let _ = unsafe { TerminateJobObject(self.job.as_raw_handle(), 1) }; + } +} + +#[cfg(windows)] +fn resume_suspended_process(process_id: u32) -> std::io::Result<()> { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + // SAFETY: the snapshot call has no borrowed pointer arguments. + let raw_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if raw_snapshot == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: the successful snapshot call returned a unique owned handle. + let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot) }; + let mut entry = THREADENTRY32 { + dwSize: u32::try_from(std::mem::size_of::()) + .map_err(|_| std::io::Error::other("thread entry structure is too large"))?, + ..THREADENTRY32::default() + }; + + // SAFETY: entry is initialized with the required size and remains live. + let mut has_entry = unsafe { Thread32First(snapshot.as_raw_handle(), &mut entry) } != 0; + while has_entry { + if entry.th32OwnerProcessID == process_id { + // SAFETY: the enumerated thread ID belongs to the suspended child. + let raw_thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if raw_thread.is_null() { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: OpenThread returned a unique owned handle on success. + let thread = unsafe { OwnedHandle::from_raw_handle(raw_thread) }; + // SAFETY: the thread handle grants THREAD_SUSPEND_RESUME access. + if unsafe { ResumeThread(thread.as_raw_handle()) } == u32::MAX { + return Err(std::io::Error::last_os_error()); + } + return Ok(()); + } + // SAFETY: entry and snapshot remain valid across enumeration calls. + has_entry = unsafe { Thread32Next(snapshot.as_raw_handle(), &mut entry) } != 0; } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "suspended process thread was not found", + )) } fn cancelled_err() -> std::io::Error { diff --git a/crates/okena-core/src/process/mod.rs b/crates/okena-core/src/process/mod.rs index 42b4dc137..a0f5784d7 100644 --- a/crates/okena-core/src/process/mod.rs +++ b/crates/okena-core/src/process/mod.rs @@ -14,7 +14,7 @@ mod bus; -pub use bus::{current_lane, with_lane, CommandBus, CommandHandle, CommandSpec, Lane}; +pub use bus::{CommandBus, CommandHandle, CommandSpec, Lane, current_lane, with_lane}; /// Create a [`std::process::Command`] that does **not** flash a console /// window on Windows. On other platforms this is identical to @@ -91,6 +91,119 @@ pub fn open_url(url: &str) { } } +/// Whether `pid` has exited but not yet been reaped by its parent. +/// +/// macOS: `proc_pidinfo(PROC_PIDTBSDINFO)` fills a record for a live process +/// and fails with `ESRCH` once it has exited — including while it is still a +/// zombie and `kill(pid, 0)` reports it present. +#[cfg(target_os = "macos")] +fn is_zombie(pid: u32) -> bool { + /// `PROC_PIDTBSDINFO` from ``. + const PROC_PIDTBSDINFO: libc::c_int = 3; + + // The record is 136 bytes today; over-size the buffer so a larger one on a + // future release still fits (too small is an error, too large is fine). + let mut info = [0u8; 512]; + let written = unsafe { + // Apple's wrapper collapses the kernel's -1 into a return of 0 and + // reports the reason through errno, so the return value alone cannot + // tell "this pid is gone" from "the call failed". Clear errno first so + // an unrelated earlier failure can't be mistaken for ours. + *libc::__error() = 0; + libc::proc_pidinfo( + pid as libc::c_int, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + info.len() as libc::c_int, + ) + }; + if written > 0 { + return false; + } + + // Only "no such process" means the pid has exited. The other ways this + // returns 0 — EPERM for a process we may not inspect, ENOMEM for a buffer + // the kernel considers too small — say nothing about liveness, and we + // already know from `kill(pid, 0)` that the pid exists. Treat those as + // running: keeping the old answer beats declaring a live daemon dead and + // respawning on top of it. + std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) +} + +/// Linux: field 3 of `/proc//stat` is the state character, `Z` for zombie. +/// The preceding `comm` field is parenthesised and may itself contain spaces +/// and parentheses, so the scan starts after its last `)`. +#[cfg(target_os = "linux")] +fn is_zombie(pid: u32) -> bool { + // Read bytes, not a String: `comm` is the raw executable name and need not + // be valid UTF-8, which would otherwise fail the read and report a zombie + // as running. + let Ok(stat) = std::fs::read(format!("/proc/{pid}/stat")) else { + return false; + }; + let stat = String::from_utf8_lossy(&stat); + let Some((_, after_comm)) = stat.rsplit_once(')') else { + return false; + }; + after_comm.split_whitespace().next() == Some("Z") +} + +/// Other unix targets keep the old behaviour: no cheap zombie probe, so a +/// pid in the table counts as alive. +#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))] +fn is_zombie(_pid: u32) -> bool { + false +} + +/// Check whether a process with the given PID is still running. +/// +/// A process that has exited but whose parent has not reaped it yet keeps its +/// pid in the table, so `kill(pid, 0)` still succeeds for it. Every caller here +/// means *running* — a zombie answers no requests, serves no port and holds no +/// lock — so zombies count as dead. This matters for processes we spawn +/// ourselves and reap late: the desktop holds its daemon's `Child` handle, so a +/// daemon that exited stayed "alive" to every pid probe until the handle was +/// finally waited on, which made a clean shutdown burn its whole timeout and a +/// crashed daemon look reachable. +/// +/// Windows needs no equivalent: its process handle is signalled at exit. +pub fn is_process_alive(pid: u32) -> bool { + #[cfg(unix)] + { + // signal 0 probes for existence without delivering a signal. + if unsafe { libc::kill(pid as libc::pid_t, 0) } != 0 { + return false; + } + !is_zombie(pid) + } + #[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 @@ -121,7 +234,8 @@ pub fn raise_fd_limit() { if libc::setrlimit(libc::RLIMIT_NOFILE, &new) == 0 { log::info!( "Raised RLIMIT_NOFILE soft limit {} -> {}", - lim.rlim_cur, target + lim.rlim_cur, + target ); } } @@ -202,6 +316,119 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); } + #[cfg(unix)] + #[test] + fn timeout_kills_foreground_tree_without_touching_unrelated_process() { + let _g = guard(); + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + let mut unrelated = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("unrelated process"); + + let started = std::time::Instant::now(); + let err = run(CommandSpec::new("/bin/sh") + .args([ + "-c", + "sleep 30 & echo $! > \"$1\"; wait $!", + "okena-test", + &pid_path, + ]) + .timeout(Duration::from_millis(500))) + .expect_err("process tree should time out"); + let elapsed = started.elapsed(); + let descendant_pid = read_test_pid(pid_file.path()); + let descendant_dead = wait_for_test_process_exit(descendant_pid, Duration::from_secs(1)); + let unrelated_alive = unrelated.try_wait().expect("probe unrelated").is_none(); + + let _ = unrelated.kill(); + let _ = unrelated.wait(); + if !descendant_dead { + kill_test_process(descendant_pid); + } + + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + assert!(elapsed < Duration::from_secs(2), "elapsed: {elapsed:?}"); + assert!(descendant_dead, "foreground descendant survived timeout"); + assert!(unrelated_alive, "unrelated process was terminated"); + } + + #[cfg(unix)] + #[test] + fn exited_parent_with_background_pipe_descendant_finishes_bounded() { + let _g = guard(); + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + + let started = std::time::Instant::now(); + let output = run(CommandSpec::new("/bin/sh").args([ + "-c", + "printf 'retained\\n'; sleep 30 & echo $! > \"$1\"", + "okena-test", + &pid_path, + ])) + .expect("parent result"); + let elapsed = started.elapsed(); + let descendant_pid = read_test_pid(pid_file.path()); + let descendant_dead = wait_for_test_process_exit(descendant_pid, Duration::from_secs(1)); + if !descendant_dead { + kill_test_process(descendant_pid); + } + + assert!(output.status.success()); + assert_eq!(output.stdout, b"retained\n"); + assert!(elapsed < Duration::from_secs(2), "elapsed: {elapsed:?}"); + assert!(descendant_dead, "background descendant survived collection"); + } + + #[cfg(unix)] + fn read_test_pid(path: &std::path::Path) -> u32 { + std::fs::read_to_string(path) + .expect("read descendant pid") + .trim() + .parse() + .expect("parse descendant pid") + } + + #[cfg(unix)] + fn wait_for_test_pid(path: &std::path::Path, timeout: Duration) -> u32 { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Ok(contents) = std::fs::read_to_string(path) + && let Ok(pid) = contents.trim().parse() + { + return pid; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for descendant pid" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[cfg(unix)] + fn wait_for_test_process_exit(pid: u32, timeout: Duration) -> bool { + let deadline = std::time::Instant::now() + timeout; + while std::time::Instant::now() < deadline { + if !is_process_alive(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(10)); + } + !is_process_alive(pid) + } + + #[cfg(unix)] + fn kill_test_process(pid: u32) { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return; + }; + // SAFETY: this fallback only targets the PID written by the test child. + let _ = unsafe { libc::kill(pid, libc::SIGKILL) }; + } + #[test] fn lane_default_is_interactive() { let _g = guard(); @@ -240,6 +467,37 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::Interrupted); } + #[cfg(unix)] + #[test] + fn cancel_kills_running_process_tree() { + let _g = guard(); + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + let handle = CommandBus::global().submit(CommandSpec::new("/bin/sh").args([ + "-c", + "sleep 30 & echo $! > \"$1\"; wait $!", + "okena-test", + &pid_path, + ])); + let descendant_pid = wait_for_test_pid(pid_file.path(), Duration::from_secs(2)); + + let started = std::time::Instant::now(); + handle.cancel(); + let err = handle.wait().expect_err("cancelled"); + let elapsed = started.elapsed(); + let descendant_dead = wait_for_test_process_exit(descendant_pid, Duration::from_secs(1)); + if !descendant_dead { + kill_test_process(descendant_pid); + } + + assert_eq!(err.kind(), std::io::ErrorKind::Interrupted); + assert!(elapsed < Duration::from_secs(2), "elapsed: {elapsed:?}"); + assert!( + descendant_dead, + "foreground descendant survived cancellation" + ); + } + #[test] fn cancel_scope_kills_group() { let _g = guard(); @@ -248,8 +506,14 @@ mod tests { let b = bus.submit(CommandSpec::new("sleep").arg("30").scope(42)); std::thread::sleep(Duration::from_millis(80)); bus.cancel_scope(42); - assert_eq!(a.wait().unwrap_err().kind(), std::io::ErrorKind::Interrupted); - assert_eq!(b.wait().unwrap_err().kind(), std::io::ErrorKind::Interrupted); + assert_eq!( + a.wait().unwrap_err().kind(), + std::io::ErrorKind::Interrupted + ); + assert_eq!( + b.wait().unwrap_err().kind(), + std::io::ErrorKind::Interrupted + ); } #[test] @@ -269,4 +533,45 @@ mod tests { let out = safe_output(&mut cmd).expect("mock"); assert_eq!(out.stdout, b"mocked"); } + + /// A child that exited but has not been `wait`ed on is a zombie: its pid is + /// still in the table, so `kill(pid, 0)` succeeds. Reporting that as alive + /// made the desktop's shutdown wait sit out its whole timeout on a corpse + /// and a crashed daemon look reachable. + #[cfg(unix)] + #[test] + fn exited_but_unreaped_child_is_not_alive() { + let mut child = std::process::Command::new("/bin/sh") + .args(["-c", "exit 0"]) + .spawn() + .expect("spawn"); + let pid = child.id(); + // Let it exit. Deliberately do NOT reap it yet. + std::thread::sleep(Duration::from_millis(300)); + + let alive = is_process_alive(pid); + let _ = child.wait(); + + assert!( + !alive, + "an exited, unreaped child must not count as running" + ); + } + + #[cfg(unix)] + #[test] + fn running_processes_are_alive() { + assert!(is_process_alive(std::process::id()), "this test process"); + + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("spawn"); + std::thread::sleep(Duration::from_millis(300)); + let alive = is_process_alive(child.id()); + let _ = child.kill(); + let _ = child.wait(); + + assert!(alive, "a live child must count as running"); + } } diff --git a/crates/okena-core/src/profiles.rs b/crates/okena-core/src/profiles.rs index 30cfbd6f5..cee823fc0 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}; @@ -18,19 +19,53 @@ pub struct ProfilePaths { } impl ProfilePaths { - pub fn workspace_json(&self) -> PathBuf { self.root.join("workspace.json") } - pub fn settings_json(&self) -> PathBuf { self.root.join("settings.json") } - pub fn keybindings_json(&self) -> PathBuf { self.root.join("keybindings.json") } - pub fn sessions_dir(&self) -> PathBuf { self.root.join("sessions") } - pub fn themes_dir(&self) -> PathBuf { self.root.join("themes") } - pub fn updates_dir(&self) -> PathBuf { self.root.join("updates") } - pub fn lock_path(&self) -> PathBuf { self.root.join("okena.lock") } - pub fn log_path(&self) -> PathBuf { self.root.join("okena.log") } - pub fn cli_json(&self) -> PathBuf { self.root.join("cli.json") } - pub fn remote_json(&self) -> PathBuf { self.root.join("remote.json") } - 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") } + pub fn workspace_json(&self) -> PathBuf { + self.root.join("workspace.json") + } + pub fn settings_json(&self) -> PathBuf { + self.root.join("settings.json") + } + pub fn keybindings_json(&self) -> PathBuf { + self.root.join("keybindings.json") + } + pub fn sessions_dir(&self) -> PathBuf { + self.root.join("sessions") + } + pub fn themes_dir(&self) -> PathBuf { + self.root.join("themes") + } + pub fn updates_dir(&self) -> PathBuf { + self.root.join("updates") + } + pub fn lock_path(&self) -> PathBuf { + self.root.join("okena.lock") + } + pub fn log_path(&self) -> PathBuf { + self.root.join("okena.log") + } + pub fn cli_json(&self) -> PathBuf { + self.root.join("cli.json") + } + pub fn remote_json(&self) -> PathBuf { + self.root.join("remote.json") + } + 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 @@ -47,7 +82,9 @@ pub fn init_profile(paths: ProfilePaths) { pub fn current() -> &'static ProfilePaths { // Intentional panic: documented precondition that init_profile() ran first. #[allow(clippy::expect_used)] - PROFILE_PATHS.get().expect("profile not initialized — call init_profile() first") + PROFILE_PATHS + .get() + .expect("profile not initialized — call init_profile() first") } /// Returns the active profile paths, or `None` if `init_profile` was never called. @@ -134,14 +171,15 @@ pub fn resolve_active_profile(flag_id: Option) -> Result { // Migration is handled by the caller (main.rs) after init_profile. let idx = bootstrap_default_profile(&root)?; if let Some(req) = &requested - && req != "default" { - bail!( - "Profile '{req}' not found. This appears to be a first launch; \ + && req != "default" + { + bail!( + "Profile '{req}' not found. This appears to be a first launch; \ the 'default' profile was just created.\n\ Run `okena --new-profile {req}` to create it, \ or omit --profile to use 'default'." - ); - } + ); + } return make_profile_paths(&idx.profiles[0], &root); } }; @@ -177,9 +215,10 @@ fn pick_profile_id(index: &ProfileIndex) -> Result { } // Use last_used if it still exists if let Some(last) = &index.last_used - && index.profiles.iter().any(|p| &p.id == last) { - return Ok(last.clone()); - } + && index.profiles.iter().any(|p| &p.id == last) + { + return Ok(last.clone()); + } // Ambiguous — give the user a clear error let mut msg = String::from( "Multiple profiles found. Specify one with --profile or OKENA_PROFILE:\n", @@ -191,7 +230,12 @@ fn pick_profile_id(index: &ProfileIndex) -> Result { } fn validate_profile_id(id: &str) -> Result<()> { - if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') { + if id.is_empty() + || id.contains('/') + || id.contains('\\') + || id.contains("..") + || id.contains('\0') + { bail!("Invalid profile id: '{id}'"); } Ok(()) @@ -264,15 +308,95 @@ pub fn all_profiles() -> Result> { Ok(ProfileIndex::load(&root)?.profiles) } -/// Delete a profile. Refuses to delete the active profile, the default profile, or a -/// profile whose `remote.json` points to a live PID. Removes the profile directory and -/// updates `profiles.json` (index written first so partial FS failure leaves index clean). -/// Claude credentials at `~/.claude-/` are intentionally preserved. +/// Runtime root used by persistent dtach sessions. +pub fn dtach_socket_base_dir() -> PathBuf { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(runtime_dir).join("okena") + } else { + #[cfg(unix)] + { + // SAFETY: `getuid(2)` takes no arguments, dereferences no pointers, + // and is documented as never failing. + let uid = unsafe { libc::getuid() }; + PathBuf::from(format!("/tmp/okena-{uid}")) + } + #[cfg(not(unix))] + { + std::env::temp_dir().join("okena") + } + } +} + +/// Profile-isolated dtach directory. The default retains the legacy root for +/// backward compatibility; named profiles use a nested runtime directory. +pub fn dtach_socket_dir_for_profile(profile_id: &str) -> PathBuf { + let base = dtach_socket_base_dir(); + if profile_id == "default" { + base + } else { + base.join("profiles").join(profile_id) + } +} + +#[cfg(unix)] +fn ensure_profile_runtime_removable(runtime_dir: &Path, profile_id: &str) -> Result<()> { + let Ok(entries) = std::fs::read_dir(runtime_dir) else { + return Ok(()); + }; + for path in entries.flatten().map(|entry| entry.path()) { + let is_dtach_socket = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("tm-") && name.ends_with(".sock")); + if !is_dtach_socket { + continue; + } + match std::os::unix::net::UnixStream::connect(&path) { + Ok(_) => { + bail!( + "Cannot delete profile '{profile_id}' while persistent terminal sessions are still running; open the profile and close its terminals first" + ); + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) => + { + let _ = std::fs::remove_file(path); + } + Err(error) => { + bail!("Cannot verify terminal cleanup for profile '{profile_id}': {error}"); + } + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_profile_runtime_removable(_runtime_dir: &Path, _profile_id: &str) -> Result<()> { + Ok(()) +} + +/// Delete a profile. Refuses to delete the active profile, the default profile, a +/// profile whose `remote.json` points to a live PID, or one with live persistent +/// terminal sessions. Removes the profile directory and updates `profiles.json` +/// (index written first so partial FS failure leaves index clean). Claude +/// credentials at `~/.claude-/` are intentionally preserved. pub fn delete_profile(id: &str) -> Result<()> { + delete_profile_with_cleanup(id, || Ok(())) +} + +/// Delete a profile after running a caller-provided terminal cleanup, but only +/// after the usual default/active/running guards have succeeded. +pub fn delete_profile_with_cleanup(id: &str, cleanup: impl FnOnce() -> Result<()>) -> Result<()> { let root = config_root(); let mut index = ProfileIndex::load(&root)?; - let entry = index.profiles.iter().find(|p| p.id == id) + let entry = index + .profiles + .iter() + .find(|p| p.id == id) .ok_or_else(|| anyhow::anyhow!("Profile '{id}' does not exist"))? .clone(); @@ -280,13 +404,17 @@ pub fn delete_profile(id: &str) -> Result<()> { bail!("Cannot delete the default profile"); } if let Some(active) = try_current() - && active.id == id { - bail!("Cannot delete the active profile — switch to another profile first"); - } + && active.id == id + { + bail!("Cannot delete the active profile — switch to another profile first"); + } let paths = make_profile_paths(&entry, &root)?; if is_profile_running(&paths) { bail!("Profile '{id}' is currently in use by another Okena instance"); } + cleanup()?; + let runtime_dir = dtach_socket_dir_for_profile(id); + ensure_profile_runtime_removable(&runtime_dir, id)?; index.profiles.retain(|p| p.id != id); if index.last_used.as_deref() == Some(id) { @@ -295,13 +423,18 @@ pub fn delete_profile(id: &str) -> Result<()> { index.save(&root)?; let _ = std::fs::remove_dir_all(&paths.root); + let _ = std::fs::remove_dir_all(runtime_dir); Ok(()) } fn is_profile_running(paths: &ProfilePaths) -> bool { let remote = paths.remote_json(); - let Ok(data) = std::fs::read_to_string(&remote) else { return false; }; - let Ok(json) = serde_json::from_str::(&data) else { return false; }; + let Ok(data) = std::fs::read_to_string(&remote) else { + return false; + }; + let Ok(json) = serde_json::from_str::(&data) else { + return false; + }; let pid = json.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32; pid != 0 && is_process_alive(pid) } @@ -312,7 +445,11 @@ pub fn list_profiles() { match ProfileIndex::load(&root) { Ok(index) => { for p in &index.profiles { - let marker = if index.last_used.as_deref() == Some(&p.id) { "*" } else { " " }; + let marker = if index.last_used.as_deref() == Some(&p.id) { + "*" + } else { + " " + }; println!("{} {:<20} {}", marker, p.id, p.display_name); } } @@ -343,24 +480,29 @@ pub fn migrate_legacy_layout_if_needed(paths: &ProfilePaths) -> Result<()> { if legacy_lock.exists() { if let Ok(content) = std::fs::read_to_string(&legacy_lock) && let Ok(pid) = content.trim().parse::() - && is_process_alive(pid) { - bail!( - "An older Okena instance is still running (PID {pid}). \ + && is_process_alive(pid) + { + bail!( + "An older Okena instance is still running (PID {pid}). \ Quit it before upgrading to profiles." - ); - } + ); + } let _ = std::fs::remove_file(&legacy_lock); } // Only migrate if there are legacy files to move let candidates = [ - "workspace.json", "workspace.json.bak", + "workspace.json", + "workspace.json.bak", "settings.json", "keybindings.json", "cli.json", "remote.json", - "remote_secret", "remote_tokens.json", "pair_code", - "okena.log", "okena.log.1", + "remote_secret", + "remote_tokens.json", + "pair_code", + "okena.log", + "okena.log.1", ]; let dir_candidates = ["sessions", "themes", "updates"]; @@ -409,6 +551,261 @@ 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 { @@ -437,7 +834,11 @@ fn unique_id(display_name: &str, index: &ProfileIndex) -> String { .collect::() .trim_matches('-') .to_string(); - let slug = if slug.is_empty() { "profile".to_string() } else { slug }; + let slug = if slug.is_empty() { + "profile".to_string() + } else { + slug + }; if !index.profiles.iter().any(|p| p.id == slug) { return slug; @@ -451,39 +852,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() @@ -502,7 +870,9 @@ fn unix_days_to_ymd(mut n: u64) -> (u64, u64, u64) { loop { let leap = y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400)); let days = if leap { 366 } else { 365 }; - if n < days { break; } + if n < days { + break; + } n -= days; y += 1; } @@ -514,7 +884,9 @@ fn unix_days_to_ymd(mut n: u64) -> (u64, u64, u64) { }; let mut mo = 1u64; for &days in &months { - if n < days { break; } + if n < days { + break; + } n -= days; mo += 1; } @@ -622,7 +994,10 @@ mod tests { root: root.join("profiles/work"), config_root: root.clone(), }; - assert_eq!(paths.workspace_json(), root.join("profiles/work/workspace.json")); + assert_eq!( + paths.workspace_json(), + root.join("profiles/work/workspace.json") + ); assert_eq!(paths.sessions_dir(), root.join("profiles/work/sessions")); } @@ -633,12 +1008,212 @@ 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, profiles: vec![ - ProfileEntry { id: "default".into(), display_name: "Default".into(), created_at: "".into(), icon: None, color: None }, - ProfileEntry { id: "work".into(), display_name: "Work".into(), created_at: "".into(), icon: None, color: None }, + ProfileEntry { + id: "default".into(), + display_name: "Default".into(), + created_at: "".into(), + icon: None, + color: None, + }, + ProfileEntry { + id: "work".into(), + display_name: "Work".into(), + created_at: "".into(), + icon: None, + color: None, + }, ], last_used: Some("work".into()), default_profile: "default".into(), @@ -659,6 +1234,34 @@ mod tests { assert_eq!(loaded.profiles.len(), idx.profiles.len()); } + #[cfg(unix)] + #[test] + fn profile_runtime_guard_refuses_live_sessions_and_removes_dead_sockets() { + let dir = temp_root(); + let socket_path = dir.path().join("tm-live.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + + let error = ensure_profile_runtime_removable(dir.path(), "work").unwrap_err(); + assert!(error.to_string().contains("persistent terminal sessions")); + assert!(socket_path.exists()); + + drop(listener); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + loop { + match ensure_profile_runtime_removable(dir.path(), "work") { + Ok(()) => break, + Err(error) + if error.to_string().contains("persistent terminal sessions") + && std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("dead socket did not become removable: {error}"), + } + } + assert!(!socket_path.exists()); + } + #[test] fn test_delete_profile_refuses_default() { let dir = temp_root(); @@ -688,7 +1291,9 @@ mod tests { // Simulate the delete logic (no try_current guard needed — OnceLock is per-process) index.profiles.retain(|p| p.id != id); - if index.last_used.as_deref() == Some(id) { index.last_used = None; } + if index.last_used.as_deref() == Some(id) { + index.last_used = None; + } index.save(root).unwrap(); let work_dir = root.join("profiles/work"); fs::remove_dir_all(&work_dir).unwrap(); @@ -709,7 +1314,9 @@ mod tests { assert_eq!(index.last_used.as_deref(), Some("work")); index.profiles.retain(|p| p.id != "work"); - if index.last_used.as_deref() == Some("work") { index.last_used = None; } + if index.last_used.as_deref() == Some("work") { + index.last_used = None; + } index.save(root).unwrap(); let reloaded = ProfileIndex::load(root).unwrap(); diff --git a/crates/okena-core/src/selection.rs b/crates/okena-core/src/selection.rs index 1db5a4474..5b0acea73 100644 --- a/crates/okena-core/src/selection.rs +++ b/crates/okena-core/src/selection.rs @@ -77,10 +77,6 @@ impl Selectable for (usize, usize) { // Implementation for 1D offset (e.g., character offset in markdown) impl Selectable for usize { fn normalized_pair(a: Self, b: Self) -> (Self, Self) { - if a <= b { - (a, b) - } else { - (b, a) - } + if a <= b { (a, b) } else { (b, a) } } } diff --git a/crates/okena-core/src/send_payload.rs b/crates/okena-core/src/send_payload.rs index cd7ee88d4..2c8629bed 100644 --- a/crates/okena-core/src/send_payload.rs +++ b/crates/okena-core/src/send_payload.rs @@ -117,10 +117,7 @@ pub fn markdown_lang_hint(path: &Path) -> &'static str { _ => {} } } - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); match ext { "rs" => "rust", "ts" => "typescript", @@ -219,7 +216,10 @@ mod tests { #[test] fn special_filenames_get_language_hint() { assert_eq!(markdown_lang_hint(Path::new("Makefile")), "make"); - assert_eq!(markdown_lang_hint(Path::new("/proj/Dockerfile")), "dockerfile"); + assert_eq!( + markdown_lang_hint(Path::new("/proj/Dockerfile")), + "dockerfile" + ); assert_eq!(markdown_lang_hint(Path::new("CMakeLists.txt")), "cmake"); assert_eq!(markdown_lang_hint(Path::new("Cargo.toml")), "toml"); } diff --git a/crates/okena-core/src/shell.rs b/crates/okena-core/src/shell.rs index d35d9d3e3..520f02f1d 100644 --- a/crates/okena-core/src/shell.rs +++ b/crates/okena-core/src/shell.rs @@ -67,9 +67,15 @@ impl ShellType { /// Resolve `ShellType::Default` into a concrete shell by checking /// the project's default shell first, then the global setting. /// Non-Default variants are returned unchanged. - pub fn resolve_default(self, project_shell: Option<&ShellType>, global_shell: &ShellType) -> ShellType { + pub fn resolve_default( + self, + project_shell: Option<&ShellType>, + global_shell: &ShellType, + ) -> ShellType { if self == ShellType::Default { - project_shell.cloned().unwrap_or_else(|| global_shell.clone()) + project_shell + .cloned() + .unwrap_or_else(|| global_shell.clone()) } else { self } @@ -108,7 +114,11 @@ impl ShellType { ShellType::Cmd => "CMD", #[cfg(windows)] ShellType::PowerShell { core } => { - if *core { "pwsh" } else { "PS" } + if *core { + "pwsh" + } else { + "PS" + } } #[cfg(windows)] ShellType::Wsl { .. } => "WSL", @@ -124,16 +134,17 @@ impl ShellType { #[cfg(windows)] ShellType::Cmd => "cmd.exe".to_string(), #[cfg(windows)] - ShellType::PowerShell { core } => { - if *core { "pwsh.exe -NoLogo" } else { "powershell.exe -NoLogo" }.to_string() + ShellType::PowerShell { core } => if *core { + "pwsh.exe -NoLogo" + } else { + "powershell.exe -NoLogo" } + .to_string(), #[cfg(windows)] - ShellType::Wsl { distro } => { - match distro { - Some(d) => format!("wsl.exe -d {}", d), - None => "wsl.exe".to_string(), - } - } + ShellType::Wsl { distro } => match distro { + Some(d) => format!("wsl.exe -d {}", d), + None => "wsl.exe".to_string(), + }, ShellType::Custom { path, args } => { if args.is_empty() { shell_quote(path) @@ -154,7 +165,15 @@ fn shell_quote(s: &str) -> String { return "''".to_string(); } // If it only contains safe characters, no quoting needed - if s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'/' || b == b'.' || b == b'-' || b == b'_' || b == b'=' || b == b':') { + if s.bytes().all(|b| { + b.is_ascii_alphanumeric() + || b == b'/' + || b == b'.' + || b == b'-' + || b == b'_' + || b == b'=' + || b == b':' + }) { return s.to_string(); } // Single-quote and escape embedded single quotes 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/theme/colors.rs b/crates/okena-core/src/theme/colors.rs index b39fcc18f..d273acc33 100644 --- a/crates/okena-core/src/theme/colors.rs +++ b/crates/okena-core/src/theme/colors.rs @@ -502,28 +502,80 @@ mod tests { fn ansi_to_argb_named_colors() { let t = &DARK_THEME; // Check all 16 named colors resolve to the theme's term_* values - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Black)), 0xFF000000 | t.term_black); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Red)), 0xFF000000 | t.term_red); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Green)), 0xFF000000 | t.term_green); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Yellow)), 0xFF000000 | t.term_yellow); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Blue)), 0xFF000000 | t.term_blue); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Magenta)), 0xFF000000 | t.term_magenta); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::Cyan)), 0xFF000000 | t.term_cyan); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::White)), 0xFF000000 | t.term_white); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightBlack)), 0xFF000000 | t.term_bright_black); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightRed)), 0xFF000000 | t.term_bright_red); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightGreen)), 0xFF000000 | t.term_bright_green); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightYellow)), 0xFF000000 | t.term_bright_yellow); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightBlue)), 0xFF000000 | t.term_bright_blue); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightMagenta)), 0xFF000000 | t.term_bright_magenta); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightCyan)), 0xFF000000 | t.term_bright_cyan); - assert_eq!(t.ansi_to_argb(&Color::Named(NamedColor::BrightWhite)), 0xFF000000 | t.term_bright_white); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Black)), + 0xFF000000 | t.term_black + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Red)), + 0xFF000000 | t.term_red + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Green)), + 0xFF000000 | t.term_green + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Yellow)), + 0xFF000000 | t.term_yellow + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Blue)), + 0xFF000000 | t.term_blue + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Magenta)), + 0xFF000000 | t.term_magenta + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::Cyan)), + 0xFF000000 | t.term_cyan + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::White)), + 0xFF000000 | t.term_white + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightBlack)), + 0xFF000000 | t.term_bright_black + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightRed)), + 0xFF000000 | t.term_bright_red + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightGreen)), + 0xFF000000 | t.term_bright_green + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightYellow)), + 0xFF000000 | t.term_bright_yellow + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightBlue)), + 0xFF000000 | t.term_bright_blue + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightMagenta)), + 0xFF000000 | t.term_bright_magenta + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightCyan)), + 0xFF000000 | t.term_bright_cyan + ); + assert_eq!( + t.ansi_to_argb(&Color::Named(NamedColor::BrightWhite)), + 0xFF000000 | t.term_bright_white + ); } #[test] fn ansi_to_argb_spec_rgb() { let t = &DARK_THEME; - let color = Color::Spec(Rgb { r: 0xAB, g: 0xCD, b: 0xEF }); + let color = Color::Spec(Rgb { + r: 0xAB, + g: 0xCD, + b: 0xEF, + }); assert_eq!(t.ansi_to_argb(&color), 0xFFABCDEF); } diff --git a/crates/okena-core/src/theme/mod.rs b/crates/okena-core/src/theme/mod.rs index a958e2913..7add24c9d 100644 --- a/crates/okena-core/src/theme/mod.rs +++ b/crates/okena-core/src/theme/mod.rs @@ -1,7 +1,5 @@ mod colors; mod types; -pub use colors::{ - ThemeColors, DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, -}; +pub use colors::{DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, ThemeColors}; pub use types::{FolderColor, ThemeInfo, ThemeMode}; diff --git a/crates/okena-core/src/types.rs b/crates/okena-core/src/types.rs index 25913a91c..6ffa5c42e 100644 --- a/crates/okena-core/src/types.rs +++ b/crates/okena-core/src/types.rs @@ -44,7 +44,10 @@ pub enum DiffMode { /// Show the diff introduced by a specific commit (hash or ref). Commit(String), /// Compare two branches (shows diff between base and head). - BranchCompare { base: String, head: String }, + BranchCompare { + base: String, + head: String, + }, } impl DiffMode { @@ -79,13 +82,23 @@ mod tests { #[test] fn diff_mode_serde_round_trip() { - for mode in [DiffMode::WorkingTree, DiffMode::Staged, DiffMode::Commit("abc1234".to_string())] { + for mode in [ + DiffMode::WorkingTree, + DiffMode::Staged, + DiffMode::Commit("abc1234".to_string()), + ] { let json = serde_json::to_string(&mode).unwrap(); let parsed: DiffMode = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, mode); } // Check exact JSON values - assert_eq!(serde_json::to_string(&DiffMode::WorkingTree).unwrap(), "\"working_tree\""); - assert_eq!(serde_json::to_string(&DiffMode::Staged).unwrap(), "\"staged\""); + assert_eq!( + serde_json::to_string(&DiffMode::WorkingTree).unwrap(), + "\"working_tree\"" + ); + assert_eq!( + serde_json::to_string(&DiffMode::Staged).unwrap(), + "\"staged\"" + ); } } diff --git a/crates/okena-core/src/ws.rs b/crates/okena-core/src/ws.rs index cfb45ffe4..917c79350 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, ApiTerminalFocusRequest, ApiToast}; use crate::keys::SpecialKey; use serde::{Deserialize, Serialize}; @@ -19,6 +19,10 @@ pub enum WsInbound { terminal_id: String, text: String, }, + SendBytes { + terminal_id: String, + data: Vec, + }, SendSpecialKey { terminal_id: String, key: SpecialKey, @@ -59,6 +63,17 @@ 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), + /// One-shot request for a connected desktop client to focus and raise an + /// exact terminal after an external API action succeeds. + TerminalFocusRequested(ApiTerminalFocusRequest), TerminalResized { terminal_id: String, cols: u16, @@ -136,6 +151,10 @@ mod tests { terminal_id: "t1".into(), text: "hello".into(), }, + WsInbound::SendBytes { + terminal_id: "t1".into(), + data: vec![0x1b, 0xff], + }, WsInbound::SendSpecialKey { terminal_id: "t1".into(), key: SpecialKey::Enter, @@ -183,6 +202,26 @@ 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::TerminalFocusRequested(ApiTerminalFocusRequest { + project_id: "p1".into(), + terminal_id: "t1".into(), + window: Some("main".into()), + }), WsOutbound::TerminalResized { terminal_id: "t1".into(), cols: 120, @@ -203,7 +242,12 @@ mod tests { let json = r#"{"type":"terminal_resized","terminal_id":"t1","cols":80,"rows":24}"#; let parsed: WsOutbound = serde_json::from_str(json).unwrap(); match parsed { - WsOutbound::TerminalResized { server_owns, cols, rows, .. } => { + WsOutbound::TerminalResized { + server_owns, + cols, + rows, + .. + } => { assert!(!server_owns); assert_eq!((cols, rows), (80, 24)); } diff --git a/crates/okena-daemon-core/Cargo.toml b/crates/okena-daemon-core/Cargo.toml new file mode 100644 index 000000000..7b3248fa7 --- /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", "test-util"] } +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..bf258e4fc --- /dev/null +++ b/crates/okena-daemon-core/src/command_loop.rs @@ -0,0 +1,8608 @@ +//! 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, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use okena_app_core::remote_snapshot::build_state_response; +#[cfg(test)] +use okena_app_core::workspace::actions::execute::apply_loaded_session; +use okena_app_core::workspace::actions::execute::{ + PreparedContentSearch, begin_workspace_replacement, cleanup_stale_prepared_terminal_launches, + cleanup_stale_workspace_replacement, clear_failed_terminal_launch_reservations, + ensure_terminal, ensure_workspace_replacement_allowed, execute_action, + execute_prepared_content_search_with_cancellation, fail_workspace_replacement, + finish_workspace_replacement, import_workspace_data, load_session_data_for_shell, + materialize_prepared_terminal_launches, materialize_workspace_replacement, + prepare_content_search, prepare_workspace_replacement, publish_prepared_terminal_launches, + reserve_uninitialized_terminal_launches, spawn_uninitialized_terminals, +}; +use okena_core::api::{ActionRequest, ApiGitStatus, ApiServiceInfo, ApiWindow, CommandResult}; +use okena_core::git_poll::{GitPollTrigger, git_poll_trigger_for_action}; +use okena_remote_server::bridge::{BridgeMessage, BridgeReceiver, RemoteCommand}; +use okena_services::config::{PreparedProjectConfig, prepare_project_config}; +use okena_services::manager::{ + ComposeProjectIdentity, ServiceKind, ServiceLoadStatus, ServiceManager, + ServiceProjectStateToken, +}; +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::{TerminalBackend, TerminalSessionTeardown}; +use okena_workspace::actions::project::ProjectDirectoryRenamePlan; +use okena_workspace::actions::soft_close::{ + begin_soft_close_flow, close_now_flow, probe_busy, undo_soft_close_flow, +}; +use okena_workspace::actions::worktree::WorktreeRemovalPlan; +use okena_workspace::context::WorkspaceCx; +use okena_workspace::focus::FocusManager; +use okena_workspace::persistence::AppSettings; +use okena_workspace::state::{ + ProjectRuntimeQuiesce, TerminalBackendMigration, WindowId, Workspace, +}; +use parking_lot::Mutex; +use tokio::sync::{Semaphore, oneshot, watch}; + +use crate::daemon_config::{DaemonConfig, get_settings_schema}; +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::SendBytes { 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); + } +} + +fn publish_config_change_after_success(result: &CommandResult, state_version: &watch::Sender) { + if matches!(result, CommandResult::Ok(_)) { + state_version.send_modify(|version| *version = version.wrapping_add(1)); + } +} + +struct SettingsUpdateOutcome { + result: CommandResult, + committed: bool, +} + +impl SettingsUpdateOutcome { + fn uncommitted(result: CommandResult) -> Self { + Self { + result, + committed: false, + } + } + + fn from_store_result(result: CommandResult) -> Self { + let committed = matches!(result, CommandResult::Ok(_)); + Self { result, committed } + } +} + +fn publish_committed_settings_change( + outcome: &SettingsUpdateOutcome, + state_version: &watch::Sender, +) { + if outcome.committed { + state_version.send_modify(|version| *version = version.wrapping_add(1)); + } +} + +const MAX_CONCURRENT_CONTENT_SEARCHES: usize = 2; + +fn spawn_content_search_with( + search: PreparedContentSearch, + reply: Option>, + runtime: &tokio::runtime::Handle, + permits: Arc, + run: Run, +) where + Run: FnOnce(PreparedContentSearch, &std::sync::atomic::AtomicBool) -> CommandResult + + Send + + 'static, +{ + let worker_runtime = runtime.clone(); + let _task = runtime.spawn(async move { + let _permit = match permits.acquire_owned().await { + Ok(permit) => permit, + Err(_) => { + if let Some(reply) = reply { + let _ = reply.send(CommandResult::Err( + "content search executor unavailable".to_string(), + )); + } + return; + } + }; + if reply.as_ref().is_some_and(oneshot::Sender::is_closed) { + return; + } + + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker_cancelled = cancelled.clone(); + let mut worker = worker_runtime.spawn_blocking(move || run(search, &worker_cancelled)); + + let result = match reply { + Some(mut reply) => { + tokio::select! { + result = &mut worker => { + let result = result.unwrap_or_else(|error| { + CommandResult::Err(format!("content search worker failed: {error}")) + }); + let _ = reply.send(result); + return; + } + _ = reply.closed() => { + cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + let _ = worker.await; + return; + } + } + } + None => worker.await, + }; + + if let Err(error) = result { + log::warn!("detached content search worker failed: {error}"); + } + }); +} + +fn spawn_content_search( + search: PreparedContentSearch, + reply: Option>, + runtime: &tokio::runtime::Handle, + permits: Arc, +) { + spawn_content_search_with(search, reply, runtime, permits, |search, cancelled| { + execute_prepared_content_search_with_cancellation(search, cancelled).into_command_result() + }); +} + +/// Run destructive cleanup only while no current project occupies the physical +/// worktree root or one of its subdirectories. The workspace guard fences +/// replacement registration against the check-to-delete window. +fn with_unclaimed_worktree_root( + workspace: &Arc>, + worktree_path: &std::path::Path, + cleanup: impl FnOnce() -> R, +) -> Option { + let workspace = workspace.lock(); + let physical_root = Workspace::physical_path_identity(worktree_path); + if workspace + .projects() + .iter() + .filter(|project| !project.is_remote) + .map(|project| Workspace::physical_path_identity(std::path::Path::new(&project.path))) + .any(|project_path| project_path.starts_with(&physical_root)) + { + return None; + } + Some(cleanup()) +} + +fn cleanup_created_worktree_if_unclaimed( + workspace: &Arc>, + worktree_path: &std::path::Path, + git_root: &std::path::Path, +) { + let result = with_unclaimed_worktree_root(workspace, worktree_path, || { + okena_git::verify_linked_worktree_fresh(git_root, worktree_path) + .and_then(|verified| okena_git::remove_worktree_fast(&verified)) + }); + match result { + Some(Err(error)) => log::warn!( + "worktree-create: failed to clean stale checkout at {}: {error}", + worktree_path.display() + ), + None => log::info!( + "worktree-create: retained checkout now claimed at or below {}", + worktree_path.display() + ), + Some(Ok(())) => {} + } +} + +fn unload_project_services_for_background_removal( + project_id: &str, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) -> Vec { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + let active_service_names = manager.active_okena_service_names(project_id); + manager.unload_project_services(project_id, &mut cx); + active_service_names +} + +fn detach_project_services_for_runtime_quiesce( + project_ids: &[String], + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) -> HashMap, Vec)> { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + project_ids + .iter() + .map(|project_id| { + let active_service_names = manager.active_okena_service_names(project_id); + let terminal_ids = + manager.unload_project_services_for_backend_migration(project_id, &mut cx); + (project_id.clone(), (active_service_names, terminal_ids)) + }) + .collect() +} + +struct QuiescedProjectRuntimes { + workspace: Vec, + active_service_names: HashMap>, +} + +#[derive(Clone)] +struct PhysicalProjectSafetyTargets { + project_ids: HashSet, + roots: Vec, + compose_identities: HashSet, +} + +fn physical_project_safety_targets( + project_paths: &[(String, String)], + roots: Vec, + service_manager: &ServiceManager, +) -> PhysicalProjectSafetyTargets { + let project_ids: HashSet = project_paths + .iter() + .map(|(project_id, _)| project_id.clone()) + .collect(); + let paths: HashMap<&str, &str> = project_paths + .iter() + .map(|(project_id, path)| (project_id.as_str(), path.as_str())) + .collect(); + let root_identities = roots + .iter() + .map(|root| Workspace::physical_path_identity(root)) + .collect::>(); + let compose_identities = service_manager + .instances() + .iter() + .filter_map(|((project_id, _), instance)| { + let ServiceKind::DockerCompose { compose_file } = &instance.kind else { + return None; + }; + let project_path = service_manager + .project_path(project_id) + .map(String::as_str) + .or_else(|| paths.get(project_id.as_str()).copied())?; + let path_identity = + Workspace::physical_path_identity(PathBuf::from(project_path).as_path()); + if !project_ids.contains(project_id) + && !root_identities + .iter() + .any(|root| path_identity.starts_with(root)) + { + return None; + } + Some(ComposeProjectIdentity::new(project_path, compose_file)) + }) + .collect(); + PhysicalProjectSafetyTargets { + project_ids, + roots, + compose_identities, + } +} + +fn ensure_no_compose_mutations( + service_manager: &ServiceManager, + targets: &PhysicalProjectSafetyTargets, +) -> Result<(), String> { + let activities = + service_manager.compose_mutations_for(&targets.project_ids, &targets.compose_identities); + if activities.is_empty() { + return Ok(()); + } + let blockers = activities + .iter() + .map(|activity| { + format!( + "{} {:?} for {} ({})", + if activity.queued { "queued" } else { "active" }, + activity.kind, + activity.service_name, + activity.project_path + ) + }) + .collect::>() + .join(", "); + Err(format!( + "cannot change project directories while Docker Compose mutations are pending: {blockers}" + )) +} + +async fn preflight_physical_project_change( + targets: &PhysicalProjectSafetyTargets, + service_manager: &Arc>, + runtime: &tokio::runtime::Handle, + inspect: Inspect, +) -> Result<(), String> +where + Inspect: FnOnce(Vec) -> Result<(), String> + Send + 'static, +{ + ensure_no_compose_mutations(&service_manager.lock(), targets)?; + let roots = targets.roots.clone(); + runtime + .spawn_blocking(move || inspect(roots)) + .await + .map_err(|error| format!("Docker Compose safety inspection failed: {error}"))??; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn begin_project_runtimes_quiesce( + project_ids: &[String], + reject_running_hooks: bool, + safety_targets: &PhysicalProjectSafetyTargets, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + settings: &AppSettings, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, + terminals: &TerminalsRegistry, + deadlines: &SoftCloseDeadlines, +) -> Result { + let mut snapshots = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + let service_manager = service_manager.lock(); + ensure_no_compose_mutations(&service_manager, safety_targets)?; + workspace.begin_project_runtimes_quiesce( + project_ids, + &settings.default_shell, + settings.session_backend, + reject_running_hooks, + &mut cx, + )? + }; + { + let mut deadlines = deadlines.lock(); + for snapshot in &snapshots { + for terminal_id in &snapshot.pending_close_terminal_ids { + deadlines.remove(terminal_id); + } + } + } + let mut detached_services = detach_project_services_for_runtime_quiesce( + project_ids, + service_manager, + service_tick, + runtime, + ); + let mut active_service_names = HashMap::new(); + for snapshot in &mut snapshots { + let (active_names, service_terminal_ids) = detached_services + .remove(&snapshot.project_id) + .unwrap_or_default(); + active_service_names.insert(snapshot.project_id.clone(), active_names); + snapshot.teardown_sessions.extend( + service_terminal_ids + .into_iter() + .map(TerminalSessionTeardown::host), + ); + snapshot + .teardown_sessions + .sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + snapshot + .teardown_sessions + .dedup_by(|a, b| a.terminal_id == b.terminal_id); + } + { + let mut registry = terminals.lock(); + for snapshot in &snapshots { + for teardown in &snapshot.teardown_sessions { + if !snapshot + .preserved_registry_terminal_ids + .contains(&teardown.terminal_id) + { + registry.remove(&teardown.terminal_id); + } + } + } + } + if let Some(monitor) = hook_monitor { + for snapshot in &snapshots { + for terminal_id in &snapshot.hook_terminal_ids { + monitor.cancel_by_terminal_id(terminal_id); + } + } + } + Ok(QuiescedProjectRuntimes { + workspace: snapshots, + active_service_names, + }) +} + +async fn flush_project_runtime_teardown( + snapshots: &[ProjectRuntimeQuiesce], + backend: &Arc, + runtime: &tokio::runtime::Handle, +) -> Result<(), String> { + let backend = backend.clone(); + let teardown_sessions = snapshots + .iter() + .flat_map(|snapshot| snapshot.teardown_sessions.iter().cloned()) + .collect::>(); + let teardown_terminal_ids = teardown_sessions + .iter() + .map(|teardown| teardown.terminal_id.clone()) + .collect::>(); + runtime + .spawn_blocking(move || { + for teardown in &teardown_sessions { + backend.kill_session(teardown); + } + if backend.flush_teardown_with_timeout(Duration::from_secs(5), &teardown_terminal_ids) { + Ok(()) + } else { + Err("terminal teardown did not release project paths in time; checkout preserved") + } + }) + .await + .map_err(|error| format!("terminal teardown task failed: {error}"))? + .map_err(str::to_string) +} + +#[allow(clippy::too_many_arguments)] +async fn recover_quiesced_project_runtime( + quiesced: &QuiescedProjectRuntimes, + expected_paths: &HashMap, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + settings: &AppSettings, + backend: &Arc, + terminals: &TerminalsRegistry, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) -> Result<(), String> { + let project_ids = quiesced + .workspace + .iter() + .map(|snapshot| snapshot.project_id.clone()) + .collect::>(); + let launches = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + for snapshot in &quiesced.workspace { + let expected_path = expected_paths + .get(&snapshot.project_id) + .ok_or_else(|| format!("missing recovery path: {}", snapshot.project_id))?; + if !workspace.project_runtime_quiesce_is_current_at(snapshot, expected_path) { + return Err(format!( + "project changed while runtimes were quiesced: {}", + snapshot.project_id + )); + } + } + reserve_uninitialized_terminal_launches(&mut workspace, &project_ids, settings, &mut cx)? + }; + + let (owners, publication_error) = + match publish_prepared_terminal_launches(&launches, terminals, backend.as_ref()) { + Ok(owners) => (Some(owners), None), + Err(error) => { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + let failed_ids = launches + .iter() + .map(|launch| launch.terminal_id().to_string()) + .collect::>(); + clear_failed_terminal_launch_reservations( + &mut workspace, + &launches, + &failed_ids, + &mut cx, + ); + drop(workspace); + (None, Some(error)) + } + }; + + let (failed_terminal_ids, terminal_errors) = if owners.is_some() { + let worker_launches = launches.clone(); + let worker_backend = backend.clone(); + match runtime + .spawn_blocking(move || { + materialize_prepared_terminal_launches(&worker_launches, worker_backend.as_ref()) + }) + .await + { + Ok(outcome) => (outcome.failed_terminal_ids, outcome.errors), + Err(error) => ( + launches + .iter() + .map(|launch| launch.terminal_id().to_string()) + .collect(), + vec![format!("terminal recovery task failed: {error}")], + ), + } + } else { + ( + Vec::new(), + vec![format!( + "terminal reservation publication failed: {}", + publication_error.unwrap_or_else(|| "unknown error".to_string()) + )], + ) + }; + + let stale_project_id = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + let mut stale_project_id = None; + for snapshot in &quiesced.workspace { + let expected_path = expected_paths + .get(&snapshot.project_id) + .ok_or_else(|| format!("missing recovery path: {}", snapshot.project_id))?; + if !workspace.project_runtime_quiesce_is_current_at(snapshot, expected_path) { + stale_project_id = Some(snapshot.project_id.clone()); + break; + } + } + if stale_project_id.is_none() { + clear_failed_terminal_launch_reservations( + &mut workspace, + &launches, + &failed_terminal_ids, + &mut cx, + ); + } + stale_project_id + }; + if let Some(stale_project_id) = stale_project_id { + if let Some(owners) = owners { + let cleanup_backend = backend.clone(); + runtime + .spawn_blocking(move || { + cleanup_stale_prepared_terminal_launches(&owners, cleanup_backend.as_ref()); + cleanup_backend.flush_teardown(); + }) + .await + .map_err(|error| format!("stale terminal cleanup failed: {error}"))?; + } + return Err(format!( + "project changed while runtimes were recovering: {stale_project_id}" + )); + } + + if let Some(owners) = &owners { + let failed_owned_ids = owners.release(&failed_terminal_ids); + if !failed_owned_ids.is_empty() { + let cleanup_backend = backend.clone(); + runtime + .spawn_blocking(move || { + for terminal_id in failed_owned_ids { + cleanup_backend.kill(&terminal_id); + } + cleanup_backend.flush_teardown(); + }) + .await + .map_err(|error| format!("partial terminal cleanup failed: {error}"))?; + } + } + + let mut errors = terminal_errors + .into_iter() + .map(|error| format!("terminal recovery failed: {error}")) + .collect::>(); + for snapshot in &quiesced.workspace { + let active_service_names = quiesced + .active_service_names + .get(&snapshot.project_id) + .map(Vec::as_slice) + .unwrap_or_default(); + if let Err(error) = recover_project_services( + &snapshot.project_id, + active_service_names, + workspace, + service_manager, + service_tick, + runtime, + ) + .await + { + errors.push(format!( + "service recovery failed for {}: {error}", + snapshot.project_id + )); + } + } + { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + for snapshot in &quiesced.workspace { + let expected_path = expected_paths + .get(&snapshot.project_id) + .ok_or_else(|| format!("missing recovery path: {}", snapshot.project_id))?; + if !workspace.project_runtime_quiesce_is_current_at(snapshot, expected_path) { + return Err(format!( + "project changed while services were recovering: {}", + snapshot.project_id + )); + } + } + for snapshot in &quiesced.workspace { + workspace.finish_project_runtime_recovery(snapshot, &mut cx); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +struct PreparedServiceOwner { + project_path: String, + data_replacement_epoch: u64, + service_state: ServiceProjectStateToken, +} + +fn snapshot_service_owner( + project_id: &str, + workspace: &Arc>, + service_manager: &Arc>, +) -> Option { + let (project_path, data_replacement_epoch) = { + let workspace = workspace.lock(); + workspace.project(project_id).and_then(|project| { + (!project.is_remote).then(|| (project.path.clone(), workspace.data_replacement_epoch())) + }) + }?; + let service_state = service_manager.lock().project_state_token(project_id); + Some(PreparedServiceOwner { + project_path, + data_replacement_epoch, + service_state, + }) +} + +fn service_owner_is_current( + project_id: &str, + owner: &PreparedServiceOwner, + workspace: &Workspace, + service_manager: &ServiceManager, +) -> bool { + workspace.data_replacement_epoch() == owner.data_replacement_epoch + && workspace + .project(project_id) + .is_some_and(|project| !project.is_remote && project.path == owner.project_path) + && service_manager.is_project_state_token_current(project_id, &owner.service_state) +} + +async fn prepare_services_off_reactor( + owner: PreparedServiceOwner, + runtime: &tokio::runtime::Handle, + prepare: Prepare, +) -> Result<(PreparedServiceOwner, PreparedProjectConfig), String> +where + Prepare: FnOnce(&str) -> PreparedProjectConfig + Send + 'static, +{ + let project_path = owner.project_path.clone(); + let prepared = runtime + .spawn_blocking(move || prepare(&project_path)) + .await + .map_err(|error| format!("service config preparation failed: {error}"))?; + Ok((owner, prepared)) +} + +async fn reload_project_services_off_reactor_with_preparer( + project_id: &str, + workspace: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, + prepare: Prepare, +) -> CommandResult +where + Prepare: FnOnce(&str) -> PreparedProjectConfig + Send + 'static, +{ + let Some(owner) = snapshot_service_owner(project_id, workspace, service_manager) else { + return CommandResult::Err(format!("project not found: {project_id}")); + }; + if service_manager.lock().project_path(project_id) != Some(&owner.project_path) { + return CommandResult::Err(format!("project not found: {project_id}")); + } + let (owner, prepared) = match prepare_services_off_reactor(owner, runtime, prepare).await { + Ok(prepared) => prepared, + Err(error) => return CommandResult::Err(error), + }; + + let workspace = workspace.lock(); + let mut manager = service_manager.lock(); + if !service_owner_is_current(project_id, &owner, &workspace, &manager) + || manager.project_path(project_id) != Some(&owner.project_path) + { + return CommandResult::Err(format!( + "project changed while services were being prepared: {project_id}" + )); + } + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut cx = reactor_ref.cx(); + let status = manager.reload_project_services_prepared( + project_id, + &owner.project_path, + prepared, + &mut cx, + ); + manager.set_project_writeback_owner( + project_id, + &owner.project_path, + owner.data_replacement_epoch, + ); + match status { + ServiceLoadStatus::Loaded => CommandResult::Ok(None), + ServiceLoadStatus::Failed => CommandResult::Err(format!( + "failed to reload services for project: {project_id}" + )), + } +} + +async fn reload_project_services_off_reactor( + project_id: &str, + workspace: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) -> CommandResult { + reload_project_services_off_reactor_with_preparer( + project_id, + workspace, + service_manager, + service_tick, + runtime, + prepare_project_config, + ) + .await +} + +async fn recover_project_services_with_preparer( + project_id: &str, + active_service_names: &[String], + workspace: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, + prepare: Prepare, +) -> Result<(), String> +where + Prepare: FnOnce(&str) -> PreparedProjectConfig + Send + 'static, +{ + let owner = snapshot_service_owner(project_id, workspace, service_manager) + .ok_or_else(|| format!("service recovery owner is unavailable: {project_id}"))?; + if service_manager.lock().project_path(project_id).is_some() { + return Err(format!( + "service manager already owns project during recovery: {project_id}" + )); + } + let (owner, prepared) = prepare_services_off_reactor(owner, runtime, prepare).await?; + if matches!(prepared, PreparedProjectConfig::Missing) { + return Err(format!( + "project path is unavailable during service recovery: {}", + owner.project_path + )); + } + + let workspace = workspace.lock(); + let mut manager = service_manager.lock(); + if !service_owner_is_current(project_id, &owner, &workspace, &manager) + || manager.project_path(project_id).is_some() + { + return Err(format!("service recovery owner became stale: {project_id}")); + } + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut cx = reactor_ref.cx(); + manager.set_project_writeback_owner( + project_id, + &owner.project_path, + owner.data_replacement_epoch, + ); + // The old persistent sessions were intentionally killed before removal; + // reconnecting their ids here would race their asynchronous teardown. + let status = manager.load_project_services_prepared_without_auto_start( + project_id, + &owner.project_path, + prepared, + &mut cx, + ); + if status == ServiceLoadStatus::Failed { + return Err(format!( + "failed to load recovered services for project: {project_id}" + )); + } + for service_name in active_service_names { + manager.start_service(project_id, service_name, &owner.project_path, &mut cx); + } + Ok(()) +} + +async fn recover_project_services( + project_id: &str, + active_service_names: &[String], + workspace: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) -> Result<(), String> { + recover_project_services_with_preparer( + project_id, + active_service_names, + workspace, + service_manager, + service_tick, + runtime, + prepare_project_config, + ) + .await +} + +fn recover_project_services_for_backend_migration( + project_id: &str, + workspace: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) { + let project_owner = { + let workspace = workspace.lock(); + workspace + .project(project_id) + .map(|project| (project.path.clone(), workspace.data_replacement_epoch())) + }; + let Some((project_path, data_replacement_epoch)) = project_owner else { + return; + }; + if !std::path::Path::new(&project_path).exists() { + return; + } + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + manager.set_project_writeback_owner(project_id, &project_path, data_replacement_epoch); + manager.load_project_services_for_backend_migration(project_id, &project_path, &mut cx); +} + +struct TerminalBackendMigrationGuard { + workspace: Arc>, + workspace_tick: watch::Sender, + hook_runner: Option, + hook_monitor: Option, + migration: TerminalBackendMigration, + active: bool, +} + +impl TerminalBackendMigrationGuard { + fn new( + workspace: Arc>, + workspace_tick: watch::Sender, + hook_runner: Option, + hook_monitor: Option, + migration: TerminalBackendMigration, + ) -> Self { + Self { + workspace, + workspace_tick, + hook_runner, + hook_monitor, + migration, + active: true, + } + } + + fn release(&mut self) { + if !self.active { + return; + } + let mut cx = + DaemonWorkspaceCx::new(&self.workspace_tick, &self.hook_runner, &self.hook_monitor); + let mut workspace = self.workspace.lock(); + if workspace.terminal_backend_migration_epoch() == Some(self.migration.epoch) { + if let Err(error) = workspace.restore_terminal_backend_migration_slots(&self.migration) + { + log::error!( + "failed to restore terminal ownership while releasing migration: {error}" + ); + } + workspace.finish_terminal_backend_migration(self.migration.epoch, &mut cx); + } + self.active = false; + } + + fn finish(mut self) { + self.release(); + } +} + +struct BackendReconfigurationGuard { + backend: Arc, + active: bool, +} + +impl BackendReconfigurationGuard { + fn new(backend: Arc) -> Self { + Self { + backend, + active: true, + } + } + + fn cancel(&mut self) { + if self.active { + self.backend.cancel_session_backend_reconfiguration(); + self.active = false; + } + } + + fn applied(mut self) { + self.active = false; + } +} + +impl Drop for BackendReconfigurationGuard { + fn drop(&mut self) { + self.cancel(); + } +} + +impl Drop for TerminalBackendMigrationGuard { + fn drop(&mut self) { + self.release(); + } +} + +struct TerminalBackendRematerializationContext<'a> { + workspace: &'a Arc>, + workspace_tick: &'a watch::Sender, + hook_runner: &'a Option, + hook_monitor: &'a Option, + backend: &'a Arc, + terminals: &'a TerminalsRegistry, + service_manager: &'a Arc>, + service_tick: &'a watch::Sender, + runtime: &'a tokio::runtime::Handle, + active_services: &'a HashMap>, +} + +fn rematerialize_terminal_backend_migration( + migration: &TerminalBackendMigration, + app_settings: &AppSettings, + context: &TerminalBackendRematerializationContext<'_>, +) -> Result<(), String> { + let mut errors = Vec::new(); + { + let mut cx = DaemonWorkspaceCx::new( + context.workspace_tick, + context.hook_runner, + context.hook_monitor, + ); + let mut ws = context.workspace.lock(); + if let Err(error) = ws.restore_terminal_backend_migration_slots(migration) { + errors.push(error); + } else { + for slot in &migration.ordinary_slots { + if ensure_terminal( + &slot.terminal_id, + context.terminals, + context.backend.as_ref(), + &ws, + app_settings, + ) + .is_none() + { + errors.push(format!( + "failed to rematerialize terminal: {}", + slot.terminal_id + )); + } + } + cx.notify(); + } + } + + for project_id in &migration.project_ids { + recover_project_services_for_backend_migration( + project_id, + context.workspace, + context.service_manager, + context.service_tick, + context.runtime, + ); + } + + let service_reactor = ServiceReactorRef::new( + context.service_manager.clone(), + context.runtime.clone(), + context.service_tick.clone(), + ); + let mut manager = context.service_manager.lock(); + let mut cx = service_reactor.cx(); + for (project_id, service_names) in context.active_services { + for service_name in service_names { + if let CommandResult::Err(error) = + manager.start_service_action(project_id, service_name, &mut cx) + { + errors.push(error); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +#[allow(clippy::too_many_arguments)] +async fn set_settings_with_backend_migration( + patch: serde_json::Value, + daemon_config: &mut DaemonConfig, + backend: &Arc, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + terminals: &TerminalsRegistry, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, + deadlines: &SoftCloseDeadlines, +) -> SettingsUpdateOutcome { + let new_settings = match daemon_config.preview_settings(patch) { + Ok(settings) => settings, + Err(error) => return SettingsUpdateOutcome::uncommitted(CommandResult::Err(error)), + }; + let old_settings = daemon_config + .preview_settings(serde_json::json!({})) + .expect( + "the current in-memory settings were already validated during daemon initialization", + ); + if new_settings.session_backend == old_settings.session_backend { + return SettingsUpdateOutcome::from_store_result( + daemon_config.store_prevalidated_settings(&new_settings), + ); + } + if !backend.supports_session_backend_reconfiguration() { + return SettingsUpdateOutcome::uncommitted(CommandResult::Err( + "terminal backend does not support live session route changes".to_string(), + )); + } + + if let Err(error) = backend.begin_session_backend_reconfiguration() { + return SettingsUpdateOutcome::uncommitted(CommandResult::Err(format!( + "failed to begin terminal backend migration: {error}" + ))); + } + let mut backend_guard = BackendReconfigurationGuard::new(backend.clone()); + + let mut migration = { + let mut ws = workspace.lock(); + match ws.begin_terminal_backend_migration( + old_settings.session_backend, + &old_settings.default_shell, + ) { + Ok(migration) => migration, + Err(error) => { + backend_guard.cancel(); + return SettingsUpdateOutcome::uncommitted(CommandResult::Err(error)); + } + } + }; + let guard = TerminalBackendMigrationGuard::new( + workspace.clone(), + workspace_tick.clone(), + hook_runner.clone(), + hook_monitor.clone(), + migration.clone(), + ); + + let service_reactor = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut active_services = HashMap::new(); + { + let mut manager = service_manager.lock(); + let mut cx = service_reactor.cx(); + for project_id in &migration.project_ids { + active_services.insert( + project_id.clone(), + manager.active_okena_service_names(project_id), + ); + migration.teardown_sessions.extend( + manager + .unload_project_services_for_backend_migration(project_id, &mut cx) + .into_iter() + .map(TerminalSessionTeardown::host), + ); + } + } + let rematerialization_context = TerminalBackendRematerializationContext { + workspace, + workspace_tick, + hook_runner, + hook_monitor, + backend, + terminals, + service_manager, + service_tick, + runtime, + active_services: &active_services, + }; + + let registry_ids: Vec = terminals.lock().keys().cloned().collect(); + migration.teardown_sessions.extend( + registry_ids + .iter() + .cloned() + .map(TerminalSessionTeardown::host), + ); + migration + .teardown_sessions + .sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + migration + .teardown_sessions + .dedup_by(|a, b| a.terminal_id == b.terminal_id); + { + let mut deadlines = deadlines.lock(); + let mut registry = terminals.lock(); + for teardown in &migration.teardown_sessions { + deadlines.remove(&teardown.terminal_id); + registry.remove(&teardown.terminal_id); + } + } + if let Some(monitor) = hook_monitor { + for terminal_id in &migration.hook_terminal_ids { + monitor.cancel_by_terminal_id(terminal_id); + } + } + + let teardown_backend = backend.clone(); + let teardown_sessions = migration.teardown_sessions.clone(); + let teardown_result = runtime + .spawn_blocking(move || { + for teardown in &teardown_sessions { + teardown_backend.kill_session(teardown); + } + teardown_backend.flush_teardown(); + teardown_backend.ensure_session_backend_reconfigurable() + }) + .await; + let teardown_result = match teardown_result { + Ok(result) => result.map_err(|error| error.to_string()), + Err(error) => Err(format!("terminal teardown task failed: {error}")), + }; + if let Err(error) = teardown_result { + backend_guard.cancel(); + let recovery = rematerialize_terminal_backend_migration( + &migration, + &old_settings, + &rematerialization_context, + ); + guard.finish(); + return SettingsUpdateOutcome::uncommitted(CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; rollback failed: {recovery_error}"), + })); + } + + let store_result = daemon_config.store_prevalidated_settings(&new_settings); + if let CommandResult::Err(error) = store_result { + backend_guard.cancel(); + let recovery = rematerialize_terminal_backend_migration( + &migration, + &old_settings, + &rematerialization_context, + ); + guard.finish(); + return SettingsUpdateOutcome::uncommitted(CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; rollback failed: {recovery_error}"), + })); + } + + if let Err(error) = backend.apply_session_backend(new_settings.session_backend) { + backend_guard.cancel(); + let restore_settings = daemon_config.store_prevalidated_settings(&old_settings); + let recovery = rematerialize_terminal_backend_migration( + &migration, + &old_settings, + &rematerialization_context, + ); + guard.finish(); + let mut failures = vec![format!("failed to switch terminal backend: {error}")]; + if let CommandResult::Err(error) = restore_settings { + failures.push(format!("settings rollback failed: {error}")); + } + if let Err(error) = recovery { + failures.push(format!("terminal rollback failed: {error}")); + } + return SettingsUpdateOutcome::uncommitted(CommandResult::Err(failures.join("; "))); + } + backend_guard.applied(); + + let recovery = rematerialize_terminal_backend_migration( + &migration, + &new_settings, + &rematerialization_context, + ); + guard.finish(); + let result = match recovery { + Ok(()) => store_result, + Err(error) => CommandResult::Err(format!( + "settings changed, but terminal rematerialization failed: {error}" + )), + }; + SettingsUpdateOutcome { + result, + committed: true, + } +} + +fn apply_deferred_hook_actions( + ws: &mut Workspace, + project_id: &str, + outcome: okena_hooks::HookActionOutcome, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + settings: &AppSettings, + cx: &mut impl WorkspaceCx, +) -> okena_app_core::workspace::actions::execute::ActionResult { + let (terminal_actions, hook_results) = outcome; + let needs_materialization = !terminal_actions.is_empty(); + ws.register_hook_results(hook_results, cx); + for (command, env) in terminal_actions { + ws.add_terminal_with_command(project_id, &command, &env, cx); + } + if needs_materialization { + spawn_uninitialized_terminals(ws, project_id, backend, terminals, settings, None, cx) + } else { + okena_app_core::workspace::actions::execute::ActionResult::Ok(None) + } +} + +#[allow(clippy::too_many_arguments)] +async fn remove_worktree_project_off_reactor_with( + project_id: String, + force: bool, + global_hooks: okena_workspace::persistence::HooksConfig, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + focus_manager: &mut FocusManager, + backend: &Arc, + terminals: &TerminalsRegistry, + settings: &AppSettings, + service_manager: &Arc>, + service_tick: &watch::Sender, + deadlines: &SoftCloseDeadlines, + runtime: &tokio::runtime::Handle, + inspect_compose_containers: Inspect, + remove: Remove, +) -> CommandResult +where + Inspect: FnOnce(Vec) -> Result<(), String> + Send + 'static, + Remove: FnOnce(&WorktreeRemovalPlan, bool) -> Result<(), String> + Send + 'static, +{ + let (plan, project_path) = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + let project_path = workspace + .project(&project_id) + .map(|project| project.path.clone()); + match ( + workspace.begin_worktree_removal(&project_id, &global_hooks, &mut cx), + project_path, + ) { + (Ok(plan), Some(project_path)) => (plan, project_path), + (Err(error), _) => return CommandResult::Err(error), + (Ok(_), None) => return CommandResult::Err(format!("project not found: {project_id}")), + } + }; + let preflight_plan = plan.clone(); + match runtime + .spawn_blocking(move || preflight_plan.preflight_remove(force)) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => return CommandResult::Err(error), + Err(error) => { + return CommandResult::Err(format!("worktree removal preflight failed: {error}")); + } + } + let safety_targets = { + let manager = service_manager.lock(); + physical_project_safety_targets( + &[(project_id.clone(), project_path)], + vec![plan.worktree_path().to_path_buf()], + &manager, + ) + }; + if let Err(error) = preflight_physical_project_change( + &safety_targets, + service_manager, + runtime, + inspect_compose_containers, + ) + .await + { + return CommandResult::Err(error); + } + let quiesced = match begin_project_runtimes_quiesce( + std::slice::from_ref(&project_id), + false, + &safety_targets, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + service_manager, + service_tick, + runtime, + terminals, + deadlines, + ) { + Ok(quiesced) => quiesced, + Err(error) => return CommandResult::Err(error), + }; + if let Err(error) = flush_project_runtime_teardown(&quiesced.workspace, backend, runtime).await + { + let expected_paths = quiesced + .workspace + .iter() + .map(|snapshot| (snapshot.project_id.clone(), snapshot.project_path.clone())) + .collect(); + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + return CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; recovery failed: {recovery_error}"), + }); + } + + let blocking_hooks = global_hooks.clone(); + let blocking_monitor = hook_monitor.clone(); + let outcome = runtime + .spawn_blocking(move || { + plan.fire_close_hooks_headless(&blocking_hooks, blocking_monitor.as_ref()); + let result = remove(&plan, force); + (plan, result) + }) + .await; + + let (plan, removal) = match outcome { + Ok(outcome) => outcome, + Err(error) => { + let error = format!("worktree removal task failed: {error}"); + let expected_paths = quiesced + .workspace + .iter() + .map(|snapshot| (snapshot.project_id.clone(), snapshot.project_path.clone())) + .collect(); + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + return CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; recovery failed: {recovery_error}"), + }); + } + }; + if let Err(error) = removal { + let expected_paths = quiesced + .workspace + .iter() + .map(|snapshot| (snapshot.project_id.clone(), snapshot.project_path.clone())) + .collect(); + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + return CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; recovery failed: {recovery_error}"), + }); + } + + let terminal_ids = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + let Some(snapshot) = quiesced.workspace.first() else { + return CommandResult::Err("worktree removal lost its runtime owner".to_string()); + }; + if !workspace.project_runtime_quiesce_is_current(snapshot) { + return CommandResult::Err(format!( + "workspace changed while worktree was being removed: {project_id}" + )); + } + workspace.finish_worktree_removal(focus_manager, &plan, &global_hooks, &mut cx); + workspace.drain_pending_terminal_kills() + }; + for terminal_id in terminal_ids { + backend.kill(&terminal_id); + terminals.lock().remove(&terminal_id); + } + CommandResult::Ok(None) +} + +#[allow(clippy::too_many_arguments)] +async fn rename_project_directory_off_reactor_with( + project_id: String, + new_name: String, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + backend: &Arc, + terminals: &TerminalsRegistry, + settings: &AppSettings, + service_manager: &Arc>, + service_tick: &watch::Sender, + deadlines: &SoftCloseDeadlines, + runtime: &tokio::runtime::Handle, + inspect_compose_containers: Inspect, + move_directory: Move, +) -> CommandResult +where + Inspect: FnOnce(Vec) -> Result<(), String> + Send + 'static, + Move: FnOnce( + &ProjectDirectoryRenamePlan, + ) + -> Result + + Send + + 'static, +{ + let (workspace_data, owner_epoch, old_path, new_path) = { + let workspace = workspace.lock(); + if workspace.is_creating_project(&project_id) { + return CommandResult::Err("project is still being created".to_string()); + } + if workspace.is_project_closing(&project_id) { + return CommandResult::Err("project operation is already in progress".to_string()); + } + let Some(project) = workspace.project(&project_id) else { + return CommandResult::Err(format!("project not found: {project_id}")); + }; + let old_path = std::path::PathBuf::from(&project.path); + let Some(parent) = old_path.parent() else { + return CommandResult::Err("cannot determine parent directory".to_string()); + }; + ( + workspace.data().clone(), + workspace.data_replacement_epoch(), + old_path.clone(), + parent.join(&new_name), + ) + }; + let plan_project_id = project_id.clone(); + let plan_new_name = new_name.clone(); + let plan_new_path = new_path.to_string_lossy().into_owned(); + let plan = match runtime + .spawn_blocking(move || { + let active_projects: Vec<(String, bool, bool)> = workspace_data + .projects + .iter() + .map(|project| (project.id.clone(), project.is_creating, project.is_closing)) + .collect(); + let mut planning_workspace = Workspace::new(workspace_data); + for (project_id, is_creating, is_closing) in active_projects { + if is_creating { + planning_workspace.mark_creating_project(&project_id); + } + if is_closing { + planning_workspace.mark_closing_project_authoritative(&project_id); + } + } + planning_workspace.prepare_project_directory_rename( + &plan_project_id, + plan_new_path, + plan_new_name, + ) + }) + .await + { + Ok(Ok(plan)) => plan, + Ok(Err(error)) => return CommandResult::Err(error), + Err(error) => { + return CommandResult::Err(format!("directory rename preparation failed: {error}")); + } + }; + + { + let workspace = workspace.lock(); + if workspace.data_replacement_epoch() != owner_epoch + || workspace + .project(&project_id) + .is_none_or(|project| std::path::Path::new(&project.path) != old_path) + { + return CommandResult::Err(format!( + "project changed while its directory rename was being prepared: {project_id}" + )); + } + } + let affected_project_ids = plan + .affected_project_ids() + .map(str::to_string) + .collect::>(); + let affected_project_paths = plan + .affected_translations() + .iter() + .map(|translation| { + ( + translation.project_id().to_string(), + translation.old_path().to_string(), + ) + }) + .collect::>(); + let safety_targets = { + let manager = service_manager.lock(); + physical_project_safety_targets( + &affected_project_paths, + vec![plan.old_path().to_path_buf()], + &manager, + ) + }; + if let Err(error) = preflight_physical_project_change( + &safety_targets, + service_manager, + runtime, + inspect_compose_containers, + ) + .await + { + return CommandResult::Err(error); + } + let quiesced = match begin_project_runtimes_quiesce( + &affected_project_ids, + true, + &safety_targets, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + service_manager, + service_tick, + runtime, + terminals, + deadlines, + ) { + Ok(quiesced) => quiesced, + Err(error) => return CommandResult::Err(error), + }; + if let Err(error) = flush_project_runtime_teardown(&quiesced.workspace, backend, runtime).await + { + let expected_paths = quiesced + .workspace + .iter() + .map(|snapshot| (snapshot.project_id.clone(), snapshot.project_path.clone())) + .collect(); + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + return CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; recovery failed: {recovery_error}"), + }); + } + + let moved = { + let plan = plan.clone(); + runtime + .spawn_blocking(move || move_directory(&plan).map(|result| (plan, result))) + .await + }; + let (plan, move_result) = match moved { + Ok(Ok(moved)) => moved, + Ok(Err(error)) => { + let expected_paths = quiesced + .workspace + .iter() + .map(|snapshot| (snapshot.project_id.clone(), snapshot.project_path.clone())) + .collect(); + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + return CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; recovery failed: {recovery_error}"), + }); + } + Err(error) => { + let error = format!("directory rename task failed: {error}"); + let expected_paths = quiesced + .workspace + .iter() + .map(|snapshot| (snapshot.project_id.clone(), snapshot.project_path.clone())) + .collect(); + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + return CommandResult::Err(match recovery { + Ok(()) => error, + Err(recovery_error) => format!("{error}; recovery failed: {recovery_error}"), + }); + } + }; + + let expected_paths = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + for snapshot in &quiesced.workspace { + if !workspace.project_runtime_quiesce_is_current(snapshot) { + return CommandResult::Err(format!( + "workspace changed while project directory was being renamed: {}", + snapshot.project_id + )); + } + } + if let Err(error) = workspace.finish_project_directory_rename(&plan, move_result, &mut cx) { + return CommandResult::Err(error); + } + plan.affected_translations() + .iter() + .map(|translation| { + ( + translation.project_id().to_string(), + translation.new_path().to_string(), + ) + }) + .collect::>() + }; + let recovery = recover_quiesced_project_runtime( + &quiesced, + &expected_paths, + workspace, + workspace_tick, + hook_runner, + hook_monitor, + settings, + backend, + terminals, + service_manager, + service_tick, + runtime, + ) + .await; + match recovery { + Ok(()) => CommandResult::Ok(None), + Err(error) => CommandResult::Err(format!("directory renamed, but {error}")), + } +} + +/// Run physical worktree removal off the reactor while the daemon keeps the +/// authoritative project row in `is_closing` state. State is deleted only after +/// Git confirms the checkout is gone; failures restore normal terminal slots. +/// +/// `extra_teardown_terminal_ids` names terminals inside the checkout that the +/// caller already killed and the project row therefore no longer lists (a +/// completed before-remove hook shell); they are verified as released alongside +/// the project's own terminals before anything is deleted. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_background_worktree_removal( + plan: WorktreeRemovalPlan, + operation_epoch: u64, + did_stash: bool, + extra_teardown_terminal_ids: &[String], + global_hooks: &okena_workspace::persistence::HooksConfig, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + backend: &Arc, + terminals: &TerminalsRegistry, + settings: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &tokio::runtime::Handle, +) -> CommandResult { + let terminal_ids = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + match ws.prepare_background_worktree_removal(&plan.project_id, &mut cx) { + Ok(ids) => ids, + Err(error) => return CommandResult::Err(error), + } + }; + // ServiceManager owns restart-on-crash. Unload before killing project PTYs + // so their exit events cannot schedule replacement services mid-removal. + let active_service_names = unload_project_services_for_background_removal( + &plan.project_id, + service_manager, + service_tick, + runtime, + ); + for id in &terminal_ids { + backend.kill(id); + terminals.lock().remove(id); + } + // Every terminal whose CWD is inside the checkout, including any the caller + // already tore down (a keep-alive before-remove hook shell), must be + // verified as released before the directory is deleted. + let mut teardown_terminal_ids = terminal_ids; + teardown_terminal_ids.extend(extra_teardown_terminal_ids.iter().cloned()); + + let workspace = workspace.clone(); + let workspace_tick = workspace_tick.clone(); + let hook_runner = hook_runner.clone(); + let hook_monitor = hook_monitor.clone(); + let global_hooks = global_hooks.clone(); + let backend = backend.clone(); + let terminals = terminals.clone(); + let settings = settings.clone(); + let service_manager = service_manager.clone(); + let service_tick = service_tick.clone(); + let runtime = runtime.clone(); + tokio::task::spawn_local(async move { + let task_project_id = plan.project_id.clone(); + let global_hooks_blocking = global_hooks.clone(); + let monitor = hook_monitor.clone(); + let teardown_backend = backend.clone(); + let outcome = tokio::task::spawn_blocking(move || { + // `kill` is asynchronous for local PTYs. Do not race destructive + // removal with a process that may still own the checkout CWD: a + // bounded failure restores the project instead of deleting it. + if !teardown_backend + .flush_teardown_with_timeout(Duration::from_secs(5), &teardown_terminal_ids) + { + return ( + plan, + Err("terminal teardown did not release the worktree in time; checkout preserved".to_string()), + None, + ); + } + let worktree_path = plan.worktree_path.clone(); + // force_remove = is_dirty && !did_stash — same condition the sync + // close_worktree path uses to fire the dirty-close safety net. Runs + // before close hooks and removal, matching the canonical sync flow. + let dirty_hook = if !did_stash && okena_git::has_uncommitted_changes(&worktree_path) { + Some(plan.fire_on_dirty_close_headless(&global_hooks_blocking, monitor.as_ref())) + } else { + None + }; + plan.fire_close_hooks_headless(&global_hooks_blocking, monitor.as_ref()); + let removal = if did_stash && okena_git::has_uncommitted_changes(&worktree_path) { + Err( + "checkout became dirty after stash; preserved it instead of removing it" + .to_string(), + ) + } else { + plan.remove_fast().map_err(|error| error.to_string()) + }; + (plan, removal, dirty_hook) + }) + .await; + match outcome { + Ok((plan, removal, dirty_hook)) => { + if let Some(Err(error)) = dirty_hook { + log::error!( + "worktree-close: dirty-close hook failed for {}: {error}", + plan.project_id + ); + } + match removal { + Ok(()) => { + let mut cx = + DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + if ws.data_replacement_epoch() != operation_epoch { + log::info!( + "worktree-close: ignoring stale completion for {}", + plan.project_id + ); + return; + } + let mut focus_manager = FocusManager::new(); + ws.finish_worktree_removal( + &mut focus_manager, + &plan, + &global_hooks, + &mut cx, + ); + for id in ws.drain_pending_terminal_kills() { + backend.kill(&id); + terminals.lock().remove(&id); + } + } + Err(e) => { + log::error!( + "worktree-close: git removal failed for {}: {e}", + plan.project_id + ); + let app_settings = settings.lock().clone(); + { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + let mut ws = workspace.lock(); + if ws.data_replacement_epoch() != operation_epoch { + log::info!( + "worktree-close: ignoring stale failure for {}", + plan.project_id + ); + return; + } + if let okena_app_core::workspace::actions::execute::ActionResult::Err( + spawn_error, + ) = spawn_uninitialized_terminals( + &mut ws, + &plan.project_id, + backend.as_ref(), + &terminals, + &app_settings, + None, + &mut cx, + ) { + log::error!( + "worktree-close: failed to restore terminals for {}: {spawn_error}", + plan.project_id + ); + } + if let Some(hm) = &hook_monitor { + hm.push_toast(okena_state::Toast::error(format!( + "Worktree checkout could not be removed and remains open at {}: {e}", + plan.worktree_path.display() + ))); + } + } + if let Err(error) = recover_project_services( + &plan.project_id, + &active_service_names, + &workspace, + &service_manager, + &service_tick, + &runtime, + ) + .await + { + log::error!( + "worktree-close: failed to recover services for {}: {error}", + plan.project_id + ); + } + let mut cx = + DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + if ws.data_replacement_epoch() == operation_epoch { + ws.finish_closing_project(&plan.project_id); + cx.notify(); + } + } + } + } + Err(e) => { + log::error!("worktree-close: removal task failed: {e}"); + let app_settings = settings.lock().clone(); + { + let mut cx = + DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + if ws.data_replacement_epoch() != operation_epoch { + log::info!( + "worktree-close: ignoring stale task failure for {task_project_id}" + ); + return; + } + if let okena_app_core::workspace::actions::execute::ActionResult::Err( + spawn_error, + ) = spawn_uninitialized_terminals( + &mut ws, + &task_project_id, + backend.as_ref(), + &terminals, + &app_settings, + None, + &mut cx, + ) { + log::error!( + "worktree-close: failed to restore terminals for {task_project_id}: {spawn_error}" + ); + } + } + if let Err(error) = recover_project_services( + &task_project_id, + &active_service_names, + &workspace, + &service_manager, + &service_tick, + &runtime, + ) + .await + { + log::error!( + "worktree-close: failed to recover services for {task_project_id}: {error}" + ); + } + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + if ws.data_replacement_epoch() == operation_epoch { + ws.finish_closing_project(&task_project_id); + cx.notify(); + } + } + } + }); + + CommandResult::Ok(Some(serde_json::json!({ "pending": true }))) +} + +fn abort_background_worktree_close( + project_id: &str, + operation_epoch: u64, + error: String, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, +) { + let project_name = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + if ws.data_replacement_epoch() != operation_epoch { + log::info!("worktree-close: ignoring stale abort for {project_id}"); + return; + } + let name = ws + .project(project_id) + .map(|project| project.name.clone()) + .unwrap_or_else(|| project_id.to_string()); + ws.finish_closing_project(project_id); + cx.notify(); + name + }; + log::error!("worktree-close: merge close failed for {project_id}: {error}"); + if let Some(monitor) = hook_monitor { + monitor.push_toast(okena_state::Toast::error(format!( + "\"{project_name}\" was not closed: {error}" + ))); + } +} + +#[allow(clippy::too_many_arguments)] +fn spawn_merge_worktree_close( + project_id: String, + stash: bool, + fetch: bool, + push: bool, + delete_branch: bool, + global_hooks: okena_workspace::persistence::HooksConfig, + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + runtime: &tokio::runtime::Handle, + backend: &Arc, + terminals: &TerminalsRegistry, + settings: &Arc>, + service_manager: &Arc>, + service_tick: &watch::Sender, +) -> CommandResult { + let prep = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + if ws.is_creating_project(&project_id) { + return CommandResult::Err("worktree is still being created".to_string()); + } + if ws.is_project_closing(&project_id) { + return CommandResult::Err("worktree is already closing".to_string()); + } + if let Err(error) = ws.ensure_worktree_removal_claim_allowed(&project_id) { + return CommandResult::Err(error); + } + let Some(project) = ws + .project(&project_id) + .filter(|project| project.worktree_info.is_some()) + else { + return CommandResult::Err(format!("not a worktree project: {project_id}")); + }; + let project_name = project.name.clone(); + let project_path = project.path.clone(); + let project_hooks = project.hooks.clone(); + let main_repo_path = ws.worktree_parent_path(&project_id).unwrap_or_default(); + let folder = ws.folder_for_project_or_parent(&project_id); + let folder_id = folder.map(|folder| folder.id.clone()); + let folder_name = folder.map(|folder| folder.name.clone()); + ws.mark_closing_project_authoritative(&project_id); + cx.notify(); + ( + ws.data_replacement_epoch(), + project_name, + project_path, + project_hooks, + main_repo_path, + folder_id, + folder_name, + ) + }; + + let workspace = workspace.clone(); + let workspace_tick = workspace_tick.clone(); + let hook_runner = hook_runner.clone(); + let hook_monitor = hook_monitor.clone(); + let runtime = runtime.clone(); + let backend = backend.clone(); + let terminals = terminals.clone(); + let settings = settings.clone(); + let service_manager = service_manager.clone(); + let service_tick = service_tick.clone(); + tokio::task::spawn_local(async move { + use okena_workspace::actions::worktree::{ + CloseWorktreeGitOutcome, close_worktree_merge_git, + }; + let ( + operation_epoch, + project_name, + project_path, + project_hooks, + main_repo_path, + folder_id, + folder_name, + ) = prep; + let blocking_project_id = project_id.clone(); + let blocking_global_hooks = global_hooks.clone(); + let blocking_monitor = hook_monitor.clone(); + let outcome = runtime + .spawn_blocking(move || { + use std::path::Path; + let branch = + okena_git::get_current_branch(Path::new(&project_path)).unwrap_or_default(); + let default_branch = + okena_git::get_default_branch(Path::new(&main_repo_path)).unwrap_or_default(); + let is_dirty = okena_git::has_uncommitted_changes(Path::new(&project_path)); + let merge_enabled = + (!is_dirty || stash) && !branch.is_empty() && !default_branch.is_empty(); + if merge_enabled { + close_worktree_merge_git( + stash && is_dirty, + fetch, + push, + delete_branch, + &blocking_project_id, + &project_name, + &project_path, + &branch, + &default_branch, + &main_repo_path, + &project_hooks, + &blocking_global_hooks, + folder_id.as_deref(), + folder_name.as_deref(), + blocking_monitor.as_ref(), + ) + } else { + CloseWorktreeGitOutcome::Ok { did_stash: false } + } + }) + .await; + + if workspace.lock().data_replacement_epoch() != operation_epoch { + log::info!("worktree-close: ignoring stale merge completion for {project_id}"); + return; + } + + let did_stash = match outcome { + Err(error) => { + abort_background_worktree_close( + &project_id, + operation_epoch, + format!("worktree close task failed: {error}"), + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + return; + } + Ok(CloseWorktreeGitOutcome::Err(error)) => { + abort_background_worktree_close( + &project_id, + operation_epoch, + error, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + return; + } + Ok(CloseWorktreeGitOutcome::RebaseConflict { error, hook_plan }) => { + if let Some(hook_plan) = hook_plan { + let outcome = okena_hooks::execute_hook_action_plan( + hook_plan, + hook_monitor.as_ref(), + hook_runner.as_ref(), + ); + let app_settings = settings.lock().clone(); + let mut cx = + DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + if let okena_app_core::workspace::actions::execute::ActionResult::Err( + spawn_error, + ) = apply_deferred_hook_actions( + &mut ws, + &project_id, + outcome, + backend.as_ref(), + &terminals, + &app_settings, + &mut cx, + ) { + log::error!( + "worktree-close: failed to materialize rebase hook terminal for {project_id}: {spawn_error}" + ); + } + } + abort_background_worktree_close( + &project_id, + operation_epoch, + error, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + return; + } + Ok(CloseWorktreeGitOutcome::Ok { did_stash }) => did_stash, + }; + + let plan = { + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + let has_before_remove = ws.project(&project_id).is_some_and(|project| { + project.hooks.worktree.before_remove.is_some() + || global_hooks.worktree.before_remove.is_some() + }); + if has_before_remove { + None + } else { + Some(ws.begin_worktree_removal(&project_id, &global_hooks, &mut cx)) + } + }; + + if let Some(plan) = plan { + match plan { + Ok(plan) => { + let _ = spawn_background_worktree_removal( + plan, + operation_epoch, + did_stash, + &[], + &global_hooks, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &runtime, + ); + } + Err(error) => abort_background_worktree_close( + &project_id, + operation_epoch, + error, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + ), + } + return; + } + + let result = { + let app_settings = settings.lock().clone(); + { + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + ws.finish_closing_project(&project_id); + cx.notify(); + } + let mut focus_manager = FocusManager::new(); + run_main_workspace_action( + ActionRequest::CloseWorktree { + project_id: project_id.clone(), + merge: false, + stash: false, + fetch: false, + push: false, + delete_branch: false, + }, + &workspace, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + }; + if let CommandResult::Err(error) = result { + abort_background_worktree_close( + &project_id, + operation_epoch, + error, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + } + }); + + CommandResult::Ok(Some(serde_json::json!({ "pending": true }))) +} + +/// 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(), + ); + let content_search_permits = Arc::new(Semaphore::new(MAX_CONCURRENT_CONTENT_SEARCHES)); + + loop { + let BridgeMessage { command, reply } = match bridge_rx.recv().await { + Ok(m) => m, + Err(_) => break, + }; + + let command = match 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, + } => { + if let ActionRequest::SendBytes { terminal_id, .. } = &action { + okena_core::latency_probe::daemon_bridge_received(terminal_id); + } + claim_input_resize_owner(&action, &connection_id); + RemoteCommand::Action(action) + } + command => command, + }; + + let command = match command { + RemoteCommand::Action(ActionRequest::SearchContent { + project_id, + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + }) => { + let prepared = { + let workspace = workspace.lock(); + prepare_content_search( + &workspace, + project_id, + query, + case_sensitive, + mode, + max_results, + file_glob, + context_lines, + show_ignored, + ) + }; + match prepared { + Ok(search) => spawn_content_search( + search, + reply, + &runtime, + content_search_permits.clone(), + ), + Err(error) => { + if let Some(reply) = reply { + let _ = reply.send(CommandResult::Err(error)); + } + } + } + continue; + } + 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 } => { + reload_project_services_off_reactor( + &project_id, + &workspace, + &service_manager, + &service_tick, + &runtime, + ) + .await + } + + // ── App-scoped: settings / theme ───────────────────────────── + ActionRequest::GetSettings => daemon_config.get_settings(), + ActionRequest::GetSettingsSchema => get_settings_schema(), + ActionRequest::SetSettings { patch } => { + let outcome = set_settings_with_backend_migration( + patch, + &mut daemon_config, + &backend, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &terminals, + &service_manager, + &service_tick, + &runtime, + &deadlines, + ) + .await; + publish_committed_settings_change(&outcome, &state_version); + outcome.result + } + ActionRequest::GetThemes => daemon_config.get_themes(), + ActionRequest::GetTheme { id } => daemon_config.get_theme(id), + ActionRequest::SetTheme { id } => { + let result = daemon_config.set_theme(id); + publish_config_change_after_success(&result, &state_version); + result + } + ActionRequest::SaveCustomTheme { + id, + config, + activate, + } => { + let result = daemon_config.save_custom_theme(id, config, activate); + publish_config_change_after_success(&result, &state_version); + result + } + + // ── 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()) + } + + // Session parsing, migration, and worktree validation touch + // disk and Git. Keep both the workspace lock and LocalSet + // reactor free until the prepared data is ready to swap. + ActionRequest::LoadSession { name } => { + let app_settings = settings.lock().clone(); + let session_backend = app_settings.session_backend; + let default_shell = app_settings.default_shell.clone(); + replace_workspace_off_reactor( + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &mut focus_manager, + &runtime, + app_settings, + move || { + let loaded = load_session_data_for_shell( + &name, + session_backend, + &default_shell, + )?; + Ok((loaded.data, loaded.stale_terminal_ids)) + }, + ) + .await + } + ActionRequest::ImportWorkspace { path } => { + let app_settings = settings.lock().clone(); + replace_workspace_off_reactor( + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &mut focus_manager, + &runtime, + app_settings, + move || import_workspace_data(&path).map(|data| (data, Vec::new())), + ) + .await + } + + // ── 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 } => finalize_soft_close_now( + &workspace, + &backend, + &terminals, + &workspace_tick, + &hook_runner, + &hook_monitor, + &deadlines, + &terminal_id, + ), + + // ── 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(); + run_main_workspace_action( + ActionRequest::CloseTerminal { + project_id, + terminal_id, + }, + &workspace, + &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(); + run_main_workspace_action( + ActionRequest::CloseTerminal { + project_id, + terminal_id, + }, + &workspace, + &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(); + run_main_workspace_action( + ActionRequest::CloseTerminal { + project_id, + terminal_id, + }, + &workspace, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + } + } + } + } + } + + // ── Create worktree: run the blocking git off the reactor ──── + // `git fetch` + `git worktree add` are network/disk-heavy (up to + // seconds on a cold fetch). Routing them through the synchronous + // `execute_action` path holds the workspace lock the whole time, + // stalling EVERY other daemon action. Split it (mirroring the + // `CloseTerminal` busy-probe): resolve paths under a brief lock, + // run the git on a blocking thread with NO lock held, then do the + // fast workspace mutation (register project + fire on_worktree_create + // + spawn PTYs) under the lock. + ActionRequest::CreateWorktree { + project_id, + branch, + create_branch, + } => { + // Phase 0: resolve paths. Read settings first, then the + // workspace (settings-before-workspace lock order), and drop + // both before the blocking git runs. + let template = settings.lock().worktree.path_template.clone(); + let prepared = { + let ws = workspace.lock(); + ws.project(&project_id).map(|p| { + let (git_root, subdir) = okena_git::resolve_git_root_and_subdir( + std::path::Path::new(&p.path), + ); + let (worktree_path, wt_project_path) = + okena_git::compute_target_paths( + &git_root, &subdir, &template, &branch, + ); + (git_root, worktree_path, wt_project_path) + }) + }; + + match prepared { + None => CommandResult::Err(format!("project not found: {project_id}")), + Some((git_root, worktree_path, wt_project_path)) => { + // OPTIMISTIC CREATE (symmetric with the optimistic close): + // register the worktree row NOW — deferred hooks, no + // terminals, layout stays None so the client renders the + // "Setting up worktree…" placeholder — then return Ok and + // run the slow `git worktree add` checkout in the + // BACKGROUND. Previously the checkout was awaited before the + // row was even created, so its (repo-scaling) duration WAS + // the perceived latency. When the checkout finishes we seed + // the layout + spawn the PTY + fire on_worktree_create; on + // failure we roll the row back + toast. + let app_settings = settings.lock().clone(); + let new_id = { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + let mut ws = workspace.lock(); + let registered = ws.register_worktree_project_deferred_hooks( + &project_id, + &branch, + &git_root, + &worktree_path, + &wt_project_path, + &app_settings.hooks, + WindowId::Main, + &mut cx, + ); + // Mark creating only on success; propagate the + // registration error (parent-missing OR the + // same-branch/path dedupe) to the caller instead of + // masking it as "project not found". + if let Ok(id) = ®istered { + ws.mark_creating_project(id); + } + let operation_epoch = ws.data_replacement_epoch(); + registered.map(|id| (id, operation_epoch)) + }; + match new_id { + Err(e) => CommandResult::Err(e), + Ok((new_id, operation_epoch)) => { + let workspace = workspace.clone(); + let workspace_tick = workspace_tick.clone(); + let hook_runner = hook_runner.clone(); + let hook_monitor = hook_monitor.clone(); + let backend = backend.clone(); + let terminals = terminals.clone(); + let app_settings = app_settings.clone(); + let git_root = git_root.clone(); + let branch = branch.clone(); + let worktree_path = worktree_path.clone(); + let new_id_task = new_id.clone(); + tokio::task::spawn_local(async move { + let git = { + let git_root = git_root.clone(); + let branch = branch.clone(); + let target = + std::path::PathBuf::from(&worktree_path); + tokio::task::spawn_blocking(move || { + let (result, default_branch) = if create_branch { + let default = okena_git::get_default_branch(&git_root); + ( + okena_git::create_worktree_with_start_point( + &git_root, &branch, &target, default.as_deref(), + ), + default, + ) + } else { + ( + okena_git::create_worktree(&git_root, &branch, &target, false), + None, + ) + }; + if result.is_ok() + && let Some(default_branch) = default_branch + { + okena_git::fetch_and_fast_forward( + &git_root, + &target, + &default_branch, + ); + } + result + }) + .await + }; + let stale = workspace.lock().data_replacement_epoch() + != operation_epoch; + if stale { + log::info!( + "worktree-create: ignoring stale completion for {new_id_task}" + ); + if matches!(&git, Ok(Ok(()))) { + let _ = + tokio::task::spawn_blocking(move || { + cleanup_created_worktree_if_unclaimed( + &workspace, + std::path::Path::new( + &worktree_path, + ), + &git_root, + ); + }) + .await; + } + return; + } + match git { + Ok(Ok(())) => { + { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + let mut ws = workspace.lock(); + // Seeds the layout from the parent, then fires on_worktree_create. + ws.fire_worktree_hooks( + &new_id_task, + &app_settings.hooks, + &mut cx, + ); + // Clear creating BEFORE spawning — spawn_uninitialized_terminals + // no-ops while is_creating (guards against spawning into a + // not-yet-checked-out worktree). The checkout is done here, so the + // dir exists and the PTYs must actually spawn. + ws.finish_creating_project(&new_id_task); + let _ = spawn_uninitialized_terminals( + &mut ws, + &new_id_task, + &*backend, + &terminals, + &app_settings, + None, + &mut cx, + ); + ws.notify_data(&mut cx); + } + } + result => { + let msg = match result { + Ok(Err( + okena_git::GitError::WorktreeExists { + path, + }, + )) => format!( + "Directory '{}' already exists", + path.display() + ), + Ok(Err(e)) => e.to_string(), + Err(join) => format!( + "worktree creation task failed: {join}" + ), + Ok(Ok(())) => { + unreachable!("success handled above") + } + }; + // Roll the optimistic row back. Clear creating + // FIRST — remove_stale_worktree skips creating + // projects. + { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + let mut ws = workspace.lock(); + ws.finish_creating_project(&new_id_task); + ws.remove_stale_worktree(&new_id_task); + ws.notify_data(&mut cx); + } + log::error!( + "worktree-create: {branch} failed: {msg}" + ); + if let Some(hm) = &hook_monitor { + hm.push_toast(okena_state::Toast::error( + msg, + )); + } + // A failed git command does not prove ownership of + // anything left at the target, so cleanup is manual. + } + } + }); + // OPTIMISTIC reply: the row exists but the + // checkout is still running in the background, + // so `path` does NOT exist on disk yet. + // `pending: true` is the machine-readable signal + // that callers (REST/CLI/agents) must not treat + // this path as ready — it materializes when the + // background checkout finishes, or the row is + // removed from state (+ a toast) on failure. Old + // clients that ignore unknown fields keep working. + CommandResult::Ok(Some(serde_json::json!({ + "project_id": new_id, + "path": wt_project_path, + "pending": true, + }))) + } + } + } + } + } + + // ── Close worktree: run the blocking git removal off the reactor ─ + // A plain (non-merge) close of a worktree with NO before_remove + // hook does a bare `git worktree remove`, whose expensive status + // checks + directory delete can block for SECONDS on a busy + // worktree (Docker holding files, a large tree), freezing the whole + // UI. Run that git off the command-loop thread: snapshot inputs + + // fire on_worktree_close under a brief lock, remove the git worktree + // on spawn_blocking with NO lock held, then finalize state under the + // lock. Merge closes run their whole Git pipeline in a detached + // task; before_remove-hook closes finish from the PTY-exit loop. + ActionRequest::CloseWorktree { + project_id, + merge, + stash, + fetch, + push, + delete_branch, + } => { + let global_hooks = settings.lock().hooks.clone(); + if merge { + spawn_merge_worktree_close( + project_id, + stash, + fetch, + push, + delete_branch, + global_hooks, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &runtime, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + ) + } else if workspace.lock().is_project_closing(&project_id) { + CommandResult::Err("worktree is already closing".to_string()) + } else { + let plan = { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + let mut ws = workspace.lock(); + let fast = ws.project(&project_id).is_some_and(|project| { + project.worktree_info.is_some() + && project.hooks.worktree.before_remove.is_none() + && global_hooks.worktree.before_remove.is_none() + }); + if fast { + Some(( + ws.data_replacement_epoch(), + ws.begin_worktree_removal( + &project_id, + &global_hooks, + &mut cx, + ), + )) + } else { + None + } + }; + match plan { + None => { + let app_settings = settings.lock().clone(); + run_main_workspace_action( + ActionRequest::CloseWorktree { + project_id, + merge: false, + stash, + fetch, + push, + delete_branch, + }, + &workspace, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &workspace_tick, + &hook_runner, + &hook_monitor, + ) + } + Some((_, Err(error))) => CommandResult::Err(error), + Some((operation_epoch, Ok(plan))) => { + spawn_background_worktree_removal( + plan, + operation_epoch, + false, + &[], + &global_hooks, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &runtime, + ) + } + } + } + } + + // ── Direct worktree removal: preserve its synchronous reply ── + ActionRequest::RemoveWorktreeProject { project_id, force } => { + let trigger = + git_poll_trigger_for_action(&ActionRequest::RemoveWorktreeProject { + project_id: project_id.clone(), + force, + }); + let global_hooks = settings.lock().hooks.clone(); + let app_settings = settings.lock().clone(); + let result = remove_worktree_project_off_reactor_with( + project_id, + force, + global_hooks, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &mut focus_manager, + &backend, + &terminals, + &app_settings, + &service_manager, + &service_tick, + &deadlines, + &runtime, + |roots| { + okena_services::docker_compose::ensure_no_compose_containers_under( + &roots, + ) + .map_err(|error| error.to_string()) + }, + |plan, force| plan.remove(force), + ) + .await; + send_git_poll_trigger_after_success(&result, trigger, &git_poll_trigger_tx); + result + } + + ActionRequest::RenameProjectDirectory { + project_id, + new_name, + } => { + let trigger = + git_poll_trigger_for_action(&ActionRequest::RenameProjectDirectory { + project_id: project_id.clone(), + new_name: new_name.clone(), + }); + let app_settings = settings.lock().clone(); + let result = rename_project_directory_off_reactor_with( + project_id, + new_name, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &app_settings, + &service_manager, + &service_tick, + &deadlines, + &runtime, + |roots| { + okena_services::docker_compose::ensure_no_compose_containers_under( + &roots, + ) + .map_err(|error| error.to_string()) + }, + |plan| plan.execute(), + ) + .await; + send_git_poll_trigger_after_success(&result, trigger, &git_poll_trigger_tx); + result + } + + // ── Default: workspace-scoped action ───────────────────────── + action => { + let git_poll_trigger = git_poll_trigger_for_action(&action); + let presentation_only_window = + matches!(&action, ActionRequest::FocusTerminal { .. }); + // Resolve the action's explicit target window (if any) + // BEFORE moving `action` into `execute_action`. The daemon + // serves only the synthetic main window. FocusTerminal may + // carry an extra UI window through daemon-side validation. + 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(Some(WindowId::Extra(uuid))) if !presentation_only_window => { + CommandResult::Err(format!("window not found: {uuid}")) + } + Ok(_) => { + // 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 result = run_main_workspace_action( + action, + &workspace, + &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 + } + } + } + } + } + + 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(); + run_main_workspace_action( + ActionRequest::Resize { + terminal_id, + cols, + rows, + }, + &workspace, + &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 + // the former GUI-headless 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, + }]; + + // Hook execution history so thin clients can render the hook + // log (the hooks run here on the daemon, not on the client). + let hooks = hook_monitor + .as_ref() + .map(|m| m.history().iter().map(|e| e.to_api()).collect()) + .unwrap_or_default(); + + // 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, + hooks, + ); + + // `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 app_settings = settings.lock().clone(); + let ws = workspace.lock(); + match ensure_terminal(&terminal_id, &terminals, &*backend, &ws, &app_settings) { + Some(term) => { + let (data, sequence) = term.render_snapshot_with_sequence(); + CommandResult::OkSnapshot { data, sequence } + } + None => CommandResult::Err(format!("terminal not found: {terminal_id}")), + } + } + + // ── PastePath ──────────────────────────────────────────────────── + RemoteCommand::PastePath { terminal_id, text } => { + let app_settings = settings.lock().clone(); + let ws = workspace.lock(); + match ensure_terminal(&terminal_id, &terminals, &*backend, &ws, &app_settings) { + Some(term) => { + term.send_paste(&text); + CommandResult::Ok(None) + } + None => CommandResult::Err(format!("terminal not found: {terminal_id}")), + } + } + }; + + if let Some(reply) = reply { + let _ = reply.send(result); + } + } +} + +/// Load replacement data on the blocking pool, then atomically apply it. +#[cfg(test)] +async fn load_workspace_off_reactor( + workspace: &Arc>, + runtime: &tokio::runtime::Handle, + loader: Load, + apply: Apply, +) -> CommandResult +where + T: Send + 'static, + Load: FnOnce() -> Result + Send + 'static, + Apply: FnOnce(&mut Workspace, T) -> CommandResult, +{ + if let Err(error) = ensure_workspace_replacement_allowed(&workspace.lock()) { + return CommandResult::Err(error); + } + + let loaded = match runtime.spawn_blocking(loader).await { + Ok(Ok(loaded)) => loaded, + Ok(Err(error)) => return CommandResult::Err(error), + Err(error) => { + return CommandResult::Err(format!("workspace loader task failed: {error}")); + } + }; + + // `apply` repeats the conflict check while this guard is held, closing the + // race with worktree operations that started while loading was in flight. + apply(&mut workspace.lock(), loaded) +} + +/// Load, reserve, publish, and materialize a workspace without blocking the reactor. +#[allow(clippy::too_many_arguments)] +async fn replace_workspace_off_reactor( + workspace: &Arc>, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + backend: &Arc, + terminals: &TerminalsRegistry, + focus_manager: &mut FocusManager, + runtime: &tokio::runtime::Handle, + app_settings: AppSettings, + loader: Load, +) -> CommandResult +where + Load: FnOnce() -> Result< + ( + okena_workspace::state::WorkspaceData, + Vec, + ), + String, + > + Send + + 'static, +{ + if let Err(error) = ensure_workspace_replacement_allowed(&workspace.lock()) { + return CommandResult::Err(error); + } + + let prepare_settings = app_settings.clone(); + let prepared = match runtime + .spawn_blocking(move || { + let (data, stale_terminal_ids) = loader()?; + Ok::<_, String>(( + prepare_workspace_replacement(data, &prepare_settings), + stale_terminal_ids, + )) + }) + .await + { + Ok(Ok(prepared)) => prepared, + Ok(Err(error)) => return CommandResult::Err(error), + Err(error) => { + return CommandResult::Err(format!("workspace loader task failed: {error}")); + } + }; + + let plan = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut workspace = workspace.lock(); + match begin_workspace_replacement( + &mut workspace, + focus_manager, + prepared.0, + prepared.1, + terminals, + backend.as_ref(), + &mut cx, + ) { + Ok(plan) => plan, + Err(error) => return CommandResult::Err(error), + } + }; + + // The owned local task keeps finalization alive if the request future is + // cancelled. Daemon shutdown observes the transition gate and polls the + // command loop until this task releases it. + let task_workspace = workspace.clone(); + let task_tick = workspace_tick.clone(); + let task_runner = hook_runner.clone(); + let task_monitor = hook_monitor.clone(); + let task_backend = backend.clone(); + let task_runtime = runtime.clone(); + let task = tokio::task::spawn_local(async move { + let materialization_backend = task_backend.clone(); + let recovery_plan = plan.clone(); + let completion = match task_runtime + .spawn_blocking(move || { + materialize_workspace_replacement(plan, materialization_backend.as_ref()) + }) + .await + { + Ok(completion) => completion, + Err(error) => fail_workspace_replacement( + recovery_plan, + format!("workspace materialization task failed: {error}"), + ), + }; + let mut cx = DaemonWorkspaceCx::new(&task_tick, &task_runner, &task_monitor); + let stale = { + let workspace = task_workspace.lock(); + workspace.terminal_backend_migration_epoch() != Some(completion.epoch()) + || workspace.data_replacement_epoch() != completion.epoch() + }; + if stale { + let cleanup_backend = task_backend.clone(); + let _ = task_runtime + .spawn_blocking(move || { + cleanup_stale_workspace_replacement(completion, cleanup_backend.as_ref()); + }) + .await; + return CommandResult::Err("stale workspace replacement completion".to_string()); + } + let mut workspace = task_workspace.lock(); + finish_workspace_replacement(&mut workspace, completion, &mut cx).into_command_result() + }); + match task.await { + Ok(result) => result, + Err(error) => CommandResult::Err(format!("workspace replacement task failed: {error}")), + } +} + +/// Materialize the PTYs for every restored project's uninitialized terminal +/// slots at daemon startup, then fire each restored project's `on_project_open` +/// lifecycle hook. +/// +/// 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(); + + // Hook panels are persisted, but their PTYs are not reconnected. Tear down + // persistent backend sessions before dropping the only ids that own them. + for project_id in &project_ids { + let stale_ids = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + workspace + .lock() + .clear_stale_hook_terminals(project_id, &mut cx) + }; + for terminal_id in stale_ids { + backend.kill(&terminal_id); + terminals.lock().remove(&terminal_id); + } + } + + 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(_) => {} + } + } + + // Fire `on_project_open` for every restored project. `add_project` fires it + // for NEW projects, but the daemon restores existing projects via + // `Workspace::new` (never `add_project`), so without this their lifecycle + // open hook — global or per-project — never runs on restart. Runs AFTER + // terminal materialization so the hook sees a settled registry; the hook's + // own PTY (if any) is registered via `register_hook_results`. + for project_id in &project_ids { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + ws.fire_project_open_hooks(project_id, &app_settings.hooks, &mut cx); + } +} + +/// Backend teardown requested while a workspace action is mutating state. +enum DeferredTerminalKill { + Terminal(String), + Session(TerminalSessionTeardown), +} + +impl DeferredTerminalKill { + fn terminal_id(&self) -> &str { + match self { + Self::Terminal(terminal_id) => terminal_id, + Self::Session(teardown) => &teardown.terminal_id, + } + } +} + +struct DeferredKillBackend<'a> { + backend: &'a dyn TerminalBackend, + pending: Mutex>, +} + +impl<'a> DeferredKillBackend<'a> { + fn new(backend: &'a dyn TerminalBackend) -> Self { + Self { + backend, + pending: Mutex::new(Vec::new()), + } + } + + fn take_pending(&self) -> Vec { + std::mem::take(&mut *self.pending.lock()) + } +} + +impl TerminalBackend for DeferredKillBackend<'_> { + fn transport(&self) -> Arc { + self.backend.transport() + } + + fn create_terminal( + &self, + cwd: &str, + shell: Option<&okena_terminal::shell_config::ShellType>, + ) -> anyhow::Result { + self.backend.create_terminal(cwd, shell) + } + + fn create_terminal_with_plan( + &self, + cwd: &str, + plan: &okena_terminal::backend::TerminalLaunchPlan, + ) -> anyhow::Result { + self.backend.create_terminal_with_plan(cwd, plan) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + cwd: &str, + shell: Option<&okena_terminal::shell_config::ShellType>, + ) -> anyhow::Result { + self.backend.reconnect_terminal(terminal_id, cwd, shell) + } + + fn reconnect_terminal_with_plan( + &self, + terminal_id: &str, + cwd: &str, + plan: &okena_terminal::backend::TerminalLaunchPlan, + ) -> anyhow::Result { + self.backend + .reconnect_terminal_with_plan(terminal_id, cwd, plan) + } + + fn kill(&self, terminal_id: &str) { + self.pending + .lock() + .push(DeferredTerminalKill::Terminal(terminal_id.to_string())); + } + + fn kill_session(&self, teardown: &TerminalSessionTeardown) { + self.pending + .lock() + .push(DeferredTerminalKill::Session(teardown.clone())); + } + + fn flush_teardown(&self) { + self.backend.flush_teardown(); + } + + fn supports_session_backend_reconfiguration(&self) -> bool { + self.backend.supports_session_backend_reconfiguration() + } + + fn begin_session_backend_reconfiguration(&self) -> anyhow::Result<()> { + self.backend.begin_session_backend_reconfiguration() + } + + fn ensure_session_backend_reconfigurable(&self) -> anyhow::Result<()> { + self.backend.ensure_session_backend_reconfigurable() + } + + fn apply_session_backend( + &self, + backend: okena_terminal::session_backend::SessionBackend, + ) -> anyhow::Result<()> { + self.backend.apply_session_backend(backend) + } + + fn cancel_session_backend_reconfiguration(&self) { + self.backend.cancel_session_backend_reconfiguration(); + } + + fn capture_buffer(&self, terminal_id: &str) -> Option { + self.backend.capture_buffer(terminal_id) + } + + fn supports_buffer_capture(&self) -> bool { + self.backend.supports_buffer_capture() + } + + fn is_remote(&self) -> bool { + self.backend.is_remote() + } + + fn get_shell_pid(&self, terminal_id: &str) -> Option { + self.backend.get_shell_pid(terminal_id) + } + + fn get_foreground_shell_pid(&self, terminal_id: &str) -> Option { + self.backend.get_foreground_shell_pid(terminal_id) + } + + fn get_service_pids(&self, terminal_id: &str) -> Vec { + self.backend.get_service_pids(terminal_id) + } + + fn get_batch_service_pids(&self, terminal_ids: &[&str]) -> HashMap> { + self.backend.get_batch_service_pids(terminal_ids) + } +} + +/// Run a workspace-scoped action against the synthetic main window. +/// +/// Shared by the generic default arm and the `CloseTerminal` immediate-close +/// fallbacks. The state mutation and kill-queue drain happen under the workspace +/// lock; PTY teardown happens only after that lock is released. +#[allow(clippy::too_many_arguments)] +fn run_main_workspace_action( + action: ActionRequest, + workspace: &Arc>, + focus_manager: &mut FocusManager, + backend: &Arc, + terminals: &TerminalsRegistry, + app_settings: &AppSettings, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, +) -> CommandResult { + let deferred_backend = DeferredKillBackend::new(&**backend); + let (result, queued_terminal_ids) = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + let result = execute_action( + action, + &mut ws, + WindowId::Main, + focus_manager, + &deferred_backend, + terminals, + app_settings, + &mut cx, + ) + .into_command_result(); + (result, ws.drain_pending_terminal_kills()) + }; + + // The GUI client tears these down from a workspace observer. The daemon has + // no equivalent observer, so it owns the drain while keeping backend waits + // outside the authoritative state lock. + let mut pending = deferred_backend.take_pending(); + for terminal_id in queued_terminal_ids { + if !pending + .iter() + .any(|teardown| teardown.terminal_id() == terminal_id) + { + pending.push(DeferredTerminalKill::Terminal(terminal_id)); + } + } + for teardown in pending { + let terminal_id = teardown.terminal_id().to_string(); + match teardown { + DeferredTerminalKill::Terminal(terminal_id) => backend.kill(&terminal_id), + DeferredTerminalKill::Session(teardown) => backend.kill_session(&teardown), + } + terminals.lock().remove(&terminal_id); + } + + result +} + +#[allow(clippy::too_many_arguments)] +fn finalize_soft_close_now( + workspace: &Arc>, + backend: &Arc, + terminals: &TerminalsRegistry, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + deadlines: &SoftCloseDeadlines, + terminal_id: &str, +) -> CommandResult { + let terminal_ids = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + close_now_flow(deadlines, &mut ws, terminal_id, &mut cx) + }; + for terminal_id in terminal_ids { + backend.kill(&terminal_id); + terminals.lock().remove(&terminal_id); + } + CommandResult::Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{StubBackend, StubTransport, default_settings, empty_workspace_data}; + use okena_core::api::StateResponse; + use okena_terminal::session_backend::SessionBackend; + use std::collections::HashSet; + use std::sync::atomic::{AtomicBool, Ordering}; + + use okena_remote_server::bridge::{BridgeReceiver, BridgeSender, bridge_channel}; + use okena_state::{LayoutNode, WorkspaceData}; + use okena_terminal::backend::TerminalBackend; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use okena_terminal::terminal::{Terminal, TerminalSize}; + 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, + } + } + + struct RecordingMigrationBackend { + events: Arc>>, + session_backend: Mutex, + reconfiguration: AtomicBool, + fail_apply: bool, + fail_reconnect: bool, + } + + impl RecordingMigrationBackend { + fn new( + events: Arc>>, + session_backend: SessionBackend, + fail_apply: bool, + fail_reconnect: bool, + ) -> Self { + Self { + events, + session_backend: Mutex::new(session_backend), + reconfiguration: AtomicBool::new(false), + fail_apply, + fail_reconnect, + } + } + } + + impl TerminalBackend for RecordingMigrationBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("unexpected fresh terminal creation") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + self.events.lock().push(format!( + "reconnect:{:?}:{terminal_id}", + *self.session_backend.lock() + )); + if self.fail_reconnect { + anyhow::bail!("injected reconnect failure"); + } + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.events.lock().push(format!("kill:{terminal_id}")); + } + + fn flush_teardown(&self) { + self.events.lock().push("flush".to_string()); + } + + fn supports_session_backend_reconfiguration(&self) -> bool { + true + } + + fn begin_session_backend_reconfiguration(&self) -> anyhow::Result<()> { + assert!(!self.reconfiguration.swap(true, Ordering::SeqCst)); + self.events.lock().push("begin".to_string()); + Ok(()) + } + + fn ensure_session_backend_reconfigurable(&self) -> anyhow::Result<()> { + assert!(self.reconfiguration.load(Ordering::SeqCst)); + self.events.lock().push("preflight".to_string()); + Ok(()) + } + + fn apply_session_backend(&self, backend: SessionBackend) -> anyhow::Result<()> { + self.events.lock().push(format!("switch:{backend:?}")); + if self.fail_apply { + anyhow::bail!("injected backend switch failure"); + } + *self.session_backend.lock() = backend; + self.reconfiguration.store(false, Ordering::SeqCst); + Ok(()) + } + + fn cancel_session_backend_reconfiguration(&self) { + if self.reconfiguration.swap(false, Ordering::SeqCst) { + self.events.lock().push("cancel".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 workspace_with_migration_terminals(path: &str) -> Workspace { + use okena_state::{HookTerminalEntry, HookTerminalStatus}; + + let mut data = workspace_with_uninitialized_terminal(path); + let project = &mut data.projects[0]; + let Some(LayoutNode::Terminal { terminal_id, .. }) = project.layout.as_mut() else { + panic!("terminal fixture") + }; + *terminal_id = Some("ordinary".to_string()); + project.hook_terminals.insert( + "hook".to_string(), + HookTerminalEntry { + label: "hook".to_string(), + status: HookTerminalStatus::Running, + hook_type: "on_project_open".to_string(), + command: "echo hook".to_string(), + cwd: path.to_string(), + }, + ); + Workspace::new(data) + } + + async fn run_recorded_backend_migration( + fail_store: bool, + fail_apply: bool, + fail_reconnect: bool, + ) -> ( + SettingsUpdateOutcome, + Vec, + Arc>, + Arc>, + std::path::PathBuf, + ) { + let project_dir = + std::env::temp_dir().join(format!("okena-backend-migration-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&project_dir).expect("create migration fixture"); + std::fs::write(project_dir.join("okena.yaml"), "services: []\n") + .expect("write migration services"); + let project_path = project_dir.to_string_lossy().into_owned(); + + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RecordingMigrationBackend::new( + events.clone(), + SessionBackend::None, + fail_apply, + fail_reconnect, + )); + let workspace = Arc::new(Mutex::new(workspace_with_migration_terminals( + &project_path, + ))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let settings = Arc::new(Mutex::new(default_settings())); + settings.lock().session_backend = SessionBackend::None; + let persistence_events = events.clone(); + let mut daemon_config = DaemonConfig::with_persistence( + settings.clone(), + Arc::new(move |_| { + persistence_events.lock().push("store".to_string()); + if fail_store { + Err("injected persistence failure".to_string()) + } else { + Ok(()) + } + }), + ); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (service_tick, _service_rx) = watch::channel(0u64); + let no_hook_runner = None; + let no_hook_monitor = None; + let result = set_settings_with_backend_migration( + serde_json::json!({ "session_backend": "Tmux" }), + &mut daemon_config, + &backend, + &workspace, + &workspace_tick, + &no_hook_runner, + &no_hook_monitor, + &terminals, + &service_manager, + &service_tick, + &tokio::runtime::Handle::current(), + &Arc::new(Mutex::new(HashMap::new())), + ) + .await; + let recorded_events = events.lock().clone(); + (result, recorded_events, workspace, settings, project_dir) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn backend_migration_cleans_before_store_and_materializes_on_new_route() { + let (outcome, events, workspace, settings, project_dir) = + run_recorded_backend_migration(false, false, false).await; + + assert!(matches!(outcome.result, CommandResult::Ok(_))); + assert!(outcome.committed); + assert_eq!( + events, + vec![ + "begin", + "kill:hook", + "kill:ordinary", + "flush", + "preflight", + "store", + "switch:Tmux", + "reconnect:Tmux:ordinary", + ] + ); + assert_eq!(settings.lock().session_backend, SessionBackend::Tmux); + let workspace = workspace.lock(); + assert_eq!(workspace.terminal_backend_migration_epoch(), None); + let project = workspace.project("p1").expect("project survives migration"); + assert!(project.hook_terminals.is_empty()); + assert!(matches!( + project.layout.as_ref(), + Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id), + .. + }) if terminal_id == "ordinary" + )); + drop(workspace); + std::fs::remove_dir_all(project_dir).expect("remove migration fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn backend_migration_save_failure_rematerializes_the_old_route() { + let (outcome, events, workspace, settings, project_dir) = + run_recorded_backend_migration(true, false, false).await; + + assert!( + matches!(outcome.result, CommandResult::Err(ref error) if error.contains("injected persistence failure")) + ); + assert!(!outcome.committed); + assert_eq!( + events, + vec![ + "begin", + "kill:hook", + "kill:ordinary", + "flush", + "preflight", + "store", + "cancel", + "reconnect:None:ordinary", + ] + ); + assert_eq!(settings.lock().session_backend, SessionBackend::None); + let workspace = workspace.lock(); + assert_eq!(workspace.terminal_backend_migration_epoch(), None); + assert!(matches!( + workspace + .project("p1") + .and_then(|project| project.layout.as_ref()), + Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id), + .. + }) if terminal_id == "ordinary" + )); + drop(workspace); + std::fs::remove_dir_all(project_dir).expect("remove migration fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn backend_migration_recovery_failure_still_publishes_committed_settings() { + let (outcome, events, _workspace, settings, project_dir) = + run_recorded_backend_migration(false, false, true).await; + let (state_version, receiver) = watch::channel(9u64); + + publish_committed_settings_change(&outcome, &state_version); + + assert!(matches!( + outcome.result, + CommandResult::Err(ref error) + if error.contains("settings changed, but terminal rematerialization failed") + )); + assert!(outcome.committed); + assert_eq!(*receiver.borrow(), 10); + assert_eq!(settings.lock().session_backend, SessionBackend::Tmux); + assert_eq!( + events, + vec![ + "begin", + "kill:hook", + "kill:ordinary", + "flush", + "preflight", + "store", + "switch:Tmux", + "reconnect:Tmux:ordinary", + ] + ); + std::fs::remove_dir_all(project_dir).expect("remove migration fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn backend_migration_rollback_does_not_publish_settings_change() { + let (outcome, events, _workspace, settings, project_dir) = + run_recorded_backend_migration(false, true, false).await; + let (state_version, receiver) = watch::channel(9u64); + + publish_committed_settings_change(&outcome, &state_version); + + assert!(matches!( + outcome.result, + CommandResult::Err(ref error) if error.contains("injected backend switch failure") + )); + assert!(!outcome.committed); + assert_eq!(*receiver.borrow(), 9); + assert_eq!(settings.lock().session_backend, SessionBackend::None); + assert_eq!( + events, + vec![ + "begin", + "kill:hook", + "kill:ordinary", + "flush", + "preflight", + "store", + "switch:Tmux", + "cancel", + "store", + "reconnect:None:ordinary", + ] + ); + std::fs::remove_dir_all(project_dir).expect("remove migration fixture"); + } + + #[test] + fn backend_migration_guard_releases_the_gate_and_publishes_a_fresh_tick() { + let workspace = Arc::new(Mutex::new(workspace_with_migration_terminals("/tmp"))); + let (workspace_tick, workspace_rx) = watch::channel(0u64); + let migration = workspace + .lock() + .begin_terminal_backend_migration(SessionBackend::None, &ShellType::Default) + .expect("begin migration"); + + drop(TerminalBackendMigrationGuard::new( + workspace.clone(), + workspace_tick, + None, + None, + migration, + )); + + let workspace = workspace.lock(); + assert_eq!(workspace.terminal_backend_migration_epoch(), None); + assert!(matches!( + workspace + .project("p1") + .and_then(|project| project.layout.as_ref()), + Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id), + .. + }) if terminal_id == "ordinary" + )); + assert_eq!(*workspace_rx.borrow(), 1); + } + + 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); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_close_service_recovery_rearms_current_writeback_owner() { + let project_dir = + std::env::temp_dir().join(format!("okena-service-recovery-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&project_dir).expect("create project fixture"); + std::fs::write(project_dir.join("okena.yaml"), "services: []\n") + .expect("write project services"); + + let project_path = project_dir.to_string_lossy().into_owned(); + let mut data = workspace_with_uninitialized_terminal(&project_path); + data.projects[0] + .service_terminals + .insert("stale".to_string(), "old-session".to_string()); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let no_hook_runner = None; + let no_hook_monitor = None; + let mut workspace_value = Workspace::new(data); + let replacement = workspace_value.data().clone(); + let mut workspace_cx = + DaemonWorkspaceCx::new(&workspace_tick, &no_hook_runner, &no_hook_monitor); + workspace_value.replace_data(&mut FocusManager::new(), replacement, &mut workspace_cx); + let current_epoch = workspace_value.data_replacement_epoch(); + let workspace = Arc::new(Mutex::new(workspace_value)); + + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + service_manager.lock().set_project_writeback_owner( + "p1", + "/stale/path", + current_epoch.wrapping_sub(1), + ); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + + let active_service_names = unload_project_services_for_background_removal( + "p1", + &service_manager, + &service_tick, + &runtime, + ); + assert!( + service_manager + .lock() + .service_terminal_writebacks() + .is_empty() + ); + + recover_project_services( + "p1", + &active_service_names, + &workspace, + &service_manager, + &service_tick, + &runtime, + ) + .await + .expect("recover project services"); + + let writebacks = service_manager.lock().service_terminal_writebacks(); + assert_eq!(writebacks.len(), 1); + let writeback = &writebacks[0]; + assert_eq!(writeback.project_id, "p1"); + assert_eq!(writeback.project_path, project_path); + assert_eq!(writeback.data_replacement_epoch, current_epoch); + assert!(writeback.terminal_ids.is_empty()); + + let mut workspace = workspace.lock(); + assert_eq!( + workspace.data_replacement_epoch(), + writeback.data_replacement_epoch + ); + assert_eq!( + workspace.project("p1").map(|project| project.path.as_str()), + Some(project_path.as_str()) + ); + workspace.sync_service_terminals("p1", writeback.terminal_ids.clone(), &mut workspace_cx); + assert!( + workspace + .project("p1") + .expect("project") + .service_terminals + .is_empty() + ); + drop(workspace); + + std::fs::remove_dir_all(project_dir).expect("remove project fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_close_service_recovery_preserves_inverted_manual_intent() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let project_dir = std::env::temp_dir().join(format!( + "okena-service-intent-recovery-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project fixture"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: configured-on\n command: echo configured\n auto_start: true\n - name: manual-on\n command: echo manual\n auto_start: false\n", + ) + .expect("write project services"); + + let project_path = project_dir.to_string_lossy().into_owned(); + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(&project_path), + ))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(RecordingMigrationBackend::new( + Arc::new(Mutex::new(Vec::new())), + SessionBackend::None, + false, + false, + )); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + let current_epoch = workspace.lock().data_replacement_epoch(); + { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + manager.set_project_writeback_owner("p1", &project_path, current_epoch); + manager.load_project_services_prepared( + "p1", + &project_path, + &HashMap::new(), + prepare_project_config(&project_path), + &mut cx, + ); + manager.stop_service("p1", "configured-on", &mut cx); + manager.start_service("p1", "manual-on", &project_path, &mut cx); + } + + let active_service_names = unload_project_services_for_background_removal( + "p1", + &service_manager, + &service_tick, + &runtime, + ); + assert_eq!(active_service_names, vec!["manual-on"]); + + recover_project_services( + "p1", + &active_service_names, + &workspace, + &service_manager, + &service_tick, + &runtime, + ) + .await + .expect("recover project services"); + + let manager = service_manager.lock(); + let statuses: HashMap<&str, &okena_services::manager::ServiceStatus> = manager + .services_for_project("p1") + .into_iter() + .map(|service| (service.definition.name.as_str(), &service.status)) + .collect(); + assert_eq!( + statuses.get("configured-on"), + Some(&&okena_services::manager::ServiceStatus::Stopped) + ); + assert!(matches!( + statuses.get("manual-on"), + Some( + okena_services::manager::ServiceStatus::Starting + | okena_services::manager::ServiceStatus::Running + ) + )); + drop(manager); + + std::fs::remove_dir_all(project_dir).expect("remove project fixture"); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_service_recovery_reports_error_and_retains_writeback_owner() { + let project_dir = std::env::temp_dir().join(format!( + "okena-service-recovery-failure-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create service recovery fixture"); + let project_path = project_dir.to_string_lossy().into_owned(); + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(&project_path), + ))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + + let error = recover_project_services_with_preparer( + "p1", + &["manual-on".to_string()], + &workspace, + &service_manager, + &service_tick, + &runtime, + |_| PreparedProjectConfig::Failed("injected parse failure".to_string()), + ) + .await + .expect_err("service recovery failure is propagated"); + + assert!(error.contains("failed to load recovered services")); + let writebacks = service_manager.lock().service_terminal_writebacks(); + assert_eq!(writebacks.len(), 1); + assert_eq!(writebacks[0].project_id, "p1"); + assert_eq!(writebacks[0].project_path, project_path); + assert_eq!( + writebacks[0].data_replacement_epoch, + workspace.lock().data_replacement_epoch() + ); + std::fs::remove_dir_all(project_dir).expect("remove service recovery fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_close_service_recovery_discards_stale_workspace_epoch() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let project_path = "/captured/project"; + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(project_path), + ))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let task_manager = service_manager.clone(); + let task_tick = service_tick.clone(); + let task_runtime = runtime.clone(); + let recovery = tokio::task::spawn_local(async move { + recover_project_services_with_preparer( + "p1", + &["manual-on".to_string()], + &task_workspace, + &task_manager, + &task_tick, + &task_runtime, + move |_| { + let _ = started_tx.send(()); + release_rx.recv().expect("release service preparation"); + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + } + }, + ) + .await + }); + + started_rx.await.expect("service preparation started"); + { + let mut workspace = workspace.lock(); + let mut replacement = workspace.data().clone(); + replacement.projects[0].path = "/replacement/project".to_string(); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &None, &None); + workspace.replace_data(&mut FocusManager::new(), replacement, &mut cx); + } + release_tx.send(()).expect("release service preparation"); + let error = recovery + .await + .expect("recovery task completed") + .expect_err("stale workspace owner is rejected"); + assert!(error.contains("stale") || error.contains("already owns")); + + let manager = service_manager.lock(); + assert_eq!(manager.project_path("p1"), None); + assert!(manager.service_terminal_writebacks().is_empty()); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_close_service_recovery_discards_stale_manager_ownership() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let project_path = "/captured/project"; + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(project_path), + ))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let task_manager = service_manager.clone(); + let task_tick = service_tick.clone(); + let task_runtime = runtime.clone(); + let recovery = tokio::task::spawn_local(async move { + recover_project_services_with_preparer( + "p1", + &["manual-on".to_string()], + &task_workspace, + &task_manager, + &task_tick, + &task_runtime, + move |_| { + let _ = started_tx.send(()); + release_rx.recv().expect("release service preparation"); + prepare_project_config(project_path) + }, + ) + .await + }); + + started_rx.await.expect("service preparation started"); + { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + manager.load_project_services_prepared( + "p1", + project_path, + &HashMap::new(), + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }, + &mut cx, + ); + } + release_tx.send(()).expect("release service preparation"); + let error = recovery + .await + .expect("recovery task completed") + .expect_err("stale manager owner is rejected"); + assert!(!error.is_empty()); + + let manager = service_manager.lock(); + assert_eq!( + manager.project_path("p1").map(String::as_str), + Some(project_path) + ); + assert!(manager.services_for_project("p1").is_empty()); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_workspace_loader_does_not_stall_local_reactor() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data()))); + let runtime = tokio::runtime::Handle::current(); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let load = tokio::task::spawn_local(async move { + load_workspace_off_reactor( + &task_workspace, + &runtime, + move || { + let _ = started_tx.send(()); + release_rx.recv().map_err(|error| error.to_string())?; + Ok(empty_workspace_data()) + }, + |_workspace, _data| CommandResult::Ok(None), + ) + .await + }); + + started_rx.await.expect("blocking loader started"); + let reactor_progressed = Arc::new(AtomicBool::new(false)); + let progressed = reactor_progressed.clone(); + tokio::task::spawn_local(async move { + tokio::task::yield_now().await; + progressed.store(true, Ordering::Release); + }) + .await + .expect("reactor task completed"); + assert!( + reactor_progressed.load(Ordering::Acquire), + "LocalSet tasks must progress while the loader is blocked" + ); + + release_tx.send(()).expect("release blocking loader"); + assert!(matches!( + load.await.expect("workspace loader task joined"), + CommandResult::Ok(_) + )); + }) + .await; + } + + struct BlockingReplacementBackend { + started: Mutex>>, + release: Mutex>, + } + + impl TerminalBackend for BlockingReplacementBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("replacement must launch its reserved id") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + if let Some(started) = self.started.lock().take() { + let _ = started.send(()); + } + self.release + .lock() + .recv() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(terminal_id.to_string()) + } + + fn kill(&self, _terminal_id: &str) {} + fn supports_buffer_capture(&self) -> bool { + false + } + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + 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() + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_replacement_materialization_keeps_localset_and_workspace_live() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data()))); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let backend: Arc = Arc::new(BlockingReplacementBackend { + started: Mutex::new(Some(started_tx)), + release: Mutex::new(release_rx), + }); + let runtime = tokio::runtime::Handle::current(); + let mut incoming = workspace_with_uninitialized_terminal("/replacement"); + incoming.projects[0].hooks = Default::default(); + + let task_workspace = workspace.clone(); + let task_tick = workspace_tick.clone(); + let task_backend = backend.clone(); + let task_terminals = terminals.clone(); + let task_runtime = runtime.clone(); + let replacement = tokio::task::spawn_local(async move { + let mut focus_manager = FocusManager::new(); + replace_workspace_off_reactor( + &task_workspace, + &task_tick, + &None, + &None, + &task_backend, + &task_terminals, + &mut focus_manager, + &task_runtime, + default_settings(), + move || Ok((incoming, Vec::new())), + ) + .await + }); + + started_rx.await.expect("reserved reconnect started"); + let reader_workspace = workspace.clone(); + let reader = tokio::task::spawn_local(async move { + tokio::task::yield_now().await; + let workspace = reader_workspace.lock(); + assert!(workspace.project("p1").is_some()); + assert!(workspace.terminal_backend_migration_epoch().is_some()); + }); + reader.await.expect("workspace reader progressed"); + + release_tx.send(()).expect("release reconnect"); + assert!(matches!( + replacement.await.expect("replacement task joined"), + CommandResult::Ok(_) + )); + assert_eq!(workspace.lock().terminal_backend_migration_epoch(), None); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_service_reload_keeps_localset_live_and_discards_stale_owner() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let project_path = "/captured/project"; + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(project_path), + ))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + manager.load_project_services_prepared( + "p1", + project_path, + &HashMap::new(), + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }, + &mut cx, + ); + } + + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let task_manager = service_manager.clone(); + let task_tick = service_tick.clone(); + let task_runtime = runtime.clone(); + let reload = tokio::task::spawn_local(async move { + reload_project_services_off_reactor_with_preparer( + "p1", + &task_workspace, + &task_manager, + &task_tick, + &task_runtime, + move |_| { + let _ = started_tx.send(()); + release_rx.recv().expect("release service preparation"); + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + } + }, + ) + .await + }); + + started_rx.await.expect("service preparation started"); + let progressed = Arc::new(AtomicBool::new(false)); + let progressed_task = progressed.clone(); + let progress_manager = service_manager.clone(); + tokio::task::spawn_local(async move { + let _manager = progress_manager.lock(); + progressed_task.store(true, Ordering::Release); + }) + .await + .expect("sibling LocalSet task completed"); + + { + let mut workspace = workspace.lock(); + let mut replacement = workspace.data().clone(); + replacement.projects[0].path = "/replacement/project".to_string(); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &None, &None); + workspace.replace_data(&mut FocusManager::new(), replacement, &mut cx); + } + release_tx.send(()).expect("release service preparation"); + + let result = reload.await.expect("reload task completed"); + assert!(progressed.load(Ordering::Acquire)); + assert!(matches!( + result, + CommandResult::Err(ref error) if error.contains("project changed") + )); + assert_eq!( + service_manager + .lock() + .project_path("p1") + .map(String::as_str), + Some(project_path) + ); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn service_reload_preserves_writeback_owner_and_reports_prepare_failure() { + let project_path = "/project"; + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(project_path), + ))); + let current_epoch = workspace.lock().data_replacement_epoch(); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend: Arc = Arc::new(StubBackend); + let service_manager = Arc::new(Mutex::new(ServiceManager::new(backend, terminals))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + manager.load_project_services_prepared( + "p1", + project_path, + &HashMap::new(), + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }, + &mut cx, + ); + manager.set_project_writeback_owner("p1", project_path, current_epoch); + } + + let result = reload_project_services_off_reactor_with_preparer( + "p1", + &workspace, + &service_manager, + &service_tick, + &runtime, + |_| PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }, + ) + .await; + + assert!(matches!(result, CommandResult::Ok(None))); + assert_eq!( + service_manager.lock().service_terminal_writebacks(), + vec![okena_services::manager::ServiceTerminalWriteback { + project_id: "p1".to_string(), + project_path: project_path.to_string(), + data_replacement_epoch: current_epoch, + terminal_ids: HashMap::new(), + }] + ); + + let result = reload_project_services_off_reactor_with_preparer( + "p1", + &workspace, + &service_manager, + &service_tick, + &runtime, + |_| PreparedProjectConfig::Failed("invalid config".to_string()), + ) + .await; + + assert!(matches!( + result, + CommandResult::Err(ref error) if error == "failed to reload services for project: p1" + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn workspace_conflict_started_during_load_rejects_atomic_swap() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_worktree_child(), + ))); + let runtime = tokio::runtime::Handle::current(); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let backend: Arc = Arc::new(StubBackend); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let settings = default_settings(); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let task_backend = backend.clone(); + let task_terminals = terminals.clone(); + let load = tokio::task::spawn_local(async move { + let mut focus_manager = FocusManager::new(); + load_workspace_off_reactor( + &task_workspace, + &runtime, + move || { + let _ = started_tx.send(()); + release_rx.recv().map_err(|error| error.to_string())?; + Ok(okena_workspace::persistence::LoadedWorkspace { + data: empty_workspace_data(), + stale_terminal_ids: Vec::new(), + }) + }, + |ws, loaded| { + let mut cx = DaemonWorkspaceCx::new( + &workspace_tick, + &None, + &None, + ); + apply_loaded_session( + ws, + &mut focus_manager, + loaded, + &*task_backend, + &task_terminals, + &settings, + &mut cx, + ) + .into_command_result() + }, + ) + .await + }); + + started_rx.await.expect("blocking loader started"); + let protected_epoch = { + let mut ws = workspace.lock(); + ws.mark_creating_project("wt1"); + ws.data_replacement_epoch() + }; + release_tx.send(()).expect("release blocking loader"); + + let result = load.await.expect("workspace loader task joined"); + assert!(matches!( + result, + CommandResult::Err(ref error) + if error == "cannot replace workspace while worktree 'Project wt1' is being created" + )); + let ws = workspace.lock(); + assert!(ws.project("p1").is_some()); + assert!(ws.project("wt1").is_some()); + assert!(ws.is_creating_project("wt1")); + assert_eq!(ws.data_replacement_epoch(), protected_epoch); + }) + .await; + } + + #[test] + fn deferred_hook_terminal_actions_are_materialized_immediately() { + let mut data = workspace_with_worktree_child(); + data.projects[1].layout = None; + let mut workspace = Workspace::new(data); + let backend = RestoringBackend; + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let (workspace_tick, _receiver) = watch::channel(0u64); + let hook_runner = None; + let hook_monitor = None; + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + + let result = apply_deferred_hook_actions( + &mut workspace, + "wt1", + (vec![("git status".to_string(), HashMap::new())], Vec::new()), + &backend, + &terminals, + &default_settings(), + &mut cx, + ); + + assert!(matches!( + result, + okena_app_core::workspace::actions::execute::ActionResult::Ok(_) + )); + assert!(matches!( + workspace.project("wt1").and_then(|project| project.layout.as_ref()), + Some(LayoutNode::Terminal { + terminal_id: Some(id), + .. + }) if id == "restored-terminal" + )); + assert!(terminals.lock().contains_key("restored-terminal")); + } + + #[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); + } + + #[test] + fn stale_create_cleanup_skips_root_claimed_by_replacement_project() { + let claimed_root = std::env::temp_dir().join("replacement-worktree"); + let mut data = workspace_with_worktree_child(); + data.projects[1].path = claimed_root.to_string_lossy().into_owned(); + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + let unnormalized_root = claimed_root.join("subdir").join(".."); + + let result = with_unclaimed_worktree_root(&workspace, &unnormalized_root, || { + panic!("claimed replacement root must not be cleaned") + }); + + assert!(result.is_none()); + } + + #[test] + fn stale_create_cleanup_skips_descendant_claimed_by_replacement_project() { + let worktree_root = std::env::temp_dir().join("replacement-worktree"); + let claimed_project = worktree_root.join("packages").join("app"); + let mut data = workspace_with_worktree_child(); + data.projects[1].path = claimed_project.to_string_lossy().into_owned(); + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + + let result = with_unclaimed_worktree_root(&workspace, &worktree_root, || { + panic!("replacement project below the checkout root must prevent cleanup") + }); + + assert!(result.is_none()); + } + + #[test] + fn stale_create_cleanup_holds_ownership_guard_through_delete() { + let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data()))); + let worktree_root = std::env::temp_dir().join("unclaimed-worktree"); + + let result = with_unclaimed_worktree_root(&workspace, &worktree_root, || { + assert!( + workspace.try_lock().is_none(), + "replacement registration must not interleave after the ownership check" + ); + "cleaned" + }); + + assert_eq!(result, Some("cleaned")); + } + + // ── 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; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn merge_close_rejects_worktree_still_being_created() { + let h = harness(); + { + let mut workspace = h.workspace.lock(); + *workspace = Workspace::new(workspace_with_worktree_child()); + workspace.mark_creating_project("wt1"); + } + 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::CloseWorktree { + project_id: "wt1".into(), + merge: true, + stash: false, + fetch: false, + push: false, + delete_branch: false, + }), + "CloseWorktree", + ) + .await; + + assert!( + matches!(&result, CommandResult::Err(error) if error == "worktree is still being created"), + "mid-create merge close must be rejected: {result:?}" + ); + { + let workspace = workspace_for_assert.lock(); + assert!(workspace.project("wt1").is_some()); + assert!(workspace.is_creating_project("wt1")); + assert!(!workspace.is_project_closing("wt1")); + } + + drop(bridge_tx); + handle.await.expect("loop task joins"); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn non_merge_close_rejects_worktree_already_closing() { + let h = harness(); + { + let mut workspace = h.workspace.lock(); + *workspace = Workspace::new(workspace_with_worktree_child()); + workspace.mark_closing_project_authoritative("wt1"); + } + 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::CloseWorktree { + project_id: "wt1".into(), + merge: false, + stash: false, + fetch: false, + push: false, + delete_branch: false, + }), + "CloseWorktree", + ) + .await; + + assert!(matches!( + &result, + CommandResult::Err(error) if error == "worktree is already closing" + )); + { + let workspace = workspace_for_assert.lock(); + assert!(workspace.project("wt1").is_some()); + assert!(workspace.is_project_closing("wt1")); + } + + drop(bridge_tx); + handle.await.expect("loop task joins"); + }) + .await; + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn merge_close_keeps_command_loop_responsive_during_hook() { + use std::process::Command; + use std::time::Duration; + + let repo = std::env::temp_dir().join(format!( + "okena-close-responsive-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let worktree = repo.with_extension("worktree"); + std::fs::create_dir_all(&repo).expect("create repository directory"); + let git = |cwd: &std::path::Path, args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "test@okena.local"]); + git(&repo, &["config", "user.name", "Okena Test"]); + std::fs::write(repo.join("file.txt"), "base\n").expect("write fixture"); + git(&repo, &["add", "file.txt"]); + git(&repo, &["commit", "-q", "-m", "base"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + worktree.to_str().expect("utf-8 worktree path"), + ], + ); + + let h = harness(); + { + let mut data = workspace_with_worktree_child(); + data.projects[0].path = repo.to_string_lossy().into_owned(); + data.projects[1].path = worktree.to_string_lossy().into_owned(); + let metadata = data.projects[1] + .worktree_info + .as_mut() + .expect("worktree metadata"); + metadata.main_repo_path = repo.to_string_lossy().into_owned(); + metadata.worktree_path = worktree.to_string_lossy().into_owned(); + metadata.branch_name = "feature".into(); + *h.workspace.lock() = Workspace::new(data); + h.settings.lock().hooks.worktree.pre_merge = Some("sleep 1".into()); + } + 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 close = tokio::time::timeout( + Duration::from_millis(500), + request( + &bridge_tx, + RemoteCommand::Action(ActionRequest::CloseWorktree { + project_id: "wt1".into(), + merge: true, + stash: false, + fetch: false, + push: false, + delete_branch: false, + }), + "CloseWorktree", + ), + ) + .await + .expect("close is accepted before the slow hook finishes"); + assert!( + matches!(close, CommandResult::Ok(Some(ref value)) if value["pending"] == true), + "background close must report pending: {close:?}" + ); + + tokio::time::timeout( + Duration::from_millis(500), + request(&bridge_tx, RemoteCommand::GetState, "GetState during close"), + ) + .await + .expect("command loop remains responsive while pre_merge runs"); + assert!(workspace_for_assert.lock().is_project_closing("wt1")); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if workspace_for_assert.lock().project("wt1").is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("background close completes"); + + drop(bridge_tx); + handle.await.expect("loop task joins"); + }) + .await; + + std::fs::remove_dir_all(&repo).ok(); + std::fs::remove_dir_all(&worktree).ok(); + } + + // ── 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, + is_creating: false, + is_closing: false, + }; + 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(), + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocked_content_search_keeps_workspace_live_and_replies_after_release() { + let fixture = + std::env::temp_dir().join(format!("okena-content-search-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&fixture).expect("create content search fixture"); + let fixture_path = fixture.to_str().expect("fixture path is utf-8"); + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(fixture_path), + ))); + let search = { + let workspace = workspace.lock(); + prepare_content_search( + &workspace, + "p1".to_string(), + "needle".to_string(), + false, + "literal".to_string(), + 1000, + None, + 0, + false, + ) + .expect("prepare content search") + }; + + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (reply_tx, reply_rx) = oneshot::channel(); + spawn_content_search_with( + search, + Some(reply_tx), + &tokio::runtime::Handle::current(), + Arc::new(Semaphore::new(1)), + move |_search, cancelled| { + started_tx.send(()).expect("signal blocked search"); + release_rx.recv().expect("release blocked search"); + assert!(!cancelled.load(std::sync::atomic::Ordering::Relaxed)); + CommandResult::Ok(Some(serde_json::json!(["done"]))) + }, + ); + + started_rx.await.expect("content search worker started"); + + let backend: Arc = Arc::new(StubBackend); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let settings = default_settings(); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let mut focus_manager = FocusManager::new(); + let unrelated_result = run_main_workspace_action( + ActionRequest::RenameProject { + project_id: "p1".to_string(), + name: "Renamed while searching".to_string(), + }, + &workspace, + &mut focus_manager, + &backend, + &terminals, + &settings, + &workspace_tick, + &None, + &None, + ); + assert!(matches!(unrelated_result, CommandResult::Ok(None))); + assert_eq!( + workspace + .lock() + .project("p1") + .map(|project| project.name.clone()), + Some("Renamed while searching".to_string()), + "unrelated action can acquire and mutate the workspace" + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(25), async { + tokio::task::yield_now().await; + }) + .await + .is_ok(), + "runtime remains live while search worker is blocked" + ); + + release_tx.send(()).expect("release content search worker"); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), reply_rx) + .await + .expect("content search reply timeout") + .expect("content search reply"); + assert!(matches!(result, CommandResult::Ok(Some(_)))); + + std::fs::remove_dir_all(fixture).expect("remove content search fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn dropped_search_reply_cancels_only_its_worker_and_releases_its_permit() { + let fixture = std::env::temp_dir().join(format!( + "okena-content-search-cancel-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&fixture).expect("create content search fixture"); + let fixture_path = fixture.to_str().expect("fixture path is utf-8"); + let workspace = Workspace::new(workspace_with_uninitialized_terminal(fixture_path)); + let prepare_search = || { + prepare_content_search( + &workspace, + "p1".to_string(), + "needle".to_string(), + false, + "literal".to_string(), + 1000, + None, + 0, + false, + ) + .expect("prepare content search") + }; + let permits = Arc::new(Semaphore::new(2)); + + let (a_started_tx, a_started_rx) = oneshot::channel(); + let (a_cancelled_tx, a_cancelled_rx) = oneshot::channel(); + let (a_reply_tx, a_reply_rx) = oneshot::channel(); + spawn_content_search_with( + prepare_search(), + Some(a_reply_tx), + &tokio::runtime::Handle::current(), + permits.clone(), + move |_search, cancelled| { + a_started_tx.send(()).expect("signal search A started"); + while !cancelled.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + a_cancelled_tx.send(()).expect("signal search A cancelled"); + CommandResult::Ok(None) + }, + ); + a_started_rx.await.expect("search A started"); + + let (b_started_tx, b_started_rx) = oneshot::channel(); + let (b_release_tx, b_release_rx) = std::sync::mpsc::channel(); + let (b_reply_tx, b_reply_rx) = oneshot::channel(); + spawn_content_search_with( + prepare_search(), + Some(b_reply_tx), + &tokio::runtime::Handle::current(), + permits.clone(), + move |_search, cancelled| { + b_started_tx.send(()).expect("signal search B started"); + b_release_rx.recv().expect("release search B"); + assert!( + !cancelled.load(std::sync::atomic::Ordering::Relaxed), + "cancelling search A must not cancel search B" + ); + CommandResult::Ok(Some(serde_json::json!(["b"]))) + }, + ); + b_started_rx.await.expect("search B started"); + + let (c_started_tx, mut c_started_rx) = oneshot::channel(); + let (c_reply_tx, c_reply_rx) = oneshot::channel(); + spawn_content_search_with( + prepare_search(), + Some(c_reply_tx), + &tokio::runtime::Handle::current(), + permits, + move |_search, cancelled| { + assert!(!cancelled.load(std::sync::atomic::Ordering::Relaxed)); + c_started_tx.send(()).expect("signal search C started"); + CommandResult::Ok(Some(serde_json::json!(["c"]))) + }, + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(25), &mut c_started_rx) + .await + .is_err(), + "search C must wait while A and B own both permits" + ); + + drop(a_reply_rx); + tokio::time::timeout(std::time::Duration::from_secs(1), a_cancelled_rx) + .await + .expect("search A cancellation timeout") + .expect("search A observes cancellation"); + tokio::time::timeout(std::time::Duration::from_secs(1), c_started_rx) + .await + .expect("search C start timeout") + .expect("search C starts after A releases its permit"); + + b_release_tx.send(()).expect("release search B"); + let b_result = b_reply_rx.await.expect("search B reply"); + let c_result = c_reply_rx.await.expect("search C reply"); + assert!(matches!(b_result, CommandResult::Ok(Some(_)))); + assert!(matches!(c_result, CommandResult::Ok(Some(_)))); + + std::fs::remove_dir_all(fixture).expect("remove content search fixture"); + } + + /// `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" + ); + } + + // ── PROOF: does a lifecycle hook actually execute in the daemon path? ───── + // + // These two tests are a matched pair driving the SAME real HookRunner + + // HookMonitor + real LocalBackend/PtyManager the daemon builds at boot + // (daemon.rs:199/211). The only variable is the entrypoint. + // + // * `restore_boot_path_fires_on_project_open` drives the actual daemon-boot + // entrypoint (`materialize_uninitialized_terminals`, called from + // `daemon.run()`) against a RESTORED project that has `project.on_open` + // configured. Result: the monitor records exactly one execution -> the + // on_project_open hook fires on restore, via `fire_project_open_hooks` + // (okena-workspace actions/project.rs) — the daemon restores projects + // through `Workspace::new`, never `add_project`, so the boot path needs + // its own fire. + // + // * `add_project_fires_on_project_open` drives `ws.add_project` (the OTHER + // fire_on_project_open call site) with the SAME services. Result: the + // monitor records exactly one `on_project_open` execution -> the firing + // machinery works from both entrypoints. + + /// Build a restored project that BOTH has an uninitialized terminal slot + /// AND a configured `project.on_open` hook, at a real cwd. + fn workspace_restored_with_on_open(path: &str, on_open: &str) -> WorkspaceData { + use okena_state::{HooksConfig, LayoutNode, ProjectData, ProjectHooks}; + 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: HooksConfig { + project: ProjectHooks { + on_open: Some(on_open.to_string()), + on_close: None, + }, + ..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, + is_creating: false, + is_closing: false, + }; + 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(), + } + } + + /// FIXED behavior: the daemon boot path materializes the restored project's + /// terminals AND fires its configured `on_project_open` hook. Uses a real + /// backend + real HookRunner/HookMonitor so the pass reflects genuine + /// execution, not a stub. (This is the same project the pre-fix proof used + /// to demonstrate the break — the assertion is flipped.) + #[test] + fn restore_boot_path_fires_on_project_open() { + use okena_hooks::{HookMonitor, HookRunner}; + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + use okena_workspace::state::LayoutNode; + + 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())); + + // The real services the daemon threads through (daemon.rs:211). + let hook_runner = Some(HookRunner::new(backend.clone(), terminals.clone())); + let hook_monitor = Some(HookMonitor::new()); + + // Restored project carries a PER-PROJECT on_open (global settings empty), + // proving the fire resolves per-project hooks reloaded from workspace.json. + let workspace = Arc::new(Mutex::new(Workspace::new(workspace_restored_with_on_open( + tmp_path, + "echo HOOK_MARKER", + )))); + let settings = Arc::new(Mutex::new(default_settings())); + let (workspace_tick, _wtrx) = watch::channel(0u64); + + materialize_uninitialized_terminals( + &*backend, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &terminals, + &settings, + ); + + // The restored terminal slot was materialized (boot path ran)... + let assigned = { + let ws = workspace.lock(); + match ws + .project("p1") + .expect("p1") + .layout + .as_ref() + .expect("layout") + { + LayoutNode::Terminal { terminal_id, .. } => terminal_id.clone(), + other => panic!("expected Terminal node, got {other:?}"), + } + }; + let assigned = assigned.expect("restored terminal slot got a real id"); + assert!( + terminals.lock().contains_key(&assigned), + "boot path spawned the layout terminal PTY" + ); + + // ...and the restored project's per-project on_project_open hook fired. + let history = hook_monitor.as_ref().unwrap().history(); + assert_eq!( + history.len(), + 1, + "restore must fire on_project_open exactly once, got: {:?}", + history.iter().map(|h| h.hook_type).collect::>() + ); + assert_eq!(history[0].hook_type, "on_project_open"); + + // The fire registered a live hook terminal in the project's map. + assert_eq!( + workspace + .lock() + .project("p1") + .expect("p1") + .hook_terminals + .len(), + 1, + "one live hook terminal registered after boot fire" + ); + } + + /// Restored projects that carry NO `on_open` hook (and no global hook) must + /// NOT fire anything at boot — no spurious hook executions. + #[test] + fn restore_boot_path_no_hook_does_not_fire() { + use okena_hooks::{HookMonitor, HookRunner}; + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + + 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 hook_runner = Some(HookRunner::new(backend.clone(), terminals.clone())); + let hook_monitor = Some(HookMonitor::new()); + + // Restored project with an uninitialized terminal slot but EMPTY hooks. + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_uninitialized_terminal(tmp_path), + ))); + let settings = Arc::new(Mutex::new(default_settings())); // global hooks empty + let (workspace_tick, _wtrx) = watch::channel(0u64); + + materialize_uninitialized_terminals( + &*backend, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &terminals, + &settings, + ); + + assert!( + hook_monitor.as_ref().unwrap().history().is_empty(), + "no hook configured → nothing fires on restore" + ); + assert!( + workspace + .lock() + .project("p1") + .expect("p1") + .hook_terminals + .is_empty(), + "no hook terminals registered when no hook is configured" + ); + } + + /// Stale `hook_terminals` restored from disk (dead PTYs from a prior session) + /// are dropped on boot before the fresh fire registers a live entry — so the + /// map does not accumulate phantoms across restarts. + #[test] + fn restore_boot_path_clears_stale_hook_terminals() { + use okena_hooks::{HookMonitor, HookRunner}; + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + use okena_workspace::state::{HookTerminalEntry, HookTerminalStatus}; + + 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 hook_runner = Some(HookRunner::new(backend.clone(), terminals.clone())); + let hook_monitor = Some(HookMonitor::new()); + + // Restored project has BOTH a persisted (dead) hook terminal AND an + // on_open hook to re-fire. + let mut data = workspace_restored_with_on_open(tmp_path, "echo HOOK_MARKER"); + data.projects[0].hook_terminals.insert( + "stale-dead-id".to_string(), + HookTerminalEntry { + label: "on_project_open".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo old".to_string(), + cwd: tmp_path.to_string(), + }, + ); + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + let settings = Arc::new(Mutex::new(default_settings())); + let (workspace_tick, _wtrx) = watch::channel(0u64); + + materialize_uninitialized_terminals( + &*backend, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &terminals, + &settings, + ); + + let ws = workspace.lock(); + let hooks = &ws.project("p1").expect("p1").hook_terminals; + assert!( + !hooks.contains_key("stale-dead-id"), + "stale persisted hook terminal must be dropped on boot" + ); + assert_eq!( + hooks.len(), + 1, + "exactly one live hook terminal after re-fire" + ); + } + + #[test] + fn restore_boot_path_kills_persistent_stale_hook_session() { + struct RecordingKillBackend { + killed: Arc>>, + } + + impl TerminalBackend for RecordingKillBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("not used") + } + + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("not used") + } + + fn kill(&self, terminal_id: &str) { + self.killed.lock().push(terminal_id.to_string()); + } + + fn supports_buffer_capture(&self) -> bool { + false + } + + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + + 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() + } + } + + use okena_workspace::state::{HookTerminalEntry, HookTerminalStatus}; + + let mut data = workspace_restored_with_on_open("/tmp", ""); + data.projects[0].layout = None; + data.projects[0].hooks = Default::default(); + data.projects[0].hook_terminals.insert( + "persistent-stale-hook".to_string(), + HookTerminalEntry { + label: "on_project_open".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo old".to_string(), + cwd: "/tmp".to_string(), + }, + ); + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + let killed = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RecordingKillBackend { + killed: killed.clone(), + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let settings = Arc::new(Mutex::new(default_settings())); + let (workspace_tick, _wtrx) = watch::channel(0u64); + + materialize_uninitialized_terminals( + &*backend, + &workspace, + &workspace_tick, + &None, + &None, + &terminals, + &settings, + ); + + assert_eq!(killed.lock().as_slice(), &["persistent-stale-hook"]); + assert!( + workspace + .lock() + .project("p1") + .unwrap() + .hook_terminals + .is_empty() + ); + } + + /// CONTRAST (machinery works): calling `add_project` with the SAME real + /// services DOES fire `on_project_open` and records exactly one execution. + /// Proves the failure above is the missing restore trigger, not a broken + /// runner or gpui-gated code path. + #[test] + fn add_project_fires_on_project_open() { + use okena_hooks::{HookMonitor, HookRunner}; + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + + let tmp = std::env::temp_dir(); + let tmp_path = tmp.to_str().expect("temp dir is utf-8").to_string(); + + 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 hook_runner = Some(HookRunner::new(backend.clone(), terminals.clone())); + let hook_monitor = Some(HookMonitor::new()); + + // Global settings carry the on_open hook (add_project builds the new + // ProjectData with empty per-project hooks and resolves against global). + let mut app_settings = default_settings(); + app_settings.hooks.project.on_open = Some("echo HOOK_MARKER".to_string()); + + let mut workspace = Workspace::new(empty_workspace_data()); + let (workspace_tick, _wtrx) = watch::channel(0u64); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + + workspace + .add_project( + "Test".to_string(), + tmp_path, + true, + &app_settings.hooks, + WindowId::Main, + &mut cx, + ) + .expect("add project"); + + let history = hook_monitor.as_ref().unwrap().history(); + assert_eq!( + history.len(), + 1, + "add_project must fire exactly one hook, got: {:?}", + history.iter().map(|h| h.hook_type).collect::>() + ); + assert_eq!(history[0].hook_type, "on_project_open"); + } + + /// `TerminalBackend` that records every `kill`ed id so a test can assert a + /// deleted project's PTYs are torn down. + struct RecordingBackend { + killed: Arc>>, + } + + struct KillBarrierBackend { + killed: Arc>>, + started: std::sync::mpsc::Sender, + release: Mutex>, + } + + 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() + } + } + + impl TerminalBackend for KillBarrierBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("kill barrier backend: create_terminal not supported") + } + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("kill barrier backend: reconnect_terminal not supported") + } + fn kill(&self, terminal_id: &str) { + self.killed.lock().push(terminal_id.to_string()); + self.started + .send(terminal_id.to_string()) + .expect("kill waiter remains alive"); + self.release + .lock() + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("test releases kill barrier"); + } + 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() + } + } + + struct RestoringBackend; + + impl TerminalBackend for RestoringBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + Ok("restored-terminal".to_string()) + } + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("restoring backend: reconnect 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() + } + } + + struct RemovalBarrierBackend { + killed: std::sync::atomic::AtomicBool, + flush_started: std::sync::atomic::AtomicBool, + release: Mutex>, + timeout_result: Option, + } + + struct RenameRecordingBackend { + events: Arc>>, + next_id: std::sync::atomic::AtomicUsize, + fail_after: Option, + } + + struct ReconnectBarrierBackend { + blocked: AtomicBool, + started: Mutex>>, + release: Mutex>, + } + + impl TerminalBackend for ReconnectBarrierBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("recovery must use reserved reconnect") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + if !self.blocked.swap(true, Ordering::SeqCst) { + if let Some(started) = self.started.lock().take() { + let _ = started.send(()); + } + self.release + .lock() + .recv() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + } + Ok(terminal_id.to_string()) + } + + 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() + } + } + + impl TerminalBackend for RenameRecordingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal(&self, cwd: &str, _shell: Option<&ShellType>) -> anyhow::Result { + self.events.lock().push(format!("create:{cwd}")); + let id = self + .next_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.fail_after.is_some_and(|fail_after| id >= fail_after) { + anyhow::bail!("injected terminal creation failure"); + } + Ok(format!("replacement-{id}")) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + self.events.lock().push(format!("create:{cwd}")); + let id = self + .next_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.fail_after.is_some_and(|fail_after| id >= fail_after) { + anyhow::bail!("injected terminal creation failure"); + } + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.events.lock().push(format!("kill:{terminal_id}")); + } + + fn flush_teardown(&self) { + self.events.lock().push("flush".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() + } + } + + impl TerminalBackend for RemovalBarrierBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("removal barrier backend does not create terminals") + } + + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("removal barrier backend does not reconnect terminals") + } + + fn kill(&self, _terminal_id: &str) { + self.killed.store(true, std::sync::atomic::Ordering::SeqCst); + } + + fn flush_teardown(&self) { + assert!( + self.killed.load(std::sync::atomic::Ordering::SeqCst), + "project PTYs must be killed before the teardown barrier" + ); + self.flush_started + .store(true, std::sync::atomic::Ordering::SeqCst); + self.release + .lock() + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("test releases teardown barrier"); + } + + fn flush_teardown_with_timeout( + &self, + _timeout: Duration, + _terminal_ids: &[String], + ) -> bool { + if let Some(result) = self.timeout_result { + assert!( + self.killed.load(std::sync::atomic::Ordering::SeqCst), + "project PTYs must be killed before bounded teardown verification" + ); + self.flush_started + .store(true, std::sync::atomic::Ordering::SeqCst); + result + } else { + self.flush_teardown(); + true + } + } + + 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, + is_creating: false, + is_closing: false, + }; + 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 workspace = Arc::new(Mutex::new(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(), + }, + &workspace, + &mut focus_manager, + &backend, + &terminals, + &settings, + &workspace_tick, + &None, + &None, + ); + + assert!( + matches!(result, CommandResult::Ok(_)), + "delete should succeed: {result:?}" + ); + assert!( + workspace.lock().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" + ); + } + + #[test] + fn generic_terminal_teardown_releases_workspace_before_kill() { + let cases = [ + ( + ActionRequest::DeleteProject { + project_id: "p1".to_string(), + }, + true, + ), + ( + ActionRequest::CloseTerminal { + project_id: "p1".to_string(), + terminal_id: "t1".to_string(), + }, + false, + ), + ]; + + for (action, project_deleted) in cases { + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_initialized_terminal("t1"), + ))); + let killed = Arc::new(Mutex::new(Vec::new())); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let backend: Arc = Arc::new(KillBarrierBackend { + killed: killed.clone(), + started: started_tx, + release: Mutex::new(release_rx), + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let settings = default_settings(); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + + let action_workspace = workspace.clone(); + let action_backend = backend.clone(); + let action_terminals = terminals.clone(); + let action_thread = std::thread::spawn(move || { + let mut focus_manager = FocusManager::new(); + run_main_workspace_action( + action, + &action_workspace, + &mut focus_manager, + &action_backend, + &action_terminals, + &settings, + &workspace_tick, + &None, + &None, + ) + }); + + assert_eq!( + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("backend kill started"), + "t1" + ); + let ws = workspace + .try_lock() + .expect("workspace remains available while backend kill waits"); + assert_eq!(ws.project("p1").is_none(), project_deleted); + if !project_deleted { + assert!( + ws.project("p1") + .is_some_and(|project| project.layout.is_none()), + "terminal close state is visible before PTY teardown completes" + ); + } + drop(ws); + release_tx.send(()).expect("release backend kill"); + + let result = action_thread.join().expect("action thread completes"); + assert!(matches!(result, CommandResult::Ok(_)), "{result:?}"); + assert_eq!(&*killed.lock(), &["t1".to_string()]); + } + } + + #[test] + fn close_now_releases_workspace_before_kill() { + let workspace = Arc::new(Mutex::new(Workspace::new( + workspace_with_initialized_terminal("t1"), + ))); + let deadlines: SoftCloseDeadlines = Arc::new(Mutex::new(HashMap::new())); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let killed = Arc::new(Mutex::new(Vec::new())); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let backend: Arc = Arc::new(KillBarrierBackend { + killed: killed.clone(), + started: started_tx, + release: Mutex::new(release_rx), + }); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + + { + let mut focus_manager = FocusManager::new(); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &None, &None); + workspace.lock().begin_soft_close( + &mut focus_manager, + "p1", + &[], + "t1", + "soft-close:t1", + &mut cx, + ); + deadlines.lock().insert( + "t1".to_string(), + std::time::Instant::now() + std::time::Duration::from_secs(60), + ); + } + + let action_workspace = workspace.clone(); + let action_backend = backend.clone(); + let action_terminals = terminals.clone(); + let action_deadlines = deadlines.clone(); + let action_thread = std::thread::spawn(move || { + finalize_soft_close_now( + &action_workspace, + &action_backend, + &action_terminals, + &workspace_tick, + &None, + &None, + &action_deadlines, + "t1", + ) + }); + + assert_eq!( + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("backend kill started"), + "t1" + ); + assert!( + !workspace + .try_lock() + .expect("workspace remains available while backend kill waits") + .has_pending_close("t1") + ); + assert!(deadlines.lock().is_empty()); + release_tx.send(()).expect("release backend kill"); + + let result = action_thread.join().expect("action thread completes"); + assert!(matches!(result, CommandResult::Ok(_)), "{result:?}"); + assert_eq!(&*killed.lock(), &["t1".to_string()]); + } + + /// A parent project plus a worktree child whose row is present but whose + /// checkout is still being created (marked via `mark_creating_project` by + /// the caller). Mirrors the optimistic-create window where the row exists + /// but `git worktree add` hasn't finished. + fn workspace_with_worktree_child() -> WorkspaceData { + use okena_state::{LayoutNode, ProjectData, WorktreeMetadata}; + let mk = |id: &str, worktree_info: Option, worktree_ids: Vec| { + ProjectData { + id: id.to_string(), + name: format!("Project {id}"), + path: "/tmp".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, + worktree_ids, + 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, + is_creating: false, + is_closing: false, + } + }; + let parent = mk("p1", None, vec!["wt1".to_string()]); + let child = mk( + "wt1", + Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "/tmp".to_string(), + worktree_path: "/tmp/worktrees/wt1".to_string(), + branch_name: String::new(), + }), + Vec::new(), + ); + WorkspaceData { + version: 1, + projects: vec![parent, child], + 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(), + } + } + + fn direct_removal_fixture(label: &str) -> (std::path::PathBuf, Arc>) { + use std::process::Command; + + let fixture = std::env::temp_dir().join(format!( + "okena-direct-remove-{label}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let repo = fixture.join("main"); + let worktree = fixture.join("worktree"); + let git = |cwd: &std::path::Path, args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + std::fs::create_dir_all(&repo).expect("create repository"); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "test@okena.local"]); + git(&repo, &["config", "user.name", "Okena Test"]); + std::fs::write(repo.join("base.txt"), "base\n").expect("write base"); + git(&repo, &["add", "base.txt"]); + git(&repo, &["commit", "-q", "-m", "base"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + worktree.to_str().expect("utf-8 worktree path"), + ], + ); + + let mut data = workspace_with_worktree_child(); + data.projects[0].path = repo.to_string_lossy().into_owned(); + data.projects[1].path = worktree.to_string_lossy().into_owned(); + data.projects[1].layout = Some(LayoutNode::Split { + direction: okena_state::SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![ + LayoutNode::Terminal { + terminal_id: Some("active-in-checkout".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }, + LayoutNode::Terminal { + terminal_id: Some("second-in-checkout".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }, + ], + }); + data.projects[1].hook_terminals.insert( + "running-hook".to_string(), + okena_state::HookTerminalEntry { + label: "Running hook".to_string(), + status: okena_state::HookTerminalStatus::Running, + hook_type: "on_project_open".to_string(), + command: "echo hook".to_string(), + cwd: worktree.to_string_lossy().into_owned(), + }, + ); + data.projects[1] + .terminal_names + .insert("running-hook".to_string(), "Running hook".to_string()); + data.projects[1].hook_terminals.insert( + "completed-hook".to_string(), + okena_state::HookTerminalEntry { + label: "Completed hook".to_string(), + status: okena_state::HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo completed".to_string(), + cwd: worktree.to_string_lossy().into_owned(), + }, + ); + let metadata = data.projects[1] + .worktree_info + .as_mut() + .expect("worktree metadata"); + metadata.main_repo_path = repo.to_string_lossy().into_owned(); + metadata.worktree_path = worktree.to_string_lossy().into_owned(); + metadata.branch_name = "feature".to_string(); + + (fixture, Arc::new(Mutex::new(Workspace::new(data)))) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn direct_worktree_removal_keeps_localset_and_workspace_live() { + let (fixture, workspace) = direct_removal_fixture("reactor"); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (teardown_release_tx, teardown_release_rx) = std::sync::mpsc::channel(); + let backend: Arc = Arc::new(RemovalBarrierBackend { + killed: std::sync::atomic::AtomicBool::new(false), + flush_started: std::sync::atomic::AtomicBool::new(false), + release: Mutex::new(teardown_release_rx), + timeout_result: None, + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let app_settings = default_settings(); + let runtime = tokio::runtime::Handle::current(); + let (started_tx, mut started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let task_tick = workspace_tick.clone(); + let task_backend = backend.clone(); + let task_terminals = terminals.clone(); + let task_runtime = runtime.clone(); + let task_service_manager = service_manager.clone(); + let task_service_tick = service_tick.clone(); + let task_deadlines = deadlines.clone(); + let removal = tokio::task::spawn_local(async move { + let mut focus_manager = FocusManager::new(); + remove_worktree_project_off_reactor_with( + "wt1".to_string(), + true, + Default::default(), + &task_workspace, + &task_tick, + &None, + &None, + &mut focus_manager, + &task_backend, + &task_terminals, + &app_settings, + &task_service_manager, + &task_service_tick, + &task_deadlines, + &task_runtime, + |_| Ok(()), + move |_plan, force| { + assert!(force, "the caller's force flag reaches git removal"); + let _ = started_tx.send(()); + release_rx.recv().map_err(|error| error.to_string())?; + Ok(()) + }, + ) + .await + }); + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut started_rx,) + .await + .is_err(), + "physical removal must wait for terminal teardown flush" + ); + teardown_release_tx + .send(()) + .expect("release terminal teardown"); + started_rx.await.expect("blocking removal started"); + let reader_workspace = workspace.clone(); + tokio::task::spawn_local(async move { + tokio::task::yield_now().await; + assert!(reader_workspace.lock().project("wt1").is_some()); + }) + .await + .expect("sibling LocalSet task reads workspace"); + + release_tx.send(()).expect("release blocking removal"); + assert!(matches!( + removal.await.expect("removal task joined"), + CommandResult::Ok(None) + )); + assert!(workspace.lock().project("wt1").is_none()); + }) + .await; + std::fs::remove_dir_all(fixture).expect("remove fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn direct_worktree_removal_does_not_finalize_into_replaced_workspace() { + let (fixture, workspace) = direct_removal_fixture("stale"); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let backend: Arc = Arc::new(StubBackend); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let app_settings = default_settings(); + let runtime = tokio::runtime::Handle::current(); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task_workspace = workspace.clone(); + let task_tick = workspace_tick.clone(); + let task_backend = backend.clone(); + let task_terminals = terminals.clone(); + let task_runtime = runtime.clone(); + let task_service_manager = service_manager.clone(); + let task_service_tick = service_tick.clone(); + let task_deadlines = deadlines.clone(); + let removal = tokio::task::spawn_local(async move { + let mut focus_manager = FocusManager::new(); + remove_worktree_project_off_reactor_with( + "wt1".to_string(), + false, + Default::default(), + &task_workspace, + &task_tick, + &None, + &None, + &mut focus_manager, + &task_backend, + &task_terminals, + &app_settings, + &task_service_manager, + &task_service_tick, + &task_deadlines, + &task_runtime, + |_| Ok(()), + move |_plan, _force| { + let _ = started_tx.send(()); + release_rx.recv().map_err(|error| error.to_string())?; + Ok(()) + }, + ) + .await + }); + + started_rx.await.expect("blocking removal started"); + { + let mut workspace = workspace.lock(); + let mut replacement = workspace.data().clone(); + replacement.projects[1].name = "Replacement project".to_string(); + let mut focus_manager = FocusManager::new(); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &None, &None); + workspace.replace_data(&mut focus_manager, replacement, &mut cx); + } + release_tx.send(()).expect("release blocking removal"); + + let result = removal.await.expect("removal task joined"); + assert!( + matches!(result, CommandResult::Err(ref error) if error.contains("workspace changed")) + ); + assert_eq!( + workspace + .lock() + .project("wt1") + .map(|project| project.name.as_str()), + Some("Replacement project") + ); + }) + .await; + std::fs::remove_dir_all(fixture).expect("remove fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn dirty_direct_removal_preflight_leaves_runtimes_and_services_untouched() { + let (fixture, workspace) = direct_removal_fixture("dirty-preflight"); + let worktree_path = fixture.join("worktree"); + std::fs::write(worktree_path.join("dirty.txt"), "dirty\n").expect("dirty worktree"); + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RenameRecordingBackend { + events: events.clone(), + next_id: std::sync::atomic::AtomicUsize::new(1), + fail_after: None, + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let runtime = tokio::runtime::Handle::current(); + let settings = default_settings(); + let before_workspace = serde_json::to_value(workspace.lock().data()) + .expect("serialize workspace before preflight"); + let workspace_epoch = workspace.lock().data_replacement_epoch(); + let before_service_token = { + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let mut manager = service_manager.lock(); + let mut cx = reactor_ref.cx(); + manager.set_project_writeback_owner( + "wt1", + &worktree_path.to_string_lossy(), + workspace_epoch, + ); + assert_eq!( + manager.load_project_services_prepared_without_auto_start( + "wt1", + &worktree_path.to_string_lossy(), + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }, + &mut cx, + ), + ServiceLoadStatus::Loaded + ); + manager.project_state_token("wt1") + }; + + let mut focus_manager = FocusManager::new(); + let result = remove_worktree_project_off_reactor_with( + "wt1".to_string(), + false, + Default::default(), + &workspace, + &workspace_tick, + &None, + &None, + &mut focus_manager, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &deadlines, + &runtime, + |_| Ok(()), + |_plan, _force| panic!("dirty checkout must fail before physical removal"), + ) + .await; + + assert!( + matches!(result, CommandResult::Err(ref error) if error.contains("uncommitted changes")), + "{result:?}" + ); + assert!(events.lock().is_empty(), "no terminal teardown or flush"); + assert_eq!( + serde_json::to_value(workspace.lock().data()) + .expect("serialize workspace after preflight"), + before_workspace + ); + let manager = service_manager.lock(); + assert_eq!( + manager.project_path("wt1").map(String::as_str), + Some(worktree_path.to_string_lossy().as_ref()) + ); + assert!(manager.is_project_state_token_current("wt1", &before_service_token)); + drop(manager); + std::fs::remove_dir_all(fixture).expect("remove fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn docker_container_preflight_blocks_removal_before_runtime_mutation() { + let (fixture, workspace) = direct_removal_fixture("docker-preflight"); + let before = serde_json::to_value(workspace.lock().data()).expect("snapshot workspace"); + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RenameRecordingBackend { + events: events.clone(), + next_id: std::sync::atomic::AtomicUsize::new(1), + fail_after: None, + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _) = watch::channel(0u64); + let (service_tick, _) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let mut focus_manager = FocusManager::new(); + + let result = remove_worktree_project_off_reactor_with( + "wt1".to_string(), + true, + Default::default(), + &workspace, + &workspace_tick, + &None, + &None, + &mut focus_manager, + &backend, + &terminals, + &default_settings(), + &service_manager, + &service_tick, + &deadlines, + &tokio::runtime::Handle::current(), + |_| Err("Compose containers still use the checkout".to_string()), + |_plan, _force| panic!("physical removal must not run"), + ) + .await; + + assert!( + matches!(result, CommandResult::Err(ref error) if error.contains("Compose containers")) + ); + assert_eq!( + serde_json::to_value(workspace.lock().data()).expect("snapshot workspace"), + before + ); + assert!(events.lock().is_empty()); + std::fs::remove_dir_all(fixture).expect("remove fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_partial_runtime_recovery_cleans_created_ptys_and_lifecycle() { + let (fixture, workspace) = direct_removal_fixture("partial-recovery"); + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RenameRecordingBackend { + events: events.clone(), + next_id: std::sync::atomic::AtomicUsize::new(1), + fail_after: Some(2), + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let completed_hook = Arc::new(Terminal::new( + "completed-hook".to_string(), + TerminalSize::default(), + backend.transport(), + fixture.join("worktree").to_string_lossy().into_owned(), + )); + completed_hook.process_output(b"retained hook output"); + terminals + .lock() + .insert("completed-hook".to_string(), completed_hook.clone()); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let runtime = tokio::runtime::Handle::current(); + let settings = default_settings(); + let mut focus_manager = FocusManager::new(); + + let result = remove_worktree_project_off_reactor_with( + "wt1".to_string(), + true, + Default::default(), + &workspace, + &workspace_tick, + &None, + &None, + &mut focus_manager, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &deadlines, + &runtime, + |_| Ok(()), + |_plan, _force| Err("injected removal failure".to_string()), + ) + .await; + + assert!( + matches!(result, CommandResult::Err(ref error) + if error.contains("injected removal failure") + && error.contains("terminal recovery failed")), + "{result:?}" + ); + let workspace = workspace.lock(); + let project = workspace + .project("wt1") + .expect("project remains after failure"); + assert!(!workspace.is_project_closing("wt1")); + let recovered_ids = project + .layout + .as_ref() + .expect("layout retained") + .collect_terminal_ids(); + assert_eq!( + recovered_ids.len(), + 1, + "only the failed reservation is cleared" + ); + assert!( + !project.hook_terminals.contains_key("running-hook"), + "cancelled active hook entry is not left dead after recovery" + ); + assert_eq!( + project + .hook_terminals + .get("completed-hook") + .map(|entry| entry.cwd.as_str()), + Some(fixture.join("worktree").to_string_lossy().as_ref()) + ); + drop(workspace); + assert_eq!(terminals.lock().len(), 2); + assert!(terminals.lock().contains_key(&recovered_ids[0])); + assert!(Arc::ptr_eq( + terminals + .lock() + .get("completed-hook") + .expect("completed hook buffer retained"), + &completed_hook + )); + assert!( + String::from_utf8_lossy(&completed_hook.render_snapshot()) + .contains("retained hook output") + ); + let events = events.lock(); + assert!(events.iter().any(|event| event.starts_with("kill:"))); + assert_eq!(events.iter().filter(|event| *event == "flush").count(), 2); + drop(events); + std::fs::remove_dir_all(fixture).expect("remove fixture"); + } + + fn rename_runtime_fixture(label: &str) -> (std::path::PathBuf, Arc>) { + let fixture = std::env::temp_dir().join(format!( + "okena-rename-runtime-{label}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let old_path = fixture.join("old"); + std::fs::create_dir_all(old_path.join("nested")).expect("create rename fixture"); + let mut data = workspace_with_initialized_terminal("live-in-old-directory"); + data.projects[0].path = old_path.to_string_lossy().into_owned(); + data.projects[0].hook_terminals.insert( + "completed-hook".to_string(), + okena_state::HookTerminalEntry { + label: "Completed hook".to_string(), + status: okena_state::HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo completed".to_string(), + cwd: old_path.to_string_lossy().into_owned(), + }, + ); + let mut nested = data.projects[0].clone(); + nested.id = "nested".to_string(); + nested.name = "Nested".to_string(); + nested.path = old_path.join("nested").to_string_lossy().into_owned(); + nested.hook_terminals.clear(); + nested.terminal_names.clear(); + nested.layout = Some(LayoutNode::Terminal { + terminal_id: Some("live-in-nested-directory".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }); + let mut unaffected = data.projects[0].clone(); + unaffected.id = "unaffected".to_string(); + unaffected.name = "Unaffected".to_string(); + unaffected.path = fixture.join("outside").to_string_lossy().into_owned(); + unaffected.hook_terminals.clear(); + unaffected.terminal_names.clear(); + unaffected.layout = Some(LayoutNode::Terminal { + terminal_id: Some("unaffected-live".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }); + data.projects.push(nested); + data.projects.push(unaffected); + data.project_order.push("nested".to_string()); + data.project_order.push("unaffected".to_string()); + (fixture, Arc::new(Mutex::new(Workspace::new(data)))) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn directory_rename_flushes_runtimes_then_materializes_new_path() { + let (fixture, workspace) = rename_runtime_fixture("success"); + let old_path = fixture.join("old"); + let new_path = fixture.join("renamed"); + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RenameRecordingBackend { + events: events.clone(), + next_id: std::sync::atomic::AtomicUsize::new(1), + fail_after: None, + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let completed_hook = Arc::new(Terminal::new( + "completed-hook".to_string(), + TerminalSize::default(), + backend.transport(), + old_path.to_string_lossy().into_owned(), + )); + completed_hook.process_output(b"renamed hook output"); + terminals + .lock() + .insert("completed-hook".to_string(), completed_hook.clone()); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let runtime = tokio::runtime::Handle::current(); + let settings = default_settings(); + + let result = tokio::task::LocalSet::new() + .run_until(rename_project_directory_off_reactor_with( + "p1".to_string(), + "renamed".to_string(), + &workspace, + &workspace_tick, + &None, + &None, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &deadlines, + &runtime, + |_| Ok(()), + { + let events = events.clone(); + move |plan| { + assert_eq!( + events.lock().as_slice(), + &[ + "kill:completed-hook", + "kill:live-in-old-directory", + "kill:live-in-nested-directory", + "flush", + ] + ); + plan.execute() + } + }, + )) + .await; + + assert!(matches!(result, CommandResult::Ok(None)), "{result:?}"); + assert!(!old_path.exists()); + assert!(new_path.exists()); + let workspace = workspace.lock(); + assert_eq!( + workspace.project("p1").map(|project| project.path.as_str()), + Some(new_path.to_string_lossy().as_ref()) + ); + assert_eq!( + workspace + .project("nested") + .map(|project| project.path.as_str()), + Some(new_path.join("nested").to_string_lossy().as_ref()) + ); + assert!(matches!( + workspace + .project("unaffected") + .and_then(|project| project.layout.as_ref()), + Some(LayoutNode::Terminal { + terminal_id: Some(id), + .. + }) if id == "unaffected-live" + )); + assert!(!workspace.is_project_closing("p1")); + assert_eq!( + workspace + .project("p1") + .and_then(|project| project.hook_terminals.get("completed-hook")) + .map(|entry| entry.cwd.as_str()), + Some(new_path.to_string_lossy().as_ref()) + ); + let recovered_id = match workspace + .project("p1") + .and_then(|project| project.layout.as_ref()) + { + Some(LayoutNode::Terminal { + terminal_id: Some(id), + .. + }) => id.clone(), + _ => panic!("renamed terminal was not recovered"), + }; + drop(workspace); + assert!(Arc::ptr_eq( + terminals + .lock() + .get("completed-hook") + .expect("completed hook buffer retained across rename"), + &completed_hook + )); + assert!( + String::from_utf8_lossy(&completed_hook.render_snapshot()) + .contains("renamed hook output") + ); + assert!(terminals.lock().contains_key(&recovered_id)); + assert_eq!( + events.lock().as_slice(), + &[ + "kill:completed-hook".to_string(), + "kill:live-in-old-directory".to_string(), + "kill:live-in-nested-directory".to_string(), + "flush".to_string(), + format!("create:{}", new_path.display()), + format!("create:{}", new_path.join("nested").display()), + ] + ); + std::fs::remove_dir_all(fixture).expect("remove rename fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn directory_rename_reconnect_keeps_localset_and_workspace_live() { + let (fixture, workspace) = rename_runtime_fixture("reconnect-barrier"); + let new_path = fixture.join("renamed"); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let backend: Arc = Arc::new(ReconnectBarrierBackend { + blocked: AtomicBool::new(false), + started: Mutex::new(Some(started_tx)), + release: Mutex::new(release_rx), + }); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let runtime = tokio::runtime::Handle::current(); + let settings = default_settings(); + + tokio::task::LocalSet::new() + .run_until(async { + let task_workspace = workspace.clone(); + let task_backend = backend.clone(); + let task_terminals = terminals.clone(); + let task_service_manager = service_manager.clone(); + let task_tick = workspace_tick.clone(); + let task_service_tick = service_tick.clone(); + let task_deadlines = deadlines.clone(); + let task_runtime = runtime.clone(); + let rename = tokio::task::spawn_local(async move { + rename_project_directory_off_reactor_with( + "p1".to_string(), + "renamed".to_string(), + &task_workspace, + &task_tick, + &None, + &None, + &task_backend, + &task_terminals, + &settings, + &task_service_manager, + &task_service_tick, + &task_deadlines, + &task_runtime, + |_| Ok(()), + |plan| plan.execute(), + ) + .await + }); + + started_rx.await.expect("reconnect reached barrier"); + assert_eq!( + workspace + .try_lock() + .expect("workspace remains unlocked during reconnect") + .project("p1") + .map(|project| project.path.as_str()), + Some(new_path.to_string_lossy().as_ref()) + ); + let sibling_workspace = workspace.clone(); + tokio::task::spawn_local(async move { + tokio::task::yield_now().await; + assert!(sibling_workspace.lock().project("nested").is_some()); + }) + .await + .expect("sibling LocalSet task progressed"); + + release_tx.send(()).expect("release reconnect"); + assert!(matches!( + rename.await.expect("rename task joined"), + CommandResult::Ok(None) + )); + }) + .await; + std::fs::remove_dir_all(fixture).expect("remove rename fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn docker_container_preflight_blocks_rename_before_runtime_mutation() { + let (fixture, workspace) = rename_runtime_fixture("docker-preflight"); + let before = serde_json::to_value(workspace.lock().data()).expect("snapshot workspace"); + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RenameRecordingBackend { + events: events.clone(), + next_id: std::sync::atomic::AtomicUsize::new(1), + fail_after: None, + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _) = watch::channel(0u64); + let (service_tick, _) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + + let result = rename_project_directory_off_reactor_with( + "p1".to_string(), + "renamed".to_string(), + &workspace, + &workspace_tick, + &None, + &None, + &backend, + &terminals, + &default_settings(), + &service_manager, + &service_tick, + &deadlines, + &tokio::runtime::Handle::current(), + |_| Err("Compose containers still use the directory".to_string()), + |_plan| panic!("physical rename must not run"), + ) + .await; + + assert!( + matches!(result, CommandResult::Err(ref error) if error.contains("Compose containers")) + ); + assert_eq!( + serde_json::to_value(workspace.lock().data()).expect("snapshot workspace"), + before + ); + assert!(events.lock().is_empty()); + std::fs::remove_dir_all(fixture).expect("remove rename fixture"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_directory_rename_recovers_old_path_runtime() { + let (fixture, workspace) = rename_runtime_fixture("failure"); + let old_path = fixture.join("old"); + let events = Arc::new(Mutex::new(Vec::new())); + let backend: Arc = Arc::new(RenameRecordingBackend { + events: events.clone(), + next_id: std::sync::atomic::AtomicUsize::new(1), + fail_after: None, + }); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + let (service_tick, _service_rx) = watch::channel(0u64); + let deadlines: SoftCloseDeadlines = Default::default(); + let runtime = tokio::runtime::Handle::current(); + let settings = default_settings(); + + let result = tokio::task::LocalSet::new() + .run_until(rename_project_directory_off_reactor_with( + "p1".to_string(), + "renamed".to_string(), + &workspace, + &workspace_tick, + &None, + &None, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &deadlines, + &runtime, + |_| Ok(()), + |_plan| Err("injected move failure".to_string()), + )) + .await; + + assert!( + matches!(result, CommandResult::Err(ref error) if error == "injected move failure"), + "{result:?}" + ); + let workspace = workspace.lock(); + assert_eq!( + workspace.project("p1").map(|project| project.path.as_str()), + Some(old_path.to_string_lossy().as_ref()) + ); + assert!(!workspace.is_project_closing("p1")); + let recovered_id = match workspace + .project("p1") + .and_then(|project| project.layout.as_ref()) + { + Some(LayoutNode::Terminal { + terminal_id: Some(id), + .. + }) => id.clone(), + _ => panic!("failed rename terminal was not recovered"), + }; + drop(workspace); + assert!(terminals.lock().contains_key(&recovered_id)); + let events = events.lock(); + assert!(events.contains(&format!("create:{}", old_path.display()))); + assert!(events.contains(&format!("create:{}", old_path.join("nested").display()))); + std::fs::remove_dir_all(fixture).expect("remove rename fixture"); + } + + /// A worktree row whose optimistic create is still in flight (`is_creating`) + /// must reject a generic `DeleteProject` action rather than dropping the row + /// mid-checkout — otherwise the delete races the background `git worktree + /// add` and strands an orphaned, git-registered worktree with no row. The + /// guard lives in the generic `delete_project` execute wrapper, so it must + /// hold on this daemon path too. + #[test] + fn delete_project_rejected_while_worktree_creating() { + let backend: Arc = Arc::new(StubBackend); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let workspace = Arc::new(Mutex::new(Workspace::new(workspace_with_worktree_child()))); + // Seed the transient creating state the way the daemon does — a freshly + // constructed `Workspace` starts with an empty lifecycle tracker, so the + // persisted `is_creating` mirror alone would not trip the guard. + workspace.lock().mark_creating_project("wt1"); + 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: "wt1".to_string(), + }, + &workspace, + &mut focus_manager, + &backend, + &terminals, + &settings, + &workspace_tick, + &None, + &None, + ); + + assert!( + matches!(&result, CommandResult::Err(e) if e == "worktree is still being created"), + "mid-create delete must be rejected: {result:?}" + ); + assert!( + workspace.lock().project("wt1").is_some(), + "the worktree row survives the rejected delete" + ); + assert!( + workspace.lock().is_creating_project("wt1"), + "creating flag untouched by the rejected delete" + ); + } + + #[test] + fn stale_background_close_abort_cannot_mutate_reused_project_id() { + let workspace = Arc::new(Mutex::new(Workspace::new(workspace_with_worktree_child()))); + let hook_monitor = Some(okena_hooks::HookMonitor::new()); + let hook_runner = None; + let (workspace_tick, _receiver) = watch::channel(0u64); + + { + let mut ws = workspace.lock(); + ws.mark_closing_project_authoritative("wt1"); + let mut replacement = workspace_with_worktree_child(); + replacement.projects[1].name = "Replacement project".to_string(); + let mut focus_manager = FocusManager::new(); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + ws.replace_data(&mut focus_manager, replacement, &mut cx); + } + + abort_background_worktree_close( + "wt1", + 0, + "old operation failed".to_string(), + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + + let ws = workspace.lock(); + let project = ws.project("wt1").expect("replacement project retained"); + assert_eq!(project.name, "Replacement project"); + assert!(!project.is_closing); + assert!( + hook_monitor + .as_ref() + .expect("monitor") + .drain_pending_toasts() + .is_empty() + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn background_removal_failure_restores_authoritative_project() { + use std::process::Command; + use std::time::Duration; + + let fixture = std::env::temp_dir().join(format!( + "okena-remove-failure-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let repo = fixture.join("main"); + let invalid_worktree = fixture.join("worktree"); + let git = |cwd: &std::path::Path, args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + std::fs::create_dir_all(&repo).expect("create repository"); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "test@okena.local"]); + git(&repo, &["config", "user.name", "Okena Test"]); + std::fs::write(repo.join("base.txt"), "base\n").expect("write base"); + git(&repo, &["add", "base.txt"]); + git(&repo, &["commit", "-q", "-m", "base"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + invalid_worktree.to_str().expect("utf-8 worktree path"), + ], + ); + + let mut data = workspace_with_worktree_child(); + data.projects[0].path = repo.to_string_lossy().into_owned(); + data.projects[1].path = invalid_worktree.to_string_lossy().into_owned(); + let metadata = data.projects[1] + .worktree_info + .as_mut() + .expect("worktree metadata"); + metadata.worktree_path = invalid_worktree.to_string_lossy().into_owned(); + metadata.main_repo_path = repo.to_string_lossy().into_owned(); + metadata.branch_name = "feature".to_string(); + if let Some(LayoutNode::Terminal { terminal_id, .. }) = data.projects[1].layout.as_mut() { + *terminal_id = Some("terminal-1".into()); + } + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + let backend: Arc = Arc::new(RestoringBackend); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let settings = Arc::new(Mutex::new(default_settings())); + let hook_monitor_service = okena_hooks::HookMonitor::new(); + let hook_monitor = Some(hook_monitor_service.clone()); + let hook_runner = None; + let (workspace_tick, _receiver) = watch::channel(0u64); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_receiver) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + let (operation_epoch, plan) = { + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + let operation_epoch = ws.data_replacement_epoch(); + let plan = ws + .begin_worktree_removal("wt1", &Default::default(), &mut cx) + .expect("build removal plan"); + (operation_epoch, plan) + }; + + // Provenance is valid when the plan is built. Replace the checkout + // afterwards; the unrelated directory must never be recursively removed. + std::fs::remove_dir_all(&invalid_worktree).expect("remove checkout directory"); + std::fs::create_dir(&invalid_worktree).expect("create unrelated replacement"); + let sentinel = invalid_worktree.join("must-survive.txt"); + std::fs::write(&sentinel, "unrelated data").expect("write sentinel"); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let result = spawn_background_worktree_removal( + plan, + operation_epoch, + false, + &[], + &Default::default(), + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &runtime, + ); + assert!( + matches!(result, CommandResult::Ok(Some(ref value)) if value["pending"] == true) + ); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if !workspace.lock().is_project_closing("wt1") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("failed removal rolls back"); + }) + .await; + + let workspace_guard = workspace.lock(); + let project = workspace_guard + .project("wt1") + .expect("project row retained"); + assert!(!project.is_closing); + assert!(matches!( + project.layout, + Some(LayoutNode::Terminal { + terminal_id: Some(ref id), + .. + }) if id == "restored-terminal" + )); + drop(workspace_guard); + let expected_service_path = invalid_worktree.to_string_lossy().into_owned(); + assert_eq!( + service_manager.lock().project_path("wt1"), + Some(&expected_service_path), + "failed removal restores service ownership" + ); + assert!(terminals.lock().contains_key("restored-terminal")); + assert_eq!( + std::fs::read_to_string(&sentinel).expect("replacement survives"), + "unrelated data" + ); + assert_eq!( + hook_monitor_service.drain_pending_toasts().len(), + 1, + "failure is surfaced to clients" + ); + std::fs::remove_dir_all(fixture).ok(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn background_removal_waits_for_teardown_then_runs_hooks_in_order() { + use std::process::Command; + use std::time::Duration; + + let repo = std::env::temp_dir().join(format!( + "okena-close-hook-order-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let worktree = repo.with_extension("worktree"); + let marker = repo.with_extension("hooks.log"); + std::fs::create_dir_all(&repo).expect("create repository directory"); + let git = |cwd: &std::path::Path, args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "test@okena.local"]); + git(&repo, &["config", "user.name", "Okena Test"]); + std::fs::write(repo.join("file.txt"), "base\n").expect("write fixture"); + git(&repo, &["add", "file.txt"]); + git(&repo, &["commit", "-q", "-m", "base"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + worktree.to_str().expect("utf-8 worktree path"), + ], + ); + std::fs::write(worktree.join("file.txt"), "dirty\n").expect("dirty worktree"); + + let mut data = workspace_with_worktree_child(); + data.projects[0].path = repo.to_string_lossy().into_owned(); + data.projects[1].path = worktree.to_string_lossy().into_owned(); + let metadata = data.projects[1] + .worktree_info + .as_mut() + .expect("worktree metadata"); + metadata.main_repo_path = repo.to_string_lossy().into_owned(); + metadata.worktree_path = worktree.to_string_lossy().into_owned(); + metadata.branch_name = "feature".into(); + if let Some(LayoutNode::Terminal { terminal_id, .. }) = data.projects[1].layout.as_mut() { + *terminal_id = Some("terminal-1".to_string()); + } + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let barrier_backend = Arc::new(RemovalBarrierBackend { + killed: std::sync::atomic::AtomicBool::new(false), + flush_started: std::sync::atomic::AtomicBool::new(false), + release: Mutex::new(release_rx), + timeout_result: None, + }); + let backend: Arc = barrier_backend.clone(); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let mut settings_value = default_settings(); + settings_value.hooks.worktree.on_dirty_close = Some(format!( + "printf 'dirty\\n' >> '{}'", + marker.to_string_lossy() + )); + settings_value.hooks.worktree.on_close = Some(format!( + "printf 'close\\n' >> '{}'", + marker.to_string_lossy() + )); + let global_hooks = settings_value.hooks.clone(); + let settings = Arc::new(Mutex::new(settings_value)); + let hook_runner = None; + let hook_monitor = None; + let (workspace_tick, _receiver) = watch::channel(0u64); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_receiver) = watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + let (operation_epoch, plan) = { + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + let mut ws = workspace.lock(); + let operation_epoch = ws.data_replacement_epoch(); + let plan = ws + .begin_worktree_removal("wt1", &global_hooks, &mut cx) + .expect("build removal plan"); + (operation_epoch, plan) + }; + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let result = spawn_background_worktree_removal( + plan, + operation_epoch, + false, + &[], + &global_hooks, + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + &backend, + &terminals, + &settings, + &service_manager, + &service_tick, + &runtime, + ); + assert!(matches!( + result, + CommandResult::Ok(Some(ref value)) if value["pending"] == true + )); + + tokio::time::timeout(Duration::from_secs(1), async { + while !barrier_backend + .flush_started + .load(std::sync::atomic::Ordering::SeqCst) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("removal reaches teardown barrier"); + assert!( + worktree.exists(), + "checkout survives while teardown is pending" + ); + assert!( + !marker.exists(), + "close hooks wait behind terminal teardown" + ); + let registration_error = { + let mut cx = + DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + workspace + .lock() + .add_project( + "late claimant".to_string(), + worktree + .join("packages/late") + .to_string_lossy() + .into_owned(), + false, + &global_hooks, + WindowId::Main, + &mut cx, + ) + .unwrap_err() + }; + assert!( + registration_error.contains("active worktree operation"), + "closing lease must reject registration at the teardown barrier" + ); + release_tx.send(()).expect("release teardown barrier"); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if workspace.lock().project("wt1").is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("background removal completes"); + }) + .await; + + assert_eq!( + std::fs::read_to_string(&marker).expect("read hook order"), + "dirty\nclose\n" + ); + std::fs::remove_dir_all(&repo).ok(); + std::fs::remove_file(&marker).ok(); + } + + #[test] + fn successful_config_changes_advance_state_version() { + let (state_version, receiver) = watch::channel(7); + publish_config_change_after_success(&CommandResult::Ok(None), &state_version); + assert_eq!(*receiver.borrow(), 8); + + publish_config_change_after_success( + &CommandResult::Err("invalid settings".to_string()), + &state_version, + ); + assert_eq!(*receiver.borrow(), 8); + } + + // ── PROOF: does the DAEMON side of quick-create-worktree work? ──────────── + // + // Drives the REAL CreateWorktree action end-to-end through `execute_action` + // (the exact arm the daemon command loop dispatches at command_loop.rs:737) + // against a REAL temp git repo, a REAL LocalBackend over + // PtyManager(SessionBackend::None), and REAL HookRunner + HookMonitor (the + // same services daemon.rs:199/211 builds). The parent project HAS a layout + // with a terminal AND a `worktree.on_create` hook configured — mirroring an + // actively-used project the user quick-creates a worktree from. + // + // Asserts BOTH reported symptoms are daemon-side-clean: + // (a) the new worktree project's layout carries a Terminal node with a + // real (Some) terminal_id whose PTY is in the TerminalsRegistry; + // (b) the on_worktree_create hook recorded exactly one execution in the + // HookMonitor AND a live hook terminal was registered on the project. + #[test] + fn create_worktree_materializes_terminal_and_fires_on_worktree_create() { + use okena_hooks::{HookMonitor, HookRunner}; + use okena_state::{HooksConfig, LayoutNode, ProjectData, WorktreeHooks}; + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + use std::process::Command; + + // A real temp git repo with one commit — `git worktree add` needs a base. + let repo = std::env::temp_dir().join(format!( + "okena-wt-proof-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&repo).expect("mk repo dir"); + let git = |args: &[&str]| { + let ok = Command::new("git") + .args(args) + .current_dir(&repo) + .output() + .expect("run git"); + assert!( + ok.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&ok.stderr) + ); + }; + git(&["init", "-q", "-b", "main"]); + git(&["config", "user.email", "proof@okena.test"]); + git(&["config", "user.name", "Proof"]); + std::fs::write(repo.join("README.md"), "seed\n").expect("seed file"); + git(&["add", "."]); + git(&["commit", "-qm", "seed"]); + + // A bare origin remote, like a real user project — the daemon bases new + // worktree branches on origin/{default}, so origin/main must exist. + let origin = repo.with_extension("origin.git"); + std::fs::create_dir_all(&origin).expect("mk origin dir"); + assert!( + Command::new("git") + .args(["init", "-q", "--bare", origin.to_str().unwrap()]) + .status() + .expect("git init bare") + .success() + ); + git(&["remote", "add", "origin", origin.to_str().unwrap()]); + git(&["push", "-q", "-u", "origin", "main"]); + git(&["remote", "set-head", "origin", "main"]); + + let repo_path = repo.to_str().expect("repo path utf-8").to_string(); + + // Parent project: real layout with a materialized terminal (simulating an + // actively-used project) + a per-project worktree.on_create hook. + let parent = ProjectData { + id: "p1".to_string(), + name: "Parent".to_string(), + path: repo_path.clone(), + layout: Some(LayoutNode::Terminal { + terminal_id: Some("parent-term".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: HooksConfig { + worktree: WorktreeHooks { + on_create: Some("echo WT_HOOK_MARKER".to_string()), + ..Default::default() + }, + ..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, + is_creating: false, + is_closing: false, + }; + let data = WorkspaceData { + version: 1, + projects: vec![parent], + 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(), + }; + + // Real daemon services. + 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 hook_runner = Some(HookRunner::new(backend.clone(), terminals.clone())); + let hook_monitor = Some(HookMonitor::new()); + + let mut workspace = Workspace::new(data); + let mut focus_manager = FocusManager::new(); + let settings = default_settings(); // default worktree.path_template + let (workspace_tick, _wtrx) = watch::channel(0u64); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &hook_runner, &hook_monitor); + + // Drive the REAL action the daemon dispatches for quick-create. + let result = execute_action( + ActionRequest::CreateWorktree { + project_id: "p1".to_string(), + branch: "neumie/tezky-medovnik".to_string(), + create_branch: true, + }, + &mut workspace, + WindowId::Main, + &mut focus_manager, + &*backend, + &terminals, + &settings, + &mut cx, + ); + if let okena_app_core::workspace::actions::execute::ActionResult::Err(e) = &result { + panic!("CreateWorktree action failed: {e}"); + } + + // Find the new worktree project (the non-parent one). + let new_id = workspace + .data() + .projects + .iter() + .find(|p| p.id != "p1") + .map(|p| p.id.clone()) + .expect("a new worktree project was created"); + + // (a) INITIAL TERMINAL: layout has a Terminal node with a real id whose + // PTY is in the registry. + let assigned = { + let p = workspace.project(&new_id).expect("new project"); + match p.layout.as_ref().expect("worktree layout present") { + LayoutNode::Terminal { terminal_id, .. } => terminal_id.clone(), + other => panic!("expected Terminal layout node, got {other:?}"), + } + }; + let assigned = assigned.expect("worktree initial terminal got a real id"); + assert!( + terminals.lock().contains_key(&assigned), + "daemon spawned + registered the worktree's initial terminal PTY" + ); + + // (b) HOOK: on_worktree_create ran exactly once and a live hook terminal + // was registered on the new project. + let history = hook_monitor.as_ref().unwrap().history(); + let wt_hooks: Vec<_> = history + .iter() + .filter(|h| h.hook_type == "on_worktree_create") + .collect(); + assert_eq!( + wt_hooks.len(), + 1, + "on_worktree_create must fire exactly once, full history: {:?}", + history.iter().map(|h| h.hook_type).collect::>() + ); + assert_eq!( + workspace + .project(&new_id) + .expect("new project") + .hook_terminals + .len(), + 1, + "one live on_worktree_create hook terminal registered on the worktree project" + ); + + // The hook PTY is a SEPARATE terminal from the initial shell (both live in + // the registry) — proving the hook does NOT consume the initial slot. + assert!( + terminals.lock().len() >= 2, + "initial terminal + hook terminal both in registry" + ); + + // cleanup + std::fs::remove_dir_all(&repo).ok(); + std::fs::remove_dir_all(&origin).ok(); + if let Some(parent) = repo.parent() { + std::fs::remove_dir_all(parent.join(format!( + "{}-wt", + repo.file_name().unwrap().to_string_lossy() + ))) + .ok(); + } + } +} diff --git a/crates/okena-daemon-core/src/daemon.rs b/crates/okena-daemon-core/src/daemon.rs new file mode 100644 index 000000000..44b8ff7a0 --- /dev/null +++ b/crates/okena-daemon-core/src/daemon.rs @@ -0,0 +1,1104 @@ +//! Final GPUI-free daemon assembly shared by both headless entry points. +//! +//! [`DaemonCore`] stands up the workspace, PTY and service managers, git watcher, +//! remote command bridge, and +//! [`RemoteServer`](okena_remote_server::server::RemoteServer) without GPUI. 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 +//! command bridge is driven by [`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 +//! +//! [`run`](DaemonCore::run) blocks until ctrl-c, a bridge failure, or a graceful +//! shutdown request. A UI-owned daemon shuts down when its final owning desktop +//! client hands off ownership. +//! +//! ## 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, HashSet}; +use std::future::Future; +use std::net::IpAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; + +use async_channel::Receiver; +use okena_core::api::{ApiGitStatus, ApiTerminalFocusRequest, 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, TerminalSessionTeardown}; +use okena_terminal::pty_manager::{PtyEvent, PtyManager}; +use okena_terminal::session_backend::{SessionBackend, reconcile_dtach_sessions}; +use okena_workspace::persistence::{self, 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; + +fn workspace_terminal_ids(data: &WorkspaceData) -> HashSet { + data.projects + .iter() + .flat_map(|project| { + let mut ids = project + .layout + .as_ref() + .map_or_else(Vec::new, okena_state::LayoutNode::collect_terminal_ids); + ids.extend(project.service_terminals.values().cloned()); + ids.extend(project.hook_terminals.keys().cloned()); + ids + }) + .collect() +} + +/// The default profile owns the pre-profile shared dtach directory. During the +/// migration window, preserve terminals referenced by every profile before +/// classifying a legacy socket as orphaned. Named profiles reconcile only their +/// isolated directories, so they need no cross-profile state. +fn reconciliation_terminal_ids(data: &WorkspaceData) -> Option> { + let mut retained = workspace_terminal_ids(data); + let Some(active_profile) = okena_core::profiles::try_current() else { + return Some(retained); + }; + if active_profile.id != "default" { + return Some(retained); + } + + let index = match okena_core::profiles::ProfileIndex::load(&active_profile.config_root) { + Ok(index) => index, + Err(error) => { + log::warn!("Skipping dtach reconciliation: cannot read profile index: {error:#}"); + return None; + } + }; + for profile in index + .profiles + .iter() + .filter(|profile| profile.id != active_profile.id) + { + let path = active_profile + .config_root + .join("profiles") + .join(&profile.id) + .join("workspace.json"); + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + log::warn!( + "Skipping dtach reconciliation: cannot read {}: {error}", + path.display() + ); + return None; + } + }; + let profile_workspace: WorkspaceData = match serde_json::from_str(&content) { + Ok(workspace) => workspace, + Err(error) => { + log::warn!( + "Skipping dtach reconciliation: cannot parse {}: {error}", + path.display() + ); + return None; + } + }; + retained.extend(workspace_terminal_ids(&profile_workspace)); + } + Some(retained) +} + +fn kill_stale_terminal_sessions( + backend: &dyn TerminalBackend, + sessions: &[TerminalSessionTeardown], +) { + for session in sessions { + backend.kill_session(session); + } +} + +/// Keep polling the command loop until its provisional backend migration ends. +async fn finish_backend_migration_before_shutdown( + workspace: &Arc>, + workspace_tick: &mut watch::Receiver, + mut command_loop: Pin<&mut F>, +) where + F: Future, +{ + while workspace + .lock() + .terminal_backend_migration_epoch() + .is_some() + { + tokio::select! { + _ = command_loop.as_mut() => return, + changed = workspace_tick.changed() => { + if changed.is_err() { + return; + } + } + } + } +} + +/// Inputs needed to construct a daemon. +pub struct DaemonParams { + /// The persisted workspace state to drive (projects, layouts, windows). + pub workspace_data: WorkspaceData, + /// Persistent sessions whose stale worktree rows were discarded on load. + pub stale_terminal_ids: Vec, + /// 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 TLS is enabled. Non-loopback listeners require it; loopback also + /// accepts plain HTTP for local clients. + pub tls_enabled: bool, + /// Whether desktop clients own this daemon's process lifetime. + pub ui_owned: 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. + /// + /// Stands up the PTY manager + broadcaster, the terminal registry + backend, + /// the workspace + reactor, the settings + config, and 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 { + let mut params = params; + // ── 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()?; + + // The caller loads before it can acquire this lock. Re-read now so an + // outgoing owner cannot save newer authoritative state between that + // initial snapshot and our reconciliation pass. + let workspace_revalidated = match persistence::load_workspace_with_cleanup_for_shell( + params.session_backend, + ¶ms.settings.default_shell, + ) { + Ok(latest) => { + params.workspace_data = latest.data; + params.stale_terminal_ids = latest.stale_terminal_ids; + true + } + Err(error) => { + // The caller snapshot predates this lock and is therefore not + // safe authority for destructive reconciliation. + log::warn!( + "Could not revalidate workspace after acquiring the instance lock; skipping dtach reconciliation and using the caller snapshot: {error:#}" + ); + false + } + }; + + // Reconcile only after acquiring the profile's single-writer lock and + // successfully reloading authoritative state, before starting a PTY + // manager. This closes the crash window where workspace state no longer + // owns a terminal but its persistent dtach process tree survived. + if workspace_revalidated + && let Some(retained_terminal_ids) = reconciliation_terminal_ids(¶ms.workspace_data) + { + reconcile_dtach_sessions(&retained_terminal_ids); + } + + // ── 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())); + kill_stale_terminal_sessions(backend.as_ref(), ¶ms.stale_terminal_ids); + + // ── 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 terminal_focus_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(), + terminal_focus_tx, + remote_subscribed_terminals.clone(), + Some(git_poll_trigger_tx.clone()), + next_connection_id, + active_connections, + shutdown_requested.clone(), + params.ui_owned, + params.tls_enabled, + env!("CARGO_PKG_VERSION"), + )?; + + // ── 7. Print pairing info to stdout ────────────────────────────────── + 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, + mut 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(); + let shutdown_workspace = reactor.workspace.clone(); + let shutdown_backend = backend.clone(); + let shutdown_terminals = terminals.clone(); + let shutdown_pty_manager = pty_manager.clone(); + let shutdown_autosaves = reactor.autosave_tracker.clone(); + 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(), + backend: backend.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.clone(), + git_poll_trigger_rx, + )); + tokio::task::spawn_local(crate::git_poll::run_git_head_poll( + reactor.workspace.clone(), + remote_subscribed_terminals, + git_poll_trigger_tx.clone(), + )); + // 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(), + reactor.state_version.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(), + )); + // Missing-PTY reconciliation deliberately has no hook-duration + // timeout: it only aborts a pending worktree close after the PTY + // manager itself no longer owns that hook terminal. + tokio::task::spawn_local(crate::worktree_close_watchdog::run_worktree_close_watchdog( + reactor.workspace.clone(), + pty_manager.clone(), + reactor.workspace_tick.clone(), + reactor.hook_runner.clone(), + reactor.hook_monitor.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::pin!(cmd); + let interrupted = tokio::select! { + _ = &mut cmd => { + log::info!("daemon command loop ended (remote server gone)"); + false + }, + 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"); + true + } + // 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"); + true + } + }; + if interrupted { + let mut migration_tick = reactor.workspace_tick.subscribe(); + finish_backend_migration_before_shutdown( + &reactor.workspace, + &mut migration_tick, + cmd.as_mut(), + ) + .await; + } + }); + // Cancel every LocalSet task first, then stop accepting new requests. + // The reactor Arc remains available for the final authoritative save. + drop(local); + // Flush BEFORE stopping the server so `remote.json` stays present for the + // whole teardown. Otherwise `RemoteServer::stop` removes the discovery + // file up front, leaving a window where the daemon is alive and still + // holding the instance lock but undiscoverable — a GUI reopening in that + // window can't attach, spawns a fresh daemon that collides on the lock, + // and surfaces "Another Okena instance is already running". The command + // loop is already gone (drop(local)), so no client can mutate state + // during the flush; `stop()` (below) removes the discovery file last, + // right before the instance lock drops as `run()` returns. + flush_shutdown_state( + &shutdown_workspace, + &*shutdown_backend, + &shutdown_terminals, + || shutdown_autosaves.flush(), + || { + // Shutdown only needs the queue to drain; per-terminal session + // verification is the destructive paths' concern. + if !shutdown_pty_manager + .flush_teardown_with_timeout(std::time::Duration::from_secs(5), &[]) + { + log::warn!("terminal teardown still owns a process at daemon shutdown"); + } + }, + persistence::save_workspace, + )?; + remote_server.stop(); + Ok(()) + } +} + +fn flush_shutdown_state( + workspace: &Arc>, + backend: &dyn TerminalBackend, + terminals: &TerminalsRegistry, + flush_autosaves: impl FnOnce(), + flush_teardown: impl FnOnce(), + save: impl FnOnce(&WorkspaceData) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + flush_autosaves(); + let (data, terminal_ids) = { + let mut ws = workspace.lock(); + let terminal_ids: HashSet = ws + .drain_pending_closes() + .into_iter() + .chain(ws.drain_pending_terminal_kills()) + .chain( + ws.projects() + .iter() + .flat_map(|project| project.hook_terminals.keys().cloned()), + ) + .collect(); + (ws.data().clone(), terminal_ids) + }; + + for terminal_id in terminal_ids { + backend.kill(&terminal_id); + terminals.lock().remove(&terminal_id); + } + flush_teardown(); + + save(&data) +} + +#[cfg(test)] +mod shutdown_tests { + use super::*; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use std::sync::atomic::{AtomicBool, Ordering}; + + 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 + } + } + + struct RecordingBackend { + killed: Arc>>, + routed: 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!("not used") + } + + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("not used") + } + + fn kill(&self, terminal_id: &str) { + self.killed.lock().push(terminal_id.to_string()); + } + + fn kill_session(&self, teardown: &TerminalSessionTeardown) { + self.killed.lock().push(teardown.terminal_id.clone()); + self.routed.lock().push(teardown.clone()); + } + + fn supports_buffer_capture(&self) -> bool { + false + } + + fn capture_buffer(&self, _terminal_id: &str) -> Option { + None + } + + 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() + } + } + + #[test] + fn startup_retains_every_workspace_owned_terminal_kind() { + let mut data = WorkspaceData::empty(); + let mut project = okena_state::ProjectData { + id: "p1".to_string(), + name: "Project".to_string(), + path: "/tmp".to_string(), + layout: Some(okena_state::LayoutNode::Terminal { + terminal_id: Some("layout".to_string()), + minimized: false, + detached: false, + shell_type: Default::default(), + zoom_level: 1.0, + }), + terminal_names: HashMap::new(), + hidden_terminals: HashMap::new(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: Default::default(), + is_remote: false, + connection_id: None, + service_terminals: HashMap::from([("web".to_string(), "service".to_string())]), + default_shell: None, + hook_terminals: HashMap::from([( + "hook".to_string(), + okena_state::HookTerminalEntry { + label: "Hook".to_string(), + status: okena_state::HookTerminalStatus::Running, + hook_type: "on_project_open".to_string(), + command: "true".to_string(), + cwd: "/tmp".to_string(), + }, + )]), + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }; + project + .terminal_names + .insert("layout".to_string(), "Shell".to_string()); + data.projects.push(project); + + assert_eq!( + workspace_terminal_ids(&data), + HashSet::from([ + "layout".to_string(), + "service".to_string(), + "hook".to_string(), + ]) + ); + } + + #[test] + fn startup_kills_sessions_owned_by_discarded_worktrees() { + let killed = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + killed: killed.clone(), + routed: Arc::new(Mutex::new(Vec::new())), + }; + + kill_stale_terminal_sessions( + &backend, + &[ + TerminalSessionTeardown::host("layout".to_string()), + TerminalSessionTeardown::host("service".to_string()), + TerminalSessionTeardown::host("hook".to_string()), + ], + ); + + assert_eq!( + *killed.lock(), + vec![ + "layout".to_string(), + "service".to_string(), + "hook".to_string(), + ] + ); + } + + #[cfg(windows)] + #[test] + fn startup_preserves_wsl_route_for_stale_session_cleanup() { + use okena_terminal::backend::TerminalTeardownRoute; + use okena_terminal::session_backend::ResolvedBackend; + + let routed = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + killed: Arc::new(Mutex::new(Vec::new())), + routed: routed.clone(), + }; + let stale = TerminalSessionTeardown { + terminal_id: "wsl-terminal".to_string(), + route: TerminalTeardownRoute::Wsl { + distro: Some("Ubuntu".to_string()), + backend: ResolvedBackend::Dtach, + }, + }; + + kill_stale_terminal_sessions(&backend, std::slice::from_ref(&stale)); + + assert_eq!(routed.lock().as_slice(), &[stale]); + } + + #[test] + fn shutdown_drains_terminal_kills_before_saving() { + let mut data = WorkspaceData::empty(); + let mut project = okena_state::ProjectData { + id: "p1".to_string(), + name: "Project".to_string(), + path: "/tmp".to_string(), + layout: None, + terminal_names: HashMap::new(), + hidden_terminals: HashMap::new(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: Default::default(), + is_remote: false, + connection_id: None, + service_terminals: HashMap::new(), + default_shell: None, + hook_terminals: HashMap::new(), + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }; + project.hook_terminals.insert( + "persistent-hook".to_string(), + okena_state::HookTerminalEntry { + label: "on_project_open".to_string(), + status: okena_state::HookTerminalStatus::Running, + hook_type: "on_project_open".to_string(), + command: "echo hook".to_string(), + cwd: "/tmp".to_string(), + }, + ); + data.projects.push(project); + data.project_order.push("p1".to_string()); + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + workspace.lock().queue_terminal_kills([ + "terminal-a".to_string(), + "terminal-a".to_string(), + "terminal-b".to_string(), + ]); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let killed = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + killed: killed.clone(), + routed: Arc::new(Mutex::new(Vec::new())), + }; + let saved = AtomicBool::new(false); + let flushed = AtomicBool::new(false); + + flush_shutdown_state( + &workspace, + &backend, + &terminals, + || {}, + || { + assert_eq!(killed.lock().len(), 3, "all kills precede teardown flush"); + flushed.store(true, Ordering::Relaxed); + }, + |_| { + assert_eq!(killed.lock().len(), 3, "cleanup precedes final save"); + assert!( + flushed.load(Ordering::Relaxed), + "teardown flush precedes save" + ); + saved.store(true, Ordering::Relaxed); + Ok(()) + }, + ) + .unwrap(); + + assert!(saved.load(Ordering::Relaxed)); + assert!(killed.lock().contains(&"persistent-hook".to_string())); + assert!(workspace.lock().drain_pending_terminal_kills().is_empty()); + } + + #[tokio::test] + async fn shutdown_waits_for_backend_migration_before_final_save() { + let mut data = WorkspaceData::empty(); + data.projects.push(okena_state::ProjectData { + id: "p1".to_string(), + name: "Project".to_string(), + path: "/tmp".to_string(), + layout: Some(okena_state::LayoutNode::Terminal { + terminal_id: Some("ordinary".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }), + terminal_names: HashMap::new(), + hidden_terminals: HashMap::new(), + worktree_info: None, + worktree_ids: Vec::new(), + folder_color: Default::default(), + hooks: Default::default(), + is_remote: false, + connection_id: None, + service_terminals: HashMap::new(), + default_shell: None, + hook_terminals: HashMap::new(), + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }); + data.project_order.push("p1".to_string()); + let workspace = Arc::new(Mutex::new(Workspace::new(data))); + let migration = workspace + .lock() + .begin_terminal_backend_migration(SessionBackend::None, &ShellType::Default) + .expect("begin migration"); + let (workspace_tick, mut migration_tick) = watch::channel(0u64); + let completion_workspace = workspace.clone(); + let completion_tick = workspace_tick.clone(); + let completion_migration = migration.clone(); + let command = async move { + tokio::task::yield_now().await; + let no_hook_runner = None; + let no_hook_monitor = None; + let mut cx = crate::workspace_cx::DaemonWorkspaceCx::new( + &completion_tick, + &no_hook_runner, + &no_hook_monitor, + ); + let mut workspace = completion_workspace.lock(); + workspace + .restore_terminal_backend_migration_slots(&completion_migration) + .expect("restore ownership"); + workspace.finish_terminal_backend_migration(completion_migration.epoch, &mut cx); + }; + tokio::pin!(command); + + finish_backend_migration_before_shutdown(&workspace, &mut migration_tick, command.as_mut()) + .await; + + let backend = RecordingBackend { + killed: Arc::new(Mutex::new(Vec::new())), + routed: Arc::new(Mutex::new(Vec::new())), + }; + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + flush_shutdown_state( + &workspace, + &backend, + &terminals, + || {}, + || {}, + |saved| { + assert!(matches!( + saved.projects[0].layout.as_ref(), + Some(okena_state::LayoutNode::Terminal { + terminal_id: Some(terminal_id), + .. + }) if terminal_id == "ordinary" + )); + Ok(()) + }, + ) + .expect("save restored workspace"); + } + + #[test] + fn shutdown_waits_for_older_autosave_before_final_save() { + let tracker = Arc::new(crate::observers::AutosaveTracker::default()); + let autosave_job = tracker.start(); + let events = Arc::new(Mutex::new(Vec::new())); + let (autosave_started_tx, autosave_started_rx) = std::sync::mpsc::channel(); + let (release_autosave_tx, release_autosave_rx) = std::sync::mpsc::channel(); + let (flush_started_tx, flush_started_rx) = std::sync::mpsc::channel(); + let workspace = Arc::new(Mutex::new(Workspace::new(WorkspaceData::empty()))); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let backend = RecordingBackend { + killed: Arc::new(Mutex::new(Vec::new())), + routed: Arc::new(Mutex::new(Vec::new())), + }; + + std::thread::scope(|scope| { + let autosave_events = events.clone(); + scope.spawn(move || { + autosave_started_tx.send(()).unwrap(); + release_autosave_rx.recv().unwrap(); + autosave_events.lock().push("autosave"); + drop(autosave_job); + }); + autosave_started_rx.recv().unwrap(); + + let shutdown_events = events.clone(); + let shutdown = scope.spawn(move || { + flush_shutdown_state( + &workspace, + &backend, + &terminals, + || { + flush_started_tx.send(()).unwrap(); + tracker.flush(); + }, + || {}, + |_| { + assert_eq!(&*shutdown_events.lock(), &["autosave"]); + shutdown_events.lock().push("final"); + Ok(()) + }, + ) + }); + + flush_started_rx.recv().unwrap(); + assert!( + events.lock().is_empty(), + "final save must wait for autosave" + ); + release_autosave_tx.send(()).unwrap(); + shutdown.join().unwrap().unwrap(); + }); + + assert_eq!(&*events.lock(), &["autosave", "final"]); + } +} 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..726545dce --- /dev/null +++ b/crates/okena-daemon-core/src/daemon_config.rs @@ -0,0 +1,223 @@ +//! 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::{ + DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, ThemeColors, ThemeMode, +}; +use okena_workspace::persistence::AppSettings; +use okena_workspace::settings::save_settings; +use parking_lot::Mutex; +use serde_json::Value; + +type SettingsPersister = Arc Result<(), String> + Send + Sync>; + +/// GPUI-free settings & theme handler backed by a shared +/// `Arc>`. +pub struct DaemonConfig { + settings: Arc>, + persist_settings: SettingsPersister, +} + +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, + persist_settings: Arc::new(|settings| { + save_settings(settings).map_err(|error| error.to_string()) + }), + } + } + + #[cfg(test)] + pub(crate) fn with_persistence( + settings: Arc>, + persist_settings: SettingsPersister, + ) -> Self { + Self { + settings, + persist_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) + } + + /// Validate a patch without persisting it or changing the shared settings. + pub fn preview_settings(&self, patch: Value) -> Result { + remote_config::preview_settings_patch(&self.settings.lock(), patch) + } + + /// Persist and publish settings that were already validated by [`Self::preview_settings`]. + pub fn store_prevalidated_settings(&mut self, settings: &AppSettings) -> CommandResult { + remote_config::store_prevalidated_settings(self, settings) + } + + /// 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. + (self.persist_settings)(new)?; + *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..d4d2b4f57 --- /dev/null +++ b/crates/okena-daemon-core/src/git_poll.rs @@ -0,0 +1,1051 @@ +//! GPUI-free git-status polling for the headless daemon. +//! +//! Projects visible in any window, or owning a terminal subscribed by a remote +//! client, stay on the responsive tier: HEAD every 250ms and full status every +//! 5s. Hidden, unsubscribed repositories use bounded fallback cadences (2s HEAD, +//! 30s full status). Explicit actions and detected HEAD changes still trigger an +//! immediate targeted refresh. Cached statuses for projects not selected in a +//! cycle remain published, so tiering changes freshness rather than visibility. + +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, HeadSnapshot}; +use okena_workspace::state::Workspace; +use parking_lot::Mutex; +use tokio::sync::{mpsc, watch}; + +/// Responsive full-status cadence for visible or remotely subscribed projects. +const GIT_POLL_INTERVAL: Duration = Duration::from_secs(5); +/// Hidden projects receive a full fallback scan every 6 responsive cycles (30s). +const HIDDEN_GIT_POLL_EVERY_N_CYCLES: u64 = 6; +/// Responsive HEAD cadence for visible or remotely subscribed projects. +const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(250); +/// Hidden projects receive a cheap HEAD fallback scan every 8 ticks (2s). +const HIDDEN_HEAD_POLL_EVERY_N_TICKS: u64 = 8; +/// 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 { + /// HEAD changed locally; invalidates in-flight results from the old commit. + head_change_ids: HashSet, + /// 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); + } else { + self.head_change_ids.insert(project_id); + } + } + + fn local_status_ids(&self) -> HashSet { + self.head_change_ids + .iter() + .chain(&self.force_gh_ids) + .chain(&self.candidate_gh_ids) + .cloned() + .collect() + } + + fn clear(&mut self) { + self.head_change_ids.clear(); + self.force_gh_ids.clear(); + self.candidate_gh_ids.clear(); + self.invalidate_gh_ids.clear(); + } +} + +struct GithubPollResult { + head_generations: HashMap, + branches: HashMap>, + pr_infos: HashMap>, + ci_checks: HashMap>, +} + +fn relevant_project_ids( + workspace: &Workspace, + remote_subscribed_terminals: &RwLock>>, +) -> HashSet { + let mut relevant = workspace.all_visible_project_ids(); + if let Ok(subscribed) = remote_subscribed_terminals.read() { + for terminal_ids in subscribed.values() { + for terminal_id in terminal_ids { + if let Some(project) = workspace.find_project_for_terminal(terminal_id) + && !project.is_remote + { + relevant.insert(project.id.clone()); + } + } + } + } + relevant +} + +fn select_status_poll_ids( + active_ids: &HashSet, + relevant_ids: &HashSet, + forced_ids: &HashSet, + newly_relevant_ids: &HashSet, + cadence_due: bool, + poll_hidden: bool, +) -> HashSet { + if poll_hidden { + return active_ids.clone(); + } + + let mut selected = HashSet::new(); + if cadence_due { + selected.extend(relevant_ids.iter().cloned()); + } + selected.extend(forced_ids.iter().cloned()); + selected.extend(newly_relevant_ids.iter().cloned()); + selected.retain(|id| active_ids.contains(id)); + selected +} + +fn merge_status_results( + previous: &HashMap, + active_ids: &HashSet, + attempted: HashMap>, +) -> HashMap { + let mut merged = previous.clone(); + merged.retain(|id, _| active_ids.contains(id)); + for (id, status) in attempted { + match status { + Some(status) if active_ids.contains(&id) => { + merged.insert(id, status); + } + _ => { + merged.remove(&id); + } + } + } + merged +} + +/// Poll only each repository's symbolic HEAD and commit id, waking the full +/// status loop when either changes. This never reads the index or worktree. +pub async fn run_git_head_poll( + workspace: Arc>, + remote_subscribed_terminals: Arc>>>, + trigger_tx: mpsc::UnboundedSender, +) { + let mut previous = HashMap::::new(); + let mut tick = 0u64; + let mut interval = tokio::time::interval(HEAD_POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + if trigger_tx.is_closed() { + return; + } + + let (projects, relevant_ids): (Vec<(String, String)>, HashSet) = { + let workspace = workspace.lock(); + let relevant = relevant_project_ids(&workspace, &remote_subscribed_terminals); + let projects = workspace + .projects() + .iter() + .filter(|project| !project.is_remote) + .map(|project| (project.id.clone(), project.path.clone())) + .collect(); + (projects, relevant) + }; + let active_ids: HashSet = projects.iter().map(|(id, _)| id.clone()).collect(); + let poll_hidden = tick.is_multiple_of(HIDDEN_HEAD_POLL_EVERY_N_TICKS); + tick = tick.wrapping_add(1); + let projects: Vec<_> = projects + .into_iter() + .filter(|(id, _)| poll_hidden || relevant_ids.contains(id)) + .collect(); + let snapshots = tokio::task::spawn_blocking(move || { + projects + .into_iter() + .filter_map(|(id, path)| { + with_lane(Lane::Poll, || git::get_head_snapshot(Path::new(&path))) + .map(|snapshot| (id, snapshot)) + }) + .collect() + }) + .await; + let Ok(snapshots) = snapshots else { + log::warn!("git HEAD poll task panicked"); + continue; + }; + + // `active_ids` deliberately includes unsampled hidden projects so their + // prior snapshots survive fast-tier ticks and later changes are detected. + for id in update_head_snapshots(&mut previous, &active_ids, snapshots) { + if trigger_tx.send(GitPollTrigger::head_change(id)).is_err() { + return; + } + } + } +} + +fn update_head_snapshots( + previous: &mut HashMap, + active_ids: &HashSet, + snapshots: HashMap, +) -> Vec { + previous.retain(|id, _| active_ids.contains(id)); + snapshots + .into_iter() + .filter_map(|(id, snapshot)| { + let changed = previous.get(&id).is_some_and(|old| old != &snapshot); + if changed { + previous.insert(id.clone(), snapshot); + Some(id) + } else { + previous.insert(id, snapshot); + None + } + }) + .collect() +} + +async fn poll_github( + projects: Vec<(String, String)>, + pr_poll_ids: HashSet, + ci_poll_ids: HashSet, + cached_pr_infos: HashMap>, + head_generations: HashMap, + branches: HashMap>, +) -> GithubPollResult { + let mut pr_infos = HashMap::new(); + let mut ci_checks = HashMap::new(); + + for (id, path) in &projects { + if !pr_poll_ids.contains(id) { + continue; + } + let task_id = id.clone(); + let path = path.clone(); + let fetched = tokio::task::spawn_blocking(move || { + with_lane(Lane::Poll, || { + 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(task_id, pr); + } + Err(error) => log::warn!("gh PR info task failed for {task_id}: {error}"), + } + } + + for (id, path) in &projects { + if !ci_poll_ids.contains(id) { + continue; + } + let task_id = id.clone(); + let path = path.clone(); + let pr_number = pr_infos + .get(id) + .or_else(|| cached_pr_infos.get(id)) + .and_then(|pr| pr.as_ref()) + .map(|pr| pr.number); + let fetched = tokio::task::spawn_blocking(move || { + with_lane(Lane::Poll, || { + 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(task_id, checks); + } + Err(error) => log::warn!("gh CI checks task failed for {task_id}: {error}"), + } + } + + GithubPollResult { + head_generations, + branches, + pr_infos, + ci_checks, + } +} + +fn apply_github_result( + result: GithubPollResult, + current_head_generations: &HashMap, + pr_infos: &mut HashMap>, + ci_checks: &mut HashMap>, + last: &mut HashMap, + git_status_tx: &watch::Sender>, + state_version: &watch::Sender, +) -> bool { + let GithubPollResult { + head_generations, + branches, + pr_infos: fetched_pr_infos, + ci_checks: fetched_ci_checks, + } = result; + let is_current = |id: &str| { + let expected_generation = head_generations.get(id).copied().unwrap_or_default(); + let current_generation = current_head_generations + .get(id) + .copied() + .unwrap_or_default(); + let expected_branch = branches.get(id); + let current_branch = last.get(id).map(|status| &status.branch); + expected_generation == current_generation && expected_branch == current_branch + }; + + for (id, pr_info) in fetched_pr_infos { + if is_current(&id) { + pr_infos.insert(id, pr_info); + } + } + for (id, checks) in fetched_ci_checks { + if is_current(&id) { + ci_checks.insert(id, checks); + } + } + + let mut enriched = last.clone(); + for (id, status) in &mut enriched { + status.pr_info = pr_infos.get(id).cloned().flatten(); + status.ci_checks = ci_checks.get(id).cloned().flatten(); + } + publish(last, &enriched, git_status_tx, state_version); + has_pending_ci(ci_checks) +} + +/// Run the daemon git-status poll loop until the `watch` channel is closed (all +/// receivers dropped → the server is gone). +/// +/// Each cycle snapshots all local projects and their current relevance, selects +/// only due or explicitly triggered repositories, and runs their gix work on the +/// blocking pool. Results merge into the prior cache so skipped hidden projects +/// remain published. The independent wall-clock interval keeps 5s/30s deadlines +/// stable even when targeted triggers wake the loop between cadence ticks. +/// PR/CI lookups retain their existing visible-project adaptive cadence. +/// +/// 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 known_relevant_ids: HashSet = HashSet::new(); + let mut trigger_rx_closed = false; + let mut head_generations: HashMap = HashMap::new(); + let (github_result_tx, mut github_result_rx) = mpsc::unbounded_channel(); + // Consume `interval`'s immediate first tick. Subsequent ticks stay anchored + // to wall time, so targeted wakes cannot postpone periodic refreshes. + let mut cadence = tokio::time::interval(GIT_POLL_INTERVAL); + cadence.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + cadence.tick().await; + let mut cadence_due = true; + + loop { + drain_git_poll_triggers(&mut trigger_rx, &mut trigger_acc, &mut trigger_rx_closed); + let mut github_cache_changed = false; + for id in &trigger_acc.head_change_ids { + *head_generations.entry(id.clone()).or_default() += 1; + github_cache_changed |= ci_checks.remove(id).is_some(); + } + github_cache_changed |= clear_github_cache_for_ids( + &trigger_acc.invalidate_gh_ids, + &mut pr_infos, + &mut ci_checks, + ); + if github_cache_changed { + any_pending_ci = has_pending_ci(&ci_checks); + } + + // ── 1. Snapshot relevance and choose this cycle's local work ───────── + let (projects, relevant_ids): (Vec<(String, String)>, HashSet) = { + let workspace = workspace.lock(); + let relevant = relevant_project_ids(&workspace, &remote_subscribed_terminals); + let projects = workspace + .projects() + .iter() + .filter(|project| !project.is_remote) + .map(|project| (project.id.clone(), project.path.clone())) + .collect(); + (projects, relevant) + }; + let active_ids: HashSet = projects.iter().map(|(id, _)| id.clone()).collect(); + pr_infos.retain(|id, _| active_ids.contains(id)); + ci_checks.retain(|id, _| active_ids.contains(id)); + head_generations.retain(|id, _| active_ids.contains(id)); + known_gh_ids.retain(|id| active_ids.contains(id)); + known_relevant_ids.retain(|id| active_ids.contains(id)); + + let newly_relevant_ids: HashSet = relevant_ids + .difference(&known_relevant_ids) + .cloned() + .collect(); + known_relevant_ids = relevant_ids.clone(); + let forced_local_ids = trigger_acc.local_status_ids(); + let poll_hidden = + cycle == 0 || (cadence_due && cycle.is_multiple_of(HIDDEN_GIT_POLL_EVERY_N_CYCLES)); + let status_poll_ids = select_status_poll_ids( + &active_ids, + &relevant_ids, + &forced_local_ids, + &newly_relevant_ids, + cadence_due, + poll_hidden, + ); + + // `gh` stays limited to relevant/explicit projects independently of the + // slower hidden local-status fallback. + let mut gh_ids = relevant_ids; + gh_ids.extend(trigger_acc.force_gh_ids.iter().cloned()); + gh_ids.extend(trigger_acc.candidate_gh_ids.iter().cloned()); + 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 selected statuses and merge into the published cache ── + let mut attempted: HashMap> = HashMap::new(); + for (id, path) in projects + .iter() + .filter(|(id, _)| status_poll_ids.contains(id)) + { + 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(); + attempted.insert(id, Some(status)); + } + Ok(None) => { + attempted.insert(id, None); + } + Err(error) => { + // Preserve the last published value on a panicked blocking + // task; the next cadence or targeted trigger retries it. + log::error!("git status poll task panicked for {id}: {error}"); + } + } + } + let missing_status_ids: HashSet = attempted + .iter() + .filter_map(|(id, status)| status.is_none().then_some(id.clone())) + .collect(); + if clear_github_cache_for_ids(&missing_status_ids, &mut pr_infos, &mut ci_checks) { + any_pending_ci = has_pending_ci(&ci_checks); + } + let mut new_statuses = merge_status_results(&last, &active_ids, attempted); + + 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 = cadence_due + && (cycle == 1 || (cycle != 0 && cycle.is_multiple_of(PR_POLL_EVERY_N_CYCLES))); + let ci_cadence = + cadence_due && (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. Start `gh` PR/CI fan-out without blocking local git refreshes ─ + if check_prs || check_ci { + let result_tx = github_result_tx.clone(); + let poll_generations = projects + .iter() + .map(|(id, _)| { + ( + id.clone(), + head_generations.get(id).copied().unwrap_or_default(), + ) + }) + .collect(); + let poll_branches = new_statuses + .iter() + .map(|(id, status)| (id.clone(), status.branch.clone())) + .collect(); + let cached_pr_infos = pr_infos.clone(); + tokio::spawn(async move { + let result = poll_github( + projects, + pr_poll_ids, + ci_poll_ids, + cached_pr_infos, + poll_generations, + poll_branches, + ) + .await; + let _ = result_tx.send(result); + }); + } + + trigger_acc.clear(); + if cadence_due { + cycle = cycle.wrapping_add(1); + } + cadence_due = false; + loop { + tokio::select! { + biased; + _ = cadence.tick() => { + cadence_due = true; + break; + } + trigger = trigger_rx.recv(), if !trigger_rx_closed => { + match trigger { + Some(trigger) => { + trigger_acc.record(trigger); + break; + } + None => trigger_rx_closed = true, + } + } + Some(result) = github_result_rx.recv() => { + any_pending_ci = apply_github_result( + result, + &head_generations, + &mut pr_infos, + &mut ci_checks, + &mut last, + &git_status_tx, + &state_version, + ); + } + } + } + } +} + +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(), + } +} + +/// 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::head_change("committed".to_string())); + 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.head_change_ids.contains("committed")); + assert!(!acc.force_gh_ids.contains("committed")); + assert!(!acc.force_gh_ids.contains("visible")); + assert_eq!( + acc.local_status_ids(), + HashSet::from([ + "committed".to_string(), + "visible".to_string(), + "switched".to_string(), + ]) + ); + } + + #[test] + fn status_poll_selection_respects_tiers_and_targeted_wakes() { + let active = HashSet::from(["visible".to_string(), "hidden".to_string()]); + let relevant = HashSet::from(["visible".to_string()]); + let hidden = HashSet::from(["hidden".to_string()]); + let empty = HashSet::new(); + + assert_eq!( + select_status_poll_ids(&active, &relevant, &empty, &empty, true, true), + active, + "startup and hidden fallback cycles scan every active project" + ); + assert_eq!( + select_status_poll_ids(&active, &relevant, &empty, &empty, true, false), + relevant, + "ordinary cadence scans only relevant projects" + ); + assert_eq!( + select_status_poll_ids(&active, &relevant, &hidden, &empty, false, false), + hidden, + "targeted hidden refreshes do not wait for fallback cadence" + ); + assert_eq!( + select_status_poll_ids(&active, &empty, &empty, &hidden, false, false), + hidden, + "promotion to the relevant tier refreshes immediately" + ); + } + + #[test] + fn merging_targeted_statuses_retains_unpolled_and_prunes_deleted() { + let previous = HashMap::from([ + ( + "visible".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + ), + ( + "hidden".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + ), + ("deleted".to_string(), GitStatus::default()), + ]); + let active = HashSet::from([ + "visible".to_string(), + "hidden".to_string(), + "not-a-repo".to_string(), + ]); + let attempted = HashMap::from([ + ( + "hidden".to_string(), + Some(GitStatus { + branch: Some("feature".to_string()), + ..GitStatus::default() + }), + ), + ("not-a-repo".to_string(), None), + ]); + + let merged = merge_status_results(&previous, &active, attempted); + assert_eq!( + merged + .get("visible") + .and_then(|status| status.branch.as_deref()), + Some("main"), + "unpolled active status stays published" + ); + assert_eq!( + merged + .get("hidden") + .and_then(|status| status.branch.as_deref()), + Some("feature") + ); + assert!(!merged.contains_key("deleted")); + assert!(!merged.contains_key("not-a-repo")); + } + + #[test] + fn unsampled_head_snapshots_survive_fast_tier_ticks() { + let mut previous = HashMap::from([ + ("hidden".to_string(), "old".to_string()), + ("deleted".to_string(), "old".to_string()), + ]); + let active = HashSet::from(["hidden".to_string()]); + + assert!(update_head_snapshots(&mut previous, &active, HashMap::new()).is_empty()); + assert_eq!(previous.get("hidden").map(String::as_str), Some("old")); + assert!(!previous.contains_key("deleted")); + + let changed = update_head_snapshots( + &mut previous, + &active, + HashMap::from([("hidden".to_string(), "new".to_string())]), + ); + assert_eq!(changed, vec!["hidden".to_string()]); + } + + #[test] + fn github_results_apply_only_to_the_captured_head() { + let (git_status_tx, _rx) = watch::channel(HashMap::new()); + let (state_version, _state_rx) = watch::channel(0); + let mut last = HashMap::from([( + "p1".to_string(), + GitStatus { + branch: Some("main".to_string()), + ..GitStatus::default() + }, + )]); + let mut pr_infos = HashMap::new(); + let mut ci_checks = HashMap::new(); + let current_generations = HashMap::from([("p1".to_string(), 2)]); + + let result = |generation, branch: &str| GithubPollResult { + head_generations: HashMap::from([("p1".to_string(), generation)]), + branches: HashMap::from([("p1".to_string(), Some(branch.to_string()))]), + pr_infos: HashMap::from([("p1".to_string(), None)]), + ci_checks: HashMap::from([("p1".to_string(), None)]), + }; + + apply_github_result( + result(1, "main"), + ¤t_generations, + &mut pr_infos, + &mut ci_checks, + &mut last, + &git_status_tx, + &state_version, + ); + apply_github_result( + result(2, "feature"), + ¤t_generations, + &mut pr_infos, + &mut ci_checks, + &mut last, + &git_status_tx, + &state_version, + ); + assert!(!pr_infos.contains_key("p1")); + assert!(!ci_checks.contains_key("p1")); + + apply_github_result( + result(2, "main"), + ¤t_generations, + &mut pr_infos, + &mut ci_checks, + &mut last, + &git_status_tx, + &state_version, + ); + assert!(pr_infos.contains_key("p1")); + assert!(ci_checks.contains_key("p1")); + } +} diff --git a/crates/okena-daemon-core/src/lib.rs b/crates/okena-daemon-core/src/lib.rs new file mode 100644 index 000000000..4897d0c21 --- /dev/null +++ b/crates/okena-daemon-core/src/lib.rs @@ -0,0 +1,48 @@ +//! 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; +pub mod worktree_close_watchdog; + +#[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..9a7d9a724 --- /dev/null +++ b/crates/okena-daemon-core/src/observers.rs @@ -0,0 +1,2336 @@ +//! 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::{HashMap, HashSet}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use okena_services::config::{PreparedProjectConfig, prepare_project_config}; +use okena_services::manager::{ + ServiceCx, ServiceLoadStatus, ServiceManager, ServiceTerminalWriteback, +}; +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); +const SERVICE_RETRY_DELAY: Duration = Duration::from_millis(500); +const SERVICE_RETRY_MAX_DELAY: Duration = Duration::from_secs(30); + +#[derive(Default)] +pub(crate) struct AutosaveTracker { + pending: parking_lot::Mutex, + drained: parking_lot::Condvar, +} + +impl AutosaveTracker { + pub(crate) fn start(self: &Arc) -> AutosaveJob { + *self.pending.lock() += 1; + AutosaveJob { + tracker: Arc::clone(self), + } + } + + pub(crate) fn flush(&self) { + let mut pending = self.pending.lock(); + while *pending != 0 { + self.drained.wait(&mut pending); + } + } +} + +pub(crate) struct AutosaveJob { + tracker: Arc, +} + +impl Drop for AutosaveJob { + fn drop(&mut self) { + let mut pending = self.tracker.pending.lock(); + *pending = pending.saturating_sub(1); + if *pending == 0 { + self.tracker.drained.notify_all(); + } + } +} + +/// Per-project snapshot taken under the workspace lock so the services diff can +/// run after the lock is dropped (the separate-lock-scope guard). +#[derive(Clone)] +struct ProjectSnapshot { + id: String, + path: String, + is_remote: bool, + is_creating: bool, + is_closing: bool, + service_terminals: std::collections::HashMap, + data_replacement_epoch: u64, +} + +struct PreparedProjectSnapshot { + project: ProjectSnapshot, + config: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct KnownProject { + path: String, + data_replacement_epoch: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PendingPreservedSessions { + identity: KnownProject, + terminal_ids: HashSet, +} + +#[derive(Default)] +struct ServiceSyncState { + known: HashMap, + pending_preserved: HashMap, + discarded_terminal_ids: HashMap>, + retry_projects: HashSet, + retry_deadline: Option, + retry_round: u32, +} + +fn replace_pending_preserved_sessions( + sync_state: &mut ServiceSyncState, + project_id: &str, + identity: &KnownProject, + terminal_ids: HashSet, + sm: &ServiceManager, +) { + if let Some(previous) = sync_state.pending_preserved.remove(project_id) { + let abandoned: HashSet = previous + .terminal_ids + .difference(&terminal_ids) + .cloned() + .collect(); + sm.kill_unclaimed_preserved_sessions(project_id, &abandoned); + } + if !terminal_ids.is_empty() { + sync_state.pending_preserved.insert( + project_id.to_string(), + PendingPreservedSessions { + identity: identity.clone(), + terminal_ids, + }, + ); + } +} + +impl ServiceSyncState { + fn request_retry(&mut self, project_id: &str) { + self.retry_projects.insert(project_id.to_string()); + if self.retry_deadline.is_none() { + let multiplier = 1u32 + .checked_shl(self.retry_round.min(16)) + .unwrap_or(u32::MAX); + let delay = SERVICE_RETRY_DELAY + .checked_mul(multiplier) + .unwrap_or(SERVICE_RETRY_MAX_DELAY) + .min(SERVICE_RETRY_MAX_DELAY); + self.retry_deadline = Some(tokio::time::Instant::now() + delay); + } + } + + fn resolve_retry(&mut self, project_id: &str) { + self.retry_projects.remove(project_id); + if self.retry_projects.is_empty() { + self.retry_deadline = None; + self.retry_round = 0; + } + } +} + +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 autosave_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(), + )); + // Autosave runs on its OWN `workspace_tick` subscription so its debounce + // window + blocking save never delay the `state_version` bump that + // notifies clients. A worktree close fires several data-changing ticks + // back-to-back; running the 500ms-debounced save inline in the tick loop + // serialized them and stalled the client-visible removal by ~2s. + tokio::task::spawn_local(autosave_task( + autosave_rx, + self.workspace.clone(), + self.runtime.clone(), + self.autosave_tracker.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` and run the +/// project→services load/unload diff on every `workspace_tick` change. Autosave +/// lives in a separate task ([`autosave_task`]) so its debounce never delays the +/// state_version bump. +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, +) { + let mut sync_state = ServiceSyncState::default(); + + // 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 sync_state, + ) + .await; + + loop { + let Some(workspace_changed) = + wait_for_service_sync_trigger(&mut tick_rx, &mut sync_state.retry_deadline).await + else { + return; + }; + + if workspace_changed { + // Retry-only passes do not represent a workspace mutation. Successful + // service loads notify through `service_tick` on their own. + state_version.send_modify(|v| *v += 1); + } else { + sync_state.retry_round = sync_state.retry_round.saturating_add(1); + } + + // ── project → services load/unload diff ───────────────────────────── + run_services_sync( + &workspace, + &service_manager, + &runtime, + &service_tick, + &mut sync_state, + ) + .await; + } +} + +/// Wait for either a real workspace mutation or the one deduplicated retry timer. +/// Returns `None` when the workspace tick sender is gone. +async fn wait_for_service_sync_trigger( + tick_rx: &mut tokio::sync::watch::Receiver, + retry_deadline: &mut Option, +) -> Option { + if let Some(deadline) = *retry_deadline { + tokio::select! { + changed = tick_rx.changed() => changed.ok().map(|_| true), + _ = tokio::time::sleep_until(deadline) => { + *retry_deadline = None; + Some(false) + } + } + } else { + tick_rx.changed().await.ok().map(|_| true) + } +} + +/// 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 writebacks = service_manager.lock().service_terminal_writebacks(); + + // 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. + apply_service_terminal_writebacks( + &workspace, + &workspace_tick, + &hook_runner, + &hook_monitor, + writebacks, + ); + } +} + +fn apply_service_terminal_writebacks( + workspace: &SharedWorkspace, + workspace_tick: &tokio::sync::watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + writebacks: Vec, +) { + let mut ws = workspace.lock(); + if ws.terminal_backend_migration_epoch().is_some() { + return; + } + let current_epoch = ws.data_replacement_epoch(); + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + for writeback in writebacks { + if writeback.data_replacement_epoch != current_epoch + || ws + .project(&writeback.project_id) + .is_none_or(|project| project.path != writeback.project_path) + { + continue; + } + ws.sync_service_terminals(&writeback.project_id, writeback.terminal_ids, &mut cx); + } +} + +/// Dedicated autosave task, driven by the same `workspace_tick` as +/// [`workspace_tick_task`] but on its OWN subscription. Kept separate so the +/// debounce sleep + blocking save I/O never delay the latency-critical +/// `state_version` bump: a worktree close fires several data-changing ticks +/// back-to-back, and running the 500ms-debounced save inline serialized them, +/// stalling the client-visible removal by ~2s. +async fn autosave_task( + mut tick_rx: tokio::sync::watch::Receiver, + workspace: SharedWorkspace, + runtime: tokio::runtime::Handle, + tracker: Arc, +) { + // Tracks the `data_version` last persisted, so UI-only changes skip the save. + let last_saved_version = Arc::new(AtomicU64::new(0)); + loop { + if tick_rx.changed().await.is_err() { + // All senders dropped — the reactor is gone; stop the task. + return; + } + autosave(&workspace, &runtime, &last_saved_version, &tracker).await; + } +} + +/// 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, + last_saved_version: &Arc, + tracker: &Arc, +) { + // Skip UI-only changes: the persistent `data_version` is unchanged. + let current_version = { + let workspace = workspace.lock(); + if workspace.terminal_backend_migration_epoch().is_some() { + return; + } + workspace.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(); + if ws.terminal_backend_migration_epoch().is_some() { + return; + } + (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 job = tracker.start(); + let save_result = runtime + .spawn_blocking(move || { + let _job = job; + 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`]. +async fn run_services_sync( + workspace: &SharedWorkspace, + service_manager: &SharedServiceManager, + runtime: &tokio::runtime::Handle, + service_tick: &tokio::sync::watch::Sender, + sync_state: &mut ServiceSyncState, +) { + run_services_sync_with_preparer( + workspace, + service_manager, + runtime, + service_tick, + sync_state, + prepare_project_snapshots, + ) + .await; +} + +async fn run_services_sync_with_preparer( + workspace: &SharedWorkspace, + service_manager: &SharedServiceManager, + runtime: &tokio::runtime::Handle, + service_tick: &tokio::sync::watch::Sender, + sync_state: &mut ServiceSyncState, + prepare: Prepare, +) where + Prepare: FnOnce(Vec) -> Vec + Send + 'static, +{ + // Lock scope 1: snapshot the projects, then drop the workspace lock. + let (projects, snapshot_epoch): (Vec, u64) = { + let ws = workspace.lock(); + if ws.terminal_backend_migration_epoch().is_some() { + return; + } + let data_replacement_epoch = ws.data_replacement_epoch(); + ( + ws.data() + .projects + .iter() + .map(|p| ProjectSnapshot { + id: p.id.clone(), + path: p.path.clone(), + is_remote: p.is_remote, + is_creating: p.is_creating, + is_closing: p.is_closing, + service_terminals: p.service_terminals.clone(), + data_replacement_epoch, + }) + .collect(), + data_replacement_epoch, + ) + }; + + let expected_project_count = projects.iter().filter(|project| !project.is_remote).count(); + let prepared_projects = match runtime.spawn_blocking(move || prepare(projects)).await { + Ok(prepared) => prepared, + Err(error) => { + log::error!("service config preparation task failed: {error}"); + return; + } + }; + + // Loading ran without locks. Reject results whose workspace owner changed + // while the filesystem was being probed. + let prepared_projects = { + let ws = workspace.lock(); + let epoch = ws.data_replacement_epoch(); + prepared_projects + .into_iter() + .filter(|prepared| { + prepared.project.data_replacement_epoch == epoch + && ws.project(&prepared.project.id).is_some_and(|project| { + project.path == prepared.project.path + && project.is_remote == prepared.project.is_remote + && project.is_creating == prepared.project.is_creating + && project.is_closing == prepared.project.is_closing + }) + }) + .collect::>() + }; + if prepared_projects.len() != expected_project_count { + return; + } + { + let workspace = workspace.lock(); + if workspace.terminal_backend_migration_epoch().is_some() + || workspace.data_replacement_epoch() != snapshot_epoch + { + return; + } + } + + // 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_prepared_services(prepared_projects, sync_state, &mut sm, &mut cx); +} + +fn prepare_project_snapshots(projects: Vec) -> Vec { + projects + .into_iter() + .filter(|project| !project.is_remote) + .map(|project| { + let config = (!project.is_creating && !project.is_closing) + .then(|| prepare_project_config(&project.path)); + PreparedProjectSnapshot { project, config } + }) + .collect() +} + +/// 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. The convergence key includes the workspace replacement +/// epoch and service-owning project data, so loading a session cannot preserve a +/// stale manager merely because the new project reused the same id. +fn sync_prepared_services( + projects: Vec, + sync_state: &mut ServiceSyncState, + sm: &mut ServiceManager, + cx: &mut impl ServiceCx, +) { + let current_ids: HashSet = projects + .iter() + .map(|prepared| prepared.project.id.clone()) + .collect(); + + for prepared in projects { + let p = prepared.project; + if p.is_creating || p.is_closing { + continue; + } + let config = prepared + .config + .expect("active project snapshots must have prepared service config"); + let identity = KnownProject { + path: p.path.clone(), + data_replacement_epoch: p.data_replacement_epoch, + }; + let saved_ids: HashSet = p.service_terminals.values().cloned().collect(); + if let Some(discarded) = sync_state.discarded_terminal_ids.get_mut(&p.id) { + discarded.retain(|terminal_id| saved_ids.contains(terminal_id)); + if discarded.is_empty() { + sync_state.discarded_terminal_ids.remove(&p.id); + } + } + let discarded = sync_state + .discarded_terminal_ids + .get(&p.id) + .cloned() + .unwrap_or_default(); + let saved_for_attempt: HashMap = p + .service_terminals + .iter() + .filter(|(_, terminal_id)| !discarded.contains(*terminal_id)) + .map(|(service, terminal_id)| (service.clone(), terminal_id.clone())) + .collect(); + let saved_ids_for_attempt: HashSet = saved_for_attempt.values().cloned().collect(); + let path_exists = !matches!(&config, PreparedProjectConfig::Missing); + + if sync_state + .pending_preserved + .get(&p.id) + .is_some_and(|pending| { + pending.identity != identity || pending.terminal_ids != saved_ids_for_attempt + }) + { + replace_pending_preserved_sessions( + sync_state, + &p.id, + &identity, + saved_ids_for_attempt.clone(), + sm, + ); + } + + let previous = sync_state.known.get(&p.id).cloned(); + let manager_owns_renamed_project = path_exists + && previous.as_ref().is_some_and(|previous| { + previous.data_replacement_epoch == identity.data_replacement_epoch + && previous.path != identity.path + }) + && sm.project_path(&p.id) == Some(&p.path) + && sm.service_terminal_writebacks().iter().any(|writeback| { + writeback.project_id == p.id + && writeback.project_path == p.path + && writeback.data_replacement_epoch == p.data_replacement_epoch + }); + if manager_owns_renamed_project { + sync_state.known.insert(p.id.clone(), identity); + sync_state.resolve_retry(&p.id); + continue; + } + if path_exists + && previous.as_ref() == Some(&identity) + && sm.project_path(&p.id) == Some(&p.path) + { + sync_state.resolve_retry(&p.id); + continue; + } + if previous.is_some() || sm.project_path(&p.id).is_some() { + let replacing_data = previous.as_ref().is_some_and(|previous| { + previous.data_replacement_epoch != identity.data_replacement_epoch + }); + if replacing_data || !path_exists { + sm.unload_project_services_preserving(&p.id, &saved_ids_for_attempt, cx); + replace_pending_preserved_sessions( + sync_state, + &p.id, + &identity, + saved_ids_for_attempt.clone(), + sm, + ); + } else { + sm.unload_project_services(&p.id, cx); + } + sync_state.known.remove(&p.id); + } + + if !path_exists { + if !saved_ids_for_attempt.is_empty() { + replace_pending_preserved_sessions( + sync_state, + &p.id, + &identity, + saved_ids_for_attempt, + sm, + ); + } + sync_state.request_retry(&p.id); + continue; + } + + sm.set_project_writeback_owner(&p.id, &p.path, p.data_replacement_epoch); + let load_status = + sm.load_project_services_prepared(&p.id, &p.path, &saved_for_attempt, config, cx); + let claimed: HashSet = sm.service_terminal_ids(&p.id).into_values().collect(); + let unclaimed: HashSet = saved_ids_for_attempt + .difference(&claimed) + .cloned() + .collect(); + sm.kill_unclaimed_preserved_sessions(&p.id, &saved_ids_for_attempt); + if !unclaimed.is_empty() { + sync_state + .discarded_terminal_ids + .entry(p.id.clone()) + .or_default() + .extend(unclaimed); + } + sync_state.pending_preserved.remove(&p.id); + + match load_status { + ServiceLoadStatus::Loaded => { + sync_state.known.insert(p.id.clone(), identity); + sync_state.resolve_retry(&p.id); + } + ServiceLoadStatus::Failed => { + sync_state.known.remove(&p.id); + sync_state.request_retry(&p.id); + } + } + } + + let removed: HashSet = sync_state + .known + .keys() + .chain(sync_state.pending_preserved.keys()) + .chain(sync_state.retry_projects.iter()) + .filter(|id| !current_ids.contains(*id)) + .cloned() + .collect(); + for id in &removed { + if let Some(pending) = sync_state.pending_preserved.remove(id) { + sm.kill_unclaimed_preserved_sessions(id, &pending.terminal_ids); + } + sm.unload_project_services(id, cx); + sync_state.known.remove(id); + sync_state.discarded_terminal_ids.remove(id); + sync_state.resolve_retry(id); + } +} + +#[cfg(test)] +fn sync_services( + projects: &[ProjectSnapshot], + sync_state: &mut ServiceSyncState, + sm: &mut ServiceManager, + cx: &mut impl ServiceCx, +) { + sync_prepared_services( + prepare_project_snapshots(projects.to_vec()), + sync_state, + sm, + cx, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{StubBackend, StubTransport, empty_workspace_data}; + use okena_terminal::backend::TerminalBackend; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use std::sync::Mutex; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RecordingBackend { + killed: Mutex>, + reconnected: Mutex>, + reconnect_fail_after: Option, + reconnect_count: AtomicUsize, + } + + impl TerminalBackend for RecordingBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("unexpected create") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + self.reconnected + .lock() + .expect("reconnect lock") + .push(terminal_id.to_string()); + let reconnect_count = self.reconnect_count.fetch_add(1, Ordering::Relaxed); + if self + .reconnect_fail_after + .is_some_and(|limit| reconnect_count >= limit) + { + anyhow::bail!("configured reconnect failure"); + } + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.killed + .lock() + .expect("kill 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() + } + } + + struct BlockingReconnectBackend { + started: Mutex>>, + release: Mutex>, + killed: Mutex>, + } + + impl TerminalBackend for BlockingReconnectBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("reserved-ID launches must reconnect") + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + if let Some(started) = self.started.lock().expect("started lock").take() { + let _ = started.send(()); + } + self.release + .lock() + .expect("release lock") + .recv() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.killed + .lock() + .expect("kill 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 `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, + is_creating: false, + is_closing: false, + service_terminals: Default::default(), + data_replacement_epoch: 0, + } + } + + fn workspace_with_service_terminal( + project_id: &str, + project_path: &str, + service_name: &str, + terminal_id: &str, + ) -> okena_workspace::state::Workspace { + workspace_with_project( + project_id, + project_path, + HashMap::from([(service_name.to_string(), terminal_id.to_string())]), + ) + } + + fn workspace_with_project( + project_id: &str, + project_path: &str, + service_terminals: HashMap, + ) -> okena_workspace::state::Workspace { + use okena_workspace::state::ProjectData; + + let mut data = empty_workspace_data(); + data.project_order.push(project_id.to_string()); + data.projects.push(ProjectData { + id: project_id.to_string(), + name: "Project".into(), + path: project_path.to_string(), + layout: None, + 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_shell: None, + hook_terminals: Default::default(), + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }); + okena_workspace::state::Workspace::new(data) + } + + /// 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) + } + + async fn wait_for_test_condition(condition: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(2), async { + while !condition() { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .expect("service task condition timed out"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_service_preparation_keeps_localset_live_and_discards_stale_snapshot() { + let workspace = Arc::new(parking_lot::Mutex::new(workspace_with_project( + "project", + "/captured", + HashMap::new(), + ))); + let sm = Arc::new(parking_lot::Mutex::new(manager())); + let (service_tick, _service_rx) = tokio::sync::watch::channel(0u64); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let progressed = Arc::new(AtomicBool::new(false)); + let progress_manager = sm.clone(); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let task_workspace = workspace.clone(); + let task_manager = sm.clone(); + let task_tick = service_tick.clone(); + let runtime = tokio::runtime::Handle::current(); + let task = tokio::task::spawn_local(async move { + let mut sync_state = ServiceSyncState::default(); + run_services_sync_with_preparer( + &task_workspace, + &task_manager, + &runtime, + &task_tick, + &mut sync_state, + move |projects| { + let _ = started_tx.send(()); + release_rx.recv().expect("release preparation"); + projects + .into_iter() + .map(|project| PreparedProjectSnapshot { + project, + config: Some(PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }), + }) + .collect() + }, + ) + .await; + }); + + started_rx.await.expect("preparation started"); + let progressed_task = progressed.clone(); + tokio::task::spawn_local(async move { + let _guard = progress_manager.lock(); + progressed_task.store(true, Ordering::Release); + }) + .await + .expect("local task completed"); + *workspace.lock() = + workspace_with_project("project", "/replacement", HashMap::new()); + release_tx.send(()).expect("release preparation"); + task.await.expect("sync task completed"); + }) + .await; + + assert!(progressed.load(Ordering::Acquire)); + assert!(sm.lock().project_path("project").is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_service_preparation_discards_lifecycle_change() { + let workspace = Arc::new(parking_lot::Mutex::new(workspace_with_project( + "project", + "/captured", + HashMap::new(), + ))); + let sm = Arc::new(parking_lot::Mutex::new(manager())); + let (service_tick, _service_rx) = tokio::sync::watch::channel(0u64); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let task_workspace = workspace.clone(); + let task_manager = sm.clone(); + let task_tick = service_tick.clone(); + let runtime = tokio::runtime::Handle::current(); + let task = tokio::task::spawn_local(async move { + let mut sync_state = ServiceSyncState::default(); + run_services_sync_with_preparer( + &task_workspace, + &task_manager, + &runtime, + &task_tick, + &mut sync_state, + move |projects| { + let _ = started_tx.send(()); + release_rx.recv().expect("release preparation"); + projects + .into_iter() + .map(|project| PreparedProjectSnapshot { + project, + config: Some(PreparedProjectConfig::Loaded { + config: None, + detected_compose_file: None, + }), + }) + .collect() + }, + ) + .await; + }); + + started_rx.await.expect("preparation started"); + workspace + .lock() + .mark_closing_project_authoritative("project"); + release_tx.send(()).expect("release preparation"); + task.await.expect("sync task completed"); + }) + .await; + + assert!(sm.lock().project_path("project").is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn service_sync_is_suppressed_during_backend_migration() { + let mut workspace_value = + workspace_with_project("project", &existing_path(), HashMap::new()); + workspace_value + .begin_terminal_backend_migration( + okena_terminal::session_backend::SessionBackend::None, + &ShellType::Default, + ) + .expect("begin migration"); + let workspace = Arc::new(parking_lot::Mutex::new(workspace_value)); + let sm = Arc::new(parking_lot::Mutex::new(manager())); + let (service_tick, _service_rx) = tokio::sync::watch::channel(0u64); + let preparer_called = Arc::new(AtomicBool::new(false)); + let preparer_flag = preparer_called.clone(); + let mut sync_state = ServiceSyncState::default(); + + run_services_sync_with_preparer( + &workspace, + &sm, + &tokio::runtime::Handle::current(), + &service_tick, + &mut sync_state, + move |_| { + preparer_flag.store(true, Ordering::Release); + Vec::new() + }, + ) + .await; + + assert!(!preparer_called.load(Ordering::Acquire)); + assert!(sm.lock().project_path("project").is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn autosave_is_suppressed_during_backend_migration() { + let (workspace_tick, _workspace_rx) = tokio::sync::watch::channel(0u64); + let no_hook_runner = None; + let no_hook_monitor = None; + let mut workspace_value = + workspace_with_project("project", &existing_path(), HashMap::new()); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &no_hook_runner, &no_hook_monitor); + workspace_value.notify_data(&mut cx); + workspace_value + .begin_terminal_backend_migration( + okena_terminal::session_backend::SessionBackend::None, + &ShellType::Default, + ) + .expect("begin migration"); + let workspace = Arc::new(parking_lot::Mutex::new(workspace_value)); + let last_saved_version = Arc::new(AtomicU64::new(0)); + let tracker = Arc::new(AutosaveTracker::default()); + + autosave( + &workspace, + &tokio::runtime::Handle::current(), + &last_saved_version, + &tracker, + ) + .await; + + assert_eq!(last_saved_version.load(Ordering::Relaxed), 0); + assert_eq!(*tracker.pending.lock(), 0); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn missing_empty_project_retries_when_mount_appears() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-empty-mount-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let project_path = project_dir.to_string_lossy().into_owned(); + let workspace = Arc::new(parking_lot::Mutex::new(workspace_with_project( + "project", + &project_path, + HashMap::new(), + ))); + let sm = Arc::new(parking_lot::Mutex::new(manager())); + let (service_tick, _service_rx) = tokio::sync::watch::channel(0u64); + let runtime = tokio::runtime::Handle::current(); + let mut sync_state = ServiceSyncState::default(); + + run_services_sync(&workspace, &sm, &runtime, &service_tick, &mut sync_state).await; + assert!(sync_state.retry_projects.contains("project")); + assert!(sync_state.retry_deadline.is_some()); + + std::fs::create_dir_all(&project_dir).expect("create mounted project"); + std::fs::write(project_dir.join("okena.yaml"), "services: []\n") + .expect("write service config"); + tokio::time::advance(SERVICE_RETRY_DELAY).await; + let (_workspace_tick, mut workspace_rx) = tokio::sync::watch::channel(0u64); + assert_eq!( + wait_for_service_sync_trigger(&mut workspace_rx, &mut sync_state.retry_deadline,).await, + Some(false) + ); + sync_state.retry_round += 1; + run_services_sync(&workspace, &sm, &runtime, &service_tick, &mut sync_state).await; + + assert_eq!(sm.lock().project_path("project"), Some(&project_path)); + assert!(!sync_state.retry_projects.contains("project")); + std::fs::remove_dir_all(project_dir).expect("remove mounted project"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_service_launch_releases_manager_and_discards_stale_result() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-blocking-launch-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write service config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(BlockingReconnectBackend { + started: Mutex::new(Some(started_tx)), + release: Mutex::new(release_rx), + killed: Mutex::new(Vec::new()), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let mut snapshot = project("project", &project_path, false); + snapshot + .service_terminals + .insert("web".into(), "persistent-web".into()); + let mut sync_state = ServiceSyncState::default(); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[snapshot], &mut sync_state, &mut manager, &mut cx); + } + started_rx.await.expect("backend launch started"); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + manager.unload_project_services("project", &mut cx); + } + release_tx.send(()).expect("release backend launch"); + wait_for_test_condition(|| !backend.killed.lock().expect("kill lock").is_empty()) + .await; + }) + .await; + + assert!(sm.lock().service_terminal_ids("project").is_empty()); + assert!( + backend + .killed + .lock() + .expect("kill lock") + .iter() + .any(|terminal_id| terminal_id == "persistent-web") + ); + std::fs::remove_dir_all(project_dir).expect("remove project dir"); + } + + #[test] + fn service_writeback_is_epoch_fenced_and_can_clear_zero_instance_ownership() { + let workspace = Arc::new(parking_lot::Mutex::new(workspace_with_service_terminal( + "project", + "/project", + "web", + "incoming-terminal", + ))); + let (workspace_tick, _rx) = tokio::sync::watch::channel(0u64); + + apply_service_terminal_writebacks( + &workspace, + &workspace_tick, + &None, + &None, + vec![ServiceTerminalWriteback { + project_id: "project".into(), + project_path: "/project".into(), + data_replacement_epoch: 1, + terminal_ids: HashMap::from([("web".into(), "stale-terminal".into())]), + }], + ); + assert_eq!( + workspace + .lock() + .project("project") + .unwrap() + .service_terminals, + HashMap::from([("web".into(), "incoming-terminal".into())]) + ); + + apply_service_terminal_writebacks( + &workspace, + &workspace_tick, + &None, + &None, + vec![ServiceTerminalWriteback { + project_id: "project".into(), + project_path: "/project".into(), + data_replacement_epoch: 0, + terminal_ids: HashMap::new(), + }], + ); + assert!( + workspace + .lock() + .project("project") + .unwrap() + .service_terminals + .is_empty() + ); + } + + #[test] + fn service_writeback_is_suppressed_during_backend_migration() { + let mut workspace_value = + workspace_with_service_terminal("project", "/project", "web", "owned-terminal"); + let migration_epoch = workspace_value + .begin_terminal_backend_migration( + okena_terminal::session_backend::SessionBackend::None, + &ShellType::Default, + ) + .expect("begin migration"); + let migration_epoch = migration_epoch.epoch; + let workspace = Arc::new(parking_lot::Mutex::new(workspace_value)); + let (workspace_tick, _rx) = tokio::sync::watch::channel(0u64); + + apply_service_terminal_writebacks( + &workspace, + &workspace_tick, + &None, + &None, + vec![ServiceTerminalWriteback { + project_id: "project".into(), + project_path: "/project".into(), + data_replacement_epoch: migration_epoch, + terminal_ids: HashMap::from([("web".into(), "old-backend".into())]), + }], + ); + + assert!( + workspace + .lock() + .project("project") + .expect("project") + .service_terminals + .is_empty() + ); + } + + #[tokio::test] + async fn due_service_retry_is_an_autonomous_sync_trigger() { + let (_tick, mut tick_rx) = tokio::sync::watch::channel(0u64); + let mut deadline = Some(tokio::time::Instant::now()); + + assert_eq!( + wait_for_service_sync_trigger(&mut tick_rx, &mut deadline).await, + Some(false) + ); + assert!(deadline.is_none()); + } + + #[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 sync_state = ServiceSyncState::default(); + + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut sync_state, &mut guard, &mut cx); + } + + // Non-remote, on-disk project is tracked; remote project is skipped. + assert!(sync_state.known.contains_key("local")); + assert!(!sync_state.known.contains_key("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 sync_state = ServiceSyncState::default(); + + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut sync_state, &mut guard, &mut cx); + } + + // Deferred worktree (missing dir) is NOT tracked, so a later pass retries. + assert!(!sync_state.known.contains_key("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 sync_state = ServiceSyncState::default(); + { + let projects = vec![project("local", &existing_path(), false)]; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut sync_state, &mut guard, &mut cx); + } + assert!(sync_state.known.contains_key("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 sync_state, &mut guard, &mut cx); + } + assert!(!sync_state.known.contains_key("local")); + } + + #[tokio::test] + async fn sync_services_suspends_closing_known_project_without_manager() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + let path = existing_path(); + let mut closing = project("local", &path, false); + closing.is_closing = true; + let identity = KnownProject { + path, + data_replacement_epoch: 0, + }; + let mut sync_state = ServiceSyncState { + known: HashMap::from([("local".to_string(), identity.clone())]), + ..ServiceSyncState::default() + }; + + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[closing], &mut sync_state, &mut guard, &mut cx); + } + + assert_eq!(sync_state.known.get("local"), Some(&identity)); + assert!(!sync_state.retry_projects.contains("local")); + assert!(sm.lock().project_path("local").is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn sync_services_suspends_lifecycle_then_resumes_once() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-lifecycle-suspend-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write service config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let mut creating = project("project", &project_path, false); + creating.is_creating = true; + creating + .service_terminals + .insert("web".into(), "persistent-web".into()); + let mut active = creating.clone(); + active.is_creating = false; + let mut sync_state = ServiceSyncState::default(); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[creating], &mut sync_state, &mut manager, &mut cx); + } + assert!( + backend + .reconnected + .lock() + .expect("reconnect lock") + .is_empty() + ); + assert!(!sync_state.known.contains_key("project")); + + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[active.clone()], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + backend.reconnected.lock().expect("reconnect lock").len() == 1 + && sm.lock().project_path("project") == Some(&project_path) + }) + .await; + + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[active.clone()], &mut sync_state, &mut manager, &mut cx); + } + assert_eq!(backend.reconnected.lock().expect("reconnect lock").len(), 1); + + let mut closing = active; + closing.is_closing = true; + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[closing], &mut sync_state, &mut manager, &mut cx); + } + }) + .await; + + assert_eq!(sm.lock().project_path("project"), Some(&project_path)); + assert!(sync_state.known.contains_key("project")); + assert_eq!(backend.reconnected.lock().expect("reconnect lock").len(), 1); + assert!(backend.killed.lock().expect("kill lock").is_empty()); + std::fs::remove_dir_all(project_dir).expect("remove project dir"); + } + + #[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 sync_state = ServiceSyncState::default(); + + // First pass loads and tracks. + { + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut sync_state, &mut guard, &mut cx); + } + let known_after_first = sync_state.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 sync_state, &mut guard, &mut cx); + } + assert_eq!(sync_state.known, known_after_first); + } + + #[tokio::test(flavor = "current_thread")] + async fn sync_services_adopts_recovered_directory_runtime_without_resetting_intent() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-renamed-runtime-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create renamed project dir"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: manual\n command: echo manual\n - name: automatic\n command: echo automatic\n auto_start: true\n", + ) + .expect("write service config"); + let new_path = project_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let local = tokio::task::LocalSet::new(); + + let mut sync_state = ServiceSyncState { + known: HashMap::from([( + "project".to_string(), + KnownProject { + path: "/previous/project/path".to_string(), + data_replacement_epoch: 0, + }, + )]), + ..ServiceSyncState::default() + }; + let mut renamed = project("project", &new_path, false); + renamed + .service_terminals + .insert("manual".into(), "manual-terminal".into()); + + let (reconnected_before_sync, killed_before_sync) = local + .run_until(async { + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + manager.set_project_writeback_owner("project", &new_path, 0); + assert_eq!( + manager.load_project_services_prepared( + "project", + &new_path, + &renamed.service_terminals, + prepare_project_config(&new_path), + &mut cx, + ), + ServiceLoadStatus::Loaded + ); + } + wait_for_test_condition(|| { + sm.lock() + .services_for_project("project") + .iter() + .any(|service| { + service.definition.name == "manual" + && service.status == okena_services::manager::ServiceStatus::Running + }) + }) + .await; + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + manager.stop_service("project", "automatic", &mut cx); + let reconnected_before_sync = + backend.reconnected.lock().expect("reconnect lock").clone(); + let killed_before_sync = backend.killed.lock().expect("kill lock").clone(); + sync_services(&[renamed], &mut sync_state, &mut manager, &mut cx); + (reconnected_before_sync, killed_before_sync) + } + }) + .await; + + let manager = sm.lock(); + let statuses: HashMap<_, _> = manager + .services_for_project("project") + .iter() + .map(|service| (service.definition.name.as_str(), service.status.clone())) + .collect(); + assert_eq!( + statuses.get("manual"), + Some(&okena_services::manager::ServiceStatus::Running) + ); + assert_eq!( + statuses.get("automatic"), + Some(&okena_services::manager::ServiceStatus::Stopped) + ); + assert_eq!( + sync_state.known.get("project"), + Some(&KnownProject { + path: new_path, + data_replacement_epoch: 0, + }) + ); + assert_eq!( + *backend.reconnected.lock().expect("reconnect lock"), + reconnected_before_sync + ); + assert_eq!( + *backend.killed.lock().expect("kill lock"), + killed_before_sync + ); + drop(manager); + std::fs::remove_dir_all(project_dir).expect("remove renamed project dir"); + } + + #[tokio::test] + async fn sync_services_reconciles_reused_id_after_data_replacement() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + let first_path = existing_path(); + let second_path = std::path::Path::new(&first_path) + .parent() + .unwrap() + .to_string_lossy() + .into_owned(); + let mut sync_state = ServiceSyncState::default(); + + { + let projects = vec![project("reused", &first_path, false)]; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut sync_state, &mut guard, &mut cx); + } + + { + let mut replacement = project("reused", &second_path, false); + replacement.data_replacement_epoch = 1; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[replacement], &mut sync_state, &mut guard, &mut cx); + } + + assert_eq!(sm.lock().project_path("reused"), Some(&second_path)); + assert_eq!( + sync_state + .known + .get("reused") + .map(|entry| entry.data_replacement_epoch), + Some(1) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn replacement_reconciliation_preserves_incoming_service_session() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-services-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write service config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let mut initial = project("project", &project_path, false); + initial + .service_terminals + .insert("web".into(), "persistent-web".into()); + let mut sync_state = ServiceSyncState::default(); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[initial], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + !backend + .reconnected + .lock() + .expect("reconnect lock") + .is_empty() + }) + .await; + + let mut replacement = project("project", &project_path, false); + replacement.data_replacement_epoch = 1; + replacement + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[replacement], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + backend.reconnected.lock().expect("reconnect lock").len() >= 2 + }) + .await; + }) + .await; + + assert!(backend.killed.lock().expect("kill lock").is_empty()); + assert_eq!( + backend + .reconnected + .lock() + .expect("reconnect lock") + .as_slice(), + &["persistent-web", "persistent-web"] + ); + std::fs::remove_dir_all(&project_dir).expect("remove project dir"); + } + + #[tokio::test(flavor = "current_thread")] + async fn replacement_kills_preserved_session_removed_from_current_config() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-stale-service-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + let config_path = project_dir.join("okena.yaml"); + std::fs::write( + &config_path, + "services:\n - name: web\n command: echo web\n", + ) + .expect("write initial service config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let mut initial = project("project", &project_path, false); + initial + .service_terminals + .insert("web".into(), "persistent-web".into()); + let mut sync_state = ServiceSyncState::default(); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[initial], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + !backend + .reconnected + .lock() + .expect("reconnect lock") + .is_empty() + }) + .await; + + std::fs::write( + &config_path, + "services:\n - name: replacement\n command: echo replacement\n", + ) + .expect("write replacement service config"); + let mut replacement = project("project", &project_path, false); + replacement.data_replacement_epoch = 1; + replacement + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[replacement], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| !backend.killed.lock().expect("kill lock").is_empty()) + .await; + }) + .await; + + assert_eq!( + backend.killed.lock().expect("kill lock").as_slice(), + &["persistent-web"] + ); + assert_eq!( + backend + .reconnected + .lock() + .expect("reconnect lock") + .as_slice(), + &["persistent-web"] + ); + assert!(sm.lock().service_terminal_ids("project").is_empty()); + std::fs::remove_dir_all(&project_dir).expect("remove project dir"); + } + + #[tokio::test(flavor = "current_thread")] + async fn replacement_kills_preserved_session_after_reconnect_failure_once() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-failed-reconnect-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write service config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: Some(1), + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let mut initial = project("project", &project_path, false); + initial + .service_terminals + .insert("web".into(), "persistent-web".into()); + let mut sync_state = ServiceSyncState::default(); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[initial], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + !backend + .reconnected + .lock() + .expect("reconnect lock") + .is_empty() + }) + .await; + + let mut replacement = project("project", &project_path, false); + replacement.data_replacement_epoch = 1; + replacement + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[replacement], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + backend.reconnected.lock().expect("reconnect lock").len() >= 2 + }) + .await; + wait_for_test_condition(|| !backend.killed.lock().expect("kill lock").is_empty()) + .await; + }) + .await; + + assert_eq!( + backend.killed.lock().expect("kill lock").as_slice(), + &["persistent-web"] + ); + assert_eq!( + backend + .reconnected + .lock() + .expect("reconnect lock") + .as_slice(), + &["persistent-web", "persistent-web"] + ); + assert!(sm.lock().service_terminal_ids("project").is_empty()); + std::fs::remove_dir_all(&project_dir).expect("remove project dir"); + } + + #[tokio::test(flavor = "current_thread")] + async fn missing_replacement_kills_pending_session_once_when_project_is_removed() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-pending-remove-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::write( + project_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write service config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let missing_path = project_dir.with_extension("missing"); + let missing_path = missing_path.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let mut sync_state = ServiceSyncState::default(); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let mut initial = project("project", &project_path, false); + initial + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[initial], &mut sync_state, &mut manager, &mut cx); + } + + let mut replacement = project("project", &missing_path, false); + replacement.data_replacement_epoch = 1; + replacement + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services( + &[replacement.clone()], + &mut sync_state, + &mut manager, + &mut cx, + ); + sync_services(&[replacement], &mut sync_state, &mut manager, &mut cx); + } + assert!(backend.killed.lock().expect("kill lock").is_empty()); + assert!(sync_state.pending_preserved.contains_key("project")); + assert!(sm.lock().service_terminal_writebacks().is_empty()); + + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[], &mut sync_state, &mut manager, &mut cx); + sync_services(&[], &mut sync_state, &mut manager, &mut cx); + } + }) + .await; + assert_eq!( + backend.killed.lock().expect("kill lock").as_slice(), + &["persistent-web"] + ); + assert!(!sync_state.pending_preserved.contains_key("project")); + std::fs::remove_dir_all(&project_dir).expect("remove project dir"); + } + + #[tokio::test(flavor = "current_thread")] + async fn missing_replacement_reconnects_pending_session_when_path_returns() { + let base = std::env::temp_dir().join(format!( + "okena-observer-pending-recover-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let initial_dir = base.join("initial"); + let recovered_dir = base.join("recovered"); + std::fs::create_dir_all(&initial_dir).expect("create initial project dir"); + std::fs::write( + initial_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write initial service config"); + let initial_path = initial_dir.to_string_lossy().into_owned(); + let recovered_path = recovered_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let mut sync_state = ServiceSyncState::default(); + let local = tokio::task::LocalSet::new(); + + local + .run_until(async { + let mut initial = project("project", &initial_path, false); + initial + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[initial], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + !backend + .reconnected + .lock() + .expect("reconnect lock") + .is_empty() + }) + .await; + + let mut replacement = project("project", &recovered_path, false); + replacement.data_replacement_epoch = 1; + replacement + .service_terminals + .insert("web".into(), "persistent-web".into()); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services( + &[replacement.clone()], + &mut sync_state, + &mut manager, + &mut cx, + ); + } + assert!(sync_state.pending_preserved.contains_key("project")); + assert!(sm.lock().service_terminal_writebacks().is_empty()); + + std::fs::create_dir_all(&recovered_dir).expect("create recovered project dir"); + std::fs::write( + recovered_dir.join("okena.yaml"), + "services:\n - name: web\n command: echo web\n", + ) + .expect("write recovered service config"); + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[replacement], &mut sync_state, &mut manager, &mut cx); + } + wait_for_test_condition(|| { + backend.reconnected.lock().expect("reconnect lock").len() >= 2 + }) + .await; + }) + .await; + + assert!(backend.killed.lock().expect("kill lock").is_empty()); + assert_eq!( + backend + .reconnected + .lock() + .expect("reconnect lock") + .as_slice(), + &["persistent-web", "persistent-web"] + ); + assert!(!sync_state.pending_preserved.contains_key("project")); + assert!(sync_state.known.contains_key("project")); + std::fs::remove_dir_all(&base).expect("remove project dirs"); + } + + #[tokio::test(flavor = "current_thread")] + async fn failed_load_retries_without_rekilling_discarded_session() { + let project_dir = std::env::temp_dir().join(format!( + "okena-observer-load-retry-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&project_dir).expect("create project dir"); + let config_path = project_dir.join("okena.yaml"); + std::fs::write(&config_path, "services: [").expect("write malformed config"); + let project_path = project_dir.to_string_lossy().into_owned(); + let backend = Arc::new(RecordingBackend { + killed: Mutex::new(Vec::new()), + reconnected: Mutex::new(Vec::new()), + reconnect_fail_after: None, + reconnect_count: AtomicUsize::new(0), + }); + let terminals = Arc::new(parking_lot::Mutex::new(Default::default())); + let sm = Arc::new(parking_lot::Mutex::new(ServiceManager::new( + backend.clone(), + terminals, + ))); + let rr = reactor_ref(&sm); + let mut sync_state = ServiceSyncState::default(); + let mut snapshot = project("project", &project_path, false); + snapshot + .service_terminals + .insert("web".into(), "persistent-web".into()); + + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[snapshot.clone()], &mut sync_state, &mut manager, &mut cx); + } + let first_deadline = sync_state.retry_deadline; + assert!(first_deadline.is_some()); + assert_eq!( + backend.killed.lock().expect("kill lock").as_slice(), + &["persistent-web"] + ); + assert_eq!( + sm.lock().service_terminal_writebacks(), + vec![ServiceTerminalWriteback { + project_id: "project".into(), + project_path: project_path.clone(), + data_replacement_epoch: 0, + terminal_ids: HashMap::new(), + }] + ); + + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[snapshot.clone()], &mut sync_state, &mut manager, &mut cx); + } + assert_eq!(sync_state.retry_deadline, first_deadline); + assert_eq!( + backend.killed.lock().expect("kill lock").as_slice(), + &["persistent-web"] + ); + + std::fs::write(&config_path, "services: []\n").expect("repair config"); + sync_state.retry_deadline = None; + { + let mut manager = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[snapshot], &mut sync_state, &mut manager, &mut cx); + } + assert!(sync_state.known.contains_key("project")); + assert!(!sync_state.retry_projects.contains("project")); + assert_eq!( + backend.killed.lock().expect("kill lock").as_slice(), + &["persistent-web"] + ); + std::fs::remove_dir_all(&project_dir).expect("remove project dir"); + } + + #[tokio::test] + async fn sync_services_unloads_changed_missing_path_and_reloads_after_recovery() { + let sm = std::sync::Arc::new(parking_lot::Mutex::new(manager())); + let rr = reactor_ref(&sm); + let mut sync_state = ServiceSyncState::default(); + + { + let projects = vec![project("local", &existing_path(), false)]; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&projects, &mut sync_state, &mut guard, &mut cx); + } + + { + let mut missing = project("local", "/path/that/does/not/exist/okena", false); + missing.data_replacement_epoch = 1; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[missing], &mut sync_state, &mut guard, &mut cx); + } + assert!(!sync_state.known.contains_key("local")); + assert!(sm.lock().project_path("local").is_none()); + + { + let mut recovered = project("local", &existing_path(), false); + recovered.data_replacement_epoch = 1; + let mut guard = sm.lock(); + let mut cx = rr.cx(); + sync_services(&[recovered], &mut sync_state, &mut guard, &mut cx); + } + assert!(sync_state.known.contains_key("local")); + let recovered_path = existing_path(); + assert_eq!(sm.lock().project_path("local"), Some(&recovered_path)); + } + + /// 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..bb268fd3c --- /dev/null +++ b/crates/okena-daemon-core/src/pty_loop.rs @@ -0,0 +1,1753 @@ +//! GPUI-free PTY event loop: the headless analogue of the GUI's batched +//! `async_channel` drain previously hosted by `okena-app`. +//! +//! 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::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +use okena_terminal::pty_manager::{PtyEvent, PtyGeneration, PtyManager}; +use okena_workspace::context::WorkspaceCx; +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>, + /// Terminal backend used to stop and restore project terminals around a + /// background worktree removal. + pub backend: 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.clone(), + service_tick.clone(), + ); + + 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, PtyGeneration, 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() { + let osc_hook_exits = process_osc_hook_exits(&dirty_terminal_ids, &terminals, &reactor); + if !osc_hook_exits.is_empty() { + resolve_osc_worktree_closes( + &osc_hook_exits, + &terminals, + &pty_manager, + &service_manager, + &service_tick, + &runtime, + &reactor, + ); + // The workspace tick carries the authoritative mutation to the + // state observer, but bump the coarse version here too so a + // client resync is not delayed behind another PTY event. + state_version.send_modify(|v| *v += 1); + } + // 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() { + let context = ExitHandlingContext { + terminals: &terminals, + pty_manager: pty_manager.as_ref(), + service_manager: &service_manager, + reactor_ref: &reactor_ref, + service_tick: &service_tick, + runtime: &runtime, + reactor: &reactor, + }; + handle_exits(&exit_events, &context); + // 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, PtyGeneration, Option)>, + dirty_terminal_ids: &mut Vec, + bytes_this_turn: &mut usize, +) { + match event { + PtyEvent::Data { + terminal_id, + generation, + data, + sequence, + } => { + if !pty_manager.is_current_generation(terminal_id, *generation) { + return; + } + // 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_with_sequence(data, *sequence); + } + dirty_terminal_ids.push(terminal_id.clone()); + } + PtyEvent::Exit { + terminal_id, + generation, + 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. + if pty_manager.cleanup_exited(terminal_id, *generation) { + exit_events.push((terminal_id.clone(), *generation, *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 and HookMonitor execution to Succeeded (code 0) / Failed otherwise, +/// and return its authoritative result for pending worktree-close resolution. +/// +/// 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 completion. +fn process_osc_hook_exits( + dirty_terminal_ids: &[String], + terminals: &TerminalsRegistry, + reactor: &PtyLoopReactor, +) -> Vec<(String, i32)> { + // 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, Option)> = 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, u32::try_from(exit_code).ok())); + } + } + } + let mut results = Vec::with_capacity(status_updates.len()); + if !status_updates.is_empty() { + let mut cx = reactor.workspace_cx(); + let mut ws = reactor.workspace.lock(); + for (tid, status, exit_code) in status_updates { + if let Some(monitor) = reactor.hook_monitor.as_ref() { + monitor.finish_by_terminal_id(&tid, exit_code); + } + let code = match &status { + HookTerminalStatus::Succeeded => 0, + HookTerminalStatus::Failed { exit_code } => *exit_code, + HookTerminalStatus::Running => unreachable!("OSC produces a completed hook status"), + }; + ws.update_hook_terminal_status(&tid, status, &mut cx); + results.push((tid, code)); + } + } + results +} + +/// Resolve before-remove hooks that reported an authoritative result through +/// OSC while their PTY remains alive. The pending map is the exactly-once claim: +/// a late PTY Exit or repeated title observes no pending entry and cannot delete +/// a project or overwrite the completed hook state. +#[allow(clippy::too_many_arguments)] +fn resolve_osc_worktree_closes( + osc_hook_exits: &[(String, i32)], + terminals: &TerminalsRegistry, + pty_manager: &PtyManager, + service_manager: &Arc>, + service_tick: &watch::Sender, + runtime: &Handle, + reactor: &PtyLoopReactor, +) { + let global_hooks = reactor.settings.lock().hooks.clone(); + for (terminal_id, exit_code) in osc_hook_exits { + let mut cx = reactor.workspace_cx(); + let mut ws = reactor.workspace.lock(); + let Some(pending) = ws.take_pending_worktree_close(terminal_id) else { + continue; + }; + + if *exit_code == 0 { + // A keep-alive shell can retain the worktree CWD after reporting + // success. Tear down its PTY before starting the canonical removal; + // unlike the watchdog this result is authoritative, not inferred. + ws.remove_hook_terminal(terminal_id, &mut cx); + match ws.begin_worktree_removal(&pending.project_id, &global_hooks, &mut cx) { + Ok(plan) => { + let operation_epoch = ws.data_replacement_epoch(); + drop(ws); + pty_manager.kill(terminal_id); + terminals.lock().remove(terminal_id); + let _ = crate::command_loop::spawn_background_worktree_removal( + plan, + operation_epoch, + false, + std::slice::from_ref(terminal_id), + &global_hooks, + &reactor.workspace, + &reactor.workspace_tick, + &reactor.hook_runner, + &reactor.hook_monitor, + &reactor.backend, + terminals, + &reactor.settings, + service_manager, + service_tick, + runtime, + ); + } + Err(error) => { + let project_name = ws + .project(&pending.project_id) + .map(|project| project.name.clone()) + .unwrap_or_else(|| pending.project_id.clone()); + ws.finish_closing_project(&pending.project_id); + cx.notify(); + if let Some(monitor) = reactor.hook_monitor.as_ref() { + monitor.push_toast(okena_state::Toast::error(format!( + "\"{project_name}\" was not closed: {error}" + ))); + } + } + } + } else { + ws.finish_closing_project(&pending.project_id); + cx.notify(); + // `process_osc_hook_exits` already completed the HookMonitor with + // this authoritative nonzero code, which queues its single failure + // toast. Do not enqueue a second toast for the same hook result. + } + } +} + +/// 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 +} + +struct ExitHandlingContext<'a> { + terminals: &'a TerminalsRegistry, + pty_manager: &'a PtyManager, + service_manager: &'a Arc>, + reactor_ref: &'a ServiceReactorRef, + service_tick: &'a watch::Sender, + runtime: &'a Handle, + reactor: &'a PtyLoopReactor, +} + +/// 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, PtyGeneration, Option)], + context: &ExitHandlingContext<'_>, +) { + // ── 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 = context.service_manager.lock(); + let mut cx = context.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) = context.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, context); + + // ── 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 = context.reactor.settings.lock().hooks.clone(); + let close_infos = collect_terminal_close_infos( + exit_events, + &service_tids, + &hook_tids, + context.reactor, + &global_hooks, + ); + let monitor = context.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 = context.terminals.lock(); + for (terminal_id, generation, _) in exit_events { + if !service_tids.contains(terminal_id) && !hook_tids.contains(terminal_id) { + context.pty_manager.kill_exited(terminal_id, *generation); + 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 = context.reactor.workspace_cx(); + let mut ws = context.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, PtyGeneration, Option)], + service_tids: &HashSet, + context: &ExitHandlingContext<'_>, +) -> HashSet { + let hook_tids: HashSet = { + let ws = context.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 = context.reactor.settings.lock().hooks.clone(); + + for (terminal_id, generation, exit_code) in exit_events { + if !hook_tids.contains(terminal_id) { + continue; + } + + let success = *exit_code == Some(0); + let tid = terminal_id.clone(); + + // Set hook status + resolve any pending worktree close. + let mut cx = context.reactor.workspace_cx(); + let mut ws = context.reactor.workspace.lock(); + let hook_is_running = ws.projects().iter().any(|project| { + project + .hook_terminals + .get(&tid) + .is_some_and(|entry| entry.status == HookTerminalStatus::Running) + }); + // A completed keep-alive hook may be intentionally torn down while its + // scrollback remains registered; its late PTY exit must not rewrite it. + if !hook_is_running { + continue; + } + + if let Some(monitor) = context.reactor.hook_monitor.as_ref() { + monitor.finish_by_terminal_id(&tid, *exit_code); + } + + 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 { + // The exited hook terminal is the `before_worktree_remove` hook + // registered with this pending close, NOT `on_worktree_close`, so + // firing `on_worktree_close` (in begin_worktree_removal) does not + // double-fire any hook. + ws.remove_hook_terminal(&tid, &mut cx); + + // Snapshot inputs + fire `on_worktree_close` under the lock, then + // run the removal OFF the reactor. Previously the whole removal — + // including `git worktree remove`, whose expensive status checks + + // directory delete take SECONDS on a busy worktree (Docker holding + // files) — ran synchronously here holding the workspace lock, + // freezing every other daemon action until it finished. Now we hold + // the lock only for the snapshot, run the git on a blocking thread + // (fast removal, matching the GUI's hook-close path), and finalize + // state (delete_project + worktree_removed) when it completes. + match ws.begin_worktree_removal(&pending.project_id, &global_hooks, &mut cx) { + Ok(plan) => { + let operation_epoch = ws.data_replacement_epoch(); + drop(ws); + teardown_completed_pending_hook( + &tid, + *generation, + context.terminals, + context.pty_manager, + ); + let _ = crate::command_loop::spawn_background_worktree_removal( + plan, + operation_epoch, + false, + std::slice::from_ref(&tid), + &global_hooks, + &context.reactor.workspace, + &context.reactor.workspace_tick, + &context.reactor.hook_runner, + &context.reactor.hook_monitor, + &context.reactor.backend, + context.terminals, + &context.reactor.settings, + context.service_manager, + context.service_tick, + context.runtime, + ); + } + Err(e) => { + // The removal was rejected after the hook already ran + // (e.g. the worktree is still mid-create). Abort like the + // hook-failure arm below: clear the mirrored `is_closing` + // marker so no client sticks at "Closing…" (and so the + // create-failure rollback isn't blocked by a closing + // project), notify, and toast why. + log::error!( + "worktree-close: begin_worktree_removal failed for {}: {e}", + pending.project_id + ); + let project_name = ws + .project(&pending.project_id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| pending.project_id.clone()); + ws.finish_closing_project(&pending.project_id); + cx.notify(); + if let Some(hm) = context.reactor.hook_monitor.as_ref() { + hm.push_toast(okena_state::Toast::error(format!( + "\"{project_name}\" was not closed: {e}" + ))); + } + drop(ws); + teardown_completed_pending_hook( + &tid, + *generation, + context.terminals, + context.pty_manager, + ); + } + } + } else { + // Hook failed → abort the close: unmark the project as closing + // (clearing the mirrored `is_closing` marker heals the initiating + // client's optimistic "Closing…" row), notify so the cleared flag + // reaches clients, and toast why the worktree wasn't removed. + let project_name = ws + .project(&pending.project_id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| pending.project_id.clone()); + ws.finish_closing_project(&pending.project_id); + cx.notify(); + if let Some(hm) = context.reactor.hook_monitor.as_ref() { + hm.push_toast(okena_state::Toast::error(format!( + "before_worktree_remove hook failed — \"{project_name}\" was not closed" + ))); + } + } + } + // Hook terminal persists on non-close paths — no auto-cleanup. A client + // can dismiss or rerun it. + } + + hook_tids +} + +fn teardown_completed_pending_hook( + terminal_id: &str, + generation: PtyGeneration, + terminals: &TerminalsRegistry, + pty_manager: &PtyManager, +) { + if !pty_manager.kill_exited(terminal_id, generation) { + log::warn!( + "worktree-close: exited before-remove hook {terminal_id} no longer owns its PTY generation" + ); + } + terminals.lock().remove(terminal_id); +} + +/// Args for a single `terminal.on_close` hook firing, collected under the +/// workspace 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. Retained ownership is +/// consumed here so duplicate exit events cannot fire the lifecycle twice. +fn collect_terminal_close_infos( + exit_events: &[(String, PtyGeneration, 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 mut ws = reactor.workspace.lock(); + exit_events + .iter() + .filter(|(tid, _, _)| !service_tids.contains(tid) && !hook_tids.contains(tid)) + .filter_map(|(tid, _, exit_code)| { + let layout_project_id = ws.find_project_for_terminal(tid).map(|p| p.id.clone()); + let retained_owner = ws.take_closing_terminal_owner(tid); + let (project_id, retained_terminal_name) = match (layout_project_id, retained_owner) { + (Some(project_id), retained) => (project_id, retained.and_then(|(_, name)| name)), + (None, Some(owner)) => owner, + (None, None) => return None, + }; + let p = ws.project(&project_id)?; + 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() + .or(retained_terminal_name); + 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_hooks::HookStatus; + use okena_state::{HookTerminalEntry, ProjectData, WorktreeMetadata}; + use okena_terminal::backend::LocalBackend; + use okena_terminal::session_backend::SessionBackend; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::{Terminal, TerminalSize}; + use okena_workspace::state::{LayoutNode, PendingWorktreeClose, WorkspaceData}; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + use std::process::Command; + use std::time::Duration; + + 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 (pty_manager, _events) = PtyManager::new(SessionBackend::None); + test_reactor_with_manager(workspace, settings, Arc::new(pty_manager)) + } + + fn test_reactor_with_manager( + workspace: Workspace, + settings: AppSettings, + pty_manager: Arc, + ) -> PtyLoopReactor { + let (workspace_tick, _wrx) = watch::channel(0u64); + let backend = Arc::new(LocalBackend::new(pty_manager)); + PtyLoopReactor { + workspace: Arc::new(Mutex::new(workspace)), + backend, + hook_runner: None, + hook_monitor: Some(HookMonitor::new()), + workspace_tick, + settings: Arc::new(Mutex::new(settings)), + } + } + + fn run_git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn real_git_worktree() -> (PathBuf, PathBuf) { + let repo = std::env::temp_dir().join(format!( + "okena-pty-close-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let worktree = repo.with_extension("worktree"); + std::fs::create_dir_all(&repo).expect("create repository directory"); + run_git(&repo, &["init", "-q", "-b", "main"]); + run_git(&repo, &["config", "user.email", "test@okena.local"]); + run_git(&repo, &["config", "user.name", "Okena Test"]); + std::fs::write(repo.join("file.txt"), "base\n").expect("write fixture"); + run_git(&repo, &["add", "file.txt"]); + run_git(&repo, &["commit", "-q", "-m", "base"]); + run_git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + worktree.to_str().expect("utf-8 worktree path"), + ], + ); + (repo, worktree) + } + + fn workspace_with_pending_close( + main_repo: &Path, + worktree: &Path, + hook_terminal_id: &str, + ) -> Workspace { + let parent = ProjectData { + id: "parent".into(), + name: "Parent".into(), + path: main_repo.to_string_lossy().into_owned(), + layout: None, + terminal_names: Default::default(), + hidden_terminals: Default::default(), + worktree_info: None, + worktree_ids: vec!["wt1".into()], + 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, + is_creating: false, + is_closing: false, + }; + let child = ProjectData { + id: "wt1".into(), + name: "Feature".into(), + path: worktree.to_string_lossy().into_owned(), + layout: None, + terminal_names: HashMap::from([( + hook_terminal_id.to_string(), + "Before remove".to_string(), + )]), + hidden_terminals: Default::default(), + worktree_info: Some(WorktreeMetadata { + parent_project_id: "parent".into(), + color_override: None, + main_repo_path: main_repo.to_string_lossy().into_owned(), + worktree_path: worktree.to_string_lossy().into_owned(), + branch_name: "feature".into(), + }), + 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: HashMap::from([( + hook_terminal_id.to_string(), + HookTerminalEntry { + label: "Before remove".into(), + status: HookTerminalStatus::Running, + hook_type: "before_worktree_remove".into(), + command: "true".into(), + cwd: worktree.to_string_lossy().into_owned(), + }, + )]), + pinned: false, + last_activity_at: None, + is_creating: false, + is_closing: false, + }; + let mut workspace = Workspace::new(WorkspaceData { + version: 1, + projects: vec![parent, child], + project_order: vec!["parent".into()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + }); + workspace.register_pending_worktree_close(PendingWorktreeClose { + project_id: "wt1".into(), + hook_terminal_id: hook_terminal_id.into(), + branch: "feature".into(), + main_repo_path: main_repo.to_string_lossy().into_owned(), + }); + workspace + } + + fn plain_project(terminal_id: &str) -> ProjectData { + ProjectData { + id: "project-1".into(), + name: "Project One".into(), + path: "/tmp/project-one".into(), + layout: Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id.into()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }), + terminal_names: HashMap::from([(terminal_id.into(), "Build shell".into())]), + 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, + is_creating: false, + is_closing: false, + } + } + + #[test] + fn terminal_close_uses_retained_owner_after_layout_removal_once() { + let mut project = plain_project("terminal-1"); + project.hooks.terminal.on_close = Some("echo closed".into()); + let mut workspace = Workspace::new(WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["project-1".into()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + }); + workspace.remember_closing_terminal_owner("project-1", "terminal-1"); + let project = workspace.data.projects.first_mut().expect("project"); + project.layout = None; + project.terminal_names.clear(); + + let reactor = test_reactor(workspace, AppSettings::default()); + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + pty_manager + .create_or_reconnect_terminal(Some("terminal-1"), &cwd) + .expect("create tracked PTY"); + let generation = pty_manager + .current_generation("terminal-1") + .expect("tracked generation"); + let exits = vec![("terminal-1".to_string(), generation, Some(9))]; + let info = collect_terminal_close_infos( + &exits, + &HashSet::new(), + &HashSet::new(), + &reactor, + &okena_state::HooksConfig::default(), + ); + + assert_eq!(info.len(), 1); + assert_eq!(info[0].project_id, "project-1"); + assert_eq!(info[0].terminal_name.as_deref(), Some("Build shell")); + assert_eq!(info[0].exit_code, Some(9)); + assert!( + collect_terminal_close_infos( + &exits, + &HashSet::new(), + &HashSet::new(), + &reactor, + &okena_state::HooksConfig::default(), + ) + .is_empty(), + "retained ownership is consumed by the first exit" + ); + } + + #[test] + fn osc_hook_exit_finishes_monitor_and_queues_failure_toast() { + let mut project = plain_project("hook-1"); + project.hook_terminals.insert( + "hook-1".into(), + HookTerminalEntry { + label: "Open hook".into(), + status: HookTerminalStatus::Running, + hook_type: "on_project_open".into(), + command: "exit 7".into(), + cwd: "/tmp/project-one".into(), + }, + ); + let workspace = Workspace::new(WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["project-1".into()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + }); + let reactor = test_reactor(workspace, AppSettings::default()); + let monitor = reactor.hook_monitor.clone().expect("hook monitor"); + monitor.record_start( + "on_project_open", + "exit 7", + "Project One", + Some("hook-1".into()), + ); + + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let terminal = Arc::new(Terminal::new( + "hook-1".into(), + terminal_size(), + reactor.backend.transport(), + "/tmp/project-one".into(), + )); + terminal.process_output(b"\x1b]0;__okena_hook_exit:7\x07"); + terminals.lock().insert("hook-1".into(), terminal); + + process_osc_hook_exits(&["hook-1".into()], &terminals, &reactor); + + let workspace = reactor.workspace.lock(); + assert!(matches!( + workspace + .project("project-1") + .expect("project") + .hook_terminals["hook-1"] + .status, + HookTerminalStatus::Failed { exit_code: 7 } + )); + drop(workspace); + assert!(matches!( + monitor.history()[0].status, + HookStatus::Failed { exit_code: 7, .. } + )); + assert_eq!(monitor.drain_pending_toasts().len(), 1); + } + + #[tokio::test] + async fn nonzero_osc_hook_exit_aborts_pending_worktree_close_once() { + let repo = std::env::temp_dir().join("okena-osc-hook-failure-main"); + let worktree = std::env::temp_dir().join("okena-osc-hook-failure-worktree"); + let reactor = test_reactor( + workspace_with_pending_close(&repo, &worktree, "hook-osc"), + AppSettings::default(), + ); + let monitor = reactor.hook_monitor.clone().expect("hook monitor"); + monitor.record_start( + "before_worktree_remove", + "exit 7", + "Feature", + Some("hook-osc".into()), + ); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let terminal = Arc::new(Terminal::new( + "hook-osc".into(), + terminal_size(), + reactor.backend.transport(), + worktree.to_string_lossy().into_owned(), + )); + terminal.process_output(b"\x1b]0;__okena_hook_exit:7\x07"); + terminals.lock().insert("hook-osc".into(), terminal); + let osc_results = process_osc_hook_exits(&["hook-osc".into()], &terminals, &reactor); + assert_eq!(osc_results, vec![("hook-osc".into(), 7)]); + + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let services = Arc::new(Mutex::new(ServiceManager::new( + reactor.backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_rx) = watch::channel(0u64); + resolve_osc_worktree_closes( + &osc_results, + &terminals, + &pty_manager, + &services, + &service_tick, + &Handle::current(), + &reactor, + ); + let workspace = reactor.workspace.lock(); + let project = workspace + .project("wt1") + .expect("failed hook retains worktree"); + assert!(!workspace.is_project_closing("wt1")); + assert!(!project.is_closing); + assert!(matches!( + project.hook_terminals["hook-osc"].status, + HookTerminalStatus::Failed { exit_code: 7 } + )); + drop(workspace); + assert_eq!(monitor.drain_pending_toasts().len(), 1); + + resolve_osc_worktree_closes( + &osc_results, + &terminals, + &pty_manager, + &services, + &service_tick, + &Handle::current(), + &reactor, + ); + assert!( + monitor.drain_pending_toasts().is_empty(), + "late OSC is a no-op" + ); + } + + async fn drive_hook_exit_through_pty_loop( + reactor: PtyLoopReactor, + terminals: TerminalsRegistry, + terminal_id: &str, + exit_code: Option, + ) { + let (pty_manager, _unused_events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + pty_manager + .create_or_reconnect_terminal(Some(terminal_id), &cwd) + .expect("create tracked hook PTY"); + let generation = pty_manager + .current_generation(terminal_id) + .expect("tracked generation"); + let pty_manager = Arc::new(pty_manager); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + reactor.backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = Handle::current(); + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + let context = ExitHandlingContext { + terminals: &terminals, + pty_manager: pty_manager.as_ref(), + service_manager: &service_manager, + reactor_ref: &reactor_ref, + service_tick: &service_tick, + runtime: &runtime, + reactor: &reactor, + }; + handle_exits( + &[(terminal_id.to_string(), generation, exit_code)], + &context, + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn successful_hook_pty_exit_removes_real_worktree() { + let (repo, worktree) = real_git_worktree(); + let (pty_manager, pty_events) = PtyManager::new(SessionBackend::None); + let hook_terminal_id = pty_manager + .create_terminal_with_shell( + worktree.to_str().expect("utf-8 worktree path"), + Some(&ShellType::for_command("exit 0".to_string())), + ) + .expect("create before-remove hook PTY"); + let pty_manager = Arc::new(pty_manager); + let reactor = test_reactor_with_manager( + workspace_with_pending_close(&repo, &worktree, &hook_terminal_id), + AppSettings::default(), + pty_manager.clone(), + ); + let workspace = reactor.workspace.clone(); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + terminals.lock().insert( + hook_terminal_id.clone(), + Arc::new(Terminal::new( + hook_terminal_id.clone(), + terminal_size(), + pty_manager.clone(), + worktree.to_string_lossy().into_owned(), + )), + ); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + reactor.backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_rx) = watch::channel(0u64); + let runtime = Handle::current(); + let reactor_ref = ServiceReactorRef::new( + service_manager.clone(), + runtime.clone(), + service_tick.clone(), + ); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let mut exit_events = Vec::new(); + let mut dirty_terminal_ids = Vec::new(); + let mut bytes_this_turn = 0; + tokio::time::timeout(Duration::from_secs(2), async { + while exit_events.is_empty() { + let event = pty_events.recv().await.expect("receive hook PTY event"); + process_event( + &event, + &terminals, + &pty_manager, + &mut exit_events, + &mut dirty_terminal_ids, + &mut bytes_this_turn, + ); + } + }) + .await + .expect("before-remove hook exits"); + + let context = ExitHandlingContext { + terminals: &terminals, + pty_manager: pty_manager.as_ref(), + service_manager: &service_manager, + reactor_ref: &reactor_ref, + service_tick: &service_tick, + runtime: &runtime, + reactor: &reactor, + }; + handle_exits(&exit_events, &context); + + assert!( + !terminals.lock().contains_key(&hook_terminal_id), + "successful pending hook releases registry ownership" + ); + assert!( + pty_manager.current_generation(&hook_terminal_id).is_none(), + "successful pending hook releases PTY generation ownership" + ); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if workspace.lock().project("wt1").is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("PTY completion removes worktree"); + }) + .await; + + assert!(!worktree.exists(), "checkout was physically removed"); + assert!( + !String::from_utf8_lossy( + &Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(&repo) + .output() + .expect("list worktrees") + .stdout + ) + .contains(worktree.to_string_lossy().as_ref()), + "git worktree registration was pruned" + ); + assert!( + workspace + .lock() + .project("parent") + .expect("parent remains") + .worktree_ids + .is_empty() + ); + std::fs::remove_dir_all(repo).ok(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn successful_osc_hook_exit_removes_worktree_once() { + let (repo, worktree) = real_git_worktree(); + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let hook_terminal_id = pty_manager + .create_terminal_with_shell( + worktree.to_str().expect("utf-8 worktree path"), + Some(&ShellType::for_command("sleep 30".to_string())), + ) + .expect("create keep-alive before-remove hook PTY"); + let pty_manager = Arc::new(pty_manager); + let reactor = test_reactor_with_manager( + workspace_with_pending_close(&repo, &worktree, &hook_terminal_id), + AppSettings::default(), + pty_manager.clone(), + ); + let workspace = reactor.workspace.clone(); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let terminal = Arc::new(Terminal::new( + hook_terminal_id.clone(), + terminal_size(), + pty_manager.clone(), + worktree.to_string_lossy().into_owned(), + )); + terminal.process_output(b"\x1b]0;__okena_hook_exit:0\x07"); + terminals.lock().insert(hook_terminal_id.clone(), terminal); + let osc_results = process_osc_hook_exits( + std::slice::from_ref(&hook_terminal_id), + &terminals, + &reactor, + ); + assert_eq!(osc_results, vec![(hook_terminal_id.clone(), 0)]); + let service_manager = Arc::new(Mutex::new(ServiceManager::new( + reactor.backend.clone(), + terminals.clone(), + ))); + let (service_tick, _service_rx) = watch::channel(0u64); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + resolve_osc_worktree_closes( + &osc_results, + &terminals, + &pty_manager, + &service_manager, + &service_tick, + &Handle::current(), + &reactor, + ); + tokio::time::timeout(Duration::from_secs(3), async { + while workspace.lock().project("wt1").is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("OSC success removes worktree through canonical path"); + resolve_osc_worktree_closes( + &osc_results, + &terminals, + &pty_manager, + &service_manager, + &service_tick, + &Handle::current(), + &reactor, + ); + }) + .await; + + assert!(!worktree.exists(), "checkout was physically removed once"); + assert!(workspace.lock().project("wt1").is_none()); + std::fs::remove_dir_all(repo).ok(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_hook_pty_exit_aborts_pending_close() { + let repo = std::env::temp_dir().join("okena-hook-failure-main"); + let worktree = std::env::temp_dir().join("okena-hook-failure-worktree"); + let reactor = test_reactor( + workspace_with_pending_close(&repo, &worktree, "hook-1"), + AppSettings::default(), + ); + let workspace = reactor.workspace.clone(); + let hook_monitor = reactor.hook_monitor.clone().expect("hook monitor"); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + + let local = tokio::task::LocalSet::new(); + local + .run_until(drive_hook_exit_through_pty_loop( + reactor, + terminals, + "hook-1", + Some(7), + )) + .await; + + let workspace_guard = workspace.lock(); + let project = workspace_guard.project("wt1").expect("project retained"); + assert!(!project.is_closing); + assert!(!workspace_guard.is_project_closing("wt1")); + assert!(matches!( + project.hook_terminals["hook-1"].status, + HookTerminalStatus::Failed { exit_code: 7 } + )); + drop(workspace_guard); + assert_eq!(hook_monitor.drain_pending_toasts().len(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn late_exit_preserves_completed_hook_status_and_registry_buffer() { + let mut project = plain_project("ordinary"); + project.hook_terminals.insert( + "completed-hook".into(), + HookTerminalEntry { + label: "Completed hook".into(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".into(), + command: "echo done".into(), + cwd: "/tmp/project-one".into(), + }, + ); + let workspace = Workspace::new(WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["project-1".into()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + }); + let reactor = test_reactor(workspace, AppSettings::default()); + let workspace = reactor.workspace.clone(); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let retained = Arc::new(Terminal::new( + "completed-hook".into(), + terminal_size(), + reactor.backend.transport(), + "/tmp/project-one".into(), + )); + retained.process_output(b"preserved output\r\n"); + terminals + .lock() + .insert("completed-hook".into(), retained.clone()); + + let local = tokio::task::LocalSet::new(); + local + .run_until(drive_hook_exit_through_pty_loop( + reactor, + terminals.clone(), + "completed-hook", + None, + )) + .await; + + assert!(matches!( + workspace + .lock() + .project("project-1") + .expect("project") + .hook_terminals["completed-hook"] + .status, + HookTerminalStatus::Succeeded + )); + assert!(Arc::ptr_eq( + terminals + .lock() + .get("completed-hook") + .expect("retained terminal"), + &retained + )); + } + + /// `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 tracked PTY generation validates the synthesized data event. + let (pty_manager, _pty_manager_events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let terminal_id = pty_manager + .create_terminal_with_shell(&cwd, None) + .expect("create tracked PTY instance"); + let generation = pty_manager + .current_generation(&terminal_id) + .expect("current PTY generation"); + 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( + terminal_id.clone(), + terminal_size(), + transport, + cwd, + )); + let gen_before = term.content_generation(); + terminals.lock().insert(terminal_id.clone(), 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, + generation, + data: b"hello".to_vec(), + sequence: 0, + }) + .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(), + ); + } + + #[test] + fn duplicate_exit_events_are_claimed_once_across_batches() { + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let terminal_id = pty_manager + .create_or_reconnect_terminal(Some("duplicate-exit"), &cwd) + .expect("create PTY"); + let generation = pty_manager + .current_generation(&terminal_id) + .expect("generation"); + let event = PtyEvent::Exit { + terminal_id, + generation, + exit_code: Some(0), + }; + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let mut dirty = Vec::new(); + let mut bytes = 0; + + let mut first_batch = Vec::new(); + process_event( + &event, + &terminals, + &pty_manager, + &mut first_batch, + &mut dirty, + &mut bytes, + ); + assert_eq!(first_batch.len(), 1); + + let mut second_batch = Vec::new(); + process_event( + &event, + &terminals, + &pty_manager, + &mut second_batch, + &mut dirty, + &mut bytes, + ); + assert!(second_batch.is_empty()); + pty_manager.flush_teardown(); + } + + #[test] + fn delayed_old_generation_exit_does_not_claim_reconnected_terminal() { + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let terminal_id = pty_manager + .create_or_reconnect_terminal(Some("reconnected"), &cwd) + .expect("create old PTY"); + let old_generation = pty_manager + .current_generation(&terminal_id) + .expect("old generation"); + pty_manager.kill(&terminal_id); + pty_manager + .create_or_reconnect_terminal(Some(&terminal_id), &cwd) + .expect("create new PTY generation"); + let new_generation = pty_manager + .current_generation(&terminal_id) + .expect("new generation"); + assert_ne!(old_generation, new_generation); + + let event = PtyEvent::Exit { + terminal_id: terminal_id.clone(), + generation: old_generation, + exit_code: Some(0), + }; + let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default())); + let mut exits = Vec::new(); + process_event( + &event, + &terminals, + &pty_manager, + &mut exits, + &mut Vec::new(), + &mut 0, + ); + + assert!(exits.is_empty()); + assert!(pty_manager.is_current_generation(&terminal_id, new_generation)); + pty_manager.kill(&terminal_id); + pty_manager.flush_teardown(); + } +} diff --git a/crates/okena-daemon-core/src/reactor.rs b/crates/okena-daemon-core/src/reactor.rs new file mode 100644 index 000000000..1a99fc982 --- /dev/null +++ b/crates/okena-daemon-core/src/reactor.rs @@ -0,0 +1,90 @@ +//! 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::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +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, + + /// Blocking autosave jobs that must drain before the final shutdown save. + pub(crate) autosave_tracker: Arc, + + /// 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), + autosave_tracker: Arc::new(crate::observers::AutosaveTracker::default()), + 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..d349a72ef --- /dev/null +++ b/crates/okena-daemon-core/src/service_cx.rs @@ -0,0 +1,244 @@ +//! 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, + task: impl FnOnce() -> T + Send + 'static, + ) -> impl Future + where + T: Send + 'static, + { + let join = self.reactor.runtime.spawn_blocking(task); + async move { join.await.expect("daemon spawn_blocking: task panicked") } + } + + fn timer(&self, duration: Duration) -> impl Future { + tokio::time::sleep(duration) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::PtyManager; + use okena_terminal::session_backend::SessionBackend; + + #[test] + fn blocking_service_work_does_not_stall_a_single_runtime_worker() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let manager = Arc::new(Mutex::new(ServiceManager::new( + Arc::new(LocalBackend::new(Arc::new(pty_manager))), + Arc::new(Mutex::new(Default::default())), + ))); + let (service_tick, _service_tick_rx) = watch::channel(0); + let async_cx = DaemonServiceAsyncCx { + reactor: ServiceReactor { + manager, + runtime: runtime.handle().clone(), + service_tick, + }, + }; + + runtime.block_on(async { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocked = async_cx.spawn_blocking(move || { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok::<_, &'static str>(42) + }); + started_rx.await.unwrap(); + + let (regular_tx, regular_rx) = tokio::sync::oneshot::channel(); + runtime.handle().spawn(async move { + regular_tx.send(()).unwrap(); + }); + tokio::time::timeout(Duration::from_secs(1), regular_rx) + .await + .expect("regular async work was stalled") + .unwrap(); + + release_tx.send(()).unwrap(); + assert_eq!(blocked.await, Ok(42)); + let error = async_cx + .spawn_blocking(|| Err::<(), _>("expected error")) + .await; + assert_eq!(error, Err("expected error")); + }); + } +} 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..51cb2f83a --- /dev/null +++ b/crates/okena-daemon-core/src/soft_close.rs @@ -0,0 +1,240 @@ +//! 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::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +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; + finalize_expired_once( + &workspace, + &backend, + &terminals, + &workspace_tick, + &hook_runner, + &hook_monitor, + &deadlines, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn finalize_expired_once( + workspace: &Arc>, + backend: &Arc, + terminals: &TerminalsRegistry, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, + deadlines: &SoftCloseDeadlines, +) { + let terminal_ids = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + finalize_expired(deadlines, &mut ws, &mut cx) + }; + for terminal_id in terminal_ids { + backend.kill(&terminal_id); + terminals.lock().remove(&terminal_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::StubTransport; + use okena_state::{LayoutNode, ProjectData, WorkspaceData}; + use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::TerminalTransport; + use okena_workspace::focus::FocusManager; + use std::collections::HashMap; + + struct KillBarrierBackend { + started: std::sync::mpsc::Sender, + release: Mutex>, + } + + impl TerminalBackend for KillBarrierBackend { + fn transport(&self) -> Arc { + Arc::new(StubTransport) + } + fn create_terminal( + &self, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("kill barrier backend does not create terminals") + } + fn reconnect_terminal( + &self, + _terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + anyhow::bail!("kill barrier backend does not reconnect terminals") + } + fn kill(&self, terminal_id: &str) { + self.started + .send(terminal_id.to_string()) + .expect("kill waiter remains alive"); + self.release + .lock() + .recv_timeout(Duration::from_secs(2)) + .expect("test releases kill barrier"); + } + 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 workspace_with_terminal() -> WorkspaceData { + let project = ProjectData { + id: "p1".to_string(), + name: "Project p1".to_string(), + path: "/tmp".to_string(), + layout: Some(LayoutNode::Terminal { + terminal_id: Some("t1".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, + is_creating: false, + is_closing: false, + }; + 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(), + } + } + + #[test] + fn expired_soft_close_releases_workspace_before_kill() { + let workspace = Arc::new(Mutex::new(Workspace::new(workspace_with_terminal()))); + let deadlines: SoftCloseDeadlines = Arc::new(Mutex::new(HashMap::new())); + let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new())); + let (workspace_tick, _workspace_rx) = watch::channel(0u64); + { + let mut focus_manager = FocusManager::new(); + let mut cx = DaemonWorkspaceCx::new(&workspace_tick, &None, &None); + workspace.lock().begin_soft_close( + &mut focus_manager, + "p1", + &[], + "t1", + "soft-close:t1", + &mut cx, + ); + deadlines.lock().insert( + "t1".to_string(), + std::time::Instant::now() - Duration::from_secs(1), + ); + } + + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let backend: Arc = Arc::new(KillBarrierBackend { + started: started_tx, + release: Mutex::new(release_rx), + }); + let task_workspace = workspace.clone(); + let task_backend = backend.clone(); + let task_terminals = terminals.clone(); + let task_deadlines = deadlines.clone(); + let task = std::thread::spawn(move || { + finalize_expired_once( + &task_workspace, + &task_backend, + &task_terminals, + &workspace_tick, + &None, + &None, + &task_deadlines, + ); + }); + + assert_eq!( + started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("backend kill started"), + "t1" + ); + assert!( + !workspace + .try_lock() + .expect("workspace remains available while backend kill waits") + .has_pending_close("t1") + ); + assert!(deadlines.lock().is_empty()); + release_tx.send(()).expect("release backend kill"); + task.join().expect("expiry finalizer completes"); + } +} 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..485495fad --- /dev/null +++ b/crates/okena-daemon-core/src/toast_poll.rs @@ -0,0 +1,158 @@ +//! 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, + state_version: tokio::sync::watch::Sender, +) { + let Some(hook_monitor) = hook_monitor else { + // No hook monitor → no toast source. Nothing to do. + return; + }; + + let mut monitor_version = 0; + 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()); + } + let next_version = hook_monitor.version(); + if next_version != monitor_version { + monitor_version = next_version; + state_version.send_modify(|version| *version = version.wrapping_add(1)); + } + 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); + let (state_tx, _) = tokio::sync::watch::channel(0); + run_toast_poll(None, tx, state_tx).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn publishes_hook_start_and_completion_to_state_watchers() { + let monitor = HookMonitor::new(); + let (toast_tx, _) = tokio::sync::broadcast::channel::(8); + let (state_tx, mut state_rx) = tokio::sync::watch::channel(0); + let task = tokio::spawn(run_toast_poll(Some(monitor.clone()), toast_tx, state_tx)); + + let id = monitor.record_start("on_open", "true", "proj", None); + tokio::time::timeout(Duration::from_secs(1), state_rx.changed()) + .await + .expect("hook start should publish") + .expect("state sender should remain live"); + + monitor.record_finish( + id, + HookStatus::Succeeded { + duration: Duration::from_millis(1), + }, + ); + tokio::time::timeout(Duration::from_secs(1), state_rx.changed()) + .await + .expect("hook completion should publish") + .expect("state sender should remain live"); + + task.abort(); + } +} 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-core/src/worktree_close_watchdog.rs b/crates/okena-daemon-core/src/worktree_close_watchdog.rs new file mode 100644 index 000000000..f967d8ed8 --- /dev/null +++ b/crates/okena-daemon-core/src/worktree_close_watchdog.rs @@ -0,0 +1,258 @@ +//! Daemon-side reconciliation for worktree closes whose before-remove hook PTY +//! disappeared without an authoritative exit event. +//! +//! A current PTY generation is the only liveness signal. There is deliberately +//! no elapsed-time policy: a hook is allowed to run indefinitely until it exits, +//! reports an authoritative result, or its PTY actually vanishes. + +use std::sync::Arc; +use std::time::Duration; + +use okena_hooks::{HookMonitor, HookRunner}; +use okena_terminal::pty_manager::PtyManager; +use okena_workspace::state::Workspace; +use parking_lot::Mutex; +use tokio::sync::watch; + +use crate::workspace_cx::DaemonWorkspaceCx; + +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +/// Periodically abort worktree closes whose hook PTY has disappeared. This is a +/// liveness reconciler, not a hook timeout: live PTYs remain pending forever. +pub async fn run_worktree_close_watchdog( + workspace: Arc>, + pty_manager: Arc, + workspace_tick: watch::Sender, + hook_runner: Option, + hook_monitor: Option, +) { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + reconcile_orphaned_worktree_closes_once( + &workspace, + &pty_manager, + &workspace_tick, + &hook_runner, + &hook_monitor, + ); + } +} + +/// Run one orphan reconciliation pass. Returns the number of atomically claimed +/// pending closes. A candidate can safely disappear between snapshot and claim: +/// the workspace operation is idempotent and loses races to normal exits. +pub fn reconcile_orphaned_worktree_closes_once( + workspace: &Arc>, + pty_manager: &PtyManager, + workspace_tick: &watch::Sender, + hook_runner: &Option, + hook_monitor: &Option, +) -> usize { + let candidates = workspace.lock().pending_worktree_close_terminal_ids(); + let mut reconciled = 0; + + for terminal_id in candidates { + // Do not use the terminals registry and never apply an age deadline: + // only PTY ownership is authoritative for liveness. + if pty_manager.current_generation(&terminal_id).is_some() { + continue; + } + + let aborted = { + let mut cx = DaemonWorkspaceCx::new(workspace_tick, hook_runner, hook_monitor); + let mut ws = workspace.lock(); + ws.abort_orphaned_worktree_close(&terminal_id, &mut cx) + }; + let Some(aborted) = aborted else { + continue; + }; + + if let Some(monitor) = hook_monitor { + // `None` records the unknown exit as a failure, decrements the + // running count, and queues the hook-failure toast exactly once. + monitor.finish_by_terminal_id(&terminal_id, None); + } + log::error!( + "worktree-close: before-remove hook PTY disappeared; close aborted for \"{}\" ({}) terminal {}", + aborted.project_name, + aborted.project_id, + terminal_id + ); + reconciled += 1; + } + + reconciled +} + +#[cfg(test)] +mod tests { + use super::*; + use okena_hooks::HookStatus; + use okena_state::{HookTerminalEntry, HookTerminalStatus, ProjectData, WorkspaceData}; + use okena_terminal::session_backend::SessionBackend; + use std::collections::HashMap; + + fn workspace_with_pending_close(terminal_id: &str) -> Workspace { + let mut project = ProjectData { + id: "worktree".into(), + name: "Feature".into(), + path: std::env::temp_dir().to_string_lossy().into_owned(), + layout: None, + terminal_names: HashMap::from([(terminal_id.into(), "Before remove".into())]), + 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, + is_creating: false, + is_closing: false, + }; + project.hook_terminals.insert( + terminal_id.into(), + HookTerminalEntry { + label: "Before remove".into(), + status: HookTerminalStatus::Running, + hook_type: "before_worktree_remove".into(), + command: "true".into(), + cwd: project.path.clone(), + }, + ); + let mut workspace = Workspace::new(WorkspaceData { + version: 1, + projects: vec![project], + project_order: vec!["worktree".into()], + folders: Vec::new(), + service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), + main_window: Default::default(), + extra_windows: Vec::new(), + }); + workspace.register_pending_worktree_close(okena_state::PendingWorktreeClose { + project_id: "worktree".into(), + hook_terminal_id: terminal_id.into(), + branch: "feature".into(), + main_repo_path: std::env::temp_dir().to_string_lossy().into_owned(), + }); + workspace + } + + #[test] + fn vanished_hook_pty_aborts_close_heals_once_and_allows_immediate_retry() { + let workspace = Arc::new(Mutex::new(workspace_with_pending_close("hook-1"))); + let (manager, _events) = PtyManager::new(SessionBackend::None); + manager + .create_or_reconnect_terminal(Some("hook-1"), &std::env::temp_dir().to_string_lossy()) + .expect("create hook PTY"); + // Deliberately do not dispatch a PtyEvent::Exit: this reproduces the + // daemon teardown race where the hook PTY disappears first. + manager.kill("hook-1"); + manager.flush_teardown(); + let (tick, tick_rx) = watch::channel(0u64); + let monitor = HookMonitor::new(); + monitor.record_start( + "before_worktree_remove", + "true", + "Feature", + Some("hook-1".into()), + ); + + assert_eq!( + reconcile_orphaned_worktree_closes_once( + &workspace, + &manager, + &tick, + &None, + &Some(monitor.clone()), + ), + 1 + ); + assert!(tick_rx.has_changed().expect("tick remains open")); + let ws = workspace.lock(); + let project = ws.project("worktree").expect("project retained"); + assert!(!ws.is_project_closing("worktree")); + assert!(!project.is_closing); + assert!(matches!( + project.hook_terminals["hook-1"].status, + HookTerminalStatus::Failed { exit_code: -1 } + )); + drop(ws); + assert!(matches!( + monitor.history()[0].status, + HookStatus::Failed { exit_code: -1, .. } + )); + assert_eq!(monitor.drain_pending_toasts().len(), 1); + assert_eq!( + reconcile_orphaned_worktree_closes_once( + &workspace, + &manager, + &tick, + &None, + &Some(monitor.clone()), + ), + 0, + "late/duplicate reconciliation must not rewrite status or toast" + ); + assert!(monitor.drain_pending_toasts().is_empty()); + workspace + .lock() + .register_pending_worktree_close(okena_state::PendingWorktreeClose { + project_id: "worktree".into(), + hook_terminal_id: "hook-retry".into(), + branch: "feature".into(), + main_repo_path: std::env::temp_dir().to_string_lossy().into_owned(), + }); + assert!( + workspace.lock().is_project_closing("worktree"), + "retry can claim close immediately" + ); + } + + #[test] + fn live_hook_never_times_out() { + let workspace = Arc::new(Mutex::new(workspace_with_pending_close("hook-live"))); + let (manager, _events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + manager + .create_or_reconnect_terminal(Some("hook-live"), &cwd) + .expect("create current PTY"); + let (tick, _tick_rx) = watch::channel(0u64); + let monitor = HookMonitor::new(); + monitor.record_start( + "before_worktree_remove", + "sleep 60", + "Feature", + Some("hook-live".into()), + ); + + for _ in 0..3 { + assert_eq!( + reconcile_orphaned_worktree_closes_once( + &workspace, + &manager, + &tick, + &None, + &Some(monitor.clone()), + ), + 0 + ); + } + let ws = workspace.lock(); + assert!(ws.is_project_closing("worktree")); + assert!(ws.project("worktree").unwrap().is_closing); + assert!(matches!( + ws.project("worktree").unwrap().hook_terminals["hook-live"].status, + HookTerminalStatus::Running + )); + drop(ws); + assert!(matches!(monitor.history()[0].status, HookStatus::Running)); + manager.kill("hook-live"); + } +} diff --git a/crates/okena-daemon/Cargo.toml b/crates/okena-daemon/Cargo.toml new file mode 100644 index 000000000..869a99ba5 --- /dev/null +++ b/crates/okena-daemon/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "okena-daemon" +version.workspace = true +edition = "2024" +license = "MIT" + +[[bin]] +name = "okena-daemon" +path = "src/main.rs" + +[features] +default = [] +# Runtime collection still requires OKENA_TERMINAL_LATENCY_PROBE=1. +terminal-latency-probe = ["okena-core/terminal-latency-probe"] + +[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..2018230f2 --- /dev/null +++ b/crates/okena-daemon/src/main.rs @@ -0,0 +1,232 @@ +//! 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 uses `okena --headless` for its UI-owned daemon. This dedicated +//! binary is used only when started explicitly and runs the same `DaemonCore`. + +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<()> { + okena_remote_server::local::remember_current_executable() + .context("failed to remember daemon executable path")?; + + 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 loaded_workspace = persistence::load_workspace_with_cleanup_for_shell( + session_backend, + &settings.default_shell, + ).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::LoadedWorkspace { + data: persistence::default_workspace(), + stale_terminal_ids: Vec::new(), + } + }); + + // 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. Purely local daemons remain + // plain HTTP and can also use the local Unix socket. + let tls_enabled = + listen_addrs.iter().any(|addr| !addr.is_loopback()) && settings.remote_tls_enabled; + let params = DaemonParams { + workspace_data: loaded_workspace.data, + stale_terminal_ids: loaded_workspace.stale_terminal_ids, + settings, + session_backend, + listen_addrs, + tls_enabled, + ui_owned: std::env::args().any(|arg| arg == "--ui-owned"), + }; + + 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/lib.rs b/crates/okena-ext-claude/src/lib.rs index 7e91bf04f..0558bdf08 100644 --- a/crates/okena-ext-claude/src/lib.rs +++ b/crates/okena-ext-claude/src/lib.rs @@ -1,13 +1,13 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] -mod status; mod settings; -pub mod usage; +mod status; mod ui_helpers; +pub mod usage; pub use usage::resolve_claude_dir; -use gpui::{AppContext as _, AnyView}; +use gpui::{AnyView, AppContext as _}; use okena_extensions::{ExtensionInstance, ExtensionManifest, ExtensionRegistration}; use std::sync::Arc; @@ -26,6 +26,8 @@ pub fn register() -> ExtensionRegistration { status_bar_right_widgets: vec![], } }), - settings_view: Some(Arc::new(|app| AnyView::from(app.new(settings::ClaudeSettingsView::new)))), + settings_view: Some(Arc::new(|app| { + AnyView::from(app.new(settings::ClaudeSettingsView::new)) + })), } } diff --git a/crates/okena-ext-claude/src/status.rs b/crates/okena-ext-claude/src/status.rs index 37c45560f..ef82dd08a 100644 --- a/crates/okena-ext-claude/src/status.rs +++ b/crates/okena-ext-claude/src/status.rs @@ -1,12 +1,12 @@ -use crate::ui_helpers::{format_api_timestamp, capitalize_first, open_url}; -use okena_extensions::ThemeColors; -use okena_ui::tokens::{ui_text_sm, ui_text_ms, ui_text_md}; +use crate::ui_helpers::{capitalize_first, format_api_timestamp, open_url}; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::{h_flex, v_flex}; +use okena_extensions::ThemeColors; +use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; use parking_lot::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; /// Refresh interval for Claude Code status @@ -126,10 +126,8 @@ impl ClaudeStatusData { continue; } - let name = - incident["name"].as_str().unwrap_or("Unknown").to_string(); - let impact = - incident["impact"].as_str().unwrap_or("none").to_string(); + let name = incident["name"].as_str().unwrap_or("Unknown").to_string(); + let impact = incident["impact"].as_str().unwrap_or("none").to_string(); let updates: Vec = incident["incident_updates"] .as_array() @@ -137,10 +135,7 @@ impl ClaudeStatusData { updates .iter() .map(|u| IncidentUpdate { - status: u["status"] - .as_str() - .unwrap_or("") - .to_string(), + status: u["status"].as_str().unwrap_or("").to_string(), body: u["body"].as_str().unwrap_or("").to_string(), created_at: format_api_timestamp( u["created_at"].as_str().unwrap_or(""), @@ -257,11 +252,7 @@ impl ClaudeStatus { .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() { @@ -304,86 +295,62 @@ impl ClaudeStatus { .on_scroll_wheel(|_, _, cx| { cx.stop_propagation(); }) - .children( - data.incidents - .iter() - .enumerate() - .map(|(idx, incident)| { - let impact_color = match incident.impact.as_str() { - "critical" | "major" => t.metric_critical, - _ => t.metric_warning, - }; - + .children(data.incidents.iter().enumerate().map(|(idx, incident)| { + let impact_color = match incident.impact.as_str() { + "critical" | "major" => t.metric_critical, + _ => t.metric_warning, + }; + + div() + .when(idx > 0, |d| d.border_t_1().border_color(rgb(t.border))) + .child( div() - .when(idx > 0, |d| { - d.border_t_1().border_color(rgb(t.border)) + .px(px(10.0)) + .py(px(6.0)) + .bg(rgb(impact_color)) + .when(idx == 0, |d| { + d.rounded_tl(px(5.0)).rounded_tr(px(5.0)) }) .child( div() - .px(px(10.0)) - .py(px(6.0)) - .bg(rgb(impact_color)) - .when(idx == 0, |d| { - d.rounded_tl(px(5.0)).rounded_tr(px(5.0)) - }) - .child( - div() - .text_size(ui_text_md(cx)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(0x000000)) - .child(incident.name.clone()), - ), - ) - .child( - v_flex() - .px(px(10.0)) - .py(px(4.0)) - .gap(px(8.0)) - .children(incident.updates.iter().map( - |update| { - v_flex() - .gap(px(2.0)) - .child( - h_flex().gap(px(4.0)).child( - div() - .text_size(ui_text_ms(cx)) - .font_weight( - FontWeight::BOLD, - ) - .text_color(rgb( - t.text_primary, - )) - .child(capitalize_first( - &update.status, - )), - ) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb( - t.text_secondary, - )) - .child(format!( - "- {}", - update.body - )), - ), - ) - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child( - update - .created_at - .clone(), - ), - ) - }, - )), - ) - }), - ), + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x000000)) + .child(incident.name.clone()), + ), + ) + .child(v_flex().px(px(10.0)).py(px(4.0)).gap(px(8.0)).children( + incident.updates.iter().map(|update| { + v_flex() + .gap(px(2.0)) + .child( + h_flex() + .gap(px(4.0)) + .child( + div() + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::BOLD) + .text_color(rgb(t.text_primary)) + .child(capitalize_first( + &update.status, + )), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(format!("- {}", update.body)), + ), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(update.created_at.clone()), + ) + }), + )) + })), ), ) .with_priority(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-codex/src/lib.rs b/crates/okena-ext-codex/src/lib.rs index 1900b25ee..0f644a9d7 100644 --- a/crates/okena-ext-codex/src/lib.rs +++ b/crates/okena-ext-codex/src/lib.rs @@ -1,11 +1,11 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] -mod status; mod settings; -mod usage; +mod status; mod ui_helpers; +mod usage; -use gpui::{AppContext as _, AnyView}; +use gpui::{AnyView, AppContext as _}; use okena_extensions::{ExtensionInstance, ExtensionManifest, ExtensionRegistration}; use std::sync::Arc; diff --git a/crates/okena-ext-codex/src/status.rs b/crates/okena-ext-codex/src/status.rs index d2675ffee..7d2f457d7 100644 --- a/crates/okena-ext-codex/src/status.rs +++ b/crates/okena-ext-codex/src/status.rs @@ -1,12 +1,12 @@ -use crate::ui_helpers::{format_api_timestamp, capitalize_first, open_url}; -use okena_extensions::ThemeColors; -use okena_ui::tokens::{ui_text_sm, ui_text_ms, ui_text_md}; +use crate::ui_helpers::{capitalize_first, format_api_timestamp, open_url}; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::{h_flex, v_flex}; +use okena_extensions::ThemeColors; +use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; use parking_lot::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; /// Refresh interval for Codex status @@ -106,8 +106,7 @@ impl CodexStatusData { for component in components { let name = component["name"].as_str().unwrap_or(""); if name == "Codex" || name.starts_with("Codex ") { - component_status = - component["status"].as_str().map(|s| s.to_string()); + component_status = component["status"].as_str().map(|s| s.to_string()); component_id = component["id"].as_str().map(|s| s.to_string()); break; } @@ -130,10 +129,8 @@ impl CodexStatusData { continue; } - let name = - incident["name"].as_str().unwrap_or("Unknown").to_string(); - let impact = - incident["impact"].as_str().unwrap_or("none").to_string(); + let name = incident["name"].as_str().unwrap_or("Unknown").to_string(); + let impact = incident["impact"].as_str().unwrap_or("none").to_string(); let updates: Vec = incident["incident_updates"] .as_array() @@ -141,10 +138,7 @@ impl CodexStatusData { updates .iter() .map(|u| IncidentUpdate { - status: u["status"] - .as_str() - .unwrap_or("") - .to_string(), + status: u["status"].as_str().unwrap_or("").to_string(), body: u["body"].as_str().unwrap_or("").to_string(), created_at: format_api_timestamp( u["created_at"].as_str().unwrap_or(""), @@ -261,11 +255,7 @@ impl CodexStatus { .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() { @@ -308,86 +298,62 @@ impl CodexStatus { .on_scroll_wheel(|_, _, cx| { cx.stop_propagation(); }) - .children( - data.incidents - .iter() - .enumerate() - .map(|(idx, incident)| { - let impact_color = match incident.impact.as_str() { - "critical" | "major" => t.metric_critical, - _ => t.metric_warning, - }; - + .children(data.incidents.iter().enumerate().map(|(idx, incident)| { + let impact_color = match incident.impact.as_str() { + "critical" | "major" => t.metric_critical, + _ => t.metric_warning, + }; + + div() + .when(idx > 0, |d| d.border_t_1().border_color(rgb(t.border))) + .child( div() - .when(idx > 0, |d| { - d.border_t_1().border_color(rgb(t.border)) + .px(px(10.0)) + .py(px(6.0)) + .bg(rgb(impact_color)) + .when(idx == 0, |d| { + d.rounded_tl(px(5.0)).rounded_tr(px(5.0)) }) .child( div() - .px(px(10.0)) - .py(px(6.0)) - .bg(rgb(impact_color)) - .when(idx == 0, |d| { - d.rounded_tl(px(5.0)).rounded_tr(px(5.0)) - }) - .child( - div() - .text_size(ui_text_md(cx)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(0x000000)) - .child(incident.name.clone()), - ), - ) - .child( - v_flex() - .px(px(10.0)) - .py(px(4.0)) - .gap(px(8.0)) - .children(incident.updates.iter().map( - |update| { - v_flex() - .gap(px(2.0)) - .child( - h_flex().gap(px(4.0)).child( - div() - .text_size(ui_text_ms(cx)) - .font_weight( - FontWeight::BOLD, - ) - .text_color(rgb( - t.text_primary, - )) - .child(capitalize_first( - &update.status, - )), - ) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb( - t.text_secondary, - )) - .child(format!( - "- {}", - update.body - )), - ), - ) - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child( - update - .created_at - .clone(), - ), - ) - }, - )), - ) - }), - ), + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x000000)) + .child(incident.name.clone()), + ), + ) + .child(v_flex().px(px(10.0)).py(px(4.0)).gap(px(8.0)).children( + incident.updates.iter().map(|update| { + v_flex() + .gap(px(2.0)) + .child( + h_flex() + .gap(px(4.0)) + .child( + div() + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::BOLD) + .text_color(rgb(t.text_primary)) + .child(capitalize_first( + &update.status, + )), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(format!("- {}", update.body)), + ), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(update.created_at.clone()), + ) + }), + )) + })), ), ) .with_priority(1) diff --git a/crates/okena-ext-codex/src/usage.rs b/crates/okena-ext-codex/src/usage.rs index 44d2f30a0..137a11663 100644 --- a/crates/okena-ext-codex/src/usage.rs +++ b/crates/okena-ext-codex/src/usage.rs @@ -1,19 +1,22 @@ -use okena_extensions::ThemeColors; -use okena_usage::{ - effective_time_pct, read_working_days, render_usage_row, usage_body_container, usage_divider, - usage_kv_row, usage_popover_container, usage_popover_header, usage_trigger_items, SegmentUnit, - UsageRow, -}; use base64::Engine as _; use gpui::prelude::FluentBuilder; use gpui::*; -use gpui_component::h_flex; +use gpui_component::{h_flex, v_flex}; +use okena_extensions::ThemeColors; +use okena_ui::expand::expand_toggle; +use okena_ui::tokens::{ui_text_ms, ui_text_xs}; +use okena_usage::{ + SegmentUnit, UsageRow, effective_time_pct, format_reset_time_epoch, read_working_days, + render_usage_row, usage_body_container, usage_divider, usage_kv_row, usage_popover_container, + usage_popover_header, usage_trigger_items, +}; use parking_lot::Mutex; +use std::cmp::Ordering as CmpOrdering; use std::fs::File; use std::io::{BufRead, BufReader}; 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}; /// Refresh interval for usage data @@ -31,6 +34,8 @@ const HOVER_REFETCH_THROTTLE: Duration = Duration::from_secs(60); /// Codex OAuth client ID (public, embedded in the Codex CLI binary) const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; + fn theme(cx: &App) -> ThemeColors { okena_extensions::theme(cx) } @@ -60,6 +65,18 @@ struct CreditsInfo { balance: f64, } +#[derive(Clone)] +struct ResetCredit { + title: String, + expires_at: Option, +} + +#[derive(Clone)] +struct ResetCreditsInfo { + available_count: u64, + credits: Vec, +} + /// All fetched usage data #[derive(Clone)] struct UsageData { @@ -68,6 +85,7 @@ struct UsageData { secondary_window: Option, review_primary: Option, credits: Option, + reset_credits: Option, } /// Shared usage data + the single background poll task. @@ -132,23 +150,24 @@ fn refresh_access_token(auth: &CodexAuth) -> Option { // Persist new tokens back to auth.json if let Ok(content) = std::fs::read_to_string(&auth.auth_path) - && let Ok(mut file_json) = serde_json::from_str::(&content) { - if let Some(tokens) = file_json.get_mut("tokens").and_then(|t| t.as_object_mut()) { + && let Ok(mut file_json) = serde_json::from_str::(&content) + { + if let Some(tokens) = file_json.get_mut("tokens").and_then(|t| t.as_object_mut()) { + tokens.insert( + "access_token".to_string(), + serde_json::Value::String(new_access.to_string()), + ); + if let Some(rt) = new_refresh { tokens.insert( - "access_token".to_string(), - serde_json::Value::String(new_access.to_string()), + "refresh_token".to_string(), + serde_json::Value::String(rt.to_string()), ); - if let Some(rt) = new_refresh { - tokens.insert( - "refresh_token".to_string(), - serde_json::Value::String(rt.to_string()), - ); - } - } - if let Ok(updated) = serde_json::to_string_pretty(&file_json) { - let _ = std::fs::write(&auth.auth_path, updated); } } + if let Ok(updated) = serde_json::to_string_pretty(&file_json) { + let _ = std::fs::write(&auth.auth_path, updated); + } + } Some(new_access.to_string()) } @@ -191,6 +210,45 @@ fn parse_window(v: &serde_json::Value) -> Option { }) } +fn parse_iso8601_to_epoch(ts: &str) -> Option { + let timestamp: jiff::Timestamp = ts.parse().ok()?; + Some(timestamp.as_millisecond() as f64 / 1_000.0) +} + +fn parse_reset_credits(body: &serde_json::Value) -> Option { + let raw_credits = body["credits"].as_array()?; + let mut credits: Vec = raw_credits + .iter() + .filter(|credit| credit["status"].as_str() == Some("available")) + .map(|credit| ResetCredit { + title: credit["title"] + .as_str() + .filter(|title| !title.trim().is_empty()) + .unwrap_or("Full reset") + .to_string(), + expires_at: credit["expires_at"] + .as_str() + .and_then(parse_iso8601_to_epoch), + }) + .collect(); + + credits.sort_by(|left, right| match (left.expires_at, right.expires_at) { + (Some(left), Some(right)) => left.total_cmp(&right), + (Some(_), None) => CmpOrdering::Less, + (None, Some(_)) => CmpOrdering::Greater, + (None, None) => CmpOrdering::Equal, + }); + + let available_count = body["available_count"] + .as_u64() + .unwrap_or(credits.len() as u64); + + Some(ResetCreditsInfo { + available_count, + credits, + }) +} + fn plan_type_from_access_token(access_token: &str) -> Option { let payload = access_token.split('.').nth(1)?; let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD @@ -266,10 +324,7 @@ fn fetch_usage_from_local_sessions(auth: &CodexAuth) -> Option { .get("unlimited") .and_then(|v| v.as_bool()) .unwrap_or(false), - balance: c - .get("balance") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0), + balance: c.get("balance").and_then(|v| v.as_f64()).unwrap_or(0.0), }); if primary_window.is_some() || secondary_window.is_some() { @@ -283,6 +338,7 @@ fn fetch_usage_from_local_sessions(auth: &CodexAuth) -> Option { secondary_window, review_primary: None, credits, + reset_credits: None, }); } } @@ -295,7 +351,7 @@ fn fetch_usage_from_local_sessions(auth: &CodexAuth) -> Option { None } -fn try_fetch_with_token( +fn try_fetch_usage_with_token( access_token: &str, account_id: &str, ) -> Result> { @@ -318,18 +374,65 @@ fn try_fetch_with_token( } } +fn try_fetch_reset_credits_with_token( + access_token: &str, + account_id: &str, +) -> Result> { + let resp = okena_transport::http::send( + okena_transport::http::HttpRequest::get(RESET_CREDITS_URL) + .bearer(access_token) + .header("chatgpt-account-id", account_id) + .timeout(Duration::from_secs(10)) + .label("codex.reset-credits"), + ) + .map_err(|_| None)?; + + if resp.is_success() { + Ok(resp) + } else { + Err(Some(resp.status())) + } +} + +fn fetch_reset_credits(access_token: &str, account_id: &str) -> Option { + let resp = match try_fetch_reset_credits_with_token(access_token, account_id) { + Ok(resp) => resp, + Err(status) => { + log::warn!("[codex-usage] reset credits API returned {:?}", status); + return None; + } + }; + let body: serde_json::Value = match resp.json() { + Ok(body) => body, + Err(error) => { + log::warn!("[codex-usage] failed to decode reset credits: {error}"); + return None; + } + }; + let parsed = parse_reset_credits(&body); + if parsed.is_none() { + log::warn!("[codex-usage] reset credits response had an unexpected shape"); + } + parsed +} + fn fetch_usage() -> Option { let auth = read_codex_auth()?; + let mut access_token = auth.access_token.clone(); // Try cached access token first, refresh on 401 - let resp = match try_fetch_with_token(&auth.access_token, &auth.account_id) { + let resp = match try_fetch_usage_with_token(&access_token, &auth.account_id) { Ok(resp) => resp, Err(Some(401)) => { let new_token = refresh_access_token(&auth)?; - match try_fetch_with_token(&new_token, &auth.account_id) { + access_token = new_token; + match try_fetch_usage_with_token(&access_token, &auth.account_id) { Ok(resp) => resp, Err(status) => { - log::warn!("[codex-usage] API returned {:?} after token refresh", status); + log::warn!( + "[codex-usage] API returned {:?} after token refresh", + status + ); return fetch_usage_from_local_sessions(&auth); } } @@ -342,10 +445,7 @@ fn fetch_usage() -> Option { let body: serde_json::Value = resp.json().ok()?; - let plan_type = body["plan_type"] - .as_str() - .unwrap_or("unknown") - .to_string(); + let plan_type = body["plan_type"].as_str().unwrap_or("unknown").to_string(); let primary_window = body["rate_limit"]["primary_window"] .as_object() @@ -368,18 +468,18 @@ fn fetch_usage() -> Option { .get("unlimited") .and_then(|v| v.as_bool()) .unwrap_or(false), - balance: c - .get("balance") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0), + balance: c.get("balance").and_then(|v| v.as_f64()).unwrap_or(0.0), }); + let reset_credits = fetch_reset_credits(&access_token, &auth.account_id); + Some(UsageData { plan_type, primary_window, secondary_window, review_primary, credits, + reset_credits, }) } @@ -483,6 +583,7 @@ impl CodexUsageData { pub struct CodexUsage { data: Entity, popover_visible: bool, + resets_expanded: bool, trigger_bounds: Bounds, hover_token: Arc, } @@ -495,6 +596,7 @@ impl CodexUsage { Self { data, popover_visible: false, + resets_expanded: false, trigger_bounds: Bounds::default(), hover_token: Arc::new(AtomicU64::new(0)), } @@ -545,6 +647,7 @@ impl CodexUsage { let _ = this.update(cx, |this, cx| { if hover_token.load(Ordering::SeqCst) == token && this.popover_visible { this.popover_visible = false; + this.resets_expanded = false; cx.notify(); } }); @@ -552,16 +655,14 @@ impl CodexUsage { .detach(); } - 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() { - Some(d) if self.popover_visible => d.clone(), - _ => return div().size_0().into_any_element(), + fn render_popover(&self, t: &ThemeColors, cx: &mut Context) -> impl IntoElement { + let data = { + let shared = self.data.read(cx); + let data = shared.data.lock(); + match data.as_ref() { + Some(data) if self.popover_visible => data.clone(), + _ => return div().size_0().into_any_element(), + } }; let working = read_working_days(cx); @@ -636,13 +737,142 @@ impl CodexUsage { .child(usage_kv_row(t, cx, "Credits", text, color)), None => el, } - }), + }) + .when_some( + data.reset_credits.as_ref().filter(|resets| { + resets.available_count > 0 || !resets.credits.is_empty() + }), + |el, resets| { + el.child(usage_divider(t)) + .child(self.render_reset_credits(t, resets, cx)) + }, + ), ), ), ) .with_priority(1) .into_any_element() } + + fn render_reset_credits( + &self, + t: &ThemeColors, + resets: &ResetCreditsInfo, + cx: &mut Context, + ) -> impl IntoElement { + let has_details = !resets.credits.is_empty(); + let expanded = self.resets_expanded && has_details; + let summary = reset_count_label(resets.available_count); + let first_expiry = first_reset_expiry_label(resets); + + v_flex() + .gap(px(5.0)) + .child( + h_flex() + .id("codex-reset-credits-toggle") + .items_center() + .justify_between() + .gap(px(8.0)) + .px(px(2.0)) + .py(px(2.0)) + .rounded(px(3.0)) + .when(has_details, |el| { + el.cursor_pointer() + .hover(|style| style.bg(rgb(t.bg_hover))) + .on_click(cx.listener(|this, _, _window, cx| { + this.resets_expanded = !this.resets_expanded; + cx.notify(); + })) + }) + .child( + h_flex() + .items_center() + .gap(px(5.0)) + .child(expand_toggle( + "codex-reset-credits-chevron", + expanded, + has_details, + t, + )) + .child( + div() + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_secondary)) + .child(summary), + ), + ) + .when_some(first_expiry, |el, expiry| { + el.child( + div() + .text_size(ui_text_xs(cx)) + .text_color(rgb(t.text_muted)) + .child(expiry), + ) + }), + ) + .when(expanded, |el| { + el.children(resets.credits.iter().map(|credit| { + h_flex() + .items_baseline() + .justify_between() + .gap(px(8.0)) + .pl(px(19.0)) + .pr(px(2.0)) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(credit.title.clone()), + ) + .child( + div() + .text_size(ui_text_xs(cx)) + .text_color(rgb(t.text_muted)) + .child(reset_expiry_label(credit)), + ) + })) + .when( + resets.available_count > resets.credits.len() as u64, + |el| { + let hidden = resets.available_count - resets.credits.len() as u64; + el.child( + div() + .pl(px(19.0)) + .text_size(ui_text_xs(cx)) + .text_color(rgb(t.text_muted)) + .child(format!("+{hidden} more")), + ) + }, + ) + }) + } +} + +fn reset_count_label(count: u64) -> String { + if count == 1 { + "1 reset available".to_string() + } else { + format!("{count} resets available") + } +} + +fn reset_expiry_label(credit: &ResetCredit) -> String { + let Some(expires_at) = credit.expires_at else { + return "no expiration".to_string(); + }; + let formatted = format_reset_time_epoch(expires_at, true); + if formatted.is_empty() { + "expiration unknown".to_string() + } else { + format!("expires {formatted}") + } +} + +fn first_reset_expiry_label(resets: &ResetCreditsInfo) -> Option { + let expires_at = resets.credits.iter().find_map(|credit| credit.expires_at)?; + let formatted = format_reset_time_epoch(expires_at, true); + (!formatted.is_empty()).then(|| format!("first expires {formatted}")) } /// Pick the grid granularity for a window: per-hour up to a day, per-day for @@ -805,4 +1035,86 @@ mod tests { assert_eq!(w.window_seconds, 604800); assert_eq!(w.reset_at, 1782371508); } + + #[test] + fn parse_reset_credits_filters_available_and_sorts_by_expiry() { + let body = serde_json::json!({ + "available_count": 3, + "credits": [ + { + "status": "available", + "title": "Later reset", + "expires_at": "2026-08-11T21:08:50.081157Z" + }, + { + "status": "redeemed", + "title": "Used reset", + "expires_at": "2026-07-01T00:00:00Z" + }, + { + "status": "available", + "title": "First reset", + "expires_at": "2026-07-18T00:32:13.609604Z" + }, + { + "status": "available", + "title": "", + "expires_at": null + } + ] + }); + + let parsed = parse_reset_credits(&body).expect("reset credits should parse"); + assert_eq!(parsed.available_count, 3); + assert_eq!(parsed.credits.len(), 3); + assert_eq!(parsed.credits[0].title, "First reset"); + assert_eq!(parsed.credits[1].title, "Later reset"); + assert_eq!(parsed.credits[2].title, "Full reset"); + assert!(parsed.credits[0].expires_at.is_some()); + assert!(parsed.credits[2].expires_at.is_none()); + } + + #[test] + fn parse_reset_credits_derives_missing_count() { + let body = serde_json::json!({ + "credits": [ + { + "status": "available", + "title": "Full reset", + "expires_at": "2026-07-18T00:32:13Z" + }, + { + "status": "redeeming", + "title": "In progress", + "expires_at": "2026-07-19T00:32:13Z" + } + ] + }); + + let parsed = parse_reset_credits(&body).expect("reset credits should parse"); + assert_eq!(parsed.available_count, 1); + assert_eq!(parsed.credits.len(), 1); + } + + #[test] + fn reset_summary_uses_singular_plural_and_first_expiry() { + assert_eq!(reset_count_label(1), "1 reset available"); + assert_eq!(reset_count_label(3), "3 resets available"); + + let resets = ResetCreditsInfo { + available_count: 1, + credits: vec![ResetCredit { + title: "Full reset".to_string(), + expires_at: parse_iso8601_to_epoch("2026-07-18T00:32:13Z"), + }], + }; + let summary = first_reset_expiry_label(&resets).expect("expiry summary should render"); + assert!(summary.starts_with("first expires ")); + + let no_expiry = ResetCredit { + title: "Full reset".to_string(), + expires_at: None, + }; + assert_eq!(reset_expiry_label(&no_expiry), "no expiration"); + } } diff --git a/crates/okena-ext-github/src/status.rs b/crates/okena-ext-github/src/status.rs index 420ead2a6..c9cdc4f5c 100644 --- a/crates/okena-ext-github/src/status.rs +++ b/crates/okena-ext-github/src/status.rs @@ -5,8 +5,8 @@ use gpui_component::{h_flex, v_flex}; use okena_extensions::ThemeColors; use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; use parking_lot::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; /// Refresh interval for GitHub status @@ -308,44 +308,37 @@ impl GitHubStatus { .child(incident.name.clone()), ), ) - .child( - v_flex() - .px(px(10.0)) - .py(px(4.0)) - .gap(px(8.0)) - .children(incident.updates.iter().map(|update| { - v_flex() - .gap(px(2.0)) - .child( - h_flex() - .gap(px(4.0)) - .child( - div() - .text_size(ui_text_ms(cx)) - .font_weight(FontWeight::BOLD) - .text_color(rgb(t.text_primary)) - .child(capitalize_first( - &update.status, - )), - ) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_secondary)) - .child(format!( - "- {}", - update.body - )), - ), - ) - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child(update.created_at.clone()), - ) - })), - ) + .child(v_flex().px(px(10.0)).py(px(4.0)).gap(px(8.0)).children( + incident.updates.iter().map(|update| { + v_flex() + .gap(px(2.0)) + .child( + h_flex() + .gap(px(4.0)) + .child( + div() + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::BOLD) + .text_color(rgb(t.text_primary)) + .child(capitalize_first( + &update.status, + )), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(format!("- {}", update.body)), + ), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(update.created_at.clone()), + ) + }), + )) })), ), ) 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/checker.rs b/crates/okena-ext-updater/src/checker.rs index cb9d755a6..8554c389f 100644 --- a/crates/okena-ext-updater/src/checker.rs +++ b/crates/okena-ext-updater/src/checker.rs @@ -36,32 +36,32 @@ fn check_blocking(app_version: &str) -> Result> { anyhow::bail!("GitHub API returned status {}", status); } - let resp: serde_json::Value = http_resp - .json() - .context("failed to parse release JSON")?; + let resp: serde_json::Value = http_resp.json().context("failed to parse release JSON")?; - let tag = resp["tag_name"] - .as_str() - .context("missing tag_name")?; + let tag = resp["tag_name"].as_str().context("missing tag_name")?; let remote_version_str = tag.strip_prefix('v').unwrap_or(tag); - let remote_version = Version::parse(remote_version_str) - .context("invalid remote version")?; + let remote_version = Version::parse(remote_version_str).context("invalid remote version")?; - let current_version = Version::parse(app_version) - .context("invalid current version")?; + let current_version = Version::parse(app_version).context("invalid current version")?; if remote_version <= current_version { - log::info!("No update available (current={}, latest={})", current_version, remote_version); + log::info!( + "No update available (current={}, latest={})", + current_version, + remote_version + ); return Ok(None); } - log::info!("Update available: {} -> {}", current_version, remote_version); + log::info!( + "Update available: {} -> {}", + current_version, + remote_version + ); let expected_asset = platform_asset_name(); - let assets = resp["assets"] - .as_array() - .context("missing assets array")?; + let assets = resp["assets"].as_array().context("missing assets array")?; let mut found_asset: Option<(String, String)> = None; let mut checksum_url: Option = None; @@ -75,9 +75,10 @@ fn check_blocking(app_version: &str) -> Result> { .to_string(); found_asset = Some((name.to_string(), url)); } else if (name == "SHA256SUMS" || name == "sha256sums.txt") - && let Some(url) = asset["browser_download_url"].as_str() { - checksum_url = Some(url.to_string()); - } + && let Some(url) = asset["browser_download_url"].as_str() + { + checksum_url = Some(url.to_string()); + } } if let Some((asset_name, asset_url)) = found_asset { @@ -91,7 +92,8 @@ fn check_blocking(app_version: &str) -> Result> { log::warn!( "Release {} exists but no matching asset '{}' found", - remote_version, expected_asset + remote_version, + expected_asset ); Ok(None) } 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..94b592d64 --- /dev/null +++ b/crates/okena-ext-updater/src/daemon_client.rs @@ -0,0 +1,94 @@ +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/downloader.rs b/crates/okena-ext-updater/src/downloader.rs index a12d10344..072b54e68 100644 --- a/crates/okena-ext-updater/src/downloader.rs +++ b/crates/okena-ext-updater/src/downloader.rs @@ -19,7 +19,8 @@ pub async fn download_asset( verify_checksum(&path, &asset_name, &cs_url)?; } Ok(path) - }).await + }) + .await } fn download_blocking( @@ -30,8 +31,7 @@ fn download_blocking( cancel_token: u64, ) -> Result { let updates_dir = crate::process::get_config_dir().join("updates"); - std::fs::create_dir_all(&updates_dir) - .context("failed to create updates directory")?; + std::fs::create_dir_all(&updates_dir).context("failed to create updates directory")?; cleanup_updates_dir(); @@ -49,8 +49,7 @@ fn download_blocking( let total = resp.content_length().unwrap_or(0); let mut downloaded: u64 = 0; let mut last_pct: u8 = 0; - let mut file = std::fs::File::create(&dest) - .context("failed to create download file")?; + let mut file = std::fs::File::create(&dest).context("failed to create download file")?; let mut reader = resp; let mut buf = [0u8; 65536]; @@ -67,8 +66,7 @@ fn download_blocking( anyhow::bail!("download cancelled"); } - let n = std::io::Read::read(&mut reader, &mut buf) - .context("download read error")?; + let n = std::io::Read::read(&mut reader, &mut buf).context("download read error")?; if n == 0 { break; } @@ -109,7 +107,7 @@ fn download_blocking( } fn verify_checksum(file_path: &Path, asset_name: &str, checksum_url: &str) -> Result<()> { - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; use std::io::Read; log::info!("Verifying checksum for {}", asset_name); @@ -136,8 +134,8 @@ fn verify_checksum(file_path: &Path, asset_name: &str, checksum_url: &str) -> Re }) .with_context(|| format!("no checksum found for '{}' in SHA256SUMS", asset_name))?; - let mut file = std::fs::File::open(file_path) - .context("failed to open downloaded file for checksum")?; + let mut file = + std::fs::File::open(file_path).context("failed to open downloaded file for checksum")?; let mut hasher = Sha256::new(); let mut buf = [0u8; 65536]; loop { @@ -165,14 +163,15 @@ fn verify_checksum(file_path: &Path, asset_name: &str, checksum_url: &str) -> Re pub fn cleanup_updates_dir() { let updates_dir = crate::process::get_config_dir().join("updates"); if updates_dir.exists() - && let Ok(entries) = std::fs::read_dir(&updates_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let _ = std::fs::remove_dir_all(&path); - } else { - let _ = std::fs::remove_file(&path); - } + && let Ok(entries) = std::fs::read_dir(&updates_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } else { + let _ = std::fs::remove_file(&path); } } + } } 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..4e16c570f 100644 --- a/crates/okena-ext-updater/src/lib.rs +++ b/crates/okena-ext-updater/src/lib.rs @@ -1,21 +1,37 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod checker; +pub mod daemon_client; pub mod downloader; pub mod installer; +mod local_build; +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; +#[cfg(feature = "gpui-ui")] +pub use local_build::{GlobalLocalBuild, LocalBuildState, LocalBuildStatus}; +pub use local_build::{LocalCheckout, detect_local_checkout}; +pub use status::{GlobalUpdateInfo, UpdateInfo, UpdateStatus, UpdateStatusSnapshot}; +#[cfg(feature = "gpui-ui")] +gpui::actions!(updater, [RebuildLocal, RestartLocalBuild]); + +#[cfg(feature = "gpui-ui")] pub fn register() -> ExtensionRegistration { ExtensionRegistration { manifest: ExtensionManifest { @@ -37,9 +53,15 @@ 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(); let update_info = UpdateInfo::new(app_version.to_string()); cx.set_global(GlobalUpdateInfo(update_info)); + + if let Some(checkout) = detect_local_checkout() { + let state = cx.new(|_| LocalBuildState::new(checkout)); + cx.set_global(GlobalLocalBuild(state)); + } } diff --git a/crates/okena-ext-updater/src/local_build.rs b/crates/okena-ext-updater/src/local_build.rs new file mode 100644 index 000000000..ef5cdff4e --- /dev/null +++ b/crates/okena-ext-updater/src/local_build.rs @@ -0,0 +1,235 @@ +use std::path::{Path, PathBuf}; + +#[cfg(feature = "gpui-ui")] +use gpui::*; + +/// A checkout-backed Okena executable that can rebuild itself with Cargo. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalCheckout { + root: PathBuf, + release_executable: PathBuf, +} + +impl LocalCheckout { + pub fn root(&self) -> &Path { + &self.root + } + + pub fn release_executable(&self) -> &Path { + &self.release_executable + } +} + +/// Detect a binary running from this source checkout's `target` directory. +pub fn detect_local_checkout() -> Option { + let executable = std::env::current_exe().ok()?; + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = crate_dir.parent()?.parent()?; + detect_local_checkout_from(&executable, workspace_root) +} + +fn detect_local_checkout_from(executable: &Path, workspace_root: &Path) -> Option { + let file_name = executable.file_name()?.to_str()?; + let is_okena_binary = if cfg!(windows) { + matches!(file_name, "okena.exe" | "okena-daemon.exe") + } else { + matches!(file_name, "okena" | "okena-daemon") + }; + if !is_okena_binary { + return None; + } + + let target_dir = workspace_root.join("target"); + if !executable.starts_with(&target_dir) { + return None; + } + if !workspace_root.join("Cargo.toml").is_file() || !workspace_root.join(".git").exists() { + return None; + } + + let release_name = if cfg!(windows) { "okena.exe" } else { "okena" }; + Some(LocalCheckout { + root: workspace_root.to_path_buf(), + release_executable: target_dir.join("release").join(release_name), + }) +} + +#[cfg(feature = "gpui-ui")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LocalBuildStatus { + Idle, + Building, + ReadyToRestart, + RestartingDaemon, + RestartingApp, + Failed { error: String }, +} + +#[cfg(feature = "gpui-ui")] +pub struct LocalBuildState { + checkout: LocalCheckout, + status: LocalBuildStatus, + daemon_ui_owned: Option, +} + +#[cfg(feature = "gpui-ui")] +impl LocalBuildState { + pub fn new(checkout: LocalCheckout) -> Self { + Self { + checkout, + status: LocalBuildStatus::Idle, + daemon_ui_owned: None, + } + } + + pub fn checkout(&self) -> &LocalCheckout { + &self.checkout + } + + pub fn status(&self) -> &LocalBuildStatus { + &self.status + } + + pub fn daemon_ui_owned(&self) -> Option { + self.daemon_ui_owned + } + + pub fn set_daemon_ui_owned(&mut self, ui_owned: bool, cx: &mut Context) { + self.daemon_ui_owned = Some(ui_owned); + cx.notify(); + } + + pub fn try_start_build(&mut self, cx: &mut Context) -> Option { + if !self.can_build() { + return None; + } + self.status = LocalBuildStatus::Building; + cx.notify(); + Some(self.checkout.clone()) + } + + pub fn try_start_restart(&mut self, cx: &mut Context) -> Option { + if !self.can_restart() { + return None; + } + self.status = LocalBuildStatus::RestartingDaemon; + cx.notify(); + Some(self.checkout.clone()) + } + + pub fn set_status(&mut self, status: LocalBuildStatus, cx: &mut Context) { + self.status = status; + cx.notify(); + } + + fn can_build(&self) -> bool { + self.daemon_ui_owned == Some(true) + && !matches!( + self.status, + LocalBuildStatus::Building + | LocalBuildStatus::ReadyToRestart + | LocalBuildStatus::RestartingDaemon + | LocalBuildStatus::RestartingApp + ) + } + + fn can_restart(&self) -> bool { + self.daemon_ui_owned == Some(true) + && matches!(self.status, LocalBuildStatus::ReadyToRestart) + } +} + +#[cfg(feature = "gpui-ui")] +#[derive(Clone)] +pub struct GlobalLocalBuild(pub Entity); + +#[cfg(feature = "gpui-ui")] +impl Global for GlobalLocalBuild {} + +#[cfg(test)] +mod tests { + use super::detect_local_checkout_from; + #[cfg(feature = "gpui-ui")] + use super::{LocalBuildState, LocalBuildStatus, LocalCheckout}; + use std::path::Path; + + fn binary_name() -> &'static str { + if cfg!(windows) { "okena.exe" } else { "okena" } + } + + #[test] + fn detects_debug_and_release_binaries_inside_workspace_target() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + for profile in ["debug", "release"] { + let executable = root.join("target").join(profile).join(binary_name()); + let checkout = detect_local_checkout_from(&executable, root).unwrap(); + assert_eq!(checkout.root(), root); + assert_eq!( + checkout.release_executable(), + root.join("target").join("release").join(binary_name()) + ); + } + } + + #[test] + fn rejects_installed_and_non_okena_binaries() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let installed = Path::new("/usr/local/bin").join(binary_name()); + assert!(detect_local_checkout_from(&installed, root).is_none()); + assert!(detect_local_checkout_from(&root.join("target/debug/helper"), root).is_none()); + } + + #[cfg(feature = "gpui-ui")] + #[test] + fn rebuild_requires_managed_daemon_and_non_active_status() { + let checkout = LocalCheckout { + root: "/repo".into(), + release_executable: "/repo/target/release/okena".into(), + }; + let mut state = LocalBuildState::new(checkout); + assert!(!state.can_build()); + + state.daemon_ui_owned = Some(true); + assert!(state.can_build()); + state.status = LocalBuildStatus::Failed { + error: "failed".to_string(), + }; + assert!(state.can_build()); + + for status in [ + LocalBuildStatus::Building, + LocalBuildStatus::ReadyToRestart, + LocalBuildStatus::RestartingDaemon, + LocalBuildStatus::RestartingApp, + ] { + state.status = status; + assert!(!state.can_build()); + } + } + + #[cfg(feature = "gpui-ui")] + #[test] + fn restart_requires_completed_build_and_managed_daemon() { + let checkout = LocalCheckout { + root: "/repo".into(), + release_executable: "/repo/target/release/okena".into(), + }; + let mut state = LocalBuildState::new(checkout); + state.status = LocalBuildStatus::ReadyToRestart; + assert!(!state.can_restart()); + + state.daemon_ui_owned = Some(true); + assert!(state.can_restart()); + + state.status = LocalBuildStatus::Idle; + assert!(!state.can_restart()); + } +} 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/orchestrator.rs b/crates/okena-ext-updater/src/orchestrator.rs index 789665da5..8a6b508fa 100644 --- a/crates/okena-ext-updater/src/orchestrator.rs +++ b/crates/okena-ext-updater/src/orchestrator.rs @@ -47,13 +47,12 @@ where let mut download = std::pin::pin!(download); let download_result: anyhow::Result = loop { - let polled = std::future::poll_fn(|task_cx| { - match download.as_mut().poll(task_cx) { + 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; + }) + .await; match polled { Some(r) => break r, None => { diff --git a/crates/okena-ext-updater/src/status.rs b/crates/okena-ext-updater/src/status.rs index 13c2748a4..63b22a0c0 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 std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +#[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,35 +214,132 @@ 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<()>, + _subscription: Option, } +#[cfg(feature = "gpui-ui")] impl UpdateStatusWidget { pub fn new(cx: &mut Context) -> Self { - // Start the background checker when the widget is created - if let Some(gui) = cx.try_global::() { + let subscription = cx + .try_global::() + .map(|local| local.0.clone()) + .map(|state| cx.observe(&state, |_this, _state, cx| cx.notify())); + + // Installed builds mirror daemon-owned update state into this process. + if subscription.is_none() + && 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 } + Self { + _subscription: subscription, + } } } +#[cfg(feature = "gpui-ui")] impl Render for UpdateStatusWidget { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(local) = cx + .try_global::() + .map(|global| global.0.clone()) + { + let t = theme(cx); + let state = local.read(cx); + let text = |label: String, color| { + div() + .px(px(6.0)) + .py(px(1.0)) + .text_color(color) + .text_size(ui_text_sm(cx)) + .child(label) + .into_any_element() + }; + + if state.daemon_ui_owned() == Some(false) { + return text( + "Local build · external daemon".to_string(), + rgb(t.text_muted), + ); + } + + return match state.status() { + crate::LocalBuildStatus::Idle => div() + .id("local-rebuild") + .cursor_pointer() + .px(px(6.0)) + .py(px(1.0)) + .text_color(rgb(t.term_green)) + .text_size(ui_text_sm(cx)) + .child("Local build · Rebuild") + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::RebuildLocal), cx); + }) + .into_any_element(), + crate::LocalBuildStatus::Building => { + text("Building release…".to_string(), rgb(t.term_yellow)) + } + crate::LocalBuildStatus::ReadyToRestart => div() + .id("local-restart") + .cursor_pointer() + .px(px(6.0)) + .py(px(1.0)) + .text_color(rgb(t.term_green)) + .text_size(ui_text_sm(cx)) + .child("Local build · Restart") + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::RestartLocalBuild), cx); + }) + .into_any_element(), + crate::LocalBuildStatus::RestartingDaemon => { + text("Restarting daemon…".to_string(), rgb(t.term_yellow)) + } + crate::LocalBuildStatus::RestartingApp => { + text("Restarting Okena…".to_string(), rgb(t.term_yellow)) + } + crate::LocalBuildStatus::Failed { error } => h_flex() + .id("local-rebuild-failed") + .gap(px(6.0)) + .items_center() + .text_size(ui_text_sm(cx)) + .child( + div() + .text_color(rgb(t.term_red)) + .child(format!("Rebuild failed: {error}")), + ) + .child( + div() + .id("local-rebuild-retry") + .cursor_pointer() + .text_color(rgb(t.text_muted)) + .hover(|s| s.text_color(rgb(t.text_primary))) + .child("Retry") + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::RebuildLocal), cx); + }), + ) + .into_any_element(), + }; + } + let Some(update_info) = cx.try_global::() else { return div().size_0().into_any_element(); }; @@ -242,26 +370,20 @@ 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(); } - })) + })), ) .child( div() @@ -272,53 +394,45 @@ impl Render for UpdateStatusWidget { .child("What's new") .on_click(move |_, _, _cx| { open_url(&release_url); - }) - ) - .into_any_element() - } - UpdateStatus::Installing { version } => { - div() - .px(px(6.0)) - .py(px(1.0)) - .text_color(rgb(t.term_yellow)) - .text_size(ui_text_sm(cx)) - .child(format!("Installing v{}...", version)) - .into_any_element() - } - UpdateStatus::ReadyToRestart { .. } => { - div() - .id("update-restart") - .cursor_pointer() - .px(px(6.0)) - .py(px(1.0)) - .text_color(rgb(t.term_green)) - .text_size(ui_text_sm(cx)) - .child("Restart to update") - .on_click(move |_, _, cx| { - crate::installer::restart_app(cx); - }) - .into_any_element() - } - UpdateStatus::Downloading { version, progress } => { - h_flex() - .gap(px(4.0)) - .child( - div() - .text_color(rgb(t.term_yellow)) - .text_size(ui_text_sm(cx)) - .child(format!("Downloading v{}... {}%", version, progress)) + }), ) .into_any_element() } - UpdateStatus::Checking => { - div() - .px(px(6.0)) - .py(px(1.0)) - .text_color(rgb(t.text_muted)) - .text_size(ui_text_sm(cx)) - .child("Checking for updates...") - .into_any_element() - } + UpdateStatus::Installing { version } => div() + .px(px(6.0)) + .py(px(1.0)) + .text_color(rgb(t.term_yellow)) + .text_size(ui_text_sm(cx)) + .child(format!("Installing v{}...", version)) + .into_any_element(), + UpdateStatus::ReadyToRestart { .. } => div() + .id("update-restart") + .cursor_pointer() + .px(px(6.0)) + .py(px(1.0)) + .text_color(rgb(t.term_green)) + .text_size(ui_text_sm(cx)) + .child("Restart to update") + .on_click(move |_, _, cx| { + crate::installer::restart_app(cx); + }) + .into_any_element(), + UpdateStatus::Downloading { version, progress } => h_flex() + .gap(px(4.0)) + .child( + div() + .text_color(rgb(t.term_yellow)) + .text_size(ui_text_sm(cx)) + .child(format!("Downloading v{}... {}%", version, progress)), + ) + .into_any_element(), + UpdateStatus::Checking => div() + .px(px(6.0)) + .py(px(1.0)) + .text_color(rgb(t.text_muted)) + .text_size(ui_text_sm(cx)) + .child("Checking for updates...") + .into_any_element(), UpdateStatus::Failed { ref error } => { let info_dismiss = info.clone(); div() @@ -330,7 +444,7 @@ impl Render for UpdateStatusWidget { div() .text_color(rgb(t.term_red)) .text_size(ui_text_sm(cx)) - .child(format!("Update failed: {}", error)) + .child(format!("Update failed: {}", error)), ) .child( div() @@ -341,7 +455,8 @@ impl Render for UpdateStatusWidget { .child("x") .on_click(move |_, _, _cx| { info_dismiss.dismiss(); - }) + let _ = crate::daemon_client::request_dismiss(); + }), ) .into_any_element() } @@ -356,7 +471,7 @@ impl Render for UpdateStatusWidget { div() .text_color(rgb(t.text_muted)) .text_size(ui_text_sm(cx)) - .child(format!("v{} — brew upgrade okena", version)) + .child(format!("v{} — brew upgrade okena", version)), ) .child( div() @@ -367,7 +482,8 @@ 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-extensions/src/lib.rs b/crates/okena-extensions/src/lib.rs index 92fda9e4e..ace6ef313 100644 --- a/crates/okena-extensions/src/lib.rs +++ b/crates/okena-extensions/src/lib.rs @@ -150,9 +150,7 @@ mod tests { // Import specific items rather than `use super::*` — the latter pulls in // `gpui`'s `test` proc-macro (re-exported via `use gpui::*`), which shadows // the std `#[test]` attribute and blows the macro recursion limit. - use super::{ - ExtensionInstance, ExtensionManifest, ExtensionRegistration, ExtensionRegistry, - }; + use super::{ExtensionInstance, ExtensionManifest, ExtensionRegistration, ExtensionRegistry}; use std::sync::Arc; #[test] diff --git a/crates/okena-files/Cargo.toml b/crates/okena-files/Cargo.toml index ebe14e68c..08987263d 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-transport = { path = "../okena-transport", features = ["cancellable-http"] } +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/code_view.rs b/crates/okena-files/src/code_view.rs index a9c161acc..5cfa6df24 100644 --- a/crates/okena-files/src/code_view.rs +++ b/crates/okena-files/src/code_view.rs @@ -9,7 +9,6 @@ use crate::selection::SelectionState; use crate::syntax::{HighlightedLine, HighlightedSpan}; use gpui::*; - /// Type alias for code selection (line index, column). pub type CodeSelection = SelectionState<(usize, usize)>; @@ -89,7 +88,9 @@ pub fn update_scrollbar_drag( let new_scroll = (drag.start_scroll_y + delta_scroll).clamp(0.0, scrollable_content); let state = scroll_handle.0.borrow_mut(); - state.base_handle.set_offset(point(px(0.0), px(-new_scroll))); + state + .base_handle + .set_offset(point(px(0.0), px(-new_scroll))); } /// Build a StyledText with optional background highlights (e.g. selection or word-level diff). @@ -191,8 +192,16 @@ pub fn selection_bg_ranges( if line_index < start_line || line_index > end_line { return vec![]; } - let sel_start = if line_index == start_line { start_col.min(line_len) } else { 0 }; - let sel_end = if line_index == end_line { end_col.min(line_len) } else { line_len }; + let sel_start = if line_index == start_line { + start_col.min(line_len) + } else { + 0 + }; + let sel_end = if line_index == end_line { + end_col.min(line_len) + } else { + line_len + }; if sel_start < sel_end { vec![(sel_start..sel_end, SELECTION_BG.into())] } else { @@ -237,7 +246,11 @@ pub fn extract_selected_text<'a>( } } - if result.is_empty() { None } else { Some(result) } + if result.is_empty() { + None + } else { + Some(result) + } } /// Get selected text from highlighted lines (convenience wrapper). diff --git a/crates/okena-files/src/content_search.rs b/crates/okena-files/src/content_search.rs index 761d44aef..84b7e57ae 100644 --- a/crates/okena-files/src/content_search.rs +++ b/crates/okena-files/src/content_search.rs @@ -5,17 +5,22 @@ use grep_matcher::Matcher; use grep_regex::RegexMatcherBuilder; -use grep_searcher::sinks::UTF8; use grep_searcher::Searcher; +use grep_searcher::sinks::UTF8; use ignore::{WalkBuilder, WalkState}; use std::ops::Range; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; /// Skip files larger than this for content search. Lockfiles, bundles, and /// generated code are typically uninteresting and dominate I/O time. const MAX_FILE_SIZE: u64 = 1_000_000; +const MAX_RESULTS: usize = 10_000; +const MAX_CONTEXT_LINES: usize = 20; +const MAX_MATCH_RANGES_PER_LINE: usize = 2_048; +const MAX_SNIPPET_BYTES: usize = 64 * 1024; +const MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; /// A single search match within a file. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -38,13 +43,18 @@ pub struct ContentMatch { /// the raw text would be misaligned. This function applies the same expansion /// and adjusts all ranges to match the expanded string. fn expand_tabs(text: &str, ranges: &[Range]) -> (String, Vec>) { - let mut expanded = String::with_capacity(text.len()); - // Map from original byte offset to expanded byte offset - let mut offset_map: Vec = Vec::with_capacity(text.len() + 1); + let mut expanded = String::with_capacity(text.len().min(MAX_SNIPPET_BYTES)); + // Match boundaries are UTF-8 boundaries, so only character boundaries need + // entries in this byte-indexed map. + let mut offset_map = vec![None; text.len() + 1]; let mut expanded_pos: usize = 0; for (orig_pos, ch) in text.char_indices() { - offset_map.resize(orig_pos + 1, expanded_pos); + offset_map[orig_pos] = Some(expanded_pos); + let expanded_len = if ch == '\t' { 4 } else { ch.len_utf8() }; + if expanded_pos + expanded_len > MAX_SNIPPET_BYTES { + break; + } if ch == '\t' { expanded.push_str(" "); expanded_pos += 4; @@ -52,29 +62,49 @@ fn expand_tabs(text: &str, ranges: &[Range]) -> (String, Vec expanded.push(ch); expanded_pos += ch.len_utf8(); } + offset_map[orig_pos + ch.len_utf8()] = Some(expanded_pos); } - // Sentinel for end-of-string - offset_map.resize(text.len() + 1, expanded_pos); let new_ranges = ranges .iter() .filter_map(|r| { - let start = *offset_map.get(r.start)?; - let end = *offset_map.get(r.end)?; + let start = offset_map.get(r.start).copied().flatten()?; + let end = offset_map + .get(r.end) + .copied() + .flatten() + .unwrap_or(expanded.len()); Some(start..end) }) + .filter(|range| range.start < range.end && range.end <= expanded.len()) .collect(); (expanded, new_ranges) } +fn expand_bounded_match_line(text: &str, ranges: &[Range]) -> (String, Vec>) { + if text.len() <= MAX_SNIPPET_BYTES { + return expand_tabs(text, ranges); + } + + // Keep the first match visible when a generated/minified line exceeds the + // snippet cap. Matcher offsets are UTF-8 boundaries. + let start = ranges.first().map_or(0, |range| range.start); + let mut end = (start + MAX_SNIPPET_BYTES).min(text.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + let adjusted_ranges: Vec<_> = ranges + .iter() + .filter(|range| range.end > start && range.start < end) + .map(|range| range.start.saturating_sub(start)..range.end.min(end) - start) + .collect(); + expand_tabs(&text[start..end], &adjusted_ranges) +} + /// Expand tabs to 4 spaces in a string (no range remapping needed). fn expand_tabs_simple(text: &str) -> String { - if text.contains('\t') { - text.replace('\t', " ") - } else { - text.to_string() - } + expand_tabs(text, &[]).0 } /// Search results grouped by file. @@ -133,7 +163,10 @@ impl Default for ContentSearchConfig { pub const ALWAYS_IGNORE: &[&str] = &["!.git/", "!.claude/worktrees/"]; /// Configure a walker with the project's ignore rules and our defaults. -fn configure_walker(project_path: &Path, config: &ContentSearchConfig) -> WalkBuilder { +fn configure_walker( + project_path: &Path, + config: &ContentSearchConfig, +) -> Result { let mut walk_builder = WalkBuilder::new(project_path); walk_builder .hidden(false) @@ -146,16 +179,21 @@ fn configure_walker(project_path: &Path, config: &ContentSearchConfig) -> WalkBu // Build overrides: always-ignore dirs + optional user glob filter let mut override_builder = ignore::overrides::OverrideBuilder::new(project_path); for pattern in ALWAYS_IGNORE { - let _ = override_builder.add(pattern); + override_builder + .add(pattern) + .map_err(|error| format!("Invalid built-in file glob '{pattern}': {error}"))?; } if let Some(ref glob) = config.file_glob { - let _ = override_builder.add(glob); - } - if let Ok(overrides) = override_builder.build() { - walk_builder.overrides(overrides); + override_builder + .add(glob) + .map_err(|error| format!("Invalid file glob '{glob}': {error}"))?; } + let overrides = override_builder + .build() + .map_err(|error| format!("Invalid file glob: {error}"))?; + walk_builder.overrides(overrides); - walk_builder + Ok(walk_builder) } /// Add context lines to matches by reading the file content. @@ -177,19 +215,71 @@ fn add_context_lines(matches: &mut [ContentMatch], file_path: &Path, context_lin // Context before let start = line_idx.saturating_sub(context_lines); for i in start..line_idx { - m.context_before.push(( - i + 1, - expand_tabs_simple(all_lines.get(i).unwrap_or(&"")), - )); + m.context_before + .push((i + 1, expand_tabs_simple(all_lines.get(i).unwrap_or(&"")))); } // Context after let end = (line_idx + 1 + context_lines).min(all_lines.len()); for i in (line_idx + 1)..end { - m.context_after.push(( - i + 1, - expand_tabs_simple(all_lines.get(i).unwrap_or(&"")), - )); + m.context_after + .push((i + 1, expand_tabs_simple(all_lines.get(i).unwrap_or(&"")))); + } + } +} + +/// Atomically claim up to `requested` result slots from the shared limit. +fn reserve_match_slots(total: &AtomicUsize, max_results: usize, requested: usize) -> usize { + let mut current = total.load(Ordering::Acquire); + loop { + let remaining = max_results.saturating_sub(current); + let reserved = requested.min(remaining); + if reserved == 0 { + return 0; + } + match total.compare_exchange_weak( + current, + current + reserved, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return reserved, + Err(observed) => current = observed, + } + } +} + +fn estimated_payload_bytes(result: &FileSearchResult) -> usize { + // JSON may escape each input byte as a six-byte sequence. The fixed costs + // also cover field names, numbers, commas, and collection delimiters. + let escaped = |len: usize| len.saturating_mul(6); + let mut bytes = 512usize + .saturating_add(escaped(result.file_path.to_string_lossy().len())) + .saturating_add(escaped(result.relative_path.len())); + for found in &result.matches { + bytes = bytes + .saturating_add(256) + .saturating_add(escaped(found.line_content.len())) + .saturating_add(found.match_ranges.len().saturating_mul(48)); + for (_, line) in found.context_before.iter().chain(&found.context_after) { + bytes = bytes.saturating_add(64).saturating_add(escaped(line.len())); + } + } + bytes +} + +fn reserve_payload_bytes(total: &AtomicUsize, requested: usize) -> bool { + let mut current = total.load(Ordering::Acquire); + loop { + let Some(next) = current.checked_add(requested) else { + return false; + }; + if next > MAX_PAYLOAD_BYTES { + return false; + } + match total.compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) => return true, + Err(observed) => current = observed, } } } @@ -206,14 +296,33 @@ pub fn search_content( config: &ContentSearchConfig, cancelled: &AtomicBool, on_result: &mut (dyn FnMut(FileSearchResult) + Send), -) { +) -> Result<(), String> { if query.is_empty() { - return; + return Ok(()); } - match config.mode { - SearchMode::Fuzzy => search_content_fuzzy(project_path, query, config, cancelled, on_result), - _ => search_content_grep(project_path, query, config, cancelled, on_result), + let mut effective_config = config.clone(); + effective_config.max_results = effective_config.max_results.min(MAX_RESULTS); + effective_config.context_lines = effective_config.context_lines.min(MAX_CONTEXT_LINES); + + let walker = configure_walker(project_path, &effective_config)?; + match effective_config.mode { + SearchMode::Fuzzy => search_content_fuzzy( + walker, + project_path, + query, + &effective_config, + cancelled, + on_result, + ), + _ => search_content_grep( + walker, + project_path, + query, + &effective_config, + cancelled, + on_result, + ), } } @@ -223,12 +332,13 @@ pub fn search_content( /// `Searcher` (it's stateful) and a clone of the matcher; results are funneled /// through a `Mutex` around the caller's callback. fn search_content_grep( + walker: WalkBuilder, project_path: &Path, query: &str, config: &ContentSearchConfig, cancelled: &AtomicBool, on_result: &mut (dyn FnMut(FileSearchResult) + Send), -) { +) -> Result<(), String> { let matcher = { let mut builder = RegexMatcherBuilder::new(); builder.case_insensitive(!config.case_sensitive); @@ -238,21 +348,22 @@ fn search_content_grep( } else { escape_regex(query) }; - match builder.build(&pattern) { - Ok(m) => m, - Err(_) => return, - } + builder + .build(&pattern) + .map_err(|error| format!("Invalid regular expression: {error}"))? }; let total_matches = AtomicUsize::new(0); + let total_payload_bytes = AtomicUsize::new(0); let max_results = config.max_results; let context_lines = config.context_lines; let on_result = Mutex::new(on_result); - configure_walker(project_path, config).build_parallel().run(|| { + walker.build_parallel().run(|| { let matcher = matcher.clone(); let mut searcher = Searcher::new(); let total_matches = &total_matches; + let total_payload_bytes = &total_payload_bytes; let on_result = &on_result; Box::new(move |entry| { @@ -290,17 +401,20 @@ fn search_content_grep( // Find match ranges within the line let mut match_ranges = Vec::new(); - matcher.find_iter(line_content.as_bytes(), |m| { - let start = m.start(); - let end = m.end().min(line_trimmed.len()); - if start < line_trimmed.len() { - match_ranges.push(start..end); - } - true - }).ok(); + matcher + .find_iter(line_content.as_bytes(), |m| { + let start = m.start(); + let end = m.end().min(line_trimmed.len()); + if start < line_trimmed.len() { + match_ranges.push(start..end); + } + match_ranges.len() < MAX_MATCH_RANGES_PER_LINE + }) + .ok(); // Expand tabs to match syntax highlighter output - let (line_expanded, match_ranges) = expand_tabs(line_trimmed, &match_ranges); + let (line_expanded, match_ranges) = + expand_bounded_match_line(line_trimmed, &match_ranges); file_matches.push(ContentMatch { line_number: line_number as usize, @@ -318,7 +432,11 @@ fn search_content_grep( return WalkState::Continue; } - total_matches.fetch_add(file_matches.len(), Ordering::Relaxed); + let reserved = reserve_match_slots(total_matches, max_results, file_matches.len()); + if reserved == 0 { + return WalkState::Quit; + } + file_matches.truncate(reserved); if context_lines > 0 { add_context_lines(&mut file_matches, path, context_lines); @@ -336,6 +454,10 @@ fn search_content_grep( best_score: 0, }; + if !reserve_payload_bytes(total_payload_bytes, estimated_payload_bytes(&result)) { + return WalkState::Quit; + } + if let Ok(mut cb) = on_result.lock() { cb(result); } @@ -343,6 +465,7 @@ fn search_content_grep( WalkState::Continue }) }); + Ok(()) } /// Search using nucleo-matcher (fuzzy mode). @@ -350,15 +473,17 @@ fn search_content_grep( /// Walks the project tree in parallel; each worker thread keeps its own /// `Matcher` (it's stateful) and reads file contents independently. fn search_content_fuzzy( + walker: WalkBuilder, project_path: &Path, query: &str, config: &ContentSearchConfig, cancelled: &AtomicBool, on_result: &mut (dyn FnMut(FileSearchResult) + Send), -) { +) -> Result<(), String> { use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str}; let total_matches = AtomicUsize::new(0); + let total_payload_bytes = AtomicUsize::new(0); let max_results = config.max_results; let context_lines = config.context_lines; let on_result = Mutex::new(on_result); @@ -372,9 +497,10 @@ fn search_content_fuzzy( _ => 30, }; - configure_walker(project_path, config).build_parallel().run(|| { + walker.build_parallel().run(|| { let mut matcher = Matcher::new(NucleoConfig::DEFAULT); let total_matches = &total_matches; + let total_payload_bytes = &total_payload_bytes; let on_result = &on_result; Box::new(move |entry| { @@ -425,6 +551,7 @@ fn search_content_fuzzy( let char_to_byte: Vec<(usize, char)> = line.char_indices().collect(); let match_ranges: Vec> = indices .iter() + .take(MAX_MATCH_RANGES_PER_LINE) .filter_map(|&idx| { let (byte_pos, ch) = char_to_byte.get(idx as usize)?; Some(*byte_pos..*byte_pos + ch.len_utf8()) @@ -432,15 +559,19 @@ fn search_content_fuzzy( .collect(); // Expand tabs to match syntax highlighter output - let (line_expanded, match_ranges) = expand_tabs(line, &match_ranges); - - scored_matches.push((score, ContentMatch { - line_number: line_idx + 1, - line_content: line_expanded, - match_ranges, - context_before: Vec::new(), - context_after: Vec::new(), - })); + let (line_expanded, match_ranges) = + expand_bounded_match_line(line, &match_ranges); + + scored_matches.push(( + score, + ContentMatch { + line_number: line_idx + 1, + line_content: line_expanded, + match_ranges, + context_before: Vec::new(), + context_after: Vec::new(), + }, + )); } } @@ -451,13 +582,15 @@ fn search_content_fuzzy( // Sort by score descending — best matches first scored_matches.sort_by_key(|b| std::cmp::Reverse(b.0)); - let best_score = scored_matches.first().map(|(s, _)| *s).unwrap_or(0); - let mut file_matches: Vec = scored_matches - .into_iter() - .map(|(_, m)| m) - .collect(); + let reserved = reserve_match_slots(total_matches, max_results, scored_matches.len()); + if reserved == 0 { + return WalkState::Quit; + } + scored_matches.truncate(reserved); - total_matches.fetch_add(file_matches.len(), Ordering::Relaxed); + let best_score = scored_matches.first().map(|(s, _)| *s).unwrap_or(0); + let mut file_matches: Vec = + scored_matches.into_iter().map(|(_, m)| m).collect(); if context_lines > 0 { add_context_lines(&mut file_matches, path, context_lines); @@ -475,6 +608,10 @@ fn search_content_fuzzy( best_score, }; + if !reserve_payload_bytes(total_payload_bytes, estimated_payload_bytes(&result)) { + return WalkState::Quit; + } + if let Ok(mut cb) = on_result.lock() { cb(result); } @@ -482,6 +619,7 @@ fn search_content_fuzzy( WalkState::Continue }) }); + Ok(()) } /// Handle for cancelling a running search. @@ -521,8 +659,7 @@ fn escape_regex(s: &str) -> String { let mut escaped = String::with_capacity(s.len() * 2); for c in s.chars() { match c { - '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' - | '$' => { + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' => { escaped.push('\\'); escaped.push(c); } @@ -581,9 +718,205 @@ mod tests { results.push(result.relative_path); }; - search_content(&dir.path, "needle", &config, &cancelled, &mut on_result); + search_content(&dir.path, "needle", &config, &cancelled, &mut on_result).unwrap(); assert!(results.iter().any(|path| path == "small.txt")); assert!(!results.iter().any(|path| path == "big.log")); } + + fn assert_dense_parallel_search_respects_cap(mode: SearchMode) { + let dir = TempDir::new(); + for file_index in 0..32 { + let content = (0..32) + .map(|line_index| format!("needle {file_index} {line_index}\n")) + .collect::(); + fs::write(dir.path.join(format!("dense-{file_index}.txt")), content).unwrap(); + } + + let cancelled = AtomicBool::new(false); + let config = ContentSearchConfig { + mode, + max_results: 17, + ..ContentSearchConfig::default() + }; + let mut matches = Vec::new(); + search_content(&dir.path, "needle", &config, &cancelled, &mut |result| { + matches.extend( + result + .matches + .into_iter() + .map(|found| (result.relative_path.clone(), found.line_number)), + ); + }) + .unwrap(); + + let unique = matches.iter().collect::>(); + assert_eq!(matches.len(), config.max_results); + assert_eq!(unique.len(), matches.len()); + } + + #[test] + fn parallel_grep_search_has_a_strict_result_cap() { + assert_dense_parallel_search_respects_cap(SearchMode::Literal); + } + + #[test] + fn parallel_fuzzy_search_has_a_strict_result_cap() { + assert_dense_parallel_search_respects_cap(SearchMode::Fuzzy); + } + + #[test] + fn invalid_file_glob_returns_an_error_without_results() { + let dir = TempDir::new(); + fs::write(dir.path.join("match.txt"), "needle\n").unwrap(); + let cancelled = AtomicBool::new(false); + let config = ContentSearchConfig { + file_glob: Some("[".to_string()), + ..ContentSearchConfig::default() + }; + let mut result_count = 0; + + let error = search_content(&dir.path, "needle", &config, &cancelled, &mut |_| { + result_count += 1 + }) + .unwrap_err(); + + assert!(error.contains("Invalid file glob"), "{error}"); + assert_eq!(result_count, 0); + } + + #[test] + fn invalid_regex_returns_an_error_without_results() { + let dir = TempDir::new(); + fs::write(dir.path.join("match.txt"), "needle\n").unwrap(); + let cancelled = AtomicBool::new(false); + let config = ContentSearchConfig { + mode: SearchMode::Regex, + ..ContentSearchConfig::default() + }; + let mut result_count = 0; + + let error = search_content(&dir.path, "(", &config, &cancelled, &mut |_| { + result_count += 1 + }) + .unwrap_err(); + + assert!(error.contains("Invalid regular expression"), "{error}"); + assert_eq!(result_count, 0); + } + + #[test] + fn minified_line_has_bounded_ranges_and_payload() { + let dir = TempDir::new(); + fs::write(dir.path.join("minified.js"), "a".repeat(900_000)).unwrap(); + let cancelled = AtomicBool::new(false); + let mut results = Vec::new(); + + search_content( + &dir.path, + "a", + &ContentSearchConfig::default(), + &cancelled, + &mut |result| results.push(result), + ) + .unwrap(); + + assert_eq!(results.len(), 1); + let found = &results[0].matches[0]; + assert!(found.line_content.len() <= MAX_SNIPPET_BYTES); + assert_eq!(found.match_ranges.len(), MAX_MATCH_RANGES_PER_LINE); + assert!( + found + .match_ranges + .iter() + .all(|range| range.end <= found.line_content.len()) + ); + assert!(serde_json::to_vec(&results).unwrap().len() < 1_000_000); + } + + #[test] + fn parallel_files_share_one_payload_budget() { + let dir = TempDir::new(); + let line = format!("needle{}", "x".repeat(MAX_SNIPPET_BYTES)); + for file_index in 0..300 { + fs::write(dir.path.join(format!("large-{file_index}.txt")), &line).unwrap(); + } + let cancelled = AtomicBool::new(false); + let config = ContentSearchConfig { + max_results: usize::MAX, + ..ContentSearchConfig::default() + }; + let mut results = Vec::new(); + + search_content(&dir.path, "needle", &config, &cancelled, &mut |result| { + results.push(result) + }) + .unwrap(); + + let estimated: usize = results.iter().map(estimated_payload_bytes).sum(); + assert!(estimated <= MAX_PAYLOAD_BYTES); + assert!(results.len() < 300); + } + + #[test] + fn unicode_lines_and_context_are_bounded_at_char_boundaries() { + let dir = TempDir::new(); + let long_unicode = "🦀".repeat(MAX_SNIPPET_BYTES); + fs::write( + dir.path.join("unicode.txt"), + format!("{long_unicode}\nneedle{long_unicode}\n{long_unicode}\n"), + ) + .unwrap(); + let cancelled = AtomicBool::new(false); + let config = ContentSearchConfig { + context_lines: usize::MAX, + ..ContentSearchConfig::default() + }; + let mut results = Vec::new(); + + search_content(&dir.path, "needle", &config, &cancelled, &mut |result| { + results.push(result) + }) + .unwrap(); + + let found = &results[0].matches[0]; + assert!(found.line_content.len() <= MAX_SNIPPET_BYTES); + assert!( + found + .context_before + .iter() + .chain(&found.context_after) + .all(|(_, line)| line.len() <= MAX_SNIPPET_BYTES + && line.is_char_boundary(line.len())) + ); + assert!(std::str::from_utf8(found.line_content.as_bytes()).is_ok()); + } + + #[test] + fn ordinary_search_results_remain_complete() { + let dir = TempDir::new(); + fs::write( + dir.path.join("ordinary.txt"), + "before\n\tneedle and needle\nafter\n", + ) + .unwrap(); + let cancelled = AtomicBool::new(false); + let config = ContentSearchConfig { + case_sensitive: true, + context_lines: 1, + ..ContentSearchConfig::default() + }; + let mut results = Vec::new(); + + search_content(&dir.path, "needle", &config, &cancelled, &mut |result| { + results.push(result) + }) + .unwrap(); + + let found = &results[0].matches[0]; + assert_eq!(found.line_content, " needle and needle"); + assert_eq!(found.match_ranges, vec![4..10, 15..21]); + assert_eq!(found.context_before, vec![(1, "before".to_string())]); + assert_eq!(found.context_after, vec![(3, "after".to_string())]); + } } diff --git a/crates/okena-files/src/content_search_dialog/mod.rs b/crates/okena-files/src/content_search_dialog/mod.rs index 37d0ef492..26a2f5437 100644 --- a/crates/okena-files/src/content_search_dialog/mod.rs +++ b/crates/okena-files/src/content_search_dialog/mod.rs @@ -75,8 +75,11 @@ pub struct ContentSearchDialog { pub(super) total_matches: usize, /// Whether a search is currently running. pub(super) searching: bool, + pub(super) error_message: Option, /// Handle to cancel running search. pub(super) search_handle: Option, + /// Identity of the latest requested search, including its debounce period. + pub(super) search_generation: u64, /// Search config toggles. pub(super) case_sensitive: bool, pub(super) regex_mode: bool, @@ -126,7 +129,11 @@ pub struct ContentSearchDialog { } impl ContentSearchDialog { - pub fn new(project_fs: std::sync::Arc, is_dark: bool, cx: &mut Context) -> Self { + pub fn new( + project_fs: std::sync::Arc, + is_dark: bool, + cx: &mut Context, + ) -> Self { let focus_handle = cx.focus_handle(); let scroll_handle = UniformListScrollHandle::new(); let syntax_set = load_syntax_set(); @@ -138,26 +145,33 @@ impl ContentSearchDialog { // Restore from previous session let memory = cx.try_global::(); - let (query, case_sensitive, regex_mode, fuzzy_mode, file_glob, glob_input_text, expanded, show_ignored) = - memory - .map(|m| { - ( - m.query.clone(), - m.case_sensitive, - m.regex, - m.fuzzy, - m.file_glob.clone(), - m.glob_input.clone(), - m.expanded, - m.show_ignored, - ) - }) - .unwrap_or_default(); + let ( + query, + case_sensitive, + regex_mode, + fuzzy_mode, + file_glob, + glob_input_text, + expanded, + show_ignored, + ) = memory + .map(|m| { + ( + m.query.clone(), + m.case_sensitive, + m.regex, + m.fuzzy, + m.file_glob.clone(), + m.glob_input.clone(), + m.expanded, + m.show_ignored, + ) + }) + .unwrap_or_default(); // Create search input entity let search_input = cx.new(|cx| { - let mut input = SimpleInputState::new(cx) - .placeholder("Search file contents..."); + let mut input = SimpleInputState::new(cx).placeholder("Search file contents..."); if !query.is_empty() { input.set_value(&query, cx); input.select_all(cx); @@ -166,15 +180,17 @@ impl ContentSearchDialog { }); // Subscribe to search input changes - cx.subscribe(&search_input, |this: &mut Self, _, _: &InputChangedEvent, cx| { - this.trigger_search(cx); - }) + cx.subscribe( + &search_input, + |this: &mut Self, _, _: &InputChangedEvent, cx| { + this.trigger_search(cx); + }, + ) .detach(); // Create glob filter input entity let glob_input = cx.new(|cx| { - let mut input = SimpleInputState::new(cx) - .placeholder("e.g. *.rs, src/**/*.ts"); + let mut input = SimpleInputState::new(cx).placeholder("e.g. *.rs, src/**/*.ts"); if !glob_input_text.is_empty() { input.set_value(&glob_input_text, cx); } @@ -182,11 +198,14 @@ impl ContentSearchDialog { }); // Subscribe to glob input changes - cx.subscribe(&glob_input, |this: &mut Self, _, _: &InputChangedEvent, cx| { - let value = this.glob_input.read(cx).value().to_string(); - this.file_glob = if value.is_empty() { None } else { Some(value) }; - this.trigger_search(cx); - }) + cx.subscribe( + &glob_input, + |this: &mut Self, _, _: &InputChangedEvent, cx| { + let value = this.glob_input.read(cx).value().to_string(); + this.file_glob = if value.is_empty() { None } else { Some(value) }; + this.trigger_search(cx); + }, + ) .detach(); let has_query = !query.is_empty(); @@ -201,7 +220,9 @@ impl ContentSearchDialog { selected_index: 0, total_matches: 0, searching: false, + error_message: None, search_handle: None, + search_generation: 0, case_sensitive, regex_mode, fuzzy_mode, @@ -262,13 +283,18 @@ impl ContentSearchDialog { pub(super) fn open_selected(&self, cx: &mut Context) { if let Some(row) = self.rows.get(self.selected_index) { let (relative_path, line) = match row { - ResultRow::Match { relative_path, line_number, .. } => { - (relative_path.clone(), *line_number) - } + ResultRow::Match { + relative_path, + line_number, + .. + } => (relative_path.clone(), *line_number), ResultRow::FileHeader { relative_path, .. } => (relative_path.clone(), 1), }; self.save_memory(cx); - cx.emit(ContentSearchDialogEvent::FileSelected { relative_path, line }); + cx.emit(ContentSearchDialogEvent::FileSelected { + relative_path, + line, + }); } } @@ -277,7 +303,11 @@ impl ContentSearchDialog { } pub(super) fn select_next(&mut self) -> bool { - crate::list_overlay::select_next(&mut self.selected_index, self.rows.len(), &self.scroll_handle) + crate::list_overlay::select_next( + &mut self.selected_index, + self.rows.len(), + &self.scroll_handle, + ) } /// Set scope to a folder or file path, updating the glob filter and re-searching. @@ -314,7 +344,10 @@ pub enum ContentSearchDialogEvent { /// A match was opened. `relative_path` is project-relative; callers don't /// need to handle absolute path semantics (which differ between local and /// remote projects). - FileSelected { relative_path: String, line: usize }, + FileSelected { + relative_path: String, + line: usize, + }, } impl EventEmitter for ContentSearchDialog {} diff --git a/crates/okena-files/src/content_search_dialog/preview.rs b/crates/okena-files/src/content_search_dialog/preview.rs index d2041ecb3..c153822f2 100644 --- a/crates/okena-files/src/content_search_dialog/preview.rs +++ b/crates/okena-files/src/content_search_dialog/preview.rs @@ -41,7 +41,8 @@ impl ContentSearchDialog { pub(super) fn close_preview_search(&mut self, window: &mut Window, cx: &mut Context) { self.preview_search = None; self.preview_search_sig = None; - self.search_input.update(cx, |input, cx| input.focus(window, cx)); + self.search_input + .update(cx, |input, cx| input.focus(window, cx)); cx.notify(); } @@ -80,7 +81,10 @@ impl ContentSearchDialog { } /// Render the file preview panel showing the selected match's file. - pub(super) fn render_preview_panel(&mut self, cx: &mut Context) -> impl IntoElement + use<> { + pub(super) fn render_preview_panel( + &mut self, + cx: &mut Context, + ) -> impl IntoElement + use<> { let t = theme(cx); // Get the currently selected match info @@ -135,7 +139,11 @@ impl ContentSearchDialog { ); } - let lines = self.highlight_cache.get(&file_path).cloned().unwrap_or_default(); + let lines = self + .highlight_cache + .get(&file_path) + .cloned() + .unwrap_or_default(); let line_count = lines.len(); let match_bg = search_match_bg(t.search_match_bg); let current_match_bg = Hsla::from(Rgba { @@ -150,7 +158,11 @@ impl ContentSearchDialog { let search_inputs = self.preview_search.as_ref().map(|search| { let query = search.input.read(cx).value().to_string(); let case = search.case_sensitive(); - ((file_path.clone(), line_count, query.clone(), case), query, case) + ( + (file_path.clone(), line_count, query.clone(), case), + query, + case, + ) }); if let Some((sig, query, case)) = search_inputs && self.preview_search_sig.as_ref() != Some(&sig) @@ -238,142 +250,157 @@ impl ContentSearchDialog { .children(preview_search_bar) // File content .child( - uniform_list( - "preview-lines", - line_count, - move |range, _window, cx| { - view.update(cx, |this, cx| { - let t = theme(cx); - range - .map(|line_idx| { - let line_number = line_idx + 1; - let line_num_str = format!("{:>4}", line_number); + uniform_list("preview-lines", line_count, move |range, _window, cx| { + view.update(cx, |this, cx| { + let t = theme(cx); + range + .map(|line_idx| { + let line_number = line_idx + 1; + let line_num_str = format!("{:>4}", line_number); - // Check if this line has matches - let line_match = all_matches_in_file - .iter() - .find(|(ln, _)| *ln == line_number); + // Check if this line has matches + let line_match = all_matches_in_file + .iter() + .find(|(ln, _)| *ln == line_number); - let is_current_match = line_number == match_line; + let is_current_match = line_number == match_line; - // Combine match highlights with selection highlights - let line_len = lines.get(line_idx).map_or(0, |hl| hl.plain_text.len()); - let sel_bg_ranges = selection_bg_ranges(&this.preview_selection, line_idx, line_len); + // Combine match highlights with selection highlights + let line_len = + lines.get(line_idx).map_or(0, |hl| hl.plain_text.len()); + let sel_bg_ranges = selection_bg_ranges( + &this.preview_selection, + line_idx, + line_len, + ); - let styled_text = if let Some(hl) = - lines.get(line_idx) - { - let mut bg_ranges: Vec<(std::ops::Range, Hsla)> = Vec::new(); - if let Some((_, ranges)) = line_match { - let bg = if is_current_match { - current_match_bg - } else { - match_bg - }; - bg_ranges.extend( - ranges - .iter() - .filter(|r| r.end <= hl.plain_text.len()) - .map(|r| (r.clone(), bg)), - ); - } - bg_ranges.extend(sel_bg_ranges); - if let Some(search) = this.preview_search.as_ref() { - bg_ranges.extend(search.ranges_for_cell(line_idx, &t)); - } - build_styled_text_with_backgrounds( - &hl.spans, &bg_ranges, - ) - } else { - StyledText::new(String::new()) - }; + let styled_text = if let Some(hl) = lines.get(line_idx) { + let mut bg_ranges: Vec<(std::ops::Range, Hsla)> = + Vec::new(); + if let Some((_, ranges)) = line_match { + let bg = if is_current_match { + current_match_bg + } else { + match_bg + }; + bg_ranges.extend( + ranges + .iter() + .filter(|r| r.end <= hl.plain_text.len()) + .map(|r| (r.clone(), bg)), + ); + } + bg_ranges.extend(sel_bg_ranges); + if let Some(search) = this.preview_search.as_ref() { + bg_ranges.extend(search.ranges_for_cell(line_idx, &t)); + } + build_styled_text_with_backgrounds(&hl.spans, &bg_ranges) + } else { + StyledText::new(String::new()) + }; - let text_layout = styled_text.layout().clone(); - let plain_text = lines.get(line_idx).map(|hl| hl.plain_text.clone()).unwrap_or_default(); + let text_layout = styled_text.layout().clone(); + let plain_text = lines + .get(line_idx) + .map(|hl| hl.plain_text.clone()) + .unwrap_or_default(); - let row_bg = if is_current_match { - Some(current_match_bg) - } else if line_match.is_some() { - Some(match_bg) - } else { - None - }; + let row_bg = if is_current_match { + Some(current_match_bg) + } else if line_match.is_some() { + Some(match_bg) + } else { + None + }; - div() - .id(ElementId::Name(format!("preview-line-{}", line_idx).into())) - .flex() - .items_center() - .px(px(8.0)) - .h(px(24.0)) - .text_size(ui_text(13.0, cx)) - .font_family("monospace") - .when_some(row_bg, |d, bg| d.bg(bg)) - .on_mouse_down(MouseButton::Left, { - let text_layout = text_layout.clone(); - let plain_text = plain_text.clone(); - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + div() + .id(ElementId::Name( + format!("preview-line-{}", line_idx).into(), + )) + .flex() + .items_center() + .px(px(8.0)) + .h(px(24.0)) + .text_size(ui_text(13.0, cx)) + .font_family("monospace") + .when_some(row_bg, |d, bg| d.bg(bg)) + .on_mouse_down(MouseButton::Left, { + let text_layout = text_layout.clone(); + let plain_text = plain_text.clone(); + cx.listener( + move |this, event: &MouseDownEvent, _window, cx| { let col = text_layout .index_for_position(event.position) .unwrap_or_else(|ix| ix) .min(line_len); if event.click_count >= 3 { - this.preview_selection.start = Some((line_idx, 0)); - this.preview_selection.end = Some((line_idx, line_len)); + this.preview_selection.start = + Some((line_idx, 0)); + this.preview_selection.end = + Some((line_idx, line_len)); this.preview_selection.finish(); } else if event.click_count == 2 { - let (start, end) = find_word_boundaries(&plain_text, col); - this.preview_selection.start = Some((line_idx, start)); - this.preview_selection.end = Some((line_idx, end)); + let (start, end) = + find_word_boundaries(&plain_text, col); + this.preview_selection.start = + Some((line_idx, start)); + this.preview_selection.end = + Some((line_idx, end)); this.preview_selection.finish(); } else { - this.preview_selection.start = Some((line_idx, col)); - this.preview_selection.end = Some((line_idx, col)); + this.preview_selection.start = + Some((line_idx, col)); + this.preview_selection.end = + Some((line_idx, col)); this.preview_selection.is_selecting = true; } cx.notify(); - }) - }) - .on_mouse_move({ - let text_layout = text_layout.clone(); - cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { + }, + ) + }) + .on_mouse_move({ + let text_layout = text_layout.clone(); + cx.listener( + move |this, event: &MouseMoveEvent, _window, cx| { if this.preview_selection.is_selecting { let col = text_layout .index_for_position(event.position) .unwrap_or_else(|ix| ix) .min(line_len); - this.preview_selection.end = Some((line_idx, col)); + this.preview_selection.end = + Some((line_idx, col)); cx.notify(); } - }) - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, _window, cx| { - this.preview_selection.finish(); - cx.notify(); - }), - ) - .child( - div() - .text_color(rgb(t.text_muted)) - .min_w(px(44.0)) - .flex_shrink_0() - .text_size(ui_text_ms(cx)) - .child(line_num_str), - ) - .child( - div() - .flex_1() - .overflow_hidden() - .text_color(rgb(t.text_primary)) - .child(styled_text), + }, ) - .into_any_element() - }) - .collect() - }) - }, - ) + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.preview_selection.finish(); + cx.notify(); + }), + ) + .child( + div() + .text_color(rgb(t.text_muted)) + .min_w(px(44.0)) + .flex_shrink_0() + .text_size(ui_text_ms(cx)) + .child(line_num_str), + ) + .child( + div() + .flex_1() + .overflow_hidden() + .text_color(rgb(t.text_primary)) + .child(styled_text), + ) + .into_any_element() + }) + .collect() + }) + }) .flex_1() .track_scroll(&self.preview_scroll_handle), ) diff --git a/crates/okena-files/src/content_search_dialog/render.rs b/crates/okena-files/src/content_search_dialog/render.rs index 6a03e128d..2581bbbec 100644 --- a/crates/okena-files/src/content_search_dialog/render.rs +++ b/crates/okena-files/src/content_search_dialog/render.rs @@ -26,20 +26,19 @@ impl Render for ContentSearchDialog { && !self.glob_editing && self.preview_search.is_none() { - self.search_input.update(cx, |input, cx| input.focus(window, cx)); + self.search_input + .update(cx, |input, cx| input.focus(window, cx)); } // Shared key handler for both modes let key_handler = cx.listener(|this, event: &KeyDownEvent, window, cx| { match event.keystroke.key.as_str() { - "up" - if this.select_prev() => { - cx.notify(); - } - "down" - if this.select_next() => { - cx.notify(); - } + "up" if this.select_prev() => { + cx.notify(); + } + "down" if this.select_next() => { + cx.notify(); + } "enter" => this.open_selected(cx), // In-page search over the preview (only visible when expanded) "f" if (event.keystroke.modifiers.platform @@ -64,14 +63,14 @@ impl Render for ContentSearchDialog { } "c" if event.keystroke.modifiers.platform => { if let Some(file_path) = &this.preview_file - && let Some(lines) = this.highlight_cache.get(file_path) { - let text = extract_selected_text( - &this.preview_selection, - lines.len(), - |i| &lines[i].plain_text, - ); - copy_to_clipboard(cx, text); - } + && let Some(lines) = this.highlight_cache.get(file_path) + { + let text = + extract_selected_text(&this.preview_selection, lines.len(), |i| { + &lines[i].plain_text + }); + copy_to_clipboard(cx, text); + } } _ => {} } @@ -110,7 +109,12 @@ impl Render for ContentSearchDialog { }; // Results list - let results_area: AnyElement = if self.rows.is_empty() { + let results_area: AnyElement = if let Some(error) = &self.error_message { + div() + .flex_1() + .child(empty_state(error, &t, cx)) + .into_any_element() + } else if self.rows.is_empty() { div() .flex_1() .child(empty_state( @@ -130,33 +134,42 @@ impl Render for ContentSearchDialog { let _has_context = self.expanded; let view = cx.entity().clone(); - uniform_list("content-search-list", rows.len(), move |range, _window, cx| { - view.update(cx, |this, cx| { - range - .map(|i| { - let row = &rows[i]; - match row { - ResultRow::FileHeader { - relative_path, - match_count, - .. - } => this - .render_file_header(i, relative_path, *match_count, cx) - .into_any_element(), - ResultRow::Match { - file_path, - line_number, - line_content, - match_ranges, - .. - } => this.render_match_row( - i, file_path, *line_number, line_content, match_ranges, cx, - ), - } - }) - .collect() - }) - }) + uniform_list( + "content-search-list", + rows.len(), + move |range, _window, cx| { + view.update(cx, |this, cx| { + range + .map(|i| { + let row = &rows[i]; + match row { + ResultRow::FileHeader { + relative_path, + match_count, + .. + } => this + .render_file_header(i, relative_path, *match_count, cx) + .into_any_element(), + ResultRow::Match { + file_path, + line_number, + line_content, + match_ranges, + .. + } => this.render_match_row( + i, + file_path, + *line_number, + line_content, + match_ranges, + cx, + ), + } + }) + .collect() + }) + }, + ) .flex_1() .track_scroll(&self.scroll_handle) .into_any_element() @@ -247,10 +260,13 @@ impl Render for ContentSearchDialog { .id("cs-filter-popover-backdrop") .absolute() .inset_0() - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - this.filter_popover_open = false; - cx.notify(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.filter_popover_open = false; + cx.notify(); + }), + ), ) }) .when_some( @@ -260,7 +276,10 @@ impl Render for ContentSearchDialog { |d, bounds| { let entity = cx.entity().downgrade(); d.child(crate::list_overlay::file_filter_popover( - bounds, self.show_ignored, &t, cx, + bounds, + self.show_ignored, + &t, + cx, move |filter, _, cx| { if let Some(e) = entity.upgrade() { e.update(cx, |this, cx| { @@ -312,10 +331,13 @@ impl Render for ContentSearchDialog { .id("cs-filter-popover-backdrop-compact") .absolute() .inset_0() - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - this.filter_popover_open = false; - cx.notify(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.filter_popover_open = false; + cx.notify(); + }), + ), ) }) .when_some( @@ -323,22 +345,26 @@ impl Render for ContentSearchDialog { .then_some(self.filter_button_bounds) .flatten(), |modal, bounds| { - let entity = cx.entity().downgrade(); - modal.child(crate::list_overlay::file_filter_popover( - bounds, self.show_ignored, &t, cx, - move |filter, _, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - if filter == "ignored" { - this.show_ignored = !this.show_ignored; - } - this.trigger_search(cx); - cx.notify(); - }); - } - }, - )) - }), + let entity = cx.entity().downgrade(); + modal.child(crate::list_overlay::file_filter_popover( + bounds, + self.show_ignored, + &t, + cx, + move |filter, _, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + if filter == "ignored" { + this.show_ignored = !this.show_ignored; + } + this.trigger_search(cx); + cx.notify(); + }); + } + }, + )) + }, + ), ) .into_any_element() } diff --git a/crates/okena-files/src/content_search_dialog/rows.rs b/crates/okena-files/src/content_search_dialog/rows.rs index b5c1c2148..1a096d4d9 100644 --- a/crates/okena-files/src/content_search_dialog/rows.rs +++ b/crates/okena-files/src/content_search_dialog/rows.rs @@ -61,7 +61,11 @@ impl ContentSearchDialog { div() .text_size(ui_text_sm(cx)) .text_color(rgb(t.text_muted)) - .child(format!("{} match{}", match_count, if match_count == 1 { "" } else { "es" })), + .child(format!( + "{} match{}", + match_count, + if match_count == 1 { "" } else { "es" } + )), ), ) } @@ -76,32 +80,38 @@ impl ContentSearchDialog { t: &okena_core::theme::ThemeColors, cx: &App, ) -> Div { - let styled_text = if let Some(highlighted) = self.get_highlighted_line(file_path, line_number) { - if let Some(ranges) = match_ranges { + let styled_text = + if let Some(highlighted) = self.get_highlighted_line(file_path, line_number) { + if let Some(ranges) = match_ranges { + let match_bg = search_match_bg(t.search_match_bg); + let bg_ranges: Vec<(std::ops::Range, Hsla)> = ranges + .iter() + .filter(|r| r.end <= highlighted.plain_text.len()) + .map(|r| (r.clone(), match_bg)) + .collect(); + build_styled_text_with_backgrounds(&highlighted.spans, &bg_ranges) + } else { + build_styled_text_with_backgrounds(&highlighted.spans, &[]) + } + } else if let Some(ranges) = match_ranges { let match_bg = search_match_bg(t.search_match_bg); - let bg_ranges: Vec<(std::ops::Range, Hsla)> = ranges + let highlights: Vec<(std::ops::Range, HighlightStyle)> = ranges .iter() - .filter(|r| r.end <= highlighted.plain_text.len()) - .map(|r| (r.clone(), match_bg)) + .filter(|r| r.end <= line_content.len()) + .map(|r| { + ( + r.clone(), + HighlightStyle { + background_color: Some(match_bg), + ..Default::default() + }, + ) + }) .collect(); - build_styled_text_with_backgrounds(&highlighted.spans, &bg_ranges) + StyledText::new(line_content.to_string()).with_highlights(highlights) } else { - build_styled_text_with_backgrounds(&highlighted.spans, &[]) - } - } else if let Some(ranges) = match_ranges { - let match_bg = search_match_bg(t.search_match_bg); - let highlights: Vec<(std::ops::Range, HighlightStyle)> = ranges - .iter() - .filter(|r| r.end <= line_content.len()) - .map(|r| (r.clone(), HighlightStyle { - background_color: Some(match_bg), - ..Default::default() - })) - .collect(); - StyledText::new(line_content.to_string()).with_highlights(highlights) - } else { - StyledText::new(line_content.to_string()) - }; + StyledText::new(line_content.to_string()) + }; let is_context = match_ranges.is_none(); @@ -124,7 +134,11 @@ impl ContentSearchDialog { .text_ellipsis() .text_size(ui_text_ms(cx)) .font_family("monospace") - .text_color(rgb(if is_context { t.text_muted } else { t.text_primary })) + .text_color(rgb(if is_context { + t.text_muted + } else { + t.text_primary + })) .child(styled_text), ) } @@ -160,7 +174,14 @@ impl ContentSearchDialog { ) .gap(px(8.0)) .pl(px(28.0)) - .child(self.render_code_line(file_path, line_number, line_content, Some(match_ranges), &t, cx)) + .child(self.render_code_line( + file_path, + line_number, + line_content, + Some(match_ranges), + &t, + cx, + )) .into_any_element() } } diff --git a/crates/okena-files/src/content_search_dialog/search.rs b/crates/okena-files/src/content_search_dialog/search.rs index 316d708db..3c724ab10 100644 --- a/crates/okena-files/src/content_search_dialog/search.rs +++ b/crates/okena-files/src/content_search_dialog/search.rs @@ -10,6 +10,9 @@ use std::path::{Path, PathBuf}; impl ContentSearchDialog { /// Trigger a debounced search. pub(super) fn trigger_search(&mut self, cx: &mut Context) { + self.search_generation = self.search_generation.wrapping_add(1); + let search_generation = self.search_generation; + // Cancel any running search if let Some(handle) = self.search_handle.take() { handle.cancel(); @@ -20,22 +23,30 @@ impl ContentSearchDialog { self.rows.clear(); self.total_matches = 0; self.searching = false; + self.error_message = None; self.selected_index = 0; cx.notify(); return; } // Debounce: wait 200ms before starting search - self.debounce_task = Some(cx.spawn(async move |this: WeakEntity, cx| { - cx.background_executor().timer(std::time::Duration::from_millis(200)).await; - this.update(cx, |this, cx| { - this.run_search(cx); - }).ok(); - })); + self.debounce_task = Some(cx.spawn( + async move |this: WeakEntity, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(200)) + .await; + this.update(cx, |this, cx| { + if this.search_generation == search_generation { + this.run_search(search_generation, cx); + } + }) + .ok(); + }, + )); } /// Actually run the search on a background thread. - fn run_search(&mut self, cx: &mut Context) { + fn run_search(&mut self, search_generation: u64, cx: &mut Context) { let query = self.search_input.read(cx).value().to_string(); if query.is_empty() { return; @@ -44,6 +55,7 @@ impl ContentSearchDialog { let handle = SearchHandle::new(); self.search_handle = Some(handle.clone()); self.searching = true; + self.error_message = None; self.highlight_cache.clear(); cx.notify(); @@ -72,14 +84,9 @@ impl ContentSearchDialog { .background_executor() .spawn(async move { let mut results: Vec = Vec::new(); - project_fs.search_content( - &query, - &config, - &cancelled, - &mut |result| { - results.push(result); - }, - ); + project_fs.search_content(&query, &config, &cancelled, &mut |result| { + results.push(result); + })?; // Sort files by best match score (highest first) for fuzzy mode, // breaking ties by relative_path for stable order across runs // (parallel walker emits in non-deterministic order). @@ -88,21 +95,29 @@ impl ContentSearchDialog { .cmp(&a.best_score) .then_with(|| a.relative_path.cmp(&b.relative_path)) }); - results + Ok::<_, String>(results) }) .await; entity .update(cx, |this, cx| { - if this - .search_handle - .as_ref() - .is_some_and(|h| !h.is_cancelled()) - { - this.apply_results(results); + if owns_search_completion( + this.search_generation, + search_generation, + this.search_handle.as_ref(), + ) { this.searching = false; - // Preload highlighting for files in search results - this.preload_result_files(cx); + match results { + Ok(results) => { + this.apply_results(results); + this.preload_result_files(cx); + } + Err(error) => { + this.rows.clear(); + this.total_matches = 0; + this.error_message = Some(error); + } + } cx.notify(); } }) @@ -137,7 +152,11 @@ impl ContentSearchDialog { } } - self.selected_index = if self.rows.is_empty() { 0 } else { 1.min(self.rows.len() - 1) }; + self.selected_index = if self.rows.is_empty() { + 0 + } else { + 1.min(self.rows.len() - 1) + }; } /// Get syntax-highlighted line for a file. Returns None if not yet cached @@ -189,13 +208,8 @@ impl ContentSearchDialog { let _ = entity.update(cx, |this, cx| { this.loading_files.remove(&fp); if let Ok(content) = result { - let lines = highlight_content( - &content, - &fp, - &this.syntax_set, - 5000, - this.is_dark, - ); + let lines = + highlight_content(&content, &fp, &this.syntax_set, 5000, this.is_dark); this.highlight_cache.insert(fp, lines); } cx.notify(); @@ -204,3 +218,28 @@ impl ContentSearchDialog { .detach(); } } + +fn owns_search_completion( + current_generation: u64, + completion_generation: u64, + current_handle: Option<&SearchHandle>, +) -> bool { + current_generation == completion_generation + && current_handle.is_some_and(|handle| !handle.is_cancelled()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[::core::prelude::v1::test] + fn cancelled_older_completion_cannot_claim_newer_search_handle() { + let older = SearchHandle::new(); + older.cancel(); + let newer = SearchHandle::new(); + + assert!(!owns_search_completion(2, 1, Some(&newer))); + assert!(owns_search_completion(2, 2, Some(&newer))); + assert!(!owns_search_completion(1, 1, Some(&older))); + } +} diff --git a/crates/okena-files/src/content_search_dialog/sidebar.rs b/crates/okena-files/src/content_search_dialog/sidebar.rs index b776f5fe2..2aea46407 100644 --- a/crates/okena-files/src/content_search_dialog/sidebar.rs +++ b/crates/okena-files/src/content_search_dialog/sidebar.rs @@ -1,7 +1,10 @@ //! Sidebar file-tree rendering for the expanded content-search dialog. use super::{ContentSearchDialog, ResultRow}; -use crate::file_tree::{FileTreeNode, build_file_tree, expandable_file_row, expandable_folder_row}; +use crate::file_tree::{ + FileTreeRow, build_file_tree, expandable_file_row, expandable_folder_row, + indexed_file_tree_rows, +}; use crate::theme::theme; use gpui::prelude::FluentBuilder; use gpui::*; @@ -24,7 +27,11 @@ impl ContentSearchDialog { }) .collect(); let result_tree = build_file_tree(matched_files.into_iter()); - let tree_elements = self.render_tree_node(&result_tree, 0, "", &t, cx); + let tree_elements = self.render_tree_rows( + indexed_file_tree_rows(&result_tree, &self.expanded_folders, false), + &t, + cx, + ); div() .w(px(240.0)) @@ -79,102 +86,109 @@ impl ContentSearchDialog { ) } - /// Recursively render file tree nodes for the sidebar. - /// Matches FileViewer's tree style (chevrons, folder icons, sizing). - fn render_tree_node( + fn render_tree_rows( &self, - node: &FileTreeNode, - depth: usize, - parent_path: &str, + rows: Vec>, t: &okena_core::theme::ThemeColors, cx: &mut Context, ) -> Vec { let mut elements = Vec::new(); - for (name, child) in &node.children { - let folder_path = if parent_path.is_empty() { - name.clone() - } else { - format!("{parent_path}/{name}") - }; - let is_expanded = self.expanded_folders.contains(&folder_path); - let is_scoped = self.scope_path.as_ref() == Some(&folder_path); - let fp_toggle = folder_path.clone(); - let fp_scope = folder_path.clone(); + for row in rows { + match row { + FileTreeRow::Folder { + path, + name, + depth, + is_expanded, + } => { + let is_scoped = self.scope_path.as_ref() == Some(&path); + let path_for_toggle = path.clone(); + let path_for_scope = path.clone(); - elements.push( - expandable_folder_row(name, depth, is_expanded, t, cx) - .id(ElementId::Name(format!("cs-folder-{}", folder_path).into())) - .when(is_scoped, |d| d.bg(rgb(t.bg_selection))) - .on_click(cx.listener(move |this, _, _window, cx| { - this.toggle_folder(&fp_toggle, cx); - })) - // Scope button - .child( - div() - .id(ElementId::Name(format!("scope-folder-{}", folder_path).into())) - .cursor_pointer() - .px(px(4.0)) - .py(px(2.0)) - .rounded(px(3.0)) - .text_size(ui_text_sm(cx)) - .text_color(rgb(if is_scoped { t.text_primary } else { t.text_muted })) - .when(is_scoped, |d| d.bg(rgb(t.border_active))) - .hover(|s| s.bg(rgb(t.bg_hover)).text_color(rgb(t.text_primary))) - .flex_shrink_0() - .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _window, cx| { - if this.scope_path.as_ref() == Some(&fp_scope) { + elements.push( + expandable_folder_row(&name, depth, is_expanded, t, cx) + .id(ElementId::Name(format!("cs-folder-{path}").into())) + .when(is_scoped, |d| d.bg(rgb(t.bg_selection))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_folder(&path_for_toggle, cx); + })) + .child( + div() + .id(ElementId::Name(format!("scope-folder-{path}").into())) + .cursor_pointer() + .px(px(4.0)) + .py(px(2.0)) + .rounded(px(3.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(if is_scoped { + t.text_primary + } else { + t.text_muted + })) + .when(is_scoped, |d| d.bg(rgb(t.border_active))) + .hover(|s| { + s.bg(rgb(t.bg_hover)).text_color(rgb(t.text_primary)) + }) + .flex_shrink_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _window, cx| { + if this.scope_path.as_ref() == Some(&path_for_scope) { + this.set_scope(None, cx); + } else { + this.set_scope(Some(path_for_scope.clone()), cx); + } + }), + ) + .child(if is_scoped { "scoped" } else { "scope" }), + ) + .into_any_element(), + ); + } + FileTreeRow::File { + item: row_index, + depth, + } => { + let Some(ResultRow::FileHeader { + relative_path, + match_count, + .. + }) = self.rows.get(row_index) + else { + continue; + }; + let filename = std::path::Path::new(relative_path.as_str()) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| relative_path.clone()); + let relative_path = relative_path.clone(); + let is_scoped = self.scope_path.as_ref() == Some(&relative_path); + let count = *match_count; + + elements.push( + expandable_file_row(&filename, depth, None, false, t, cx) + .id(ElementId::Name(format!("cs-file-{row_index}").into())) + .when(is_scoped, |d| d.bg(rgb(t.bg_selection))) + .on_click(cx.listener(move |this, _, _window, cx| { + if this.scope_path.as_ref() == Some(&relative_path) { this.set_scope(None, cx); } else { - this.set_scope(Some(fp_scope.clone()), cx); + this.set_scope(Some(relative_path.clone()), cx); } })) - .child(if is_scoped { "scoped" } else { "scope" }), - ) - .into_any_element(), - ); - - if is_expanded { - elements.extend(self.render_tree_node(child, depth + 1, &folder_path, t, cx)); - } - } - - for &row_index in &node.files { - if let Some(ResultRow::FileHeader { - relative_path, - match_count, - .. - }) = self.rows.get(row_index) - { - let filename = std::path::Path::new(relative_path.as_str()) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| relative_path.clone()); - let rel = relative_path.clone(); - let is_scoped = self.scope_path.as_ref() == Some(&rel); - let count = *match_count; - - elements.push( - expandable_file_row(&filename, depth, None, false, t, cx) - .id(ElementId::Name(format!("cs-file-{}", row_index).into())) - .when(is_scoped, |d| d.bg(rgb(t.bg_selection))) - .on_click(cx.listener(move |this, _, _window, cx| { - if this.scope_path.as_ref() == Some(&rel) { - this.set_scope(None, cx); - } else { - this.set_scope(Some(rel.clone()), cx); - } - })) - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .flex_shrink_0() - .ml(px(4.0)) - .child(count.to_string()), - ) - .into_any_element(), - ); + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .flex_shrink_0() + .ml(px(4.0)) + .child(count.to_string()), + ) + .into_any_element(), + ); + } + FileTreeRow::Loading { .. } => {} } } diff --git a/crates/okena-files/src/content_search_dialog/toggles.rs b/crates/okena-files/src/content_search_dialog/toggles.rs index f848de19e..a97b31f34 100644 --- a/crates/okena-files/src/content_search_dialog/toggles.rs +++ b/crates/okena-files/src/content_search_dialog/toggles.rs @@ -22,8 +22,20 @@ impl ContentSearchDialog { .py(px(6.0)) .border_b_1() .border_color(rgb(t.border)) - .child(self.render_toggle_button("Aa", self.case_sensitive, "Case Sensitive", "case", cx)) - .child(self.render_toggle_button(".*", self.regex_mode, "Regular Expression", "regex", cx)) + .child(self.render_toggle_button( + "Aa", + self.case_sensitive, + "Case Sensitive", + "case", + cx, + )) + .child(self.render_toggle_button( + ".*", + self.regex_mode, + "Regular Expression", + "regex", + cx, + )) .child(self.render_toggle_button("~", self.fuzzy_mode, "Fuzzy Match", "fuzzy", cx)) .child(self.render_file_filter_button(cx)) // Glob filter input @@ -35,8 +47,16 @@ impl ContentSearchDialog { .py(px(3.0)) .rounded(px(4.0)) .text_size(ui_text_sm(cx)) - .bg(rgb(if has_glob { t.border_active } else { t.bg_secondary })) - .text_color(rgb(if has_glob { t.text_primary } else { t.text_muted })) + .bg(rgb(if has_glob { + t.border_active + } else { + t.bg_secondary + })) + .text_color(rgb(if has_glob { + t.text_primary + } else { + t.text_muted + })) .child(if has_glob { format!("filter: {}", glob_value) } else { @@ -47,39 +67,50 @@ impl ContentSearchDialog { cx.listener(|this, _, window, cx| { this.glob_editing = !this.glob_editing; if this.glob_editing { - this.glob_input.update(cx, |input, cx| input.focus(window, cx)); + this.glob_input + .update(cx, |input, cx| input.focus(window, cx)); } else { - this.search_input.update(cx, |input, cx| input.focus(window, cx)); + this.search_input + .update(cx, |input, cx| input.focus(window, cx)); } cx.notify(); }), ), ) .child( - div() - .flex_1() - .flex() - .justify_end() - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child(if self.searching { - "Searching...".to_string() - } else if self.total_matches > 0 { - format!( - "{} match{} in {} file{}", - self.total_matches, - if self.total_matches == 1 { "" } else { "es" }, - self.rows.iter().filter(|r| matches!(r, ResultRow::FileHeader { .. })).count(), - if self.rows.iter().filter(|r| matches!(r, ResultRow::FileHeader { .. })).count() == 1 { "" } else { "s" }, - ) - } else if !self.search_input.read(cx).value().is_empty() { - "No results".to_string() - } else { - String::new() - }), - ), + div().flex_1().flex().justify_end().child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(if self.searching { + "Searching...".to_string() + } else if self.total_matches > 0 { + format!( + "{} match{} in {} file{}", + self.total_matches, + if self.total_matches == 1 { "" } else { "es" }, + self.rows + .iter() + .filter(|r| matches!(r, ResultRow::FileHeader { .. })) + .count(), + if self + .rows + .iter() + .filter(|r| matches!(r, ResultRow::FileHeader { .. })) + .count() + == 1 + { + "" + } else { + "s" + }, + ) + } else if !self.search_input.read(cx).value().is_empty() { + "No results".to_string() + } else { + String::new() + }), + ), ) } @@ -106,12 +137,10 @@ impl ContentSearchDialog { .font_weight(FontWeight::MEDIUM) .tooltip(move |_window, cx| Tooltip::new(tooltip_text.clone()).build(_window, cx)) .when(active, |d: Stateful
| { - d.bg(rgb(t.border_active)) - .text_color(rgb(t.text_primary)) + d.bg(rgb(t.border_active)).text_color(rgb(t.text_primary)) }) .when(!active, |d: Stateful
| { - d.bg(rgb(t.bg_secondary)) - .text_color(rgb(t.text_muted)) + d.bg(rgb(t.bg_secondary)).text_color(rgb(t.text_muted)) }) .hover(|s: StyleRefinement| s.bg(rgb(t.bg_hover))) .on_mouse_down( @@ -121,11 +150,15 @@ impl ContentSearchDialog { "case" => this.case_sensitive = !this.case_sensitive, "regex" => { this.regex_mode = !this.regex_mode; - if this.regex_mode { this.fuzzy_mode = false; } + if this.regex_mode { + this.fuzzy_mode = false; + } } "fuzzy" => { this.fuzzy_mode = !this.fuzzy_mode; - if this.fuzzy_mode { this.regex_mode = false; } + if this.fuzzy_mode { + this.regex_mode = false; + } } _ => {} } @@ -144,7 +177,10 @@ impl ContentSearchDialog { let entity2 = entity.clone(); crate::list_overlay::file_filter_button( - "cs-filter-btn", active_count, &t, cx, + "cs-filter-btn", + active_count, + &t, + cx, move |_, _, cx| { if let Some(e) = entity.upgrade() { e.update(cx, |this, cx| { 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..a4357c283 100644 --- a/crates/okena-files/src/file_search.rs +++ b/crates/okena-files/src/file_search.rs @@ -3,50 +3,35 @@ //! 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; 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 okena_ui::tokens::{ui_text, ui_text_ms, ui_text_sm}; +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]); /// Binary/non-openable file extensions that get pushed to the bottom of results. const BINARY_EXTENSIONS: &[&str] = &[ - "png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", "webp", - "mp3", "mp4", "wav", "avi", "mov", - "zip", "tar", "gz", "rar", "7z", - "pdf", "woff", "woff2", "ttf", "eot", "exe", "bin", + "png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", "webp", "mp3", "mp4", "wav", "avi", "mov", + "zip", "tar", "gz", "rar", "7z", "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 @@ -76,6 +61,7 @@ pub struct FileSearchDialog { filter_popover_open: bool, filter_button_bounds: Option>, loading: bool, + error_message: Option, } impl FileSearchDialog { @@ -110,8 +96,7 @@ impl FileSearchDialog { // Create search input entity let search_input = cx.new(|cx| { - let mut input = SimpleInputState::new(cx) - .placeholder("Type to search files..."); + let mut input = SimpleInputState::new(cx).placeholder("Type to search files..."); if !query.is_empty() { input.set_value(&query, cx); input.select_all(cx); @@ -120,25 +105,33 @@ impl FileSearchDialog { }); // Subscribe to input changes for filtering - cx.subscribe(&search_input, |this: &mut Self, _, _: &InputChangedEvent, cx| { - this.filter_files(cx); - cx.notify(); - }) + cx.subscribe( + &search_input, + |this: &mut Self, _, _: &InputChangedEvent, cx| { + this.filter_files(cx); + cx.notify(); + }, + ) .detach(); // Load files asynchronously to avoid blocking the UI thread (important for remote projects) let fs_for_scan = fs.clone(); cx.spawn(async move |entity: WeakEntity, cx| { - let files = cx + let result = cx .background_executor() .spawn(async move { fs_for_scan.list_files(show_ignored) }) .await; let _ = entity.update(cx, |this, cx| { - this.files = files; this.loading = false; - this.filter_files(cx); - if restored_index < this.filtered_files.len() { - this.selected_index = restored_index; + match result { + Ok(files) => { + this.files = files; + this.filter_files(cx); + if restored_index < this.filtered_files.len() { + this.selected_index = restored_index; + } + } + Err(error) => this.error_message = Some(error), } cx.notify(); }); @@ -159,83 +152,17 @@ impl FileSearchDialog { filter_popover_open: false, filter_button_bounds: None, loading: true, + error_message: None, } } /// 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. @@ -266,11 +193,12 @@ impl FileSearchDialog { let fs = self.fs.clone(); let show_ignored = self.show_ignored; self.loading = true; + self.error_message = None; self.files.clear(); self.filtered_files.clear(); self.selected_index = 0; cx.spawn(async move |entity: WeakEntity, cx| { - let files = cx + let result = cx .background_executor() .spawn(async move { fs.list_files(show_ignored) }) .await; @@ -279,9 +207,14 @@ impl FileSearchDialog { if this.show_ignored != show_ignored { return; } - this.files = files; this.loading = false; - this.filter_files(cx); + match result { + Ok(files) => { + this.files = files; + this.filter_files(cx); + } + Err(error) => this.error_message = Some(error), + } cx.notify(); }); }) @@ -299,7 +232,9 @@ impl FileSearchDialog { if let Some(&(file_index, _)) = self.filtered_files.get(self.selected_index) { let file = &self.files[file_index]; self.save_memory(cx); - cx.emit(FileSearchDialogEvent::FileSelected(file.relative_path.clone())); + cx.emit(FileSearchDialogEvent::FileSelected( + file.relative_path.clone(), + )); } } @@ -308,7 +243,11 @@ impl FileSearchDialog { } fn select_next(&mut self) -> bool { - crate::list_overlay::select_next(&mut self.selected_index, self.filtered_files.len(), &self.scroll_handle) + crate::list_overlay::select_next( + &mut self.selected_index, + self.filtered_files.len(), + &self.scroll_handle, + ) } /// Filter files based on the search query using fuzzy matching with scoring. @@ -318,7 +257,8 @@ impl FileSearchDialog { if query.is_empty() { self.filtered_files = (0..self.files.len()).map(|i| (i, vec![])).collect(); } else { - let mut scored: Vec<(usize, i32, Vec)> = self.files + let mut scored: Vec<(usize, i32, Vec)> = self + .files .iter() .enumerate() .filter_map(|(i, file)| { @@ -336,7 +276,12 @@ impl FileSearchDialog { } /// Fuzzy match with scoring using nucleo-matcher. Returns (score, matched_byte_positions) or None. - fn fuzzy_score(text: &str, query: &str, filename: &str, relative_path: &str) -> Option<(i32, Vec)> { + fn fuzzy_score( + text: &str, + query: &str, + filename: &str, + relative_path: &str, + ) -> Option<(i32, Vec)> { if query.is_empty() { return Some((0, vec![])); } @@ -389,9 +334,10 @@ impl FileSearchDialog { if let Some(ext) = std::path::Path::new(relative_path) .extension() .and_then(|e| e.to_str()) - && BINARY_EXTENSIONS.contains(&ext.to_lowercase().as_str()) { - score -= 1000; - } + && BINARY_EXTENSIONS.contains(&ext.to_lowercase().as_str()) + { + score -= 1000; + } Some((score, positions)) } @@ -465,7 +411,8 @@ impl FileSearchDialog { .map(|&p| p - filename_start) .collect(); - let filename_element = Self::styled_text_with_highlights(filename, &filename_positions, t.border_active); + let filename_element = + Self::styled_text_with_highlights(filename, &filename_positions, t.border_active); let dir_element = if dir_path.is_empty() { StyledText::new("\u{00A0}".to_string()) } else { @@ -473,45 +420,45 @@ impl FileSearchDialog { }; selectable_list_item( - ElementId::Name(format!("file-{}", filtered_index).into()), - is_selected, - &t, - ) - .w_full() - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, _window, cx| { - this.selected_index = filtered_index; - this.open_selected(cx); - }), - ) - .gap(px(8.0)) - .child(file_icon(filename, &t, cx)) - .child( - div() - .flex_1() - .flex() - .flex_col() - .gap(px(2.0)) - .overflow_hidden() - .child( - div() - .text_size(ui_text(13.0, cx)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(t.text_primary)) - .overflow_hidden() - .text_ellipsis() - .child(filename_element), - ) - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .overflow_hidden() - .text_ellipsis() - .child(dir_element), - ), - ) + ElementId::Name(format!("file-{}", filtered_index).into()), + is_selected, + &t, + ) + .w_full() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _window, cx| { + this.selected_index = filtered_index; + this.open_selected(cx); + }), + ) + .gap(px(8.0)) + .child(file_icon(filename, &t, cx)) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap(px(2.0)) + .overflow_hidden() + .child( + div() + .text_size(ui_text(13.0, cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_primary)) + .overflow_hidden() + .text_ellipsis() + .child(filename_element), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .overflow_hidden() + .text_ellipsis() + .child(dir_element), + ), + ) } fn render_filter_bar(&self, cx: &mut Context) -> impl IntoElement + use<> { @@ -529,24 +476,25 @@ impl FileSearchDialog { .py(px(6.0)) .border_b_1() .border_color(rgb(t.border)) - .child( - crate::list_overlay::file_filter_button( - "filter-btn", active_count, &t, cx, - move |_, _, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - this.filter_popover_open = !this.filter_popover_open; - cx.notify(); - }); - } - }, - move |bounds, _, cx| { - if let Some(e) = entity2.upgrade() { - e.update(cx, |this, _| this.filter_button_bounds = Some(bounds)); - } - }, - ) - ) + .child(crate::list_overlay::file_filter_button( + "filter-btn", + active_count, + &t, + cx, + move |_, _, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + this.filter_popover_open = !this.filter_popover_open; + cx.notify(); + }); + } + }, + move |bounds, _, cx| { + if let Some(e) = entity2.upgrade() { + e.update(cx, |this, _| this.filter_button_bounds = Some(bounds)); + } + }, + )) } } @@ -561,15 +509,15 @@ pub enum FileSearchDialogEvent { FileSelected(String), /// User toggled the gitignore filter. The caller persists this to /// settings so the new state becomes the default for future opens. - FiltersChanged { - show_ignored: bool, - }, + FiltersChanged { show_ignored: bool }, } impl EventEmitter for FileSearchDialog {} impl okena_ui::overlay::CloseEvent for FileSearchDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl Render for FileSearchDialog { @@ -581,7 +529,8 @@ impl Render for FileSearchDialog { // Focus search input on first render let input_focus = self.search_input.read(cx).focus_handle(cx); if !input_focus.is_focused(window) { - self.search_input.update(cx, |input, cx| input.focus(window, cx)); + self.search_input + .update(cx, |input, cx| input.focus(window, cx)); } modal_backdrop("file-search-backdrop", &t) @@ -594,14 +543,12 @@ impl Render for FileSearchDialog { })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { match event.keystroke.key.as_str() { - "up" - if this.select_prev() => { - cx.notify(); - } - "down" - if this.select_next() => { - cx.notify(); - } + "up" if this.select_prev() => { + cx.notify(); + } + "down" if this.select_next() => { + cx.notify(); + } "enter" => this.open_selected(cx), "escape" => this.close(cx), _ => {} @@ -625,18 +572,31 @@ impl Render for FileSearchDialog { cx, cx.listener(|this, _, _window, cx| this.close(cx)), )) - .child(crate::list_overlay::search_input_row(&self.search_input, &t, cx)) + .child(crate::list_overlay::search_input_row( + &self.search_input, + &t, + cx, + )) .child(self.render_filter_bar(cx)) .child(if self.loading { div() .flex_1() .child(empty_state("Loading files…", &t, cx)) .into_any_element() + } else if let Some(error) = &self.error_message { + div() + .flex_1() + .child(empty_state(error, &t, cx)) + .into_any_element() } else if self.filtered_files.is_empty() { div() .flex_1() .child(empty_state( - if self.files.is_empty() { "No files found in project" } else { "No matching files" }, + if self.files.is_empty() { + "No files found in project" + } else { + "No matching files" + }, &t, cx, )) @@ -644,20 +604,16 @@ impl Render for FileSearchDialog { } else { let filtered = self.filtered_files.clone(); let view = cx.entity().clone(); - uniform_list( - "file-list", - filtered.len(), - move |range, _window, cx| { - view.update(cx, |this, cx| { - range - .map(|i| { - let (file_index, positions) = &filtered[i]; - this.render_file_row(i, *file_index, positions, cx) - }) - .collect() - }) - }, - ) + uniform_list("file-list", filtered.len(), move |range, _window, cx| { + view.update(cx, |this, cx| { + range + .map(|i| { + let (file_index, positions) = &filtered[i]; + this.render_file_row(i, *file_index, positions, cx) + }) + .collect() + }) + }) .flex_1() .track_scroll(&self.scroll_handle) .into_any_element() @@ -692,10 +648,13 @@ impl Render for FileSearchDialog { .id("filter-popover-backdrop") .absolute() .inset_0() - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - this.filter_popover_open = false; - cx.notify(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.filter_popover_open = false; + cx.notify(); + }), + ), ) }) .when_some( @@ -705,7 +664,10 @@ impl Render for FileSearchDialog { |modal, bounds| { let entity = cx.entity().downgrade(); modal.child(crate::list_overlay::file_filter_popover( - bounds, self.show_ignored, &t, cx, + bounds, + self.show_ignored, + &t, + cx, move |filter, _, cx| { if let Some(e) = entity.upgrade() { e.update(cx, |this, cx| this.toggle_filter(filter, cx)); diff --git a/crates/okena-files/src/file_tree.rs b/crates/okena-files/src/file_tree.rs index 7d23997d2..f9d89d535 100644 --- a/crates/okena-files/src/file_tree.rs +++ b/crates/okena-files/src/file_tree.rs @@ -3,13 +3,13 @@ //! Used by both the diff viewer and file viewer for sidebar navigation, //! and by the git status popover. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; +use gpui::prelude::*; +use gpui::*; use okena_core::theme::ThemeColors; use okena_ui::file_icon::file_icon; use okena_ui::tokens::ui_text; -use gpui::prelude::*; -use gpui::*; /// A node in the file tree. #[derive(Default, Clone)] @@ -20,6 +20,30 @@ pub struct FileTreeNode { pub children: BTreeMap, } +/// A flattened row in the order shown by a file tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FileTreeRow { + Folder { + path: String, + name: String, + depth: usize, + is_expanded: bool, + }, + File { + item: T, + depth: usize, + }, + Loading { + depth: usize, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileTreeNavigationDirection { + Previous, + Next, +} + /// Build a file tree from an iterator of (index, relative_path) pairs. pub fn build_file_tree(paths: impl Iterator)>) -> FileTreeNode { let mut root = FileTreeNode::default(); @@ -37,6 +61,113 @@ pub fn build_file_tree(paths: impl Iterator)>) -> root } +/// Flatten an indexed tree in the same order used by the tree renderer. +pub fn indexed_file_tree_rows( + node: &FileTreeNode, + expanded_folders: &HashSet, + include_collapsed: bool, +) -> Vec> { + fn collect( + node: &FileTreeNode, + parent_path: &str, + depth: usize, + expanded_folders: &HashSet, + include_collapsed: bool, + rows: &mut Vec>, + ) { + for (name, child) in &node.children { + let path = if parent_path.is_empty() { + name.clone() + } else { + format!("{parent_path}/{name}") + }; + let is_expanded = expanded_folders.contains(&path); + rows.push(FileTreeRow::Folder { + path: path.clone(), + name: name.clone(), + depth, + is_expanded, + }); + if include_collapsed || is_expanded { + collect( + child, + &path, + depth + 1, + expanded_folders, + include_collapsed, + rows, + ); + } + } + + rows.extend( + node.files + .iter() + .copied() + .map(|item| FileTreeRow::File { item, depth }), + ); + } + + let mut rows = Vec::new(); + collect(node, "", 0, expanded_folders, include_collapsed, &mut rows); + rows +} + +/// Find the adjacent visible file, falling back from a selection hidden by collapse. +pub fn adjacent_file_tree_item( + visible_rows: &[FileTreeRow], + all_rows: &[FileTreeRow], + selected: Option<&T>, + direction: FileTreeNavigationDirection, +) -> Option { + let visible: Vec<&T> = visible_rows + .iter() + .filter_map(|row| match row { + FileTreeRow::File { item, .. } => Some(item), + FileTreeRow::Folder { .. } | FileTreeRow::Loading { .. } => None, + }) + .collect(); + + let Some(selected) = selected else { + return match direction { + FileTreeNavigationDirection::Previous => visible.last().map(|item| (**item).clone()), + FileTreeNavigationDirection::Next => visible.first().map(|item| (**item).clone()), + }; + }; + + if let Some(position) = visible.iter().position(|item| *item == selected) { + return match direction { + FileTreeNavigationDirection::Previous => position + .checked_sub(1) + .map(|position| (*visible[position]).clone()), + FileTreeNavigationDirection::Next => { + visible.get(position + 1).map(|item| (**item).clone()) + } + }; + } + + let all: Vec<&T> = all_rows + .iter() + .filter_map(|row| match row { + FileTreeRow::File { item, .. } => Some(item), + FileTreeRow::Folder { .. } | FileTreeRow::Loading { .. } => None, + }) + .collect(); + let position = all.iter().position(|item| *item == selected)?; + + match direction { + FileTreeNavigationDirection::Previous => all[..position] + .iter() + .rev() + .find(|item| visible.iter().any(|visible_item| *visible_item == **item)) + .map(|item| (**item).clone()), + FileTreeNavigationDirection::Next => all[position + 1..] + .iter() + .find(|item| visible.iter().any(|visible_item| *visible_item == **item)) + .map(|item| (**item).clone()), + } +} + /// Base div for an expandable folder row: chevron + folder icon + name. /// /// Caller chains `.id(...)`, `.on_click(...)`, `.when(...)` for selection, @@ -60,7 +191,11 @@ pub fn expandable_folder_row( // Chevron .child( svg() - .path(if is_expanded { "icons/chevron-down.svg" } else { "icons/chevron-right.svg" }) + .path(if is_expanded { + "icons/chevron-down.svg" + } else { + "icons/chevron-right.svg" + }) .size(px(14.0)) .text_color(rgb(t.text_muted)) .mr(px(4.0)) @@ -142,7 +277,11 @@ pub fn expandable_file_row( #[cfg(test)] mod tests { - use super::build_file_tree; + use super::{ + FileTreeNavigationDirection, FileTreeRow, adjacent_file_tree_item, build_file_tree, + indexed_file_tree_rows, + }; + use std::collections::HashSet; #[test] fn test_build_file_tree_empty() { @@ -171,10 +310,12 @@ mod tests { #[test] fn test_build_file_tree_multi_level() { - let files = ["src/views/mod.rs", + let files = [ + "src/views/mod.rs", "src/views/render.rs", "src/main.rs", - "Cargo.toml"]; + "Cargo.toml", + ]; let tree = build_file_tree(files.iter().enumerate().map(|(i, f)| (i, *f))); assert_eq!(tree.files, vec![3]); // Cargo.toml let src = &tree.children["src"]; @@ -182,4 +323,88 @@ mod tests { let views = &src.children["views"]; assert_eq!(views.files, vec![0, 1]); // mod.rs, render.rs } + + fn navigation_tree() -> super::FileTreeNode { + let paths = ["README.md", "src/lib.rs", "src/views/mod.rs", "src/main.rs"]; + build_file_tree(paths.iter().enumerate()) + } + + fn expanded() -> HashSet { + HashSet::from(["src".to_string(), "src/views".to_string()]) + } + + fn file_items(rows: &[FileTreeRow]) -> Vec { + rows.iter() + .filter_map(|row| match row { + FileTreeRow::File { item, .. } => Some(*item), + FileTreeRow::Folder { .. } | FileTreeRow::Loading { .. } => None, + }) + .collect() + } + + #[test] + fn indexed_rows_match_rendered_tree_order() { + let rows = indexed_file_tree_rows(&navigation_tree(), &expanded(), false); + + assert_eq!(file_items(&rows), vec![2, 1, 3, 0]); + } + + #[test] + fn adjacent_item_follows_visible_tree_order() { + let tree = navigation_tree(); + let visible = indexed_file_tree_rows(&tree, &expanded(), false); + let all = indexed_file_tree_rows(&tree, &expanded(), true); + + assert_eq!( + adjacent_file_tree_item(&visible, &all, Some(&2), FileTreeNavigationDirection::Next), + Some(1) + ); + assert_eq!( + adjacent_file_tree_item( + &visible, + &all, + Some(&0), + FileTreeNavigationDirection::Previous + ), + Some(3) + ); + assert_eq!( + adjacent_file_tree_item(&visible, &all, Some(&0), FileTreeNavigationDirection::Next), + None + ); + } + + #[test] + fn adjacent_item_skips_collapsed_folder_contents() { + let tree = navigation_tree(); + let expanded = HashSet::from(["src".to_string()]); + let visible = indexed_file_tree_rows(&tree, &expanded, false); + let all = indexed_file_tree_rows(&tree, &expanded, true); + + assert_eq!(file_items(&visible), vec![1, 3, 0]); + assert_eq!( + adjacent_file_tree_item(&visible, &all, Some(&2), FileTreeNavigationDirection::Next), + Some(1) + ); + } + + #[test] + fn adjacent_item_starts_at_visible_edge_without_selection() { + let tree = navigation_tree(); + let visible = indexed_file_tree_rows(&tree, &expanded(), false); + + assert_eq!( + adjacent_file_tree_item(&visible, &visible, None, FileTreeNavigationDirection::Next), + Some(2) + ); + assert_eq!( + adjacent_file_tree_item( + &visible, + &visible, + None, + FileTreeNavigationDirection::Previous + ), + Some(0) + ); + } } diff --git a/crates/okena-files/src/file_viewer/blame_load.rs b/crates/okena-files/src/file_viewer/blame_load.rs index d285b48c9..d74edddaa 100644 --- a/crates/okena-files/src/file_viewer/blame_load.rs +++ b/crates/okena-files/src/file_viewer/blame_load.rs @@ -47,7 +47,10 @@ impl FileViewer { if tab.relative_path.is_empty() { return; } - if matches!(tab.blame, BlameLoadState::Loading | BlameLoadState::Loaded(_)) { + if matches!( + tab.blame, + BlameLoadState::Loading | BlameLoadState::Loaded(_) + ) { return; } tab.blame = BlameLoadState::Loading; diff --git a/crates/okena-files/src/file_viewer/blame_render.rs b/crates/okena-files/src/file_viewer/blame_render.rs index ebc1ab432..08cba6a10 100644 --- a/crates/okena-files/src/file_viewer/blame_render.rs +++ b/crates/okena-files/src/file_viewer/blame_render.rs @@ -131,7 +131,8 @@ fn render_committed_cell( let author_color = mix_alpha(t.text_secondary, alpha); let date_color = mix_alpha(t.text_muted, alpha); - let element_id: ElementId = ElementId::Name(format!("blame-{}-{}", full_hash, entry.line_number).into()); + let element_id: ElementId = + ElementId::Name(format!("blame-{}-{}", full_hash, entry.line_number).into()); div() .id(element_id) @@ -165,16 +166,8 @@ fn render_committed_cell( .font_family("monospace") .child(hash), ) - .child( - div() - .text_color(author_color) - .child(author), - ) - .child( - div() - .text_color(date_color) - .child(date), - ) + .child(div().text_color(author_color).child(author)) + .child(div().text_color(date_color).child(date)) .into_any_element() } diff --git a/crates/okena-files/src/file_viewer/context_menu.rs b/crates/okena-files/src/file_viewer/context_menu.rs index 044a61d62..bdeb089af 100644 --- a/crates/okena-files/src/file_viewer/context_menu.rs +++ b/crates/okena-files/src/file_viewer/context_menu.rs @@ -8,7 +8,7 @@ use okena_ui::button::button; use okena_ui::icon_button::icon_button_sized; use okena_ui::menu::{context_menu_panel, menu_item, menu_item_with_color, menu_separator}; use okena_ui::modal::{modal_backdrop, modal_content}; -use okena_ui::rename_state::{start_rename_with_blur, RenameState}; +use okena_ui::rename_state::{RenameState, start_rename_with_blur}; use okena_ui::simple_input::SimpleInput; use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_xl}; use std::path::{Path, PathBuf}; @@ -40,8 +40,13 @@ fn parent_relative_of_target( /// What kind of tree node was right-clicked. pub(crate) enum TreeNodeTarget { - File { path: PathBuf }, - Folder { folder_path: String, abs_path: PathBuf }, + File { + path: PathBuf, + }, + Folder { + folder_path: String, + abs_path: PathBuf, + }, } impl TreeNodeTarget { @@ -185,21 +190,35 @@ 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 let Err(error) = self.project_fs.rename_file(&old_rel, &new_name) { + self.tree_error_message = Some(format!("Failed to rename: {error}")); cx.notify(); return; } + self.tree_error_message = None; - 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); @@ -262,9 +281,10 @@ impl FileViewer { if tab.file_path == *old_path { tab.file_path = new_path.to_path_buf(); } else if tab.file_path.starts_with(old_path) - && let Ok(relative) = tab.file_path.strip_prefix(old_path) { - tab.file_path = new_path.join(relative); - } + && let Ok(relative) = tab.file_path.strip_prefix(old_path) + { + tab.file_path = new_path.join(relative); + } if let (Some(old_rel), Some(new_rel)) = (old_rel, new_rel) { if tab.relative_path == old_rel { @@ -272,11 +292,8 @@ impl FileViewer { } else { let prefix = format!("{}/", old_rel); if tab.relative_path.starts_with(&prefix) { - tab.relative_path = format!( - "{}/{}", - new_rel, - &tab.relative_path[prefix.len()..], - ); + tab.relative_path = + format!("{}/{}", new_rel, &tab.relative_path[prefix.len()..],); } } } @@ -284,9 +301,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 +349,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 +373,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,7 +434,17 @@ impl FileViewer { .to_string_lossy() .to_string(); - let send_path = if is_folder { None } else { Some(abs_path_buf.clone()) }; + let daemon_path = self.project_fs.absolute_path(&rel_path).map(PathBuf::from); + let abs_to_copy = daemon_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| abs_path.clone()); + + let send_path = if is_folder { + None + } else { + Some(daemon_path.unwrap_or(abs_path_buf)) + }; Some( div() @@ -429,10 +467,11 @@ impl FileViewer { anchored().position(position).snap_to_window().child( context_menu_panel("fv-tree-context-menu", t) .child( - menu_item("fv-ctx-rename", "icons/edit.svg", "Rename", t) - .on_click(cx.listener(|this, _, window, cx| { + menu_item("fv-ctx-rename", "icons/edit.svg", "Rename", t).on_click( + cx.listener(|this, _, window, cx| { this.start_rename(window, cx); - })), + }), + ), ) .when_some(send_path, |d, path| { d.child( @@ -442,12 +481,16 @@ impl FileViewer { "Send to Terminal", t, ) - .on_click(cx.listener(move |this, _, _, cx| { - this.close_context_menu(cx); - cx.emit(super::FileViewerEvent::SendToTerminal( - okena_core::send_payload::SendPayload::Path(path.clone()), - )); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + this.close_context_menu(cx); + cx.emit(super::FileViewerEvent::SendToTerminal( + okena_core::send_payload::SendPayload::Path( + path.clone(), + ), + )); + }, + )), ) }) .child( @@ -457,12 +500,14 @@ impl FileViewer { "Copy Relative Path", t, ) - .on_click(cx.listener(move |this, _, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - rel_path.clone(), - )); - this.close_context_menu(cx); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + rel_path.clone(), + )); + this.close_context_menu(cx); + }, + )), ) .child( menu_item( @@ -471,30 +516,30 @@ impl FileViewer { "Copy Absolute Path", t, ) - .on_click(cx.listener(move |this, _, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - abs_path.clone(), - )); - this.close_context_menu(cx); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + abs_to_copy.clone(), + )); + this.close_context_menu(cx); + }, + )), ) .child(menu_separator(t)) .child( menu_item_with_color( "fv-ctx-delete", "icons/trash.svg", - if is_folder { - "Delete Folder" - } else { - "Delete" - }, + if is_folder { "Delete Folder" } else { "Delete" }, t.error, t.error, t, ) - .on_click(cx.listener(|this, _, _, cx| { - this.start_delete(cx); - })), + .on_click(cx.listener( + |this, _, _, cx| { + this.start_delete(cx); + }, + )), ), ), )) @@ -511,11 +556,16 @@ impl FileViewer { let position = menu.position; let tab_index = menu.tab_index; - let send_path: Option = self - .tabs - .get(tab_index) - .filter(|t| !t.is_empty()) - .map(|tab| tab.file_path.clone()); + let send_path: Option = + self.tabs + .get(tab_index) + .filter(|t| !t.is_empty()) + .map(|tab| { + self.project_fs + .absolute_path(&tab.relative_path) + .map(PathBuf::from) + .unwrap_or_else(|| tab.file_path.clone()) + }); Some( div() @@ -547,13 +597,17 @@ impl FileViewer { "Send to Terminal", t, ) - .on_click(cx.listener(move |this, _, _, cx| { - this.tab_context_menu = None; - cx.emit(super::FileViewerEvent::SendToTerminal( - okena_core::send_payload::SendPayload::Path(path.clone()), - )); - cx.notify(); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + this.tab_context_menu = None; + cx.emit(super::FileViewerEvent::SendToTerminal( + okena_core::send_payload::SendPayload::Path( + path.clone(), + ), + )); + cx.notify(); + }, + )), ) .child(menu_separator(t)) }) @@ -571,10 +625,12 @@ impl FileViewer { "Close Others", t, ) - .on_click(cx.listener(move |this, _, _, cx| { - this.tab_context_menu = None; - this.close_other_tabs(tab_index, cx); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + this.tab_context_menu = None; + this.close_other_tabs(tab_index, cx); + }, + )), ) .child( menu_item( @@ -583,10 +639,12 @@ impl FileViewer { "Close All", t, ) - .on_click(cx.listener(|this, _, _, cx| { - this.tab_context_menu = None; - this.close_all_tabs(cx); - })), + .on_click(cx.listener( + |this, _, _, cx| { + this.tab_context_menu = None; + this.close_all_tabs(cx); + }, + )), ), ), )) diff --git a/crates/okena-files/src/file_viewer/loading.rs b/crates/okena-files/src/file_viewer/loading.rs index 32a71d370..7789cec86 100644 --- a/crates/okena-files/src/file_viewer/loading.rs +++ b/crates/okena-files/src/file_viewer/loading.rs @@ -1,15 +1,14 @@ //! File loading and syntax highlighting for the file viewer. use super::{ - font_format_for_path, image_format_for_path, DecodedImage, FileViewerTab, FontData, - FontFormat, MAX_FILE_SIZE, MAX_IMAGE_FILE_SIZE, MAX_LINES, + DecodedImage, FileViewerTab, FontData, FontFormat, MAX_LINES, font_format_for_path, + image_format_for_path, }; -use crate::syntax::{highlight_content, HighlightedLine}; +use crate::syntax::highlight_content; use gpui::{Image, ImageFormat, SvgRenderer}; use okena_markdown::MarkdownDocument; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; -use std::time::SystemTime; use syntect::parsing::SyntaxSet; /// Max font file size (in source-on-disk bytes). Fonts are typically much @@ -17,9 +16,7 @@ use syntect::parsing::SyntaxSet; /// file and stops us hammering ttf-parser with multi-GB inputs. pub(super) const MAX_FONT_FILE_SIZE: u64 = 20 * 1024 * 1024; -/// Content produced by the async loader. Text, image, and font tabs share -/// the same plumbing (apply_loaded_content / apply_freshness_reload) but -/// carry different payloads. +/// Content produced by the daemon-backed async loader. pub(super) enum LoadedContent { Text(String), Image { @@ -36,174 +33,6 @@ pub(super) enum LoadedContent { }, } -/// Result of a background freshness check. Carries enough info that the UI -/// thread can apply field assignments without doing any blocking I/O. -pub(super) enum FreshnessOutcome { - /// File unchanged (or couldn't be stat'd) — nothing to do. - Unchanged, - /// File changed and was successfully re-read. - Reloaded(FreshnessReload), - /// File changed but re-read/decode failed. `new_mtime` is the file's - /// current mtime so apply can pin it and avoid re-reading the bad file - /// on every throttle tick. - Failed { - message: String, - new_mtime: Option, - }, -} - -/// All the heavy work (stat, read, syntax highlighting, markdown parse) has -/// already happened on the background executor; applying this back on the -/// UI thread is just a set of field assignments. -pub(super) struct FreshnessReload { - pub kind: FreshnessKind, - pub modified_at: Option, -} - -pub(super) enum FreshnessKind { - Text { - content: String, - highlighted_lines: Vec, - markdown_doc: Option, - }, - Image { - decoded: DecodedImage, - /// For SVG, the raw XML plus its pre-highlighted lines so the user - /// keeps their source view in sync across mtime reloads. `None` - /// for raster, or for SVG whose bytes failed UTF-8 decode. - source: Option<(String, Vec)>, - }, - Font { - data: Arc, - ttf_bytes: Arc>, - }, -} - -/// Stat `path` and, if its mtime differs from `old_mtime`, read and -/// re-highlight it. Returns `Ok(None)` when the file is unchanged (or can't be -/// stat'd), `Ok(Some(..))` with the recomputed content when it changed, and -/// `Err` when the file changed but could not be read. -/// -/// Pure / blocking — meant to run on the background executor, so it captures no -/// GPUI handles and touches no entity state. -pub(super) fn compute_freshness_reload( - path: &PathBuf, - old_mtime: Option, - is_markdown: bool, - syntax_set: &SyntaxSet, - is_dark: bool, - svg_renderer: &SvgRenderer, -) -> FreshnessOutcome { - let Some(old_mtime) = old_mtime else { - return FreshnessOutcome::Unchanged; - }; - let Ok(metadata) = std::fs::metadata(path) else { - return FreshnessOutcome::Unchanged; - }; - let Ok(new_mtime) = metadata.modified() else { - return FreshnessOutcome::Unchanged; - }; - if new_mtime == old_mtime { - return FreshnessOutcome::Unchanged; - } - // Inline helper so every Err branch carries new_mtime — the UI thread - // pins this so the throttle loop doesn't re-read the bad file every - // second. - let failed = |message: String| FreshnessOutcome::Failed { - message, - new_mtime: Some(new_mtime), - }; - if font_format_for_path(path).is_some() { - if metadata.len() > MAX_FONT_FILE_SIZE { - return failed(format!( - "Font too large ({:.1} MB). Maximum size is 20 MB.", - metadata.len() as f64 / 1024.0 / 1024.0 - )); - } - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) => return failed(format!("Cannot read file: {}", e)), - }; - let (data, ttf_bytes) = match build_font_content(path, bytes) { - Ok(LoadedContent::Font { data, ttf_bytes }) => (data, ttf_bytes), - Ok(_) => return failed("Internal error decoding font".to_string()), - Err(e) => return failed(e), - }; - return FreshnessOutcome::Reloaded(FreshnessReload { - kind: FreshnessKind::Font { data, ttf_bytes }, - modified_at: Some(new_mtime), - }); - } - if image_format_for_path(path).is_some() { - if metadata.len() > MAX_IMAGE_FILE_SIZE { - return failed(format!( - "Image too large ({:.1} MB). Maximum size is 20 MB.", - metadata.len() as f64 / 1024.0 / 1024.0 - )); - } - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) => return failed(format!("Cannot read file: {}", e)), - }; - // Defense in depth: TOCTOU could grow the file between stat and - // read. The build below has its own cost, so reject before paying it. - if bytes.len() as u64 > MAX_IMAGE_FILE_SIZE { - return failed(format!( - "Image too large ({:.1} MB). Maximum size is 20 MB.", - bytes.len() as f64 / 1024.0 / 1024.0 - )); - } - let (decoded, source) = match build_image_content(path, bytes, svg_renderer) { - Ok(LoadedContent::Image { decoded, source }) => (decoded, source), - Ok(_) => return failed("Internal error decoding image".to_string()), - Err(e) => return failed(e), - }; - let source = source.map(|content| { - let highlighted = - highlight_content(&content, path, syntax_set, MAX_LINES, is_dark); - (content, highlighted) - }); - return FreshnessOutcome::Reloaded(FreshnessReload { - kind: FreshnessKind::Image { decoded, source }, - modified_at: Some(new_mtime), - }); - } - if metadata.len() > MAX_FILE_SIZE { - return failed(format!( - "File too large ({:.1} MB). Maximum size is 5 MB.", - metadata.len() as f64 / 1024.0 / 1024.0 - )); - } - let content = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - // Distinguish binary files from other read errors, matching load_file. - let message = if let Ok(bytes) = std::fs::read(path) - && bytes.iter().take(1024).any(|&b| b == 0) - { - "Cannot display binary file".to_string() - } else { - format!("Cannot read file: {}", e) - }; - return failed(message); - } - }; - let highlighted_lines = highlight_content(&content, path, syntax_set, MAX_LINES, is_dark); - let markdown_doc = if is_markdown { - Some(MarkdownDocument::parse(&content)) - } else { - None - }; - FreshnessOutcome::Reloaded(FreshnessReload { - kind: FreshnessKind::Text { - content, - highlighted_lines, - markdown_doc, - }, - modified_at: Some(new_mtime), - }) -} - /// Map the `image` crate's content-sniffed format onto GPUI's `ImageFormat`. /// `image::ImageFormat` covers more formats than GPUI knows; we return /// `None` for anything GPUI can't render so the caller can fall back to @@ -273,7 +102,9 @@ pub(super) fn build_image_content( if pixels == 0 || pixels > MAX_SVG_PIXELS { return Err(format!( "SVG dimensions out of range ({}×{}). Max {} megapixels.", - w, h, MAX_SVG_PIXELS / 1024 / 1024 + w, + h, + MAX_SVG_PIXELS / 1024 / 1024 )); } let initial_scale: f32 = 1.0; @@ -341,44 +172,47 @@ pub(super) fn build_image_content( /// GPUI's text-system registration. Only raw OpenType (TTF/OTF) is decoded; /// WOFF/WOFF2 are rejected with a user-visible error (decompressing them /// would require a dependency we deliberately don't pull in). -pub(super) fn build_font_content( - path: &Path, - bytes: Vec, -) -> Result { +pub(super) fn build_font_content(path: &Path, bytes: Vec) -> Result { let format = font_format_for_path(path).ok_or_else(|| { format!( "Unsupported font extension: {}", - path.extension().and_then(|e| e.to_str()).unwrap_or("(none)") + path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("(none)") ) })?; let ttf_bytes: Vec = match format { FontFormat::OpenType => bytes, FontFormat::Woff => { return Err( - "WOFF/WOFF2 preview is not supported yet — only OTF and TTF are." - .to_string(), + "WOFF/WOFF2 preview is not supported yet — only OTF and TTF are.".to_string(), ); } }; - let face = ttf_parser::Face::parse(&ttf_bytes, 0) - .map_err(|e| format!("Cannot parse font: {}", e))?; + let face = + ttf_parser::Face::parse(&ttf_bytes, 0).map_err(|e| format!("Cannot parse font: {}", e))?; let read_name = |name_id: u16| -> Option { face.names() .into_iter() .find(|n| n.name_id == name_id && n.to_string().is_some()) .and_then(|n| n.to_string()) }; - let family_name = read_name(ttf_parser::name_id::FAMILY) - .unwrap_or_else(|| { - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Unknown") - .to_string() - }); - let full_name = read_name(ttf_parser::name_id::FULL_NAME) - .unwrap_or_else(|| family_name.clone()); - let style = read_name(ttf_parser::name_id::SUBFAMILY) - .unwrap_or_else(|| if face.is_italic() { "Italic" } else { "Regular" }.to_string()); + let family_name = read_name(ttf_parser::name_id::FAMILY).unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + let full_name = + read_name(ttf_parser::name_id::FULL_NAME).unwrap_or_else(|| family_name.clone()); + let style = read_name(ttf_parser::name_id::SUBFAMILY).unwrap_or_else(|| { + if face.is_italic() { + "Italic" + } else { + "Regular" + } + .to_string() + }); let version = read_name(ttf_parser::name_id::VERSION).unwrap_or_default(); let data = Arc::new(FontData { family_name, @@ -407,159 +241,16 @@ impl FileViewerTab { }) .unwrap_or(false) } - - /// Load file content and apply syntax highlighting. - pub(super) fn load_file( - &mut self, - path: &PathBuf, - syntax_set: &SyntaxSet, - is_dark: bool, - svg_renderer: &SvgRenderer, - ) { - // Check file size first - let metadata = match std::fs::metadata(path) { - Ok(m) => m, - Err(e) => { - self.error_message = Some(format!("Cannot read file: {}", e)); - return; - } - }; - - if self.is_image { - if metadata.len() > MAX_IMAGE_FILE_SIZE { - self.error_message = Some(format!( - "Image too large ({:.1} MB). Maximum size is 20 MB.", - metadata.len() as f64 / 1024.0 / 1024.0 - )); - self.image_data = None; - return; - } - match std::fs::read(path) { - Ok(bytes) => match build_image_content(path, bytes, svg_renderer) { - Ok(LoadedContent::Image { decoded, source }) => { - self.image_data = Some(decoded); - if let Some(content) = source { - self.content = content; - self.do_highlight_content(path, syntax_set, is_dark); - } else { - self.content.clear(); - self.highlighted_lines.clear(); - self.line_count = 0; - self.line_num_width = 3; - } - // Only commit the new mtime after a successful - // decode. If decode fails we leave modified_at at - // its previous value so a subsequent retry (e.g. - // file fixed in place without mtime change) still - // re-runs through reload_if_changed. - self.modified_at = metadata.modified().ok(); - } - Ok(_) => { - // Unreachable: build_image_content only returns Image. - self.error_message = Some("Internal error decoding image".to_string()); - self.image_data = None; - } - Err(e) => { - self.error_message = Some(e); - self.image_data = None; - } - }, - Err(e) => { - self.error_message = Some(format!("Cannot read file: {}", e)); - self.image_data = None; - } - } - return; - } - - if self.is_font { - if metadata.len() > MAX_FONT_FILE_SIZE { - self.error_message = Some(format!( - "Font too large ({:.1} MB). Maximum size is 20 MB.", - metadata.len() as f64 / 1024.0 / 1024.0 - )); - self.font_data = None; - return; - } - match std::fs::read(path) { - Ok(bytes) => match build_font_content(path, bytes) { - Ok(LoadedContent::Font { data, .. }) => { - self.font_data = Some(data); - self.image_data = None; - self.content.clear(); - self.highlighted_lines.clear(); - self.line_count = 0; - self.line_num_width = 3; - // Note: bytes are not re-registered with the text - // system here. Sync reload via reload_if_changed - // updates the displayed metadata; the sample text - // keeps rendering with the originally-registered - // font (same family name resolves), which is - // acceptable until the user reopens the tab. - self.modified_at = metadata.modified().ok(); - } - Ok(_) => { - self.error_message = Some("Internal error decoding font".to_string()); - self.font_data = None; - } - Err(e) => { - self.error_message = Some(e); - self.font_data = None; - } - }, - Err(e) => { - self.error_message = Some(format!("Cannot read file: {}", e)); - self.font_data = None; - } - } - return; - } - - if metadata.len() > MAX_FILE_SIZE { - self.error_message = Some(format!( - "File too large ({:.1} MB). Maximum size is 5 MB.", - metadata.len() as f64 / 1024.0 / 1024.0 - )); - return; - } - self.modified_at = metadata.modified().ok(); - - // Read file content - match std::fs::read_to_string(path) { - Ok(content) => { - self.content = content; - self.do_highlight_content(path, syntax_set, is_dark); - // Parse markdown if this is a markdown file - if self.is_markdown { - self.markdown_doc = Some(MarkdownDocument::parse(&self.content)); - } - } - Err(e) => { - // Try reading as binary and check if it's a binary file - match std::fs::read(path) { - Ok(bytes) => { - if bytes.iter().take(1024).any(|&b| b == 0) { - self.error_message = Some("Cannot display binary file".to_string()); - } else { - self.error_message = Some(format!("Cannot read file: {}", e)); - } - } - Err(_) => { - self.error_message = Some(format!("Cannot read file: {}", e)); - } - } - } - } - } - /// Apply content that was loaded asynchronously in the background. pub(super) fn apply_loaded_content( &mut self, result: Result, + modified_at: Option, syntax_set: &SyntaxSet, is_dark: bool, ) { self.loading = false; + self.modified_at = modified_at; match result { Ok(LoadedContent::Text(content)) => { self.content = content; @@ -567,7 +258,6 @@ impl FileViewerTab { if self.is_markdown { self.markdown_doc = Some(MarkdownDocument::parse(&self.content)); } - self.modified_at = self.local_mtime(); } Ok(LoadedContent::Image { decoded, source }) => { self.image_data = Some(decoded); @@ -584,7 +274,6 @@ impl FileViewerTab { self.line_count = 0; self.line_num_width = 3; } - self.modified_at = self.local_mtime(); } Ok(LoadedContent::Font { data, .. }) => { self.font_data = Some(data); @@ -595,7 +284,6 @@ impl FileViewerTab { self.highlighted_lines.clear(); self.line_count = 0; self.line_num_width = 3; - self.modified_at = self.local_mtime(); } Err(e) => { self.error_message = Some(e); @@ -603,107 +291,6 @@ impl FileViewerTab { } } - /// Read mtime for the active file, but only when `file_path` looks like - /// an absolute on-disk path (local project). For remote projects the - /// loader synthesises a relative path placeholder; stat'ing that on the - /// UI host either fails or — worse — coincidentally matches an - /// unrelated local file. Returning None there keeps the freshness loop - /// dormant rather than poisoned. - fn local_mtime(&self) -> Option { - if !self.file_path.is_absolute() { - return None; - } - std::fs::metadata(&self.file_path) - .ok() - .and_then(|m| m.modified().ok()) - } - - /// Apply the result of a background freshness check computed by - /// `compute_freshness_reload`. All heavy work (stat/read/highlight) already - /// happened off-thread; this is just field assignment on the UI thread. - pub(super) fn apply_freshness_reload(&mut self, outcome: FreshnessOutcome) { - match outcome { - FreshnessOutcome::Reloaded(reload) => { - self.error_message = None; - match reload.kind { - FreshnessKind::Text { content, highlighted_lines, markdown_doc } => { - self.content = content; - self.line_count = highlighted_lines.len(); - self.line_num_width = self.line_count.to_string().len().max(3); - self.highlighted_lines = highlighted_lines; - self.markdown_doc = markdown_doc; - // A text reload replaces a previous image preview; - // drop the old bitmap so we don't keep it around. - self.image_data = None; - } - FreshnessKind::Image { decoded, source } => { - self.image_data = Some(decoded); - self.font_data = None; - // Always overwrite the source-view fields so a - // source=None reload doesn't leave stale XML from - // the previous revision visible in Source mode. - if let Some((content, highlighted)) = source { - self.content = content; - self.line_count = highlighted.len(); - self.line_num_width = self.line_count.to_string().len().max(3); - self.highlighted_lines = highlighted; - } else { - self.content.clear(); - self.highlighted_lines.clear(); - self.line_count = 0; - self.line_num_width = 3; - } - } - FreshnessKind::Font { data, .. } => { - self.font_data = Some(data); - self.image_data = None; - self.content.clear(); - self.highlighted_lines.clear(); - self.line_count = 0; - self.line_num_width = 3; - } - } - self.modified_at = reload.modified_at; - } - FreshnessOutcome::Unchanged => {} - FreshnessOutcome::Failed { message, new_mtime } => { - self.error_message = Some(message); - // Pin the mtime so we don't re-attempt the same expensive - // read on every throttle tick. The next change to the file - // bumps the mtime again and re-triggers a real reload. - if let Some(mtime) = new_mtime { - self.modified_at = Some(mtime); - } - } - } - } - - /// Check if the file was modified externally and reload if so. - /// Returns true if the file was reloaded. - pub(super) fn reload_if_changed( - &mut self, - syntax_set: &SyntaxSet, - is_dark: bool, - svg_renderer: &SvgRenderer, - ) -> bool { - let Some(old_mtime) = self.modified_at else { - return false; - }; - let Ok(metadata) = std::fs::metadata(&self.file_path) else { - return false; - }; - let Ok(new_mtime) = metadata.modified() else { - return false; - }; - if new_mtime == old_mtime { - return false; - } - let path = self.file_path.clone(); - self.error_message = None; - self.load_file(&path, syntax_set, is_dark, svg_renderer); - true - } - /// Apply syntax highlighting to the content using shared utilities. pub(super) fn do_highlight_content( &mut self, diff --git a/crates/okena-files/src/file_viewer/mod.rs b/crates/okena-files/src/file_viewer/mod.rs index 973b329e8..f25b3bb86 100644 --- a/crates/okena-files/src/file_viewer/mod.rs +++ b/crates/okena-files/src/file_viewer/mod.rs @@ -10,19 +10,20 @@ mod loading; mod render; mod search; mod selection; +mod tree; use crate::blame::{BlameError, BlameLine, BlameProvider}; use crate::code_view::ScrollbarDrag; use crate::list_directory::DirEntry; use crate::selection::SelectionState; -use crate::syntax::{load_syntax_set, HighlightedLine}; +use crate::syntax::{HighlightedLine, load_syntax_set}; use context_menu::{DeleteConfirmState, FileRenameState, FileTreeContextMenu, TabContextMenu}; use gpui::*; use okena_markdown::{MarkdownDocument, MarkdownSelection}; +use okena_ui::resizable_sidebar::ResizableSidebarState; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::SystemTime; use syntect::parsing::SyntaxSet; /// Maximum file size to load for text/markdown (5MB) @@ -41,6 +42,35 @@ const MAX_TABS: usize = 50; /// Maximum navigation history stack size const MAX_HISTORY: usize = 50; +fn is_missing_directory_error(error: &str) -> bool { + error.contains(crate::list_directory::DIRECTORY_NOT_FOUND_ERROR) + // Keep recovery working while a client is connected to an older daemon. + || error.contains("(os error 2)") + || error.contains("(os error 3)") +} + +fn is_path_or_descendant(path: &str, ancestor: &str) -> bool { + path == ancestor + || path + .strip_prefix(ancestor) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn prune_missing_directory( + relative_path: &str, + loaded_dirs: &mut HashMap>, + loading_dirs: &mut HashSet, + expanded_folders: &mut HashSet, +) -> String { + loaded_dirs.retain(|path, _| !is_path_or_descendant(path, relative_path)); + loading_dirs.retain(|path| !is_path_or_descendant(path, relative_path)); + expanded_folders.retain(|path| !is_path_or_descendant(path, relative_path)); + + relative_path + .rsplit_once('/') + .map_or_else(String::new, |(parent, _)| parent.to_string()) +} + /// Display mode for file viewer. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub(super) enum DisplayMode { @@ -140,15 +170,12 @@ impl ImageViewState { /// Type alias for source view selection (line, column). type Selection = SelectionState<(usize, usize)>; -/// Width of file tree sidebar. -const SIDEBAR_WIDTH: f32 = 240.0; - /// Per-file state for a single tab in the file viewer. /// /// `relative_path` is the canonical identifier (project-relative, used for /// `fs.read_file`, tab equality, history, and tree highlighting). `file_path` -/// is the derived absolute path used for filesystem-level context-menu -/// operations (rename/delete) — it's only meaningful for local projects. +/// is a client-only identity rooted at the project id; daemon paths come from +/// `ProjectFs::absolute_path`. pub(super) struct FileViewerTab { pub file_path: PathBuf, pub relative_path: String, @@ -172,8 +199,8 @@ pub(super) struct FileViewerTab { pub markdown_list_font: f32, pub source_scroll_handle: UniformListScrollHandle, pub scrollbar_drag: Option, - /// Last known modification time of the file (for detecting external changes). - pub modified_at: Option, + /// Last daemon-reported modification time in Unix milliseconds. + pub modified_at: Option, /// Whether the tab content is still being loaded asynchronously. pub loading: bool, /// Per-line git blame for this file. Lazy-loaded when the user toggles @@ -514,12 +541,15 @@ pub struct FileViewer { pub(super) loaded_dirs: HashMap>, /// Folder paths whose listing is currently in flight. pub(super) loading_dirs: HashSet, + pub(super) tree_error_message: Option, /// Which folder paths are currently expanded expanded_folders: HashSet, /// Scroll handle for the file tree sidebar tree_scroll_handle: ScrollHandle, /// Whether the sidebar is visible sidebar_visible: bool, + /// Width and active resize gesture for the file tree sidebar. + pub(super) sidebar_resize: ResizableSidebarState, /// Open tabs pub(super) tabs: Vec, /// Index of the active tab @@ -566,17 +596,12 @@ pub struct FileViewer { } impl FileViewer { - /// Resolve a project-relative path to an absolute `PathBuf` for filesystem - /// ops. For remote projects the absolute path doesn't exist locally; - /// callers fall back to the relative path wrapped as a `PathBuf`. - fn resolve_absolute( + /// Build a stable client-side identity path without touching local disk. + fn tree_path( fs: &std::sync::Arc, relative_path: &str, ) -> PathBuf { - match fs.project_root() { - Some(root) => root.join(relative_path), - None => PathBuf::from(relative_path), - } + PathBuf::from(fs.project_id()).join(relative_path) } /// Create a new file viewer with `relative_path` (project-relative) opened @@ -594,7 +619,7 @@ impl FileViewer { let expanded_folders = Self::compute_expanded_for_relative(&relative_path); let syntax_set = load_syntax_set(); - let file_path = Self::resolve_absolute(&project_fs, &relative_path); + let file_path = Self::tree_path(&project_fs, &relative_path); let tab = FileViewerTab::new_loading(relative_path.clone(), file_path.clone()); let mut viewer = Self { @@ -607,9 +632,11 @@ impl FileViewer { loading: true, loaded_dirs: HashMap::new(), loading_dirs: HashSet::new(), + tree_error_message: None, expanded_folders, tree_scroll_handle: ScrollHandle::new(), sidebar_visible: true, + sidebar_resize: ResizableSidebarState::default(), tabs: vec![tab], active_tab: 0, history: NavigationHistory::new(), @@ -663,9 +690,11 @@ impl FileViewer { loading: true, loaded_dirs: HashMap::new(), loading_dirs: HashSet::new(), + tree_error_message: None, expanded_folders: HashSet::new(), tree_scroll_handle: ScrollHandle::new(), sidebar_visible: true, + sidebar_resize: ResizableSidebarState::default(), tabs: vec![FileViewerTab::new_empty()], active_tab: 0, history: NavigationHistory::new(), @@ -709,7 +738,7 @@ impl FileViewer { } /// Update configuration (font size and dark mode) from the host app. - /// Also refreshes the file tree and all tabs that were modified externally. + /// Also refreshes the daemon-backed file tree. pub fn update_config(&mut self, font_size: f32, is_dark: bool, cx: &mut Context) { let rehighlight = is_dark != self.is_dark; self.file_font_size = font_size; @@ -718,33 +747,17 @@ impl FileViewer { // Re-fetch directory listings so the sidebar reflects added/removed files self.refresh_file_tree_async(cx); - let svg_renderer = cx.svg_renderer(); for tab in &mut self.tabs { if tab.is_empty() { continue; } - // Reload externally modified files (also re-highlights) - if tab.reload_if_changed(&self.syntax_set, self.is_dark, &svg_renderer) { - tab.blame = BlameLoadState::NotLoaded; - continue; - } // Theme changed — re-highlight without reloading. Raster image // and font tabs have no highlighted content; SVG tabs do (the // source-view XML), so they need the rehighlight too. if rehighlight && !tab.is_font && (!tab.is_image || tab.is_svg) { - tab.do_highlight_content( - &tab.file_path.clone(), - &self.syntax_set, - self.is_dark, - ); + tab.do_highlight_content(&tab.file_path.clone(), &self.syntax_set, self.is_dark); } } - - // Kick off blame load for the active tab if the gutter is visible and - // its blame got invalidated by the reload above. - if self.blame_visible { - self.spawn_blame_load_for_active(cx); - } } /// Invalidate the cached directory listings and re-fetch the ones that are @@ -756,6 +769,7 @@ impl FileViewer { .collect(); self.loaded_dirs.clear(); self.loading_dirs.clear(); + self.tree_error_message = None; for path in to_refetch { self.fetch_directory(path, cx); } @@ -822,9 +836,30 @@ impl FileViewer { match result { Ok(entries) => { this.loaded_dirs.insert(relative_path.clone(), entries); + if relative_path.is_empty() { + this.tree_error_message = None; + } + } + Err(error) + if !relative_path.is_empty() && is_missing_directory_error(&error) => + { + let parent = prune_missing_directory( + &relative_path, + &mut this.loaded_dirs, + &mut this.loading_dirs, + &mut this.expanded_folders, + ); + let parent_is_current = parent.is_empty() + || this.loaded_dirs.contains_key(&parent) + || this.loading_dirs.contains(&parent) + || this.expanded_folders.contains(&parent); + if parent_is_current { + this.loaded_dirs.remove(&parent); + this.fetch_directory(parent, cx); + } } - Err(e) => { - log::warn!("list_directory({}) failed: {}", relative_path, e); + Err(error) => { + this.tree_error_message = Some(error); // Cache an empty vec so we don't retry on every render. this.loaded_dirs.insert(relative_path.clone(), Vec::new()); } @@ -838,13 +873,7 @@ impl FileViewer { .detach(); } - /// Check if the active tab's file was modified externally and reload it if - /// so. Throttled to at most once per second. The actual stat + (on change) - /// read + re-highlight runs on the background executor so it never blocks - /// the render/UI thread; results are swapped in via `entity.update`. - /// - /// Called cheaply from `render`: the render thread only checks the throttle - /// and (at most once/sec) schedules a background task — no filesystem I/O. + /// Poll daemon metadata and reload the active tab when it changes. pub(super) fn check_active_tab_freshness(&mut self, cx: &mut Context) { if self.freshness_check_in_flight || self.last_change_check.elapsed() < std::time::Duration::from_secs(1) @@ -858,81 +887,27 @@ impl FileViewer { return; } - // Capture only the plain data the background work needs — no entity or - // tab borrows held across the await. let relative_path = tab.relative_path.clone(); - let path = tab.file_path.clone(); let old_mtime = tab.modified_at; - let is_markdown = tab.is_markdown; - let syntax_set = self.syntax_set.clone(); - let is_dark = self.is_dark; - let svg_renderer = cx.svg_renderer(); + let fs = self.project_fs.clone(); + let path_for_request = relative_path.clone(); self.freshness_check_in_flight = true; cx.spawn(async move |entity: WeakEntity, cx| { let result = cx .background_executor() - .spawn(async move { - loading::compute_freshness_reload( - &path, - old_mtime, - is_markdown, - &syntax_set, - is_dark, - &svg_renderer, - ) - }) + .spawn(async move { fs.file_metadata(&path_for_request) }) .await; let _ = entity.update(cx, |this, cx| { this.freshness_check_in_flight = false; - let reloaded = matches!(result, loading::FreshnessOutcome::Reloaded(_)); - // Register fresh font bytes with the platform text system - // before apply, mirroring spawn_tab_load. add_fonts is - // idempotent so a no-change font reload is a cheap no-op. - if let loading::FreshnessOutcome::Reloaded(reload) = &result - && let loading::FreshnessKind::Font { ttf_bytes, .. } = &reload.kind - { - register_font_bytes(cx, ttf_bytes); - } - // Re-find the tab by relative_path; concurrent reorders/closes - // mean the index may have shifted since we scheduled the check. - // Capture the previously-installed image so we can evict its - // sprite-atlas tile / asset-cache entry AFTER apply assigns - // the new one — apply runs on `&mut FileViewerTab` and can't - // see `cx: &mut App`. - let mut old_image: Option = None; - if let Some(tab) = - this.tabs.iter_mut().find(|t| t.relative_path == relative_path) - { - if reloaded { - old_image = tab.image_data.take(); - } - tab.apply_freshness_reload(result); - if reloaded { - tab.blame = BlameLoadState::NotLoaded; - } - } - if let Some(decoded) = old_image { - release_image_assets(decoded, cx); - } - if reloaded { - if this.blame_visible { - this.spawn_blame_load_for_active(cx); + match result { + Ok(metadata) if metadata.modified_at_millis != old_mtime => { + this.spawn_tab_load(relative_path, cx); } - // The freshness reload of an SVG always rebuilds the - // bitmap at rendered_scale=1.0, but image_view.zoom - // persists across the reload. Without this kick the - // user's previously-tuned high-zoom view stays - // pixelated until they touch the wheel. - if let Some(tab) = this - .tabs - .iter() - .find(|t| t.relative_path == relative_path) - && tab.is_svg - { - this.maybe_rerender_svg_for(relative_path.clone(), cx); + Ok(_) => {} + Err(error) => { + log::warn!("File freshness check failed: {error}"); } - cx.notify(); } }); }) @@ -958,11 +933,7 @@ impl FileViewer { /// callback's chase-loop recursion, where the user may have switched /// tabs mid-raster and `self.active_tab()` would target the wrong /// path. - pub(super) fn maybe_rerender_svg_for( - &mut self, - relative_path: String, - cx: &mut Context, - ) { + pub(super) fn maybe_rerender_svg_for(&mut self, relative_path: String, cx: &mut Context) { let svg_renderer = cx.svg_renderer(); let Some((bytes, target_scale)) = self.compute_rerender_target(&relative_path) else { return; @@ -1001,12 +972,15 @@ impl FileViewer { { tab.image_view.svg_rerender_in_flight = false; match (result, tab.image_data.as_mut()) { - (Ok(new_image), Some(DecodedImage::Rendered { - image, - rendered_scale, - svg_bytes, - .. - })) => { + ( + Ok(new_image), + Some(DecodedImage::Rendered { + image, + rendered_scale, + svg_bytes, + .. + }), + ) => { // Discard the result if image_data was // replaced (different SVG bytes) while we // were rasterizing — otherwise the stale @@ -1030,11 +1004,14 @@ impl FileViewer { cx.drop_image(new_image, None); } } - (Err(_), Some(DecodedImage::Rendered { - rendered_scale, - svg_bytes, - .. - })) => { + ( + Err(_), + Some(DecodedImage::Rendered { + rendered_scale, + svg_bytes, + .. + }), + ) => { // Pin rendered_scale to the failed target so // compute_rerender_target stops requesting // the same raster on every chase tick. The @@ -1078,11 +1055,11 @@ impl FileViewer { /// would meaningfully sharpen the preview. Returns `None` when the tab /// isn't an SVG, isn't zoomed in past its rendered scale, or already /// has a re-raster in flight. - fn compute_rerender_target( - &self, - relative_path: &str, - ) -> Option<(Arc>, f32)> { - let tab = self.tabs.iter().find(|t| t.relative_path == relative_path)?; + fn compute_rerender_target(&self, relative_path: &str) -> Option<(Arc>, f32)> { + let tab = self + .tabs + .iter() + .find(|t| t.relative_path == relative_path)?; if !tab.is_svg || tab.image_view.svg_rerender_in_flight { return None; } @@ -1147,7 +1124,11 @@ impl FileViewer { /// - Otherwise creates a new tab after the active one. pub fn open_file_in_tab(&mut self, relative_path: String, cx: &mut Context) { // Already open? Switch to it. - if let Some(idx) = self.tabs.iter().position(|t| t.relative_path == relative_path) { + if let Some(idx) = self + .tabs + .iter() + .position(|t| t.relative_path == relative_path) + { if idx != self.active_tab { let current = self.active_tab().relative_path.clone(); self.history.push(¤t); @@ -1160,7 +1141,7 @@ impl FileViewer { self.expand_ancestors_and_fetch(&relative_path, cx); - let file_path = Self::resolve_absolute(&self.project_fs, &relative_path); + let file_path = Self::tree_path(&self.project_fs, &relative_path); let new_tab = FileViewerTab::new_loading(relative_path.clone(), file_path); // If current tab is empty (no file loaded), replace it @@ -1342,7 +1323,11 @@ impl FileViewer { /// Navigate to a file without pushing history (used by back/forward). fn navigate_to_file_no_history(&mut self, relative_path: String, cx: &mut Context) { // If file is open in a tab, switch to it - if let Some(idx) = self.tabs.iter().position(|t| t.relative_path == relative_path) { + if let Some(idx) = self + .tabs + .iter() + .position(|t| t.relative_path == relative_path) + { self.active_tab = idx; cx.notify(); return; @@ -1350,7 +1335,7 @@ impl FileViewer { self.expand_ancestors_and_fetch(&relative_path, cx); - let file_path = Self::resolve_absolute(&self.project_fs, &relative_path); + let file_path = Self::tree_path(&self.project_fs, &relative_path); let new_tab = FileViewerTab::new_loading(relative_path.clone(), file_path); let old_image = self.tabs[self.active_tab].image_data.take(); self.tabs[self.active_tab] = new_tab; @@ -1368,7 +1353,10 @@ impl FileViewer { fn spawn_tab_load(&mut self, relative_path: String, cx: &mut Context) { self.next_load_generation = self.next_load_generation.wrapping_add(1); let generation = self.next_load_generation; - if let Some(tab) = self.tabs.iter_mut().find(|t| t.relative_path == relative_path) + if let Some(tab) = self + .tabs + .iter_mut() + .find(|t| t.relative_path == relative_path) { tab.load_generation = generation; } @@ -1381,15 +1369,17 @@ impl FileViewer { let is_font = !is_image && font_format_for_path(&asset_path).is_some(); let svg_renderer = cx.svg_renderer(); cx.spawn(async move |entity: WeakEntity, cx| { - let result: Result = cx + let result: Result<(loading::LoadedContent, Option), String> = cx .background_executor() .spawn(async move { - if is_image { - // Don't do a separate file_size round-trip — the - // server enforces MAX_IMAGE_FILE_SIZE inside - // read_file_bytes (TOCTOU close), and for local - // projects build_image_content rejects oversize - // bytes too. + let metadata = fs.file_metadata(&rel)?; + let content = if is_image { + if metadata.size > MAX_IMAGE_FILE_SIZE { + return Err(format!( + "Image too large ({} bytes). Maximum size is 20 MB.", + metadata.size + )); + } let bytes = fs.read_file_bytes(&rel)?; if bytes.len() as u64 > MAX_IMAGE_FILE_SIZE { return Err(format!( @@ -1397,8 +1387,14 @@ impl FileViewer { bytes.len() as f64 / 1024.0 / 1024.0 )); } - loading::build_image_content(&asset_path, bytes, &svg_renderer) + loading::build_image_content(&asset_path, bytes, &svg_renderer)? } else if is_font { + if metadata.size > loading::MAX_FONT_FILE_SIZE { + return Err(format!( + "Font too large ({} bytes). Maximum size is 20 MB.", + metadata.size + )); + } let bytes = fs.read_file_bytes(&rel)?; if bytes.len() as u64 > loading::MAX_FONT_FILE_SIZE { return Err(format!( @@ -1406,23 +1402,25 @@ impl FileViewer { bytes.len() as f64 / 1024.0 / 1024.0 )); } - loading::build_font_content(&asset_path, bytes) + loading::build_font_content(&asset_path, bytes)? } else { - let size = fs.file_size(&rel)?; - if size > MAX_FILE_SIZE { + if metadata.size > MAX_FILE_SIZE { return Err(format!( - "File too large ({:.1} MB). Maximum size is 5 MB.", - size as f64 / 1024.0 / 1024.0 + "File too large ({} bytes). Maximum size is 5 MB.", + metadata.size )); } - fs.read_file(&rel).map(loading::LoadedContent::Text) - } + loading::LoadedContent::Text(fs.read_file(&rel)?) + }; + Ok((content, metadata.modified_at_millis)) }) .await; let _ = entity.update(cx, |this, cx| { let mut old_image: Option = None; - if let Some(tab) = - this.tabs.iter_mut().find(|t| t.relative_path == relative_path) + if let Some(tab) = this + .tabs + .iter_mut() + .find(|t| t.relative_path == relative_path) { // Drop stale results: a newer spawn_tab_load has been // queued for this tab (closed-and-reopened, navigated @@ -1437,7 +1435,7 @@ impl FileViewer { // a byte hash so a re-register of the same font is a // no-op (without that, GPUI's add_fonts pushes every // call into the platform font source and never frees). - if let Ok(loading::LoadedContent::Font { ttf_bytes, .. }) = &result { + if let Ok((loading::LoadedContent::Font { ttf_bytes, .. }, _)) = &result { register_font_bytes(cx, ttf_bytes); } // Capture the previously-installed image so we can @@ -1445,7 +1443,16 @@ impl FileViewer { // place — apply_loaded_content runs on `&mut tab` // alone and can't see `cx: &mut App`. old_image = tab.image_data.take(); - tab.apply_loaded_content(result, &this.syntax_set, this.is_dark); + let modified_at = result + .as_ref() + .ok() + .and_then(|(_, modified_at)| *modified_at); + tab.apply_loaded_content( + result.map(|(content, _)| content), + modified_at, + &this.syntax_set, + this.is_dark, + ); tab.blame = BlameLoadState::NotLoaded; cx.notify(); } @@ -1549,7 +1556,10 @@ fn register_font_bytes(cx: &mut App, ttf_bytes: &Arc>) { } let bytes: Vec = ttf_bytes.as_ref().clone(); - if let Err(e) = cx.text_system().add_fonts(vec![std::borrow::Cow::Owned(bytes)]) { + if let Err(e) = cx + .text_system() + .add_fonts(vec![std::borrow::Cow::Owned(bytes)]) + { log::warn!("Failed to register font with text system: {}", e); // Roll back the dedup entry so a transient registration failure // doesn't permanently block a retry from re-attempting it. @@ -1577,7 +1587,12 @@ impl Focusable for FileViewer { #[cfg(test)] mod tests { - use super::{FileViewer, FileViewerTab, NavigationHistory, MAX_TABS}; + use super::{ + FileViewer, FileViewerTab, MAX_TABS, NavigationHistory, is_missing_directory_error, + prune_missing_directory, + }; + use crate::list_directory::{DIRECTORY_NOT_FOUND_ERROR, DirEntry}; + use std::collections::{HashMap, HashSet}; fn tab(name: &str) -> FileViewerTab { FileViewerTab::new_loading(name.to_string(), name.into()) @@ -1590,8 +1605,7 @@ mod tests { #[::core::prelude::v1::test] fn insert_tab_below_limit_inserts_after_active() { let mut tabs = vec![tab("a"), tab("b"), tab("c")]; - let (active, evicted) = - FileViewer::insert_tab_after_active(&mut tabs, 0, tab("new")); + let (active, evicted) = FileViewer::insert_tab_after_active(&mut tabs, 0, tab("new")); assert_eq!(active, 1); assert!(evicted.is_none()); assert_eq!(paths(&tabs), ["a", "new", "b", "c"]); @@ -1599,11 +1613,9 @@ mod tests { #[::core::prelude::v1::test] fn insert_tab_at_limit_evicts_oldest_and_keeps_active() { - let mut tabs: Vec = - (0..MAX_TABS).map(|i| tab(&format!("f{i}"))).collect(); + let mut tabs: Vec = (0..MAX_TABS).map(|i| tab(&format!("f{i}"))).collect(); // Active is somewhere in the middle. - let (active, evicted) = - FileViewer::insert_tab_after_active(&mut tabs, 10, tab("new")); + let (active, evicted) = FileViewer::insert_tab_after_active(&mut tabs, 10, tab("new")); assert_eq!(tabs.len(), MAX_TABS); // Oldest (index 0) was evicted; everything shifted left by one, so the // active file f10 stays active and the new tab lands right after it. @@ -1615,15 +1627,16 @@ mod tests { #[::core::prelude::v1::test] fn insert_tab_at_limit_skips_active_when_active_is_oldest() { - let mut tabs: Vec = - (0..MAX_TABS).map(|i| tab(&format!("f{i}"))).collect(); + let mut tabs: Vec = (0..MAX_TABS).map(|i| tab(&format!("f{i}"))).collect(); // Active IS the oldest tab — must not evict it. - let (active, evicted) = - FileViewer::insert_tab_after_active(&mut tabs, 0, tab("new")); + let (active, evicted) = FileViewer::insert_tab_after_active(&mut tabs, 0, tab("new")); assert_eq!(tabs.len(), MAX_TABS); assert_eq!(active, 1); // f0 (active) preserved at index 0; f1 (next oldest) evicted. - assert_eq!(evicted.expect("next-oldest tab returned").relative_path, "f1"); + assert_eq!( + evicted.expect("next-oldest tab returned").relative_path, + "f1" + ); assert_eq!(tabs[0].relative_path, "f0"); assert_eq!(tabs[1].relative_path, "new"); assert_eq!(tabs[2].relative_path, "f2"); @@ -1730,4 +1743,56 @@ mod tests { } assert_eq!(count, 50); } + + #[::core::prelude::v1::test] + fn recognizes_missing_directory_errors_from_current_and_older_daemons() { + assert!(is_missing_directory_error(DIRECTORY_NOT_FOUND_ERROR)); + assert!(is_missing_directory_error( + "Server returned 400 Bad Request: {\"error\":\"Cannot read directory: No such file or directory (os error 2)\"}" + )); + assert!(!is_missing_directory_error( + "Server returned 500 Internal Server Error" + )); + } + + #[::core::prelude::v1::test] + fn missing_directory_prunes_only_its_subtree() { + let mut loaded_dirs: HashMap> = [ + (String::new(), Vec::new()), + ("apps".to_string(), Vec::new()), + ("apps/deleted".to_string(), Vec::new()), + ("apps/deleted/nested".to_string(), Vec::new()), + ("apps/kept".to_string(), Vec::new()), + ] + .into_iter() + .collect(); + let mut loading_dirs = HashSet::from([ + "apps/deleted/other".to_string(), + "apps/kept/other".to_string(), + ]); + let mut expanded_folders = HashSet::from([ + "apps".to_string(), + "apps/deleted".to_string(), + "apps/deleted/nested".to_string(), + "apps/kept".to_string(), + ]); + + let parent = prune_missing_directory( + "apps/deleted", + &mut loaded_dirs, + &mut loading_dirs, + &mut expanded_folders, + ); + + assert_eq!(parent, "apps"); + assert_eq!( + loaded_dirs.keys().cloned().collect::>(), + HashSet::from([String::new(), "apps".to_string(), "apps/kept".to_string(),]) + ); + assert_eq!(loading_dirs, HashSet::from(["apps/kept/other".to_string()])); + assert_eq!( + expanded_folders, + HashSet::from(["apps".to_string(), "apps/kept".to_string()]) + ); + } } diff --git a/crates/okena-files/src/file_viewer/render.rs b/crates/okena-files/src/file_viewer/render.rs index 38f67cec6..4fd4cc338 100644 --- a/crates/okena-files/src/file_viewer/render.rs +++ b/crates/okena-files/src/file_viewer/render.rs @@ -5,26 +5,30 @@ use crate::code_view::{ selection_bg_ranges, }; use crate::file_search::Cancel; -use crate::file_tree::{expandable_file_row, expandable_folder_row}; +use crate::file_tree::{FileTreeRow, expandable_file_row, expandable_folder_row}; use crate::selection::{Selection1DExtension, Selection2DNonEmpty}; use crate::syntax::HighlightedLine; use crate::theme::theme; use gpui::prelude::*; use gpui::*; -use gpui_component::{h_flex, v_flex}; use gpui_component::scroll::ScrollableElement; +use gpui_component::{h_flex, v_flex}; use okena_core::theme::ThemeColors; -use std::path::PathBuf; use okena_markdown::RenderedNode; use okena_ui::code_block::code_block_container; -use okena_ui::modal::{detached_needs_controls, fullscreen_overlay, fullscreen_panel, window_drag_spacer, window_min_max_controls}; -use okena_ui::toggle::segmented_toggle; use okena_ui::file_icon::file_icon; +use okena_ui::modal::{ + detached_needs_controls, fullscreen_overlay, fullscreen_panel, window_drag_spacer, + window_min_max_controls, +}; +use okena_ui::resizable_sidebar::resizable_sidebar; +use okena_ui::toggle::segmented_toggle; use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; +use std::path::PathBuf; use std::sync::Arc; use super::context_menu::TreeNodeTarget; -use super::{DisplayMode, FileViewer, FontData, PreviewBackground, SIDEBAR_WIDTH}; +use super::{DisplayMode, FileViewer, FontData, PreviewBackground}; /// Helper to create rgba from u32 color and alpha. fn rgba(color: u32, alpha: f32) -> Rgba { @@ -198,76 +202,94 @@ impl FileViewer { let active_count = self.show_ignored as u8; let _is_open = self.filter_popover_open; - div() - .w(px(SIDEBAR_WIDTH)) - .h_full() - .border_r_1() + let header = div() + .px(px(12.0)) + .py(px(10.0)) + .border_b_1() .border_color(rgb(t.border)) - .bg(rgb(t.bg_primary)) .flex() - .flex_col() - .child( - div() - .px(px(12.0)) - .py(px(10.0)) - .border_b_1() - .border_color(rgb(t.border)) - .flex() - .items_center() - .justify_between() - .child( - div() - .text_size(ui_text_ms(cx)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(t.text_secondary)) - .line_height(px(11.0)) - .child("Files"), - ) - .child({ - let entity = cx.entity().downgrade(); - let entity2 = entity.clone(); - crate::list_overlay::file_filter_button( - "fv-filter-btn", active_count, t, cx, - move |_, _, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - this.filter_popover_open = !this.filter_popover_open; - cx.notify(); - }); - } - }, - move |bounds, _, cx| { - if let Some(e) = entity2.upgrade() { - e.update(cx, |this, _| this.filter_button_bounds = Some(bounds)); - } - }, - ) - }), - ) + .items_center() + .justify_between() .child( div() - .id("file-viewer-tree") - .flex_1() - .overflow_y_scroll() - .track_scroll(&self.tree_scroll_handle) - .py(px(6.0)) - .children(tree_elements), + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_secondary)) + .line_height(px(11.0)) + .child("Files"), ) - } + .child({ + let entity = cx.entity().downgrade(); + let entity2 = entity.clone(); + crate::list_overlay::file_filter_button( + "fv-filter-btn", + active_count, + t, + cx, + move |_, _, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + this.filter_popover_open = !this.filter_popover_open; + cx.notify(); + }); + } + }, + move |bounds, _, cx| { + if let Some(e) = entity2.upgrade() { + e.update(cx, |this, _| this.filter_button_bounds = Some(bounds)); + } + }, + ) + }); + let tree = div() + .id("file-viewer-tree") + .flex_1() + .overflow_y_scroll() + .track_scroll(&self.tree_scroll_handle) + .py(px(6.0)) + .when_some(self.tree_error_message.clone(), |d, error| { + d.child( + div() + .px(px(12.0)) + .py(px(6.0)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.error)) + .child(error), + ) + }) + .children(tree_elements); + + let entity = cx.entity().downgrade(); + let entity_for_end = entity.clone(); + resizable_sidebar( + self.sidebar_resize.width(), + t.bg_primary, + t.border, + t.border_active, + vec![header.into_any_element(), tree.into_any_element()], + move |mouse_pos, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, _| { + this.sidebar_resize.start_resize(f32::from(mouse_pos.x)); + }); + } + }, + move |cx| { + if let Some(entity) = entity_for_end.upgrade() { + entity.update(cx, |this, _| this.sidebar_resize.end_resize()); + } + }, + ) + } - /// Recursively render file tree nodes with expand/collapse, lazy-loading - /// directory listings via `loaded_dirs` as folders open. - pub(super) fn render_tree_node( + /// Render the canonical visible file-tree rows. + pub(super) fn render_file_tree( &self, - parent_relative: &str, - depth: usize, t: &ThemeColors, cx: &mut Context, ) -> Vec { let mut elements: Vec = Vec::new(); - - // Active and open file paths drive highlighting in the tree. let active_relative = self.active_tab().relative_path.clone(); let open_relatives: std::collections::HashSet = self .tabs @@ -276,48 +298,34 @@ impl FileViewer { .map(|t| t.relative_path.clone()) .collect(); - let entries = match self.loaded_dirs.get(parent_relative) { - Some(entries) => entries, - None => { - if self.loading_dirs.contains(parent_relative) { - elements.push(loading_row(depth, t, cx).into_any_element()); + for row in self.file_tree_rows(false) { + match row { + FileTreeRow::Folder { + path, name, depth, .. + } => { + self.render_folder_row(&mut elements, &name, &path, depth, t, cx); } - return elements; - } - }; - - for entry in entries { - let child_relative = if parent_relative.is_empty() { - entry.name.clone() - } else { - format!("{}/{}", parent_relative, entry.name) - }; - - if entry.is_dir { - self.render_folder_row( - &mut elements, - entry, - &child_relative, + FileTreeRow::File { + item: file_relative, depth, - t, - cx, - ); - if self.expanded_folders.contains(&child_relative) { - elements.extend(self.render_tree_node(&child_relative, depth + 1, t, cx)); + } => { + let filename = file_relative.rsplit('/').next().unwrap_or(&file_relative); + let is_active = active_relative == file_relative; + let is_open = open_relatives.contains(&file_relative); + self.render_file_row( + &mut elements, + filename, + &file_relative, + depth, + is_active, + is_open, + t, + cx, + ); + } + FileTreeRow::Loading { depth } => { + elements.push(loading_row(depth, t, cx).into_any_element()); } - } else { - let is_active = active_relative == child_relative; - let is_open = open_relatives.contains(&child_relative); - self.render_file_row( - &mut elements, - entry, - &child_relative, - depth, - is_active, - is_open, - t, - cx, - ); } } @@ -327,7 +335,7 @@ impl FileViewer { fn render_folder_row( &self, elements: &mut Vec, - entry: &crate::list_directory::DirEntry, + name: &str, folder_relative: &str, depth: usize, t: &ThemeColors, @@ -383,14 +391,13 @@ impl FileViewer { let folder_for_click = folder_relative.to_string(); let folder_for_ctx = folder_relative.to_string(); - let abs_path_for_ctx = match self.project_fs.project_root() { - Some(root) => root.join(folder_relative), - None => PathBuf::from(folder_relative), - }; + let abs_path_for_ctx = PathBuf::from(self.project_fs.project_id()).join(folder_relative); elements.push( - expandable_folder_row(&entry.name, depth, is_expanded, t, cx) - .id(ElementId::Name(format!("fv-folder-{}", folder_relative).into())) + expandable_folder_row(name, depth, is_expanded, t, cx) + .id(ElementId::Name( + format!("fv-folder-{}", folder_relative).into(), + )) .when(is_ctx_target, |d| d.bg(rgb(t.bg_selection))) .on_click(cx.listener(move |this, _, _window, cx| { this.toggle_folder(&folder_for_click, cx); @@ -422,7 +429,7 @@ impl FileViewer { fn render_file_row( &self, elements: &mut Vec, - entry: &crate::list_directory::DirEntry, + filename: &str, file_relative: &str, depth: usize, is_active: bool, @@ -430,10 +437,7 @@ impl FileViewer { t: &ThemeColors, cx: &mut Context, ) { - let abs_path = match self.project_fs.project_root() { - Some(root) => root.join(file_relative), - None => PathBuf::from(file_relative), - }; + let abs_path = PathBuf::from(self.project_fs.project_id()).join(file_relative); let is_renaming = self.is_renaming_file(&abs_path); let is_ctx_target = self.is_context_menu_target_file(&abs_path); let highlight = is_active || is_ctx_target; @@ -441,7 +445,9 @@ impl FileViewer { if is_renaming { let mut row = div() - .id(ElementId::Name(format!("fv-file-{}-rename", file_relative).into())) + .id(ElementId::Name( + format!("fv-file-{}-rename", file_relative).into(), + )) .flex() .items_center() .gap(px(6.0)) @@ -449,7 +455,7 @@ impl FileViewer { .pl(px(indent + 8.0 + 18.0)) .pr(px(12.0)) .bg(rgb(t.bg_selection)) - .child(file_icon(&entry.name, t, cx).mr(px(4.0))); + .child(file_icon(filename, t, cx).mr(px(4.0))); if let Some(input) = self.render_rename_input(t, cx) { row = row.child(input); } @@ -464,7 +470,7 @@ impl FileViewer { let file_relative_for_click = file_relative.to_string(); elements.push( - expandable_file_row(&entry.name, depth, None, is_open || is_active, t, cx) + expandable_file_row(filename, depth, None, is_open || is_active, t, cx) .id(ElementId::Name(format!("fv-file-{}", file_relative).into())) .when(highlight, |d| d.bg(rgba(t.bg_selection, 0.5))) .on_click(cx.listener(move |this, _, _window, cx| { @@ -559,8 +565,7 @@ impl FileViewer { .border_color(rgb(t.border)) .cursor_pointer() .when(is_active, |d| { - d.bg(rgb(t.bg_secondary)) - .text_color(rgb(t.text_primary)) + d.bg(rgb(t.bg_secondary)).text_color(rgb(t.text_primary)) }) .when(!is_active, |d| { d.bg(rgb(t.bg_header)) @@ -579,11 +584,10 @@ impl FileViewer { .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.tab_context_menu = - Some(super::context_menu::TabContextMenu { - position: event.position, - tab_index: i, - }); + this.tab_context_menu = Some(super::context_menu::TabContextMenu { + position: event.position, + tab_index: i, + }); cx.notify(); }), ) @@ -711,13 +715,7 @@ impl FileViewer { ) } - fn render_hint( - &self, - key: &str, - action: &str, - t: &ThemeColors, - cx: &App, - ) -> impl IntoElement { + fn render_hint(&self, key: &str, action: &str, t: &ThemeColors, cx: &App) -> impl IntoElement { h_flex() .gap(px(4.0)) .child( @@ -757,8 +755,9 @@ impl FileViewer { }; if needs_new { + // Unmeasured items are zero-height, making the scrollbar grow while scrolling. tab.markdown_list_state = - Some(ListState::new(count, ListAlignment::Top, px(400.0))); + Some(ListState::new(count, ListAlignment::Top, px(400.0)).measure_all()); tab.markdown_list_nodes = count; tab.markdown_list_font = font; } else if tab.markdown_list_font != font { @@ -819,17 +818,16 @@ impl FileViewer { }; let fallback_muted = muted; - let img_element = img(image) - .with_fallback(move || { - div() - .flex() - .items_center() - .justify_center() - .text_size(px(14.0)) - .text_color(rgb(fallback_muted)) - .child("Cannot decode image") - .into_any_element() - }); + let img_element = img(image).with_fallback(move || { + div() + .flex() + .items_center() + .justify_center() + .text_size(px(14.0)) + .text_color(rgb(fallback_muted)) + .child("Cannot decode image") + .into_any_element() + }); let img_styled: AnyElement = if auto_fit { img_element @@ -1082,7 +1080,11 @@ fn image_background_toggle( .when(is_active, |d| d.bg(rgb(t_active))) .when(!is_active, |d| d.hover(|s| s.bg(rgb(t_hover)))) .text_size(px(12.0)) - .text_color(rgb(if is_active { t_text_primary } else { t_text_muted })) + .text_color(rgb(if is_active { + t_text_primary + } else { + t_text_muted + })) .on_click(cx.listener(move |this, _, _, cx| { this.image_set_background(bg, cx); })) @@ -1201,7 +1203,7 @@ impl Render for FileViewer { // Pre-render tree elements for sidebar let tree_elements = if sidebar_visible { - self.render_tree_node("", 0, &t, cx) + self.render_file_tree(&t, cx) } else { Vec::new() }; @@ -1209,12 +1211,12 @@ impl Render for FileViewer { // Markdown preview uses a virtualized list (built below) so only the // visible blocks are rendered per frame. Ensure the per-tab ListState // exists and matches the current document and font size. - let markdown_list_state: Option = - if !has_error && is_preview_mode && is_markdown { - self.ensure_markdown_list_state(cx) - } else { - None - }; + let markdown_list_state: Option = if !has_error && is_preview_mode && is_markdown + { + self.ensure_markdown_list_state(cx) + } else { + None + }; // Render tab bar let tab_bar: Option = if show_tabs { @@ -1224,22 +1226,23 @@ impl Render for FileViewer { }; // Focus on first render, but not when inline rename or search input is active - if self.rename_state.is_none() && self.search_state.is_none() && !focus_handle.is_focused(window) { + if self.rename_state.is_none() + && self.search_state.is_none() + && !focus_handle.is_focused(window) + { window.focus(&focus_handle, cx); } let outer = if self.is_detached { fullscreen_panel("file-viewer", &t) - .when( - cfg!(target_os = "macos") && !window.is_fullscreen(), - |d| d.pt(px(28.0)), - ) + .when(cfg!(target_os = "macos") && !window.is_fullscreen(), |d| { + d.pt(px(28.0)) + }) } else { fullscreen_overlay("file-viewer", &t) - .when( - cfg!(target_os = "macos") && !window.is_fullscreen(), - |d| d.top(px(28.0)), - ) + .when(cfg!(target_os = "macos") && !window.is_fullscreen(), |d| { + d.top(px(28.0)) + }) }; outer .track_focus(&focus_handle) @@ -1292,6 +1295,9 @@ impl Render for FileViewer { }) { return; } + if this.rename_state.is_some() { + return; + } let key = event.keystroke.key.as_str(); let modifiers = &event.keystroke.modifiers; @@ -1366,6 +1372,8 @@ impl Render for FileViewer { "r" if !modifiers.platform && !modifiers.control => { this.refresh_file_tree_async(cx); } + "up" => this.previous_file_tree_item(cx), + "down" => this.next_file_tree_item(cx), "left" if modifiers.alt => { this.go_back(cx); } @@ -1376,6 +1384,10 @@ impl Render for FileViewer { } })) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + let x = f32::from(event.position.x); + if this.sidebar_resize.update_resize(x) { + cx.notify(); + } if this.active_tab().scrollbar_drag.is_some() { let y = f32::from(event.position.y); this.update_scrollbar_drag(y, cx); @@ -2164,11 +2176,12 @@ impl FileViewer { ) .child(okena_ui::menu::menu_separator(t)) .child( - okena_ui::menu::menu_item("fv-sel-ctx-copy", "icons/copy.svg", "Copy", t) - .on_click(cx.listener(|this, _, _, cx| { + okena_ui::menu::menu_item("fv-sel-ctx-copy", "icons/copy.svg", "Copy", t).on_click( + cx.listener(|this, _, _, cx| { this.selection_context_menu = None; this.copy_selection(cx); - })), + }), + ), ); Some( @@ -2243,10 +2256,20 @@ fn render_font_preview( ("Full name", data.full_name.clone()), ("Style", data.style.clone()), ("Weight class", data.weight_class.to_string()), - ("Italic", if data.is_italic { "yes" } else { "no" }.to_string()), + ( + "Italic", + if data.is_italic { "yes" } else { "no" }.to_string(), + ), ("Glyphs", data.num_glyphs.to_string()), ("Units per em", data.units_per_em.to_string()), - ("Version", if data.version.is_empty() { "—".to_string() } else { data.version.clone() }), + ( + "Version", + if data.version.is_empty() { + "—".to_string() + } else { + data.version.clone() + }, + ), ]; let metadata_block = v_flex() @@ -2280,12 +2303,7 @@ fn render_font_preview( .gap(px(32.0)) .p(px(32.0)) .child(sample_block) - .child( - div() - .h(px(1.0)) - .bg(rgb(t.border)) - .w_full(), - ) + .child(div().h(px(1.0)).bg(rgb(t.border)).w_full()) .child(metadata_block), ) .into_any_element() diff --git a/crates/okena-files/src/file_viewer/selection.rs b/crates/okena-files/src/file_viewer/selection.rs index 827fdd595..cc94f8cd4 100644 --- a/crates/okena-files/src/file_viewer/selection.rs +++ b/crates/okena-files/src/file_viewer/selection.rs @@ -1,9 +1,10 @@ //! Selection, clipboard, scrollbar, and navigation for the file viewer. use crate::code_view::{get_selected_text, start_scrollbar_drag, update_scrollbar_drag}; -use crate::selection::{copy_to_clipboard, Selection1DExtension, Selection2DNonEmpty}; +use crate::selection::{Selection1DExtension, Selection2DNonEmpty, copy_to_clipboard}; use gpui::*; use okena_core::send_payload::{CodeBlock, SendPayload}; +use std::path::PathBuf; use super::{DisplayMode, FileViewer, FileViewerEvent, PreviewBackground}; @@ -55,7 +56,8 @@ impl FileViewer { let first_idx = start_line.min(last_line_idx); let last_idx = end_line.min(last_line_idx); - let text: String = tab.highlighted_lines + let text: String = tab + .highlighted_lines .get(first_idx..=last_idx)? .iter() .map(|l| l.plain_text.as_str()) @@ -63,7 +65,11 @@ impl FileViewer { .join("\n"); Some(SendPayload::Code(vec![CodeBlock { - absolute_path: tab.file_path.clone(), + absolute_path: self + .project_fs + .absolute_path(&tab.relative_path) + .map(PathBuf::from) + .unwrap_or_else(|| tab.file_path.clone()), first: first_idx + 1, last: last_idx + 1, text, diff --git a/crates/okena-files/src/file_viewer/tree.rs b/crates/okena-files/src/file_viewer/tree.rs new file mode 100644 index 000000000..190674bdc --- /dev/null +++ b/crates/okena-files/src/file_viewer/tree.rs @@ -0,0 +1,193 @@ +//! Shared-row adaptation and keyboard navigation for the lazy file tree. + +use super::FileViewer; +use crate::file_tree::{FileTreeNavigationDirection, FileTreeRow, adjacent_file_tree_item}; +use crate::list_directory::DirEntry; +use gpui::Context; +use std::collections::{HashMap, HashSet}; + +fn collect_file_tree_rows( + parent_path: &str, + depth: usize, + loaded_dirs: &HashMap>, + loading_dirs: &HashSet, + expanded_folders: &HashSet, + include_collapsed: bool, + rows: &mut Vec>, +) { + let Some(entries) = loaded_dirs.get(parent_path) else { + if loading_dirs.contains(parent_path) { + rows.push(FileTreeRow::Loading { depth }); + } + return; + }; + + for entry in entries { + let path = if parent_path.is_empty() { + entry.name.clone() + } else { + format!("{parent_path}/{}", entry.name) + }; + + if entry.is_dir { + let is_expanded = expanded_folders.contains(&path); + rows.push(FileTreeRow::Folder { + path: path.clone(), + name: entry.name.clone(), + depth, + is_expanded, + }); + if include_collapsed || is_expanded { + collect_file_tree_rows( + &path, + depth + 1, + loaded_dirs, + loading_dirs, + expanded_folders, + include_collapsed, + rows, + ); + } + } else { + rows.push(FileTreeRow::File { item: path, depth }); + } + } +} + +impl FileViewer { + pub(super) fn file_tree_rows(&self, include_collapsed: bool) -> Vec> { + let mut rows = Vec::new(); + collect_file_tree_rows( + "", + 0, + &self.loaded_dirs, + &self.loading_dirs, + &self.expanded_folders, + include_collapsed, + &mut rows, + ); + rows + } + + fn navigate_file_tree( + &mut self, + direction: FileTreeNavigationDirection, + cx: &mut Context, + ) { + let visible = self.file_tree_rows(false); + let all = self.file_tree_rows(true); + let active_path = &self.active_tab().relative_path; + let selected = (!active_path.is_empty()).then_some(active_path); + + if let Some(path) = adjacent_file_tree_item(&visible, &all, selected, direction) { + self.navigate_to_file_no_history(path, cx); + } + } + + pub(super) fn previous_file_tree_item(&mut self, cx: &mut Context) { + self.navigate_file_tree(FileTreeNavigationDirection::Previous, cx); + } + + pub(super) fn next_file_tree_item(&mut self, cx: &mut Context) { + self.navigate_file_tree(FileTreeNavigationDirection::Next, cx); + } +} + +#[cfg(test)] +mod tests { + use super::collect_file_tree_rows; + use crate::file_tree::FileTreeRow; + use crate::list_directory::DirEntry; + use std::collections::{HashMap, HashSet}; + + fn entry(name: &str, is_dir: bool) -> DirEntry { + DirEntry { + name: name.to_string(), + is_dir, + } + } + + fn loaded_dirs() -> HashMap> { + HashMap::from([ + ( + String::new(), + vec![entry("src", true), entry("README.md", false)], + ), + ( + "src".to_string(), + vec![entry("views", true), entry("lib.rs", false)], + ), + ("src/views".to_string(), vec![entry("render.rs", false)]), + ]) + } + + fn file_paths(rows: &[FileTreeRow]) -> Vec<&str> { + rows.iter() + .filter_map(|row| match row { + FileTreeRow::File { item, .. } => Some(item.as_str()), + FileTreeRow::Folder { .. } | FileTreeRow::Loading { .. } => None, + }) + .collect() + } + + #[test] + fn lazy_rows_match_visible_render_order() { + let mut rows = Vec::new(); + collect_file_tree_rows( + "", + 0, + &loaded_dirs(), + &HashSet::new(), + &HashSet::from(["src".to_string()]), + false, + &mut rows, + ); + + assert_eq!(file_paths(&rows), vec!["src/lib.rs", "README.md"]); + } + + #[test] + fn all_rows_include_loaded_collapsed_contents() { + let mut rows = Vec::new(); + collect_file_tree_rows( + "", + 0, + &loaded_dirs(), + &HashSet::new(), + &HashSet::from(["src".to_string()]), + true, + &mut rows, + ); + + assert_eq!( + file_paths(&rows), + vec!["src/views/render.rs", "src/lib.rs", "README.md"] + ); + } + + #[test] + fn loading_row_uses_child_depth() { + let mut rows = Vec::new(); + collect_file_tree_rows( + "", + 0, + &HashMap::from([( + String::new(), + vec![entry("src", true), entry("README.md", false)], + )]), + &HashSet::from(["src".to_string()]), + &HashSet::from(["src".to_string()]), + false, + &mut rows, + ); + + assert!(matches!( + rows.as_slice(), + [ + FileTreeRow::Folder { depth: 0, .. }, + FileTreeRow::Loading { depth: 1 }, + FileTreeRow::File { depth: 0, .. } + ] + )); + } +} diff --git a/crates/okena-files/src/in_page_search.rs b/crates/okena-files/src/in_page_search.rs index b59126090..4d098beef 100644 --- a/crates/okena-files/src/in_page_search.rs +++ b/crates/okena-files/src/in_page_search.rs @@ -323,9 +323,15 @@ pub fn render_search_bar( ) .child({ let on_prev = cb.on_prev.clone(); - icon_button_sized("in-page-search-prev-btn", "icons/chevron-up.svg", 24.0, 14.0, t) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(cx.listener(move |this, _, _window, cx| (on_prev)(this, cx))) + icon_button_sized( + "in-page-search-prev-btn", + "icons/chevron-up.svg", + 24.0, + 14.0, + t, + ) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _, _window, cx| (on_prev)(this, cx))) }) .child({ let on_next = cb.on_next.clone(); 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..7b1ee782e 100644 --- a/crates/okena-files/src/list_directory.rs +++ b/crates/okena-files/src/list_directory.rs @@ -9,6 +9,8 @@ use ignore::WalkBuilder; use serde::{Deserialize, Serialize}; use std::path::Path; +pub(crate) const DIRECTORY_NOT_FOUND_ERROR: &str = "Directory no longer exists"; + /// One direct child of a directory. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct DirEntry { @@ -32,8 +34,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))?; @@ -41,17 +43,14 @@ pub fn list_directory( canonical_root.clone() } else { let joined = canonical_root.join(relative_path); - let canonical = joined - .canonicalize() - .map_err(|e| format!("Cannot read directory: {}", e))?; + let canonical = joined.canonicalize().map_err(directory_read_error)?; if !canonical.starts_with(&canonical_root) { return Err("path traversal not allowed".to_string()); } canonical }; - let metadata = std::fs::metadata(&target) - .map_err(|e| format!("Cannot read directory: {}", e))?; + let metadata = std::fs::metadata(&target).map_err(directory_read_error)?; if !metadata.is_dir() { return Err(format!("Not a directory: {}", target.display())); } @@ -110,6 +109,14 @@ pub fn list_directory( Ok(entries) } +fn directory_read_error(error: std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + DIRECTORY_NOT_FOUND_ERROR.to_string() + } else { + format!("Cannot read directory: {error}") + } +} + #[cfg(test)] mod tests { use super::*; @@ -138,7 +145,10 @@ mod tests { fs::create_dir(root.join("a_dir")).unwrap(); let entries = list_directory(root, "", false).unwrap(); - assert_eq!(names(&entries), vec!["a_dir", "z_dir", "a_file.txt", "z_file.txt"]); + assert_eq!( + names(&entries), + vec!["a_dir", "z_dir", "a_file.txt", "z_file.txt"] + ); assert!(entries[0].is_dir && entries[1].is_dir); assert!(!entries[2].is_dir && !entries[3].is_dir); } @@ -240,7 +250,7 @@ mod tests { fn errors_on_missing_path() { let tmp = TempDir::new().unwrap(); let err = list_directory(tmp.path(), "nope/nada", false).unwrap_err(); - assert!(err.contains("Cannot read directory"), "err = {}", err); + assert_eq!(err, DIRECTORY_NOT_FOUND_ERROR); } #[test] diff --git a/crates/okena-files/src/list_overlay.rs b/crates/okena-files/src/list_overlay.rs index a20321cd4..8bd353d3e 100644 --- a/crates/okena-files/src/list_overlay.rs +++ b/crates/okena-files/src/list_overlay.rs @@ -138,9 +138,7 @@ pub struct ListOverlayState { impl ListOverlayState { /// Create a new state with the given items and config. pub fn new(items: Vec, config: ListOverlayConfig, cx: &mut App) -> Self { - let filtered: Vec = (0..items.len()) - .map(FilterResult::new) - .collect(); + let filtered: Vec = (0..items.len()).map(FilterResult::new).collect(); Self { focus_handle: cx.focus_handle(), @@ -154,7 +152,12 @@ impl ListOverlayState { } /// Create a new state with a pre-selected index. - pub fn with_selected(items: Vec, config: ListOverlayConfig, selected_index: usize, cx: &mut App) -> Self { + pub fn with_selected( + items: Vec, + config: ListOverlayConfig, + selected_index: usize, + cx: &mut App, + ) -> Self { let mut state = Self::new(items, config, cx); state.selected_index = selected_index.min(state.filtered.len().saturating_sub(1)); state @@ -415,12 +418,10 @@ pub fn file_filter_button( .text_size(ui_text_sm(cx)) .font_weight(FontWeight::MEDIUM) .when(active_count > 0, |d: Stateful
| { - d.bg(rgb(t.border_active)) - .text_color(rgb(t.text_primary)) + d.bg(rgb(t.border_active)).text_color(rgb(t.text_primary)) }) .when(active_count == 0, |d: Stateful
| { - d.bg(rgb(t.bg_secondary)) - .text_color(rgb(t.text_muted)) + d.bg(rgb(t.bg_secondary)).text_color(rgb(t.text_muted)) }) .hover(|s: StyleRefinement| s.bg(rgb(t.bg_hover))) .on_mouse_down(MouseButton::Left, on_click) @@ -448,16 +449,23 @@ pub fn file_filter_popover( deferred( anchored() - .position(point(bounds.origin.x, bounds.origin.y + bounds.size.height + px(2.0))) + .position(point( + bounds.origin.x, + bounds.origin.y + bounds.size.height + px(2.0), + )) .snap_to_window() .child( popover_panel("filter-popover", t) .min_w(px(180.0)) .py(px(4.0)) - .child( - file_filter_option("ignored", "Include gitignored", show_ignored, t, cx, - move |_, window, cx| on_toggle("ignored", window, cx)) - ) + .child(file_filter_option( + "ignored", + "Include gitignored", + show_ignored, + t, + cx, + move |_, window, cx| on_toggle("ignored", window, cx), + )), ), ) } diff --git a/crates/okena-files/src/markdown_highlight.rs b/crates/okena-files/src/markdown_highlight.rs index 7d08b656b..db546ec79 100644 --- a/crates/okena-files/src/markdown_highlight.rs +++ b/crates/okena-files/src/markdown_highlight.rs @@ -253,7 +253,12 @@ fn syntax_for_lang<'a>( /// Build spans for one (non-code) line from the per-byte colour buffer, /// coalescing equal-coloured runs, expanding tabs, and dropping line endings. -fn line_spans(line: &str, base: usize, colors: &[Option], default: Rgba) -> Vec { +fn line_spans( + line: &str, + base: usize, + colors: &[Option], + default: Rgba, +) -> Vec { let mut spans: Vec = Vec::new(); for (i, ch) in line.char_indices() { if ch == '\n' || ch == '\r' { @@ -295,7 +300,10 @@ fn plain_line_spans(content: &str, default: Rgba, max_lines: usize) -> Vec, scope: &str) -> Option { +fn resolve_scope( + highlighter: &Highlighter, + default_fg: Option, + scope: &str, +) -> Option { let stack = ScopeStack::from_str(scope).ok()?; let fg = highlighter.style_for_stack(stack.as_slice()).foreground; if let Some(d) = default_fg diff --git a/crates/okena-files/src/project_fs.rs b/crates/okena-files/src/project_fs.rs index 0ec11d55a..af1886f36 100644 --- a/crates/okena-files/src/project_fs.rs +++ b/crates/okena-files/src/project_fs.rs @@ -1,15 +1,20 @@ -//! 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; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProjectFileMetadata { + pub size: u64, + pub modified_at_millis: Option, +} + /// Provides file system operations from either local disk or a remote server. pub trait ProjectFs: Send + Sync + 'static { /// List files in the project (for file search dialog). - fn list_files(&self, show_ignored: bool) -> Vec; + fn list_files(&self, show_ignored: bool) -> Result, String>; /// List immediate children of a project-relative directory (for the lazy /// file viewer tree). `relative_path = ""` lists the project root. @@ -25,8 +30,14 @@ pub trait ProjectFs: Send + Sync + 'static { /// Read file content as raw bytes. Used for binary previews (images). fn read_file_bytes(&self, relative_path: &str) -> Result, String>; - /// Get file size in bytes. - fn file_size(&self, relative_path: &str) -> Result; + /// Get daemon-side file metadata used for size limits and freshness checks. + fn file_metadata(&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( @@ -35,7 +46,7 @@ pub trait ProjectFs: Send + Sync + 'static { config: &ContentSearchConfig, cancelled: &AtomicBool, on_result: &mut (dyn FnMut(FileSearchResult) + Send), - ); + ) -> Result<(), String>; /// Project display name (directory name). fn project_name(&self) -> String; @@ -43,131 +54,54 @@ pub trait ProjectFs: Send + Sync + 'static { /// Unique project identifier (used for caching). fn project_id(&self) -> String; - /// Local absolute path to the project root, if available. Used to convert - /// project-relative paths into absolute `PathBuf`s for filesystem - /// 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. pub struct RemoteProjectFs { - host: String, - port: u16, - token: String, + client: okena_transport::remote_action::RemoteActionClient, 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( + client: okena_transport::remote_action::RemoteActionClient, + project_id: String, + project_name: String, + root: String, + ) -> Self { + Self { + client, + 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) + fn post_action( + &self, + action: okena_core::api::ActionRequest, + ) -> Result, String> { + self.client.post_action(action) } } impl ProjectFs for RemoteProjectFs { - fn list_files(&self, show_ignored: bool) -> Vec { + fn list_files(&self, show_ignored: bool) -> Result, String> { let action = okena_core::api::ActionRequest::ListFiles { project_id: self.project_id.clone(), show_ignored, }; - match self.post_action(action) { - Ok(Some(value)) => serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("Failed to deserialize file list: {}", e); - Vec::new() - }), - _ => Vec::new(), - } + let value = self + .post_action(action)? + .ok_or_else(|| "Missing file list response".to_string())?; + serde_json::from_value(value) + .map_err(|error| format!("Invalid file list response: {error}")) } fn list_directory( @@ -193,12 +127,11 @@ impl ProjectFs for RemoteProjectFs { relative_path: relative_path.to_string(), }; match self.post_action(action)? { - Some(value) => { - value.get("content") - .and_then(|v| v.as_str()) - .map(String::from) - .ok_or_else(|| "Missing content in response".to_string()) - } + Some(value) => value + .get("content") + .and_then(|v| v.as_str()) + .map(String::from) + .ok_or_else(|| "Missing content in response".to_string()), None => Err("Empty response".to_string()), } } @@ -223,28 +156,47 @@ impl ProjectFs for RemoteProjectFs { } } - fn file_size(&self, relative_path: &str) -> Result { + fn file_metadata(&self, relative_path: &str) -> Result { let action = okena_core::api::ActionRequest::FileSize { project_id: self.project_id.clone(), relative_path: relative_path.to_string(), }; match self.post_action(action)? { - Some(value) => { - value.get("size") + Some(value) => Ok(ProjectFileMetadata { + size: value + .get("size") .and_then(|v| v.as_u64()) - .ok_or_else(|| "Missing size in response".to_string()) - } + .ok_or_else(|| "Missing size in response".to_string())?, + modified_at_millis: value.get("modified_at_millis").and_then(|v| v.as_u64()), + }), None => Err("Empty response".to_string()), } } + 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, config: &ContentSearchConfig, cancelled: &AtomicBool, on_result: &mut (dyn FnMut(FileSearchResult) + Send), - ) { + ) -> Result<(), String> { let mode = match config.mode { SearchMode::Literal => "literal", SearchMode::Regex => "regex", @@ -258,19 +210,21 @@ impl ProjectFs for RemoteProjectFs { max_results: config.max_results, file_glob: config.file_glob.clone(), context_lines: config.context_lines, + show_ignored: config.show_ignored, }; - if let Ok(Some(value)) = self.post_action(action) { - let results: Vec = serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("Failed to deserialize search results: {}", e); - Vec::new() - }); - for result in results { - if cancelled.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - on_result(result); + let value = self + .client + .post_action_cancellable(action, cancelled)? + .ok_or_else(|| "Missing content search response".to_string())?; + let results: Vec = serde_json::from_value(value) + .map_err(|error| format!("Invalid content search response: {error}"))?; + for result in results { + if cancelled.load(std::sync::atomic::Ordering::Relaxed) { + break; } + on_result(result); } + Ok(()) } fn project_name(&self) -> String { @@ -281,7 +235,11 @@ impl ProjectFs for RemoteProjectFs { self.project_id.clone() } - 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-files/src/selection.rs b/crates/okena-files/src/selection.rs index be17ab9b6..76516220c 100644 --- a/crates/okena-files/src/selection.rs +++ b/crates/okena-files/src/selection.rs @@ -13,9 +13,10 @@ use gpui::{ClipboardItem, Context}; /// This is a convenience function to avoid duplicating clipboard logic. pub fn copy_to_clipboard(cx: &mut Context, text: Option) { if let Some(text) = text - && !text.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } + && !text.is_empty() + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } } /// Extension trait for SelectionState with 2D positions to check line selection. @@ -64,9 +65,7 @@ pub trait Selection2DNonEmpty { impl Selection2DNonEmpty for SelectionState<(usize, usize)> { fn normalized_non_empty(&self) -> Option<((usize, usize), (usize, usize))> { match (&self.start, &self.end) { - (Some(s), Some(e)) if s != e => { - Some(<(usize, usize)>::normalized_pair(*s, *e)) - } + (Some(s), Some(e)) if s != e => Some(<(usize, usize)>::normalized_pair(*s, *e)), _ => None, } } diff --git a/crates/okena-files/src/syntax.rs b/crates/okena-files/src/syntax.rs index 1a4193b1c..ed6ae8215 100644 --- a/crates/okena-files/src/syntax.rs +++ b/crates/okena-files/src/syntax.rs @@ -276,12 +276,12 @@ pub fn highlight_content( // Try to merge with previous span if same color if let Some(last) = merged.last_mut() && (last.color.r - color.r).abs() < 0.01 - && (last.color.g - color.g).abs() < 0.01 - && (last.color.b - color.b).abs() < 0.01 - { - last.text.push_str(&processed); - continue; - } + && (last.color.g - color.g).abs() < 0.01 + && (last.color.b - color.b).abs() < 0.01 + { + last.text.push_str(&processed); + continue; + } merged.push(HighlightedSpan { color, diff --git a/crates/okena-git/src/blame.rs b/crates/okena-git/src/blame.rs index 5f44d6ef8..218b190b0 100644 --- a/crates/okena-git/src/blame.rs +++ b/crates/okena-git/src/blame.rs @@ -9,8 +9,8 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; -use gix::bstr::BStr; use gix::ObjectId; +use gix::bstr::BStr; /// Whether a line is attributable to a committed change or is a working-tree /// modification that has no commit yet. @@ -83,10 +83,7 @@ pub fn get_blame(repo_path: &Path, relative_path: &str) -> Result let repo = crate::gix_helpers::open(repo_path).ok_or(BlameError::NotGitRepo)?; let workdir = repo.workdir().ok_or(BlameError::NotGitRepo)?.to_path_buf(); - let head_id = repo - .head_id() - .map_err(|_| BlameError::NoCommits)? - .detach(); + let head_id = repo.head_id().map_err(|_| BlameError::NoCommits)?.detach(); let head_commit = repo .head_commit() .map_err(|e| BlameError::Backend(e.to_string()))?; @@ -255,7 +252,7 @@ fn count_lines(content: &[u8]) -> usize { /// line is unchanged, or `None` if the line was inserted/modified in the /// working tree. Uses imara-diff (already pulled by the gix `blame` feature). fn map_wt_lines_to_head(head: &[u8], wt: &[u8]) -> Vec> { - use gix::diff::blob::{sources::byte_lines, Algorithm, Diff, InternedInput}; + use gix::diff::blob::{Algorithm, Diff, InternedInput, sources::byte_lines}; let input = InternedInput::new(byte_lines(head), byte_lines(wt)); let mut diff = Diff::compute(Algorithm::Histogram, &input); @@ -316,8 +313,16 @@ mod tests { fn commit_file(dir: &Path, file: &str, content: &str, msg: &str) { fs::write(dir.join(file), content).unwrap(); - Command::new("git").args(["add", file]).current_dir(dir).output().unwrap(); - Command::new("git").args(["commit", "-m", msg]).current_dir(dir).output().unwrap(); + Command::new("git") + .args(["add", file]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", msg]) + .current_dir(dir) + .output() + .unwrap(); } #[test] diff --git a/crates/okena-git/src/branch_names.rs b/crates/okena-git/src/branch_names.rs index 8312a03ac..31f10c342 100644 --- a/crates/okena-git/src/branch_names.rs +++ b/crates/okena-git/src/branch_names.rs @@ -15,44 +15,158 @@ struct BakedGood { } const GOODS: &[BakedGood] = &[ - BakedGood { name: "rohlik", gender: Gender::Masculine }, - BakedGood { name: "houska", gender: Gender::Feminine }, - BakedGood { name: "kolac", gender: Gender::Masculine }, - BakedGood { name: "veka", gender: Gender::Feminine }, - BakedGood { name: "chleb", gender: Gender::Masculine }, - BakedGood { name: "buchta", gender: Gender::Feminine }, - BakedGood { name: "kobliha", gender: Gender::Feminine }, - BakedGood { name: "strudl", gender: Gender::Masculine }, - BakedGood { name: "mazanec", gender: Gender::Masculine }, - BakedGood { name: "vanocka", gender: Gender::Feminine }, - BakedGood { name: "trdlo", gender: Gender::Neuter }, - BakedGood { name: "trdelnik", gender: Gender::Masculine }, - BakedGood { name: "loupak", gender: Gender::Masculine }, - BakedGood { name: "makovec", gender: Gender::Masculine }, - BakedGood { name: "zavin", gender: Gender::Masculine }, - BakedGood { name: "kremrole", gender: Gender::Feminine }, - BakedGood { name: "venecek", gender: Gender::Masculine }, - BakedGood { name: "rakvicka", gender: Gender::Feminine }, - BakedGood { name: "laskonka", gender: Gender::Feminine }, - BakedGood { name: "medovnik", gender: Gender::Masculine }, - BakedGood { name: "bublanina", gender: Gender::Feminine }, - BakedGood { name: "pernik", gender: Gender::Masculine }, - BakedGood { name: "knedlik", gender: Gender::Masculine }, - BakedGood { name: "palacinka", gender: Gender::Feminine }, - BakedGood { name: "babovka", gender: Gender::Feminine }, - BakedGood { name: "povidlak", gender: Gender::Masculine }, - BakedGood { name: "vdolek", gender: Gender::Masculine }, - BakedGood { name: "bochanek", gender: Gender::Masculine }, - BakedGood { name: "kolatek", gender: Gender::Masculine }, - BakedGood { name: "zemle", gender: Gender::Feminine }, - BakedGood { name: "paska", gender: Gender::Feminine }, - BakedGood { name: "pletenak", gender: Gender::Masculine }, - BakedGood { name: "orechovec", gender: Gender::Masculine }, - BakedGood { name: "tvarohac", gender: Gender::Masculine }, - BakedGood { name: "jablecnak", gender: Gender::Masculine }, - BakedGood { name: "svestkac", gender: Gender::Masculine }, - BakedGood { name: "linecak", gender: Gender::Masculine }, - BakedGood { name: "vetrnik", gender: Gender::Masculine }, + BakedGood { + name: "rohlik", + gender: Gender::Masculine, + }, + BakedGood { + name: "houska", + gender: Gender::Feminine, + }, + BakedGood { + name: "kolac", + gender: Gender::Masculine, + }, + BakedGood { + name: "veka", + gender: Gender::Feminine, + }, + BakedGood { + name: "chleb", + gender: Gender::Masculine, + }, + BakedGood { + name: "buchta", + gender: Gender::Feminine, + }, + BakedGood { + name: "kobliha", + gender: Gender::Feminine, + }, + BakedGood { + name: "strudl", + gender: Gender::Masculine, + }, + BakedGood { + name: "mazanec", + gender: Gender::Masculine, + }, + BakedGood { + name: "vanocka", + gender: Gender::Feminine, + }, + BakedGood { + name: "trdlo", + gender: Gender::Neuter, + }, + BakedGood { + name: "trdelnik", + gender: Gender::Masculine, + }, + BakedGood { + name: "loupak", + gender: Gender::Masculine, + }, + BakedGood { + name: "makovec", + gender: Gender::Masculine, + }, + BakedGood { + name: "zavin", + gender: Gender::Masculine, + }, + BakedGood { + name: "kremrole", + gender: Gender::Feminine, + }, + BakedGood { + name: "venecek", + gender: Gender::Masculine, + }, + BakedGood { + name: "rakvicka", + gender: Gender::Feminine, + }, + BakedGood { + name: "laskonka", + gender: Gender::Feminine, + }, + BakedGood { + name: "medovnik", + gender: Gender::Masculine, + }, + BakedGood { + name: "bublanina", + gender: Gender::Feminine, + }, + BakedGood { + name: "pernik", + gender: Gender::Masculine, + }, + BakedGood { + name: "knedlik", + gender: Gender::Masculine, + }, + BakedGood { + name: "palacinka", + gender: Gender::Feminine, + }, + BakedGood { + name: "babovka", + gender: Gender::Feminine, + }, + BakedGood { + name: "povidlak", + gender: Gender::Masculine, + }, + BakedGood { + name: "vdolek", + gender: Gender::Masculine, + }, + BakedGood { + name: "bochanek", + gender: Gender::Masculine, + }, + BakedGood { + name: "kolatek", + gender: Gender::Masculine, + }, + BakedGood { + name: "zemle", + gender: Gender::Feminine, + }, + BakedGood { + name: "paska", + gender: Gender::Feminine, + }, + BakedGood { + name: "pletenak", + gender: Gender::Masculine, + }, + BakedGood { + name: "orechovec", + gender: Gender::Masculine, + }, + BakedGood { + name: "tvarohac", + gender: Gender::Masculine, + }, + BakedGood { + name: "jablecnak", + gender: Gender::Masculine, + }, + BakedGood { + name: "svestkac", + gender: Gender::Masculine, + }, + BakedGood { + name: "linecak", + gender: Gender::Masculine, + }, + BakedGood { + name: "vetrnik", + gender: Gender::Masculine, + }, ]; /// (stem, masculine_suffix, feminine_suffix, neuter_suffix) @@ -69,7 +183,13 @@ const ADJECTIVE_STEMS: &[(&str, &str, &str, &str)] = &[ ("rychl", "y", "a", "e"), ]; -fn adjective_for(stem: &str, suffix_m: &str, suffix_f: &str, suffix_n: &str, good: &BakedGood) -> String { +fn adjective_for( + stem: &str, + suffix_m: &str, + suffix_f: &str, + suffix_n: &str, + good: &BakedGood, +) -> String { let suffix = match good.gender { Gender::Masculine => suffix_m, Gender::Feminine => suffix_f, @@ -80,11 +200,14 @@ fn adjective_for(stem: &str, suffix_m: &str, suffix_f: &str, suffix_n: &str, goo /// Per-repo username cache. Keyed by canonical repo path so that different /// repositories resolve to their own GitHub owner. -static USERNAME_CACHE: parking_lot::Mutex>> = - parking_lot::Mutex::new(None); +static USERNAME_CACHE: parking_lot::Mutex< + Option>, +> = parking_lot::Mutex::new(None); fn detect_github_username(repo_path: &Path) -> String { - let canonical = repo_path.canonicalize().unwrap_or_else(|_| repo_path.to_path_buf()); + let canonical = repo_path + .canonicalize() + .unwrap_or_else(|_| repo_path.to_path_buf()); let mut guard = USERNAME_CACHE.lock(); let cache = guard.get_or_insert_with(std::collections::HashMap::new); if let Some(cached) = cache.get(&canonical) { @@ -100,22 +223,24 @@ fn detect_github_username_inner(repo_path: &Path) -> String { // which is correct even when the remote is owned by an org. // Result is cached so the network call only happens once. if let Ok(output) = safe_output(command("gh").args(["api", "user", "--jq", ".login"])) - && output.status.success() { - let login = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !login.is_empty() { - return sanitize_username(&login); - } + && output.status.success() + { + let login = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !login.is_empty() { + return sanitize_username(&login); } + } // Tier 2: git config user.name if let Some(repo) = crate::gix_helpers::open(repo_path) - && let Some(name) = repo.config_snapshot().string("user.name") { - let name = name.to_string(); - let trimmed = name.trim(); - if !trimmed.is_empty() { - return sanitize_username(trimmed); - } + && let Some(name) = repo.config_snapshot().string("user.name") + { + let name = name.to_string(); + let trimmed = name.trim(); + if !trimmed.is_empty() { + return sanitize_username(trimmed); } + } // Tier 3: fallback "dev".to_string() @@ -144,7 +269,10 @@ pub fn generate_branch_name(repo_path: &Path) -> String { let (username, taken) = std::thread::scope(|s| { let u = s.spawn(|| detect_github_username(repo_path)); let t = s.spawn(|| collect_taken_branches(repo_path)); - (u.join().expect("username detection thread panicked"), t.join().expect("branch listing thread panicked")) + ( + u.join().expect("username detection thread panicked"), + t.join().expect("branch listing thread panicked"), + ) }); // Shuffle goods and adjectives so the generated name feels random @@ -204,7 +332,10 @@ fn collect_taken_branches(repo_path: &Path) -> HashSet { let (branches, wt_branches) = std::thread::scope(|s| { let b = s.spawn(|| super::repository::list_branches(repo_path)); let w = s.spawn(|| super::repository::get_worktree_branches(repo_path)); - (b.join().expect("branch listing thread panicked"), w.join().expect("worktree branch listing thread panicked")) + ( + b.join().expect("branch listing thread panicked"), + w.join().expect("worktree branch listing thread panicked"), + ) }); let mut taken: HashSet = branches.into_iter().collect(); taken.extend(wt_branches); @@ -238,9 +369,18 @@ mod tests { #[test] fn test_adjective_gender_agreement() { - let m_good = BakedGood { name: "rohlik", gender: Gender::Masculine }; - let f_good = BakedGood { name: "houska", gender: Gender::Feminine }; - let n_good = BakedGood { name: "trdlo", gender: Gender::Neuter }; + let m_good = BakedGood { + name: "rohlik", + gender: Gender::Masculine, + }; + let f_good = BakedGood { + name: "houska", + gender: Gender::Feminine, + }; + let n_good = BakedGood { + name: "trdlo", + gender: Gender::Neuter, + }; assert_eq!(adjective_for("velk", "y", "a", "e", &m_good), "velky"); assert_eq!(adjective_for("velk", "y", "a", "e", &f_good), "velka"); diff --git a/crates/okena-git/src/commit_graph.rs b/crates/okena-git/src/commit_graph.rs index 1df21c242..3027003c8 100644 --- a/crates/okena-git/src/commit_graph.rs +++ b/crates/okena-git/src/commit_graph.rs @@ -78,9 +78,10 @@ fn collect_refs(repo: &gix::Repository) -> HashMap> { // Detached HEAD: emit a bare "HEAD" label if HEAD doesn't point to a branch. if head_branch.is_none() - && let Some(oid) = head_id { - map.entry(oid).or_default().insert(0, "HEAD".to_string()); - } + && let Some(oid) = head_id + { + map.entry(oid).or_default().insert(0, "HEAD".to_string()); + } map } @@ -94,9 +95,10 @@ fn resolve_tip(repo: &gix::Repository, branch: Option<&str>) -> Option // Try as a ref first (covers "main", "origin/main", "refs/heads/foo"), // then fall back to a full rev_parse for SHAs / advanced specs. if let Ok(Some(mut r)) = repo.try_find_reference(name) - && let Ok(id) = r.peel_to_id() { - return Some(id.detach()); - } + && let Ok(id) = r.peel_to_id() + { + return Some(id.detach()); + } repo.rev_parse_single(name).ok().map(|id| id.detach()) } } diff --git a/crates/okena-git/src/diff.rs b/crates/okena-git/src/diff.rs index 7d972343a..631205541 100644 --- a/crates/okena-git/src/diff.rs +++ b/crates/okena-git/src/diff.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use okena_core::process::{command, safe_output}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// Type of a diff line. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -77,7 +77,6 @@ impl FileDiff { .or(self.old_path.as_deref()) .unwrap_or("unknown") } - } pub use okena_core::types::DiffMode; @@ -378,7 +377,8 @@ pub fn get_diff_with_options( use crate::error::GitError; let t_total = std::time::Instant::now(); - let path_str = path.to_str() + let path_str = path + .to_str() .ok_or_else(|| GitError::InvalidPath(path.to_path_buf()))?; // Build git diff command based on mode @@ -389,18 +389,39 @@ pub fn get_diff_with_options( let range_str; let mut args = match mode { DiffMode::WorkingTree => vec!["-C", path_str, "diff", "--no-color", "--no-ext-diff"], - DiffMode::Staged => vec!["-C", path_str, "diff", "--cached", "--no-color", "--no-ext-diff"], + DiffMode::Staged => vec![ + "-C", + path_str, + "diff", + "--cached", + "--no-color", + "--no-ext-diff", + ], DiffMode::Commit(ref hash) => { crate::validate_git_ref(hash)?; range_str = format!("{}^..{}", hash, hash); - vec!["-C", path_str, "diff", &range_str, "--no-color", "--no-ext-diff"] + vec![ + "-C", + path_str, + "diff", + &range_str, + "--no-color", + "--no-ext-diff", + ] } DiffMode::BranchCompare { ref base, ref head } => { crate::validate_git_ref(base)?; crate::validate_git_ref(head)?; // Three-dot diff: changes on head since it diverged from base range_str = format!("{}...{}", base, head); - vec!["-C", path_str, "diff", &range_str, "--no-color", "--no-ext-diff"] + vec![ + "-C", + path_str, + "diff", + &range_str, + "--no-color", + "--no-ext-diff", + ] } }; @@ -411,7 +432,11 @@ pub fn get_diff_with_options( let t0 = std::time::Instant::now(); let output = safe_output(command("git").args(&args))?; - log::debug!("[get_diff_with_options] git diff command: {:?}, stdout: {} bytes", t0.elapsed(), output.stdout.len()); + log::debug!( + "[get_diff_with_options] git diff command: {:?}, stdout: {} bytes", + t0.elapsed(), + output.stdout.len() + ); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); @@ -424,13 +449,21 @@ pub fn get_diff_with_options( let t1 = std::time::Instant::now(); let stdout = String::from_utf8_lossy(&output.stdout); let mut result = parse_unified_diff(&stdout); - log::debug!("[get_diff_with_options] parse_unified_diff: {:?}, files: {}", t1.elapsed(), result.files.len()); + log::debug!( + "[get_diff_with_options] parse_unified_diff: {:?}, files: {}", + t1.elapsed(), + result.files.len() + ); // For unstaged mode, also include untracked files if matches!(mode, DiffMode::WorkingTree) { let t2 = std::time::Instant::now(); let untracked = get_untracked_files(path); - log::debug!("[get_diff_with_options] get_untracked_files: {:?}, count: {}", t2.elapsed(), untracked.len()); + log::debug!( + "[get_diff_with_options] get_untracked_files: {:?}, count: {}", + t2.elapsed(), + untracked.len() + ); for file_path in untracked { if let Some(file_diff) = create_untracked_file_diff(path, &file_path) { result.files.push(file_diff); @@ -526,9 +559,10 @@ pub fn is_git_repo(path: &Path) -> bool { let guard = GIT_REPO_CACHE.lock(); if let Some(ref cache) = *guard && let Some(&(result, ts)) = cache.get(&path_buf) - && ts.elapsed() < GIT_REPO_TTL { - return result; - } + && ts.elapsed() < GIT_REPO_TTL + { + return result; + } } let result = crate::gix_helpers::open(path).is_some(); @@ -550,7 +584,6 @@ pub fn is_git_repo(path: &Path) -> bool { result } - /// Get the full content of a file from git at a specific revision. /// /// - `revision` can be "HEAD", a commit hash, or empty for the index (staged version) @@ -644,7 +677,11 @@ pub fn get_file_contents_for_diff( (old, new) } }; - log::debug!("[get_file_contents_for_diff] {:?}, file: {}", t0.elapsed(), file_path); + log::debug!( + "[get_file_contents_for_diff] {:?}, file: {}", + t0.elapsed(), + file_path + ); result } @@ -658,13 +695,22 @@ mod tests { // init_temp_repo commits file.txt = "x". let (_tmp, repo) = init_temp_repo(); - assert_eq!(get_file_from_git(&repo, "HEAD", "file.txt").as_deref(), Some("x")); + assert_eq!( + get_file_from_git(&repo, "HEAD", "file.txt").as_deref(), + Some("x") + ); // Stage a modified version: HEAD stays "x", the index becomes "y". std::fs::write(repo.join("file.txt"), "y").unwrap(); git_in(&repo, &["add", "file.txt"]); - assert_eq!(get_file_from_git(&repo, "HEAD", "file.txt").as_deref(), Some("x")); - assert_eq!(get_file_from_git(&repo, "", "file.txt").as_deref(), Some("y")); + assert_eq!( + get_file_from_git(&repo, "HEAD", "file.txt").as_deref(), + Some("x") + ); + assert_eq!( + get_file_from_git(&repo, "", "file.txt").as_deref(), + Some("y") + ); // Missing path resolves to nothing rather than erroring. assert!(get_file_from_git(&repo, "HEAD", "nope.txt").is_none()); @@ -675,7 +721,10 @@ mod tests { assert_eq!(parse_hunk_header("@@ -1,5 +1,7 @@ fn main()"), (1, 1)); assert_eq!(parse_hunk_header("@@ -10,3 +15,5 @@"), (10, 15)); assert_eq!(parse_hunk_header("@@ -1 +1 @@"), (1, 1)); - assert_eq!(parse_hunk_header("@@ -100,20 +95,15 @@ impl Foo"), (100, 95)); + assert_eq!( + parse_hunk_header("@@ -100,20 +95,15 @@ impl Foo"), + (100, 95) + ); } #[test] @@ -867,7 +916,11 @@ diff --git a/b.rs b/b.rs let result = safe_repo_path(dir.path(), "src/main.rs"); assert!(result.is_some()); - assert!(result.unwrap().starts_with(dir.path().canonicalize().unwrap())); + assert!( + result + .unwrap() + .starts_with(dir.path().canonicalize().unwrap()) + ); } #[test] @@ -922,8 +975,14 @@ diff --git a/b.rs b/b.rs rename to src/new_name.rs\n"; let result = parse_unified_diff(diff); assert_eq!(result.files.len(), 1); - assert_eq!(result.files[0].old_path, Some("src/old_name.rs".to_string())); - assert_eq!(result.files[0].new_path, Some("src/new_name.rs".to_string())); + assert_eq!( + result.files[0].old_path, + Some("src/old_name.rs".to_string()) + ); + assert_eq!( + result.files[0].new_path, + Some("src/new_name.rs".to_string()) + ); assert!(result.files[0].hunks.is_empty()); // Bug fix: previously returned "unknown" because both paths were None. assert_eq!(result.files[0].display_name(), "src/new_name.rs"); @@ -971,7 +1030,10 @@ rename to src/new.rs fn test_parse_diff_git_header_helper() { assert_eq!( parse_diff_git_header("diff --git a/src/main.rs b/src/main.rs"), - (Some("src/main.rs".to_string()), Some("src/main.rs".to_string())) + ( + Some("src/main.rs".to_string()), + Some("src/main.rs".to_string()) + ) ); assert_eq!( parse_diff_git_header("diff --git a/old.rs b/new.rs"), diff --git a/crates/okena-git/src/error.rs b/crates/okena-git/src/error.rs index d10b6db71..377cf72bf 100644 --- a/crates/okena-git/src/error.rs +++ b/crates/okena-git/src/error.rs @@ -27,6 +27,10 @@ pub enum GitError { source: std::io::Error, }, + /// A destructive worktree operation failed its ownership check. + #[error("unsafe worktree operation for '{path}': {reason}")] + UnsafeWorktree { path: PathBuf, reason: String }, + /// A git ref (branch name, commit hash) looks like a CLI flag. #[error("invalid git ref: {0}")] InvalidRef(String), diff --git a/crates/okena-git/src/gix_helpers.rs b/crates/okena-git/src/gix_helpers.rs index 4f368a412..350bfc5e0 100644 --- a/crates/okena-git/src/gix_helpers.rs +++ b/crates/okena-git/src/gix_helpers.rs @@ -94,14 +94,22 @@ pub(crate) fn list_untracked_files(query_path: &Path) -> Option> { // Compute the prefix from workdir to query_path. Empty when query_path // is the workdir itself. - let canonical_query = query_path.canonicalize().unwrap_or_else(|_| query_path.to_path_buf()); - let canonical_workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf()); + let canonical_query = query_path + .canonicalize() + .unwrap_or_else(|_| query_path.to_path_buf()); + let canonical_workdir = workdir + .canonicalize() + .unwrap_or_else(|_| workdir.to_path_buf()); let prefix: String = canonical_query .strip_prefix(&canonical_workdir) .ok() .map(|p| { let s = p.to_string_lossy().to_string(); - if s.is_empty() { String::new() } else { format!("{}/", s) } + if s.is_empty() { + String::new() + } else { + format!("{}/", s) + } }) .unwrap_or_default(); @@ -118,7 +126,10 @@ pub(crate) fn list_untracked_files(query_path: &Path) -> Option> { { Ok(i) => i, Err(e) => { - log::warn!("gix status iter init failed for {}: {e}", query_path.display()); + log::warn!( + "gix status iter init failed for {}: {e}", + query_path.display() + ); return None; } }; @@ -128,7 +139,10 @@ pub(crate) fn list_untracked_files(query_path: &Path) -> Option> { let item = match item_result { Ok(i) => i, Err(e) => { - log::warn!("gix status iteration failed for {}: {e}", query_path.display()); + log::warn!( + "gix status iteration failed for {}: {e}", + query_path.display() + ); return None; } }; diff --git a/crates/okena-git/src/lib.rs b/crates/okena-git/src/lib.rs index 93372e4df..fe16ec907 100644 --- a/crates/okena-git/src/lib.rs +++ b/crates/okena-git/src/lib.rs @@ -8,42 +8,24 @@ pub mod error; pub(crate) mod gix_helpers; pub mod repository; -pub use blame::{get_blame, BlameCommit, BlameError, BlameKind, BlameLine}; -pub use error::{GitError, GitResult}; +pub use blame::{BlameCommit, BlameError, BlameKind, BlameLine, get_blame}; pub use commit_graph::fetch_commit_log; -pub use diff::{DiffResult, DiffMode, FileDiff, DiffLineType, get_diff_with_options, is_git_repo, get_file_contents_for_diff}; +pub use diff::{ + DiffLineType, DiffMode, DiffResult, FileDiff, get_diff_with_options, + get_file_contents_for_diff, is_git_repo, +}; +pub use error::{GitError, GitResult}; pub use repository::{ - create_worktree, - remove_worktree, - remove_worktree_fast, - get_available_branches_for_worktree, - get_repo_root, - resolve_git_root_and_subdir, - compute_target_paths, - project_path_in_worktree, - has_uncommitted_changes, - get_current_branch, - get_default_branch, - resolve_review_base, - rebase_onto, - merge_branch, - stash_changes, - stash_pop, - stage_file, - unstage_file, - discard_file_changes, - fetch_all, - delete_local_branch, - delete_remote_branch, - push_branch, - count_unpushed_commits, - count_ahead_behind, - list_branches, - list_branches_classified, - BranchList, - checkout_local_branch, - checkout_remote_branch, - create_and_checkout_branch, + BranchList, HeadSnapshot, VerifiedWorktree, checkout_local_branch, checkout_remote_branch, + compute_target_paths, count_ahead_behind, count_unpushed_commits, create_and_checkout_branch, + create_worktree, create_worktree_with_start_point, delete_local_branch, delete_remote_branch, + discard_file_changes, fetch_all, fetch_and_fast_forward, get_available_branches_for_worktree, + get_current_branch, get_default_branch, get_head_snapshot, get_repo_common_dir, get_repo_root, + has_uncommitted_changes, list_branches, list_branches_classified, list_linked_worktree_paths, + list_pull_requests, merge_branch, move_worktree, project_path_in_worktree, push_branch, + rebase_onto, remove_worktree, remove_worktree_fast, resolve_git_root_and_subdir, + resolve_review_base, stage_file, stash_changes, stash_pop, unstage_file, + verify_linked_worktree_fresh, }; /// Validate that a git ref (branch name, commit hash, revision) doesn't look @@ -215,11 +197,15 @@ pub fn refresh_git_status_with_pr_base(path: &Path, pr_base: Option<&str>) -> Op if let Some(base) = pr_base { repository::apply_pr_base(&mut s, path, base); } - with_cache(|cache| { cache.insert(path_buf, Some(s.clone())); }); + with_cache(|cache| { + cache.insert(path_buf, Some(s.clone())); + }); Some(s) } repository::StatusFetch::NotRepo => { - with_cache(|cache| { cache.insert(path_buf, None); }); + with_cache(|cache| { + cache.insert(path_buf, None); + }); None } repository::StatusFetch::Transient => { @@ -242,25 +228,29 @@ pub fn warm_branch_cache(path: &Path) { return; }; with_cache(|cache| { - cache.entry(path_buf).or_insert_with(|| Some(GitStatus { - branch: Some(branch), - lines_added: 0, - lines_removed: 0, - pr_info: None, - ci_checks: None, - ahead: None, - behind: None, - unpushed: None, - review_base: None, - default_branch: None, - })); + cache.entry(path_buf).or_insert_with(|| { + Some(GitStatus { + branch: Some(branch), + lines_added: 0, + lines_removed: 0, + pr_info: None, + ci_checks: None, + ahead: None, + behind: None, + unpushed: None, + review_base: None, + default_branch: None, + }) + }); }); } /// Invalidate cache for a specific path (call when you know files changed) #[allow(dead_code)] pub fn invalidate_cache(path: &Path) { - with_cache(|cache| { cache.remove(path); }); + with_cache(|cache| { + cache.remove(path); + }); } /// Get per-file diff summary for a repository. @@ -315,7 +305,10 @@ mod tests { std::fs::write(repo.join("file.txt"), "a\nb\nc\n").unwrap(); std::fs::write(repo.join("doomed.txt"), "x\ny\n").unwrap(); git_in(&repo, &["add", "."]); - git_in(&repo, &["-c", "commit.gpgsign=false", "commit", "-m", "seed"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "seed"], + ); std::fs::write(repo.join("file.txt"), "a\nB\nc\nd\n").unwrap(); std::fs::remove_file(repo.join("doomed.txt")).unwrap(); std::fs::write(repo.join("staged.txt"), "p\nq\n").unwrap(); @@ -326,47 +319,83 @@ mod tests { let mut want: std::collections::HashMap = std::collections::HashMap::new(); let out = std::process::Command::new("git") - .args(["-C", repo.to_str().unwrap(), "diff", "--numstat", "--no-renames", "--no-color", "--no-ext-diff", "HEAD"]) + .args([ + "-C", + repo.to_str().unwrap(), + "diff", + "--numstat", + "--no-renames", + "--no-color", + "--no-ext-diff", + "HEAD", + ]) .output() .unwrap(); for line in String::from_utf8_lossy(&out.stdout).lines() { let p: Vec<&str> = line.split('\t').collect(); if p.len() >= 3 { - want.insert(p[2].to_string(), (p[0].parse().unwrap_or(0), p[1].parse().unwrap_or(0), false)); + want.insert( + p[2].to_string(), + (p[0].parse().unwrap_or(0), p[1].parse().unwrap_or(0), false), + ); } } want.insert("untracked.txt".to_string(), (2, 0, true)); - let got: std::collections::HashMap = get_diff_file_summary(&repo) - .into_iter() - .map(|s| (s.path, (s.added, s.removed, s.is_new))) - .collect(); + let got: std::collections::HashMap = + get_diff_file_summary(&repo) + .into_iter() + .map(|s| (s.path, (s.added, s.removed, s.is_new))) + .collect(); assert_eq!(got, want); } #[test] fn ci_tooltip_all_passed() { - let summary = CiCheckSummary { status: CiStatus::Success, passed: 4, failed: 0, pending: 0, total: 4, checks: Vec::new() }; + let summary = CiCheckSummary { + status: CiStatus::Success, + passed: 4, + failed: 0, + pending: 0, + total: 4, + checks: Vec::new(), + }; assert_eq!(summary.tooltip_text(), "4/4 checks passed"); } #[test] fn ci_tooltip_failure() { - let summary = CiCheckSummary { status: CiStatus::Failure, passed: 3, failed: 1, pending: 0, total: 4, checks: Vec::new() }; + let summary = CiCheckSummary { + status: CiStatus::Failure, + passed: 3, + failed: 1, + pending: 0, + total: 4, + checks: Vec::new(), + }; assert_eq!(summary.tooltip_text(), "1 failed, 3 passed of 4 checks"); } #[test] fn ci_tooltip_pending() { - let summary = CiCheckSummary { status: CiStatus::Pending, passed: 1, failed: 0, pending: 2, total: 3, checks: Vec::new() }; + let summary = CiCheckSummary { + status: CiStatus::Pending, + passed: 1, + failed: 0, + pending: 2, + total: 3, + checks: Vec::new(), + }; assert_eq!(summary.tooltip_text(), "2 pending, 1 passed of 3 checks"); } #[test] fn format_relative_time_just_now() { let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; assert_eq!(format_relative_time(now), "just now"); assert_eq!(format_relative_time(now - 30), "just now"); } @@ -374,7 +403,9 @@ mod tests { #[test] fn format_relative_time_minutes() { let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; assert_eq!(format_relative_time(now - 60), "1m ago"); assert_eq!(format_relative_time(now - 300), "5m ago"); assert_eq!(format_relative_time(now - 3599), "59m ago"); @@ -383,7 +414,9 @@ mod tests { #[test] fn format_relative_time_hours() { let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; assert_eq!(format_relative_time(now - 3600), "1h ago"); assert_eq!(format_relative_time(now - 7200), "2h ago"); } @@ -391,7 +424,9 @@ mod tests { #[test] fn format_relative_time_days() { let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; assert_eq!(format_relative_time(now - 86400), "1d ago"); assert_eq!(format_relative_time(now - 259200), "3d ago"); } @@ -407,16 +442,30 @@ mod tests { #[test] fn validate_git_ref_rejects_flag_like_refs() { - assert!(matches!(validate_git_ref("--upload-pack=evil"), Err(GitError::InvalidRef(_)))); - assert!(matches!(validate_git_ref("-b"), Err(GitError::InvalidRef(_)))); - assert!(matches!(validate_git_ref("--exec=malicious"), Err(GitError::InvalidRef(_)))); - assert!(matches!(validate_git_ref("-"), Err(GitError::InvalidRef(_)))); + assert!(matches!( + validate_git_ref("--upload-pack=evil"), + Err(GitError::InvalidRef(_)) + )); + assert!(matches!( + validate_git_ref("-b"), + Err(GitError::InvalidRef(_)) + )); + assert!(matches!( + validate_git_ref("--exec=malicious"), + Err(GitError::InvalidRef(_)) + )); + assert!(matches!( + validate_git_ref("-"), + Err(GitError::InvalidRef(_)) + )); } #[test] fn format_relative_time_weeks() { let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; assert_eq!(format_relative_time(now - 604800), "1w ago"); assert_eq!(format_relative_time(now - 1209600), "2w ago"); } diff --git a/crates/okena-git/src/repository/branch.rs b/crates/okena-git/src/repository/branch.rs index bb2f52a1d..e92a5de38 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}; @@ -19,7 +21,8 @@ pub fn list_branches(path: &Path) -> Vec { /// Get branches that don't have a worktree yet pub fn get_available_branches_for_worktree(path: &Path) -> Vec { let all_branches = list_branches(path); - let used_branches: std::collections::HashSet<_> = super::get_worktree_branches(path).into_iter().collect(); + let used_branches: std::collections::HashSet<_> = + super::get_worktree_branches(path).into_iter().collect(); all_branches .into_iter() @@ -29,24 +32,39 @@ 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() { - return Some(branch.to_string()); - } + && 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() + && 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() { + if repo + .find_reference(&format!("refs/heads/{}", candidate)) + .is_ok() + { return Some(candidate.to_string()); } } @@ -67,7 +85,8 @@ pub fn resolve_review_base(repo_path: &Path) -> Option { // Don't offer reviewing the default branch against itself. if let Some(current) = super::status::get_current_branch(repo_path) - && current == default { + && current == default + { return None; } @@ -126,8 +145,16 @@ pub fn rebase_onto(worktree_path: &Path, target_branch: &str) -> GitResult<()> { /// Stash uncommitted changes. pub fn stash_changes(path: &Path) -> GitResult<()> { let p = path_str(path)?; - let output = safe_output(command("git").args(["-C", p, "stash"]))?; - require_success(output) + let output = + safe_output(command("git").args(["-C", p, "stash", "push", "--include-untracked"]))?; + require_success(output)?; + if crate::repository::status::has_uncommitted_changes(path) { + return Err(GitError::UnsafeWorktree { + path: path.to_path_buf(), + reason: "checkout remains dirty after stash; refusing destructive removal".to_string(), + }); + } + Ok(()) } /// Pop the most recent stash entry. @@ -149,7 +176,8 @@ pub fn stage_file(repo_path: &Path, file_path: &str) -> GitResult<()> { /// Works for both modified and newly-added files. pub fn unstage_file(repo_path: &Path, file_path: &str) -> GitResult<()> { let p = path_str(repo_path)?; - let output = safe_output(command("git").args(["-C", p, "restore", "--staged", "--", file_path]))?; + let output = + safe_output(command("git").args(["-C", p, "restore", "--staged", "--", file_path]))?; require_success(output) } @@ -196,7 +224,8 @@ pub fn delete_local_branch(repo_path: &Path, branch: &str) -> GitResult<()> { pub fn delete_remote_branch(repo_path: &Path, branch: &str) -> GitResult<()> { crate::validate_git_ref(branch)?; let p = path_str(repo_path)?; - let output = safe_output(command("git").args(["-C", p, "push", "origin", "--delete", "--", branch]))?; + let output = + safe_output(command("git").args(["-C", p, "push", "origin", "--delete", "--", branch]))?; require_success(output) } @@ -210,7 +239,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, @@ -258,9 +287,10 @@ pub fn list_branches_classified(path: &Path) -> BranchList { } // Skip remote refs that have a corresponding local branch if let Some(stripped) = name.strip_prefix("origin/") - && local_names.contains(stripped) { - continue; - } + && local_names.contains(stripped) + { + continue; + } remote.push(name); } } @@ -355,6 +385,70 @@ mod tests { assert!(stash_changes(&path).is_err()); } + #[test] + fn stash_changes_preserves_untracked_file_from_linked_worktree() { + let (_tmp, repo) = init_temp_repo(); + let worktree_tmp = tempfile::tempdir().expect("create worktree tempdir"); + let worktree = worktree_tmp.path().join("feature"); + git_in( + &repo, + &[ + "worktree", + "add", + worktree.to_str().unwrap(), + "-b", + "feature", + ], + ); + std::fs::write(worktree.join("untracked.txt"), "preserve me\n").unwrap(); + + stash_changes(&worktree).expect("stash untracked file"); + + assert!(!worktree.join("untracked.txt").exists()); + assert!(!crate::repository::status::has_uncommitted_changes( + &worktree + )); + git_in(&repo, &["worktree", "remove", worktree.to_str().unwrap()]); + git_in(&repo, &["stash", "apply"]); + assert_eq!( + std::fs::read_to_string(repo.join("untracked.txt")).unwrap(), + "preserve me\n" + ); + } + + #[test] + fn stash_changes_rejects_dirty_submodule_checkout() { + let (_tmp, repo) = init_temp_repo(); + let submodule_tmp = tempfile::tempdir().expect("create submodule tempdir"); + let submodule = submodule_tmp.path(); + git_in(submodule, &["init", "-b", "main"]); + std::fs::write(submodule.join("sub.txt"), "base\n").unwrap(); + git_in(submodule, &["add", "sub.txt"]); + git_in(submodule, &["commit", "-m", "base"]); + git_in( + &repo, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + submodule.to_str().unwrap(), + "submodule", + ], + ); + git_in(&repo, &["commit", "-m", "add submodule"]); + std::fs::write(repo.join("submodule/sub.txt"), "dirty\n").unwrap(); + + let error = stash_changes(&repo).expect_err("dirty submodule must block removal"); + + assert!(error.to_string().contains("remains dirty after stash")); + assert_eq!( + std::fs::read_to_string(repo.join("submodule/sub.txt")).unwrap(), + "dirty\n" + ); + assert!(crate::repository::status::has_uncommitted_changes(&repo)); + } + #[test] fn stash_pop_returns_err_for_invalid_path() { let path = PathBuf::from("/nonexistent/path/that/does/not/exist"); @@ -417,8 +511,7 @@ mod tests { #[test] fn create_and_checkout_branch_switches_head() { let (_tmp, repo) = init_temp_repo(); - create_and_checkout_branch(&repo, "feat/header-redesign", None) - .expect("create branch"); + create_and_checkout_branch(&repo, "feat/header-redesign", None).expect("create branch"); assert_eq!( get_current_branch(&repo).as_deref(), Some("feat/header-redesign") @@ -452,6 +545,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(); @@ -481,11 +592,17 @@ mod tests { // Add an origin remote-tracking ref: now preferred over local. git_in(&repo, &["update-ref", "refs/remotes/origin/main", "HEAD"]); - assert_eq!(resolve_base_ref(&repo, "main").as_deref(), Some("origin/main")); + assert_eq!( + resolve_base_ref(&repo, "main").as_deref(), + Some("origin/main") + ); // Add an upstream remote-tracking ref: fork workflow — upstream wins. git_in(&repo, &["update-ref", "refs/remotes/upstream/main", "HEAD"]); - assert_eq!(resolve_base_ref(&repo, "main").as_deref(), Some("upstream/main")); + assert_eq!( + resolve_base_ref(&repo, "main").as_deref(), + Some("upstream/main") + ); } #[test] diff --git a/crates/okena-git/src/repository/ci.rs b/crates/okena-git/src/repository/ci.rs index ebdcb4154..05fc45436 100644 --- a/crates/okena-git/src/repository/ci.rs +++ b/crates/okena-git/src/repository/ci.rs @@ -17,6 +17,62 @@ use super::status::get_pushed_sha; /// elapses so one stuck repo can never wedge the git-status loop. const GH_TIMEOUT: Duration = Duration::from_secs(15); +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GhPullRequest { + number: u32, + title: String, + head_ref_name: String, +} + +/// List open pull requests that can be checked out as worktrees. +pub fn list_pull_requests( + path: &Path, + limit: usize, +) -> Result, String> { + let limit = limit.clamp(1, 100).to_string(); + let output = safe_output_with_timeout( + command("gh") + .args([ + "pr", + "list", + "--json", + "number,title,headRefName", + "--limit", + &limit, + ]) + .current_dir(path), + GH_TIMEOUT, + ) + .map_err(|error| format!("Failed to run GitHub CLI: {error}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + "GitHub CLI failed to list pull requests".to_string() + } else { + stderr + }); + } + + parse_pull_request_list(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_pull_request_list( + json: &str, +) -> Result, String> { + let pull_requests: Vec = serde_json::from_str(json) + .map_err(|error| format!("Failed to parse pull requests: {error}"))?; + Ok(pull_requests + .into_iter() + .map(|pull_request| okena_core::api::WorktreePullRequest { + number: pull_request.number, + title: pull_request.title, + branch: pull_request.head_ref_name, + }) + .collect()) +} + /// Whether the repo has any remote pointing at github.com (https or ssh). /// /// Used to gate `gh` PR/CI polling: repos with no GitHub remote (local-only, @@ -55,13 +111,24 @@ 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", + "pr", + "list", + "--head", + &branch, + "--state", + "all", + "--limit", + "1", + "--json", + "url,state,isDraft,number,baseRefName,headRefOid", + "--jq", + ".[0] | [.url, .state, .isDraft, .number, .baseRefName, .headRefOid] | @tsv", ]) .current_dir(path_str), GH_TIMEOUT, @@ -70,28 +137,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, @@ -107,7 +184,23 @@ fn parse_pr_list_tsv(line: &str) -> Option { .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(str::to_string); - Some(crate::PrInfo { url, state, number, base }) + 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 @@ -352,33 +445,62 @@ pub(crate) fn parse_branch_ci( } else { // Concatenated pages — split on top-level `}{` boundaries. for chunk in json.split("}{").map(|s| s.to_string()).collect::>() { - let normalized = if !chunk.starts_with('{') { format!("{{{chunk}") } else { chunk.clone() }; - let normalized = if !normalized.ends_with('}') { format!("{normalized}}}") } else { normalized }; + let normalized = if !chunk.starts_with('{') { + format!("{{{chunk}") + } else { + chunk.clone() + }; + let normalized = if !normalized.ends_with('}') { + format!("{normalized}}}") + } else { + normalized + }; if let Ok(v) = serde_json::from_str::(&normalized) - && let Some(arr) = v.get("check_runs").and_then(|x| x.as_array()) { - runs.extend(arr.iter().cloned()); - } + && let Some(arr) = v.get("check_runs").and_then(|x| x.as_array()) + { + runs.extend(arr.iter().cloned()); + } } } for run in runs { - let name = run.get("name").and_then(|v| v.as_str()).unwrap_or("(unnamed)").to_string(); + let name = run + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("(unnamed)") + .to_string(); let status_str = run.get("status").and_then(|v| v.as_str()).unwrap_or(""); let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or(""); let (status, is_skipped) = match (status_str, conclusion) { - (_, "success") => { passed += 1; (crate::CiStatus::Success, false) } - (_, "failure") | (_, "timed_out") | (_, "action_required") | (_, "cancelled") | (_, "stale") | (_, "startup_failure") => { + (_, "success") => { + passed += 1; + (crate::CiStatus::Success, false) + } + (_, "failure") + | (_, "timed_out") + | (_, "action_required") + | (_, "cancelled") + | (_, "stale") + | (_, "startup_failure") => { failed += 1; (crate::CiStatus::Failure, false) } (_, "skipped") | (_, "neutral") => (crate::CiStatus::Pending, true), - ("queued", _) | ("in_progress", _) | ("waiting", _) | ("pending", _) | ("requested", _) => { + ("queued", _) + | ("in_progress", _) + | ("waiting", _) + | ("pending", _) + | ("requested", _) => { pending += 1; (crate::CiStatus::Pending, false) } _ => continue, }; - let link = run.get("html_url").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from); + let link = run + .get("html_url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); let description = run .get("output") .and_then(|o| o.get("summary")) @@ -388,7 +510,11 @@ pub(crate) fn parse_branch_ci( let workflow = run .get("check_suite") .and_then(|s| s.get("workflow_id")) - .and_then(|_| run.get("app").and_then(|a| a.get("name")).and_then(|v| v.as_str())) + .and_then(|_| { + run.get("app") + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + }) .filter(|s| !s.is_empty()) .map(String::from); let elapsed_ms = compute_elapsed_ms( @@ -410,33 +536,55 @@ pub(crate) fn parse_branch_ci( if let Some(json) = statuses_json && let Ok(v) = serde_json::from_str::(json) - && let Some(arr) = v.get("statuses").and_then(|x| x.as_array()) { - for st in arr { - let name = st.get("context").and_then(|v| v.as_str()).unwrap_or("(unnamed)").to_string(); - let state = st.get("state").and_then(|v| v.as_str()).unwrap_or(""); - let (status, is_skipped) = match state { - "success" => { passed += 1; (crate::CiStatus::Success, false) } - "failure" | "error" => { failed += 1; (crate::CiStatus::Failure, false) } - "pending" => { pending += 1; (crate::CiStatus::Pending, false) } - _ => continue, - }; - let link = st.get("target_url").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from); - let description = st.get("description").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from); - let elapsed_ms = compute_elapsed_ms( - st.get("created_at").and_then(|v| v.as_str()), - st.get("updated_at").and_then(|v| v.as_str()), - ); - checks.push(crate::CiCheck { - name, - workflow: None, - status, - is_skipped, - link, - description, - elapsed_ms, - }); + && let Some(arr) = v.get("statuses").and_then(|x| x.as_array()) + { + for st in arr { + let name = st + .get("context") + .and_then(|v| v.as_str()) + .unwrap_or("(unnamed)") + .to_string(); + let state = st.get("state").and_then(|v| v.as_str()).unwrap_or(""); + let (status, is_skipped) = match state { + "success" => { + passed += 1; + (crate::CiStatus::Success, false) } - } + "failure" | "error" => { + failed += 1; + (crate::CiStatus::Failure, false) + } + "pending" => { + pending += 1; + (crate::CiStatus::Pending, false) + } + _ => continue, + }; + let link = st + .get("target_url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + let description = st + .get("description") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + let elapsed_ms = compute_elapsed_ms( + st.get("created_at").and_then(|v| v.as_str()), + st.get("updated_at").and_then(|v| v.as_str()), + ); + checks.push(crate::CiCheck { + name, + workflow: None, + status, + is_skipped, + link, + description, + elapsed_ms, + }); + } + } let total = passed + failed + pending; if total == 0 && checks.is_empty() { @@ -467,12 +615,27 @@ pub(crate) fn parse_branch_ci( #[cfg(test)] mod tests { + #[test] + fn parse_worktree_pull_requests() { + let json = r#"[{"number":12,"title":"Remote worktree","headRefName":"feature/remote"}]"#; + let pull_requests = super::parse_pull_request_list(json).expect("should parse"); + assert_eq!(pull_requests.len(), 1); + assert_eq!(pull_requests[0].number, 12); + assert_eq!(pull_requests[0].title, "Remote worktree"); + assert_eq!(pull_requests[0].branch, "feature/remote"); + } + + #[test] + fn malformed_worktree_pull_requests_are_rejected() { + assert!(super::parse_pull_request_list("not json").is_err()); + } + // ─── PR list TSV parsing 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 +645,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 ──────────────────────────────────────── @@ -658,7 +835,8 @@ mod tests { #[test] fn parse_branch_ci_combines_runs_and_statuses() { - let runs = r#"{"check_runs":[{"name":"Lint","status":"completed","conclusion":"success"}]}"#; + let runs = + r#"{"check_runs":[{"name":"Lint","status":"completed","conclusion":"success"}]}"#; let statuses = r#"{"statuses":[{"context":"vercel/deploy","state":"failure"}]}"#; let result = super::parse_branch_ci(Some(runs), Some(statuses)).unwrap(); assert_eq!(result.status, crate::CiStatus::Failure); diff --git a/crates/okena-git/src/repository/mod.rs b/crates/okena-git/src/repository/mod.rs index 7a49573c3..ada5a8fbd 100644 --- a/crates/okena-git/src/repository/mod.rs +++ b/crates/okena-git/src/repository/mod.rs @@ -22,26 +22,27 @@ pub mod status; pub mod worktree; pub use branch::{ - checkout_local_branch, checkout_remote_branch, create_and_checkout_branch, + BranchList, checkout_local_branch, checkout_remote_branch, create_and_checkout_branch, delete_local_branch, delete_remote_branch, discard_file_changes, fetch_all, get_available_branches_for_worktree, get_default_branch, list_branches, - list_branches_classified, merge_branch, push_branch, rebase_onto, - resolve_base_ref, resolve_review_base, stage_file, - stash_changes, stash_pop, unstage_file, BranchList, + list_branches_classified, merge_branch, push_branch, rebase_onto, resolve_base_ref, + resolve_review_base, stage_file, stash_changes, stash_pop, unstage_file, }; -pub use ci::{get_ci_checks, get_pr_info, has_github_remote}; +pub use ci::{get_ci_checks, get_pr_info, has_github_remote, list_pull_requests}; pub use paths::{ - compute_target_paths, get_repo_root, normalize_path, project_path_in_worktree, - resolve_git_root_and_subdir, + compute_target_paths, get_repo_common_dir, get_repo_root, normalize_path, + project_path_in_worktree, resolve_git_root_and_subdir, }; +pub(crate) use status::worktree_diff; pub use status::{ - apply_pr_base, count_ahead_behind, count_ahead_behind_vs, count_unpushed_commits, - get_current_branch, get_head_sha, get_status, has_uncommitted_changes, StatusFetch, + HeadSnapshot, StatusFetch, apply_pr_base, count_ahead_behind, count_ahead_behind_vs, + count_unpushed_commits, get_current_branch, get_head_sha, get_head_snapshot, get_status, + has_uncommitted_changes, }; -pub(crate) use status::worktree_diff; pub use worktree::{ - create_worktree, create_worktree_with_start_point, list_git_worktrees, remove_worktree, - remove_worktree_fast, + VerifiedWorktree, create_worktree, create_worktree_with_start_point, fetch_and_fast_forward, + list_git_worktrees, list_linked_worktree_paths, move_worktree, remove_worktree, + remove_worktree_fast, verify_linked_worktree_fresh, }; /// Run a git command and return `Ok(())` if it exits successfully, @@ -60,18 +61,25 @@ pub(crate) fn require_success(output: std::process::Output) -> GitResult<()> { /// Convert a `Path` to a UTF-8 `&str`, returning `GitError::InvalidPath` on failure. pub(crate) fn path_str(path: &Path) -> GitResult<&str> { - path.to_str().ok_or_else(|| GitError::InvalidPath(path.to_path_buf())) + path.to_str() + .ok_or_else(|| GitError::InvalidPath(path.to_path_buf())) } /// Get branches that are already checked out in worktrees (main + linked). /// Detached worktrees are skipped. pub(crate) fn get_worktree_branches(path: &Path) -> Vec { - worktree::list_git_worktrees(path).into_iter().map(|(_, b)| b).collect() + worktree::list_git_worktrees(path) + .into_iter() + .map(|(_, b)| b) + .collect() } /// Read the short branch name from a repo's HEAD, or `None` if detached. pub(crate) fn head_branch_short(repo: &gix::Repository) -> Option { - repo.head_name().ok().flatten().map(|n| n.shorten().to_string()) + repo.head_name() + .ok() + .flatten() + .map(|n| n.shorten().to_string()) } /// Shared test helpers used by submodule unit tests. @@ -113,6 +121,11 @@ pub(crate) mod test_support { .env("GIT_COMMITTER_EMAIL", "test@test") .output() .expect("git command failed"); - assert!(status.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&status.stderr)); + assert!( + status.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&status.stderr) + ); } } diff --git a/crates/okena-git/src/repository/paths.rs b/crates/okena-git/src/repository/paths.rs index 800ca385f..18f6b478f 100644 --- a/crates/okena-git/src/repository/paths.rs +++ b/crates/okena-git/src/repository/paths.rs @@ -12,12 +12,21 @@ pub fn get_repo_root(path: &Path) -> Option { repo.workdir().map(|p| p.to_path_buf()) } +/// Return the shared Git directory for a main or linked worktree. +pub fn get_repo_common_dir(path: &Path) -> Option { + let repo = crate::gix_helpers::open(path)?; + let common_dir = repo.common_dir(); + Some(std::fs::canonicalize(common_dir).unwrap_or_else(|_| normalize_path(common_dir))) +} + /// Normalize a path by resolving `.` and `..` components without filesystem access. pub fn normalize_path(path: &Path) -> PathBuf { let mut result = PathBuf::new(); for component in path.components() { match component { - Component::ParentDir => { result.pop(); } + Component::ParentDir => { + result.pop(); + } Component::CurDir => {} other => result.push(other), } @@ -32,11 +41,11 @@ pub fn normalize_path(path: &Path) -> PathBuf { /// Both paths are normalized before `strip_prefix` to handle symlinks, /// trailing slashes, and `..` components. pub fn resolve_git_root_and_subdir(project_path: &Path) -> (PathBuf, PathBuf) { - let git_root = get_repo_root(project_path) - .unwrap_or_else(|| project_path.to_path_buf()); + let git_root = get_repo_root(project_path).unwrap_or_else(|| project_path.to_path_buf()); let norm_project = normalize_path(project_path); let norm_root = normalize_path(&git_root); - let subdir = norm_project.strip_prefix(&norm_root) + let subdir = norm_project + .strip_prefix(&norm_root) .unwrap_or(Path::new("")) .to_path_buf(); (git_root, subdir) @@ -63,7 +72,8 @@ pub fn compute_target_paths( template: &str, branch: &str, ) -> (String, String) { - let repo_name = git_root.file_name() + let repo_name = git_root + .file_name() .and_then(|n| n.to_str()) .unwrap_or("repo"); let safe_branch = branch.replace('/', "-"); @@ -97,6 +107,7 @@ mod tests { fn get_repo_root_returns_none_for_invalid_path() { let path = PathBuf::from("/nonexistent/path/that/does/not/exist"); assert!(get_repo_root(&path).is_none()); + assert!(get_repo_common_dir(&path).is_none()); } /// Compare computed paths as `Path` objects for cross-platform correctness @@ -108,7 +119,8 @@ mod tests { fn target_path_simple_repo() { let git_root = PathBuf::from("/projects/myrepo"); let subdir = Path::new(""); - let (wt, proj) = compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "feature"); + let (wt, proj) = + compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "feature"); let expected = PathBuf::from("/projects").join("myrepo-wt").join("feature"); assert_paths_eq(&wt, &expected); assert_paths_eq(&proj, &expected); @@ -118,8 +130,11 @@ mod tests { fn target_path_monorepo() { let git_root = PathBuf::from("/projects/monorepo"); let subdir = Path::new("app-in-monorepo"); - let (wt, proj) = compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "feature"); - let expected_wt = PathBuf::from("/projects").join("monorepo-wt").join("feature"); + let (wt, proj) = + compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "feature"); + let expected_wt = PathBuf::from("/projects") + .join("monorepo-wt") + .join("feature"); assert_paths_eq(&wt, &expected_wt); assert_paths_eq(&proj, &expected_wt.join("app-in-monorepo")); } @@ -128,8 +143,11 @@ mod tests { fn target_path_nested_monorepo_subdir() { let git_root = PathBuf::from("/projects/monorepo"); let subdir = Path::new("packages/app"); - let (wt, proj) = compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "fix-bug"); - let expected_wt = PathBuf::from("/projects").join("monorepo-wt").join("fix-bug"); + let (wt, proj) = + compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "fix-bug"); + let expected_wt = PathBuf::from("/projects") + .join("monorepo-wt") + .join("fix-bug"); assert_paths_eq(&wt, &expected_wt); assert_paths_eq(&proj, &expected_wt.join("packages").join("app")); } @@ -138,8 +156,12 @@ mod tests { fn target_path_absolute_template() { let git_root = PathBuf::from("/projects/monorepo"); let subdir = Path::new("app"); - let (wt, proj) = compute_target_paths(&git_root, subdir, "/tmp/worktrees/{repo}/{branch}", "main"); - let expected_wt = PathBuf::from("/tmp").join("worktrees").join("monorepo").join("main"); + let (wt, proj) = + compute_target_paths(&git_root, subdir, "/tmp/worktrees/{repo}/{branch}", "main"); + let expected_wt = PathBuf::from("/tmp") + .join("worktrees") + .join("monorepo") + .join("main"); assert_paths_eq(&wt, &expected_wt); assert_paths_eq(&proj, &expected_wt.join("app")); } @@ -148,8 +170,15 @@ mod tests { fn target_path_branch_with_slashes() { let git_root = PathBuf::from("/projects/repo"); let subdir = Path::new(""); - let (wt, proj) = compute_target_paths(&git_root, subdir, "../{repo}-wt/{branch}", "feature/my-branch"); - let expected = PathBuf::from("/projects").join("repo-wt").join("feature-my-branch"); + let (wt, proj) = compute_target_paths( + &git_root, + subdir, + "../{repo}-wt/{branch}", + "feature/my-branch", + ); + let expected = PathBuf::from("/projects") + .join("repo-wt") + .join("feature-my-branch"); assert_paths_eq(&wt, &expected); assert_paths_eq(&proj, &expected); } @@ -175,7 +204,13 @@ mod tests { let wt_path = wt_tmp.path().join("my-worktree"); git_in( &repo, - &["worktree", "add", wt_path.to_str().unwrap(), "-b", "wt-branch"], + &[ + "worktree", + "add", + wt_path.to_str().unwrap(), + "-b", + "wt-branch", + ], ); // Create a nested subdirectory inside the worktree (monorepo subproject) @@ -185,6 +220,28 @@ mod tests { // get_repo_root from the nested subdir should return the worktree root, // NOT the main repo — this is the path `git worktree remove` needs. let root = get_repo_root(&nested).expect("should resolve worktree root"); - assert_eq!(root.canonicalize().unwrap(), wt_path.canonicalize().unwrap()); + assert_eq!( + root.canonicalize().unwrap(), + wt_path.canonicalize().unwrap() + ); + } + + #[test] + fn linked_worktree_shares_common_dir_with_main_repository() { + let (_tmp, repo) = init_temp_repo(); + let worktree_parent = tempfile::tempdir().unwrap(); + let worktree = worktree_parent.path().join("linked"); + git_in( + &repo, + &[ + "worktree", + "add", + worktree.to_str().unwrap(), + "-b", + "linked", + ], + ); + + assert_eq!(get_repo_common_dir(&repo), get_repo_common_dir(&worktree)); } } diff --git a/crates/okena-git/src/repository/status.rs b/crates/okena-git/src/repository/status.rs index 8f03a7499..1f77a5599 100644 --- a/crates/okena-git/src/repository/status.rs +++ b/crates/okena-git/src/repository/status.rs @@ -4,6 +4,24 @@ use std::path::Path; use crate::GitStatus; +/// Cheap identity of the commit currently checked out in a worktree. +/// +/// Unlike [`GitStatus`], reading this does not inspect the index or walk the +/// working tree, so callers can poll it frequently and use changes as a signal +/// to refresh the full status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HeadSnapshot { + reference: Option, + commit_id: Option, +} + +impl HeadSnapshot { + /// Whether HEAD switched between symbolic refs or attached/detached state. + pub fn reference_changed(&self, previous: &Self) -> bool { + self.reference != previous.reference + } +} + /// Three-state result of a fresh git status fetch. /// /// Distinguishing "not a repo" from "transient failure" lets the polling @@ -100,6 +118,16 @@ pub fn get_current_branch(path: &Path) -> Option { Some(id.shorten().ok()?.to_string()) } +/// Read the symbolic HEAD target and resolved commit without walking the worktree. +pub fn get_head_snapshot(path: &Path) -> Option { + let repo = crate::gix_helpers::open(path)?; + let head = repo.head().ok()?; + Some(HeadSnapshot { + reference: head.referent_name().map(ToString::to_string), + commit_id: head.id().map(|id| id.detach()), + }) +} + /// Get the full 40-character SHA of HEAD, or `None` if not a git repo or HEAD /// has no commits yet. pub fn get_head_sha(path: &Path) -> Option { @@ -118,12 +146,17 @@ pub fn get_head_sha(path: &Path) -> Option { pub fn get_pushed_sha(path: &Path) -> Option { let repo = crate::gix_helpers::open(path)?; let branch = super::head_branch_short(&repo)?; - let head_ref = repo.find_reference(&format!("refs/heads/{}", branch)).ok()?; + let head_ref = repo + .find_reference(&format!("refs/heads/{}", branch)) + .ok()?; let head_full: gix::refs::FullName = head_ref.name().into(); let upstream_name = repo .branch_remote_tracking_ref_name(head_full.as_ref(), gix::remote::Direction::Fetch)? .ok()?; - let id = repo.rev_parse_single(upstream_name.as_bstr()).ok()?.detach(); + let id = repo + .rev_parse_single(upstream_name.as_bstr()) + .ok()? + .detach(); Some(id.to_hex().to_string()) } @@ -173,7 +206,11 @@ pub(crate) fn worktree_diff(path: &Path) -> Option { .ok() .map(|p| { let s = p.to_string_lossy().to_string(); - if s.is_empty() { String::new() } else { format!("{}/", s) } + if s.is_empty() { + String::new() + } else { + format!("{}/", s) + } }) .unwrap_or_default(); @@ -192,7 +229,8 @@ pub(crate) fn worktree_diff(path: &Path) -> Option { // HEAD-blob vs worktree-file directly, so the staging split doesn't matter. // Untracked files surface as `DirectoryContents` entries — collect those in // the same pass instead of walking the worktree a second time. - let mut changed: std::collections::HashSet = std::collections::HashSet::new(); + let mut changed: std::collections::HashSet = + std::collections::HashSet::new(); let mut untracked: Vec = Vec::new(); for item in iter { let item = item.ok()?; @@ -270,7 +308,9 @@ fn head_blob_bytes(head_tree: Option<&gix::Tree<'_>>, rela_path: &gix::bstr::BSt }; let path = gix::path::from_bstr(rela_path); match tree.lookup_entry_by_path(path.as_ref()) { - Ok(Some(entry)) if entry.mode().is_blob() => entry.object().map(|o| o.data.clone()).unwrap_or_default(), + Ok(Some(entry)) if entry.mode().is_blob() => { + entry.object().map(|o| o.data.clone()).unwrap_or_default() + } _ => Vec::new(), } } @@ -279,11 +319,16 @@ fn head_blob_bytes(head_tree: Option<&gix::Tree<'_>>, rela_path: &gix::bstr::BSt /// in via gix's `blame` feature), with Git's slider heuristics so hunk /// placement — and therefore the counts — match `git diff --numstat`. fn diff_line_counts(before: &[u8], after: &[u8]) -> (usize, usize) { - use gix::diff::blob::{diff_with_slider_heuristics, sources::byte_lines, Algorithm, InternedInput}; + use gix::diff::blob::{ + Algorithm, InternedInput, diff_with_slider_heuristics, sources::byte_lines, + }; let input = InternedInput::new(byte_lines(before), byte_lines(after)); let diff = diff_with_slider_heuristics(Algorithm::Histogram, &input); - (diff.count_additions() as usize, diff.count_removals() as usize) + ( + diff.count_additions() as usize, + diff.count_removals() as usize, + ) } /// Git treats a blob as binary if a NUL byte appears near the start; such files @@ -305,14 +350,19 @@ pub fn count_ahead_behind(path: &Path) -> Option<(usize, usize)> { // Resolve the upstream tracking ref via gix; `None` (skip) for branches // without one — the common local-only branch case. - let head_ref = repo.find_reference(&format!("refs/heads/{}", branch)).ok()?; + let head_ref = repo + .find_reference(&format!("refs/heads/{}", branch)) + .ok()?; let head_full: gix::refs::FullName = head_ref.name().into(); let upstream_name = repo .branch_remote_tracking_ref_name(head_full.as_ref(), gix::remote::Direction::Fetch)? .ok()?; // Resolve both tips to commit ids. - let upstream_id = repo.rev_parse_single(upstream_name.as_bstr()).ok()?.detach(); + let upstream_id = repo + .rev_parse_single(upstream_name.as_bstr()) + .ok()? + .detach(); let head_id = repo.head_id().ok()?.detach(); // ahead = commits reachable from HEAD but not upstream; behind = the reverse. @@ -421,11 +471,14 @@ mod tests { let tmp = tempfile::tempdir().expect("create temp dir"); match get_status(tmp.path()) { StatusFetch::NotRepo => {} - other => panic!("expected NotRepo for non-git path, got {:?}", match other { - StatusFetch::Status(_) => "Status", - StatusFetch::NotRepo => "NotRepo", - StatusFetch::Transient => "Transient", - }), + other => panic!( + "expected NotRepo for non-git path, got {:?}", + match other { + StatusFetch::Status(_) => "Status", + StatusFetch::NotRepo => "NotRepo", + StatusFetch::Transient => "Transient", + } + ), } } @@ -449,11 +502,14 @@ mod tests { std::fs::write(repo.join("new.txt"), "line1\nline2\nline3\n").unwrap(); match get_status(&repo) { StatusFetch::Status(s) => assert_eq!(s.lines_added, 3), - other => panic!("expected Status with 3 untracked lines, got {}", match other { - StatusFetch::Status(_) => "Status", - StatusFetch::NotRepo => "NotRepo", - StatusFetch::Transient => "Transient", - }), + other => panic!( + "expected Status with 3 untracked lines, got {}", + match other { + StatusFetch::Status(_) => "Status", + StatusFetch::NotRepo => "NotRepo", + StatusFetch::Transient => "Transient", + } + ), } } @@ -535,7 +591,10 @@ mod tests { let remote_tmp = tempfile::tempdir().expect("create remote tempdir"); let remote_path = remote_tmp.path().join("origin.git"); git_in(&repo, &["init", "--bare", remote_path.to_str().unwrap()]); - git_in(&repo, &["remote", "add", "origin", remote_path.to_str().unwrap()]); + git_in( + &repo, + &["remote", "add", "origin", remote_path.to_str().unwrap()], + ); git_in(&repo, &["push", "-u", "origin", "main"]); // No unpushed commits yet. @@ -547,7 +606,13 @@ mod tests { git_in(&repo, &["add", "."]); git_in( &repo, - &["-c", "commit.gpgsign=false", "commit", "-m", &format!("c{}", i)], + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + &format!("c{}", i), + ], ); } @@ -571,7 +636,13 @@ mod tests { git_in(&repo, &["add", "."]); git_in( &repo, - &["-c", "commit.gpgsign=false", "commit", "-m", &format!("f{}", i)], + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + &format!("f{}", i), + ], ); } // Two commits ahead of main, none behind — independent of any upstream. @@ -592,7 +663,58 @@ mod tests { let branch = get_current_branch(&repo).expect("should return short hash"); // Short hash from gix has at least 7 chars and is hex assert!(branch.len() >= 7, "expected short hash, got {:?}", branch); - assert!(branch.chars().all(|c| c.is_ascii_hexdigit()), "expected hex hash, got {:?}", branch); + assert!( + branch.chars().all(|c| c.is_ascii_hexdigit()), + "expected hex hash, got {:?}", + branch + ); + } + + #[test] + fn head_snapshot_changes_only_when_head_moves() { + let (_tmp, repo) = init_temp_repo(); + let initial = get_head_snapshot(&repo).expect("read initial HEAD"); + + std::fs::write(repo.join("file.txt"), "modified").unwrap(); + assert_eq!(get_head_snapshot(&repo).as_ref(), Some(&initial)); + + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "change"], + ); + let committed = get_head_snapshot(&repo).expect("read committed HEAD"); + assert_ne!(committed, initial); + assert!(!committed.reference_changed(&initial)); + + git_in(&repo, &["checkout", "-b", "feature"]); + let switched = get_head_snapshot(&repo).expect("read switched HEAD"); + assert_ne!(switched, committed); + assert!(switched.reference_changed(&committed)); + } + + #[test] + fn head_snapshot_is_specific_to_linked_worktree() { + let (_tmp, repo) = init_temp_repo(); + let linked_parent = tempfile::tempdir().expect("create linked worktree parent"); + let linked = linked_parent.path().join("feature"); + git_in( + &repo, + &["worktree", "add", "-b", "feature", linked.to_str().unwrap()], + ); + + let main = get_head_snapshot(&repo).expect("read main worktree HEAD"); + let feature = get_head_snapshot(&linked).expect("read linked worktree HEAD"); + assert_ne!(feature, main); + + std::fs::write(linked.join("file.txt"), "linked change").unwrap(); + git_in(&linked, &["add", "file.txt"]); + git_in( + &linked, + &["-c", "commit.gpgsign=false", "commit", "-m", "linked"], + ); + assert_ne!(get_head_snapshot(&linked).as_ref(), Some(&feature)); + assert_eq!(get_head_snapshot(&repo).as_ref(), Some(&main)); } #[test] @@ -646,7 +768,10 @@ mod tests { let (_tmp, repo) = init_temp_repo(); std::fs::write(repo.join("orig.txt"), "l1\nl2\nl3\n").unwrap(); git_in(&repo, &["add", "."]); - git_in(&repo, &["-c", "commit.gpgsign=false", "commit", "-m", "add orig"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "add orig"], + ); git_in(&repo, &["mv", "orig.txt", "renamed.txt"]); // --no-renames semantics: 3 lines removed from orig + 3 added to renamed. assert_eq!(get_diff_stats(&repo), Some((3, 3))); @@ -661,7 +786,10 @@ mod tests { std::fs::write(repo.join("keep.txt"), "1\n2\n3\n4\n5\n").unwrap(); std::fs::write(repo.join("doomed.txt"), "x\ny\n").unwrap(); git_in(&repo, &["add", "."]); - git_in(&repo, &["-c", "commit.gpgsign=false", "commit", "-m", "seed"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "seed"], + ); std::fs::write(repo.join("keep.txt"), "1\n2\nTWO-AND-A-HALF\n3\n4\n5\n6\n").unwrap(); std::fs::remove_file(repo.join("doomed.txt")).unwrap(); std::fs::write(repo.join("staged-new.txt"), "p\nq\n").unwrap(); @@ -670,7 +798,16 @@ mod tests { // CLI baseline: tracked numstat (HEAD) + untracked line counts. let out = std::process::Command::new("git") - .args(["-C", repo.to_str().unwrap(), "diff", "--numstat", "--no-renames", "--no-color", "--no-ext-diff", "HEAD"]) + .args([ + "-C", + repo.to_str().unwrap(), + "diff", + "--numstat", + "--no-renames", + "--no-color", + "--no-ext-diff", + "HEAD", + ]) .output() .unwrap(); let (mut cli_add, mut cli_rem) = (0usize, 0usize); @@ -682,11 +819,20 @@ mod tests { } } let untracked = std::process::Command::new("git") - .args(["-C", repo.to_str().unwrap(), "ls-files", "--others", "--exclude-standard"]) + .args([ + "-C", + repo.to_str().unwrap(), + "ls-files", + "--others", + "--exclude-standard", + ]) .output() .unwrap(); for f in String::from_utf8_lossy(&untracked.stdout).lines() { - cli_add += std::fs::read_to_string(repo.join(f)).unwrap().lines().count(); + cli_add += std::fs::read_to_string(repo.join(f)) + .unwrap() + .lines() + .count(); } assert_eq!(get_diff_stats(&repo), Some((cli_add, cli_rem))); @@ -733,4 +879,3 @@ mod tests { assert_eq!((s.ahead, s.behind), (Some(2), Some(0))); } } - diff --git a/crates/okena-git/src/repository/worktree.rs b/crates/okena-git/src/repository/worktree.rs index ec12bc6c7..b3567e589 100644 --- a/crates/okena-git/src/repository/worktree.rs +++ b/crates/okena-git/src/repository/worktree.rs @@ -1,83 +1,226 @@ -//! Worktree operations: create / remove / list / clean stale dirs. +//! Worktree operations: create / remove / list. use std::path::{Path, PathBuf}; use okena_core::process::{command, safe_output}; use super::branch::get_default_branch; -use super::paths::normalize_path; use super::{head_branch_short, path_str, require_success}; use crate::error::{GitError, GitResult}; -/// Collect the work-directory paths of every active worktree (main + linked), -/// including detached ones — unlike `list_git_worktrees`, which skips detached -/// heads. Best-effort: returns an empty vec if the repo can't be opened. -fn active_worktree_paths(repo_path: &Path) -> Vec { - let Some(repo) = crate::gix_helpers::open(repo_path) else { - return Vec::new(); - }; +#[derive(Clone, Debug, PartialEq, Eq)] +enum FilesystemObjectIdentity { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(windows)] + Windows { volume: u32, file: u64 }, + #[cfg(not(any(unix, windows)))] + Canonical(PathBuf), +} - let mut paths = Vec::new(); +fn filesystem_object_identity(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; - // Main worktree, resolved via common_dir so this works from a linked worktree. - if let Ok(main_repo) = gix::open(repo.common_dir()) - && let Some(workdir) = main_repo.workdir() { - paths.push(workdir.to_path_buf()); - } + let metadata = std::fs::metadata(path).ok()?; + Some(FilesystemObjectIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; - // Linked worktrees from .git/worktrees/*; base() resolves even if the - // worktree directory is currently inaccessible. - if let Ok(worktrees) = repo.worktrees() { - for proxy in worktrees { - if let Ok(base) = proxy.base() { - paths.push(base); - } + let metadata = std::fs::metadata(path).ok()?; + match (metadata.volume_serial_number(), metadata.file_index()) { + (Some(volume), Some(file)) => Some(FilesystemObjectIdentity::Windows { volume, file }), + _ => None, } } + #[cfg(not(any(unix, windows)))] + { + std::fs::canonicalize(path) + .ok() + .map(FilesystemObjectIdentity::Canonical) + } +} - paths +/// Freshly verified ownership of one linked worktree checkout. +/// +/// This token is intentionally produced without the status-path repository +/// cache and carries the checkout directory's filesystem identity. Destructive +/// operations revalidate both before touching the path. +#[derive(Clone, Debug)] +pub struct VerifiedWorktree { + parent_path: PathBuf, + checkout_path: PathBuf, + identity: FilesystemObjectIdentity, } -/// If `target_path` exists but is NOT a currently registered worktree, remove -/// the stale directory and prune worktree metadata so a fresh `worktree add` -/// can succeed. Returns an error only when the path is still an active worktree. -fn clean_stale_worktree_dir(repo_path: &Path, target_path: &Path) -> GitResult<()> { - if !target_path.exists() { - return Ok(()); +impl VerifiedWorktree { + pub fn checkout_path(&self) -> &Path { + &self.checkout_path } - // Ask gix which paths are active worktrees (main + linked, incl. detached). - let repo_str = path_str(repo_path)?; - let target_normalized = normalize_path(target_path); - if active_worktree_paths(repo_path) - .iter() - .any(|p| normalize_path(p) == target_normalized) - { - return Err(GitError::WorktreeExists { - path: target_path.to_path_buf(), - }); + pub fn parent_path(&self) -> &Path { + &self.parent_path } +} - // Not an active worktree — remove the stale directory and prune metadata - log::info!( - "Removing stale worktree directory: {}", - target_path.display() - ); - std::fs::remove_dir_all(target_path) - .map_err(|e| GitError::RemoveFailed { - path: target_path.to_path_buf(), - source: e, +fn unsafe_worktree(path: &Path, reason: impl Into) -> GitError { + GitError::UnsafeWorktree { + path: path.to_path_buf(), + reason: reason.into(), + } +} + +fn fresh_repo(path: &Path) -> GitResult { + gix::ThreadSafeRepository::discover(path) + .map(|repository| repository.to_thread_local()) + .map_err(|error| unsafe_worktree(path, format!("repository discovery failed: {error}"))) +} + +/// Verify from fresh filesystem and Git metadata that `checkout_path` is a +/// linked worktree registered by `parent_path`. +pub fn verify_linked_worktree_fresh( + parent_path: &Path, + checkout_path: &Path, +) -> GitResult { + let parent_repo = fresh_repo(parent_path)?; + let checkout_repo = fresh_repo(checkout_path)?; + let checkout_root = checkout_repo + .workdir() + .ok_or_else(|| unsafe_worktree(checkout_path, "checkout repository has no work directory"))? + .to_path_buf(); + let identity = filesystem_object_identity(&checkout_root).ok_or_else(|| { + unsafe_worktree( + &checkout_root, + "checkout filesystem identity is unavailable", + ) + })?; + let parent_common = filesystem_object_identity(parent_repo.common_dir()).ok_or_else(|| { + unsafe_worktree(parent_path, "parent Git directory identity is unavailable") + })?; + let checkout_common = + filesystem_object_identity(checkout_repo.common_dir()).ok_or_else(|| { + unsafe_worktree( + checkout_path, + "checkout Git directory identity is unavailable", + ) })?; + if parent_common != checkout_common { + return Err(unsafe_worktree( + checkout_path, + "checkout does not belong to the parent repository", + )); + } + + let registered = parent_repo + .worktrees() + .map_err(|error| { + unsafe_worktree(parent_path, format!("linked worktree list failed: {error}")) + })? + .into_iter() + .filter_map(|proxy| proxy.base().ok()) + .filter_map(|path| filesystem_object_identity(&path)) + .any(|registered| registered == identity); + if !registered { + return Err(unsafe_worktree( + checkout_path, + "checkout is not registered as a linked worktree", + )); + } - let _ = safe_output(command("git").args(["-C", repo_str, "worktree", "prune"])); + Ok(VerifiedWorktree { + parent_path: parent_path.to_path_buf(), + checkout_path: checkout_root, + identity, + }) +} +fn revalidate_verified_worktree(verified: &VerifiedWorktree) -> GitResult<()> { + let current = verify_linked_worktree_fresh(&verified.parent_path, &verified.checkout_path)?; + if current.identity != verified.identity { + return Err(unsafe_worktree( + &verified.checkout_path, + "checkout directory identity changed", + )); + } + Ok(()) +} + +/// Remove only a directory that is absent, empty, or contains regular +/// `.DS_Store` files. This handles Finder metadata recreated after the verified +/// checkout was quarantined without ever deleting a replacement directory. +fn remove_benign_residual(path: &Path) -> std::io::Result { + let entries = match std::fs::read_dir(path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true), + Err(error) => return Err(error), + }; + + let mut ds_store_files = Vec::new(); + for entry in entries { + let entry = entry?; + let file_type = entry.file_type()?; + if entry.file_name() != ".DS_Store" || !file_type.is_file() || file_type.is_symlink() { + return Ok(false); + } + ds_store_files.push(entry.path()); + } + for ds_store in ds_store_files { + match std::fs::remove_file(ds_store) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + match std::fs::remove_dir(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + // A concurrent Finder write is harmless only if a subsequent inspection + // again proves the residual is exclusively benign metadata. + Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => Ok(false), + Err(error) => Err(error), + } +} + +fn cleanup_benign_residual(path: &Path) -> GitResult<()> { + match remove_benign_residual(path) { + Ok(true) => Ok(()), + Ok(false) => Err(unsafe_worktree( + path, + "checkout path was recreated with non-benign content; preserved it", + )), + Err(source) => Err(GitError::RemoveFailed { + path: path.to_path_buf(), + source, + }), + } +} + +/// Refuse every existing target. An unregistered directory is not proof that +/// Okena owns its contents, so create must never remove it speculatively. +fn require_absent_worktree_target(target_path: &Path) -> GitResult<()> { + if target_path.exists() { + return Err(GitError::WorktreeExists { + path: target_path.to_path_buf(), + }); + } Ok(()) } /// Create a new worktree. -pub fn create_worktree(repo_path: &Path, branch: &str, target_path: &Path, create_branch: bool) -> GitResult<()> { +pub fn create_worktree( + repo_path: &Path, + branch: &str, + target_path: &Path, + create_branch: bool, +) -> GitResult<()> { crate::validate_git_ref(branch)?; - clean_stale_worktree_dir(repo_path, target_path)?; + require_absent_worktree_target(target_path)?; let repo_str = path_str(repo_path)?; let target_str = path_str(target_path)?; @@ -93,7 +236,13 @@ pub fn create_worktree(repo_path: &Path, branch: &str, target_path: &Path, creat args.push(branch); args.push(target_str); if let Some(default_branch) = get_default_branch(repo_path) { - let _ = safe_output(command("git").args(["-C", repo_str, "fetch", "origin", &default_branch])); + let _ = safe_output(command("git").args([ + "-C", + repo_str, + "fetch", + "origin", + &default_branch, + ])); start_point = format!("origin/{}", default_branch); args.push(&start_point); } @@ -119,7 +268,7 @@ pub fn create_worktree_with_start_point( if let Some(sb) = start_branch { crate::validate_git_ref(sb)?; } - clean_stale_worktree_dir(repo_path, target_path)?; + require_absent_worktree_target(target_path)?; let repo_str = path_str(repo_path)?; let target_str = path_str(target_path)?; @@ -136,9 +285,49 @@ pub fn create_worktree_with_start_point( require_success(output) } +/// Best-effort freshen a just-created worktree to the latest remote default: +/// `git fetch origin `, then fast-forward the worktree's branch +/// to `origin/` with `merge --ff-only`. +/// +/// This lets the worktree window appear immediately (created from the LOCAL +/// `origin/` with no blocking fetch) and then catch up to the true +/// remote tip in the background. `--ff-only` NEVER rewrites local work: if the +/// branch has diverged (a commit was made) or the tree is dirty in a conflicting +/// way, git declines and this is a safe no-op. All failures are non-fatal (the +/// worktree simply stays on the local base) — logged, not returned. +pub fn fetch_and_fast_forward(repo_path: &Path, worktree_path: &Path, default_branch: &str) { + let (Ok(repo_str), Ok(wt_str)) = (path_str(repo_path), path_str(worktree_path)) else { + return; + }; + match safe_output(command("git").args(["-C", repo_str, "fetch", "origin", default_branch])) { + Ok(out) if out.status.success() => {} + Ok(out) => { + log::warn!( + "worktree freshen: fetch origin {default_branch} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + return; + } + Err(e) => { + log::warn!("worktree freshen: fetch origin {default_branch} failed: {e}"); + return; + } + } + let start = format!("origin/{}", default_branch); + match safe_output(command("git").args(["-C", wt_str, "merge", "--ff-only", &start])) { + Ok(out) if out.status.success() => {} + Ok(out) => log::info!( + "worktree freshen: fast-forward to {start} skipped (branch diverged or dirty): {}", + String::from_utf8_lossy(&out.stderr).trim() + ), + Err(e) => log::warn!("worktree freshen: fast-forward merge failed: {e}"), + } +} + /// Remove a worktree. -pub fn remove_worktree(worktree_path: &Path, force: bool) -> GitResult<()> { - let wt_str = path_str(worktree_path)?; +pub fn remove_worktree(verified: &VerifiedWorktree, force: bool) -> GitResult<()> { + revalidate_verified_worktree(verified)?; + let wt_str = path_str(&verified.checkout_path)?; let mut args = vec!["-C", wt_str, "worktree", "remove"]; @@ -152,26 +341,88 @@ pub fn remove_worktree(worktree_path: &Path, force: bool) -> GitResult<()> { require_success(output) } -/// Fast worktree removal: delete the directory and prune stale worktree metadata. +/// Fast worktree removal: quarantine the verified directory, delete it, and +/// prune stale worktree metadata. /// Much faster than `git worktree remove` which does expensive status checks. /// Only safe when the caller has already handled dirty state (stash/discard). /// /// Note: `git worktree prune` removes ALL stale entries (not just the one we deleted). /// This is safe because prune only acts on entries whose directories no longer exist, /// and we only delete the single target directory before pruning. -pub fn remove_worktree_fast(worktree_path: &Path, main_repo_path: &Path) -> GitResult<()> { - // Remove the worktree directory (treat NotFound as success — already gone) - match std::fs::remove_dir_all(worktree_path) { +pub fn remove_worktree_fast(verified: &VerifiedWorktree) -> GitResult<()> { + remove_worktree_fast_with(verified, |path| std::fs::remove_dir_all(path)) +} + +fn remove_worktree_fast_with( + verified: &VerifiedWorktree, + remove_dir_all: impl FnOnce(&Path) -> std::io::Result<()>, +) -> GitResult<()> { + revalidate_verified_worktree(verified)?; + let worktree_path = &verified.checkout_path; + let parent = worktree_path + .parent() + .ok_or_else(|| unsafe_worktree(worktree_path, "checkout directory has no parent"))?; + let quarantine = parent.join(format!(".okena-removing-{}", uuid::Uuid::new_v4())); + std::fs::rename(worktree_path, &quarantine).map_err(|source| GitError::RemoveFailed { + path: worktree_path.clone(), + source, + })?; + + let quarantined_identity = filesystem_object_identity(&quarantine); + if quarantined_identity.as_ref() != Some(&verified.identity) { + let restore = if !worktree_path.exists() { + std::fs::rename(&quarantine, worktree_path) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "original checkout path was claimed during quarantine", + )) + }; + let reason = match restore { + Ok(()) => "quarantined directory identity changed; original path restored".to_string(), + Err(error) => format!( + "quarantined directory identity changed; data preserved at '{}': {error}", + quarantine.display() + ), + }; + return Err(unsafe_worktree(worktree_path, reason)); + } + + match remove_dir_all(&quarantine) { Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(GitError::RemoveFailed { - path: worktree_path.to_path_buf(), - source: e, - }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + // `remove_dir_all` can have already removed the checkout and leave + // only Finder metadata behind. Delete that narrow, verified class of + // debris; otherwise restore the still-owned quarantine and fail closed. + if let Err(cleanup_error) = cleanup_benign_residual(&quarantine) { + let source = match std::fs::rename(&quarantine, worktree_path) { + Ok(()) => std::io::Error::other(format!( + "{error}; residual cleanup refused: {cleanup_error}" + )), + Err(restore_error) => std::io::Error::new( + error.kind(), + format!( + "{error}; residual cleanup refused: {cleanup_error}; remaining checkout preserved at '{}'; restore failed: {restore_error}", + quarantine.display() + ), + ), + }; + return Err(GitError::RemoveFailed { + path: worktree_path.to_path_buf(), + source, + }); + } + } } + // A process such as Finder can recreate the old path after the atomic + // quarantine. It is safe to delete only an empty directory or `.DS_Store`; + // any other replacement is foreign data and must survive without pruning. + cleanup_benign_residual(worktree_path)?; + // Prune stale worktree entries from the main repo - let main_str = path_str(main_repo_path)?; + let main_str = path_str(&verified.parent_path)?; let output = safe_output(command("git").args(["-C", main_str, "worktree", "prune"]))?; if !output.status.success() { @@ -182,6 +433,36 @@ pub fn remove_worktree_fast(worktree_path: &Path, main_repo_path: &Path) -> GitR Ok(()) } +/// Move a verified linked worktree and return a fresh token for its new root. +pub fn move_worktree(verified: &VerifiedWorktree, new_path: &Path) -> GitResult { + revalidate_verified_worktree(verified)?; + require_absent_worktree_target(new_path)?; + let parent = path_str(&verified.parent_path)?; + let old = path_str(&verified.checkout_path)?; + let new = path_str(new_path)?; + let output = safe_output(command("git").args(["-C", parent, "worktree", "move", old, new]))?; + require_success(output)?; + + match verify_linked_worktree_fresh(&verified.parent_path, new_path) { + Ok(moved) => Ok(moved), + Err(error) => { + let rollback = + safe_output(command("git").args(["-C", parent, "worktree", "move", new, old])) + .map_err(GitError::from) + .and_then(require_success); + match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(unsafe_worktree( + new_path, + format!( + "post-move verification failed: {error}; rollback failed: {rollback_error}" + ), + )), + } + } + } +} + /// List all worktrees in a repository (main + linked). Returns vec of /// (path, branch_name) pairs; detached worktrees are omitted. pub fn list_git_worktrees(repo_path: &Path) -> Vec<(String, String)> { @@ -194,15 +475,20 @@ pub fn list_git_worktrees(repo_path: &Path) -> Vec<(String, String)> { // Main worktree: open via common_dir, which always resolves to the main // repository even when `repo_path` lives in a linked worktree. if let Ok(main_repo) = gix::open(repo.common_dir()) - && let (Some(workdir), Some(branch)) = (main_repo.workdir(), head_branch_short(&main_repo)) { - result.push((workdir.to_string_lossy().into_owned(), branch)); - } + && let (Some(workdir), Some(branch)) = (main_repo.workdir(), head_branch_short(&main_repo)) + { + result.push((workdir.to_string_lossy().into_owned(), branch)); + } // Linked worktrees from .git/worktrees/*. if let Ok(worktrees) = repo.worktrees() { for proxy in worktrees { - let Some(workdir) = proxy.base().ok() else { continue }; - let Ok(wt_repo) = proxy.into_repo_with_possibly_inaccessible_worktree() else { continue }; + let Some(workdir) = proxy.base().ok() else { + continue; + }; + let Ok(wt_repo) = proxy.into_repo_with_possibly_inaccessible_worktree() else { + continue; + }; if let Some(branch) = head_branch_short(&wt_repo) { result.push((workdir.to_string_lossy().into_owned(), branch)); } @@ -212,6 +498,31 @@ pub fn list_git_worktrees(repo_path: &Path) -> Vec<(String, String)> { result } +/// List the paths Git registers as linked worktrees for a repository. +/// The main worktree is intentionally excluded. +pub fn list_linked_worktree_paths(repo_path: &Path) -> Vec { + let Some(repo) = crate::gix_helpers::open(repo_path) else { + return Vec::new(); + }; + // macOS exposes `/var` through `/private/var`. gix may report either spelling + // for the main worktree, so compare existing paths by canonical filesystem + // identity instead of lexical components. Missing paths retain the portable + // lexical fallback used elsewhere in this module. + let main_worktree = repo.workdir().map(path_identity); + let Ok(worktrees) = repo.worktrees() else { + return Vec::new(); + }; + worktrees + .into_iter() + .filter_map(|proxy| proxy.base().ok()) + .filter(|path| main_worktree.as_ref() != Some(&path_identity(path))) + .collect() +} + +fn path_identity(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| crate::repository::normalize_path(path)) +} + #[cfg(test)] mod tests { use super::*; @@ -229,7 +540,10 @@ mod tests { let (_tmp, repo) = init_temp_repo(); let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); let wt_path = wt_tmp.path().join("wt-feat"); - git_in(&repo, &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"]); + git_in( + &repo, + &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"], + ); let mut entries = list_git_worktrees(&repo); entries.sort_by(|a, b| a.1.cmp(&b.1)); @@ -237,15 +551,223 @@ mod tests { assert_eq!(branches, vec!["feat", "main"]); } + #[test] + fn path_identity_prefers_canonical_filesystem_path() { + let directory = tempfile::tempdir().expect("create identity directory"); + let dotted = directory.path().join("."); + assert_eq!( + path_identity(&dotted), + directory.path().canonicalize().unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn path_identity_matches_a_directory_symlink_alias() { + let parent = tempfile::tempdir().expect("create identity parent"); + let actual = parent.path().join("actual"); + let alias = parent.path().join("alias"); + std::fs::create_dir(&actual).expect("create actual directory"); + std::os::unix::fs::symlink(&actual, &alias).expect("create directory alias"); + + assert_eq!(path_identity(&actual), path_identity(&alias)); + } + + #[test] + fn list_linked_worktree_paths_excludes_main_worktree() { + let (_tmp, repo) = init_temp_repo(); + let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); + let wt_path = wt_tmp.path().join("wt-feat"); + git_in( + &repo, + &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"], + ); + + assert_eq!( + list_linked_worktree_paths(&repo) + .iter() + .map(|path| path_identity(path)) + .collect::>(), + vec![path_identity(&wt_path)] + ); + } + + #[test] + fn fresh_verification_rejects_a_path_replaced_after_cached_discovery() { + let (_tmp, repo) = init_temp_repo(); + let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); + let wt_path = wt_tmp.path().join("wt-feat"); + let moved_path = wt_tmp.path().join("moved-feat"); + git_in( + &repo, + &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"], + ); + + assert!(crate::repository::get_repo_root(&wt_path).is_some()); + assert!(crate::repository::get_repo_common_dir(&wt_path).is_some()); + std::fs::rename(&wt_path, &moved_path).expect("move original checkout"); + std::fs::create_dir(&wt_path).expect("create replacement directory"); + let sentinel = wt_path.join("must-survive.txt"); + std::fs::write(&sentinel, "independent data").expect("write sentinel"); + + assert!(verify_linked_worktree_fresh(&repo, &wt_path).is_err()); + assert_eq!( + std::fs::read_to_string(sentinel).expect("replacement survives"), + "independent data" + ); + } + + #[test] + fn benign_residual_cleanup_accepts_ds_store_and_absence() { + let parent = tempfile::tempdir().expect("create residual parent"); + let residual = parent.path().join("worktree"); + std::fs::create_dir(&residual).expect("create residual"); + std::fs::write(residual.join(".DS_Store"), "finder metadata").expect("write metadata"); + + assert!(remove_benign_residual(&residual).expect("remove benign metadata")); + assert!(!residual.exists()); + assert!(remove_benign_residual(&residual).expect("already absent is benign")); + } + + #[test] + fn benign_residual_cleanup_preserves_foreign_replacement() { + let parent = tempfile::tempdir().expect("create residual parent"); + let residual = parent.path().join("worktree"); + std::fs::create_dir(&residual).expect("create residual"); + let sentinel = residual.join("must-survive.txt"); + std::fs::write(&sentinel, "foreign data").expect("write sentinel"); + + assert!(!remove_benign_residual(&residual).expect("inspect foreign residual")); + assert_eq!( + std::fs::read_to_string(sentinel).expect("sentinel survives"), + "foreign data" + ); + } + + #[test] + fn fast_removal_cleans_partial_ds_store_residual_and_preserves_old_path_replacement() { + let (_tmp, repo) = init_temp_repo(); + let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); + let wt_path = wt_tmp.path().join("wt-feat"); + git_in( + &repo, + &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"], + ); + let verified = verify_linked_worktree_fresh(&repo, &wt_path).expect("verify worktree"); + let replacement_path = wt_path.clone(); + + let result = remove_worktree_fast_with(&verified, |quarantine| { + std::fs::remove_dir_all(quarantine).expect("remove quarantined checkout contents"); + std::fs::create_dir(quarantine).expect("recreate partial quarantine residual"); + std::fs::write(quarantine.join(".DS_Store"), "finder metadata") + .expect("write partial residual"); + std::fs::create_dir(&replacement_path).expect("recreate old checkout path"); + std::fs::write(replacement_path.join("must-survive.txt"), "foreign data") + .expect("write replacement sentinel"); + Err(std::io::Error::other( + "simulated partial remove_dir_all failure", + )) + }); + + assert!( + result.is_err(), + "foreign old-path replacement must stop pruning" + ); + assert!( + !wt_tmp + .path() + .read_dir() + .expect("inspect worktree parent") + .filter_map(Result::ok) + .any(|entry| entry + .file_name() + .to_string_lossy() + .starts_with(".okena-removing-")), + "benign .DS_Store quarantine residual must be removed" + ); + assert_eq!( + std::fs::read_to_string(wt_path.join("must-survive.txt")) + .expect("foreign replacement survives"), + "foreign data" + ); + } + + #[test] + fn guarded_fast_removal_rejects_a_replaced_checkout() { + let (_tmp, repo) = init_temp_repo(); + let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); + let wt_path = wt_tmp.path().join("wt-feat"); + let moved_path = wt_tmp.path().join("moved-feat"); + git_in( + &repo, + &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"], + ); + let verified = verify_linked_worktree_fresh(&repo, &wt_path).unwrap(); + std::fs::rename(&wt_path, &moved_path).expect("move original checkout"); + std::fs::create_dir(&wt_path).expect("create replacement directory"); + let sentinel = wt_path.join("must-survive.txt"); + std::fs::write(&sentinel, "independent data").expect("write sentinel"); + + assert!(remove_worktree_fast(&verified).is_err()); + assert!(moved_path.exists()); + assert_eq!( + std::fs::read_to_string(sentinel).expect("replacement survives"), + "independent data" + ); + } + + #[test] + fn worktree_move_returns_fresh_ownership() { + let (_tmp, repo) = init_temp_repo(); + let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); + let old_path = wt_tmp.path().join("wt-feat"); + let new_path = wt_tmp.path().join("renamed-feat"); + git_in( + &repo, + &["worktree", "add", old_path.to_str().unwrap(), "-b", "feat"], + ); + let verified = verify_linked_worktree_fresh(&repo, &old_path).unwrap(); + + let moved = move_worktree(&verified, &new_path).expect("move linked worktree"); + + assert_eq!(moved.checkout_path(), new_path); + assert!(!old_path.exists()); + assert!(verify_linked_worktree_fresh(&repo, &new_path).is_ok()); + } + #[test] fn get_worktree_branches_returns_branch_names() { let (_tmp, repo) = init_temp_repo(); let wt_tmp = tempfile::tempdir().expect("create worktree tempdir"); let wt_path = wt_tmp.path().join("wt-feat"); - git_in(&repo, &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"]); + git_in( + &repo, + &["worktree", "add", wt_path.to_str().unwrap(), "-b", "feat"], + ); let mut branches = crate::repository::get_worktree_branches(&repo); branches.sort(); assert_eq!(branches, vec!["feat", "main"]); } + + #[test] + fn create_refuses_existing_unregistered_directory_without_deleting_it() { + let (_tmp, repo) = init_temp_repo(); + let target_parent = tempfile::tempdir().expect("create target parent"); + let target = target_parent.path().join("existing-directory"); + std::fs::create_dir(&target).expect("create existing target"); + let sentinel = target.join("keep-me.txt"); + std::fs::write(&sentinel, "user data").expect("write sentinel"); + + let result = create_worktree(&repo, "feature", &target, true); + + assert!(matches!( + result, + Err(GitError::WorktreeExists { ref path }) if path == &target + )); + assert_eq!( + std::fs::read_to_string(sentinel).expect("existing data survives"), + "user data" + ); + } } diff --git a/crates/okena-hooks/Cargo.toml b/crates/okena-hooks/Cargo.toml index 02ca63a61..db0c4c07e 100644 --- a/crates/okena-hooks/Cargo.toml +++ b/crates/okena-hooks/Cargo.toml @@ -4,14 +4,19 @@ 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" libc = "0.2" +base64 = "0.22" diff --git a/crates/okena-hooks/src/hook_monitor.rs b/crates/okena-hooks/src/hook_monitor.rs index c368b0320..ac47b2d28 100644 --- a/crates/okena-hooks/src/hook_monitor.rs +++ b/crates/okena-hooks/src/hook_monitor.rs @@ -1,20 +1,33 @@ +use okena_core::api::{ApiHookExecution, ApiHookStatus}; use okena_state::Toast; use parking_lot::Mutex; -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::mpsc; +use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; /// Maximum number of hook executions to keep in history. const MAX_HISTORY: usize = 50; +/// Exit events can beat sync-hook waiter registration immediately after PTY spawn. +const MAX_EARLY_EXITS: usize = 128; +const EARLY_EXIT_TTL: Duration = Duration::from_secs(30); + /// Status of a hook execution. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum HookStatus { Running, - Succeeded { duration: Duration }, - Failed { duration: Duration, exit_code: i32, stderr: String }, - SpawnError { message: String }, + Succeeded { + duration: Duration, + }, + Failed { + duration: Duration, + exit_code: i32, + stderr: String, + }, + SpawnError { + message: String, + }, } /// A single hook execution record. @@ -29,6 +42,100 @@ pub struct HookExecution { pub terminal_id: Option, } +/// Intern a wire hook-type string back to the `&'static str` the domain type +/// uses. The set is closed (every `hook_type` passed to `record_start` is a +/// literal); an unrecognized value maps to `"unknown"` rather than leaking. +fn intern_hook_type(s: &str) -> &'static str { + match s { + "on_project_open" => "on_project_open", + "on_project_close" => "on_project_close", + "on_worktree_create" => "on_worktree_create", + "on_worktree_close" => "on_worktree_close", + "pre_merge" => "pre_merge", + "post_merge" => "post_merge", + "before_worktree_remove" => "before_worktree_remove", + "worktree_removed" => "worktree_removed", + "on_rebase_conflict" => "on_rebase_conflict", + "on_dirty_worktree_close" => "on_dirty_worktree_close", + "terminal.on_close" => "terminal.on_close", + _ => "unknown", + } +} + +fn same_snapshot_execution(a: &HookExecution, b: &HookExecution) -> bool { + a.id == b.id + && a.hook_type == b.hook_type + && a.command == b.command + && a.project_name == b.project_name + && a.status == b.status + && a.terminal_id == b.terminal_id +} + +impl HookExecution { + /// Project onto the wire mirror ([`ApiHookExecution`]). Durations collapse + /// to whole milliseconds; `started_at` is dropped (process-local `Instant`). + pub fn to_api(&self) -> ApiHookExecution { + let status = match &self.status { + HookStatus::Running => ApiHookStatus::Running, + HookStatus::Succeeded { duration } => ApiHookStatus::Succeeded { + duration_ms: duration.as_millis() as u64, + }, + HookStatus::Failed { + duration, + exit_code, + stderr, + } => ApiHookStatus::Failed { + duration_ms: duration.as_millis() as u64, + exit_code: *exit_code, + stderr: stderr.clone(), + }, + HookStatus::SpawnError { message } => ApiHookStatus::SpawnError { + message: message.clone(), + }, + }; + ApiHookExecution { + id: self.id, + hook_type: self.hook_type.to_string(), + command: self.command.clone(), + project_name: self.project_name.clone(), + status, + terminal_id: self.terminal_id.clone(), + } + } + + /// Rebuild from the wire mirror on a thin client. `started_at` is stamped + /// to now (see [`ApiHookExecution`]); `hook_type` is re-interned. + pub fn from_api(api: &ApiHookExecution) -> Self { + let status = match &api.status { + ApiHookStatus::Running => HookStatus::Running, + ApiHookStatus::Succeeded { duration_ms } => HookStatus::Succeeded { + duration: Duration::from_millis(*duration_ms), + }, + ApiHookStatus::Failed { + duration_ms, + exit_code, + stderr, + } => HookStatus::Failed { + duration: Duration::from_millis(*duration_ms), + exit_code: *exit_code, + stderr: stderr.clone(), + }, + ApiHookStatus::SpawnError { message } => HookStatus::SpawnError { + message: message.clone(), + }, + }; + HookExecution { + id: api.id, + hook_type: intern_hook_type(&api.hook_type), + command: api.command.clone(), + project_name: api.project_name.clone(), + started_at: Instant::now(), + status, + terminal_id: api.terminal_id.clone(), + } + } +} + /// Internal mutable state behind the Arc>. struct HookMonitorInner { history: VecDeque, @@ -36,6 +143,9 @@ struct HookMonitorInner { next_id: u64, running_count: usize, exit_waiters: HashMap>>, + early_exits: VecDeque<(String, Option, Instant)>, + next_exit_reservation_id: u64, + unbound_exit_reservations: HashSet, /// Monotonic counter incremented on every mutation. Allows cheap /// "has anything changed?" checks without cloning the full history. version: u64, @@ -48,6 +158,61 @@ struct HookMonitorInner { #[derive(Clone)] pub struct HookMonitor(Arc>); +/// RAII reservation covering the narrow gap between PTY spawn and waiter binding. +/// +/// While at least one reservation exists, otherwise-unclassified exits may be +/// held briefly for a just-spawned sync hook. Dropping an unbound reservation +/// cancels it and clears unmatched provisional exits once no reservation remains. +pub struct ExitWaitReservation { + monitor: Weak>, + id: u64, + bound: bool, +} + +impl ExitWaitReservation { + /// Bind this reservation to the terminal ID returned by PTY creation. + pub fn bind(mut self, terminal_id: &str) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + let Some(monitor) = self.monitor.upgrade() else { + return rx; + }; + let mut inner = monitor.lock(); + if !inner.unbound_exit_reservations.remove(&self.id) { + return rx; + } + self.bound = true; + prune_early_exits(&mut inner.early_exits); + if let Some(index) = inner + .early_exits + .iter() + .position(|(id, _, _)| id == terminal_id) + { + if let Some((_, exit_code, _)) = inner.early_exits.remove(index) { + let _ = tx.send(exit_code); + } + } else { + inner.exit_waiters.insert(terminal_id.to_string(), tx); + } + clear_unmatched_provisional_exits(&mut inner); + rx + } +} + +impl Drop for ExitWaitReservation { + fn drop(&mut self) { + if self.bound { + return; + } + let Some(monitor) = self.monitor.upgrade() else { + return; + }; + let mut inner = monitor.lock(); + inner.unbound_exit_reservations.remove(&self.id); + clear_unmatched_provisional_exits(&mut inner); + } +} + +#[cfg(feature = "gpui")] impl gpui::Global for HookMonitor {} impl Default for HookMonitor { @@ -64,6 +229,9 @@ impl HookMonitor { next_id: 1, running_count: 0, exit_waiters: HashMap::new(), + early_exits: VecDeque::new(), + next_exit_reservation_id: 1, + unbound_exit_reservations: HashSet::new(), version: 0, }))) } @@ -97,14 +265,31 @@ impl HookMonitor { let removed = inner.history.pop_front(); // If we're removing a still-running entry (shouldn't normally happen), adjust count if let Some(entry) = removed - && matches!(entry.status, HookStatus::Running) { - inner.running_count = inner.running_count.saturating_sub(1); - } + && matches!(entry.status, HookStatus::Running) + { + inner.running_count = inner.running_count.saturating_sub(1); + } } id } + /// Record a hook execution whose type came from persisted/wire state. + pub fn record_start_named( + &self, + hook_type: &str, + command: &str, + project_name: &str, + terminal_id: Option, + ) -> u64 { + self.record_start( + intern_hook_type(hook_type), + command, + project_name, + terminal_id, + ) + } + /// Record hook completion (success, failure, or spawn error). pub fn record_finish(&self, id: u64, status: HookStatus) { let mut inner = self.0.lock(); @@ -119,11 +304,8 @@ impl HookMonitor { match &status { HookStatus::Failed { stderr, .. } => { let first_line = stderr.lines().next().unwrap_or("(no output)"); - let msg = format!( - "Hook `{}` failed: {}", - hook_type, - truncate(first_line, 120), - ); + let msg = + format!("Hook `{}` failed: {}", hook_type, truncate(first_line, 120),); inner.pending_toasts.push(Toast::error(msg)); } HookStatus::SpawnError { message } => { @@ -147,6 +329,43 @@ 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); + } + + /// Replace the entire history from a daemon snapshot (thin-client ingest). + /// + /// `newest_first` is in the same order [`history`](Self::history) returns + /// (newest first) — the daemon builds it from its own `history()`. To keep + /// the log rendering unchanged, it is stored reversed (oldest-first) so + /// `history()`'s own `.rev()` yields newest-first again. + /// + /// A full wire-visible comparison skips unchanged snapshots. `started_at` is + /// intentionally ignored because it is reconstructed locally on each ingest. + pub fn replace_history(&self, newest_first: Vec) { + let mut inner = self.0.lock(); + let unchanged = inner.history.len() == newest_first.len() + && inner + .history + .iter() + .rev() + .zip(newest_first.iter()) + .all(|(a, b)| same_snapshot_execution(a, b)); + if unchanged { + return; + } + inner.running_count = newest_first + .iter() + .filter(|e| matches!(e.status, HookStatus::Running)) + .count(); + inner.history = newest_first.into_iter().rev().collect(); + inner.version += 1; + } + /// Get a snapshot of the execution history (newest first). pub fn history(&self) -> Vec { let inner = self.0.lock(); @@ -170,17 +389,64 @@ impl HookMonitor { pub fn register_exit_waiter(&self, terminal_id: &str) -> mpsc::Receiver> { let (tx, rx) = mpsc::channel(); let mut inner = self.0.lock(); - inner.exit_waiters.insert(terminal_id.to_string(), tx); + prune_early_exits(&mut inner.early_exits); + if let Some(index) = inner + .early_exits + .iter() + .position(|(id, _, _)| id == terminal_id) + { + if let Some((_, exit_code, _)) = inner.early_exits.remove(index) { + let _ = tx.send(exit_code); + } + } else { + inner.exit_waiters.insert(terminal_id.to_string(), tx); + } rx } + /// Reserve an exit waiter before starting a sync-hook PTY. + pub fn reserve_exit_waiter(&self) -> ExitWaitReservation { + let mut inner = self.0.lock(); + let id = inner.next_exit_reservation_id; + inner.next_exit_reservation_id = inner.next_exit_reservation_id.wrapping_add(1); + inner.unbound_exit_reservations.insert(id); + ExitWaitReservation { + monitor: Arc::downgrade(&self.0), + id, + bound: false, + } + } + + /// Cancel a waiter after its synchronous hook stops waiting. + pub fn cancel_exit_waiter(&self, terminal_id: &str) { + let mut inner = self.0.lock(); + inner.exit_waiters.remove(terminal_id); + inner.early_exits.retain(|(id, _, _)| id != terminal_id); + } + + /// Silently remove an intentionally cancelled hook terminal. + pub fn cancel_by_terminal_id(&self, terminal_id: &str) -> bool { + let mut inner = self.0.lock(); + inner.exit_waiters.remove(terminal_id); + inner.early_exits.retain(|(id, _, _)| id != terminal_id); + let Some(index) = inner.history.iter().position(|entry| { + entry.terminal_id.as_deref() == Some(terminal_id) + && matches!(entry.status, HookStatus::Running) + }) else { + return false; + }; + inner.history.remove(index); + inner.running_count = inner.running_count.saturating_sub(1); + inner.version += 1; + true + } + /// Find and finish a hook execution by its terminal ID. /// Returns `true` if a matching running execution was found and finished. pub fn finish_by_terminal_id(&self, terminal_id: &str, exit_code: Option) -> bool { let mut inner = self.0.lock(); if let Some(entry) = inner.history.iter_mut().find(|e| { - e.terminal_id.as_deref() == Some(terminal_id) - && matches!(e.status, HookStatus::Running) + e.terminal_id.as_deref() == Some(terminal_id) && matches!(e.status, HookStatus::Running) }) { let duration = entry.started_at.elapsed(); let success = exit_code == Some(0); @@ -210,10 +476,47 @@ impl HookMonitor { let mut inner = self.0.lock(); if let Some(tx) = inner.exit_waiters.remove(terminal_id) { let _ = tx.send(exit_code); + } else if !inner.unbound_exit_reservations.is_empty() + || inner.history.iter().any(|entry| { + entry.terminal_id.as_deref() == Some(terminal_id) + && matches!(entry.status, HookStatus::Running) + }) + { + prune_early_exits(&mut inner.early_exits); + inner.early_exits.retain(|(id, _, _)| id != terminal_id); + inner + .early_exits + .push_back((terminal_id.to_string(), exit_code, Instant::now())); + while inner.early_exits.len() > MAX_EARLY_EXITS { + inner.early_exits.pop_front(); + } } } } +fn clear_unmatched_provisional_exits(inner: &mut HookMonitorInner) { + if inner.unbound_exit_reservations.is_empty() { + let expected: HashSet<&str> = inner + .history + .iter() + .filter(|entry| matches!(entry.status, HookStatus::Running)) + .filter_map(|entry| entry.terminal_id.as_deref()) + .collect(); + inner + .early_exits + .retain(|(terminal_id, _, _)| expected.contains(terminal_id.as_str())); + } +} + +fn prune_early_exits(early_exits: &mut VecDeque<(String, Option, Instant)>) { + while early_exits + .front() + .is_some_and(|(_, _, received_at)| received_at.elapsed() > EARLY_EXIT_TTL) + { + early_exits.pop_front(); + } +} + fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() @@ -233,9 +536,12 @@ mod tests { let id = monitor.record_start("on_project_open", "echo hi", "my-project", None); assert_eq!(monitor.running_count(), 1); - monitor.record_finish(id, HookStatus::Succeeded { - duration: Duration::from_millis(50), - }); + monitor.record_finish( + id, + HookStatus::Succeeded { + duration: Duration::from_millis(50), + }, + ); assert_eq!(monitor.running_count(), 0); let history = monitor.history(); @@ -249,11 +555,14 @@ mod tests { let monitor = HookMonitor::new(); let id = monitor.record_start("pre_merge", "exit 1", "test-project", None); - monitor.record_finish(id, HookStatus::Failed { - duration: Duration::from_millis(10), - exit_code: 1, - stderr: "something went wrong".to_string(), - }); + monitor.record_finish( + id, + HookStatus::Failed { + duration: Duration::from_millis(10), + exit_code: 1, + stderr: "something went wrong".to_string(), + }, + ); let toasts = monitor.drain_pending_toasts(); assert_eq!(toasts.len(), 1); @@ -266,9 +575,12 @@ mod tests { let monitor = HookMonitor::new(); for i in 0..60 { let id = monitor.record_start("test", &format!("cmd-{}", i), "proj", None); - monitor.record_finish(id, HookStatus::Succeeded { - duration: Duration::from_millis(1), - }); + monitor.record_finish( + id, + HookStatus::Succeeded { + duration: Duration::from_millis(1), + }, + ); } assert!(monitor.history().len() <= 50); } @@ -277,9 +589,19 @@ mod tests { fn history_returned_newest_first() { let monitor = HookMonitor::new(); let id1 = monitor.record_start("first", "echo 1", "proj", None); - monitor.record_finish(id1, HookStatus::Succeeded { duration: Duration::from_millis(1) }); + monitor.record_finish( + id1, + HookStatus::Succeeded { + duration: Duration::from_millis(1), + }, + ); let id2 = monitor.record_start("second", "echo 2", "proj", None); - monitor.record_finish(id2, HookStatus::Succeeded { duration: Duration::from_millis(1) }); + monitor.record_finish( + id2, + HookStatus::Succeeded { + duration: Duration::from_millis(1), + }, + ); let history = monitor.history(); assert_eq!(history[0].hook_type, "second"); @@ -290,9 +612,12 @@ mod tests { fn spawn_error_queues_toast() { let monitor = HookMonitor::new(); let id = monitor.record_start("on_project_open", "bad-cmd", "proj", None); - monitor.record_finish(id, HookStatus::SpawnError { - message: "command not found".to_string(), - }); + monitor.record_finish( + id, + HookStatus::SpawnError { + message: "command not found".to_string(), + }, + ); let toasts = monitor.drain_pending_toasts(); assert_eq!(toasts.len(), 1); @@ -315,10 +640,292 @@ mod tests { assert_eq!(rx.recv().unwrap(), None); } + #[test] + fn cancel_exit_waiter_drops_bound_sender() { + let monitor = HookMonitor::new(); + let rx = monitor.reserve_exit_waiter().bind("term-timeout"); + + monitor.cancel_exit_waiter("term-timeout"); + + assert!(matches!( + rx.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + } + + #[test] + fn intentional_terminal_cancellation_is_silent() { + let monitor = HookMonitor::new(); + monitor.record_start( + "on_project_open", + "echo hook", + "project", + Some("term-cancelled".into()), + ); + let rx = monitor.register_exit_waiter("term-cancelled"); + + assert!(monitor.cancel_by_terminal_id("term-cancelled")); + + assert!(monitor.history().is_empty()); + assert!(monitor.drain_pending_toasts().is_empty()); + assert!(matches!( + rx.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + } + + #[test] + fn exit_waiter_receives_exit_that_arrived_before_registration() { + let monitor = HookMonitor::new(); + let reservation = monitor.reserve_exit_waiter(); + monitor.notify_exit("term-early", Some(7)); + + let rx = reservation.bind("term-early"); + + assert_eq!(rx.try_recv().unwrap(), Some(7)); + } + + #[test] + fn early_exit_can_finish_execution_recorded_after_the_event() { + let monitor = HookMonitor::new(); + let reservation = monitor.reserve_exit_waiter(); + monitor.notify_exit("term-fast", Some(0)); + monitor.record_start("pre_merge", "true", "project", Some("term-fast".into())); + + let rx = reservation.bind("term-fast"); + let exit_code = rx.try_recv().unwrap(); + assert!(monitor.finish_by_terminal_id("term-fast", exit_code)); + + assert!(matches!( + monitor.history()[0].status, + HookStatus::Succeeded { .. } + )); + } + + #[test] + fn early_exit_buffer_is_bounded() { + let monitor = HookMonitor::new(); + let reservation = monitor.reserve_exit_waiter(); + for index in 0..=MAX_EARLY_EXITS { + monitor.notify_exit(&format!("term-{index}"), Some(0)); + } + + let newest = reservation.bind(&format!("term-{MAX_EARLY_EXITS}")); + let evicted = monitor.register_exit_waiter("term-0"); + + assert!(matches!(evicted.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert_eq!(newest.try_recv().unwrap(), Some(0)); + } + + #[test] + fn record_start_named_interns_persisted_hook_type() { + let monitor = HookMonitor::new(); + monitor.record_start_named( + "on_project_open", + "echo rerun", + "project", + Some("term-rerun".into()), + ); + + let history = monitor.history(); + assert_eq!(history[0].hook_type, "on_project_open"); + assert_eq!(history[0].terminal_id.as_deref(), Some("term-rerun")); + } + #[test] fn notify_exit_without_waiter_is_noop() { let monitor = HookMonitor::new(); - // Should not panic monitor.notify_exit("nonexistent", Some(1)); + + let rx = monitor.register_exit_waiter("nonexistent"); + assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + } + + #[test] + fn dropping_reservation_discards_unmatched_ordinary_exits() { + let monitor = HookMonitor::new(); + let reservation = monitor.reserve_exit_waiter(); + monitor.notify_exit("ordinary-terminal", Some(0)); + drop(reservation); + + let rx = monitor.register_exit_waiter("ordinary-terminal"); + assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + } + + #[test] + fn to_api_from_api_round_trips() { + let exec = HookExecution { + id: 7, + hook_type: "worktree_removed", + command: "echo bye".into(), + project_name: "proj".into(), + started_at: Instant::now(), + status: HookStatus::Failed { + duration: Duration::from_millis(1234), + exit_code: 3, + stderr: "boom".into(), + }, + terminal_id: Some("t9".into()), + }; + let api = exec.to_api(); + assert_eq!(api.hook_type, "worktree_removed"); + assert!(matches!( + api.status, + ApiHookStatus::Failed { + duration_ms: 1234, + exit_code: 3, + .. + } + )); + + let back = HookExecution::from_api(&api); + assert_eq!(back.id, 7); + // hook_type re-interned to the same &'static str. + assert_eq!(back.hook_type, "worktree_removed"); + assert_eq!(back.command, "echo bye"); + assert_eq!(back.terminal_id.as_deref(), Some("t9")); + match back.status { + HookStatus::Failed { + duration, + exit_code, + ref stderr, + } => { + assert_eq!(duration, Duration::from_millis(1234)); + assert_eq!(exit_code, 3); + assert_eq!(stderr, "boom"); + } + _ => panic!("status kind not preserved"), + } + } + + #[test] + fn from_api_interns_every_recorded_hook_type() { + // Every literal passed to a `run_hook*` -> `record_start` call site in + // hooks.rs must round-trip through interning without falling to + // "unknown"; a thin client re-interns mirrored history and would + // otherwise mislabel these in the Hook Log. + for ty in [ + "on_project_open", + "on_project_close", + "on_worktree_create", + "on_worktree_close", + "pre_merge", + "post_merge", + "before_worktree_remove", + "worktree_removed", + "on_rebase_conflict", + "on_dirty_worktree_close", + "terminal.on_close", + ] { + let api = ApiHookExecution { + id: 1, + hook_type: ty.into(), + command: "x".into(), + project_name: "p".into(), + status: ApiHookStatus::Running, + terminal_id: None, + }; + assert_eq!( + HookExecution::from_api(&api).hook_type, + ty, + "hook type {ty:?} must intern to itself, not \"unknown\"" + ); + } + } + + #[test] + fn from_api_interns_unknown_hook_type() { + let api = ApiHookExecution { + id: 1, + hook_type: "totally_made_up".into(), + command: "x".into(), + project_name: "p".into(), + status: ApiHookStatus::Running, + terminal_id: None, + }; + assert_eq!(HookExecution::from_api(&api).hook_type, "unknown"); + } + + fn api_exec(id: u64, status: ApiHookStatus) -> HookExecution { + HookExecution::from_api(&ApiHookExecution { + id, + hook_type: "pre_merge".into(), + command: format!("cmd-{id}"), + project_name: "p".into(), + status, + terminal_id: None, + }) + } + + #[test] + fn replace_history_preserves_newest_first_and_bumps_version() { + let monitor = HookMonitor::new(); + let v0 = monitor.version(); + // Daemon `history()` yields newest-first: id 2 then id 1. + monitor.replace_history(vec![ + api_exec(2, ApiHookStatus::Running), + api_exec(1, ApiHookStatus::Succeeded { duration_ms: 5 }), + ]); + + let hist = monitor.history(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].id, 2, "newest-first order must survive ingest"); + assert_eq!(hist[1].id, 1); + assert!(monitor.version() > v0); + assert_eq!(monitor.running_count(), 1); + } + + #[test] + fn replace_history_skips_bump_when_unchanged() { + let monitor = HookMonitor::new(); + let snapshot = || vec![api_exec(1, ApiHookStatus::Succeeded { duration_ms: 5 })]; + monitor.replace_history(snapshot()); + let v = monitor.version(); + // Same wire-visible data → no write, no version bump. + monitor.replace_history(snapshot()); + assert_eq!(monitor.version(), v); + } + + #[test] + fn replace_history_bumps_when_status_kind_changes() { + let monitor = HookMonitor::new(); + monitor.replace_history(vec![api_exec(1, ApiHookStatus::Running)]); + let v = monitor.version(); + // Same id, different status kind (Running → Succeeded) → must bump. + monitor.replace_history(vec![api_exec( + 1, + ApiHookStatus::Succeeded { duration_ms: 9 }, + )]); + assert!(monitor.version() > v); + } + + #[test] + fn replace_history_replaces_reused_ids_after_daemon_restart() { + let monitor = HookMonitor::new(); + monitor.replace_history(vec![api_exec( + 1, + ApiHookStatus::Succeeded { duration_ms: 5 }, + )]); + let v = monitor.version(); + + let replacement = HookExecution::from_api(&ApiHookExecution { + id: 1, + hook_type: "pre_merge".into(), + command: "new daemon command".into(), + project_name: "new-project".into(), + status: ApiHookStatus::Succeeded { duration_ms: 9 }, + terminal_id: Some("new-terminal".into()), + }); + monitor.replace_history(vec![replacement]); + + assert!(monitor.version() > v); + let history = monitor.history(); + assert_eq!(history[0].command, "new daemon command"); + assert_eq!(history[0].project_name, "new-project"); + assert!(matches!( + history[0].status, + HookStatus::Succeeded { duration } if duration == Duration::from_millis(9) + )); + assert_eq!(history[0].terminal_id.as_deref(), Some("new-terminal")); } } diff --git a/crates/okena-hooks/src/hooks.rs b/crates/okena-hooks/src/hooks.rs index 80421f7f8..4e291ebfb 100644 --- a/crates/okena-hooks/src/hooks.rs +++ b/crates/okena-hooks/src/hooks.rs @@ -3,13 +3,14 @@ // them into a context struct would obscure more than it clarifies here. #![allow(clippy::too_many_arguments)] -use okena_terminal::backend::TerminalBackend; -use okena_terminal::shell_config::ShellType; -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 okena_state::HooksConfig; +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::{TerminalBackend, TerminalLaunchCommand, TerminalLaunchPlan}; +use okena_terminal::shell_config::ShellType; +use okena_terminal::terminal::{Terminal, TerminalSize}; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; @@ -28,11 +29,45 @@ impl HookRunner { } } +#[cfg(feature = "gpui")] impl gpui::Global for HookRunner {} /// Pending terminal-backed hook actions paired with their env vars, returned /// alongside the `HookTerminalResult`s produced by background PTY commands. -type HookActionOutcome = (Vec<(String, HashMap)>, Vec); +pub type HookActionOutcome = ( + Vec<(String, HashMap)>, + Vec, +); + +/// Hook actions resolved off-reactor and deferred until their owner can +/// register any spawned PTYs before yielding back to the event loop. +#[derive(Clone)] +pub struct HookActionPlan { + command: String, + env_vars: HashMap, + hook_type: &'static str, + project_name: String, + project_id: String, + keep_alive: bool, +} + +/// Execute a previously resolved hook plan with the caller's PTY services. +pub fn execute_hook_action_plan( + plan: HookActionPlan, + monitor: Option<&HookMonitor>, + runner: Option<&HookRunner>, +) -> HookActionOutcome { + run_hook_actions( + &plan.command, + plan.env_vars, + monitor, + plan.hook_type, + &plan.project_name, + runner, + &plan.project_id, + plan.keep_alive, + ) +} /// Result of a hook execution via PTY. #[derive(Clone)] @@ -47,6 +82,39 @@ pub struct HookTerminalResult { pub cwd: String, } +/// Immutable terminal-backed hook launch with caller-reserved ownership. +#[derive(Clone)] +pub struct PreparedHookTerminal { + result: HookTerminalResult, + launch_plan: TerminalLaunchPlan, + monitor_command: String, + project_name: String, +} + +impl PreparedHookTerminal { + pub fn result(&self) -> &HookTerminalResult { + &self.result + } + + /// Publish monitor ownership before a fast PTY can emit its exit event. + pub fn publish_monitor(&self, monitor: Option<&HookMonitor>) { + if let Some(monitor) = monitor { + let _ = monitor.record_start( + self.result.hook_type, + &self.monitor_command, + &self.project_name, + Some(self.result.terminal_id.clone()), + ); + } + } + + pub fn finish_failed_monitor(&self, monitor: Option<&HookMonitor>) { + if let Some(monitor) = monitor { + monitor.finish_by_terminal_id(&self.result.terminal_id, None); + } + } +} + impl HookRunner { /// Create a PTY-backed terminal for a hook command. /// Returns (terminal_id, full_cmd). The terminal is registered in the TerminalsRegistry. @@ -62,89 +130,27 @@ impl HookRunner { project_path: &str, keep_alive: bool, ) -> Result<(String, String), String> { - // Build the full command with env vars baked in. - // Filter out any keys that aren't valid shell identifiers to prevent injection. - let safe_env: Vec<_> = env_vars - .iter() - .filter(|(k, _)| { - if is_valid_env_key(k) { - true - } else { - log::warn!("Skipping invalid env var key in hook terminal: {:?}", k); - false - } - }) - .collect(); + // Store an independently rerunnable command, including persistent env setup. + let full_cmd = rerunnable_hook_command(command, env_vars); - let full_cmd = if cfg!(windows) { - // Escape all cmd.exe special characters in env var values. - // ^ must be escaped first since it's the cmd.exe escape character. - let env_prefix = safe_env - .iter() - .map(|(k, v)| { - // Escape all cmd.exe special characters. - // ^ must be first since it's the escape character itself. - let escaped = v - .replace('^', "^^") - .replace('%', "%%") - .replace('"', "\\\"") - .replace('&', "^&") - .replace('|', "^|") - .replace('<', "^<") - .replace('>', "^>") - .replace('(', "^(") - .replace(')', "^)"); - format!("set \"{}={}\"", k, escaped) - }) - .collect::>() - .join(" && "); - if env_prefix.is_empty() { - command.to_string() - } else { - format!("{} && {}", env_prefix, command) - } + let cwd = if project_path.is_empty() { + "." } else { - // POSIX single-quote escaping: wrap values in '...' and replace each - // embedded ' with the sequence '\'' (end current single-quoted string, - // insert an escaped literal quote, re-open single-quoted string). - // This is the standard POSIX single-quote escape pattern. - let env_prefix = safe_env - .iter() - .map(|(k, v)| format!("{}='{}'", k, v.replace('\'', "'\\''"))) - .collect::>() - .join(" "); - if env_prefix.is_empty() { - command.to_string() - } else { - format!("{} {}", env_prefix, command) - } + project_path }; - let cwd = if project_path.is_empty() { "." } else { project_path }; - - let terminal_id = if keep_alive { - // Build a shell command that: - // 1. Exports env vars (available to the hook and the interactive shell) - // 2. Runs the hook command - // 3. Execs into the user's default shell so the terminal stays alive - // This avoids noisy export echoing and zsh session restore issues. - let mut script = String::new(); - for (k, v) in &safe_env { - let escaped_v = v.replace('\'', "'\\''"); - script.push_str(&format!("export {}='{}'; ", k, escaped_v)); - } - script.push_str(command); - // Capture exit code and report it via OSC title before exec-ing - // into the interactive shell. The PTY event loop detects titles - // matching __okena_hook_exit: and updates hook status. - script.push_str("; __okena_rc=$?; printf '\\033]0;__okena_hook_exit:%d\\007' \"$__okena_rc\"; exec \"${SHELL:-sh}\""); - let shell = ShellType::for_command(script); - self.backend.create_terminal(cwd, Some(&shell)) + let shell = if keep_alive { + keep_alive_hook_shell(&full_cmd) } else { // Use sh -c so the PTY exits when the command completes. - let shell = ShellType::for_command(full_cmd.clone()); - self.backend.create_terminal(cwd, Some(&shell)) - }.map_err(|e| format!("Failed to create hook terminal: {}", e))?; + ShellType::for_command(full_cmd.clone()) + }; + let plan = + TerminalLaunchPlan::for_shell(shell).with_environment(safe_hook_environment(env_vars)); + let terminal_id = self + .backend + .create_terminal_with_plan(cwd, &plan) + .map_err(|e| format!("Failed to create hook terminal: {}", e))?; let transport = self.backend.transport(); let terminal = Arc::new(Terminal::new( @@ -157,6 +163,107 @@ impl HookRunner { Ok((terminal_id, full_cmd)) } + + /// Publish the UI terminal before launching a prepared hook PTY. + pub fn publish_prepared_terminal(&self, prepared: &PreparedHookTerminal) { + let terminal = Arc::new(Terminal::new( + prepared.result.terminal_id.clone(), + TerminalSize::default(), + self.backend.transport(), + prepared.result.cwd.clone(), + )); + self.terminals + .lock() + .insert(prepared.result.terminal_id.clone(), terminal); + } + + /// Launch a prepared hook using its already-published logical id. + pub fn launch_prepared_terminal(&self, prepared: &PreparedHookTerminal) -> Result<(), String> { + let terminal_id = self + .backend + .reconnect_terminal_with_plan( + &prepared.result.terminal_id, + &prepared.result.cwd, + &prepared.launch_plan, + ) + .map_err(|error| format!("Failed to create hook terminal: {error}"))?; + if terminal_id != prepared.result.terminal_id { + self.backend.kill(&terminal_id); + return Err(format!( + "hook backend returned unexpected terminal id {terminal_id}" + )); + } + Ok(()) + } + + pub fn remove_prepared_terminal(&self, prepared: &PreparedHookTerminal) { + self.terminals.lock().remove(&prepared.result.terminal_id); + } +} + +/// Resolve one project-open hook without launching its PTY. +pub fn prepare_project_open_hook( + terminal_id: String, + project_hooks: &HooksConfig, + project_id: &str, + project_name: &str, + project_path: &str, + folder_id: Option<&str>, + folder_name: Option<&str>, + global_hooks: &HooksConfig, +) -> Option { + let command = resolve_hook(project_hooks, global_hooks, |hooks| &hooks.project.on_open)?; + let env_vars = project_env( + project_id, + project_name, + project_path, + folder_id, + folder_name, + ); + let full_command = rerunnable_hook_command(&command, &env_vars); + let cwd = if project_path.is_empty() { + ".".to_string() + } else { + project_path.to_string() + }; + let launch_plan = TerminalLaunchPlan::for_shell(keep_alive_hook_shell(&full_command)) + .with_environment(safe_hook_environment(&env_vars)); + Some(PreparedHookTerminal { + result: HookTerminalResult { + terminal_id, + label: build_hook_label("on_project_open", &env_vars, project_name), + hook_type: "on_project_open", + project_id: project_id.to_string(), + command: full_command, + cwd, + }, + launch_plan, + monitor_command: command, + project_name: project_name.to_string(), + }) +} + +/// Wrap a rerunnable hook command so it reports completion and stays interactive. +pub fn keep_alive_hook_shell(command: &str) -> ShellType { + build_keep_alive_hook_shell(command, cfg!(windows)) +} + +fn build_keep_alive_hook_shell(command: &str, windows: bool) -> ShellType { + if windows { + let script = format!( + "{} & set \"__okena_rc=!ERRORLEVEL!\" & bool { if !bytes[0].is_ascii_alphabetic() && bytes[0] != b'_' { return false; } - bytes[1..].iter().all(|&b| b.is_ascii_alphanumeric() || b == b'_') + bytes[1..] + .iter() + .all(|&b| b.is_ascii_alphanumeric() || b == b'_') } -/// Build shell export statements from a HashMap of env vars. -/// POSIX: `export KEY='value'; ` with single-quote escaping. -/// Windows: `set "KEY=value" && ` with cmd.exe escaping. +/// Build POSIX shell export statements from a HashMap of env vars. +#[cfg(not(windows))] fn build_export_prefix(env_vars: &HashMap) -> String { let safe_env: Vec<_> = env_vars .iter() - .filter(|(k, _)| is_valid_env_key(k)) + .filter(|(k, _)| { + if is_valid_env_key(k) { + true + } else { + log::warn!("Skipping invalid env var key in hook terminal: {:?}", k); + false + } + }) .collect(); if safe_env.is_empty() { @@ -212,6 +327,55 @@ fn build_export_prefix(env_vars: &HashMap) -> String { } } +#[cfg(windows)] +fn build_export_prefix(_env_vars: &HashMap) -> String { + // Windows callers that need environment propagation must use TerminalLaunchPlan. + String::new() +} + +#[cfg(windows)] +fn windows_encoded_hook_command(command: &str, env_vars: &HashMap) -> String { + use base64::Engine; + + let mut script = String::new(); + for (key, value) in safe_hook_environment(env_vars) { + let value = base64::engine::general_purpose::STANDARD.encode(value.as_bytes()); + script.push_str(&format!( + "$env:{key}=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{value}'));" + )); + } + let command = base64::engine::general_purpose::STANDARD.encode(command.as_bytes()); + script.push_str(&format!( + "$okenaCommand=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{command}'));" + )); + script.push_str("& $env:ComSpec /D /S /C $okenaCommand;exit $LASTEXITCODE"); + + let utf16: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let encoded = base64::engine::general_purpose::STANDARD.encode(utf16); + format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}") +} + +fn safe_hook_environment(env_vars: &HashMap) -> Vec<(String, String)> { + let mut environment: Vec<_> = env_vars + .iter() + .filter(|(key, _)| is_valid_env_key(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + environment.sort_by(|a, b| a.0.cmp(&b.0)); + environment +} + +fn rerunnable_hook_command(command: &str, env_vars: &HashMap) -> String { + #[cfg(windows)] + { + return windows_encoded_hook_command(command, env_vars); + } + #[cfg(not(windows))] + { + format!("{}{}", build_export_prefix(env_vars), command) + } +} + /// Build environment variables for terminal hooks. /// Includes base project vars and, for worktree projects, OKENA_BRANCH. pub fn terminal_hook_env( @@ -222,7 +386,13 @@ pub fn terminal_hook_env( folder_id: Option<&str>, folder_name: Option<&str>, ) -> HashMap { - let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); + let mut env = project_env( + project_id, + project_name, + project_path, + folder_id, + folder_name, + ); if is_worktree { let path = std::path::Path::new(project_path); let branch = okena_git::get_git_status(path) @@ -237,7 +407,10 @@ pub fn terminal_hook_env( /// Build a `std::process::Command` for headless hook execution. /// Handles platform dispatch (sh -c / cmd /C), env vars, and cwd. -fn build_headless_command(command: &str, env_vars: &HashMap) -> std::process::Command { +fn build_headless_command( + command: &str, + env_vars: &HashMap, +) -> std::process::Command { #[cfg(unix)] let mut cmd = okena_core::process::command("sh"); #[cfg(unix)] @@ -260,8 +433,13 @@ fn build_headless_command(command: &str, env_vars: &HashMap) -> } /// Build a display label for a hook terminal tab. -fn build_hook_label(hook_type: &str, env_vars: &HashMap, project_name: &str) -> String { - let context = env_vars.get("OKENA_BRANCH") +fn build_hook_label( + hook_type: &str, + env_vars: &HashMap, + project_name: &str, +) -> String { + let context = env_vars + .get("OKENA_BRANCH") .map(|s| s.as_str()) .unwrap_or(project_name); format!("{} ({})", hook_type, context) @@ -312,7 +490,16 @@ fn run_hook_actions( for action in actions { match action { HookAction::Background(cmd) => { - if let Some(result) = run_hook(cmd, env_vars.clone(), monitor, hook_type, project_name, runner, project_id, keep_alive) { + if let Some(result) = run_hook( + cmd, + env_vars.clone(), + monitor, + hook_type, + project_name, + runner, + project_id, + keep_alive, + ) { hook_results.push(result); } } @@ -351,11 +538,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() } @@ -379,15 +568,29 @@ fn run_hook( ) -> Option { // PTY path: create a real terminal so output is visible in the service panel if let Some(runner) = runner { - let project_path = env_vars.get("OKENA_PROJECT_PATH").cloned().unwrap_or_default(); + let project_path = env_vars + .get("OKENA_PROJECT_PATH") + .cloned() + .unwrap_or_default(); let label = build_hook_label(hook_type, &env_vars, project_name); - let resolved_cwd = if project_path.is_empty() { ".".to_string() } else { project_path.clone() }; + let resolved_cwd = if project_path.is_empty() { + ".".to_string() + } else { + project_path.clone() + }; match runner.create_hook_terminal(&command, &env_vars, &project_path, keep_alive) { Ok((terminal_id, full_cmd)) => { // exec_id not needed — PTY hooks are finished via finish_by_terminal_id - let _ = monitor.map(|m| m.record_start(hook_type, &command, project_name, Some(terminal_id.clone()))); - log::info!("Hook '{}' started in terminal {} (label: {})", hook_type, terminal_id, label); + let _ = monitor.map(|m| { + m.record_start(hook_type, &command, project_name, Some(terminal_id.clone())) + }); + log::info!( + "Hook '{}' started in terminal {} (label: {})", + hook_type, + terminal_id, + label + ); return Some(HookTerminalResult { terminal_id, label, @@ -433,26 +636,28 @@ fn run_hook( } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let exit_code = output.status.code().unwrap_or(-1); - log::warn!( - "Hook command failed (exit {}): {}", - exit_code, - stderr, - ); + log::warn!("Hook command failed (exit {}): {}", exit_code, stderr,); if let (Some(monitor), Some(id)) = (&monitor_clone, exec_id) { - monitor.record_finish(id, HookStatus::Failed { - duration, - exit_code, - stderr, - }); + monitor.record_finish( + id, + HookStatus::Failed { + duration, + exit_code, + stderr, + }, + ); } } } Err(e) => { log::error!("Failed to execute hook command '{}': {}", command, e); if let (Some(monitor), Some(id)) = (&monitor_clone, exec_id) { - monitor.record_finish(id, HookStatus::SpawnError { - message: e.to_string(), - }); + monitor.record_finish( + id, + HookStatus::SpawnError { + message: e.to_string(), + }, + ); } } } @@ -477,31 +682,48 @@ fn run_hook_sync( // PTY path: requires both runner and monitor (monitor provides the exit waiter channel). // If runner exists but monitor is missing, fall through to headless execution. if let (Some(runner), Some(monitor)) = (runner, monitor) { - let project_path = env_vars.get("OKENA_PROJECT_PATH").cloned().unwrap_or_default(); + let project_path = env_vars + .get("OKENA_PROJECT_PATH") + .cloned() + .unwrap_or_default(); let label = build_hook_label(hook_type, &env_vars, project_name); - let resolved_cwd = if project_path.is_empty() { ".".to_string() } else { project_path.clone() }; + let resolved_cwd = if project_path.is_empty() { + ".".to_string() + } else { + project_path.clone() + }; - let (terminal_id, full_cmd) = runner.create_hook_terminal(command, &env_vars, &project_path, false)?; + let exit_reservation = monitor.reserve_exit_waiter(); + let (terminal_id, full_cmd) = + runner.create_hook_terminal(command, &env_vars, &project_path, false)?; // exec_id not needed — PTY hooks are finished via finish_by_terminal_id let _ = monitor.record_start(hook_type, command, project_name, Some(terminal_id.clone())); // Register exit waiter and block until the PTY process exits (5 min timeout) - let rx = monitor.register_exit_waiter(&terminal_id); - - let exit_code = rx.recv_timeout(std::time::Duration::from_secs(300)) - .map_err(|e| match e { - std::sync::mpsc::RecvTimeoutError::Timeout => { - format!("Hook '{}' timed out after 5 minutes — dismiss it from the sidebar to unblock", hook_type) - } - std::sync::mpsc::RecvTimeoutError::Disconnected => { - "Hook terminal exit channel closed unexpectedly".to_string() - } - })?; + let rx = exit_reservation.bind(&terminal_id); + + let exit_code = match rx.recv_timeout(std::time::Duration::from_secs(300)) { + Ok(exit_code) => exit_code, + Err(error) => { + monitor.cancel_exit_waiter(&terminal_id); + runner.backend.kill(&terminal_id); + runner.terminals.lock().remove(&terminal_id); + monitor.finish_by_terminal_id(&terminal_id, None); + return Err(match error { + std::sync::mpsc::RecvTimeoutError::Timeout => { + format!("Hook '{}' timed out after 5 minutes", hook_type) + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "Hook terminal exit channel closed unexpectedly".to_string() + } + }); + } + }; - // Do NOT call record_finish here — the main thread's handle_hook_terminal_exits - // calls finish_by_terminal_id which is the sole authority for PTY hook completion - // (avoids duplicate toast notifications). + // The PTY loop normally finishes this first. The idempotent fallback covers + // a very fast exit that arrived before the execution and waiter were registered. + monitor.finish_by_terminal_id(&terminal_id, exit_code); let success = exit_code == Some(0); if success { @@ -515,10 +737,15 @@ fn run_hook_sync( })); } else { let code = exit_code.map(|c| c as i32).unwrap_or(-1); + runner.backend.kill(&terminal_id); + runner.terminals.lock().remove(&terminal_id); return Err(format!("Hook failed (exit {})", code)); } } else if runner.is_some() { - log::warn!("HookRunner available but no HookMonitor for sync hook '{}'; falling back to headless", hook_type); + log::warn!( + "HookRunner available but no HookMonitor for sync hook '{}'; falling back to headless", + hook_type + ); } // Fallback: headless execution @@ -530,14 +757,18 @@ fn run_hook_sync( .lane(okena_core::process::Lane::Long) .label("hook") .timeout(std::time::Duration::from_secs(300)); - let output = okena_core::process::run(spec) - .map_err(|e| { - let msg = format!("Failed to execute hook '{}': {}", command, e); - if let (Some(monitor), Some(id)) = (monitor, exec_id) { - monitor.record_finish(id, HookStatus::SpawnError { message: e.to_string() }); - } - msg - })?; + let output = okena_core::process::run(spec).map_err(|e| { + let msg = format!("Failed to execute hook '{}': {}", command, e); + if let (Some(monitor), Some(id)) = (monitor, exec_id) { + monitor.record_finish( + id, + HookStatus::SpawnError { + message: e.to_string(), + }, + ); + } + msg + })?; let duration = start.elapsed(); if output.status.success() { @@ -549,13 +780,16 @@ fn run_hook_sync( let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let exit_code = output.status.code().unwrap_or(-1); if let (Some(monitor), Some(id)) = (monitor, exec_id) { - monitor.record_finish(id, HookStatus::Failed { duration, exit_code, stderr: stderr.clone() }); + monitor.record_finish( + id, + HookStatus::Failed { + duration, + exit_code, + stderr: stderr.clone(), + }, + ); } - Err(format!( - "Hook failed (exit {}): {}", - exit_code, - stderr, - )) + Err(format!("Hook failed (exit {}): {}", exit_code, stderr,)) } } @@ -581,6 +815,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 +827,31 @@ 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) { + 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 + ); + if let Some(result) = run_hook( + cmd, + env, + monitor, + "on_project_open", + project_name, + runner, + project_id, + true, + ) { return vec![result]; } } @@ -605,6 +860,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 +871,73 @@ 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); + 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 + ); + run_hook( + cmd, + env, + monitor, + "on_project_close", + project_name, + None, + project_id, + true, + ); } } +/// Run `on_project_close` to completion without creating a project-owned PTY. +pub fn fire_on_project_close_headless_sync( + project_hooks: &HooksConfig, + project_id: &str, + project_name: &str, + project_path: &str, + folder_id: Option<&str>, + folder_name: Option<&str>, + global_hooks: &HooksConfig, + monitor: Option<&HookMonitor>, +) -> Result<(), String> { + let Some(command) = resolve_hook(project_hooks, global_hooks, |h| &h.project.on_close) else { + return Ok(()); + }; + 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 + ); + run_hook_sync( + &command, + env, + monitor, + "on_project_close", + project_name, + None, + project_id, + )?; + Ok(()) +} + /// 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 +947,29 @@ 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); + 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 +978,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,20 +990,111 @@ 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); + 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); + log::info!( + "Running on_worktree_close hook for project '{}' (branch: {})", + project_name, + branch + ); + run_hook( + cmd, + env, + monitor, + "on_worktree_close", + project_name, + None, + project_id, + true, + ); } } +/// Run `on_worktree_close` to completion while its checkout still exists. +#[allow(clippy::too_many_arguments)] +pub fn fire_on_worktree_close_headless_sync( + 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, + monitor: Option<&HookMonitor>, +) -> Result<(), String> { + let Some(command) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_close) else { + return Ok(()); + }; + 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 + ); + run_hook_sync( + &command, + env, + monitor, + "on_worktree_close", + project_name, + None, + project_id, + )?; + Ok(()) +} + +/// 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> { +fn run_hook_sync_bare( + command: &str, + env_vars: HashMap, +) -> Result, String> { run_hook_sync(command, env_vars, None, "", "", None, "") } @@ -687,7 +1109,13 @@ fn merge_env( folder_id: Option<&str>, folder_name: Option<&str>, ) -> HashMap { - let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); + let mut env = project_env( + project_id, + project_name, + project_path, + folder_id, + folder_name, + ); env.insert("OKENA_BRANCH".into(), branch.into()); env.insert("OKENA_TARGET_BRANCH".into(), target_branch.into()); env.insert("OKENA_MAIN_REPO_PATH".into(), main_repo_path.into()); @@ -710,9 +1138,26 @@ pub fn fire_pre_merge( runner: Option<&HookRunner>, ) -> Result, String> { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.pre_merge) { - let env = merge_env(project_id, project_name, project_path, branch, target_branch, main_repo_path, folder_id, folder_name); + let env = merge_env( + project_id, + project_name, + project_path, + branch, + target_branch, + main_repo_path, + folder_id, + folder_name, + ); log::info!("Running pre_merge hook for project '{}'", project_name); - return run_hook_sync(&cmd, env, monitor, "pre_merge", project_name, runner, project_id); + return run_hook_sync( + &cmd, + env, + monitor, + "pre_merge", + project_name, + runner, + project_id, + ); } Ok(None) } @@ -733,15 +1178,74 @@ pub fn fire_post_merge( runner: Option<&HookRunner>, ) -> Vec { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.post_merge) { - let env = merge_env(project_id, project_name, project_path, branch, target_branch, main_repo_path, folder_id, folder_name); + let env = merge_env( + project_id, + project_name, + project_path, + branch, + target_branch, + main_repo_path, + folder_id, + folder_name, + ); log::info!("Running post_merge hook for project '{}'", project_name); - if let Some(result) = run_hook(cmd, env, monitor, "post_merge", project_name, runner, project_id, true) { + if let Some(result) = run_hook( + cmd, + env, + monitor, + "post_merge", + project_name, + runner, + project_id, + true, + ) { return vec![result]; } } Vec::new() } +/// Run `post_merge` synchronously without creating a PTY or detached thread. +pub fn fire_post_merge_headless_sync( + project_hooks: &HooksConfig, + global_hooks: &HooksConfig, + project_id: &str, + project_name: &str, + project_path: &str, + branch: &str, + target_branch: &str, + main_repo_path: &str, + folder_id: Option<&str>, + folder_name: Option<&str>, + monitor: Option<&HookMonitor>, +) -> Result<(), String> { + let Some(command) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.post_merge) + else { + return Ok(()); + }; + let env = merge_env( + project_id, + project_name, + project_path, + branch, + target_branch, + main_repo_path, + folder_id, + folder_name, + ); + log::info!("Running post_merge hook synchronously for project '{project_name}'"); + run_hook_sync( + &command, + env, + monitor, + "post_merge", + project_name, + None, + project_id, + )?; + Ok(()) +} + /// Fire the `before_worktree_remove` hook synchronously. Returns Err if hook fails. pub fn fire_before_worktree_remove( project_hooks: &HooksConfig, @@ -757,11 +1261,28 @@ pub fn fire_before_worktree_remove( runner: Option<&HookRunner>, ) -> Result, String> { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.before_remove) { - let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); + let mut env = project_env( + project_id, + project_name, + project_path, + folder_id, + folder_name, + ); env.insert("OKENA_BRANCH".into(), branch.into()); env.insert("OKENA_MAIN_REPO_PATH".into(), main_repo_path.into()); - log::info!("Running before_worktree_remove hook for project '{}'", project_name); - return run_hook_sync(&cmd, env, monitor, "before_worktree_remove", project_name, runner, project_id); + log::info!( + "Running before_worktree_remove hook for project '{}'", + project_name + ); + return run_hook_sync( + &cmd, + env, + monitor, + "before_worktree_remove", + project_name, + runner, + project_id, + ); } Ok(None) } @@ -783,20 +1304,82 @@ pub fn fire_before_worktree_remove_async( runner: Option<&HookRunner>, ) -> Vec { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.before_remove) { - let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); + let mut env = project_env( + project_id, + project_name, + project_path, + folder_id, + folder_name, + ); env.insert("OKENA_BRANCH".into(), branch.into()); env.insert("OKENA_MAIN_REPO_PATH".into(), main_repo_path.into()); - log::info!("Running before_worktree_remove hook (async) for project '{}'", project_name); - if let Some(result) = run_hook(cmd, env, monitor, "before_worktree_remove", project_name, runner, project_id, false) { + log::info!( + "Running before_worktree_remove hook (async) for project '{}'", + project_name + ); + if let Some(result) = run_hook( + cmd, + env, + monitor, + "before_worktree_remove", + project_name, + runner, + project_id, + false, + ) { return vec![result]; } } Vec::new() } -/// Fire the `on_rebase_conflict` hook. -/// Background actions fire immediately. Returns terminal actions for the caller to spawn, -/// and any HookTerminalResult values from PTY-backed background commands. +/// Resolve `on_rebase_conflict` without spawning any PTYs. +#[allow(clippy::too_many_arguments)] +pub fn plan_on_rebase_conflict( + project_hooks: &HooksConfig, + global_hooks: &HooksConfig, + project_id: &str, + project_name: &str, + project_path: &str, + branch: &str, + target_branch: &str, + main_repo_path: &str, + rebase_error: &str, + folder_id: Option<&str>, + folder_name: Option<&str>, +) -> Option { + if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| { + &h.worktree.on_rebase_conflict + }) { + let mut env = merge_env( + project_id, + project_name, + project_path, + branch, + target_branch, + main_repo_path, + folder_id, + folder_name, + ); + env.insert("OKENA_REBASE_ERROR".into(), rebase_error.into()); + log::info!( + "Running on_rebase_conflict hook for project '{}'", + project_name + ); + return Some(HookActionPlan { + command: cmd, + env_vars: env, + hook_type: "on_rebase_conflict", + project_name: project_name.to_string(), + project_id: project_id.to_string(), + keep_alive: true, + }); + } + None +} + +/// Fire the `on_rebase_conflict` hook immediately. +#[allow(clippy::too_many_arguments)] pub fn fire_on_rebase_conflict( project_hooks: &HooksConfig, global_hooks: &HooksConfig, @@ -812,13 +1395,22 @@ pub fn fire_on_rebase_conflict( monitor: Option<&HookMonitor>, runner: Option<&HookRunner>, ) -> HookActionOutcome { - if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_rebase_conflict) { - let mut env = merge_env(project_id, project_name, project_path, branch, target_branch, main_repo_path, folder_id, folder_name); - env.insert("OKENA_REBASE_ERROR".into(), rebase_error.into()); - log::info!("Running on_rebase_conflict hook for project '{}'", project_name); - return run_hook_actions(&cmd, env, monitor, "on_rebase_conflict", project_name, runner, project_id, true); - } - (Vec::new(), Vec::new()) + let Some(plan) = plan_on_rebase_conflict( + project_hooks, + global_hooks, + project_id, + project_name, + project_path, + branch, + target_branch, + main_repo_path, + rebase_error, + folder_id, + folder_name, + ) else { + return (Vec::new(), Vec::new()); + }; + execute_hook_action_plan(plan, monitor, runner) } /// Fire the `on_dirty_worktree_close` hook. @@ -837,14 +1429,79 @@ pub fn fire_on_dirty_worktree_close( runner: Option<&HookRunner>, ) -> HookActionOutcome { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_dirty_close) { - let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); + 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_dirty_worktree_close hook for project '{}'", project_name); - return run_hook_actions(&cmd, env, monitor, "on_dirty_worktree_close", project_name, runner, project_id, true); + log::info!( + "Running on_dirty_worktree_close hook for project '{}'", + project_name + ); + return run_hook_actions( + &cmd, + env, + monitor, + "on_dirty_worktree_close", + project_name, + runner, + project_id, + true, + ); } (Vec::new(), Vec::new()) } +/// Run the dirty-close safety hook to completion without creating project-owned PTYs. +pub fn fire_on_dirty_worktree_close_headless( + project_hooks: &HooksConfig, + global_hooks: &HooksConfig, + project_id: &str, + project_name: &str, + project_path: &str, + branch: &str, + folder_id: Option<&str>, + folder_name: Option<&str>, + monitor: Option<&HookMonitor>, +) -> Result<(), String> { + let Some(command) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_dirty_close) + else { + return Ok(()); + }; + + 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_dirty_worktree_close hook for project '{}'", + project_name + ); + + for action in parse_hook_actions(&command) { + let command = match action { + HookAction::Background(command) | HookAction::Terminal(command) => command, + }; + run_hook_sync( + &command, + env.clone(), + monitor, + "on_dirty_worktree_close", + project_name, + None, + project_id, + )?; + } + Ok(()) +} + /// Fire the `worktree_removed` hook asynchronously. pub fn fire_worktree_removed( project_hooks: &HooksConfig, @@ -860,11 +1517,29 @@ pub fn fire_worktree_removed( runner: Option<&HookRunner>, ) -> Vec { if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.after_remove) { - let mut env = project_env(project_id, project_name, project_path, folder_id, folder_name); + let mut env = project_env( + project_id, + project_name, + project_path, + folder_id, + folder_name, + ); env.insert("OKENA_BRANCH".into(), branch.into()); env.insert("OKENA_MAIN_REPO_PATH".into(), main_repo_path.into()); - log::info!("Running worktree_removed hook for project '{}'", project_name); - if let Some(result) = run_hook(cmd, env, monitor, "worktree_removed", project_name, runner, project_id, true) { + log::info!( + "Running worktree_removed hook for project '{}'", + project_name + ); + if let Some(result) = run_hook( + cmd, + env, + monitor, + "worktree_removed", + project_name, + runner, + project_id, + true, + ) { return vec![result]; } } @@ -873,13 +1548,16 @@ 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>, global_hooks: &HooksConfig, _cx: &App, ) -> Option { - resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| &h.terminal.on_create) + resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| { + &h.terminal.on_create + }) } /// Resolve the `terminal.on_create` hook command (without GPUI context). @@ -889,23 +1567,137 @@ pub fn resolve_terminal_on_create_simple( parent_hooks: Option<&HooksConfig>, global_hooks: &HooksConfig, ) -> Option { - resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| &h.terminal.on_create) + resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| { + &h.terminal.on_create + }) } /// Apply the `terminal.on_create` command by wrapping the shell to run /// the command first, then `exec` into the original shell. -/// Environment variables are exported so they persist in the shell session. +/// On POSIX, environment variables are exported so they persist in the shell session. +/// Windows callers must use [`terminal_launch_plan`] to propagate environment safely. /// Produces: `sh -c 'export K=V; ...; ; exec '` -pub fn apply_on_create(shell: &ShellType, on_create_cmd: &str, env_vars: &HashMap) -> ShellType { +pub fn apply_on_create( + shell: &ShellType, + on_create_cmd: &str, + env_vars: &HashMap, +) -> ShellType { let shell_cmd = shell.to_command_string(); let prefix = build_export_prefix(env_vars); let script = format!("{}{}; exec {}", prefix, on_create_cmd, shell_cmd); 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( +/// Compose create-only hooks without losing the shell used for backend routing. +pub fn terminal_launch_plan( + shell: ShellType, + shell_wrapper: Option<&str>, + on_create: Option<&str>, + env_vars: &HashMap, +) -> TerminalLaunchPlan { + if shell_wrapper.is_none() && on_create.is_none() { + return TerminalLaunchPlan::for_shell(shell); + } + + let (program, args, handoff, separator, needs_handoff) = terminal_launch_parts(&shell); + let wrapped = shell_wrapper + .map(|wrapper| wrapper.replace("{shell}", &handoff)) + .unwrap_or_else(|| handoff.clone()); + let body = match (on_create, shell_wrapper, needs_handoff) { + (Some(command), _, true) => format!("{command}{separator}{wrapped}"), + (Some(command), None, false) => command.to_string(), + (Some(command), Some(_), false) => format!("{command}{separator}{wrapped}"), + (None, Some(_), _) => wrapped, + (None, None, _) => unreachable!("empty hooks returned above"), + }; + let mut command_args = args; + command_args.push(body); + TerminalLaunchPlan { + route: shell, + initial_command: Some(TerminalLaunchCommand { + program, + args: command_args, + }), + environment: safe_hook_environment(env_vars), + } +} + +fn terminal_launch_parts(shell: &ShellType) -> (String, Vec, String, &'static str, bool) { + #[cfg(windows)] + match shell { + ShellType::Cmd | ShellType::Default => { + return ( + "cmd.exe".to_string(), + vec!["/D".to_string(), "/S".to_string(), "/C".to_string()], + "cmd.exe /K".to_string(), + " & ", + true, + ); + } + ShellType::PowerShell { core } => { + let program = if *core { "pwsh.exe" } else { "powershell.exe" }; + return ( + program.to_string(), + vec![ + "-NoLogo".to_string(), + "-NoExit".to_string(), + "-Command".to_string(), + ], + format!("{program} -NoLogo -NoExit"), + "; ", + false, + ); + } + ShellType::Wsl { .. } => { + return ( + "sh".to_string(), + vec!["-lc".to_string()], + "exec \"${SHELL:-sh}\"".to_string(), + "; ", + true, + ); + } + ShellType::Custom { path, args } => { + let mut command_args = args.clone(); + command_args.push("-ic".to_string()); + return ( + path.clone(), + command_args, + format!("exec {}", shell.to_command_string()), + "; ", + true, + ); + } + } + + #[cfg(not(windows))] + { + let (program, mut args) = match shell { + ShellType::Custom { path, args } => (path.clone(), args.clone()), + ShellType::Default => ( + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()), + Vec::new(), + ), + }; + args.push("-ic".to_string()); + ( + program, + args, + format!("exec {}", shell.to_command_string()), + "; ", + true, + ) + } +} + +/// 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,10 +1710,18 @@ 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); + 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, + ); env.insert("OKENA_TERMINAL_ID".into(), terminal_id.into()); if let Some(name) = terminal_name { env.insert("OKENA_TERMINAL_NAME".into(), name.into()); @@ -938,12 +1738,61 @@ pub fn fire_terminal_on_close( env.insert("OKENA_BRANCH".into(), branch); } } - 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); + log::info!( + "Running terminal.on_close hook for terminal '{}'", + terminal_id + ); + 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( @@ -951,19 +1800,26 @@ pub fn resolve_shell_wrapper( parent_hooks: Option<&HooksConfig>, global_hooks: &HooksConfig, ) -> Option { - resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| &h.terminal.shell_wrapper) + resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| { + &h.terminal.shell_wrapper + }) } /// Apply shell_wrapper to a ShellType, producing a new ShellType. /// The wrapper template uses `{shell}` as a placeholder for the resolved shell command. -/// Environment variables are exported so they persist in the shell session. +/// On POSIX, environment variables are exported so they persist in the shell session. +/// Windows callers must use [`terminal_launch_plan`] to propagate environment safely. /// /// If the result contains shell metacharacters (`&&`, `||`, `;`, `|`), it is wrapped /// in `sh -c` for proper execution. Otherwise, it is split into executable + args directly, /// avoiding an extra `sh` process layer (important for session backends like dtach/tmux). /// /// The shell is expected to be already resolved (not `ShellType::Default`). -pub fn apply_shell_wrapper(shell: &ShellType, wrapper: &str, env_vars: &HashMap) -> ShellType { +pub fn apply_shell_wrapper( + shell: &ShellType, + wrapper: &str, + env_vars: &HashMap, +) -> ShellType { let shell_cmd = shell.to_command_string(); // Replace {shell} with `exec ` so the shell replaces the wrapper process. // This is critical for session backends (dtach/tmux) that monitor the top-level process. @@ -991,14 +1847,68 @@ mod tests { assert!(result.is_err()); } + #[test] + fn pty_sync_failure_releases_terminal_ownership() { + use okena_terminal::backend::LocalBackend; + use okena_terminal::pty_manager::{PtyEvent, PtyManager}; + use okena_terminal::session_backend::SessionBackend; + + let monitor = HookMonitor::new(); + let (pty_manager, 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 = Default::default(); + let runner = HookRunner::new(backend, terminals.clone()); + let event_monitor = monitor.clone(); + let event_thread = std::thread::spawn(move || { + loop { + if let PtyEvent::Exit { + terminal_id, + exit_code, + .. + } = events.recv_blocking().expect("receive hook PTY event") + { + event_monitor.notify_exit(&terminal_id, exit_code); + return terminal_id; + } + } + }); + + let result = run_hook_sync( + "exit 7", + HashMap::new(), + Some(&monitor), + "pre_merge", + "Project", + Some(&runner), + "p1", + ); + let terminal_id = event_thread.join().expect("event thread joins"); + + assert!(matches!(result, Err(message) if message == "Hook failed (exit 7)")); + assert!(!terminals.lock().contains_key(&terminal_id)); + assert!(pty_manager.current_generation(&terminal_id).is_none()); + assert!(matches!( + monitor.history()[0].status, + HookStatus::Failed { exit_code: 7, .. } + )); + pty_manager.flush_teardown(); + } + #[test] fn resolve_hook_prefers_project_over_global() { let project = HooksConfig { - worktree: WorktreeHooks { pre_merge: Some("project-cmd".into()), ..Default::default() }, + worktree: WorktreeHooks { + pre_merge: Some("project-cmd".into()), + ..Default::default() + }, ..Default::default() }; let global = HooksConfig { - worktree: WorktreeHooks { pre_merge: Some("global-cmd".into()), ..Default::default() }, + worktree: WorktreeHooks { + pre_merge: Some("global-cmd".into()), + ..Default::default() + }, ..Default::default() }; let resolved = resolve_hook(&project, &global, |h| &h.worktree.pre_merge); @@ -1009,7 +1919,10 @@ mod tests { fn resolve_hook_falls_back_to_global() { let project = HooksConfig::default(); let global = HooksConfig { - worktree: WorktreeHooks { pre_merge: Some("global-cmd".into()), ..Default::default() }, + worktree: WorktreeHooks { + pre_merge: Some("global-cmd".into()), + ..Default::default() + }, ..Default::default() }; let resolved = resolve_hook(&project, &global, |h| &h.worktree.pre_merge); @@ -1040,7 +1953,8 @@ mod tests { #[test] fn parse_hook_actions_mixed_multiline() { - let actions = parse_hook_actions("terminal: claude -p \"fix\"\necho logged\n\nterminal: htop"); + let actions = + parse_hook_actions("terminal: claude -p \"fix\"\necho logged\n\nterminal: htop"); assert_eq!(actions.len(), 3); assert!(matches!(&actions[0], HookAction::Terminal(cmd) if cmd == "claude -p \"fix\"")); assert!(matches!(&actions[1], HookAction::Background(cmd) if cmd == "echo logged")); @@ -1065,23 +1979,131 @@ mod tests { fn run_hook_actions_returns_terminal_actions() { let mut env = HashMap::new(); env.insert("KEY".into(), "val".into()); - let (terminal_actions, _hook_results) = run_hook_actions("terminal: my-cmd\necho bg", env, None, "test", "proj", None, "proj-id", true); + let (terminal_actions, _hook_results) = run_hook_actions( + "terminal: my-cmd\necho bg", + env, + None, + "test", + "proj", + None, + "proj-id", + true, + ); assert_eq!(terminal_actions.len(), 1); assert_eq!(terminal_actions[0].0, "my-cmd"); assert_eq!(terminal_actions[0].1.get("KEY").unwrap(), "val"); } + #[test] + fn rebase_conflict_plan_defers_actions_until_execution() { + let hooks = HooksConfig { + worktree: WorktreeHooks { + on_rebase_conflict: Some("terminal: fix-conflict".into()), + ..Default::default() + }, + ..Default::default() + }; + let plan = plan_on_rebase_conflict( + &hooks, + &HooksConfig::default(), + "p1", + "Project", + ".", + "feature", + "main", + ".", + "conflict", + None, + None, + ) + .unwrap(); + + let (terminal_actions, hook_results) = execute_hook_action_plan(plan, None, None); + + assert_eq!(terminal_actions.len(), 1); + assert_eq!(terminal_actions[0].0, "fix-conflict"); + assert!(hook_results.is_empty()); + } + + #[test] + fn dirty_close_terminal_actions_run_headless_to_completion() { + let hooks = HooksConfig { + worktree: WorktreeHooks { + on_dirty_close: Some("terminal: true".into()), + ..Default::default() + }, + ..Default::default() + }; + let monitor = HookMonitor::new(); + + fire_on_dirty_worktree_close_headless( + &hooks, + &HooksConfig::default(), + "project-id", + "project", + ".", + "feature", + None, + None, + Some(&monitor), + ) + .expect("headless dirty-close action should succeed"); + + let history = monitor.history(); + assert_eq!(history.len(), 1); + assert!(history[0].terminal_id.is_none()); + assert!(matches!(history[0].status, HookStatus::Succeeded { .. })); + } + + #[test] + fn post_merge_headless_sync_finishes_before_returning() { + let hooks = HooksConfig { + worktree: WorktreeHooks { + post_merge: Some("true".into()), + ..Default::default() + }, + ..Default::default() + }; + let monitor = HookMonitor::new(); + + fire_post_merge_headless_sync( + &hooks, + &HooksConfig::default(), + "project-id", + "project", + ".", + "feature", + "main", + ".", + None, + None, + Some(&monitor), + ) + .expect("post_merge should complete synchronously"); + + let history = monitor.history(); + assert_eq!(history.len(), 1); + assert!(history[0].terminal_id.is_none()); + assert!(matches!(history[0].status, HookStatus::Succeeded { .. })); + } + #[test] fn build_hook_label_uses_branch() { let mut env = HashMap::new(); env.insert("OKENA_BRANCH".into(), "feature/foo".into()); - assert_eq!(build_hook_label("on_project_open", &env, "my-project"), "on_project_open (feature/foo)"); + assert_eq!( + build_hook_label("on_project_open", &env, "my-project"), + "on_project_open (feature/foo)" + ); } #[test] fn build_hook_label_falls_back_to_project_name() { let env = HashMap::new(); - assert_eq!(build_hook_label("on_project_open", &env, "my-project"), "on_project_open (my-project)"); + assert_eq!( + build_hook_label("on_project_open", &env, "my-project"), + "on_project_open (my-project)" + ); } #[test] @@ -1090,16 +2112,23 @@ mod tests { let project = HooksConfig::default(); let parent = HooksConfig { - terminal: TerminalHooks { on_create: Some("parent-cmd".into()), ..Default::default() }, + terminal: TerminalHooks { + on_create: Some("parent-cmd".into()), + ..Default::default() + }, ..Default::default() }; let global = HooksConfig { - terminal: TerminalHooks { on_create: Some("global-cmd".into()), ..Default::default() }, + terminal: TerminalHooks { + on_create: Some("global-cmd".into()), + ..Default::default() + }, ..Default::default() }; // Project empty → falls through to parent - let resolved = resolve_hook_with_parent(&project, Some(&parent), &global, |h| &h.terminal.on_create); + let resolved = + resolve_hook_with_parent(&project, Some(&parent), &global, |h| &h.terminal.on_create); assert_eq!(resolved, Some("parent-cmd".into())); // Project empty, no parent → falls through to global @@ -1108,10 +2137,15 @@ mod tests { // Project set → wins over parent and global let project_with_hook = HooksConfig { - terminal: TerminalHooks { on_create: Some("project-cmd".into()), ..Default::default() }, + terminal: TerminalHooks { + on_create: Some("project-cmd".into()), + ..Default::default() + }, ..Default::default() }; - let resolved = resolve_hook_with_parent(&project_with_hook, Some(&parent), &global, |h| &h.terminal.on_create); + let resolved = resolve_hook_with_parent(&project_with_hook, Some(&parent), &global, |h| { + &h.terminal.on_create + }); assert_eq!(resolved, Some("project-cmd".into())); } @@ -1132,6 +2166,7 @@ mod tests { assert!(!is_valid_env_key("FOO=BAR")); } + #[cfg(not(windows))] #[test] fn apply_shell_wrapper_simple() { use super::apply_shell_wrapper; @@ -1145,12 +2180,17 @@ mod tests { ShellType::Custom { path: _, args } => { // for_command uses $SHELL -ic on Unix assert!(args[0] == "-c" || args[0] == "-ic", "got: {}", args[0]); - assert!(args[1].contains("devcontainer exec -- exec /bin/zsh --login"), "got: {}", args[1]); + assert!( + args[1].contains("devcontainer exec -- exec /bin/zsh --login"), + "got: {}", + args[1] + ); } other => panic!("Expected ShellType::Custom, got: {:?}", other), } } + #[cfg(not(windows))] #[test] fn apply_shell_wrapper_with_metacharacters() { use super::apply_shell_wrapper; @@ -1164,7 +2204,11 @@ mod tests { ShellType::Custom { path: _, args } => { // for_command uses $SHELL -ic on Unix assert!(args[0] == "-c" || args[0] == "-ic", "got: {}", args[0]); - assert!(args[1].contains("echo hello && exec /bin/zsh"), "got: {}", args[1]); + assert!( + args[1].contains("echo hello && exec /bin/zsh"), + "got: {}", + args[1] + ); } other => panic!("Expected ShellType::Custom, got: {:?}", other), } @@ -1179,11 +2223,13 @@ mod tests { assert_eq!(shell.to_command_string(), "/usr/bin/fish"); } + #[cfg(not(windows))] #[test] fn build_export_prefix_empty() { assert_eq!(build_export_prefix(&HashMap::new()), ""); } + #[cfg(not(windows))] #[test] fn build_export_prefix_single_var() { let mut env = HashMap::new(); @@ -1198,6 +2244,7 @@ mod tests { } } + #[cfg(not(windows))] #[test] fn build_export_prefix_escapes_single_quotes() { let mut env = HashMap::new(); @@ -1205,10 +2252,15 @@ mod tests { let prefix = build_export_prefix(&env); if !cfg!(windows) { // POSIX: single quotes with '\'' escaping - assert!(prefix.contains("'\\''"), "Expected single-quote escape in: {}", prefix); + assert!( + prefix.contains("'\\''"), + "Expected single-quote escape in: {}", + prefix + ); } } + #[cfg(not(windows))] #[test] fn build_export_prefix_filters_invalid_keys() { let mut env = HashMap::new(); @@ -1221,6 +2273,37 @@ mod tests { assert!(!prefix.contains("123BAD"), "got: {}", prefix); } + #[cfg(not(windows))] + #[test] + fn keep_alive_hook_shell_reports_exit_and_reopens_shell_on_unix() { + let shell = build_keep_alive_hook_shell("export KEY='value'; false", false); + let ShellType::Custom { args, .. } = shell else { + panic!("expected custom command shell"); + }; + let script = &args[1]; + + assert!(script.contains("export KEY='value'; false")); + assert!(script.contains("__okena_hook_exit:%d")); + assert!(script.contains("exec \"${SHELL:-sh}\"")); + } + + #[test] + fn keep_alive_hook_shell_uses_cmd_completion_protocol_on_windows() { + let shell = build_keep_alive_hook_shell("set \"KEY=value\" && exit /B 4", true); + let ShellType::Custom { path, args } = shell else { + panic!("expected custom command shell"); + }; + + assert_eq!(path, "cmd"); + assert_eq!(args[0], "/V:ON"); + assert_eq!(args[1], "/C"); + assert!(args[2].contains("!ERRORLEVEL!")); + assert!(args[2].contains("__okena_hook_exit:!__okena_rc!")); + assert!(args[2].contains("cmd /K")); + assert!(!args[2].contains("${SHELL:-sh}")); + } + + #[cfg(not(windows))] #[test] fn apply_on_create_with_env_vars() { let shell = ShellType::Custom { @@ -1241,6 +2324,99 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn cmd_on_create_uses_cmd_handoff_without_posix_exec() { + let plan = terminal_launch_plan(ShellType::Cmd, None, Some("echo ready"), &HashMap::new()); + let command = plan.initial_command.expect("create command"); + + assert_eq!(plan.route, ShellType::Cmd); + assert_eq!(command.program, "cmd.exe"); + assert_eq!(&command.args[..3], ["/D", "/S", "/C"]); + assert!(command.args[3].contains("echo ready & cmd.exe /K")); + assert!(!command.args[3].contains("; exec")); + } + + #[cfg(windows)] + #[test] + fn cmd_launch_keeps_hostile_environment_out_of_script() { + let hostile = "quote\" & echo owned>%TEMP%\\okena-sentinel !bang!\r\nnext".to_string(); + let env = HashMap::from([("OKENA_PROJECT_NAME".to_string(), hostile.clone())]); + let plan = terminal_launch_plan(ShellType::Cmd, None, Some("echo ready"), &env); + let command = plan.initial_command.expect("create command"); + + assert_eq!( + plan.environment, + vec![("OKENA_PROJECT_NAME".to_string(), hostile.clone())] + ); + assert!(!command.args.iter().any(|arg| arg.contains(&hostile))); + } + + #[cfg(windows)] + #[test] + fn rerunnable_cmd_hook_encodes_environment_and_command() { + use base64::Engine; + + let hostile = "quote\" & %PATH% !bang!\r\nnext"; + let env = HashMap::from([("OKENA_PROJECT_NAME".to_string(), hostile.to_string())]); + let rerunnable = windows_encoded_hook_command("echo ready", &env); + let encoded = rerunnable + .split_whitespace() + .last() + .expect("encoded command"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("base64 command"); + let utf16: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + let script = String::from_utf16(&utf16).expect("UTF-16LE command"); + + assert!(!script.contains(hostile)); + assert!(script.contains(&base64::engine::general_purpose::STANDARD.encode(hostile))); + assert!(script.contains(&base64::engine::general_purpose::STANDARD.encode("echo ready"))); + } + + #[cfg(windows)] + #[test] + fn powershell_on_create_uses_no_exit_command_argv() { + let plan = terminal_launch_plan( + ShellType::PowerShell { core: true }, + None, + Some("Write-Host ready"), + &HashMap::new(), + ); + let command = plan.initial_command.expect("create command"); + + assert_eq!(command.program, "pwsh.exe"); + assert_eq!(&command.args[..3], ["-NoLogo", "-NoExit", "-Command"]); + assert_eq!(command.args[3], "Write-Host ready"); + assert!(!command.args[3].contains("exec")); + } + + #[cfg(windows)] + #[test] + fn wsl_wrapper_and_on_create_preserve_wsl_route() { + let route = ShellType::Wsl { + distro: Some("Ubuntu".to_string()), + }; + let plan = terminal_launch_plan( + route.clone(), + Some("envbox -- {shell}"), + Some("echo ready"), + &HashMap::new(), + ); + let command = plan.initial_command.expect("create command"); + + assert_eq!(plan.route, route); + assert_eq!(command.program, "sh"); + assert_eq!(&command.args[..1], ["-lc"]); + assert!(command.args[1].contains("echo ready; envbox -- exec \"${SHELL:-sh}\"")); + assert!(!command.args[1].contains("cmd.exe")); + } + + #[cfg(not(windows))] #[test] fn apply_shell_wrapper_with_env_vars() { let shell = ShellType::Custom { diff --git a/crates/okena-hooks/src/lib.rs b/crates/okena-hooks/src/lib.rs index 8c5f9053a..6edd31d8b 100644 --- a/crates/okena-hooks/src/lib.rs +++ b/crates/okena-hooks/src/lib.rs @@ -17,10 +17,17 @@ pub mod hooks; pub use hook_monitor::{HookExecution, HookMonitor, HookStatus}; 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, + HookActionOutcome, HookActionPlan, HookRunner, HookTerminalResult, PreparedHookTerminal, + apply_shell_wrapper, execute_hook_action_plan, fire_before_worktree_remove, + fire_before_worktree_remove_async, fire_on_dirty_worktree_close, + fire_on_dirty_worktree_close_headless, fire_on_project_close, + fire_on_project_close_headless_sync, fire_on_project_open, fire_on_rebase_conflict, + fire_on_worktree_close_headless_sync, 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, plan_on_rebase_conflict, prepare_project_open_hook, 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-layout/src/lib.rs b/crates/okena-layout/src/lib.rs index b27db1edd..2de02ac55 100644 --- a/crates/okena-layout/src/lib.rs +++ b/crates/okena-layout/src/lib.rs @@ -48,7 +48,11 @@ impl LayoutNode { /// Returns true if this node is effectively hidden (all terminals within it are minimized or detached). pub fn is_all_hidden(&self) -> bool { match self { - LayoutNode::Terminal { minimized, detached, .. } => *minimized || *detached, + LayoutNode::Terminal { + minimized, + detached, + .. + } => *minimized || *detached, LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { children.iter().all(|c| c.is_all_hidden()) } @@ -71,6 +75,29 @@ impl LayoutNode { } } + /// Remove terminal leaves with matching IDs, collapsing their parents. + /// + /// The root is wrapped in `Option` because removing its last terminal leaves + /// no representable layout node. Returns the number of removed leaves. + pub fn remove_terminal_ids(layout: &mut Option, terminal_ids: &HashSet<&str>) -> usize { + let mut removed = 0; + for terminal_id in terminal_ids { + let Some(root) = layout.as_mut() else { + break; + }; + let Some(path) = root.find_terminal_path(terminal_id) else { + continue; + }; + if path.is_empty() { + *layout = None; + removed += 1; + } else if root.remove_at_path(&path).is_some() { + removed += 1; + } + } + removed + } + /// Recursively flip every `Split` in this subtree between horizontal and /// vertical. `Tabs` and `Terminal` nodes are unaffected (tabs have no /// orientation), but the walk descends into both so nested splits inside @@ -80,7 +107,11 @@ impl LayoutNode { pub fn transpose(&mut self) { match self { LayoutNode::Terminal { .. } => {} - LayoutNode::Split { direction, children, .. } => { + LayoutNode::Split { + direction, + children, + .. + } => { *direction = direction.flipped(); for child in children { child.transpose(); @@ -180,13 +211,28 @@ impl LayoutNode { } } + /// Whether any leaf still carries a terminal ID. Allocation-free counterpart + /// to `collect_terminal_ids().is_empty()` for render-path checks. + pub fn has_terminal_ids(&self) -> bool { + match self { + LayoutNode::Terminal { terminal_id, .. } => terminal_id.is_some(), + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + children.iter().any(LayoutNode::has_terminal_ids) + } + } + } + /// Clear terminal IDs except those in the `keep` set (e.g. hook terminals). /// Kept terminals preserve their ID, minimized, and detached state. pub fn clear_terminal_ids_except(&mut self, keep: &HashSet<&str>) { match self { - LayoutNode::Terminal { terminal_id, minimized, detached, .. } => { - let should_keep = terminal_id.as_deref() - .is_some_and(|id| keep.contains(id)); + LayoutNode::Terminal { + terminal_id, + minimized, + detached, + .. + } => { + let should_keep = terminal_id.as_deref().is_some_and(|id| keep.contains(id)); if !should_keep { *terminal_id = None; *minimized = false; @@ -206,7 +252,11 @@ impl LayoutNode { self.find_terminal_path_recursive(target_id, vec![]) } - fn find_terminal_path_recursive(&self, target_id: &str, current_path: Vec) -> Option> { + fn find_terminal_path_recursive( + &self, + target_id: &str, + current_path: Vec, + ) -> Option> { match self { LayoutNode::Terminal { terminal_id, .. } => { if terminal_id.as_deref() == Some(target_id) { @@ -219,7 +269,9 @@ impl LayoutNode { for (i, child) in children.iter().enumerate() { let mut child_path = current_path.clone(); child_path.push(i); - if let Some(found_path) = child.find_terminal_path_recursive(target_id, child_path) { + if let Some(found_path) = + child.find_terminal_path_recursive(target_id, child_path) + { return Some(found_path); } } @@ -241,9 +293,9 @@ impl LayoutNode { None } } - LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { - children.iter().find_map(|c| c.find_terminal_node(target_id)) - } + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => children + .iter() + .find_map(|c| c.find_terminal_node(target_id)), } } @@ -257,19 +309,27 @@ impl LayoutNode { /// - bare Terminal: wrapped together with `node` into a horizontal Split. pub fn append_to_root(&mut self, node: LayoutNode) { match self { - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { children.push(node); let n = children.len(); *sizes = vec![1.0 / n as f32; n]; } - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { children.push(node); *active_tab = children.len() - 1; } LayoutNode::Terminal { .. } => { let existing = std::mem::replace( self, - LayoutNode::Tabs { children: Vec::new(), active_tab: 0 }, + LayoutNode::Tabs { + children: Vec::new(), + active_tab: 0, + }, ); *self = LayoutNode::Split { direction: SplitDirection::Horizontal, @@ -288,20 +348,26 @@ impl LayoutNode { result } - fn collect_inactive_tabs_recursive(&self, result: &mut HashSet, is_behind_inactive_tab: bool) { + fn collect_inactive_tabs_recursive( + &self, + result: &mut HashSet, + is_behind_inactive_tab: bool, + ) { match self { LayoutNode::Terminal { terminal_id, .. } => { - if is_behind_inactive_tab - && let Some(id) = terminal_id { - result.insert(id.clone()); - } + if is_behind_inactive_tab && let Some(id) = terminal_id { + result.insert(id.clone()); + } } LayoutNode::Split { children, .. } => { for child in children { child.collect_inactive_tabs_recursive(result, is_behind_inactive_tab); } } - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { for (i, child) in children.iter().enumerate() { let inactive = is_behind_inactive_tab || i != *active_tab; child.collect_inactive_tabs_recursive(result, inactive); @@ -321,10 +387,9 @@ impl LayoutNode { fn collect_tab_group_recursive(&self, result: &mut HashSet, inside_tab_group: bool) { match self { LayoutNode::Terminal { terminal_id, .. } => { - if inside_tab_group - && let Some(id) = terminal_id { - result.insert(id.clone()); - } + if inside_tab_group && let Some(id) = terminal_id { + result.insert(id.clone()); + } } LayoutNode::Split { children, .. } => { for child in children { @@ -354,7 +419,10 @@ impl LayoutNode { child.activate_tabs_along_path(&path[1..]); } } - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { *active_tab = path[0]; if let Some(child) = children.get_mut(path[0]) { child.activate_tabs_along_path(&path[1..]); @@ -370,13 +438,20 @@ impl LayoutNode { result } - fn collect_minimized_recursive(&self, result: &mut Vec<(String, Vec)>, current_path: Vec) { + fn collect_minimized_recursive( + &self, + result: &mut Vec<(String, Vec)>, + current_path: Vec, + ) { match self { - LayoutNode::Terminal { terminal_id, minimized, .. } => { - if *minimized - && let Some(id) = terminal_id { - result.push((id.clone(), current_path)); - } + LayoutNode::Terminal { + terminal_id, + minimized, + .. + } => { + if *minimized && let Some(id) = terminal_id { + result.push((id.clone(), current_path)); + } } LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { for (i, child) in children.iter().enumerate() { @@ -395,13 +470,20 @@ impl LayoutNode { result } - fn collect_detached_recursive(&self, result: &mut Vec<(String, Vec)>, current_path: Vec) { + fn collect_detached_recursive( + &self, + result: &mut Vec<(String, Vec)>, + current_path: Vec, + ) { match self { - LayoutNode::Terminal { terminal_id, detached, .. } => { - if *detached - && let Some(id) = terminal_id { - result.push((id.clone(), current_path)); - } + LayoutNode::Terminal { + terminal_id, + detached, + .. + } => { + if *detached && let Some(id) = terminal_id { + result.push((id.clone(), current_path)); + } } LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { for (i, child) in children.iter().enumerate() { @@ -418,15 +500,21 @@ impl LayoutNode { self.find_uninitialized_terminal_path_recursive(vec![]) } - fn find_uninitialized_terminal_path_recursive(&self, current_path: Vec) -> Option> { + fn find_uninitialized_terminal_path_recursive( + &self, + current_path: Vec, + ) -> Option> { match self { - LayoutNode::Terminal { terminal_id: None, .. } => Some(current_path), + LayoutNode::Terminal { + terminal_id: None, .. + } => Some(current_path), LayoutNode::Terminal { .. } => None, LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { for (i, child) in children.iter().enumerate() { let mut child_path = current_path.clone(); child_path.push(i); - if let Some(path) = child.find_uninitialized_terminal_path_recursive(child_path) { + if let Some(path) = child.find_uninitialized_terminal_path_recursive(child_path) + { return Some(path); } } @@ -451,7 +539,11 @@ impl LayoutNode { self.find_terminal_path_recursive_impl(vec![], follow_active_tab) } - fn find_terminal_path_recursive_impl(&self, current_path: Vec, follow_active_tab: bool) -> Vec { + fn find_terminal_path_recursive_impl( + &self, + current_path: Vec, + follow_active_tab: bool, + ) -> Vec { match self { LayoutNode::Terminal { .. } => current_path, LayoutNode::Split { children, .. } => { @@ -463,7 +555,11 @@ impl LayoutNode { current_path } } - LayoutNode::Tabs { children, active_tab, .. } => { + LayoutNode::Tabs { + children, + active_tab, + .. + } => { let idx = if follow_active_tab { (*active_tab).min(children.len().saturating_sub(1)) } else { @@ -481,6 +577,7 @@ impl LayoutNode { } /// Remove a child node at the given path. + /// For split parents, transfers removed weight to the previous sibling, falling back to the next. /// If the parent has only one child left after removal, collapses the parent to that child. /// Returns the removed node, or None if the path is invalid. pub fn remove_at_path(&mut self, path: &[usize]) -> Option { @@ -495,13 +592,19 @@ impl LayoutNode { match parent { LayoutNode::Terminal { .. } => None, - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { if child_index >= children.len() { return None; } let removed = children.remove(child_index); if child_index < sizes.len() { - sizes.remove(child_index); + let removed_size = sizes.remove(child_index); + let recipient = child_index.saturating_sub(1); + if let Some(size) = sizes.get_mut(recipient) { + *size += removed_size; + } } if children.len() == 1 { let remaining = children.remove(0); @@ -509,7 +612,10 @@ impl LayoutNode { } Some(removed) } - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { if child_index >= children.len() { return None; } @@ -540,27 +646,40 @@ impl LayoutNode { } } - if let LayoutNode::Tabs { children, active_tab } = self { + if let LayoutNode::Tabs { + children, + active_tab, + } = self + { *active_tab = (*active_tab).min(children.len().saturating_sub(1)); } - if let LayoutNode::Split { sizes, children, .. } = self - && sizes.len() != children.len() { - sizes.truncate(children.len()); - while sizes.len() < children.len() { - sizes.push(100.0 / children.len() as f32); - } + if let LayoutNode::Split { + sizes, children, .. + } = self + && sizes.len() != children.len() + { + sizes.truncate(children.len()); + while sizes.len() < children.len() { + sizes.push(100.0 / children.len() as f32); } + } // Sizes are relative weights — the tiny-pair threshold is 10% of the total // sum so the check works regardless of overall scale. - if let LayoutNode::Split { sizes, children, .. } = self { + if let LayoutNode::Split { + sizes, children, .. + } = self + { let has_invalid = sizes.iter().any(|s| *s <= 0.0 || !s.is_finite()); let total: f32 = sizes.iter().sum(); let min_resize = total * 0.1; let has_tiny_pair = sizes.windows(2).any(|w| w[0] + w[1] <= min_resize); if has_invalid || has_tiny_pair { - log::warn!("Layout has invalid/too-small sizes {:?}, resetting to equal", sizes); + log::warn!( + "Layout has invalid/too-small sizes {:?}, resetting to equal", + sizes + ); let equal = 100.0 / children.len() as f32; for s in sizes.iter_mut() { *s = equal; @@ -569,7 +688,9 @@ impl LayoutNode { } let should_unwrap = match self { - LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => children.len() <= 1, + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + children.len() <= 1 + } _ => false, }; if should_unwrap { @@ -586,8 +707,15 @@ impl LayoutNode { return; } - if let LayoutNode::Split { direction, sizes, children } = self { - let has_same_dir_child = children.iter().any(|c| matches!(c, LayoutNode::Split { direction: d, .. } if d == direction)); + if let LayoutNode::Split { + direction, + sizes, + children, + } = self + { + let has_same_dir_child = children + .iter() + .any(|c| matches!(c, LayoutNode::Split { direction: d, .. } if d == direction)); if has_same_dir_child { let dir = *direction; let mut new_children = Vec::new(); @@ -596,7 +724,11 @@ impl LayoutNode { for (i, child) in children.drain(..).enumerate() { let parent_size = sizes[i]; match child { - LayoutNode::Split { direction: child_dir, sizes: child_sizes, children: grandchildren } if child_dir == dir => { + LayoutNode::Split { + direction: child_dir, + sizes: child_sizes, + children: grandchildren, + } if child_dir == dir => { let child_total: f32 = child_sizes.iter().sum(); for (j, grandchild) in grandchildren.into_iter().enumerate() { new_children.push(grandchild); @@ -620,19 +752,30 @@ impl LayoutNode { /// Used when creating worktree projects to duplicate layout with fresh terminals. pub fn clone_structure(&self) -> Self { match self { - LayoutNode::Terminal { shell_type, zoom_level, .. } => LayoutNode::Terminal { + LayoutNode::Terminal { + shell_type, + zoom_level, + .. + } => LayoutNode::Terminal { terminal_id: None, minimized: false, detached: false, shell_type: shell_type.clone(), zoom_level: *zoom_level, }, - LayoutNode::Split { direction, sizes, children } => LayoutNode::Split { + LayoutNode::Split { + direction, + sizes, + children, + } => LayoutNode::Split { direction: *direction, sizes: sizes.clone(), children: children.iter().map(|c| c.clone_structure()).collect(), }, - LayoutNode::Tabs { children, active_tab } => LayoutNode::Tabs { + LayoutNode::Tabs { + children, + active_tab, + } => LayoutNode::Tabs { children: children.iter().map(|c| c.clone_structure()).collect(), active_tab: *active_tab, }, @@ -642,67 +785,171 @@ impl LayoutNode { /// Merge server layout structure with locally-preserved visual state. /// /// Takes the structural layout from `server` (terminals, splits, tabs) but - /// preserves local visual state from `local` where the structure matches: - /// - **Terminal** with same ID → keep local `minimized` and `detached` - /// - **Split** with same direction + child count → keep local `sizes`, recurse children - /// - **Tabs** with same child count → keep local `active_tab`, recurse children - /// - **Mismatch** → use server's structure but apply visual state from matching terminals + /// preserves local visual state from `local` where the structure matches. + /// Children and selected tabs are reconciled by terminal identity so a + /// daemon-side reorder cannot attach presentation to a different pane. pub fn merge_visual_state(server: &LayoutNode, local: &LayoutNode) -> LayoutNode { + let mut result = LayoutNode::merge_container_visual_state(server, local); + let mut visual_states = HashMap::new(); + local.collect_terminal_visual_state(&mut visual_states); + result.apply_terminal_visual_state(&visual_states); + result + } + + fn merge_container_visual_state(server: &LayoutNode, local: &LayoutNode) -> LayoutNode { match (server, local) { + (LayoutNode::Terminal { .. }, _) => server.clone(), ( - LayoutNode::Terminal { terminal_id: s_id, shell_type, zoom_level, .. }, - LayoutNode::Terminal { terminal_id: l_id, minimized, detached, .. }, - ) if s_id == l_id => { - LayoutNode::Terminal { - terminal_id: s_id.clone(), - minimized: *minimized, - detached: *detached, - shell_type: shell_type.clone(), - zoom_level: *zoom_level, - } - } - ( - LayoutNode::Split { direction: s_dir, children: s_children, .. }, - LayoutNode::Split { direction: l_dir, sizes: l_sizes, children: l_children, .. }, - ) if s_dir == l_dir && s_children.len() == l_children.len() => { - let merged_children: Vec = s_children.iter() - .zip(l_children.iter()) - .map(|(sc, lc)| LayoutNode::merge_visual_state(sc, lc)) - .collect(); + LayoutNode::Split { + direction: s_dir, + sizes: s_sizes, + children: s_children, + }, + LayoutNode::Split { + direction: l_dir, + sizes: l_sizes, + children: l_children, + .. + }, + ) if s_dir == l_dir => { + let mapping = LayoutNode::matching_child_indices(s_children, l_children); + let merged_children = + LayoutNode::merge_mapped_children(s_children, l_children, mapping.as_deref()); + let sizes = mapping + .filter(|indices| l_sizes.len() == indices.len()) + .map(|indices| indices.into_iter().map(|index| l_sizes[index]).collect()) + .unwrap_or_else(|| s_sizes.clone()); LayoutNode::Split { direction: *s_dir, - sizes: l_sizes.clone(), + sizes, children: merged_children, } } ( - LayoutNode::Tabs { children: s_children, .. }, - LayoutNode::Tabs { children: l_children, active_tab: l_active, .. }, - ) if s_children.len() == l_children.len() => { - let merged_children: Vec = s_children.iter() - .zip(l_children.iter()) - .map(|(sc, lc)| LayoutNode::merge_visual_state(sc, lc)) - .collect(); + LayoutNode::Tabs { + children: s_children, + active_tab: s_active, + }, + LayoutNode::Tabs { + children: l_children, + active_tab: l_active, + .. + }, + ) => { + let mapping = LayoutNode::matching_child_indices(s_children, l_children); + let merged_children = + LayoutNode::merge_mapped_children(s_children, l_children, mapping.as_deref()); LayoutNode::Tabs { children: merged_children, - active_tab: *l_active, + active_tab: LayoutNode::merged_active_tab( + s_children, *s_active, l_children, *l_active, + ), } } - _ => { - let mut visual_states = HashMap::new(); - local.collect_terminal_visual_state(&mut visual_states); - let mut result = server.clone(); - result.apply_terminal_visual_state(&visual_states); - result - } + _ => server.clone(), } } - /// Collect visual state (minimized, detached) from all terminals in this tree. - fn collect_terminal_visual_state(&self, states: &mut HashMap) { + fn merge_mapped_children( + server: &[LayoutNode], + local: &[LayoutNode], + mapping: Option<&[usize]>, + ) -> Vec { + server + .iter() + .enumerate() + .map(|(server_index, server_child)| { + mapping + .and_then(|indices| indices.get(server_index)) + .and_then(|local_index| local.get(*local_index)) + .map(|local_child| { + LayoutNode::merge_container_visual_state(server_child, local_child) + }) + .unwrap_or_else(|| server_child.clone()) + }) + .collect() + } + + /// Map every server child to the corresponding local child. Exact subtree + /// identities handle reorder; positional overlap handles a child that grew. + fn matching_child_indices(server: &[LayoutNode], local: &[LayoutNode]) -> Option> { + let server_ids: Vec> = server + .iter() + .map(|child| child.collect_terminal_ids().into_iter().collect()) + .collect(); + let local_ids: Vec> = local + .iter() + .map(|child| child.collect_terminal_ids().into_iter().collect()) + .collect(); + + let mut used = HashSet::new(); + let exact: Option> = server_ids + .iter() + .map(|ids| { + if ids.is_empty() { + return None; + } + let index = local_ids + .iter() + .enumerate() + .find(|(index, candidate)| !used.contains(index) && *candidate == ids) + .map(|(index, _)| index)?; + used.insert(index); + Some(index) + }) + .collect(); + if exact.is_some() { + return exact; + } + + server_ids + .iter() + .zip(&local_ids) + .all(|(server, local)| { + (server.is_empty() && local.is_empty()) + || server.iter().any(|id| local.contains(id)) + }) + .then(|| (0..server.len()).collect()) + } + + fn merged_active_tab( + server_children: &[LayoutNode], + server_active: usize, + local_children: &[LayoutNode], + local_active: usize, + ) -> usize { + let fallback = server_active.min(server_children.len().saturating_sub(1)); + let Some(local_child) = local_children.get(local_active) else { + return fallback; + }; + let selected_ids: HashSet = + local_child.collect_terminal_ids().into_iter().collect(); + if selected_ids.is_empty() { + return local_active.min(server_children.len().saturating_sub(1)); + } + + server_children + .iter() + .position(|child| { + child + .collect_terminal_ids() + .iter() + .any(|id| selected_ids.contains(id)) + }) + .unwrap_or(fallback) + } + + /// Collect client-owned terminal presentation from this tree. + fn collect_terminal_visual_state(&self, states: &mut HashMap) { match self { - LayoutNode::Terminal { terminal_id: Some(id), minimized, detached, .. } => { - states.insert(id.clone(), (*minimized, *detached)); + LayoutNode::Terminal { + terminal_id: Some(id), + minimized, + detached, + zoom_level, + .. + } => { + states.insert(id.clone(), (*minimized, *detached, *zoom_level)); } LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { for child in children { @@ -713,13 +960,20 @@ impl LayoutNode { } } - /// Apply visual state from a map of terminal_id → (minimized, detached) to matching terminals. - fn apply_terminal_visual_state(&mut self, states: &HashMap) { + /// Apply client-owned presentation to matching terminals. + fn apply_terminal_visual_state(&mut self, states: &HashMap) { match self { - LayoutNode::Terminal { terminal_id: Some(id), minimized, detached, .. } => { - if let Some(&(m, d)) = states.get(id) { + LayoutNode::Terminal { + terminal_id: Some(id), + minimized, + detached, + zoom_level, + .. + } => { + if let Some(&(m, d, zoom)) = states.get(id) { *minimized = m; *detached = d; + *zoom_level = zoom; } } LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { @@ -739,12 +993,13 @@ impl LayoutNode { terminal_id, minimized, detached, + shell_type, .. } => LayoutNode::Terminal { terminal_id: terminal_id.clone(), minimized: *minimized, detached: *detached, - shell_type: Default::default(), + shell_type: shell_type.clone(), zoom_level: 1.0, }, okena_core::api::ApiLayoutNode::Split { @@ -774,12 +1029,13 @@ impl LayoutNode { terminal_id, minimized, detached, + shell_type, .. } => LayoutNode::Terminal { terminal_id: terminal_id.as_ref().map(|id| format!("{}:{}", prefix, id)), minimized: *minimized, detached: *detached, - shell_type: Default::default(), + shell_type: shell_type.clone(), zoom_level: 1.0, }, okena_core::api::ApiLayoutNode::Split { @@ -822,6 +1078,7 @@ impl LayoutNode { terminal_id, minimized, detached, + shell_type, .. } => { let (cols, rows) = terminal_id @@ -833,6 +1090,7 @@ impl LayoutNode { terminal_id: terminal_id.clone(), minimized: *minimized, detached: *detached, + shell_type: shell_type.clone(), cols, rows, } @@ -844,13 +1102,19 @@ impl LayoutNode { } => okena_core::api::ApiLayoutNode::Split { direction: *direction, sizes: split_sizes.clone(), - children: children.iter().map(|c| c.to_api_with_sizes(sizes)).collect(), + children: children + .iter() + .map(|c| c.to_api_with_sizes(sizes)) + .collect(), }, LayoutNode::Tabs { children, active_tab, } => okena_core::api::ApiLayoutNode::Tabs { - children: children.iter().map(|c| c.to_api_with_sizes(sizes)).collect(), + children: children + .iter() + .map(|c| c.to_api_with_sizes(sizes)) + .collect(), active_tab: *active_tab, }, } @@ -906,15 +1170,27 @@ mod tests { ]); tree.transpose(); - let LayoutNode::Split { direction, children, .. } = &tree else { + let LayoutNode::Split { + direction, + children, + .. + } = &tree + else { panic!("expected split"); }; assert_eq!(*direction, SplitDirection::Vertical); - let LayoutNode::Tabs { children: tab_children, active_tab } = &children[1] else { + let LayoutNode::Tabs { + children: tab_children, + active_tab, + } = &children[1] + else { panic!("expected tabs"); }; assert_eq!(*active_tab, 0, "tab structure preserved"); - let LayoutNode::Split { direction: inner, .. } = &tab_children[0] else { + let LayoutNode::Split { + direction: inner, .. + } = &tab_children[0] + else { panic!("expected nested split inside tabs"); }; assert_eq!(*inner, SplitDirection::Horizontal, "nested split flipped"); @@ -944,7 +1220,10 @@ mod tests { fn append_to_root_split_pushes_and_rebalances() { let mut tree = hsplit(vec![terminal("a"), terminal("b")]); tree.append_to_root(terminal("c")); - let LayoutNode::Split { children, sizes, .. } = &tree else { + let LayoutNode::Split { + children, sizes, .. + } = &tree + else { panic!("expected split"); }; assert_eq!(children.len(), 3); @@ -962,7 +1241,11 @@ mod tests { active_tab: 0, }; tree.append_to_root(terminal("c")); - let LayoutNode::Tabs { children, active_tab } = &tree else { + let LayoutNode::Tabs { + children, + active_tab, + } = &tree + else { panic!("expected tabs"); }; assert_eq!(children.len(), 3); @@ -973,7 +1256,10 @@ mod tests { fn append_to_root_bare_terminal_wraps_in_split() { let mut tree = terminal("a"); tree.append_to_root(terminal("b")); - let LayoutNode::Split { children, sizes, .. } = &tree else { + let LayoutNode::Split { + children, sizes, .. + } = &tree + else { panic!("expected split after wrapping a bare terminal"); }; assert_eq!(children.len(), 2); @@ -1081,17 +1367,35 @@ mod tests { } #[test] - fn clear_terminal_ids_resets_all() { - let mut node = hsplit(vec![ - terminal_minimized("t1"), - terminal_detached("t2"), + fn has_terminal_ids_matches_collect() { + let nested = hsplit(vec![ + LayoutNode::new_terminal(), + tabs(vec![LayoutNode::new_terminal(), terminal("t1")]), ]); + assert!(nested.has_terminal_ids()); + + let mut cleared = nested; + cleared.clear_terminal_ids_except(&HashSet::new()); + assert!( + !cleared.has_terminal_ids(), + "a tree stripped for worktree teardown carries no ids", + ); + } + + #[test] + fn clear_terminal_ids_resets_all() { + let mut node = hsplit(vec![terminal_minimized("t1"), terminal_detached("t2")]); node.clear_terminal_ids_except(&HashSet::new()); assert!(node.collect_terminal_ids().is_empty()); match &node { LayoutNode::Split { children, .. } => { for child in children { - if let LayoutNode::Terminal { minimized, detached, .. } = child { + if let LayoutNode::Terminal { + minimized, + detached, + .. + } = child + { assert!(!minimized); assert!(!detached); } @@ -1174,10 +1478,7 @@ mod tests { #[test] fn collect_detached_terminals_finds_correct() { - let node = hsplit(vec![ - terminal_detached("t1"), - terminal("t2"), - ]); + let node = hsplit(vec![terminal_detached("t1"), terminal("t2")]); let detached = node.collect_detached_terminals(); assert_eq!(detached.len(), 1); assert_eq!(detached[0].0, "t1"); @@ -1250,7 +1551,12 @@ mod tests { ], }; node.normalize(); - if let LayoutNode::Split { children, direction, sizes } = &node { + if let LayoutNode::Split { + children, + direction, + sizes, + } = &node + { assert_eq!(*direction, SplitDirection::Horizontal); assert_eq!(children.len(), 3); assert_eq!(sizes.len(), 3); @@ -1267,16 +1573,24 @@ mod tests { let mut node = LayoutNode::Split { direction: SplitDirection::Horizontal, sizes: vec![50.0, 50.0], - children: vec![ - vsplit(vec![terminal("t1"), terminal("t2")]), - terminal("t3"), - ], + children: vec![vsplit(vec![terminal("t1"), terminal("t2")]), terminal("t3")], }; node.normalize(); - if let LayoutNode::Split { children, direction, .. } = &node { + if let LayoutNode::Split { + children, + direction, + .. + } = &node + { assert_eq!(*direction, SplitDirection::Horizontal); assert_eq!(children.len(), 2); - assert!(matches!(&children[0], LayoutNode::Split { direction: SplitDirection::Vertical, .. })); + assert!(matches!( + &children[0], + LayoutNode::Split { + direction: SplitDirection::Vertical, + .. + } + )); } else { panic!("Expected horizontal split with nested vertical"); } @@ -1296,7 +1610,11 @@ mod tests { active_tab: 5, }; node.normalize(); - if let LayoutNode::Tabs { children, active_tab } = &node { + if let LayoutNode::Tabs { + children, + active_tab, + } = &node + { assert_eq!(*active_tab, children.len() - 1); } else { panic!("Expected tabs after normalize"); @@ -1320,7 +1638,12 @@ mod tests { let mut node = LayoutNode::Split { direction: SplitDirection::Horizontal, sizes: vec![5.0, 2.5, 2.5, -12.0], - children: vec![terminal("t1"), terminal("t2"), terminal("t3"), terminal("t4")], + children: vec![ + terminal("t1"), + terminal("t2"), + terminal("t3"), + terminal("t4"), + ], }; node.normalize(); if let LayoutNode::Split { sizes, .. } = &node { @@ -1415,7 +1738,9 @@ mod tests { LayoutNode::Split { children, .. } => { assert_eq!(children.len(), 2); assert!(matches!(&children[0], LayoutNode::Terminal { .. })); - assert!(matches!(&children[1], LayoutNode::Tabs { children, .. } if children.len() == 2)); + assert!( + matches!(&children[1], LayoutNode::Tabs { children, .. } if children.len() == 2) + ); } _ => panic!("Expected split"), } @@ -1444,14 +1769,30 @@ mod tests { let removed = node.remove_at_path(&[1]); assert!(removed.is_some()); match &node { - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { assert_eq!(children.len(), 2); - assert_eq!(sizes.len(), 2); + assert_eq!(sizes, &[66.0, 34.0]); } _ => panic!("Expected split with 2 children"), } } + #[test] + fn remove_at_path_transfers_first_child_weight_to_next_sibling() { + let mut node = LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![20.0, 30.0, 50.0], + children: vec![terminal("t1"), terminal("t2"), terminal("t3")], + }; + node.remove_at_path(&[0]); + match &node { + LayoutNode::Split { sizes, .. } => assert_eq!(sizes, &[50.0, 50.0]), + _ => panic!("Expected split with 2 children"), + } + } + #[test] fn remove_at_path_from_tabs_collapses_if_1() { let mut node = tabs(vec![terminal("t1"), terminal("t2")]); @@ -1501,6 +1842,28 @@ mod tests { } } + #[test] + fn remove_terminal_ids_removes_leaves_and_collapses_layout() { + let mut layout = Some(LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![0.5, 0.5], + children: vec![terminal("keep"), terminal("stale")], + }); + let ids = HashSet::from(["stale"]); + + assert_eq!(LayoutNode::remove_terminal_ids(&mut layout, &ids), 1); + assert_eq!(layout, Some(terminal("keep"))); + } + + #[test] + fn remove_terminal_ids_clears_a_matching_root() { + let mut layout = Some(terminal("stale")); + let ids = HashSet::from(["stale"]); + + assert_eq!(LayoutNode::remove_terminal_ids(&mut layout, &ids), 1); + assert!(layout.is_none()); + } + #[test] fn serde_round_trip_terminal() { let node = terminal("t1"); @@ -1526,32 +1889,95 @@ mod tests { #[test] fn merge_matching_terminals_preserves_visual_flags() { - let server = terminal("t1"); + let server = LayoutNode::Terminal { + terminal_id: Some("t1".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Custom { + path: "/bin/zsh".to_string(), + args: Vec::new(), + }, + zoom_level: 1.0, + }; let local = LayoutNode::Terminal { terminal_id: Some("t1".to_string()), minimized: true, detached: true, shell_type: ShellType::Default, - zoom_level: 1.0, + zoom_level: 1.75, }; let merged = LayoutNode::merge_visual_state(&server, &local); match merged { - LayoutNode::Terminal { minimized, detached, terminal_id, .. } => { + LayoutNode::Terminal { + minimized, + detached, + terminal_id, + shell_type, + zoom_level, + } => { assert_eq!(terminal_id.as_deref(), Some("t1")); assert!(minimized, "local minimized should be preserved"); assert!(detached, "local detached should be preserved"); + assert_eq!(zoom_level, 1.75, "local zoom should be preserved"); + assert_eq!( + shell_type, + ShellType::Custom { + path: "/bin/zsh".to_string(), + args: Vec::new(), + }, + "server shell should remain authoritative" + ); } _ => panic!("Expected terminal"), } } + #[test] + fn api_layout_preserves_daemon_shell_type() { + let node = LayoutNode::Terminal { + terminal_id: Some("t1".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Custom { + path: "/bin/fish".to_string(), + args: vec!["--private".to_string()], + }, + zoom_level: 2.0, + }; + + let restored = LayoutNode::from_api(&node.to_api()); + let LayoutNode::Terminal { + shell_type, + zoom_level, + .. + } = restored + else { + panic!("Expected terminal"); + }; + assert_eq!( + shell_type, + ShellType::Custom { + path: "/bin/fish".to_string(), + args: vec!["--private".to_string()], + } + ); + assert_eq!( + zoom_level, 1.0, + "client zoom is not daemon-owned wire state" + ); + } + #[test] fn merge_different_terminals_uses_server() { let server = terminal("t1"); let local = terminal_minimized("t2"); let merged = LayoutNode::merge_visual_state(&server, &local); match merged { - LayoutNode::Terminal { terminal_id, minimized, .. } => { + LayoutNode::Terminal { + terminal_id, + minimized, + .. + } => { assert_eq!(terminal_id.as_deref(), Some("t1")); assert!(!minimized, "server state should win on ID mismatch"); } @@ -1574,7 +2000,10 @@ mod tests { let merged = LayoutNode::merge_visual_state(&server, &local); match merged { LayoutNode::Split { sizes, .. } => { - assert!((sizes[0] - 30.0).abs() < f32::EPSILON, "local sizes should be preserved"); + assert!( + (sizes[0] - 30.0).abs() < f32::EPSILON, + "local sizes should be preserved" + ); assert!((sizes[1] - 70.0).abs() < f32::EPSILON); } _ => panic!("Expected split"), @@ -1595,9 +2024,14 @@ mod tests { }; let merged = LayoutNode::merge_visual_state(&server, &local); match merged { - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { assert_eq!(children.len(), 3, "server child count should win"); - assert!((sizes[0] - 33.0).abs() < f32::EPSILON, "server sizes should be used"); + assert!( + (sizes[0] - 33.0).abs() < f32::EPSILON, + "server sizes should be used" + ); } _ => panic!("Expected split"), } @@ -1622,6 +2056,91 @@ mod tests { } } + #[test] + fn merge_reordered_tabs_preserves_presentation_by_terminal_identity() { + let server = LayoutNode::Tabs { + children: vec![terminal("t2"), terminal("t1")], + active_tab: 1, + }; + let local = LayoutNode::Tabs { + children: vec![ + LayoutNode::Terminal { + terminal_id: Some("t1".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.75, + }, + terminal_minimized("t2"), + ], + active_tab: 0, + }; + + let merged = LayoutNode::merge_visual_state(&server, &local); + let LayoutNode::Tabs { + children, + active_tab, + } = merged + else { + panic!("expected tabs"); + }; + assert_eq!(active_tab, 1, "selected terminal should follow the reorder"); + assert!(matches!( + &children[0], + LayoutNode::Terminal { + terminal_id: Some(id), + minimized: true, + .. + } if id == "t2" + )); + assert!(matches!( + &children[1], + LayoutNode::Terminal { + terminal_id: Some(id), + zoom_level, + .. + } if id == "t1" && (*zoom_level - 1.75).abs() < f32::EPSILON + )); + } + + #[test] + fn merge_reordered_split_keeps_sizes_with_their_panes() { + let server = LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![50.0, 50.0], + children: vec![terminal("t2"), terminal("t1")], + }; + let local = LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![25.0, 75.0], + children: vec![terminal("t1"), terminal("t2")], + }; + + let merged = LayoutNode::merge_visual_state(&server, &local); + let LayoutNode::Split { sizes, .. } = merged else { + panic!("expected split"); + }; + assert_eq!(sizes, vec![75.0, 25.0]); + } + + #[test] + fn merge_closed_tab_keeps_the_selected_terminal_active() { + let server = LayoutNode::Tabs { + children: vec![terminal("t1"), terminal("t3")], + active_tab: 0, + }; + let local = LayoutNode::Tabs { + children: vec![terminal("t1"), terminal("t2"), terminal("t3")], + active_tab: 2, + }; + + let merged = LayoutNode::merge_visual_state(&server, &local); + let LayoutNode::Tabs { active_tab, .. } = merged else { + panic!("expected tabs"); + }; + assert_eq!(active_tab, 1); + } + #[test] fn merge_type_mismatch_uses_server() { let server = hsplit(vec![terminal("t1"), terminal("t2")]); @@ -1629,7 +2148,11 @@ mod tests { let merged = LayoutNode::merge_visual_state(&server, &local); match merged { LayoutNode::Split { children, .. } => { - assert_eq!(children.len(), 2, "server structure should win on type mismatch"); + assert_eq!( + children.len(), + 2, + "server structure should win on type mismatch" + ); } _ => panic!("Expected split"), } @@ -1663,7 +2186,9 @@ mod tests { }; let merged = LayoutNode::merge_visual_state(&server, &local); match &merged { - LayoutNode::Split { sizes, children, .. } => { + LayoutNode::Split { + sizes, children, .. + } => { assert!((sizes[0] - 25.0).abs() < f32::EPSILON); assert!((sizes[1] - 75.0).abs() < f32::EPSILON); match &children[0] { @@ -1682,20 +2207,39 @@ mod tests { #[test] fn merge_split_from_terminal_preserves_minimized() { let server = hsplit(vec![terminal("t1"), terminal("t2")]); - let local = terminal_minimized("t1"); + let local = LayoutNode::Terminal { + terminal_id: Some("t1".to_string()), + minimized: true, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.5, + }; let merged = LayoutNode::merge_visual_state(&server, &local); match &merged { LayoutNode::Split { children, .. } => { assert_eq!(children.len(), 2); match &children[0] { - LayoutNode::Terminal { terminal_id, minimized, .. } => { + LayoutNode::Terminal { + terminal_id, + minimized, + zoom_level, + .. + } => { assert_eq!(terminal_id.as_deref(), Some("t1")); - assert!(*minimized, "minimized state should be preserved after split"); + assert!( + *minimized, + "minimized state should be preserved after split" + ); + assert_eq!(*zoom_level, 1.5, "zoom should survive structure changes"); } _ => panic!("Expected terminal"), } match &children[1] { - LayoutNode::Terminal { terminal_id, minimized, .. } => { + LayoutNode::Terminal { + terminal_id, + minimized, + .. + } => { assert_eq!(terminal_id.as_deref(), Some("t2")); assert!(!*minimized, "new terminal should not be minimized"); } @@ -1712,14 +2256,12 @@ mod tests { let local = terminal_detached("t1"); let merged = LayoutNode::merge_visual_state(&server, &local); match &merged { - LayoutNode::Split { children, .. } => { - match &children[0] { - LayoutNode::Terminal { detached, .. } => { - assert!(*detached, "detached state should be preserved"); - } - _ => panic!("Expected terminal"), + LayoutNode::Split { children, .. } => match &children[0] { + LayoutNode::Terminal { detached, .. } => { + assert!(*detached, "detached state should be preserved"); } - } + _ => panic!("Expected terminal"), + }, _ => panic!("Expected split"), } } diff --git a/crates/okena-markdown/src/parser.rs b/crates/okena-markdown/src/parser.rs index 563f86287..b958bfdb1 100644 --- a/crates/okena-markdown/src/parser.rs +++ b/crates/okena-markdown/src/parser.rs @@ -2,8 +2,8 @@ use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; -use super::types::{FmValue, Frontmatter, Inline, Node}; use super::MarkdownDocument; +use super::types::{FmValue, Frontmatter, Inline, Node}; impl MarkdownDocument { /// Parse markdown content into a document. @@ -148,7 +148,11 @@ impl MarkdownDocument { let headers = std::mem::take(&mut table_headers); let rows = std::mem::take(&mut table_rows); let col_widths = Self::table_col_widths(&headers, &rows); - nodes.push(Node::Table { headers, rows, col_widths }); + nodes.push(Node::Table { + headers, + rows, + col_widths, + }); in_table = false; } Event::Start(Tag::TableHead) => { @@ -202,13 +206,18 @@ impl MarkdownDocument { Event::End(TagEnd::Link) => { let mut children = inline_stack.pop().unwrap_or_default(); // Extract URL from marker - let url = children.iter().find_map(|c| { - if let Inline::Text(t) = c - && t.starts_with("\x00LINK:") && t.ends_with("\x00") { - return Some(t[6..t.len()-1].to_string()); + let url = children + .iter() + .find_map(|c| { + if let Inline::Text(t) = c + && t.starts_with("\x00LINK:") + && t.ends_with("\x00") + { + return Some(t[6..t.len() - 1].to_string()); } - None - }).unwrap_or_default(); + None + }) + .unwrap_or_default(); children.retain(|c| { if let Inline::Text(t) = c { !t.starts_with("\x00LINK:") @@ -217,7 +226,10 @@ impl MarkdownDocument { } }); if let Some(last) = inline_stack.last_mut() { - last.push(Inline::Link { _url: url, children }); + last.push(Inline::Link { + _url: url, + children, + }); } } Event::Code(text) => { @@ -271,9 +283,9 @@ impl MarkdownDocument { /// Convert a node to flat text (in characters, not bytes). pub(crate) fn node_to_flat_text(node: &Node, text: &mut String) { match node { - Node::Heading { children, .. } | - Node::Paragraph { children } | - Node::Blockquote { children } => { + Node::Heading { children, .. } + | Node::Paragraph { children } + | Node::Blockquote { children } => { Self::inlines_to_flat_text(children, text); text.push('\n'); } @@ -291,13 +303,17 @@ impl MarkdownDocument { } Node::Table { headers, rows, .. } => { for (i, header) in headers.iter().enumerate() { - if i > 0 { text.push('\t'); } + if i > 0 { + text.push('\t'); + } Self::inlines_to_flat_text(header, text); } text.push('\n'); for row in rows { for (i, cell) in row.iter().enumerate() { - if i > 0 { text.push('\t'); } + if i > 0 { + text.push('\t'); + } Self::inlines_to_flat_text(cell, text); } text.push('\n'); @@ -318,8 +334,10 @@ impl MarkdownDocument { headers: &[Vec], rows: &[Vec>], ) -> Vec { - let mut col_widths: Vec = - headers.iter().map(|h| Self::inlines_text_length(h)).collect(); + let mut col_widths: Vec = headers + .iter() + .map(|h| Self::inlines_text_length(h)) + .collect(); for row in rows { for (i, cell) in row.iter().enumerate() { let len = Self::inlines_text_length(cell); @@ -389,9 +407,7 @@ fn parse_frontmatter(inner: &str) -> Option { Ok(serde_yaml_ng::Value::Mapping(map)) => { let entries: Vec<(String, FmValue)> = map .into_iter() - .map(|(k, v)| { - (super::types::yaml_key_to_string(&k), FmValue::from_yaml(v)) - }) + .map(|(k, v)| (super::types::yaml_key_to_string(&k), FmValue::from_yaml(v))) .collect(); if entries.is_empty() { return None; @@ -462,7 +478,11 @@ draft: true let doc = MarkdownDocument::parse(content); // First node is the frontmatter card, parsed in order. - let Node::Frontmatter { block: Frontmatter::Parsed(entries), .. } = &doc.nodes[0] else { + let Node::Frontmatter { + block: Frontmatter::Parsed(entries), + .. + } = &doc.nodes[0] + else { panic!("expected parsed frontmatter, got something else"); }; assert_eq!(entries.len(), 2); @@ -472,9 +492,11 @@ draft: true assert!(matches!(&entries[1].1, FmValue::Scalar(s) if s == "true")); // The heading after the closing fence still parses as a heading. - assert!(doc.nodes[1..] - .iter() - .any(|n| matches!(n, Node::Heading { .. }))); + assert!( + doc.nodes[1..] + .iter() + .any(|n| matches!(n, Node::Heading { .. })) + ); // Offsets stay consistent with node lengths once frontmatter is included. let mut offset = 0usize; for (i, node) in doc.nodes.iter().enumerate() { @@ -497,7 +519,11 @@ author: body "; let doc = MarkdownDocument::parse(content); - let Node::Frontmatter { block: Frontmatter::Parsed(entries), .. } = &doc.nodes[0] else { + let Node::Frontmatter { + block: Frontmatter::Parsed(entries), + .. + } = &doc.nodes[0] + else { panic!("expected parsed frontmatter"); }; assert!(matches!(&entries[0].1, FmValue::List(items) if items.len() == 2)); @@ -511,7 +537,10 @@ body let doc = MarkdownDocument::parse(content); assert!(matches!( &doc.nodes[0], - Node::Frontmatter { block: Frontmatter::Raw(_), .. } + Node::Frontmatter { + block: Frontmatter::Raw(_), + .. + } )); } @@ -529,7 +558,10 @@ body let doc = MarkdownDocument::parse(content); assert!(matches!( &doc.nodes[0], - Node::Frontmatter { block: Frontmatter::Parsed(_), .. } + Node::Frontmatter { + block: Frontmatter::Parsed(_), + .. + } )); } diff --git a/crates/okena-markdown/src/render.rs b/crates/okena-markdown/src/render.rs index fead571d0..afb1d4814 100644 --- a/crates/okena-markdown/src/render.rs +++ b/crates/okena-markdown/src/render.rs @@ -1,15 +1,13 @@ //! Rendering logic for markdown nodes and inline elements. +use gpui::prelude::FluentBuilder; +use gpui::*; +use gpui_component::{h_flex, v_flex}; use okena_core::theme::ThemeColors; use okena_ui::code_block::code_block_container; use okena_ui::tokens::{ui_text, ui_text_md, ui_text_xl}; -use gpui::*; -use gpui::prelude::FluentBuilder; -use gpui_component::{h_flex, v_flex}; -use super::types::{ - char_len, slice_by_chars, FmValue, Frontmatter, Inline, Node, -}; +use super::types::{FmValue, Frontmatter, Inline, Node, char_len, slice_by_chars}; use super::{MarkdownDocument, RenderedNode}; impl MarkdownDocument { @@ -35,220 +33,236 @@ impl MarkdownDocument { if end <= offset || start >= offset + node_len { None } else { - Some(( - start.saturating_sub(offset), - (end - offset).min(node_len), - )) + Some((start.saturating_sub(offset), (end - offset).min(node_len))) } }); let rendered = match node { - Node::CodeBlock { language, code } => { - // Return code blocks with individual lines for per-line selection - let selection_bg = rgba(0x3390ff40); - let mut lines = Vec::new(); - let mut line_offset = offset; - - for line in code.lines() { - let line_len = char_len(line); - let line_end = line_offset + line_len + 1; // +1 for newline - - let line_sel = node_selection.and_then(|(s, e)| { - let rel_offset = line_offset - offset; - let rel_end = rel_offset + line_len + 1; - if e <= rel_offset || s >= rel_end { + Node::CodeBlock { language, code } => { + // Return code blocks with individual lines for per-line selection + let selection_bg = rgba(0x3390ff40); + let mut lines = Vec::new(); + let mut line_offset = offset; + + for line in code.lines() { + let line_len = char_len(line); + let line_end = line_offset + line_len + 1; // +1 for newline + + let line_sel = node_selection.and_then(|(s, e)| { + let rel_offset = line_offset - offset; + let rel_end = rel_offset + line_len + 1; + if e <= rel_offset || s >= rel_end { + None + } else { + Some((s.saturating_sub(rel_offset), (e - rel_offset).min(line_len))) + } + }); + + let line_div = if let Some((sel_start, sel_end)) = line_sel { + let (before, selected, after) = slice_by_chars(line, sel_start, sel_end); + div() + .h(px(18.0)) + .flex() + .child(div().child(before)) + .child(div().bg(selection_bg).child(selected)) + .child(div().child(after)) + } else { + div().h(px(18.0)).child(if line.is_empty() { + " ".to_string() + } else { + line.to_string() + }) + }; + + lines.push((line_div, line_offset, line_end)); + line_offset = line_end; + } + + RenderedNode::CodeBlock { + language: language.clone(), + lines, + } + } + Node::Table { + headers, + rows, + col_widths, + } => { + // Return tables with individual rows for per-row selection. + // Column widths are precomputed at parse time. + let mut row_offset = offset; + let mut rendered_rows = Vec::new(); + let mut rendered_header = None; + + // Header row + if !headers.is_empty() { + let header_len: usize = headers + .iter() + .map(|h| Self::inlines_text_length(h)) + .sum::() + + headers.len().saturating_sub(1) + + 1; // tabs + newline + let header_end = row_offset + header_len; + + let header_sel = node_selection.and_then(|(s, e)| { + let rel_start = row_offset - offset; + let rel_end = rel_start + header_len; + if e <= rel_start || s >= rel_end { + None + } else { + Some((s.saturating_sub(rel_start), (e - rel_start).min(header_len))) + } + }); + + let mut header_row = h_flex(); + let mut cell_offset = 0usize; + for (i, header) in headers.iter().enumerate() { + let cell_len = + Self::inlines_text_length(header) + if i > 0 { 1 } else { 0 }; + let cell_sel = header_sel.and_then(|(s, e)| { + let cell_start = cell_offset + if i > 0 { 1 } else { 0 }; + let cell_end = cell_offset + cell_len; + if e <= cell_start || s >= cell_end { None } else { Some(( - s.saturating_sub(rel_offset), - (e - rel_offset).min(line_len), + s.saturating_sub(cell_start), + (e - cell_start).min(Self::inlines_text_length(header)), )) } }); - let line_div = if let Some((sel_start, sel_end)) = line_sel { - let (before, selected, after) = slice_by_chars(line, sel_start, sel_end); - div() - .h(px(18.0)) - .flex() - .child(div().child(before)) - .child(div().bg(selection_bg).child(selected)) - .child(div().child(after)) - } else { - div() - .h(px(18.0)) - .child(if line.is_empty() { " ".to_string() } else { line.to_string() }) - }; - - lines.push((line_div, line_offset, line_end)); - line_offset = line_end; + let width = col_widths.get(i).copied().unwrap_or(10); + let min_w = ((width * 8) + 24).max(80) as f32; + header_row = header_row.child( + div().min_w(px(min_w)).px(px(12.0)).py(px(8.0)).child( + Self::render_inlines_with_selection(header, t, cx, cell_sel) + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_primary)), + ), + ); + cell_offset += cell_len; } - RenderedNode::CodeBlock { - language: language.clone(), - lines, - } + let header_div = header_row + .bg(rgb(t.bg_header)) + .border_b_1() + .border_color(rgb(t.border)); + rendered_header = Some((header_div, row_offset, header_end)); + row_offset = header_end; } - Node::Table { headers, rows, col_widths } => { - // Return tables with individual rows for per-row selection. - // Column widths are precomputed at parse time. - let mut row_offset = offset; - let mut rendered_rows = Vec::new(); - let mut rendered_header = None; - - // Header row - if !headers.is_empty() { - let header_len: usize = headers.iter().map(|h| Self::inlines_text_length(h)).sum::() - + headers.len().saturating_sub(1) + 1; // tabs + newline - let header_end = row_offset + header_len; - - let header_sel = node_selection.and_then(|(s, e)| { - let rel_start = row_offset - offset; - let rel_end = rel_start + header_len; - if e <= rel_start || s >= rel_end { - None - } else { - Some((s.saturating_sub(rel_start), (e - rel_start).min(header_len))) - } - }); - let mut header_row = h_flex(); - let mut cell_offset = 0usize; - for (i, header) in headers.iter().enumerate() { - let cell_len = Self::inlines_text_length(header) + if i > 0 { 1 } else { 0 }; - let cell_sel = header_sel.and_then(|(s, e)| { - let cell_start = cell_offset + if i > 0 { 1 } else { 0 }; - let cell_end = cell_offset + cell_len; - if e <= cell_start || s >= cell_end { - None - } else { - Some((s.saturating_sub(cell_start), (e - cell_start).min(Self::inlines_text_length(header)))) - } - }); - - let width = col_widths.get(i).copied().unwrap_or(10); - let min_w = ((width * 8) + 24).max(80) as f32; - header_row = header_row.child( - div() - .min_w(px(min_w)) - .px(px(12.0)) - .py(px(8.0)) - .child( - Self::render_inlines_with_selection(header, t, cx, cell_sel) - .text_size(ui_text_md(cx)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(t.text_primary)) - ) - ); - cell_offset += cell_len; + // Data rows + for (row_idx, row) in rows.iter().enumerate() { + let row_len: usize = row + .iter() + .map(|cell| Self::inlines_text_length(cell)) + .sum::() + + row.len().saturating_sub(1) + + 1; // tabs + newline + let row_end = row_offset + row_len; + + let row_sel = node_selection.and_then(|(s, e)| { + let rel_start = row_offset - offset; + let rel_end = rel_start + row_len; + if e <= rel_start || s >= rel_end { + None + } else { + Some((s.saturating_sub(rel_start), (e - rel_start).min(row_len))) } + }); - let header_div = header_row.bg(rgb(t.bg_header)).border_b_1().border_color(rgb(t.border)); - rendered_header = Some((header_div, row_offset, header_end)); - row_offset = header_end; + let mut row_div = h_flex(); + if row_idx % 2 == 1 { + row_div = row_div.bg(rgb(t.bg_secondary)); + } + if row_idx < rows.len() - 1 { + row_div = row_div.border_b_1().border_color(rgb(t.border)); } - // Data rows - for (row_idx, row) in rows.iter().enumerate() { - let row_len: usize = row.iter().map(|cell| Self::inlines_text_length(cell)).sum::() - + row.len().saturating_sub(1) + 1; // tabs + newline - let row_end = row_offset + row_len; - - let row_sel = node_selection.and_then(|(s, e)| { - let rel_start = row_offset - offset; - let rel_end = rel_start + row_len; - if e <= rel_start || s >= rel_end { + let mut cell_offset = 0usize; + for (i, cell) in row.iter().enumerate() { + let cell_len = Self::inlines_text_length(cell) + if i > 0 { 1 } else { 0 }; + let cell_sel = row_sel.and_then(|(s, e)| { + let cell_start = cell_offset + if i > 0 { 1 } else { 0 }; + let cell_end = cell_offset + cell_len; + if e <= cell_start || s >= cell_end { None } else { - Some((s.saturating_sub(rel_start), (e - rel_start).min(row_len))) + Some(( + s.saturating_sub(cell_start), + (e - cell_start).min(Self::inlines_text_length(cell)), + )) } }); - let mut row_div = h_flex(); - if row_idx % 2 == 1 { - row_div = row_div.bg(rgb(t.bg_secondary)); - } - if row_idx < rows.len() - 1 { - row_div = row_div.border_b_1().border_color(rgb(t.border)); - } - - let mut cell_offset = 0usize; - for (i, cell) in row.iter().enumerate() { - let cell_len = Self::inlines_text_length(cell) + if i > 0 { 1 } else { 0 }; - let cell_sel = row_sel.and_then(|(s, e)| { - let cell_start = cell_offset + if i > 0 { 1 } else { 0 }; - let cell_end = cell_offset + cell_len; - if e <= cell_start || s >= cell_end { - None - } else { - Some((s.saturating_sub(cell_start), (e - cell_start).min(Self::inlines_text_length(cell)))) - } - }); - - let width = col_widths.get(i).copied().unwrap_or(10); - let min_w = ((width * 8) + 24).max(80) as f32; - row_div = row_div.child( - div() - .min_w(px(min_w)) - .px(px(12.0)) - .py(px(6.0)) - .child( - Self::render_inlines_with_selection(cell, t, cx, cell_sel) - .text_size(ui_text_md(cx)) - .text_color(rgb(t.text_secondary)) - ) - ); - cell_offset += cell_len; - } - - rendered_rows.push((row_div, row_offset, row_end)); - row_offset = row_end; + let width = col_widths.get(i).copied().unwrap_or(10); + let min_w = ((width * 8) + 24).max(80) as f32; + row_div = row_div.child( + div().min_w(px(min_w)).px(px(12.0)).py(px(6.0)).child( + Self::render_inlines_with_selection(cell, t, cx, cell_sel) + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_secondary)), + ), + ); + cell_offset += cell_len; } - RenderedNode::Table { - header: rendered_header, - rows: rendered_rows, - } + rendered_rows.push((row_div, row_offset, row_end)); + row_offset = row_end; } - _ => { - // Other nodes are simple blocks - let node_div = Self::render_node_with_selection(node, t, cx, node_selection); - RenderedNode::Simple { - div: node_div, - start_offset: offset, - end_offset: offset + node_len, - } + + RenderedNode::Table { + header: rendered_header, + rows: rendered_rows, } - }; + } + _ => { + // Other nodes are simple blocks + let node_div = Self::render_node_with_selection(node, t, cx, node_selection); + RenderedNode::Simple { + div: node_div, + start_offset: offset, + end_offset: offset + node_len, + } + } + }; Some(rendered) } - /// Calculate the text length of a node (for selection offset tracking, in characters). pub(crate) fn node_text_length(node: &Node) -> usize { match node { - Node::Heading { level: _, children } | - Node::Paragraph { children } | - Node::Blockquote { children } => { + Node::Heading { level: _, children } + | Node::Paragraph { children } + | Node::Blockquote { children } => { Self::inlines_text_length(children) + 1 // +1 for newline } Node::CodeBlock { code, .. } => { // Sum of character lengths of each line + 1 newline per line - code.lines().map(|line| char_len(line) + 1).sum::().max(1) - } - Node::List { items, .. } => { - items.iter().map(|item| Self::inlines_text_length(item) + 1).sum() + code.lines() + .map(|line| char_len(line) + 1) + .sum::() + .max(1) } + Node::List { items, .. } => items + .iter() + .map(|item| Self::inlines_text_length(item) + 1) + .sum(), Node::Table { headers, rows, .. } => { let header_len: usize = headers.iter().map(|h| Self::inlines_text_length(h)).sum::() + headers.len().saturating_sub(1) // tabs + 1; // newline - let rows_len: usize = rows.iter().map(|row| { - row.iter().map(|cell| Self::inlines_text_length(cell)).sum::() + let rows_len: usize = rows + .iter() + .map(|row| { + row.iter().map(|cell| Self::inlines_text_length(cell)).sum::() + row.len().saturating_sub(1) // tabs + 1 // newline - }).sum(); + }) + .sum(); header_len + rows_len } Node::HorizontalRule => 1, // newline @@ -258,22 +272,26 @@ impl MarkdownDocument { /// Calculate the text length of inline elements (in characters, not bytes). pub(crate) fn inlines_text_length(inlines: &[Inline]) -> usize { - inlines.iter().map(|inline| { - match inline { + inlines + .iter() + .map(|inline| match inline { Inline::Text(t) => char_len(t), Inline::Code(c) => char_len(c), Inline::Bold(children) | Inline::Italic(children) => { Self::inlines_text_length(children) } - Inline::Link { children, .. } => { - Self::inlines_text_length(children) - } - } - }).sum() + Inline::Link { children, .. } => Self::inlines_text_length(children), + }) + .sum() } /// Render a node with selection highlighting. - fn render_node_with_selection(node: &Node, t: &ThemeColors, cx: &App, selection: Option<(usize, usize)>) -> Div { + fn render_node_with_selection( + node: &Node, + t: &ThemeColors, + cx: &App, + selection: Option<(usize, usize)>, + ) -> Div { match node { Node::Heading { level, children } => { let (size, weight) = match level { @@ -301,9 +319,7 @@ impl MarkdownDocument { .text_color(rgb(t.text_primary)) .pb(px(4.0)) .when(*level <= 2, |d| { - d.border_b_1() - .border_color(rgb(t.border)) - .mb(px(4.0)) + d.border_b_1().border_color(rgb(t.border)).mb(px(4.0)) }) .child(content) } @@ -325,10 +341,7 @@ impl MarkdownDocument { if e <= offset || s >= line_end { None } else { - Some(( - s.saturating_sub(offset), - (e - offset).min(line_len), - )) + Some((s.saturating_sub(offset), (e - offset).min(line_len))) } }); @@ -341,26 +354,27 @@ impl MarkdownDocument { .child(div().bg(selection_bg).child(selected)) .child(div().child(after)) } else { - div() - .h(px(18.0)) - .child(if line.is_empty() { " ".to_string() } else { line.to_string() }) + div().h(px(18.0)).child(if line.is_empty() { + " ".to_string() + } else { + line.to_string() + }) }; code_lines.push(line_div); offset = line_end; } - code_block_container(language.as_deref(), t, cx) - .child( - div() - .p(px(12.0)) - .font_family("monospace") - .text_size(ui_text_md(cx)) - .text_color(rgb(t.text_secondary)) - .flex() - .flex_col() - .children(code_lines) - ) + code_block_container(language.as_deref(), t, cx).child( + div() + .p(px(12.0)) + .font_family("monospace") + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_secondary)) + .flex() + .flex_col() + .children(code_lines), + ) } Node::List { ordered, items } => { let mut list = v_flex().gap(px(4.0)).pl(px(16.0)); @@ -394,35 +408,32 @@ impl MarkdownDocument { .text_color(rgb(t.text_muted)) .w(px(16.0)) .flex_shrink_0() - .child(marker) + .child(marker), ) - .child(Self::render_inlines_with_selection(item_inlines, t, cx, item_sel).flex_1()) + .child( + Self::render_inlines_with_selection(item_inlines, t, cx, item_sel) + .flex_1(), + ), ); offset += item_len; } list } - Node::Table { headers, rows, col_widths } => { - Self::render_table_with_selection(headers, rows, col_widths, t, cx, selection) - } - Node::Blockquote { children } => { - div() - .pl(px(12.0)) - .border_l_2() - .border_color(rgb(t.text_muted)) - .child( - Self::render_inlines_with_selection(children, t, cx, selection) - .text_color(rgb(t.text_muted)) - .italic() - ) - } - Node::HorizontalRule => { - div() - .w_full() - .h(px(1.0)) - .bg(rgb(t.border)) - .my(px(8.0)) - } + Node::Table { + headers, + rows, + col_widths, + } => Self::render_table_with_selection(headers, rows, col_widths, t, cx, selection), + Node::Blockquote { children } => div() + .pl(px(12.0)) + .border_l_2() + .border_color(rgb(t.text_muted)) + .child( + Self::render_inlines_with_selection(children, t, cx, selection) + .text_color(rgb(t.text_muted)) + .italic(), + ), + Node::HorizontalRule => div().w_full().h(px(1.0)).bg(rgb(t.border)).my(px(8.0)), // Frontmatter renders as a self-contained metadata card. Partial // (inline) selection highlighting is intentionally omitted; block // selection and copy still work through the flat-text offsets. @@ -444,18 +455,23 @@ impl MarkdownDocument { .text_size(ui_text_md(cx)); match fm { - Frontmatter::Raw(raw) => card.font_family("monospace").children( - raw.lines().map(|line| { - div() - .text_color(rgb(t.text_secondary)) - .child(if line.is_empty() { " ".to_string() } else { line.to_string() }) - }), - ), - Frontmatter::Parsed(entries) => { - card.children(entries.iter().map(|(key, value)| { - Self::render_fm_entry(key, value, t, cx) - })) + Frontmatter::Raw(raw) => { + card.font_family("monospace") + .children(raw.lines().map(|line| { + div() + .text_color(rgb(t.text_secondary)) + .child(if line.is_empty() { + " ".to_string() + } else { + line.to_string() + }) + })) } + Frontmatter::Parsed(entries) => card.children( + entries + .iter() + .map(|(key, value)| Self::render_fm_entry(key, value, t, cx)), + ), } } @@ -494,15 +510,12 @@ impl MarkdownDocument { .gap(px(2.0)) .child(key_label()) .child(Self::render_fm_list(items, t, cx)), - FmValue::Map(sub) => v_flex() - .gap(px(2.0)) - .child(key_label()) - .child( - v_flex() - .gap(px(4.0)) - .pl(px(16.0)) - .children(sub.iter().map(|(k, v)| Self::render_fm_entry(k, v, t, cx))), - ), + FmValue::Map(sub) => v_flex().gap(px(2.0)).child(key_label()).child( + v_flex() + .gap(px(4.0)) + .pl(px(16.0)) + .children(sub.iter().map(|(k, v)| Self::render_fm_entry(k, v, t, cx))), + ), } } @@ -537,7 +550,12 @@ impl MarkdownDocument { } /// Render inline elements with selection highlighting. - pub(crate) fn render_inlines_with_selection(inlines: &[Inline], t: &ThemeColors, cx: &App, selection: Option<(usize, usize)>) -> Div { + pub(crate) fn render_inlines_with_selection( + inlines: &[Inline], + t: &ThemeColors, + cx: &App, + selection: Option<(usize, usize)>, + ) -> Div { let mut elements: Vec
= Vec::new(); let mut offset = 0usize; @@ -545,7 +563,9 @@ impl MarkdownDocument { let inline_len = match inline { Inline::Text(text) => char_len(text), Inline::Code(code) => char_len(code), - Inline::Bold(children) | Inline::Italic(children) => Self::inlines_text_length(children), + Inline::Bold(children) | Inline::Italic(children) => { + Self::inlines_text_length(children) + } Inline::Link { children, .. } => Self::inlines_text_length(children), }; @@ -553,14 +573,13 @@ impl MarkdownDocument { if e <= offset || s >= offset + inline_len { None } else { - Some(( - s.saturating_sub(offset), - (e - offset).min(inline_len), - )) + Some((s.saturating_sub(offset), (e - offset).min(inline_len))) } }); - elements.push(Self::render_inline_with_selection(inline, t, cx, inline_sel)); + elements.push(Self::render_inline_with_selection( + inline, t, cx, inline_sel, + )); offset += inline_len; } @@ -581,7 +600,12 @@ impl MarkdownDocument { } /// Render a single inline element with selection. - fn render_inline_with_selection(inline: &Inline, t: &ThemeColors, cx: &App, selection: Option<(usize, usize)>) -> Div { + fn render_inline_with_selection( + inline: &Inline, + t: &ThemeColors, + cx: &App, + selection: Option<(usize, usize)>, + ) -> Div { let selection_bg = rgba(0x3390ff40); match inline { @@ -639,7 +663,8 @@ impl MarkdownDocument { Some((s.saturating_sub(offset), (e - offset).min(child_len))) } }); - container = container.child(Self::render_inline_with_selection(child, t, cx, child_sel)); + container = container + .child(Self::render_inline_with_selection(child, t, cx, child_sel)); offset += child_len; } container @@ -661,7 +686,8 @@ impl MarkdownDocument { Some((s.saturating_sub(offset), (e - offset).min(child_len))) } }); - container = container.child(Self::render_inline_with_selection(child, t, cx, child_sel)); + container = container + .child(Self::render_inline_with_selection(child, t, cx, child_sel)); offset += child_len; } container @@ -687,7 +713,8 @@ impl MarkdownDocument { Some((s.saturating_sub(offset), (e - offset).min(child_len))) } }); - container = container.child(Self::render_inline_with_selection(child, t, cx, child_sel)); + container = container + .child(Self::render_inline_with_selection(child, t, cx, child_sel)); offset += child_len; } container @@ -739,16 +766,12 @@ impl MarkdownDocument { let width = col_widths.get(i).copied().unwrap_or(10); let min_w = ((width * 8) + 24).max(80) as f32; header_row = header_row.child( - div() - .min_w(px(min_w)) - .px(px(12.0)) - .py(px(8.0)) - .child( - Self::render_inlines_with_selection(header, t, cx, cell_sel) - .text_size(ui_text_md(cx)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(t.text_primary)) - ) + div().min_w(px(min_w)).px(px(12.0)).py(px(8.0)).child( + Self::render_inlines_with_selection(header, t, cx, cell_sel) + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_primary)), + ), ); offset += cell_len; } @@ -784,15 +807,11 @@ impl MarkdownDocument { let width = col_widths.get(i).copied().unwrap_or(10); let min_w = ((width * 8) + 24).max(80) as f32; row_div = row_div.child( - div() - .min_w(px(min_w)) - .px(px(12.0)) - .py(px(6.0)) - .child( - Self::render_inlines_with_selection(cell, t, cx, cell_sel) - .text_size(ui_text_md(cx)) - .text_color(rgb(t.text_secondary)) - ) + div().min_w(px(min_w)).px(px(12.0)).py(px(6.0)).child( + Self::render_inlines_with_selection(cell, t, cx, cell_sel) + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_secondary)), + ), ); offset += cell_len; } @@ -831,7 +850,11 @@ impl MarkdownDocument { /// Render heading text with selection highlighting. /// Returns a Div with flex layout containing the text split by selection. - fn render_heading_text_with_selection(inlines: &[Inline], sel_start: usize, sel_end: usize) -> Div { + fn render_heading_text_with_selection( + inlines: &[Inline], + sel_start: usize, + sel_end: usize, + ) -> Div { let selection_bg = rgba(0x3390ff40); let text = Self::render_inlines_as_text(inlines); let (before, selected, after) = slice_by_chars(&text, sel_start, sel_end); diff --git a/crates/okena-markdown/src/types.rs b/crates/okena-markdown/src/types.rs index 8482ffc45..603f769e5 100644 --- a/crates/okena-markdown/src/types.rs +++ b/crates/okena-markdown/src/types.rs @@ -3,10 +3,21 @@ /// A node in the markdown AST. #[derive(Clone)] pub(crate) enum Node { - Heading { level: u8, children: Vec }, - Paragraph { children: Vec }, - CodeBlock { language: Option, code: String }, - List { ordered: bool, items: Vec> }, + Heading { + level: u8, + children: Vec, + }, + Paragraph { + children: Vec, + }, + CodeBlock { + language: Option, + code: String, + }, + List { + ordered: bool, + items: Vec>, + }, Table { headers: Vec>, rows: Vec>>, @@ -14,14 +25,19 @@ pub(crate) enum Node { /// rendering does not re-measure every cell on every frame. col_widths: Vec, }, - Blockquote { children: Vec }, + Blockquote { + children: Vec, + }, HorizontalRule, /// YAML frontmatter at the top of the document, rendered as a metadata card /// rather than fed to the markdown parser (where it degrades into a heading). /// `text_len` is the flat-text length (in chars), precomputed at parse time /// so selection-offset accounting does not rebuild the flat text on every /// frame — same rationale as `Table`'s `col_widths`. - Frontmatter { block: Frontmatter, text_len: usize }, + Frontmatter { + block: Frontmatter, + text_len: usize, + }, } /// Parsed YAML frontmatter block. diff --git a/crates/okena-markdown/tests/inline_layout.rs b/crates/okena-markdown/tests/inline_layout.rs index 73a257db2..5802dc96c 100644 --- a/crates/okena-markdown/tests/inline_layout.rs +++ b/crates/okena-markdown/tests/inline_layout.rs @@ -8,7 +8,7 @@ //! measured height stays at the natural multi-line text height. use gpui::prelude::*; -use gpui::{div, px, AvailableSpace, Point, Size, TestAppContext}; +use gpui::{AvailableSpace, Point, Size, TestAppContext, div, px}; use okena_core::theme::DARK_THEME; use okena_markdown::{MarkdownDocument, RenderedNode}; diff --git a/crates/okena-mobile-ffi/Cargo.toml b/crates/okena-mobile-ffi/Cargo.toml index c84e04e92..70f463fa7 100644 --- a/crates/okena-mobile-ffi/Cargo.toml +++ b/crates/okena-mobile-ffi/Cargo.toml @@ -28,7 +28,6 @@ uniffi = { version = "0.31", features = ["tokio"] } # its direct dependencies — no flutter_rust_bridge. alacritty_terminal = "0.25" tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } async-channel = "2.3" parking_lot = "0.12" uuid = { version = "1.10", features = ["v4"] } diff --git a/crates/okena-mobile-ffi/src/api/connection.rs b/crates/okena-mobile-ffi/src/api/connection.rs index 12b362e4f..09e376185 100644 --- a/crates/okena-mobile-ffi/src/api/connection.rs +++ b/crates/okena-mobile-ffi/src/api/connection.rs @@ -23,7 +23,9 @@ pub enum ConnectionStatus { impl From for ConnectionStatus { fn from(status: okena_transport::client::ConnectionStatus) -> Self { match status { - okena_transport::client::ConnectionStatus::Disconnected => ConnectionStatus::Disconnected, + okena_transport::client::ConnectionStatus::Disconnected => { + ConnectionStatus::Disconnected + } okena_transport::client::ConnectionStatus::Connecting => ConnectionStatus::Connecting, okena_transport::client::ConnectionStatus::Connected => ConnectionStatus::Connected, okena_transport::client::ConnectionStatus::Pairing => ConnectionStatus::Pairing, diff --git a/crates/okena-mobile-ffi/src/api/state.rs b/crates/okena-mobile-ffi/src/api/state.rs index 699593a18..e3848350d 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,15 +68,17 @@ 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() }; let (git_branch, git_lines_added, git_lines_removed) = if let Some(ref gs) = p.git_status { - (gs.branch.clone(), gs.lines_added as u32, gs.lines_removed as u32) + ( + gs.branch.clone(), + gs.lines_added as u32, + gs.lines_removed as u32, + ) } else { (None, 0, 0) }; @@ -154,18 +156,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/handler.rs b/crates/okena-mobile-ffi/src/client/handler.rs index 38481361b..d2afbb3fd 100644 --- a/crates/okena-mobile-ffi/src/client/handler.rs +++ b/crates/okena-mobile-ffi/src/client/handler.rs @@ -1,6 +1,6 @@ use crate::client::terminal_holder::TerminalHolder; -use okena_transport::client::{is_remote_terminal, ConnectionHandler, WsClientMessage}; +use okena_transport::client::{ConnectionHandler, WsClientMessage, is_remote_terminal}; use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; use std::sync::Arc; @@ -48,7 +48,11 @@ impl ConnectionHandler for MobileConnectionHandler { if self.terminals.read().contains_key(prefixed_id) { return; } - let (c, r) = if cols > 0 && rows > 0 { (cols, rows) } else { (80, 24) }; + let (c, r) = if cols > 0 && rows > 0 { + (cols, rows) + } else { + (80, 24) + }; let holder = TerminalHolder::new(c, r); self.terminals .write() diff --git a/crates/okena-mobile-ffi/src/client/manager.rs b/crates/okena-mobile-ffi/src/client/manager.rs index 8131818c5..174328925 100644 --- a/crates/okena-mobile-ffi/src/client/manager.rs +++ b/crates/okena-mobile-ffi/src/client/manager.rs @@ -1,13 +1,13 @@ use crate::client::handler::MobileConnectionHandler; use crate::client::terminal_holder::TerminalHolder; -use okena_core::api::{ActionRequest, StateResponse}; +use okena_core::api::{ActionRequest, ApiFullscreen, ApiLayoutNode, StateResponse}; use okena_transport::client::{ - make_prefixed_id, ConnectionEvent, ConnectionStatus, RemoteClient, RemoteConnectionConfig, - WsClientMessage, + ConnectionEvent, ConnectionStatus, RemoteClient, RemoteConnectionConfig, WsClientMessage, + make_prefixed_id, }; use parking_lot::RwLock; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; static MANAGER: OnceLock = OnceLock::new(); @@ -25,6 +25,285 @@ struct MobileConnection { _event_task: Option>, } +fn merge_layout_presentation(server: &ApiLayoutNode, local: &ApiLayoutNode) -> ApiLayoutNode { + let mut merged = merge_layout_structure(server, local); + let mut presentation = HashMap::new(); + collect_terminal_presentation(local, &mut presentation); + apply_terminal_presentation(&mut merged, &presentation); + merged +} + +fn merge_layout_structure(server: &ApiLayoutNode, local: &ApiLayoutNode) -> ApiLayoutNode { + match (server, local) { + (ApiLayoutNode::Terminal { .. }, _) => server.clone(), + ( + ApiLayoutNode::Split { + direction, + sizes: server_sizes, + children: server_children, + }, + ApiLayoutNode::Split { + direction: local_direction, + sizes: local_sizes, + children: local_children, + }, + ) if direction == local_direction => { + let mapping = matching_api_child_indices(server_children, local_children); + let children = + merge_mapped_api_children(server_children, local_children, mapping.as_deref()); + let sizes = mapping + .filter(|indices| local_sizes.len() == indices.len()) + .map(|indices| { + indices + .into_iter() + .map(|index| local_sizes[index]) + .collect() + }) + .unwrap_or_else(|| server_sizes.clone()); + ApiLayoutNode::Split { + direction: *direction, + sizes, + children, + } + } + ( + ApiLayoutNode::Tabs { + children: server_children, + active_tab: server_active, + }, + ApiLayoutNode::Tabs { + children: local_children, + active_tab: local_active, + }, + ) => { + let mapping = matching_api_child_indices(server_children, local_children); + ApiLayoutNode::Tabs { + children: merge_mapped_api_children( + server_children, + local_children, + mapping.as_deref(), + ), + active_tab: merged_api_active_tab( + server_children, + *server_active, + local_children, + *local_active, + ), + } + } + _ => server.clone(), + } +} + +fn merge_mapped_api_children( + server: &[ApiLayoutNode], + local: &[ApiLayoutNode], + mapping: Option<&[usize]>, +) -> Vec { + server + .iter() + .enumerate() + .map(|(server_index, server_child)| { + mapping + .and_then(|indices| indices.get(server_index)) + .and_then(|local_index| local.get(*local_index)) + .map(|local_child| merge_layout_structure(server_child, local_child)) + .unwrap_or_else(|| server_child.clone()) + }) + .collect() +} + +fn matching_api_child_indices( + server: &[ApiLayoutNode], + local: &[ApiLayoutNode], +) -> Option> { + let server_ids: Vec> = server + .iter() + .map(|child| child.collect_terminal_ids().into_iter().collect()) + .collect(); + let local_ids: Vec> = local + .iter() + .map(|child| child.collect_terminal_ids().into_iter().collect()) + .collect(); + + let mut used = HashSet::new(); + let exact: Option> = server_ids + .iter() + .map(|ids| { + if ids.is_empty() { + return None; + } + let index = local_ids + .iter() + .enumerate() + .find(|(index, candidate)| !used.contains(index) && *candidate == ids) + .map(|(index, _)| index)?; + used.insert(index); + Some(index) + }) + .collect(); + if exact.is_some() { + return exact; + } + + server_ids + .iter() + .zip(&local_ids) + .all(|(server, local)| { + (server.is_empty() && local.is_empty()) || server.iter().any(|id| local.contains(id)) + }) + .then(|| (0..server.len()).collect()) +} + +fn merged_api_active_tab( + server_children: &[ApiLayoutNode], + server_active: usize, + local_children: &[ApiLayoutNode], + local_active: usize, +) -> usize { + let fallback = server_active.min(server_children.len().saturating_sub(1)); + let Some(local_child) = local_children.get(local_active) else { + return fallback; + }; + let selected_ids: HashSet = local_child.collect_terminal_ids().into_iter().collect(); + if selected_ids.is_empty() { + return local_active.min(server_children.len().saturating_sub(1)); + } + + server_children + .iter() + .position(|child| { + child + .collect_terminal_ids() + .iter() + .any(|id| selected_ids.contains(id)) + }) + .unwrap_or(fallback) +} + +fn collect_terminal_presentation( + layout: &ApiLayoutNode, + presentation: &mut HashMap, +) { + match layout { + ApiLayoutNode::Terminal { + terminal_id: Some(id), + minimized, + detached, + .. + } => { + presentation.insert(id.clone(), (*minimized, *detached)); + } + ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { + for child in children { + collect_terminal_presentation(child, presentation); + } + } + ApiLayoutNode::Terminal { + terminal_id: None, .. + } => {} + } +} + +fn apply_terminal_presentation( + layout: &mut ApiLayoutNode, + presentation: &HashMap, +) { + match layout { + ApiLayoutNode::Terminal { + terminal_id: Some(id), + minimized, + detached, + .. + } => { + if let Some(&(local_minimized, local_detached)) = presentation.get(id) { + *minimized = local_minimized; + *detached = local_detached; + } + } + ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { + for child in children { + apply_terminal_presentation(child, presentation); + } + } + ApiLayoutNode::Terminal { + terminal_id: None, .. + } => {} + } +} + +fn merge_state_presentation(next: &mut StateResponse, previous: &StateResponse) { + for project in &mut next.projects { + let Some(previous_project) = previous.projects.iter().find(|old| old.id == project.id) + else { + continue; + }; + if let (Some(server), Some(local)) = (&project.layout, &previous_project.layout) { + project.layout = Some(merge_layout_presentation(server, local)); + } + } + + next.fullscreen_terminal = previous + .fullscreen_terminal + .as_ref() + .and_then(|fullscreen| { + let terminal_exists = next.projects.iter().any(|project| { + project.id == fullscreen.project_id + && project.layout.as_ref().is_some_and(|layout| { + layout_contains_terminal(layout, &fullscreen.terminal_id) + }) + }); + terminal_exists.then(|| fullscreen.clone()) + }); +} + +fn layout_contains_terminal(layout: &ApiLayoutNode, terminal_id: &str) -> bool { + match layout { + ApiLayoutNode::Terminal { + terminal_id: Some(id), + .. + } => id == terminal_id, + ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => children + .iter() + .any(|child| layout_contains_terminal(child, terminal_id)), + ApiLayoutNode::Terminal { + terminal_id: None, .. + } => false, + } +} + +fn toggle_terminal_minimized(layout: &mut ApiLayoutNode, terminal_id: &str) -> bool { + match layout { + ApiLayoutNode::Terminal { + terminal_id: Some(id), + minimized, + .. + } if id == terminal_id => { + *minimized = !*minimized; + true + } + ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => children + .iter_mut() + .any(|child| toggle_terminal_minimized(child, terminal_id)), + ApiLayoutNode::Terminal { .. } => false, + } +} + +fn layout_at_path_mut<'a>( + mut layout: &'a mut ApiLayoutNode, + path: &[usize], +) -> Option<&'a mut ApiLayoutNode> { + for &index in path { + layout = match layout { + ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { + children.get_mut(index)? + } + ApiLayoutNode::Terminal { .. } => return None, + }; + } + Some(layout) +} + impl ConnectionManager { /// Initialize the global singleton. Call once at app startup. pub fn init() { @@ -101,6 +380,7 @@ impl ConnectionManager { port: u16, saved_token: Option, tls: bool, + pinned_cert_sha256: Option, ) -> String { // Replace any existing connection targeting the same server. We collect // matching ids first (read lock), then remove them (which takes its own @@ -129,7 +409,8 @@ impl ConnectionManager { saved_token, token_obtained_at: None, tls, - pinned_cert_sha256: None, + pinned_cert_sha256, + local_endpoint: None, }; let conn_id = config.id.clone(); @@ -139,12 +420,7 @@ impl ConnectionManager { let (event_tx, event_rx) = async_channel::bounded::(256); - let client = RemoteClient::new( - config, - self.runtime.clone(), - handler.clone(), - event_tx, - ); + let client = RemoteClient::new(config, self.runtime.clone(), handler.clone(), event_tx); // Spawn event processor task let conn_id_clone = conn_id.clone(); @@ -162,10 +438,9 @@ impl ConnectionManager { self.connections.write().insert(conn_id.clone(), connection); // Spawn event processor - let event_task = self.runtime.spawn(Self::process_events( - conn_id_clone.clone(), - event_rx, - )); + let event_task = self + .runtime + .spawn(Self::process_events(conn_id_clone.clone(), event_rx)); // Store the task handle if let Some(conn) = self.connections.write().get_mut(&conn_id) { @@ -227,6 +502,111 @@ impl ConnectionManager { .and_then(|conn| conn.state_cache.read().clone()) } + pub fn toggle_minimized_local( + &self, + conn_id: &str, + project_id: &str, + terminal_id: &str, + ) -> Result<(), String> { + let connections = self.connections.read(); + let connection = connections + .get(conn_id) + .ok_or_else(|| format!("Connection not found: {conn_id}"))?; + let mut state = connection.state_cache.write(); + let project = state + .as_mut() + .and_then(|state| { + state + .projects + .iter_mut() + .find(|project| project.id == project_id) + }) + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let changed = project + .layout + .as_mut() + .is_some_and(|layout| toggle_terminal_minimized(layout, terminal_id)); + if changed { + Ok(()) + } else { + Err(format!("Terminal not found: {terminal_id}")) + } + } + + pub fn set_fullscreen_local( + &self, + conn_id: &str, + project_id: &str, + terminal_id: Option, + ) -> Result<(), String> { + let connections = self.connections.read(); + let connection = connections + .get(conn_id) + .ok_or_else(|| format!("Connection not found: {conn_id}"))?; + let mut state = connection.state_cache.write(); + let state = state + .as_mut() + .ok_or_else(|| format!("State unavailable for connection: {conn_id}"))?; + + state.fullscreen_terminal = match terminal_id { + Some(terminal_id) => { + let exists = state.projects.iter().any(|project| { + project.id == project_id + && project + .layout + .as_ref() + .is_some_and(|layout| layout_contains_terminal(layout, &terminal_id)) + }); + if !exists { + return Err(format!("Terminal not found: {terminal_id}")); + } + Some(ApiFullscreen { + project_id: project_id.to_string(), + terminal_id, + }) + } + None => None, + }; + Ok(()) + } + + pub fn set_active_tab_local( + &self, + conn_id: &str, + project_id: &str, + path: &[usize], + index: usize, + ) -> Result<(), String> { + let connections = self.connections.read(); + let connection = connections + .get(conn_id) + .ok_or_else(|| format!("Connection not found: {conn_id}"))?; + let mut state = connection.state_cache.write(); + let layout = state + .as_mut() + .and_then(|state| { + state + .projects + .iter_mut() + .find(|project| project.id == project_id) + }) + .and_then(|project| project.layout.as_mut()) + .and_then(|layout| layout_at_path_mut(layout, path)) + .ok_or_else(|| format!("Tab group not found at path: {path:?}"))?; + let ApiLayoutNode::Tabs { + children, + active_tab, + } = layout + else { + return Err(format!("Layout at path is not a tab group: {path:?}")); + }; + if index >= children.len() { + return Err(format!("Tab index out of range: {index}")); + } + *active_tab = index; + Ok(()) + } + /// Access a terminal holder for reading cells / cursor. /// The callback receives the TerminalHolder if found. pub fn with_terminal(&self, conn_id: &str, terminal_id: &str, f: F) -> Option @@ -283,11 +663,7 @@ impl ConnectionManager { } /// Send an action to the remote server via POST /v1/actions. - pub async fn send_action( - &self, - conn_id: &str, - action: ActionRequest, - ) -> anyhow::Result<()> { + pub async fn send_action(&self, conn_id: &str, action: ActionRequest) -> anyhow::Result<()> { self.send_action_with_response(conn_id, action).await?; Ok(()) } @@ -298,43 +674,29 @@ impl ConnectionManager { conn_id: &str, action: ActionRequest, ) -> anyhow::Result { - let (host, port, token) = { + let (config, token) = { let connections = self.connections.read(); let conn = connections .get(conn_id) .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", conn_id))?; let config = conn.client.read().config().clone(); let token = config - .saved_token + .effective_auth_token() .ok_or_else(|| anyhow::anyhow!("No auth token for connection: {}", conn_id))?; - (config.host, config.port, token) + (config, token) }; - let url = format!("http://{}:{}/v1/actions", host, port); - let client = reqwest::Client::new(); - let resp = client - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .json(&action) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("Action failed ({}): {}", status, body); + let response = okena_transport::remote_action::post_action_async(&config, &token, action) + .await + .map_err(anyhow::Error::msg)?; + match response { + Some(value) => Ok(serde_json::to_string(&value)?), + None => Ok(String::new()), } - - let body = resp.text().await.unwrap_or_default(); - Ok(body) } /// Background task that drains the event channel and updates connection state. - async fn process_events( - conn_id: String, - event_rx: async_channel::Receiver, - ) { + async fn process_events(conn_id: String, event_rx: async_channel::Receiver) { while let Ok(event) = event_rx.recv().await { let mgr = match MANAGER.get() { Some(m) => m, @@ -368,7 +730,9 @@ impl ConnectionManager { cert_fingerprint.clone(); } } - ConnectionEvent::TlsUpgraded { cert_fingerprint, .. } => { + ConnectionEvent::TlsUpgraded { + cert_fingerprint, .. + } => { let mut client = conn.client.write(); client.config_mut().tls = true; client.config_mut().pinned_cert_sha256 = cert_fingerprint.clone(); @@ -383,26 +747,232 @@ impl ConnectionManager { .as_secs() as i64, ); } - ConnectionEvent::StateReceived { state, .. } => { - *conn.state_cache.write() = Some(state); + ConnectionEvent::StateReceived { mut state, .. } => { + let mut cache = conn.state_cache.write(); + if let Some(previous) = cache.as_ref() { + merge_state_presentation(&mut state, previous); + } else { + state.fullscreen_terminal = None; + } + *cache = Some(state); } + ConnectionEvent::SettingsChanged { .. } => {} ConnectionEvent::SubscriptionMappings { mappings, .. } => { conn.client.write().update_stream_mappings(mappings); } - ConnectionEvent::GitStatusChanged { - statuses, - .. - } => { + ConnectionEvent::GitStatusChanged { statuses, .. } => { if let Some(state) = conn.state_cache.write().as_mut() { for project in &mut state.projects { project.git_status = statuses.get(&project.id).cloned(); } } } + ConnectionEvent::SystemStatsChanged { .. } + | ConnectionEvent::TerminalFocusRequested { .. } => {} 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); + } } } } } + +#[cfg(test)] +mod tests { + use super::*; + use okena_core::api::ApiProject; + use okena_core::shell::ShellType; + use okena_core::types::SplitDirection; + + fn terminal(id: &str, minimized: bool, shell_type: ShellType) -> ApiLayoutNode { + ApiLayoutNode::Terminal { + terminal_id: Some(id.to_string()), + minimized, + detached: false, + shell_type, + cols: None, + rows: None, + } + } + + fn project(id: &str, layout: ApiLayoutNode) -> ApiProject { + ApiProject { + id: id.to_string(), + name: id.to_string(), + path: "/tmp".to_string(), + show_in_overview: true, + layout: Some(layout), + terminal_names: HashMap::new(), + git_status: None, + folder_color: Default::default(), + 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(), + is_creating: false, + is_closing: false, + } + } + + fn state(projects: Vec) -> StateResponse { + StateResponse { + state_version: 1, + projects, + focused_project_id: None, + fullscreen_terminal: None, + project_order: Vec::new(), + folders: Vec::new(), + windows: Vec::new(), + hooks: Vec::new(), + } + } + + #[test] + fn mobile_layout_merge_keeps_local_presentation_and_server_shell() { + let server = ApiLayoutNode::Tabs { + children: vec![ + terminal( + "one", + false, + ShellType::Custom { + path: "/bin/fish".to_string(), + args: Vec::new(), + }, + ), + terminal("two", false, ShellType::Default), + ], + active_tab: 0, + }; + let local = ApiLayoutNode::Tabs { + children: vec![ + terminal("one", true, ShellType::Default), + terminal("two", false, ShellType::Default), + ], + active_tab: 1, + }; + + let merged = merge_layout_presentation(&server, &local); + let ApiLayoutNode::Tabs { + children, + active_tab, + } = merged + else { + panic!("expected tabs"); + }; + assert_eq!(active_tab, 1); + let ApiLayoutNode::Terminal { + minimized, + shell_type, + .. + } = &children[0] + else { + panic!("expected terminal"); + }; + assert!(*minimized); + assert_eq!( + shell_type, + &ShellType::Custom { + path: "/bin/fish".to_string(), + args: Vec::new(), + } + ); + } + + #[test] + fn mobile_layout_merge_follows_reordered_terminal_identity() { + let server = ApiLayoutNode::Tabs { + children: vec![ + terminal("two", false, ShellType::Default), + terminal("one", false, ShellType::Default), + ], + active_tab: 1, + }; + let local = ApiLayoutNode::Tabs { + children: vec![ + terminal("one", false, ShellType::Default), + terminal("two", true, ShellType::Default), + ], + active_tab: 0, + }; + + let merged = merge_layout_presentation(&server, &local); + let ApiLayoutNode::Tabs { + children, + active_tab, + } = merged + else { + panic!("expected tabs"); + }; + assert_eq!(active_tab, 1); + assert!(matches!( + &children[0], + ApiLayoutNode::Terminal { + terminal_id: Some(id), + minimized: true, + .. + } if id == "two" + )); + } + + #[test] + fn mobile_layout_merge_does_not_reuse_sizes_across_direction_change() { + let server = ApiLayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![40.0, 60.0], + children: vec![ + terminal("one", false, ShellType::Default), + terminal("two", false, ShellType::Default), + ], + }; + let local = ApiLayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![25.0, 75.0], + children: vec![ + terminal("one", false, ShellType::Default), + terminal("two", false, ShellType::Default), + ], + }; + + let merged = merge_layout_presentation(&server, &local); + let ApiLayoutNode::Split { sizes, .. } = merged else { + panic!("expected split"); + }; + assert_eq!(sizes, vec![40.0, 60.0]); + } + + #[test] + fn mobile_fullscreen_survives_resync_only_while_terminal_exists() { + let mut previous = state(vec![project( + "project", + terminal("terminal", false, ShellType::Default), + )]); + previous.fullscreen_terminal = Some(ApiFullscreen { + project_id: "project".to_string(), + terminal_id: "terminal".to_string(), + }); + + let mut next = state(vec![project( + "project", + terminal("terminal", false, ShellType::Default), + )]); + merge_state_presentation(&mut next, &previous); + assert!(next.fullscreen_terminal.is_some()); + + let mut without_terminal = state(vec![project( + "project", + terminal("replacement", false, ShellType::Default), + )]); + merge_state_presentation(&mut without_terminal, &previous); + assert!(without_terminal.fullscreen_terminal.is_none()); + } +} diff --git a/crates/okena-mobile-ffi/src/client/terminal_holder.rs b/crates/okena-mobile-ffi/src/client/terminal_holder.rs index 03bd54979..786a05c2f 100644 --- a/crates/okena-mobile-ffi/src/client/terminal_holder.rs +++ b/crates/okena-mobile-ffi/src/client/terminal_holder.rs @@ -143,8 +143,13 @@ impl TerminalHolder { // Hide cursor when scrolled into history (cursor would be off-screen) let cursor_visual_line = cursor.line.0 + display_offset; let screen_lines = term.grid().screen_lines() as i32; - let visible = term.mode().contains(alacritty_terminal::term::TermMode::SHOW_CURSOR) - && !matches!(cursor_shape, alacritty_terminal::vte::ansi::CursorShape::Hidden) + let visible = term + .mode() + .contains(alacritty_terminal::term::TermMode::SHOW_CURSOR) + && !matches!( + cursor_shape, + alacritty_terminal::vte::ansi::CursorShape::Hidden + ) && cursor_visual_line >= 0 && cursor_visual_line < screen_lines; @@ -233,11 +238,12 @@ impl TerminalHolder { pub fn selection_bounds(&self) -> Option<((usize, i32), (usize, i32))> { let term = self.term.lock(); if let Some(ref selection) = term.selection - && let Some(range) = selection.to_range(&*term) { - let start = (range.start.column.0, range.start.line.0); - let end = (range.end.column.0, range.end.line.0); - return Some((start, end)); - } + && let Some(range) = selection.to_range(&*term) + { + let start = (range.start.column.0, range.start.line.0); + let end = (range.end.column.0, range.end.line.0); + return Some((start, end)); + } None } @@ -268,7 +274,11 @@ mod tests { holder.process_output(b"Hello, world!"); let cells = holder.get_visible_cells(&DARK_THEME); // Cells should contain H, e, l, l, o, etc. (minus WIDE_CHAR_SPACERs) - let text: String = cells.iter().take(13).map(|c| c.character.as_str()).collect(); + let text: String = cells + .iter() + .take(13) + .map(|c| c.character.as_str()) + .collect(); assert_eq!(text, "Hello, world!"); } diff --git a/crates/okena-mobile-ffi/src/lib.rs b/crates/okena-mobile-ffi/src/lib.rs index a0ef9a03f..4e24dad76 100644 --- a/crates/okena-mobile-ffi/src/lib.rs +++ b/crates/okena-mobile-ffi/src/lib.rs @@ -30,10 +30,10 @@ mod client; mod types; use okena_core::api::ActionRequest; -use okena_transport::client::{collect_state_terminal_ids, WsClientMessage}; use okena_core::keys::SpecialKey; use okena_core::theme::DARK_THEME; use okena_core::types::{DiffMode, SplitDirection}; +use okena_transport::client::{WsClientMessage, collect_state_terminal_ids}; use crate::client::manager::ConnectionManager; @@ -59,10 +59,8 @@ pub fn init_app() { /// at the binding boundary so the RN UI and persisted server config can carry /// the TLS flag. /// -/// `tls` is forwarded to `ConnectionManager::add_connection`. The pinned cert, -/// however, is established via TOFU during the handshake (the manager has no -/// param to pre-seed a fingerprint — it records it from the `TlsUpgraded` / -/// pairing events), so `pinned_cert_fingerprint` is not forwarded yet. +/// Existing pins are enforced immediately; first-use connections capture and +/// report their fingerprint through the normal connection events. #[uniffi::export] pub fn connect( host: String, @@ -71,12 +69,8 @@ pub fn connect( tls: bool, pinned_cert_fingerprint: Option, ) -> String { - // `pinned_cert_fingerprint` is intentionally not forwarded yet — the - // manager pins via TOFU events rather than an up-front fingerprint. Touch it - // so the unused-var lint stays quiet and the intent is explicit. - let _ = pinned_cert_fingerprint; let mgr = ConnectionManager::get(); - let conn_id = mgr.add_connection(&host, port, saved_token, tls); + let conn_id = mgr.add_connection(&host, port, saved_token, tls, pinned_cert_fingerprint); mgr.connect(&conn_id); conn_id } @@ -184,7 +178,12 @@ pub fn get_visible_cells_packed(conn_id: String, terminal_id: String) -> Vec buf.extend_from_slice(&rows.to_le_bytes()); for cell in &cells { // Primary scalar; empty (wide-char spacer) or space → 0x20. - let codepoint: u32 = cell.character.chars().next().map(|c| c as u32).unwrap_or(0x20); + let codepoint: u32 = cell + .character + .chars() + .next() + .map(|c| c as u32) + .unwrap_or(0x20); let codepoint = if codepoint == 0 { 0x20 } else { codepoint }; buf.extend_from_slice(&codepoint.to_le_bytes()); buf.extend_from_slice(&cell.fg.to_le_bytes()); @@ -210,7 +209,13 @@ pub fn get_cursor(conn_id: String, terminal_id: String) -> CursorState { /// Send text input to a terminal. Synchronous: only enqueues a WS message. #[uniffi::export] pub fn send_text(conn_id: String, terminal_id: String, text: String) { - ConnectionManager::get().send_ws_message(&conn_id, WsClientMessage::SendText { terminal_id, text }); + ConnectionManager::get().send_ws_message( + &conn_id, + WsClientMessage::SendInput { + terminal_id, + data: text.into_bytes(), + }, + ); } /// Resize a terminal (local grid + WS resize message). @@ -404,8 +409,13 @@ pub fn send_special_key( message: format!("Unknown special key: {key}"), })?; let bytes = special_key.to_bytes(); - let text = String::from_utf8_lossy(&bytes).to_string(); - ConnectionManager::get().send_ws_message(&conn_id, WsClientMessage::SendText { terminal_id, text }); + ConnectionManager::get().send_ws_message( + &conn_id, + WsClientMessage::SendInput { + terminal_id, + data: bytes.to_vec(), + }, + ); Ok(()) } @@ -437,15 +447,19 @@ 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 +469,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 +486,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,37 +504,30 @@ 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(()) -} - -/// Focus a terminal. + send_mobile_action( + &conn_id, + ActionRequest::RenameTerminal { + project_id, + terminal_id, + name, + }, + ) + .await +} + +/// Record activity for a terminal focused in the mobile client's local state. #[uniffi::export(async_runtime = "tokio")] pub async fn focus_terminal( conn_id: String, project_id: String, terminal_id: String, ) -> Result<(), MobileFfiError> { - ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::FocusTerminal { - project_id, - terminal_id, - window: None, - }, - ) - .await?; - Ok(()) + let _ = terminal_id; + send_mobile_action( + &conn_id, + ActionRequest::RecordProjectActivity { project_id }, + ) + .await } /// Toggle minimized state of a terminal. @@ -535,15 +538,8 @@ pub async fn toggle_minimized( terminal_id: String, ) -> Result<(), MobileFfiError> { ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::ToggleMinimized { - project_id, - terminal_id, - }, - ) - .await?; - Ok(()) + .toggle_minimized_local(&conn_id, &project_id, &terminal_id) + .map_err(|message| MobileFfiError::Action { message }) } /// Set/clear fullscreen terminal. @@ -554,16 +550,8 @@ pub async fn set_fullscreen( terminal_id: Option, ) -> Result<(), MobileFfiError> { ConnectionManager::get() - .send_action( - &conn_id, - ActionRequest::SetFullscreen { - project_id, - terminal_id, - window: None, - }, - ) - .await?; - Ok(()) + .set_fullscreen_local(&conn_id, &project_id, terminal_id) + .map_err(|message| MobileFfiError::Action { message }) } /// Split a terminal pane. `direction` is "vertical" or "horizontal". @@ -578,17 +566,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 +584,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 +687,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 +704,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,46 +721,32 @@ 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. #[uniffi::export(async_runtime = "tokio")] -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(()) +pub async fn start_all_services(conn_id: String, project_id: String) -> Result<(), MobileFfiError> { + 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 +758,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 +770,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 +789,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 +807,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 +828,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 +847,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 +866,19 @@ pub async fn set_active_tab( path: Vec, index: u32, ) -> Result<(), MobileFfiError> { + let path = path + .into_iter() + .map(usize::try_from) + .collect::, _>>() + .map_err(|error| MobileFfiError::Action { + message: format!("Invalid tab path: {error}"), + })?; + let index = usize::try_from(index).map_err(|error| MobileFfiError::Action { + message: format!("Invalid tab index: {error}"), + })?; 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(()) + .set_active_tab_local(&conn_id, &project_id, &path, index) + .map_err(|message| MobileFfiError::Action { message }) } /// Move a tab within a tab group. @@ -935,18 +890,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 +912,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 +935,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/Cargo.toml b/crates/okena-remote-client/Cargo.toml index 369a166d9..36844a3a6 100644 --- a/crates/okena-remote-client/Cargo.toml +++ b/crates/okena-remote-client/Cargo.toml @@ -15,10 +15,11 @@ gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } tokio = { version = "1", features = ["rt-multi-thread"] } async-channel = "2.3" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde_json = "1.0" anyhow = "1.0" log = "0.4" uuid = { version = "1.10", features = ["v4"] } +parking_lot = "0.12" [dev-dependencies] gpui = { git = "https://github.com/zed-industries/zed", package = "gpui", features = ["test-support"] } -parking_lot = "0.12" diff --git a/crates/okena-remote-client/src/backend.rs b/crates/okena-remote-client/src/backend.rs index ac86995d7..41e79f3f7 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::{ + REMOTE_TERMINAL_ANSWERS_QUERIES, REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS, + REMOTE_TERMINAL_USES_MOUSE_BACKEND, WsClientMessage, close_remote_terminal, make_prefixed_id, + resize_remote_terminal, send_remote_terminal_input, +}; use std::path::PathBuf; use std::sync::Arc; @@ -12,34 +16,61 @@ use std::sync::Arc; /// Used inside `Terminal` objects for I/O - the Terminal doesn't know /// it's remote vs local. pub struct RemoteTransport { - pub(crate) ws_tx: async_channel::Sender, + ws_tx: parking_lot::RwLock>, pub(crate) connection_id: String, } +impl RemoteTransport { + pub(crate) fn new( + ws_tx: async_channel::Sender, + connection_id: String, + ) -> Self { + Self { + ws_tx: parking_lot::RwLock::new(ws_tx), + connection_id, + } + } + + pub(crate) fn replace_sender(&self, ws_tx: async_channel::Sender) { + *self.ws_tx.write() = ws_tx; + } + + fn sender(&self) -> async_channel::Sender { + self.ws_tx.read().clone() + } +} + 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.sender(), &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.sender(), &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 +111,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.sender(), &self.connection_id, terminal_id); } fn capture_buffer(&self, _terminal_id: &str) -> Option { @@ -94,7 +119,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..132fe7d3f 100644 --- a/crates/okena-remote-client/src/connection.rs +++ b/crates/okena-remote-client/src/connection.rs @@ -1,12 +1,12 @@ use crate::backend::{RemoteBackend, RemoteTransport}; +use okena_terminal::TerminalsRegistry; 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, + ConnectionEvent, ConnectionHandler, ConnectionStatus, RemoteClient, RemoteConnectionConfig, + WsClientMessage, is_remote_terminal, }; use std::collections::HashMap; @@ -15,6 +15,7 @@ use std::sync::Arc; /// Desktop-specific handler that creates `Terminal` objects and manages the registry. pub struct DesktopConnectionHandler { terminals: TerminalsRegistry, + transports: parking_lot::Mutex>>, /// Coalescing doorbell rung on every chunk of remote output so the manager's /// activity pump wakes and repaints server-driven sidebar indicators. See /// `RemoteConnectionManager::start_terminal_activity_pump`. @@ -25,6 +26,7 @@ impl DesktopConnectionHandler { pub fn new(terminals: TerminalsRegistry, activity_tx: async_channel::Sender<()>) -> Self { Self { terminals, + transports: parking_lot::Mutex::new(HashMap::new()), activity_tx, } } @@ -46,29 +48,37 @@ impl ConnectionHandler for DesktopConnectionHandler { // keeps the views' Arc references valid and avoids leaking // the old Terminal (with its ~19-48 MB scrollback grid) on every reconnect. if terminals.contains_key(prefixed_id) { + if let Some(transport) = self.transports.lock().get(prefixed_id) { + transport.replace_sender(ws_sender); + } return; } - let transport = Arc::new(RemoteTransport { - ws_tx: ws_sender, - connection_id: connection_id.to_string(), - }); + let transport = Arc::new(RemoteTransport::new(ws_sender, connection_id.to_string())); let size = if cols > 0 && rows > 0 { - TerminalSize { cols, rows, ..TerminalSize::default() } + TerminalSize { + cols, + rows, + ..TerminalSize::default() + } } else { TerminalSize::default() }; let terminal = Arc::new(Terminal::new( prefixed_id.to_string(), size, - transport, + transport.clone(), String::new(), )); terminals.insert(prefixed_id.to_string(), terminal); + self.transports + .lock() + .insert(prefixed_id.to_string(), transport); } fn on_terminal_output(&self, prefixed_id: &str, data: &[u8]) { let terminal = self.terminals.lock().get(prefixed_id).cloned(); if let Some(terminal) = terminal { + okena_core::latency_probe::client_output_received(prefixed_id); terminal.enqueue_output(data); // Ring the doorbell so the GPUI-side activity pump wakes and // repaints bell/idle indicators. Capacity 1: a full channel means a @@ -78,6 +88,9 @@ 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 @@ -93,6 +106,7 @@ impl ConnectionHandler for DesktopConnectionHandler { fn remove_terminal(&self, prefixed_id: &str) { self.terminals.lock().remove(prefixed_id); + self.transports.lock().remove(prefixed_id); } fn remove_all_terminals(&self, connection_id: &str) { @@ -104,6 +118,7 @@ impl ConnectionHandler for DesktopConnectionHandler { .collect(); for key in to_remove { terminals.remove(&key); + self.transports.lock().remove(&key); } } @@ -127,6 +142,7 @@ impl ConnectionHandler for DesktopConnectionHandler { } for key in to_remove { terminals.remove(&key); + self.transports.lock().remove(&key); } } } @@ -161,6 +177,10 @@ impl RemoteConnection { self.client.disconnect(); } + pub fn reconnect(&mut self) { + self.client.reconnect(); + } + pub fn config(&self) -> &RemoteConnectionConfig { self.client.config() } @@ -194,6 +214,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); } @@ -205,13 +233,10 @@ impl RemoteConnection { /// Get a TerminalBackend for this connection. pub fn backend(&self) -> Arc { let ws_tx = self.client.ws_sender().cloned().unwrap_or_else(|| { - let (tx, _) = async_channel::bounded::(1); + let (tx, _) = async_channel::unbounded::(); tx }); - let transport = Arc::new(RemoteTransport { - ws_tx, - connection_id: self.config().id.clone(), - }); + let transport = Arc::new(RemoteTransport::new(ws_tx, self.config().id.clone())); Arc::new(RemoteBackend::new(transport, self.config().id.clone())) } } @@ -228,15 +253,20 @@ mod tests { let terminals: TerminalsRegistry = Arc::new(parking_lot::Mutex::new(HashMap::new())); let (activity_tx, _activity_rx) = async_channel::bounded(1); let (ws_tx, _ws_rx) = async_channel::bounded(8); - let transport = Arc::new(RemoteTransport { ws_tx, connection_id: "conn".into() }); + let transport = Arc::new(RemoteTransport::new(ws_tx, "conn".into())); let terminal = Arc::new(Terminal::new( prefixed_id.to_string(), TerminalSize::default(), transport, String::new(), )); - terminals.lock().insert(prefixed_id.to_string(), terminal.clone()); - (DesktopConnectionHandler::new(terminals, activity_tx), terminal) + terminals + .lock() + .insert(prefixed_id.to_string(), terminal.clone()); + ( + DesktopConnectionHandler::new(terminals, activity_tx), + terminal, + ) } #[test] @@ -268,4 +298,30 @@ mod tests { handler.resize_terminal("conn:t2", 100, 30, false); assert!(terminal.is_resize_owner_local()); } + + #[test] + fn reconnect_replaces_sender_for_existing_terminal() { + let terminals: TerminalsRegistry = Arc::new(parking_lot::Mutex::new(HashMap::new())); + let (activity_tx, _activity_rx) = async_channel::bounded(1); + let handler = DesktopConnectionHandler::new(terminals.clone(), activity_tx); + let (old_tx, old_rx) = async_channel::unbounded(); + handler.create_terminal("conn", "term", "remote:conn:term", old_tx, 80, 24); + + let (new_tx, new_rx) = async_channel::unbounded(); + handler.create_terminal("conn", "term", "remote:conn:term", new_tx, 80, 24); + terminals + .lock() + .get("remote:conn:term") + .unwrap() + .send_bytes(b"input"); + + assert!(old_rx.try_recv().is_err()); + match new_rx.try_recv().unwrap() { + WsClientMessage::SendInput { terminal_id, data } => { + assert_eq!(terminal_id, "term"); + assert_eq!(data, b"input"); + } + other => panic!("unexpected message: {other:?}"), + } + } } diff --git a/crates/okena-remote-client/src/manager.rs b/crates/okena-remote-client/src/manager.rs index cdf8157bb..36915b589 100644 --- a/crates/okena-remote-client/src/manager.rs +++ b/crates/okena-remote-client/src/manager.rs @@ -1,20 +1,136 @@ use crate::connection::RemoteConnection; +use okena_terminal::TerminalsRegistry; use okena_terminal::backend::TerminalBackend; use okena_terminal::terminal::Terminal; -use okena_workspace::toast::ToastManager; -use okena_terminal::TerminalsRegistry; -use okena_workspace::settings::{load_settings, update_remote_connections}; +use okena_workspace::settings::{AppSettings, load_settings, update_remote_connections}; +use okena_workspace::toast::{Toast, ToastManager}; -use okena_core::api::{ActionRequest, StateResponse}; -use okena_transport::client::{ - ConnectionEvent, ConnectionStatus, RemoteConnectionConfig, +use okena_core::api::{ActionRequest, ApiSystemStats, StateResponse}; +use okena_core::soft_close::{ + SOFT_CLOSE_KILL_PREFIX, SOFT_CLOSE_UNDO_PREFIX, decode_action, encode_action, }; use okena_transport::client::connection::try_refresh_token; +use okena_transport::client::{ + ConnectionEvent, ConnectionStatus, LOCAL_DAEMON_CONNECTION_ID, RemoteConnectionConfig, + make_prefixed_id, +}; use gpui::*; use std::collections::HashMap; use std::sync::Arc; +struct QueuedAction { + config: RemoteConnectionConfig, + token: String, + action: ActionRequest, +} + +struct PasteUpload { + endpoint: &'static str, + content_type: String, + extension: Option, + bytes: Vec, +} + +struct ActionQueues { + runtime: Arc, + event_tx: async_channel::Sender, + senders: parking_lot::Mutex>>, +} + +impl ActionQueues { + fn new( + runtime: Arc, + event_tx: async_channel::Sender, + ) -> Self { + Self { + runtime, + event_tx, + senders: parking_lot::Mutex::new(HashMap::new()), + } + } + + fn enqueue(&self, connection_id: &str, action: QueuedAction) { + let sender = self + .senders + .lock() + .entry(connection_id.to_string()) + .or_insert_with(|| { + let (tx, rx) = async_channel::unbounded(); + let connection_id = connection_id.to_string(); + let event_tx = self.event_tx.clone(); + self.runtime.spawn(async move { + run_action_queue(connection_id, rx, event_tx).await; + }); + tx + }) + .clone(); + + if sender.try_send(action).is_err() { + log::error!("action queue unexpectedly closed for {connection_id}"); + } + } +} + +async fn run_action_queue( + connection_id: String, + receiver: async_channel::Receiver, + event_tx: async_channel::Sender, +) { + let mut pending = None; + loop { + let mut action = match pending.take() { + Some(action) => action, + None => match receiver.recv().await { + Ok(action) => action, + Err(_) => break, + }, + }; + if matches!(&action.action, ActionRequest::SetSettings { .. }) { + while let Ok(next) = receiver.try_recv() { + if matches!(&next.action, ActionRequest::SetSettings { .. }) { + action = next; + } else { + pending = Some(next); + break; + } + } + } + send_queued_action(&connection_id, action, &event_tx).await; + } +} + +async fn send_queued_action( + connection_id: &str, + queued: QueuedAction, + event_tx: &async_channel::Sender, +) { + let QueuedAction { + config, + token, + action, + } = queued; + let name = config.name.clone(); + let result = okena_transport::remote_action::post_action_async(&config, &token, action).await; + + let message = match result { + Ok(_) => { + log::debug!("send_action: success for {name}"); + return; + } + Err(error) => { + log::error!("send_action: request error for {name}: {error}"); + format!("Action request failed: {error}") + } + }; + let _ = event_tx + .send(ConnectionEvent::ServerWarning { + connection_id: connection_id.to_string(), + message, + }) + .await; +} + /// Lightweight events emitted by [`RemoteConnectionManager`] that must NOT go /// through `cx.notify()`. /// @@ -27,7 +143,34 @@ 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), + + /// An external API client asked the desktop to focus and raise an exact + /// remote terminal. IDs are already prefixed for this manager connection. + TerminalFocusRequested { + project_id: String, + terminal_id: String, + window: Option, + }, + + /// 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, + + /// The local daemon published a new authoritative settings snapshot. + SettingsChanged(Box), } /// GPUI Entity managing all remote connections. @@ -42,6 +185,9 @@ pub struct RemoteConnectionManager { /// Channel for events coming from tokio tasks event_tx: async_channel::Sender, + /// Per-connection FIFO queues for state-changing HTTP actions. + action_queues: ActionQueues, + /// Coalescing doorbell rung by the tokio reader whenever a remote terminal /// produces output. Capacity 1: a wake already pending absorbs further /// output until the GPUI side drains, so output bursts collapse into a @@ -87,11 +233,13 @@ impl RemoteConnectionManager { // Coalescing doorbell for remote terminal output (see field docs). let (activity_tx, activity_rx) = async_channel::bounded::<()>(1); + let action_queues = ActionQueues::new(runtime.clone(), event_tx.clone()); let manager = Self { connections: HashMap::new(), terminals, runtime, event_tx, + action_queues, activity_tx, }; manager.start_terminal_activity_pump(activity_rx, cx); @@ -102,28 +250,17 @@ impl RemoteConnectionManager { /// woken by the `activity_rx` doorbell rather than by polling. /// /// Remote output arrives on a tokio task that only buffers bytes via - /// `Terminal::enqueue_output` — it never touches GPUI. The per-pane dirty - /// loop (`TerminalPane::start_remote_dirty_check_loop`) repaints the - /// *focused* terminal grid, but two server-driven indicators are left - /// stale until unrelated local input forces a global repaint (issue #128): - /// - /// 1. **Background (unmounted) terminals never get parsed.** A sidebar - /// entry whose pane isn't mounted has no per-pane loop, so its pending - /// bytes are never drained — `has_bell()` stays false and the bell - /// badge never appears. - /// 2. **The sidebar is never notified.** It reads bell/idle straight from - /// the `TerminalsRegistry` (a plain `Arc>`, invisible to - /// GPUI's automatic per-entity dependency tracking), so nothing tells - /// it to re-render when a terminal's derived state changes. + /// `Terminal::enqueue_output`; it cannot touch GPUI directly. Each enqueue + /// rings a capacity-1 doorbell (`try_send`, so bursts coalesce). On every + /// wake this drains and parses pending output for all remote terminals on + /// the GPUI thread, then watches `content_generation` to identify which + /// terminals advanced. /// - /// Each `enqueue_output` rings the capacity-1 doorbell (`try_send`, so - /// bursts coalesce). On every wake this drains+parses pending output for all - /// remote terminals on the GPUI thread (fixing #1) and watches - /// `content_generation` to confirm something actually advanced — regardless - /// of whether the per-pane loop also drained it. When so it emits - /// `RemoteManagerEvent::TerminalActivity`, which repaints every window's - /// sidebar via the subscription in `WindowView::set_remote_manager` - /// (fixing #2). Idle ⇒ the task simply parks on `recv()`, no CPU. + /// `RemoteManagerEvent::TerminalActivity` carries those terminal ids to + /// `WindowView`, which directly notifies their registered content panes and + /// repaints each window's sidebar. This keeps mounted and background bell / + /// idle state current without a per-pane 8 ms polling task. While idle this + /// task parks on `recv()`, consuming no CPU. fn start_terminal_activity_pump( &self, activity_rx: async_channel::Receiver<()>, @@ -148,21 +285,40 @@ 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 { + // Consume the edge-triggered dirty marker before parsing. + // Any bytes arriving after this point enqueue another + // activity wake, so no per-pane polling is needed. + terminal.take_dirty(); // 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()); + okena_core::latency_probe::client_output_parsed(id); + 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; if changed { + for terminal_id in &advanced { + okena_core::latency_probe::client_activity_emitted(terminal_id); + } // 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() { @@ -208,11 +364,38 @@ impl RemoteConnectionManager { Ok(()) } - /// Reconnect an existing connection (disconnect then connect again). + /// Reconnect without discarding the last state or live terminal objects. pub fn reconnect(&mut self, connection_id: &str, cx: &mut Context) { if let Some(conn) = self.connections.get_mut(connection_id) { - conn.disconnect(); - conn.connect(); + conn.reconnect(); + cx.notify(); + } + } + + /// 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.reconnect(); cx.notify(); } } @@ -281,6 +464,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 +504,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, @@ -318,13 +525,8 @@ impl RemoteConnectionManager { /// Send an action to a remote server via HTTP POST /v1/actions. /// - /// Fire-and-forget: spawns on the tokio runtime, logs errors and shows toast on failure. - pub fn send_action( - &self, - connection_id: &str, - action: ActionRequest, - cx: &mut Context, - ) { + /// Fire-and-forget from the UI thread, but FIFO within each connection. + pub fn send_action(&self, connection_id: &str, action: ActionRequest, cx: &mut Context) { let config = match self.connections.get(connection_id) { Some(conn) => conn.config().clone(), None => { @@ -332,54 +534,26 @@ 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); + log::error!( + "send_action: no auth token for connection {}", + connection_id + ); ToastManager::error("No auth token for remote connection".to_string(), cx); return; } }; - 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 result = client - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .json(&action) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await; - - match result { - Ok(resp) if resp.status().is_success() => { - log::debug!("send_action: success for {}", name); - } - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - log::error!("send_action: failed ({}): {} for {}", status, body, name); - // Send a warning event back to the GPUI thread - let _ = event_tx.try_send(ConnectionEvent::ServerWarning { - connection_id: String::new(), - message: format!("Action failed ({}): {}", status, body), - }); - } - Err(e) => { - log::error!("send_action: request error for {}: {}", name, e); - let _ = event_tx.try_send(ConnectionEvent::ServerWarning { - connection_id: String::new(), - message: format!("Action request failed: {}", e), - }); - } - } - }); + self.action_queues.enqueue( + connection_id, + QueuedAction { + config, + token, + action, + }, + ); } /// Upload a pasted clipboard image to the remote server, which writes it to @@ -397,18 +571,63 @@ impl RemoteConnectionManager { bytes: Vec, cx: &mut Context, ) { + self.upload_pastes( + connection_id, + terminal_id, + "Image paste", + vec![PasteUpload { + endpoint: "paste-image", + content_type: mime.to_string(), + extension: None, + bytes, + }], + cx, + ); + } + + /// Upload dropped files sequentially so their pasted paths preserve order. + pub fn upload_paste_files( + &self, + connection_id: &str, + terminal_id: &str, + files: Vec<(String, Vec)>, + cx: &mut Context, + ) { + let uploads = files + .into_iter() + .map(|(extension, bytes)| PasteUpload { + endpoint: "paste-file", + content_type: "application/octet-stream".to_string(), + extension: Some(extension), + bytes, + }) + .collect(); + self.upload_pastes(connection_id, terminal_id, "File drop", uploads, cx); + } + + fn upload_pastes( + &self, + connection_id: &str, + terminal_id: &str, + label: &'static str, + uploads: Vec, + cx: &mut Context, + ) { + if uploads.is_empty() { + return; + } let config = match self.connections.get(connection_id) { Some(conn) => conn.config().clone(), None => { - log::error!("upload_paste_image: connection {} not found", connection_id); + log::error!("paste upload: connection {} not found", connection_id); 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 {}", + "paste upload: no auth token for connection {}", connection_id ); ToastManager::error("No auth token for remote connection".to_string(), cx); @@ -416,51 +635,56 @@ impl RemoteConnectionManager { } }; - let host = config.host.clone(); - let port = config.port; let name = config.name.clone(); let event_tx = self.event_tx.clone(); + let connection_id = connection_id.to_string(); 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 = reqwest::Client::new(); - let result = client - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Content-Type", mime) - .body(bytes) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await; - - match result { - Ok(resp) if resp.status().is_success() => { - log::debug!("upload_paste_image: success for {}", name); - } - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - log::error!( - "upload_paste_image: failed ({}): {} for {}", - status, body, name - ); - let _ = event_tx.try_send(ConnectionEvent::ServerWarning { - connection_id: String::new(), - message: format!("Image paste failed ({}): {}", status, body), - }); - } - Err(e) => { - log::error!("upload_paste_image: request error for {}: {}", name, e); - let _ = event_tx.try_send(ConnectionEvent::ServerWarning { - connection_id: String::new(), - message: format!("Image paste request failed: {}", e), - }); + let (client, base_url) = + match okena_transport::remote_http::async_client_and_url(&config, "") { + Ok(client_and_url) => client_and_url, + Err(error) => { + let _ = event_tx.try_send(ConnectionEvent::ServerWarning { + connection_id, + message: format!("{label} client initialisation failed: {error}"), + }); + return; + } + }; + for upload in uploads { + let url = format!("{base_url}/v1/terminals/{terminal_id}/{}", upload.endpoint); + let mut request = client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", upload.content_type) + .body(upload.bytes) + .timeout(std::time::Duration::from_secs(90)); + if let Some(extension) = upload.extension { + request = request.header("X-Okena-File-Extension", extension); } + let result = request.send().await; + + let message = match result { + Ok(response) if response.status().is_success() => { + log::debug!("paste upload: success for {}", name); + continue; + } + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + log::error!("paste upload failed ({status}): {body} for {name}"); + format!("{label} failed ({status}): {body}") + } + Err(error) => { + log::error!("paste upload request error for {name}: {error}"); + format!("{label} request failed: {error}") + } + }; + let _ = event_tx.try_send(ConnectionEvent::ServerWarning { + connection_id: connection_id.clone(), + message, + }); } }); } @@ -472,8 +696,12 @@ impl RemoteConnectionManager { ConnectionEvent::TokenObtained { .. } => "TokenObtained", ConnectionEvent::TlsUpgraded { .. } => "TlsUpgraded", ConnectionEvent::StateReceived { .. } => "StateReceived", + ConnectionEvent::SettingsChanged { .. } => "SettingsChanged", ConnectionEvent::SubscriptionMappings { .. } => "SubscriptionMappings", ConnectionEvent::GitStatusChanged { .. } => "GitStatusChanged", + ConnectionEvent::SystemStatsChanged { .. } => "SystemStatsChanged", + ConnectionEvent::Toast { .. } => "Toast", + ConnectionEvent::TerminalFocusRequested { .. } => "TerminalFocusRequested", ConnectionEvent::ServerWarning { .. } => "ServerWarning", ConnectionEvent::TokenRefreshed { .. } => "TokenRefreshed", }; @@ -507,6 +735,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 { @@ -573,6 +806,21 @@ impl RemoteConnectionManager { } cx.notify(); } + ConnectionEvent::SettingsChanged { + connection_id, + settings, + } => { + if connection_id == LOCAL_DAEMON_CONNECTION_ID { + match serde_json::from_value::(settings) { + Ok(settings) => { + cx.emit(RemoteManagerEvent::SettingsChanged(Box::new(settings))) + } + Err(error) => { + log::warn!("Failed to decode daemon settings: {error}"); + } + } + } + } ConnectionEvent::SubscriptionMappings { connection_id, mappings, @@ -586,13 +834,57 @@ impl RemoteConnectionManager { statuses, } => { if let Some(conn) = self.connections.get_mut(&connection_id) - && let Some(state) = conn.remote_state_mut() { - for project in &mut state.projects { - project.git_status = statuses.get(&project.id).cloned(); - } + && let Some(state) = conn.remote_state_mut() + { + for project in &mut state.projects { + project.git_status = statuses.get(&project.id).cloned(); } + } cx.notify(); } + ConnectionEvent::SystemStatsChanged { + connection_id, + stats, + } => { + if let Some(conn) = self.connections.get_mut(&connection_id) { + conn.set_system_stats(Some(stats)); + } + } + ConnectionEvent::TerminalFocusRequested { + connection_id, + request, + } => { + cx.emit(RemoteManagerEvent::TerminalFocusRequested { + project_id: make_prefixed_id(&connection_id, &request.project_id), + terminal_id: make_prefixed_id(&connection_id, &request.terminal_id), + window: request.window, + }); + } + 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 +979,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 +1002,140 @@ fn now_unix_timestamp() -> i64 { #[cfg(test)] mod tests { - use super::{activity_changed, RemoteConnectionManager}; + use super::{ + ActionQueues, QueuedAction, RemoteConnectionManager, activity_changed, + is_local_connection_terminal_failure, + }; + use okena_core::api::ActionRequest; + use okena_transport::client::{ConnectionStatus, LOCAL_DAEMON_CONNECTION_ID}; + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::time::{Duration, Instant}; + + fn accept_until(listener: &TcpListener, deadline: Instant) -> TcpStream { + loop { + match listener.accept() { + Ok((stream, _)) => return stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "timed out waiting for request"); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("failed to accept request: {error}"), + } + } + } + + fn read_request_body(stream: &mut TcpStream) -> String { + let mut reader = BufReader::new(&mut *stream); + let mut content_length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + let lowercase = line.to_ascii_lowercase(); + if let Some(value) = lowercase.strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap(); + } + } + let mut body = vec![0; content_length]; + reader.read_exact(&mut body).unwrap(); + String::from_utf8(body).unwrap() + } + + fn respond_ok(stream: &mut TcpStream) { + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + } + + #[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()), + )); + } + + #[test] + fn action_queue_waits_for_each_response_before_sending_the_next_action() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(3); + let mut first = accept_until(&listener, deadline); + assert!(read_request_body(&mut first).contains("first")); + + std::thread::sleep(Duration::from_millis(100)); + match listener.accept() { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Ok(_) => panic!("second action started before the first response"), + Err(error) => panic!("failed to inspect action queue: {error}"), + } + respond_ok(&mut first); + + let mut second = accept_until(&listener, deadline); + assert!(read_request_body(&mut second).contains("second")); + respond_ok(&mut second); + }); + + let runtime = Arc::new( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(), + ); + let (event_tx, _event_rx) = async_channel::unbounded(); + let queues = ActionQueues::new(runtime, event_tx); + let mut config = make_config("127.0.0.1", port); + config.name = "ordered-test".to_string(); + for terminal_id in ["first", "second"] { + queues.enqueue( + "connection", + QueuedAction { + config: config.clone(), + token: "token".to_string(), + action: ActionRequest::SendText { + terminal_id: terminal_id.to_string(), + text: "input".to_string(), + }, + }, + ); + } + + server.join().unwrap(); + } fn gens(pairs: &[(&str, u64)]) -> HashMap { pairs.iter().map(|(id, g)| (id.to_string(), *g)).collect() @@ -731,8 +1170,8 @@ mod tests { let current = gens(&[("b", 3)]); assert!(activity_changed(&last, ¤t)); } - use okena_terminal::TerminalsRegistry; use gpui::AppContext as _; + use okena_terminal::TerminalsRegistry; use okena_transport::client::RemoteConnectionConfig; use parking_lot::Mutex as PMutex; use std::collections::HashMap; @@ -748,6 +1187,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..0aa374691 100644 --- a/crates/okena-remote-server/Cargo.toml +++ b/crates/okena-remote-server/Cargo.toml @@ -1,24 +1,34 @@ [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"] } async-channel = "2.3" futures = "0.3" parking_lot = "0.12" +fs2 = "0.4" # 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 +53,14 @@ 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"] } +okena-transport = { path = "../okena-transport", features = ["blocking-http"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } tempfile = "3" diff --git a/crates/okena-remote-server/src/CLAUDE.md b/crates/okena-remote-server/src/CLAUDE.md index 885ee71a3..e30efb11f 100644 --- a/crates/okena-remote-server/src/CLAUDE.md +++ b/crates/okena-remote-server/src/CLAUDE.md @@ -34,6 +34,7 @@ Note: the client-side connection logic lives in `crates/okena-remote-client/`. - **PTY fan-out**: `PtyBroadcaster` uses tokio's `broadcast` channel so multiple WebSocket clients can subscribe to the same terminal's output independently. - **Window model**: `GET /v1/state` returns `windows` (`ApiWindow[]`) — each open OS window with its `active` flag, per-window focus (project + terminal), fullscreen, visible projects, folder filter, OS bounds, and sidebar state. Built by `Okena::build_api_windows` (`src/app/extras.rs`). The flat `focused_project_id`/`fullscreen_terminal` are derived from the active window for backward compatibility. Headless serves a single synthetic main window. - **Per-window action targeting**: `FocusTerminal`, `SetProjectShowInOverview`, and `SetFullscreen` accept an optional `window` field (`"main"` | extra UUID). The bridge parses it (`parse_window_id`) and the `FocusManagerResolver` (`Fn(&App, Option) -> Option<(WindowId, FocusManager)>`) routes the action to that window's `FocusManager`; `None` targets the focused/active window, a missing window yields "window not found". See `src/app/remote_commands.rs` + `src/app/extras.rs`. +- **External terminal focus is a one-shot client presentation event.** The headless daemon's synthetic `FocusManager` cannot give a desktop pane keyboard focus. After a REST `FocusTerminal` action succeeds, `routes/actions.rs` broadcasts `WsOutbound::TerminalFocusRequested`; connected desktop clients prefix the remote IDs and route it through `Okena::jump_to_terminal`, which focuses, raises, and refreshes the exact pane. Do not persist this request into regular state snapshots — replaying snapshot focus would repeatedly steal the user's focus. ## Clients diff --git a/crates/okena-remote-server/src/auth.rs b/crates/okena-remote-server/src/auth.rs index 240973d37..a137117f4 100644 --- a/crates/okena-remote-server/src/auth.rs +++ b/crates/okena-remote-server/src/auth.rs @@ -1,4 +1,5 @@ use base64::Engine as _; +use fs2::FileExt; use hmac::{Hmac, Mac}; use parking_lot::Mutex; use rand::Rng; @@ -177,9 +178,10 @@ impl AuthStore { // Return existing code if still valid (60s TTL) if let Some(ref code) = inner.current_code - && now.duration_since(inner.code_created_at) < Duration::from_secs(60) { - return code.clone(); - } + && now.duration_since(inner.code_created_at) < Duration::from_secs(60) + { + return code.clone(); + } // Generate new code let code = generate_pairing_code(); @@ -208,7 +210,9 @@ impl AuthStore { let inner = self.inner.lock(); match &inner.current_code { Some(_) => { - let elapsed = Instant::now().duration_since(inner.code_created_at).as_secs(); + let elapsed = Instant::now() + .duration_since(inner.code_created_at) + .as_secs(); 60u64.saturating_sub(elapsed) } None => 0, @@ -228,7 +232,8 @@ impl AuthStore { let in_memory_valid = match &inner.current_code { Some(current) => { let now = Instant::now(); - let not_expired = now.duration_since(inner.code_created_at) < Duration::from_secs(60); + let not_expired = + now.duration_since(inner.code_created_at) < Duration::from_secs(60); not_expired && constant_time_eq(current.as_bytes(), code.as_bytes()) } None => false, @@ -349,8 +354,8 @@ impl AuthStore { let before = inner.tokens.len(); inner.tokens.retain(|r| r.id != id); let removed = inner.tokens.len() < before; - if removed { - save_tokens_to(&self.tokens_path, &inner.tokens); + if removed && let Err(error) = remove_persisted_token(&self.tokens_path, id) { + log::error!("Failed to persist token revocation: {error}"); } removed } @@ -512,40 +517,65 @@ 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 Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_path = path.with_extension("lock"); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path)?; + FileExt::lock_exclusive(&lock)?; + + 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 - 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)] + let secret = generate_secret(); + { + use std::io::Write; + let tmp_path = path.with_extension("tmp"); { - 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); + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&secret)?; + file.sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; } } + std::fs::rename(tmp_path, path)?; } - 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. @@ -564,9 +594,9 @@ pub fn tokens_path() -> PathBuf { } /// Save token records to disk, filtering out expired tokens. -fn save_tokens_to(path: &std::path::Path, tokens: &[TokenRecord]) { +fn persisted_tokens(tokens: &[TokenRecord]) -> Vec { let now = SystemTime::now(); - let persisted: Vec = tokens + tokens .iter() .filter(|t| { now.duration_since(t.created_at).unwrap_or(Duration::MAX) @@ -584,36 +614,144 @@ fn save_tokens_to(path: &std::path::Path, tokens: &[TokenRecord]) { created_at: unix, } }) - .collect(); - - let json = match serde_json::to_string_pretty(&persisted) { - Ok(j) => j, - Err(e) => { - log::error!("Failed to serialize tokens: {}", e); - return; - } - }; + .collect() +} +fn lock_tokens_file(path: &std::path::Path) -> std::io::Result { if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + std::fs::create_dir_all(parent)?; + } + let lock_path = path.with_extension("lock"); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path)?; + FileExt::lock_exclusive(&file)?; + Ok(file) +} + +fn load_persisted_unlocked(path: &std::path::Path) -> Result, TokenLoadError> { + let data = std::fs::read_to_string(path).map_err(TokenLoadError::Read)?; + serde_json::from_str(&data).map_err(TokenLoadError::Parse) +} + +fn normalize_persisted_tokens(tokens: &mut Vec) { + let cutoff = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(TOKEN_TTL_SECS); + tokens.retain(|token| token.created_at > cutoff); + let mut index = 0; + while index < tokens.len() { + if tokens[index + 1..] + .iter() + .any(|later| later.id == tokens[index].id) + { + tokens.remove(index); + } else { + index += 1; + } } - if let Err(e) = std::fs::write(path, json.as_bytes()) { - log::error!("Failed to write remote_tokens.json: {}", e); - } else { + tokens.sort_by_key(|token| token.created_at); + const MAX_TOKENS: usize = 64; + if tokens.len() > MAX_TOKENS { + tokens.drain(0..tokens.len() - MAX_TOKENS); + } +} + +fn write_persisted_unlocked( + path: &std::path::Path, + tokens: &[PersistedToken], +) -> std::io::Result<()> { + let json = serde_json::to_vec_pretty(tokens).map_err(std::io::Error::other)?; + let tmp_path = path.with_extension("json.tmp"); + { + use std::io::Write; + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&json)?; + file.sync_all()?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - let _ = std::fs::set_permissions(path, perms); + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; } } + std::fs::rename(tmp_path, path) +} + +pub(crate) fn append_persisted_token( + path: &std::path::Path, + token: PersistedToken, +) -> std::io::Result<()> { + let _guard = lock_tokens_file(path)?; + let mut tokens = match load_persisted_unlocked(path) { + Ok(tokens) => tokens, + Err(TokenLoadError::Read(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Vec::new() + } + Err(error) => return Err(std::io::Error::other(error.to_string())), + }; + tokens.push(token); + normalize_persisted_tokens(&mut tokens); + write_persisted_unlocked(path, &tokens) +} + +fn remove_persisted_token(path: &std::path::Path, id: &str) -> std::io::Result<()> { + let _guard = lock_tokens_file(path)?; + let mut tokens = match load_persisted_unlocked(path) { + Ok(tokens) => tokens, + Err(TokenLoadError::Read(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Vec::new() + } + Err(error) => return Err(std::io::Error::other(error.to_string())), + }; + tokens.retain(|token| token.id != id); + normalize_persisted_tokens(&mut tokens); + write_persisted_unlocked(path, &tokens) +} + +fn save_tokens_to(path: &std::path::Path, tokens: &[TokenRecord]) { + let _guard = match lock_tokens_file(path) { + Ok(guard) => guard, + Err(error) => { + log::error!("Failed to lock remote_tokens.json: {error}"); + return; + } + }; + let mut persisted = match load_persisted_unlocked(path) { + Ok(tokens) => tokens, + Err(TokenLoadError::Read(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Vec::new() + } + Err(error) => { + log::error!("Failed to merge remote_tokens.json: {error}"); + return; + } + }; + for token in persisted_tokens(tokens) { + if let Some(existing) = persisted + .iter_mut() + .find(|existing| existing.id == token.id) + { + *existing = token; + } else { + persisted.push(token); + } + } + normalize_persisted_tokens(&mut persisted); + + if let Err(error) = write_persisted_unlocked(path, &persisted) { + log::error!("Failed to write remote_tokens.json: {error}"); + } } /// Load token records from disk, filtering out expired tokens. fn load_tokens_from(path: &std::path::Path) -> Result, TokenLoadError> { - let data = std::fs::read_to_string(path).map_err(TokenLoadError::Read)?; - let persisted: Vec = - serde_json::from_str(&data).map_err(TokenLoadError::Parse)?; + let _guard = lock_tokens_file(path).map_err(TokenLoadError::Read)?; + let persisted = load_persisted_unlocked(path)?; let now = SystemTime::now(); Ok(persisted @@ -655,7 +793,9 @@ mod tests { /// Helper: pair and return a valid token. fn pair_token(store: &AuthStore) -> String { let code = store.get_or_create_code(); - store.try_pair(&code, test_ip()).expect("pairing should succeed") + store + .try_pair(&code, test_ip()) + .expect("pairing should succeed") } #[test] @@ -663,8 +803,13 @@ mod tests { let store = test_store(); let original = pair_token(&store); - let refreshed = store.refresh_token(&original).expect("refresh should succeed"); - assert_ne!(original, refreshed, "refreshed token should differ from original"); + let refreshed = store + .refresh_token(&original) + .expect("refresh should succeed"); + assert_ne!( + original, refreshed, + "refreshed token should differ from original" + ); } #[test] @@ -679,10 +824,18 @@ mod tests { let store = test_store(); let original = pair_token(&store); - let refreshed = store.refresh_token(&original).expect("refresh should succeed"); + let refreshed = store + .refresh_token(&original) + .expect("refresh should succeed"); - assert!(store.validate_token(&original), "original token should still be valid"); - assert!(store.validate_token(&refreshed), "refreshed token should be valid"); + assert!( + store.validate_token(&original), + "original token should still be valid" + ); + assert!( + store.validate_token(&refreshed), + "refreshed token should be valid" + ); } #[test] @@ -695,7 +848,10 @@ mod tests { let result = store.try_pair(&code, test_ip()); assert!(result.is_ok(), "file-based pairing should succeed"); - assert!(!store.pair_code_path.exists(), "pair_code file should be deleted after successful pairing"); + assert!( + !store.pair_code_path.exists(), + "pair_code file should be deleted after successful pairing" + ); } #[test] @@ -743,7 +899,9 @@ mod tests { let _token1 = pair_token(&store); // Generate a fresh code for second pairing let code2 = store.generate_fresh_code(); - let _token2 = store.try_pair(&code2, test_ip()).expect("second pairing should succeed"); + let _token2 = store + .try_pair(&code2, test_ip()) + .expect("second pairing should succeed"); let tokens = store.list_tokens(); assert_eq!(tokens.len(), 2, "should list 2 paired tokens"); @@ -766,18 +924,26 @@ mod tests { let store = test_store(); let token1 = pair_token(&store); let code2 = store.generate_fresh_code(); - let token2 = store.try_pair(&code2, test_ip()).expect("second pairing should succeed"); + let token2 = store + .try_pair(&code2, test_ip()) + .expect("second pairing should succeed"); let tokens = store.list_tokens(); assert_eq!(tokens.len(), 2); // Revoke the first token by ID let id_to_revoke = tokens[0].id.clone(); - assert!(store.revoke_token(&id_to_revoke), "revoke should return true"); + assert!( + store.revoke_token(&id_to_revoke), + "revoke should return true" + ); let remaining = store.list_tokens(); assert_eq!(remaining.len(), 1, "should have 1 token after revoke"); - assert_ne!(remaining[0].id, id_to_revoke, "revoked token should be gone"); + assert_ne!( + remaining[0].id, id_to_revoke, + "revoked token should be gone" + ); // The revoked token should fail validation, the other should pass // (We don't know which token maps to which ID, so just verify counts) @@ -791,7 +957,10 @@ mod tests { #[test] fn revoke_nonexistent_returns_false() { let store = test_store(); - assert!(!store.revoke_token("nonexistent-id"), "revoking nonexistent token should return false"); + assert!( + !store.revoke_token("nonexistent-id"), + "revoking nonexistent token should return false" + ); } #[test] @@ -812,7 +981,10 @@ mod tests { // A check from ip2 triggers pruning; ip1's empty entry should be removed assert!(limiter.check(ip2).is_ok()); - assert!(!limiter.per_ip.contains_key(&ip1), "empty IP entry should be pruned"); + assert!( + !limiter.per_ip.contains_key(&ip1), + "empty IP entry should be pruned" + ); assert!(limiter.per_ip.contains_key(&ip2)); } @@ -887,13 +1059,39 @@ mod tests { assert!(store.reload_tokens()); // After reload: both tokens are valid - assert!(store.validate_token(&token1), "original token should still work"); + assert!( + store.validate_token(&token1), + "original token should still work" + ); assert!( store.validate_token(external_token), "externally written token should be valid after reload" ); } + #[test] + fn daemon_save_preserves_token_minted_by_an_external_process() { + let store = test_store(); + let _ = pair_token(&store); + append_persisted_token( + &store.tokens_path, + PersistedToken { + id: "external".to_string(), + token_hmac: base64::engine::general_purpose::STANDARD.encode([7u8; 32]), + created_at: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(), + }, + ) + .unwrap(); + + save_tokens_to(&store.tokens_path, &store.inner.lock().tokens); + let persisted = load_persisted_unlocked(&store.tokens_path).unwrap(); + assert!(persisted.iter().any(|token| token.id == "external")); + assert!(persisted.iter().any(|token| token.id != "external")); + } + #[test] fn reload_tokens_keeps_existing_tokens_on_parse_error() { let store = test_store(); diff --git a/crates/okena-remote-server/src/bridge.rs b/crates/okena-remote-server/src/bridge.rs index 97b92d126..245dbd0d6 100644 --- a/crates/okena-remote-server/src/bridge.rs +++ b/crates/okena-remote-server/src/bridge.rs @@ -18,15 +18,26 @@ 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). RenderSnapshot { terminal_id: String }, /// Get current grid sizes (cols, rows) for multiple terminals. GetTerminalSizes { terminal_ids: Vec }, - /// Bracketed-paste the path of a remote-pasted image (already written to a - /// temp file on this host) into the target terminal. - PasteImage { terminal_id: String, path: String }, + /// Bracketed-paste server-local text into the target terminal. + PastePath { terminal_id: String, text: String }, } /// Channel types for the bridge. diff --git a/crates/okena-remote-server/src/lib.rs b/crates/okena-remote-server/src/lib.rs index e28a52030..a794d4a2a 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,9 @@ 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..45038cb96 --- /dev/null +++ b/crates/okena-remote-server/src/local.rs @@ -0,0 +1,1633 @@ +//! 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::{LOCAL_DAEMON_CONNECTION_ID, LocalEndpoint, RemoteConnectionConfig}; +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::sync::OnceLock; +use std::time::{Duration, Instant}; + +static PROCESS_EXECUTABLE: OnceLock = OnceLock::new(); + +/// 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 local TCP clients should negotiate TLS with this daemon. + pub tls: bool, + /// Whether desktop clients own this daemon's process lifetime. + pub ui_owned: bool, + pub local_endpoint: Option, +} + +impl LocalDaemon { + /// Loopback host to dial. + pub fn host(&self) -> &str { + &self.host + } + + /// Build the canonical implicit local-daemon client configuration. + pub fn connection_config(&self, token: Option) -> RemoteConnectionConfig { + RemoteConnectionConfig { + id: LOCAL_DAEMON_CONNECTION_ID.to_string(), + name: "Local".to_string(), + host: self.host.clone(), + port: self.port, + saved_token: token, + token_obtained_at: None, + tls: self.tls, + pinned_cert_sha256: None, + local_endpoint: self.local_endpoint.clone(), + } + } +} + +/// 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 ui_owned = v.get("ui_owned").and_then(|v| v.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, + ui_owned, + 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 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(); + + let persisted = PersistedToken { + id: token_id.clone(), + token_hmac: token_hmac_b64, + created_at, + }; + auth::append_persisted_token(&dir.join("remote_tokens.json"), persisted) + .map_err(|e| format!("Failed to write remote_tokens.json: {e}"))?; + + 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()) +} + +/// Cache the executable path before an in-place update replaces its inode. +pub fn remember_current_executable() -> std::io::Result { + if let Some(path) = PROCESS_EXECUTABLE.get() { + return Ok(path.clone()); + } + let path = std::env::current_exe()?; + let _ = PROCESS_EXECUTABLE.set(path.clone()); + Ok(PROCESS_EXECUTABLE.get().cloned().unwrap_or(path)) +} + +/// Spawn a local daemon and writes `remote.json`. +/// +/// The desktop always runs its own executable with `--headless`; an explicitly +/// started standalone `okena-daemon` is discovered and attached before this path. +/// The child inherits `OKENA_PROFILE`, so it uses the same config dir. +/// +/// The caller owns the returned [`std::process::Child`]. In the UI-owned +/// lifecycle the final desktop client requests graceful shutdown; the child +/// handle is retained only for bounded fallback cleanup. Mint the token *before* +/// spawning so the fresh daemon loads it at startup (no reload needed). +pub fn spawn_daemon() -> std::io::Result { + let exe = remember_current_executable()?; + std::process::Command::new(exe) + .args(["--headless", UI_OWNED_FLAG]) + .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 this call spawned the daemon. Used to reap that exact + /// child if graceful lifecycle handoff fails; attached daemons are never killed. + 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 immediate shutdown or armed it for the last disconnect. + 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. +/// +/// A UI-owned daemon arms shutdown while another client is connected and exits +/// after the last disconnect. A standalone daemon refuses the request. +/// +/// 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"; + +/// Marks a daemon as spawned by (and therefore reapable by) a desktop client. +/// Passed by [`spawn_daemon`], carried across restarts by +/// [`spawn_replacement_daemon`], and verified before any reap. +pub const UI_OWNED_FLAG: &str = "--ui-owned"; + +/// 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 = remember_current_executable()?; + 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"); + } + + // `running_daemon_in` returned None → no advertised daemon. But a UI-owned + // daemon may still be alive and holding the instance lock while momentarily + // un-advertised: it dropped `remote.json` mid-teardown, or is rebinding after + // a restart. Spawning now would collide on the lock and the child would exit + // printing "Another Okena instance is already running", which the GUI turns + // into a hard lockout. Give the owner a bounded window to either re-advertise + // (→ attach / reconnect) or finish exiting and release the lock (→ spawn + // cleanly); if it does neither, reap it (guarded) so the user is never stuck. + if instance_lock_owner_alive() { + log::info!( + "Instance lock held but no daemon advertised; waiting for the owner to re-advertise or exit before spawning" + ); + let mut deadline = Instant::now() + LOCK_SETTLE_TIMEOUT; + // A reap can hand the lock straight to a restart successor, which + // deserves its own settle window. Bounded so a pathological chain of + // wedged daemons still terminates instead of reaping forever. + let mut reaps_left = 2; + loop { + if let Some(daemon) = wait_until_reachable_in(dir, LOCK_SETTLE_POLL) { + let token = auth_token_for_daemon(dir, &daemon)?; + log::info!( + "Un-advertised daemon re-appeared (pid {}); attaching instead of spawning", + daemon.pid + ); + return Ok(EnsuredDaemon { + daemon, + token, + spawned: None, + }); + } + if !instance_lock_owner_alive() { + log::info!("Instance lock owner exited; spawning a fresh daemon"); + break; + } + if Instant::now() >= deadline { + let Some(pid) = instance_lock_owner_pid() else { + break; + }; + if !instance_lock_is_held() { + break; + } + if reaps_left == 0 { + return Err(format!( + "Instance lock is still held by pid {pid} after two recovery attempts." + )); + } + log::warn!( + "Instance lock owner pid {pid} neither re-advertised nor exited within {LOCK_SETTLE_TIMEOUT:?}; reaping it" + ); + if !kill_pid_if_ui_owned_okena(pid) { + return Err(format!( + "Instance lock is held by pid {pid}, but it is not a verified UI-owned Okena daemon; refusing to terminate it." + )); + } + reaps_left -= 1; + // Wait for the process itself to go, which is what releases the + // flock — rather than sleeping a fixed guess. + if !wait_for_pid_exit(pid, REAP_RELEASE_TIMEOUT) { + return Err(format!( + "UI-owned daemon pid {pid} did not exit after termination." + )); + } + + // Do not trust the lockfile owner here: a successor can acquire + // the lock before it replaces the old process's stale PID. + if !instance_lock_owner_alive() { + log::info!("Instance lock released; spawning a fresh daemon"); + break; + } + // A successor took over — give it the same chance to advertise + // that the process we just reaped had. + log::info!("Instance lock passed to a successor; waiting for it to advertise"); + deadline = Instant::now() + LOCK_SETTLE_TIMEOUT; + } + } + } + + let mut child = spawn_daemon().map_err(|e| format!("Failed to spawn daemon: {e}"))?; + match wait_until_reachable_in(dir, spawn_timeout) { + Some(daemon) => { + let spawned_this_daemon = daemon.pid == child.id(); + if !spawned_this_daemon { + log::info!( + "Another daemon won startup (advertised pid {}, child pid {}); attaching", + daemon.pid, + child.id(), + ); + let _ = child.kill(); + let _ = child.wait(); + } + + match auth_token_for_daemon(dir, &daemon) { + Ok(token) => Ok(EnsuredDaemon { + daemon, + token, + spawned: spawned_this_daemon.then_some(child), + }), + Err(e) => { + if spawned_this_daemon { + let _ = child.kill(); + let _ = child.wait(); + } + Err(e) + } + } + } + None => { + let _ = child.kill(); + let _ = child.wait(); + Err("Daemon did not become ready in time.".into()) + } + } +} + +/// How long to wait for an un-advertised instance-lock owner to re-advertise or +/// exit before reaping it (see [`ensure_local_daemon_in`]). Long enough to cover +/// a normal daemon teardown / rebind, short enough to keep startup responsive. +const LOCK_SETTLE_TIMEOUT: Duration = Duration::from_secs(8); +/// Per-iteration reachability poll while waiting out an un-advertised owner. +const LOCK_SETTLE_POLL: Duration = Duration::from_millis(400); + +/// How long to wait for a reaped process to actually disappear (and with it the +/// flock it held) before deciding the reap failed. +const REAP_RELEASE_TIMEOUT: Duration = Duration::from_secs(2); + +/// Best-effort read of the instance-lock file's recorded owner pid (0/absent → None). +fn instance_lock_owner_pid() -> Option { + let path = okena_workspace::persistence::instance_lock_path(); + let content = std::fs::read_to_string(&path).ok()?; + okena_workspace::persistence::instance_lock_pid(&content).filter(|&pid| pid != 0) +} + +/// True when the lock is held and names a still-alive process. +fn instance_lock_owner_alive() -> bool { + instance_lock_is_held() && instance_lock_owner_pid().is_some_and(is_process_alive) +} + +/// Check the OS lock rather than trusting stale lockfile contents. +fn instance_lock_is_held() -> bool { + let path = okena_workspace::persistence::instance_lock_path(); + let Ok(file) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + else { + return false; + }; + match fs2::FileExt::try_lock_exclusive(&file) { + Ok(()) => { + let _ = fs2::FileExt::unlock(&file); + false + } + Err(_) => true, + } +} + +/// What we could learn about a live process, as the reap guard needs it. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct ProcessIdentity { + name: Option, + exe_file_name: Option, + args: Vec, +} + +/// Read a live process's name, executable and argv. +/// +/// Split out from the guard so the sysinfo refresh contract is testable on its +/// own. `System::refresh_processes` refreshes only a minimal set of fields and +/// leaves `cmd()` **empty** — with it, the `--ui-owned` check below never +/// matched, so no process was ever verified and the reap path was dead code: +/// every un-advertised lock holder became a hard lockout the user had to clear +/// by killing the process by hand. The command line has to be requested. +fn read_process_identity(pid: u32) -> Option { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + let spid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes_specifics( + ProcessesToUpdate::Some(&[spid]), + true, + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_exe(UpdateKind::Always), + ); + let proc = sys.process(spid)?; + + Some(ProcessIdentity { + name: proc.name().to_str().map(str::to_string), + exe_file_name: proc + .exe() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_string), + args: proc + .cmd() + .iter() + .filter_map(|arg| arg.to_str().map(str::to_string)) + .collect(), + }) +} + +/// Whether a process looks like a daemon this UI spawned and may therefore reap. +fn is_ui_owned_okena(identity: &ProcessIdentity) -> bool { + let is_okena = |name: &String| name.starts_with("okena"); + let named_okena = identity.name.as_ref().is_some_and(is_okena) + || identity.exe_file_name.as_ref().is_some_and(is_okena); + let ui_owned = identity.args.iter().any(|arg| arg == UI_OWNED_FLAG); + named_okena && ui_owned +} + +/// Reap only a process that still matches the lockfile and explicit lifecycle mode. +fn kill_pid_if_ui_owned_okena(pid: u32) -> bool { + if instance_lock_owner_pid() != Some(pid) || !instance_lock_is_held() { + return false; + } + + let Some(identity) = read_process_identity(pid) else { + return false; + }; + if !is_ui_owned_okena(&identity) { + log::warn!( + "Refusing to reap pid {pid}: process is not a verified UI-owned Okena daemon ({identity:?})" + ); + return false; + } + + use sysinfo::{Pid, ProcessesToUpdate, System}; + let spid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true); + sys.process(spid).is_some_and(|proc| proc.kill()) +} + +/// 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 identity(name: &str, exe: &str, args: &[&str]) -> ProcessIdentity { + ProcessIdentity { + name: Some(name.to_string()), + exe_file_name: Some(exe.to_string()), + args: args.iter().map(|arg| arg.to_string()).collect(), + } + } + + #[test] + fn ui_owned_okena_requires_both_an_okena_binary_and_the_flag() { + assert!(is_ui_owned_okena(&identity( + "okena", + "okena", + &["--headless", UI_OWNED_FLAG] + ))); + assert!( + is_ui_owned_okena(&identity("okena-daemon", "okena-daemon", &[UI_OWNED_FLAG])), + "the standalone daemon binary counts too", + ); + assert!( + !is_ui_owned_okena(&identity("okena", "okena", &["--headless"])), + "a daemon the user started themselves is not ours to kill", + ); + assert!( + !is_ui_owned_okena(&identity("zsh", "zsh", &[UI_OWNED_FLAG])), + "a recycled pid carrying the flag is still not okena", + ); + assert!( + !is_ui_owned_okena(&ProcessIdentity::default()), + "unknown process — never reap", + ); + } + + /// Pins the sysinfo contract the guard depends on. `refresh_processes` alone + /// leaves `cmd()` empty, which silently made `is_ui_owned_okena` fail for + /// every process: the reap path became unreachable and any un-advertised + /// lock holder turned into a lockout the user had to clear by hand. + #[cfg(unix)] + #[test] + fn read_process_identity_reports_the_command_line() { + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("spawn sleep"); + // Give the OS a moment to publish the new process. + std::thread::sleep(std::time::Duration::from_millis(300)); + + let identity = read_process_identity(child.id()); + let _ = child.kill(); + let _ = child.wait(); + + let identity = identity.expect("sysinfo should see a live child process"); + assert!( + !identity.args.is_empty(), + "command line must be populated, got {identity:?}", + ); + assert!( + identity.args.iter().any(|arg| arg == "30"), + "argv should carry the argument we spawned with, got {identity:?}", + ); + } + + 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, + ui_owned: true, + 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_and_lifecycle() { + let dir = temp_dir(); + std::fs::write( + dir.join("remote.json"), + r#"{"port": 19123, "pid": 4242, "tls": true, "ui_owned": 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, + ui_owned: 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 connection_config_preserves_discovered_transport() { + let daemon = LocalDaemon { + port: 19123, + host: "::1".to_string(), + pid: 4242, + tls: true, + ui_owned: true, + local_endpoint: Some(LocalEndpoint::UnixSocket { + path: "/tmp/okena.sock".to_string(), + }), + }; + + let config = daemon.connection_config(Some("token".to_string())); + + assert_eq!(config.id, LOCAL_DAEMON_CONNECTION_ID); + assert_eq!(config.host, "::1"); + assert_eq!(config.port, 19123); + assert!(config.tls); + assert_eq!(config.saved_token.as_deref(), Some("token")); + assert_eq!(config.local_endpoint, daemon.local_endpoint); + } + + #[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, + ui_owned: 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, + ui_owned: true, + 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 concurrent_mints_preserve_every_token() { + let dir = temp_dir(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let mut handles = Vec::new(); + for _ in 0..2 { + let dir = dir.clone(); + let barrier = barrier.clone(); + handles.push(std::thread::spawn(move || { + barrier.wait(); + mint_local_token_in(&dir).expect("concurrent mint") + })); + } + barrier.wait(); + let minted: Vec = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect(); + + let raw = std::fs::read_to_string(dir.join("remote_tokens.json")).unwrap(); + let persisted: Vec = serde_json::from_str(&raw).unwrap(); + let secret = std::fs::read(dir.join("remote_secret")).unwrap(); + assert_eq!(persisted.len(), 2); + assert!( + minted + .iter() + .all(|minted| persisted.iter().any(|token| token.id == minted.token_id)) + ); + for minted in minted { + let expected = base64::engine::general_purpose::STANDARD + .encode(auth::compute_hmac(&secret, minted.token.as_bytes())); + assert_eq!( + persisted + .iter() + .find(|token| token.id == minted.token_id) + .unwrap() + .token_hmac, + expected + ); + } + } + + #[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 remembered_executable_is_stable() { + let first = remember_current_executable().expect("current executable"); + let second = remember_current_executable().expect("remembered executable"); + assert_eq!(first, second); + } + + #[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..433018d5b 100644 --- a/crates/okena-remote-server/src/pty_broadcaster.rs +++ b/crates/okena-remote-server/src/pty_broadcaster.rs @@ -1,14 +1,26 @@ use okena_terminal::pty_manager::PtyOutputSink; +use parking_lot::Mutex; +use std::collections::HashMap; use tokio::sync::broadcast; /// A PTY broadcast event for WebSocket subscribers. #[derive(Clone, Debug)] pub enum PtyBroadcastEvent { /// Terminal output data. - Output { terminal_id: String, data: Vec }, + Output { + terminal_id: String, + data: Vec, + sequence: u64, + }, /// 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. @@ -18,6 +30,12 @@ pub enum PtyBroadcastEvent { /// notify the client with a `dropped` message. pub struct PtyBroadcaster { tx: broadcast::Sender, + publish_state: Mutex, +} + +struct PublishState { + next_sequence: u64, + last_published: HashMap, } impl Default for PtyBroadcaster { @@ -29,31 +47,96 @@ impl Default for PtyBroadcaster { impl PtyBroadcaster { pub fn new() -> Self { let (tx, _) = broadcast::channel(4096); - Self { tx } + Self { + tx, + publish_state: Mutex::new(PublishState { + next_sequence: 1, + last_published: HashMap::new(), + }), + } } /// Publish a PTY output event. Non-blocking; drops if no subscribers. - pub fn publish(&self, terminal_id: String, data: Vec) { - let _ = self.tx.send(PtyBroadcastEvent::Output { terminal_id, data }); + pub fn publish(&self, terminal_id: String, data: Vec) -> u64 { + let mut state = self.publish_state.lock(); + let sequence = state.next_sequence; + state.next_sequence += 1; + state.last_published.insert(terminal_id.clone(), sequence); + let _ = self.tx.send(PtyBroadcastEvent::Output { + terminal_id, + data, + sequence, + }); + sequence } /// 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. pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } + + pub fn last_published_sequence(&self, terminal_id: &str) -> u64 { + self.publish_state + .lock() + .last_published + .get(terminal_id) + .copied() + .unwrap_or(0) + } } impl PtyOutputSink for PtyBroadcaster { - fn publish(&self, terminal_id: String, data: Vec) { - self.publish(terminal_id, data); + fn publish(&self, terminal_id: String, data: Vec) -> u64 { + PtyBroadcaster::publish(self, terminal_id, data) + } + + 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); } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn output_sequences_are_monotonic_and_travel_with_events() { + let broadcaster = PtyBroadcaster::new(); + let mut receiver = broadcaster.subscribe(); + + let first = broadcaster.publish("terminal".to_string(), b"a".to_vec()); + let second = broadcaster.publish("terminal".to_string(), b"b".to_vec()); + assert!(second > first); + assert_eq!(broadcaster.last_published_sequence("terminal"), second); - fn publish_resize(&self, terminal_id: String, cols: u16, rows: u16, server_owns: bool) { - self.publish_resize(terminal_id, cols, rows, server_owns); + match receiver.recv().await.unwrap() { + PtyBroadcastEvent::Output { sequence, .. } => assert_eq!(sequence, first), + PtyBroadcastEvent::Resized { .. } => panic!("expected output"), + } } } diff --git a/crates/okena-remote-server/src/routes/actions.rs b/crates/okena-remote-server/src/routes/actions.rs index e93b0ca71..fbbc4c789 100644 --- a/crates/okena-remote-server/src/routes/actions.rs +++ b/crates/okena-remote-server/src/routes/actions.rs @@ -5,11 +5,28 @@ use axum::Json; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; +use okena_core::api::ApiTerminalFocusRequest; + +fn terminal_focus_request(action: &ActionRequest) -> Option { + match action { + ActionRequest::FocusTerminal { + project_id, + terminal_id, + window, + } => Some(ApiTerminalFocusRequest { + project_id: project_id.clone(), + terminal_id: terminal_id.clone(), + window: window.clone(), + }), + _ => None, + } +} pub async fn post_actions( State(state): State, Json(action): Json, ) -> impl IntoResponse { + let terminal_focus = terminal_focus_request(&action); let command = RemoteCommand::Action(action); let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); @@ -28,10 +45,13 @@ pub async fn post_actions( match reply_rx.await { Ok(CommandResult::Ok(payload)) => { + if let Some(request) = terminal_focus { + let _ = state.terminal_focus_tx.send(request); + } let body = payload.unwrap_or(serde_json::json!({"ok": true})); (StatusCode::OK, Json(body)).into_response() } - Ok(CommandResult::OkBytes(_)) => { + Ok(CommandResult::OkBytes(_) | CommandResult::OkSnapshot { .. }) => { (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response() } Ok(CommandResult::Err(e)) => ( @@ -46,3 +66,28 @@ pub async fn post_actions( .into_response(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focus_request_preserves_exact_target() { + let request = terminal_focus_request(&ActionRequest::FocusTerminal { + project_id: "project-1".into(), + terminal_id: "terminal-1".into(), + window: Some("main".into()), + }) + .expect("focus action should broadcast"); + + assert_eq!(request.project_id, "project-1"); + assert_eq!(request.terminal_id, "terminal-1"); + assert_eq!(request.window.as_deref(), Some("main")); + assert!( + terminal_focus_request(&ActionRequest::RecordProjectActivity { + project_id: "project-1".into(), + }) + .is_none() + ); + } +} diff --git a/crates/okena-remote-server/src/routes/auth_reload.rs b/crates/okena-remote-server/src/routes/auth_reload.rs index be89b5653..d760bd268 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,24 @@ 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 +43,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 +52,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..c61bf276e 100644 --- a/crates/okena-remote-server/src/routes/mod.rs +++ b/crates/okena-remote-server/src/routes/mod.rs @@ -4,23 +4,28 @@ 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, ApiTerminalFocusRequest, ApiToast}; +use okena_core::git_poll::GitPollTrigger; use rust_embed::RustEmbed; use std::collections::{HashMap, HashSet}; -use std::sync::atomic::AtomicU64; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Arc, RwLock}; use std::time::Instant; @@ -39,10 +44,67 @@ 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>, + /// One-shot exact-terminal focus requests produced by successful external + /// actions and consumed by connected desktop clients. + pub terminal_focus_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` uses + /// it for UI-owned lifecycle handoff. (`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, + /// UI-owned daemons shut down once the last connected client leaves after + /// any desktop has requested lifecycle handoff. Standalone daemons ignore + /// desktop quit requests. + pub ui_owned: bool, + pub shutdown_when_idle: Arc, + /// Set true once at least one authenticated client has connected. Gates the + /// idle-exit monitor (see [`shutdown::run_idle_exit_monitor`]) so a freshly + /// spawned UI-owned daemon isn't reaped before its GUI makes first contact. + pub had_client: Arc, + /// Graceful process-shutdown trigger for `/v1/shutdown`. The shared daemon + /// run loop awaits it and tears down the socket, discovery file, and + /// instance lock through normal drops. + pub process_shutdown: Arc, + 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 +117,16 @@ pub fn build_router( state_version: Arc>, start_time: Instant, git_status: Arc>>, + toast_tx: Arc>, + terminal_focus_tx: Arc>, remote_subscribed_terminals: Arc>>>, + git_poll_trigger_tx: Option>, next_connection_id: Arc, + active_connections: Arc, + process_shutdown: Arc, + ui_owned: bool, + had_client: Arc, + update_info: okena_ext_updater::UpdateInfo, ) -> Router { let state = AppState { bridge_tx, @@ -65,8 +135,17 @@ pub fn build_router( state_version, start_time, git_status, + toast_tx, + terminal_focus_tx, remote_subscribed_terminals, + git_poll_trigger_tx, next_connection_id, + active_connections, + process_shutdown, + ui_owned, + shutdown_when_idle: Arc::new(AtomicBool::new(false)), + had_client, + update_info, }; // Routes that require auth @@ -78,10 +157,22 @@ pub fn build_router( axum::routing::post(paste_image::post_paste_image) .layer(DefaultBodyLimit::max(paste_image::IMAGE_UPLOAD_LIMIT)), ) + .route( + "/v1/terminals/{terminal_id}/paste-file", + axum::routing::post(paste_image::post_paste_file) + .layer(DefaultBodyLimit::max(paste_image::FILE_UPLOAD_LIMIT)), + ) .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 +182,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 +254,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 +292,25 @@ 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/paste_image.rs b/crates/okena-remote-server/src/routes/paste_image.rs index d8c27a51d..2eb2cb9a7 100644 --- a/crates/okena-remote-server/src/routes/paste_image.rs +++ b/crates/okena-remote-server/src/routes/paste_image.rs @@ -1,7 +1,5 @@ -//! `POST /v1/terminals/{terminal_id}/paste-image` — accept a clipboard image -//! pasted on a remote client, write it to a temp file on *this* (server) host, -//! and bracketed-paste its path into the target terminal so a TUI like Claude -//! Code can attach it. +//! Materialize pasted images and dropped files on the daemon host, then paste +//! their server-local paths into the target terminal. //! //! The image must live where the terminal's process runs: the client's local //! clipboard isn't reachable from here, and a client-local path would point at @@ -15,13 +13,15 @@ use axum::Json; use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode, header::CONTENT_TYPE}; -use axum::response::IntoResponse; +use axum::response::{IntoResponse, Response}; use std::sync::atomic::{AtomicU64, Ordering}; /// Max accepted image upload (20 MiB). Clipboard screenshots — especially /// retina full-screen PNGs — routinely exceed the global 1 MiB body cap, so /// this route raises its own limit (see `build_router`). pub const IMAGE_UPLOAD_LIMIT: usize = 20 * 1024 * 1024; +/// Max accepted dropped file upload (64 MiB). +pub const FILE_UPLOAD_LIMIT: usize = 64 * 1024 * 1024; pub async fn post_paste_image( State(state): State, @@ -29,37 +29,64 @@ pub async fn post_paste_image( headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { + let mime = headers + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/png"); + let ext = image_ext_for_mime(mime); + + materialize_and_paste(state, terminal_id, body, ext, false, "image").await +} + +pub async fn post_paste_file( + State(state): State, + Path(terminal_id): Path, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + let extension = headers + .get("x-okena-file-extension") + .and_then(|value| value.to_str().ok()) + .and_then(safe_file_extension) + .unwrap_or("bin"); + materialize_and_paste(state, terminal_id, body, extension, true, "file").await +} + +async fn materialize_and_paste( + state: AppState, + terminal_id: String, + body: Bytes, + extension: &str, + append_space: bool, + kind: &str, +) -> Response { if body.is_empty() { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "empty image body"})), + Json(serde_json::json!({"error": format!("empty {kind} body")})), ) .into_response(); } - let mime = headers - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("image/png"); - let ext = image_ext_for_mime(mime); - - let filename = format!("okena-remote-paste-{}.{}", next_token(), ext); + let filename = format!("okena-remote-paste-{}.{}", next_token(), extension); let path = std::env::temp_dir().join(filename); if let Err(e) = tokio::fs::write(&path, &body).await { - log::error!("paste-image: failed to write {}: {}", path.display(), e); + log::error!("paste upload: failed to write {}: {}", path.display(), e); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("failed to write image: {e}")})), + Json(serde_json::json!({"error": format!("failed to write {kind}: {e}")})), ) .into_response(); } let path_str = path.to_string_lossy().into_owned(); - let command = RemoteCommand::PasteImage { - terminal_id, - path: path_str.clone(), + let text = if append_space { + format!("{path_str} ") + } else { + path_str.clone() }; + let command = RemoteCommand::PastePath { terminal_id, text }; let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); let msg = BridgeMessage { @@ -68,6 +95,7 @@ pub async fn post_paste_image( }; if state.bridge_tx.send(msg).await.is_err() { + let _ = tokio::fs::remove_file(&path).await; return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "bridge unavailable"})), @@ -76,20 +104,39 @@ pub async fn post_paste_image( } match reply_rx.await { - Ok(CommandResult::Ok(_)) | Ok(CommandResult::OkBytes(_)) => { + Ok(CommandResult::Ok(_)) + | Ok(CommandResult::OkBytes(_)) + | Ok(CommandResult::OkSnapshot { .. }) => { (StatusCode::OK, Json(serde_json::json!({"path": path_str}))).into_response() } Ok(CommandResult::Err(e)) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e}))).into_response() + let _ = tokio::fs::remove_file(&path).await; + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ) + .into_response() + } + Err(_) => { + let _ = tokio::fs::remove_file(&path).await; + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "command processing failed"})), + ) + .into_response() } - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "command processing failed"})), - ) - .into_response(), } } +fn safe_file_extension(extension: &str) -> Option<&str> { + (!extension.is_empty() + && extension.len() <= 16 + && extension + .chars() + .all(|character| character.is_ascii_alphanumeric())) + .then_some(extension) +} + /// File extension for a clipboard image MIME type. Mirrors the desktop /// `paste_filename` mapping (GPUI `ImageFormat`), defaulting to `png` for /// anything unrecognised. Tolerates parameters (`image/png; charset=…`). @@ -124,7 +171,7 @@ fn next_token() -> String { #[cfg(test)] mod tests { - use super::image_ext_for_mime; + use super::{image_ext_for_mime, safe_file_extension}; #[test] fn maps_known_mime_types() { @@ -145,4 +192,12 @@ mod tests { assert_eq!(image_ext_for_mime("application/octet-stream"), "png"); assert_eq!(image_ext_for_mime(""), "png"); } + + #[test] + fn accepts_only_safe_file_extensions() { + assert_eq!(safe_file_extension("tar"), Some("tar")); + assert_eq!(safe_file_extension(""), None); + assert_eq!(safe_file_extension("../sh"), None); + assert_eq!(safe_file_extension("extension-way-too-long"), None); + } } diff --git a/crates/okena-remote-server/src/routes/refresh.rs b/crates/okena-remote-server/src/routes/refresh.rs index 2a14d6786..210fe3f56 100644 --- a/crates/okena-remote-server/src/routes/refresh.rs +++ b/crates/okena-remote-server/src/routes/refresh.rs @@ -1,9 +1,9 @@ use crate::auth::TOKEN_TTL_SECS; use crate::routes::AppState; +use axum::Json; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; -use axum::Json; pub async fn post_refresh( State(state): State, @@ -20,7 +20,7 @@ pub async fn post_refresh( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "missing or invalid authorization header"})), ) - .into_response() + .into_response(); } }; 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..5a73d8c75 --- /dev/null +++ b/crates/okena-remote-server/src/routes/restart.rs @@ -0,0 +1,102 @@ +//! `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. Notify the graceful daemon run loop on a short timer — *after* the +//! response has been flushed. The daemon persists its final state and drops +//! its socket, discovery file, and instance lock before 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; + } + } + + // Wake the graceful run loop after the response is flushed. The replacement + // is already waiting on this PID, so it cannot race the outgoing daemon's + // final persistence and lock release. + let process_shutdown = state.process_shutdown.clone(); + tokio::spawn(async move { + tokio::time::sleep(EXIT_DELAY).await; + log::info!("Daemon exiting for restart"); + process_shutdown.notify_one(); + }); + + 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..51ed63d50 --- /dev/null +++ b/crates/okena-remote-server/src/routes/shutdown.rs @@ -0,0 +1,262 @@ +//! `POST /v1/shutdown` — loopback-only, client-aware daemon shutdown. +//! +//! A quitting GUI arms its UI-owned daemon to stop after the final authenticated +//! client disconnects. Standalone daemons ignore desktop lifecycle handoff. +//! +//! Self-exclusion: the caller disconnects its OWN loopback WS before calling and +//! the daemon simply counts live WS connections — see `local::request_local_shutdown`. +//! +//! Every host wakes the shared graceful daemon run loop. + +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::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +/// 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); + +/// How often the idle-exit monitor samples the live-client count. +const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(3); +/// How long a UI-owned daemon may sit with zero clients (after having served at +/// least one) before it self-terminates. Long enough to absorb a GUI's normal +/// reconnect blip, short enough that a closed/crashed GUI's daemon doesn't +/// linger holding the instance lock. +const IDLE_EXIT_GRACE: Duration = Duration::from_secs(10); + +/// Safety-net that self-terminates a UI-owned daemon once it has been idle (zero +/// authenticated clients) for [`IDLE_EXIT_GRACE`] after serving at least one. +/// +/// The explicit `POST /v1/shutdown` handshake only fires on a *clean* GUI quit. +/// A GUI that crashes / is force-quit / SIGKILLed never arms shutdown, yet a +/// UI-owned daemon with no client has no reason to keep running (and blocks the +/// next launch by holding the instance lock). The `had_client` gate ensures we +/// never reap a freshly-spawned daemon before its GUI makes first contact. +/// Standalone (non-UI-owned) daemons never run this — they are meant to outlive +/// clients. +pub(crate) async fn run_idle_exit_monitor( + active_connections: Arc, + had_client: Arc, + process_shutdown: Arc, +) { + idle_exit_monitor( + active_connections, + had_client, + process_shutdown, + IDLE_CHECK_INTERVAL, + IDLE_EXIT_GRACE, + ) + .await +} + +/// Parameterized core of [`run_idle_exit_monitor`] (timings injectable for tests). +async fn idle_exit_monitor( + active_connections: Arc, + had_client: Arc, + process_shutdown: Arc, + check_interval: Duration, + grace: Duration, +) { + let mut idle_since: Option = None; + let mut ticker = tokio::time::interval(check_interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + let count = active_connections.load(Ordering::SeqCst); + if had_client.load(Ordering::SeqCst) && count == 0 { + let since = *idle_since.get_or_insert_with(Instant::now); + if since.elapsed() >= grace { + log::info!( + "UI-owned daemon idle with no clients for {grace:?} after serving one; exiting" + ); + process_shutdown.notify_one(); + return; + } + } else { + // A client is (re)connected — reset the idle timer. + idle_since = None; + } + } +} + +/// 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, +} + +/// Only daemons explicitly spawned for a desktop lifecycle accept quit handoff. +pub fn should_shut_down(ui_owned: bool) -> bool { + ui_owned +} + +pub(crate) fn schedule_process_shutdown(state: &AppState) { + let notify = state.process_shutdown.clone(); + tokio::spawn(async move { + tokio::time::sleep(EXIT_DELAY).await; + notify.notify_one(); + }); +} + +pub async fn post_shutdown( + Extension(peer): Extension, + State(state): State, +) -> Response { + if !peer.is_local_trusted() { + return StatusCode::FORBIDDEN.into_response(); + } + + if !should_shut_down(state.ui_owned) { + let active_clients = state.active_connections.load(Ordering::Relaxed); + log::info!("Shutdown ignored for standalone daemon"); + return Json(ShutdownResponse { + shutting_down: false, + active_clients, + }) + .into_response(); + } + + // Arm before reading the connection count so a concurrent last disconnect + // either observes the flag or is observed by the load below. + state.shutdown_when_idle.store(true, Ordering::SeqCst); + let active_clients = state.active_connections.load(Ordering::SeqCst); + if active_clients == 0 && state.shutdown_when_idle.swap(false, Ordering::SeqCst) { + log::info!("UI-owned daemon shutting down with no clients remaining"); + schedule_process_shutdown(&state); + } else { + log::info!( + "UI-owned daemon shutdown armed until {active_clients} remaining client(s) disconnect" + ); + } + + 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 only_ui_owned_daemons_accept_lifecycle_handoff() { + assert!(should_shut_down(true)); + assert!(!should_shut_down(false)); + } + + #[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()); + } + + use super::idle_exit_monitor; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::time::Duration; + use tokio::sync::Notify; + + // Tiny timings so the tests finish in milliseconds. + const T_INTERVAL: Duration = Duration::from_millis(5); + const T_GRACE: Duration = Duration::from_millis(20); + + fn spawn_monitor(count: u64, had_client: bool) -> Arc { + let active = Arc::new(AtomicU64::new(count)); + let had = Arc::new(AtomicBool::new(had_client)); + let notify = Arc::new(Notify::new()); + tokio::spawn(idle_exit_monitor( + active, + had, + notify.clone(), + T_INTERVAL, + T_GRACE, + )); + notify + } + + /// Served a client, now idle past the grace window → self-exit fires. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn idle_monitor_exits_when_idle_after_serving_a_client() { + let notify = spawn_monitor(0, true); + tokio::time::timeout(Duration::from_secs(2), notify.notified()) + .await + .expect("idle UI-owned daemon must self-exit after the grace window"); + } + + /// Never served a client (freshly spawned) → must NOT exit before its GUI + /// makes first contact. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn idle_monitor_waits_for_first_client() { + let notify = spawn_monitor(0, false); + assert!( + tokio::time::timeout(Duration::from_millis(200), notify.notified()) + .await + .is_err(), + "must not exit before any client has connected" + ); + } + + /// A client is connected → never exits. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn idle_monitor_stays_while_client_connected() { + let notify = spawn_monitor(1, true); + assert!( + tokio::time::timeout(Duration::from_millis(200), notify.notified()) + .await + .is_err(), + "must not exit while a client is connected" + ); + } + + /// A reconnect within the grace window resets the timer (no premature exit). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn idle_monitor_reconnect_resets_grace() { + let active = Arc::new(AtomicU64::new(0)); + let had = Arc::new(AtomicBool::new(true)); + let notify = Arc::new(Notify::new()); + tokio::spawn(idle_exit_monitor( + active.clone(), + had, + notify.clone(), + T_INTERVAL, + T_GRACE, + )); + // Reconnect before the grace elapses, then stay connected. + tokio::time::sleep(Duration::from_millis(10)).await; + active.store(1, Ordering::SeqCst); + assert!( + tokio::time::timeout(Duration::from_millis(200), notify.notified()) + .await + .is_err(), + "a reconnect within grace must cancel the pending exit" + ); + } +} diff --git a/crates/okena-remote-server/src/routes/state.rs b/crates/okena-remote-server/src/routes/state.rs index 30ec32c08..84a68a4db 100644 --- a/crates/okena-remote-server/src/routes/state.rs +++ b/crates/okena-remote-server/src/routes/state.rs @@ -22,9 +22,7 @@ pub async fn get_state(State(state): State) -> impl IntoResponse { } match reply_rx.await { - Ok(CommandResult::Ok(Some(value))) => { - (StatusCode::OK, Json(value)).into_response() - } + Ok(CommandResult::Ok(Some(value))) => (StatusCode::OK, Json(value)).into_response(), Ok(CommandResult::Err(e)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e})), diff --git a/crates/okena-remote-server/src/routes/stream.rs b/crates/okena-remote-server/src/routes/stream.rs index 4214e7ec0..9942c3166 100644 --- a/crates/okena-remote-server/src/routes/stream.rs +++ b/crates/okena-remote-server/src/routes/stream.rs @@ -3,19 +3,72 @@ #![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); + +fn output_is_newer(watermarks: &HashMap, terminal_id: &str, sequence: u64) -> bool { + sequence > watermarks.get(terminal_id).copied().unwrap_or(0) +} + +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 +77,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) @@ -73,16 +135,35 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = HashMap::new(); // terminal_id -> stream_id + let mut output_watermarks: HashMap = HashMap::new(); let mut reverse_stream_map: HashMap = 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::SeqCst); + // Record that this daemon has served a client, arming the idle-exit monitor. + state.had_client.store(true, Ordering::SeqCst); // 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(); + let mut terminal_focus_rx = state.terminal_focus_tx.subscribe(); + // Once a sender is gone we disable its select arm, otherwise `recv()` would + // resolve `Err(Closed)` instantly and busy-spin the loop. + let mut toast_open = true; + let mut terminal_focus_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); + let mut writer_finished = false; loop { tokio::select! { @@ -105,6 +186,9 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = terminal_ids .iter() .filter_map(|id| { @@ -129,24 +213,42 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option watermarks, + Err(()) => break, + }; + output_watermarks.extend(watermarks); } Ok(WsInbound::Unsubscribe { terminal_ids }) => { for id in &terminal_ids { if let Some(sid) = subscribed_ids.remove(id) { reverse_stream_map.remove(&sid); } + output_watermarks.remove(id); } // Sync to shared state for git polling if let Ok(mut map) = state.remote_subscribed_terminals.write() { @@ -159,22 +261,72 @@ 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 }) => { + Ok(WsInbound::SendBytes { terminal_id, data }) => { + okena_core::latency_probe::daemon_input_received( + &terminal_id, + &data, + ); let _ = state.bridge_tx.send(BridgeMessage { - command: RemoteCommand::Action(ActionRequest::SendSpecialKey { terminal_id, key }), + command: RemoteCommand::ActionFromConnection { + action: ActionRequest::SendBytes { terminal_id, data }, + connection_id: connection_owner_id.clone(), + }, reply: None, }).await; } - Ok(WsInbound::Resize { terminal_id, cols, rows }) => { + Ok(WsInbound::SendSpecialKey { terminal_id, key }) => { let _ = state.bridge_tx.send(BridgeMessage { - command: RemoteCommand::Action(ActionRequest::Resize { terminal_id, cols, rows }), + 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 }) => { + // 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"); if out_tx.send(Message::Text(resp.into())).await.is_err() { @@ -196,12 +348,18 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = Vec::new(); match &event { - crate::pty_broadcaster::PtyBroadcastEvent::Output { terminal_id, data } => { - if let Some(&stream_id) = subscribed_ids.get(terminal_id) { + crate::pty_broadcaster::PtyBroadcastEvent::Output { terminal_id, data, sequence } => { + if output_is_newer(&output_watermarks, terminal_id, *sequence) + && let Some(&stream_id) = subscribed_ids.get(terminal_id) + { + okena_core::latency_probe::daemon_stream_queued(terminal_id); batch.entry(stream_id).or_default().extend_from_slice(data); } } - crate::pty_broadcaster::PtyBroadcastEvent::Resized { terminal_id, cols, rows, server_owns } => { + 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, + )); } } } @@ -242,21 +411,32 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option match &ev { - crate::pty_broadcaster::PtyBroadcastEvent::Output { terminal_id, data } => { - if let Some(&sid) = subscribed_ids.get(terminal_id) { + crate::pty_broadcaster::PtyBroadcastEvent::Output { terminal_id, data, sequence } => { + if output_is_newer(&output_watermarks, terminal_id, *sequence) + && let Some(&sid) = subscribed_ids.get(terminal_id) + { + okena_core::latency_probe::daemon_stream_queued(terminal_id); batch.entry(sid).or_default().extend_from_slice(data); } } - crate::pty_broadcaster::PtyBroadcastEvent::Resized { terminal_id, cols, rows, server_owns } => { + 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, + )); } } }, @@ -272,11 +452,22 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = subscribed_ids.keys().cloned().collect(); - if send_snapshots(&out_tx, &state, &ids, &subscribed_ids).await.is_err() { - channel_closed = true; - break; + match send_snapshots_and_reconcile( + &out_tx, + &state, + &mut pty_rx, + &ids, + &subscribed_ids, + &connection_owner_id, + ) + .await + { + Ok(watermarks) => output_watermarks.extend(watermarks), + Err(()) => { + channel_closed = true; + break; + } } - while pty_rx.try_recv().is_ok() {} break; } Err(broadcast::error::TryRecvError::Closed) => { @@ -321,11 +512,19 @@ async fn handle_ws(mut socket: WebSocket, state: AppState, query_token: Option = subscribed_ids.keys().cloned().collect(); - if send_snapshots(&out_tx, &state, &ids, &subscribed_ids).await.is_err() { - break; + match send_snapshots_and_reconcile( + &out_tx, + &state, + &mut pty_rx, + &ids, + &subscribed_ids, + &connection_owner_id, + ) + .await + { + Ok(watermarks) => output_watermarks.extend(watermarks), + Err(()) => break, } - // Drain stale PTY events — snapshot already includes their effects. - while pty_rx.try_recv().is_ok() {} } Err(broadcast::error::RecvError::Closed) => break, } @@ -360,8 +559,63 @@ 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; + } + } + + // One-shot exact-terminal focus request for connected desktop clients. + result = terminal_focus_rx.recv(), if terminal_focus_open => { + match result { + Ok(request) => { + let resp = serde_json::to_string(&WsOutbound::TerminalFocusRequested(request)) + .expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(resp.into())).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + log::debug!("terminal-focus broadcast lagged, dropped {n} request(s) for a client"); + } + Err(broadcast::error::RecvError::Closed) => { + terminal_focus_open = false; + } + } + } + + // 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 => { + writer_finished = true; break; } } @@ -372,9 +626,25 @@ 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(()) +} + +enum PostSnapshotDrain { + Complete, + Lagged(u64), +} + +/// Drop only output already included in each snapshot and forward newer events. +async fn drain_post_snapshot( + out_tx: &mpsc::Sender, + pty_rx: &mut broadcast::Receiver, + subscribed_ids: &HashMap, + watermarks: &HashMap, + connection_owner_id: &str, +) -> Result { + use crate::pty_broadcaster::PtyBroadcastEvent; + + let mut batch: HashMap> = HashMap::new(); + let mut resize_msgs: Vec = Vec::new(); + + loop { + match pty_rx.try_recv() { + Ok(PtyBroadcastEvent::Output { + terminal_id, + data, + sequence, + }) => { + let watermark = watermarks.get(&terminal_id).copied().unwrap_or(0); + if sequence > watermark + && let Some(&stream_id) = subscribed_ids.get(&terminal_id) + { + batch.entry(stream_id).or_default().extend_from_slice(&data); + } + } + Ok(PtyBroadcastEvent::Resized { + terminal_id, + cols, + rows, + server_owns, + owner_connection_id, + }) => { + if 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, + )); + } + } + Err(broadcast::error::TryRecvError::Empty) => break, + Err(broadcast::error::TryRecvError::Lagged(count)) => { + return Ok(PostSnapshotDrain::Lagged(count)); + } + Err(broadcast::error::TryRecvError::Closed) => return Err(()), + } + } + + 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(PostSnapshotDrain::Complete) +} + /// 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( out_tx: &mpsc::Sender, state: &AppState, terminal_ids: &[String], subscribed_ids: &HashMap, -) -> Result<(), ()> { +) -> Result, ()> { + let mut watermarks = HashMap::new(); for id in terminal_ids { if let Some(&stream_id) = subscribed_ids.get(id) { - let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); - if state - .bridge_tx - .send(BridgeMessage { - command: RemoteCommand::RenderSnapshot { - terminal_id: id.clone(), - }, - reply: Some(reply_tx), - }) - .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 target_sequence = state.broadcaster.last_published_sequence(id); + let (data, sequence) = render_snapshot_after(state, id, target_sequence).await?; + let frame = build_binary_frame(FRAME_TYPE_SNAPSHOT, stream_id, &data); + if out_tx.send(Message::Binary(frame.into())).await.is_err() { + return Err(()); + } + watermarks.insert(id.clone(), sequence); + } + } + Ok(watermarks) +} + +async fn render_snapshot_after( + state: &AppState, + terminal_id: &str, + target_sequence: u64, +) -> Result<(Vec, u64), ()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + state + .bridge_tx + .send(BridgeMessage { + command: RemoteCommand::RenderSnapshot { + terminal_id: terminal_id.to_string(), + }, + reply: Some(reply_tx), + }) + .await + .map_err(|_| ())?; + let Ok(CommandResult::OkSnapshot { data, sequence }) = reply_rx.await else { + return Err(()); + }; + if sequence >= target_sequence { + return Ok((data, sequence)); + } + if tokio::time::Instant::now() >= deadline { + log::warn!( + "Terminal {terminal_id} snapshot stopped at sequence {sequence}, expected {target_sequence}" + ); + return Err(()); + } + tokio::time::sleep(Duration::from_millis(1)).await; + } +} + +async fn send_snapshots_and_reconcile( + out_tx: &mpsc::Sender, + state: &AppState, + pty_rx: &mut broadcast::Receiver, + terminal_ids: &[String], + subscribed_ids: &HashMap, + connection_owner_id: &str, +) -> Result, ()> { + loop { + let watermarks = send_snapshots(out_tx, state, terminal_ids, subscribed_ids).await?; + match drain_post_snapshot( + out_tx, + pty_rx, + subscribed_ids, + &watermarks, + connection_owner_id, + ) + .await? + { + PostSnapshotDrain::Complete => return Ok(watermarks), + PostSnapshotDrain::Lagged(count) => { + let message = serde_json::to_string(&WsOutbound::Dropped { count }) + .expect("BUG: WsOutbound must serialize"); + if out_tx.send(Message::Text(message.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")); + } + + #[test] + fn late_broadcasts_already_covered_by_snapshot_are_ignored() { + let watermarks = HashMap::from([("terminal".to_string(), 42)]); + assert!(!output_is_newer(&watermarks, "terminal", 41)); + assert!(!output_is_newer(&watermarks, "terminal", 42)); + assert!(output_is_newer(&watermarks, "terminal", 43)); + } + + #[tokio::test] + async fn post_snapshot_drain_forwards_only_output_after_watermark() { + let broadcaster = crate::pty_broadcaster::PtyBroadcaster::new(); + let mut pty_rx = broadcaster.subscribe(); + let covered = broadcaster.publish("terminal".to_string(), b"covered".to_vec()); + broadcaster.publish("terminal".to_string(), b"new".to_vec()); + + let (out_tx, mut out_rx) = mpsc::channel(4); + let subscribed = HashMap::from([("terminal".to_string(), 7)]); + let watermarks = HashMap::from([("terminal".to_string(), covered)]); + let outcome = + drain_post_snapshot(&out_tx, &mut pty_rx, &subscribed, &watermarks, "connection") + .await + .unwrap(); + assert!(matches!(outcome, PostSnapshotDrain::Complete)); + + let message = out_rx.recv().await.unwrap(); + let Message::Binary(frame) = message else { + panic!("expected PTY frame"); + }; + let (frame_type, stream_id, payload) = parse_binary_frame(&frame).unwrap(); + assert_eq!(frame_type, okena_core::ws::FRAME_TYPE_PTY); + assert_eq!(stream_id, 7); + assert_eq!(payload, b"new"); + } } diff --git a/crates/okena-remote-server/src/routes/tokens.rs b/crates/okena-remote-server/src/routes/tokens.rs index 0b0ef01e1..db4d9e770 100644 --- a/crates/okena-remote-server/src/routes/tokens.rs +++ b/crates/okena-remote-server/src/routes/tokens.rs @@ -1,8 +1,8 @@ use crate::routes::AppState; +use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use axum::Json; pub async fn list_tokens(State(state): State) -> impl IntoResponse { let tokens = state.auth_store.list_tokens(); 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..e5f052f5a 100644 --- a/crates/okena-remote-server/src/serve.rs +++ b/crates/okena-remote-server/src/serve.rs @@ -62,6 +62,8 @@ 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 +90,155 @@ pub async fn serve_dual_stack( }); } } + +/// Serve TLS exclusively. Plain HTTP is rejected during the rustls handshake. +pub async fn serve_tls( + listener: TcpListener, + app: Router, + tls: Arc, + shutdown: impl std::future::Future, +) -> std::io::Result<()> { + let acceptor = TlsAcceptor::from(tls); + tokio::pin!(shutdown); + + loop { + let (stream, peer) = tokio::select! { + res = listener.accept() => match res { + Ok(value) => value, + Err(error) => { + log::warn!("Remote server accept error: {error}"); + continue; + } + }, + _ = &mut shutdown => return Ok(()), + }; + + let app = app.clone(); + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let tls_stream = match acceptor.accept(stream).await { + Ok(stream) => stream, + Err(error) => { + log::debug!("Rejected non-TLS or invalid handshake from {peer}: {error}"); + return; + } + }; + 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(error) = Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(tls_stream), svc) + .await + { + log::debug!("TLS connection from {peer} ended: {error}"); + } + }); + } +} + +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..ecfd7407f 100644 --- a/crates/okena-remote-server/src/server.rs +++ b/crates/okena-remote-server/src/server.rs @@ -2,13 +2,19 @@ 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, ApiTerminalFocusRequest, 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; use std::sync::{Arc, RwLock}; use tokio::sync::watch; +fn tls_listener_allows_plaintext(addr: IpAddr) -> bool { + addr.is_loopback() +} + /// Handle to a running remote control server. /// Dropping this will trigger shutdown. pub struct RemoteServer { @@ -31,11 +37,18 @@ impl RemoteServer { auth_store: Arc, broadcaster: Arc, state_version: Arc>, - bind_addr: IpAddr, + bind_addrs: Vec, git_status: Arc>>, + toast_tx: Arc>, + terminal_focus_tx: Arc>, remote_subscribed_terminals: Arc>>>, + git_poll_trigger_tx: Option>, next_connection_id: Arc, + active_connections: Arc, + process_shutdown: Arc, + ui_owned: bool, 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 +62,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,15 +82,25 @@ 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" + let scheme = if tls_enabled && bind_addrs.iter().any(|addr| !addr.is_loopback()) { + "https (remote), http+https (loopback)" + } else if tls_enabled { + "http+https (loopback)" } else { "http/ws" }; log::info!( "Remote control server listening on {}:{} ({})", - bind_addr, + bind_addrs + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), port, scheme ); @@ -97,27 +113,85 @@ 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, + ui_owned, + &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()); + + // Set true once a client authenticates; gates the idle-exit monitor so a + // freshly-spawned daemon isn't reaped before its GUI first connects. + let had_client = Arc::new(std::sync::atomic::AtomicBool::new(false)); // Spawn the server task - let shutdown_rx_clone = shutdown_rx.clone(); + let mut shutdown_rx_clone = shutdown_rx.clone(); runtime.spawn(async move { + if okena_ext_updater::detect_local_checkout().is_none() { + routes::update::spawn_background_checker(update_info.clone()); + } + + // UI-owned daemons self-terminate once idle (see + // `run_idle_exit_monitor`) so a closed or crashed GUI never leaves a + // daemon holding the instance lock. Spawned here, inside the runtime, + // BEFORE `build_router` moves the shared handles. + if ui_owned { + tokio::spawn(routes::shutdown::run_idle_exit_monitor( + active_connections.clone(), + had_client.clone(), + process_shutdown.clone(), + )); + } + let app = routes::build_router( bridge_tx, auth_store, @@ -125,40 +199,75 @@ impl RemoteServer { state_version, start_time, git_status, + toast_tx, + terminal_focus_tx, remote_subscribed_terminals, + git_poll_trigger_tx, next_connection_id, + active_connections, + process_shutdown, + ui_owned, + had_client, + 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) => { - crate::serve::serve_dual_stack( - listener, - app, - tls_config, - shutdown_signal(shutdown_rx_clone), - ) - .await - } - Err(e) => { - log::error!("Failed to build TLS server config: {e:#}"); - Err(std::io::Error::other(e.to_string())) - } - } + #[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 { - // 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 + None }; - match serve_result { - Ok(()) => log::info!("Remote control server shut down"), - Err(e) => log::error!("Remote control server exited with error: {e}"), + 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 { + if tls_listener_allows_plaintext(addr) { + // Preserve local plain-http compatibility only where + // traffic cannot leave the host. + crate::serve::serve_dual_stack( + listener, + app, + tls_config, + shutdown_signal(shutdown_rx), + ) + .await + } else { + crate::serve::serve_tls( + listener, + app, + tls_config, + 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}") + } + } + })); + } + let _ = shutdown_rx_clone.changed().await; + for handle in tcp_servers { + let _ = handle.await; + } + + #[cfg(unix)] + if let Some(handle) = local_server { + handle.abort(); } }); @@ -197,6 +306,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 +378,28 @@ 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, + ui_owned: 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, + "ui_owned": ui_owned, }); + 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,10 +416,41 @@ 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 ensures a late or concurrent +/// teardown never clobbers a successor daemon'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); } } @@ -272,25 +472,39 @@ 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"); + } + + #[test] + fn tls_plaintext_compatibility_is_loopback_only() { + assert!(tls_listener_allows_plaintext("127.0.0.1".parse().unwrap())); + assert!(tls_listener_allows_plaintext("::1".parse().unwrap())); + assert!(!tls_listener_allows_plaintext("0.0.0.0".parse().unwrap())); + assert!(!tls_listener_allows_plaintext( + "192.168.1.20".parse().unwrap() + )); } } diff --git a/crates/okena-remote-server/src/tls.rs b/crates/okena-remote-server/src/tls.rs index cc84aa22e..e13e7aa18 100644 --- a/crates/okena-remote-server/src/tls.rs +++ b/crates/okena-remote-server/src/tls.rs @@ -3,7 +3,7 @@ //! The server uses a **persisted self-signed certificate**. There is no CA in //! the picture — clients establish trust by pinning the certificate's SHA-256 //! fingerprint on first connect (TOFU) and verifying it out-of-band against the -//! fingerprint shown here on the host. See `okena-core::client::tls` for the +//! fingerprint shown here on the host. See `okena_transport::tls` for the //! client-side pinned verifier. //! //! The cert is generated once and reused across restarts so the pinned @@ -105,11 +105,10 @@ fn atomic_write(path: &Path, bytes: &[u8], _mode: u32) -> std::io::Result<()> { /// Build a rustls `ServerConfig` from the persisted self-signed cert + key, /// using the ring provider (matches the client side). pub fn server_config(material: &TlsMaterial) -> Result> { - let certs: Vec> = - CertificateDer::pem_file_iter(&material.cert_path) - .with_context(|| format!("reading certificate {:?}", material.cert_path))? - .collect::>() - .context("parsing certificate chain")?; + let certs: Vec> = CertificateDer::pem_file_iter(&material.cert_path) + .with_context(|| format!("reading certificate {:?}", material.cert_path))? + .collect::>() + .context("parsing certificate chain")?; let key = PrivateKeyDer::from_pem_file(&material.key_path) .with_context(|| format!("reading private key {:?}", material.key_path))?; @@ -142,7 +141,10 @@ mod tests { fn fingerprint_is_64_hex_chars() { let fp = fingerprint_hex(&[0u8; 4]); assert_eq!(fp.len(), 64); - assert!(fp.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())); + assert!( + fp.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()) + ); } #[test] @@ -174,7 +176,7 @@ mod tests { mod handshake_tests { use super::*; use axum::Router; - use axum::routing::get; + use axum::routing::{get, post}; /// Start the real dual-stack server (both http + TLS on one port). async fn spawn_dual_stack(material: &TlsMaterial) -> std::net::SocketAddr { @@ -183,19 +185,34 @@ mod handshake_tests { let tls = super::server_config(material).unwrap(); let app = Router::new().route("/health", get(|| async { "ok" })); tokio::spawn(async move { - let _ = crate::serve::serve_dual_stack( - listener, - app, - tls, - std::future::pending::<()>(), - ) - .await; + let _ = + crate::serve::serve_dual_stack(listener, app, tls, std::future::pending::<()>()) + .await; + }); + addr + } + + async fn spawn_tls_only(material: &TlsMaterial) -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let tls = super::server_config(material).unwrap(); + let app = Router::new() + .route("/health", get(|| async { "ok" })) + .route( + "/v1/actions", + post(|| async { axum::Json(serde_json::json!({ "actions": [] })) }), + ); + tokio::spawn(async move { + let _ = crate::serve::serve_tls(listener, app, tls, std::future::pending::<()>()).await; }); addr } /// GET with a short readiness retry while the server task spins up. - async fn get_with_retry(client: &reqwest::Client, url: &str) -> Result { + async fn get_with_retry( + client: &reqwest::Client, + url: &str, + ) -> Result { let mut last_err = None; for _ in 0..20 { match client.get(url).send().await { @@ -235,8 +252,12 @@ mod handshake_tests { // 1) TOFU (no pin): handshake succeeds and the client captures exactly // the server's cert fingerprint. let observed = okena_transport::client::tls::new_observed(); - let client = okena_transport::client::tls::build_reqwest_client(true, None, observed.clone()); - let resp = get_with_retry(&client, &url).await.expect("TOFU connect should succeed"); + let client = + okena_transport::client::tls::build_reqwest_client(true, None, observed.clone()) + .unwrap(); + let resp = get_with_retry(&client, &url) + .await + .expect("TOFU connect should succeed"); assert!(resp.status().is_success()); assert_eq!( observed.lock().unwrap().as_deref(), @@ -249,7 +270,8 @@ mod handshake_tests { true, Some(material.fingerprint.clone()), okena_transport::client::tls::new_observed(), - ); + ) + .unwrap(); assert!( get_with_retry(&client_ok, &url).await.is_ok(), "matching pin must connect" @@ -260,10 +282,82 @@ mod handshake_tests { true, Some("00".repeat(32)), okena_transport::client::tls::new_observed(), - ); + ) + .unwrap(); assert!( client_bad.get(&url).send().await.is_err(), "mismatched pin must be rejected" ); } + + #[tokio::test] + async fn tls_only_listener_rejects_plain_http() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let dir = tempfile::tempdir().unwrap(); + let material = load_or_generate(dir.path()).unwrap(); + let addr = spawn_tls_only(&material).await; + let https_client = okena_transport::client::tls::build_reqwest_client( + true, + Some(material.fingerprint.clone()), + okena_transport::client::tls::new_observed(), + ) + .unwrap(); + let https_url = format!("https://{addr}/health"); + assert!( + get_with_retry(&https_client, &https_url) + .await + .map(|response| response.status().is_success()) + .unwrap_or(false) + ); + + let http_url = format!("http://{addr}/health"); + assert!( + reqwest::Client::new().get(http_url).send().await.is_err(), + "TLS-only listeners must close plaintext HTTP connections" + ); + } + + #[tokio::test] + async fn remote_actions_use_pinned_tls_in_async_and_blocking_clients() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let dir = tempfile::tempdir().unwrap(); + let material = load_or_generate(dir.path()).unwrap(); + let addr = spawn_tls_only(&material).await; + let config = okena_transport::RemoteConnectionConfig { + id: "tls-test".to_string(), + name: "TLS test".to_string(), + host: addr.ip().to_string(), + port: addr.port(), + saved_token: Some("test-token".to_string()), + token_obtained_at: None, + tls: true, + pinned_cert_sha256: Some(material.fingerprint.clone()), + local_endpoint: None, + }; + + let async_result = okena_transport::remote_action::post_action_async( + &config, + "test-token", + okena_core::api::ActionRequest::ListActions, + ) + .await + .unwrap(); + assert_eq!(async_result, Some(serde_json::json!({ "actions": [] }))); + + let client = okena_transport::remote_action::RemoteActionClient::new( + config, + "test-token".to_string(), + ); + + let result = tokio::task::spawn_blocking(move || { + client.post_action(okena_core::api::ActionRequest::ListActions) + }) + .await + .unwrap() + .unwrap(); + + assert_eq!(result, Some(serde_json::json!({ "actions": [] }))); + } } diff --git a/crates/okena-remote-server/src/types.rs b/crates/okena-remote-server/src/types.rs index ba2837830..2e4d792d2 100644 --- a/crates/okena-remote-server/src/types.rs +++ b/crates/okena-remote-server/src/types.rs @@ -1,13 +1,13 @@ #[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::{ - WsInbound, WsOutbound, build_binary_frame, build_pty_frame, parse_binary_frame, - parse_pty_frame, FRAME_TYPE_INPUT, FRAME_TYPE_PTY, FRAME_TYPE_SNAPSHOT, PROTO_VERSION, + FRAME_TYPE_INPUT, FRAME_TYPE_PTY, FRAME_TYPE_SNAPSHOT, PROTO_VERSION, WsInbound, WsOutbound, + build_binary_frame, build_pty_frame, parse_binary_frame, parse_pty_frame, }; // LayoutNode conversion helpers (from_api, from_api_prefixed, to_api) are now @@ -15,9 +15,9 @@ pub use okena_core::ws::{ #[cfg(test)] mod tests { - use okena_workspace::state::LayoutNode; use okena_core::api::ApiLayoutNode; use okena_core::types::SplitDirection; + use okena_workspace::state::LayoutNode; #[test] fn prefixed_terminal_id() { @@ -25,6 +25,7 @@ mod tests { terminal_id: Some("abc-123".into()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }; @@ -43,6 +44,7 @@ mod tests { terminal_id: None, minimized: true, detached: false, + shell_type: Default::default(), cols: None, rows: None, }; @@ -70,6 +72,7 @@ mod tests { terminal_id: Some("t1".into()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }, @@ -80,6 +83,7 @@ mod tests { terminal_id: Some("t2".into()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }, @@ -87,6 +91,7 @@ mod tests { terminal_id: Some("t3".into()), minimized: false, detached: true, + shell_type: Default::default(), cols: None, rows: None, }, @@ -105,6 +110,7 @@ mod tests { terminal_id: Some("raw-id".into()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }; diff --git a/crates/okena-services/Cargo.toml b/crates/okena-services/Cargo.toml index 6ae402a68..526a9f4ab 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,7 @@ log = "0.4" thiserror = "2.0" smol = "2.0" async-channel = "2.3" -libc = "0.2" +uuid = { version = "1.10", features = ["v4"] } + +[dev-dependencies] +anyhow = "1.0" diff --git a/crates/okena-services/src/config.rs b/crates/okena-services/src/config.rs index f9de4722f..62612e8a6 100644 --- a/crates/okena-services/src/config.rs +++ b/crates/okena-services/src/config.rs @@ -39,6 +39,40 @@ pub struct ServiceDefinition { pub restart_delay_ms: u64, } +/// Filesystem result prepared away from the service-manager reactor. +#[derive(Debug)] +pub enum PreparedProjectConfig { + Missing, + Loaded { + config: Option, + detected_compose_file: Option, + }, + Failed(String), +} + +/// Probe and parse one project's service config without touching manager state. +pub fn prepare_project_config(project_path: &str) -> PreparedProjectConfig { + if !Path::new(project_path).exists() { + return PreparedProjectConfig::Missing; + } + match load_project_config(project_path) { + Ok(config) => { + let detect_compose = config + .as_ref() + .and_then(|config| config.docker_compose.as_ref()) + .is_none_or(|docker| docker.enabled != Some(false) && docker.file.is_none()); + let detected_compose_file = detect_compose + .then(|| crate::docker_compose::detect_compose_file(project_path)) + .flatten(); + PreparedProjectConfig::Loaded { + config, + detected_compose_file, + } + } + Err(error) => PreparedProjectConfig::Failed(error.to_string()), + } +} + fn default_cwd() -> String { ".".to_string() } @@ -57,11 +91,10 @@ pub fn load_project_config(project_path: &str) -> crate::ServiceResult bool { let cached = || { AVAILABLE_CACHE.lock().ok().and_then(|g| { g.and_then(|(ts, ok)| { - let ttl = if ok { AVAILABLE_TTL_OK } else { AVAILABLE_TTL_FAIL }; + let ttl = if ok { + AVAILABLE_TTL_OK + } else { + AVAILABLE_TTL_FAIL + }; (ts.elapsed() < ttl).then_some(ok) }) }) @@ -42,7 +49,9 @@ pub fn is_docker_compose_available() -> bool { if let Some(ok) = cached() { return ok; } - let _flight = AVAILABLE_REFRESH_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _flight = AVAILABLE_REFRESH_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); if let Some(ok) = cached() { return ok; } @@ -147,8 +156,8 @@ pub fn list_services(project_path: &str, compose_file: &str) -> crate::ServiceRe fn parse_compose_config_services(json: &str) -> crate::ServiceResult> { use crate::error::ServiceError; - let config: ComposeConfig = serde_json::from_str(json) - .map_err(|e| ServiceError::ParseError { + let config: ComposeConfig = + serde_json::from_str(json).map_err(|e| ServiceError::ParseError { context: "docker compose config JSON".to_string(), detail: e.to_string(), })?; @@ -156,9 +165,7 @@ fn parse_compose_config_services(json: &str) -> crate::ServiceResult Ok(config .services .into_iter() - .filter(|(_, svc)| { - !matches!(svc.deploy, Some(DeployConfig { replicas: Some(0) })) - }) + .filter(|(_, svc)| !matches!(svc.deploy, Some(DeployConfig { replicas: Some(0) }))) .map(|(name, _)| name) .collect()) } @@ -224,12 +231,21 @@ const PS_SNAPSHOT_TTL: Duration = Duration::from_secs(4); /// share this one snapshot, so the host is queried at most once per TTL /// regardless of how many projects are polling. static PS_SNAPSHOT: Mutex)>> = Mutex::new(None); +/// Prevents an in-flight pre-mutation refresh from repopulating stale state. +static PS_SNAPSHOT_GENERATION: AtomicU64 = AtomicU64::new(0); /// Single-flight gate for refreshing [`PS_SNAPSHOT`]. Without it, all per-project /// pollers waking on the same ~5s tick find the cache stale at once and each /// spawn their own `docker ps` before any stores a result (thundering herd). static PS_REFRESH_LOCK: Mutex<()> = Mutex::new(()); +pub(crate) fn invalidate_status_snapshot() { + PS_SNAPSHOT_GENERATION.fetch_add(1, Ordering::AcqRel); + *PS_SNAPSHOT + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; +} + /// One compose container distilled from `docker ps -a --format json`. #[derive(Clone)] struct ContainerSnapshot { @@ -247,6 +263,10 @@ struct ContainerSnapshot { /// Raw JSON shape from `docker ps -a --format json` (NDJSON). #[derive(Deserialize)] struct DockerPsAEntry { + #[serde(rename = "ID")] + id: Option, + #[serde(rename = "Names")] + name: Option, #[serde(rename = "Labels")] labels: Option, #[serde(rename = "State")] @@ -257,12 +277,212 @@ struct DockerPsAEntry { ports: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComposeContainerBlocker { + pub container_id: Option, + pub container_name: Option, + pub working_dir: PathBuf, + pub service: Option, + pub state: String, +} + +#[derive(Debug)] +pub enum ComposeContainerSafetyError { + Inspection(crate::ServiceError), + ContainersPresent { + blockers: Vec, + }, +} + +impl ComposeContainerSafetyError { + pub fn blockers(&self) -> &[ComposeContainerBlocker] { + match self { + Self::Inspection(_) => &[], + Self::ContainersPresent { blockers } => blockers, + } + } +} + +impl fmt::Display for ComposeContainerSafetyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Inspection(error) => { + write!(formatter, "failed to inspect Docker containers: {error}") + } + Self::ContainersPresent { blockers } => { + write!( + formatter, + "{} Docker Compose container(s) still reference the target: ", + blockers.len() + )?; + for (index, blocker) in blockers.iter().enumerate() { + if index > 0 { + formatter.write_str(", ")?; + } + let container = blocker + .container_name + .as_deref() + .or(blocker.container_id.as_deref()) + .unwrap_or("unknown container"); + write!( + formatter, + "{container} [{}] at {}", + blocker.state, + blocker.working_dir.display() + )?; + } + formatter.write_str("; stop or remove them before continuing") + } + } + } +} + +impl std::error::Error for ComposeContainerSafetyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Inspection(error) => Some(error), + Self::ContainersPresent { .. } => None, + } + } +} + +struct DockerPsCommandOutput { + success: bool, + stdout: Vec, + stderr: Vec, +} + +trait DockerPsRunner { + fn inspect(&self) -> std::io::Result; +} + +struct CommandDockerPsRunner; + +impl DockerPsRunner for CommandDockerPsRunner { + fn inspect(&self) -> std::io::Result { + let mut command = process::command("docker"); + command.args(["ps", "-a", "--format", "json", "--no-trunc"]); + process::safe_output_with_timeout(&mut command, DOCKER_TIMEOUT).map(|output| { + DockerPsCommandOutput { + success: output.status.success(), + stdout: output.stdout, + stderr: output.stderr, + } + }) + } +} + +/// Fail when any Docker Compose container still points inside `roots`. +/// +/// This deliberately performs a fresh `docker ps -a` inspection instead of +/// using the status-poller cache. A missing Docker executable is treated as no +/// local Docker integration; all other inspection failures are fail-closed. +pub fn ensure_no_compose_containers_under( + roots: &[PathBuf], +) -> Result<(), ComposeContainerSafetyError> { + ensure_no_compose_containers_under_with(roots, &CommandDockerPsRunner) +} + +fn ensure_no_compose_containers_under_with( + roots: &[PathBuf], + runner: &dyn DockerPsRunner, +) -> Result<(), ComposeContainerSafetyError> { + let output = match runner.inspect() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(ComposeContainerSafetyError::Inspection( + crate::ServiceError::CommandFailed(error), + )); + } + }; + if !output.success { + return Err(ComposeContainerSafetyError::Inspection( + crate::ServiceError::CommandExitError { + context: "docker ps".to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }, + )); + } + + let roots: Vec = roots.iter().map(|root| physical_path(root)).collect(); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut blockers = Vec::new(); + for line in stdout.lines().filter(|line| !line.trim().is_empty()) { + let entry = serde_json::from_str::(line).map_err(|error| { + ComposeContainerSafetyError::Inspection(crate::ServiceError::ParseError { + context: "docker ps line".to_string(), + detail: error.to_string(), + }) + })?; + let labels = entry.labels.unwrap_or_default(); + let Some(working_dir) = label_value(&labels, "com.docker.compose.project.working_dir") + else { + continue; + }; + let working_dir = physical_path(Path::new(working_dir)); + if !roots.iter().any(|root| working_dir.starts_with(root)) { + continue; + } + blockers.push(ComposeContainerBlocker { + container_id: entry.id, + container_name: entry.name, + working_dir, + service: label_value(&labels, "com.docker.compose.service").map(str::to_string), + state: entry.state.unwrap_or_else(|| "unknown".to_string()), + }); + } + if blockers.is_empty() { + Ok(()) + } else { + blockers.sort_by(|left, right| { + left.working_dir + .cmp(&right.working_dir) + .then_with(|| left.container_name.cmp(&right.container_name)) + .then_with(|| left.container_id.cmp(&right.container_id)) + }); + Err(ComposeContainerSafetyError::ContainersPresent { blockers }) + } +} + +pub(crate) fn physical_path(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + if let Ok(canonical) = absolute.canonicalize() { + return canonical; + } + + let mut cursor = absolute.as_path(); + let mut suffix = Vec::new(); + while let Some(name) = cursor.file_name() { + suffix.push(name.to_os_string()); + let Some(parent) = cursor.parent() else { + break; + }; + cursor = parent; + if let Ok(mut canonical) = cursor.canonicalize() { + for component in suffix.iter().rev() { + canonical.push(component); + } + return canonical; + } + } + absolute +} + /// Value of `key` in docker's flattened `k=v,k=v` label string. Robust to /// commas inside *other* values (those fragments simply lack `=` for `key`). fn label_value<'a>(labels: &'a str, key: &str) -> Option<&'a str> { - labels - .split(',') - .find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v)) + labels.split(',').find_map(|kv| { + kv.split_once('=') + .filter(|(k, _)| *k == key) + .map(|(_, v)| v) + }) } /// Parse the exit code out of a `docker ps` Status like "Exited (137) 2h ago". @@ -326,7 +546,11 @@ fn refresh_ps_snapshot() -> crate::ServiceResult> { service: service.to_string(), state, exit_code, - ports: entry.ports.as_deref().map(parse_published_ports).unwrap_or_default(), + ports: entry + .ports + .as_deref() + .map(parse_published_ports) + .unwrap_or_default(), }); } Ok(containers) @@ -356,8 +580,12 @@ fn ps_snapshot() -> crate::ServiceResult> { return Ok(snap); } + let generation = PS_SNAPSHOT_GENERATION.load(Ordering::Acquire); let fresh = refresh_ps_snapshot()?; - if let Ok(mut guard) = PS_SNAPSHOT.lock() { + let mut guard = PS_SNAPSHOT + .lock() + .unwrap_or_else(|error| error.into_inner()); + if PS_SNAPSHOT_GENERATION.load(Ordering::Acquire) == generation { *guard = Some((Instant::now(), fresh.clone())); } Ok(fresh) @@ -368,7 +596,10 @@ fn ps_snapshot() -> crate::ServiceResult> { /// Reads from the shared `docker ps -a` snapshot and filters to the containers /// whose compose project working-dir matches `project_path` — so no per-project /// `docker compose ps` spawn. -pub fn poll_status(project_path: &str, _compose_file: &str) -> crate::ServiceResult> { +pub fn poll_status( + project_path: &str, + _compose_file: &str, +) -> crate::ServiceResult> { let project_canon = std::path::Path::new(project_path) .canonicalize() .unwrap_or_else(|_| std::path::PathBuf::from(project_path)); @@ -403,43 +634,44 @@ pub fn parse_docker_ps_output(output: &str) -> crate::ServiceResult = if trimmed.starts_with('[') { // JSON array format - serde_json::from_str(trimmed) - .map_err(|e| ServiceError::ParseError { - context: "docker ps JSON array".to_string(), - detail: e.to_string(), - })? + serde_json::from_str(trimmed).map_err(|e| ServiceError::ParseError { + context: "docker ps JSON array".to_string(), + detail: e.to_string(), + })? } else { // NDJSON format (one JSON object per line) trimmed .lines() .filter(|l| !l.trim().is_empty()) .map(|line| { - serde_json::from_str::(line) - .map_err(|e| ServiceError::ParseError { - context: "docker ps line".to_string(), - detail: e.to_string(), - }) + serde_json::from_str::(line).map_err(|e| ServiceError::ParseError { + context: "docker ps line".to_string(), + detail: e.to_string(), + }) }) .collect::, _>>()? }; - Ok(entries.into_iter().map(|e| { - let ports = extract_ports(&e.publishers); - let name = e.service_name - .or(e.container_name) - .unwrap_or_default(); - DockerServiceStatus { - name, - state: e.state.unwrap_or_else(|| "unknown".to_string()), - exit_code: e.exit_code, - ports, - } - }).collect()) + Ok(entries + .into_iter() + .map(|e| { + let ports = extract_ports(&e.publishers); + let name = e.service_name.or(e.container_name).unwrap_or_default(); + DockerServiceStatus { + name, + state: e.state.unwrap_or_else(|| "unknown".to_string()), + exit_code: e.exit_code, + ports, + } + }) + .collect()) } /// Extract published host ports from the Publishers array. fn extract_ports(publishers: &Option>) -> Vec { - let Some(pubs) = publishers else { return Vec::new() }; + let Some(pubs) = publishers else { + return Vec::new(); + }; let mut ports: Vec = pubs .iter() .filter_map(|p| p.published_port) @@ -472,6 +704,41 @@ pub fn map_docker_state(state: &str, exit_code: Option) -> ServiceStatus { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct FakeDockerPsRunner { + calls: AtomicUsize, + output: String, + success: bool, + stderr: String, + error_kind: Option, + } + + impl FakeDockerPsRunner { + fn output(output: String) -> Self { + Self { + calls: AtomicUsize::new(0), + output, + success: true, + stderr: String::new(), + error_kind: None, + } + } + } + + impl DockerPsRunner for FakeDockerPsRunner { + fn inspect(&self) -> std::io::Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(kind) = self.error_kind { + return Err(std::io::Error::new(kind, "simulated Docker error")); + } + Ok(DockerPsCommandOutput { + success: self.success, + stdout: self.output.as_bytes().to_vec(), + stderr: self.stderr.as_bytes().to_vec(), + }) + } + } #[test] fn test_parse_docker_ps_json_ndjson() { @@ -515,7 +782,10 @@ mod tests { fn test_map_docker_state() { assert_eq!(map_docker_state("running", None), ServiceStatus::Running); assert_eq!(map_docker_state("Running", None), ServiceStatus::Running); - assert_eq!(map_docker_state("restarting", None), ServiceStatus::Restarting); + assert_eq!( + map_docker_state("restarting", None), + ServiceStatus::Restarting + ); assert_eq!(map_docker_state("paused", None), ServiceStatus::Running); assert_eq!(map_docker_state("created", None), ServiceStatus::Stopped); assert_eq!(map_docker_state("exited", Some(0)), ServiceStatus::Stopped); @@ -529,19 +799,34 @@ mod tests { ); assert_eq!( map_docker_state("dead", Some(137)), - ServiceStatus::Crashed { exit_code: Some(137) } + ServiceStatus::Crashed { + exit_code: Some(137) + } + ); + assert_eq!( + map_docker_state("unknown_state", None), + ServiceStatus::Stopped ); - assert_eq!(map_docker_state("unknown_state", None), ServiceStatus::Stopped); } #[test] fn test_parse_publishers_ports() { let pubs = vec![ - Publisher { published_port: Some(8080) }, - Publisher { published_port: Some(0) }, - Publisher { published_port: None }, - Publisher { published_port: Some(3000) }, - Publisher { published_port: Some(8080) }, // duplicate + Publisher { + published_port: Some(8080), + }, + Publisher { + published_port: Some(0), + }, + Publisher { + published_port: None, + }, + Publisher { + published_port: Some(3000), + }, + Publisher { + published_port: Some(8080), + }, // duplicate ]; let ports = extract_ports(&Some(pubs)); assert_eq!(ports, vec![3000, 8080]); @@ -575,9 +860,119 @@ mod tests { result.sort(); assert_eq!(result, vec!["db", "redis", "web"]); } -} + #[test] + fn compose_safety_query_finds_every_container_state_without_cache() { + let root = + std::env::temp_dir().join(format!("okena-compose-safety-{}", std::process::id())); + std::fs::create_dir_all(root.join("nested")).expect("create safety fixture"); + let working_dir = root.join("nested"); + let labels = |service: &str, path: &Path| { + format!( + "com.docker.compose.service={service},com.docker.compose.project.working_dir={}", + path.display() + ) + }; + let output = ["running", "created", "exited", "dead"] + .into_iter() + .enumerate() + .map(|(index, state)| { + serde_json::json!({ + "ID": format!("container-{index}"), + "Names": format!("fixture-{state}"), + "Labels": labels(state, &working_dir), + "State": state, + "Status": "", + "Ports": "", + }) + .to_string() + }) + .collect::>() + .join("\n"); + let runner = FakeDockerPsRunner::output(output); + + for _ in 0..2 { + let error = + ensure_no_compose_containers_under_with(std::slice::from_ref(&root), &runner) + .expect_err("all Compose states must block removal"); + assert_eq!( + error + .blockers() + .iter() + .map(|blocker| blocker.state.as_str()) + .collect::>(), + std::collections::HashSet::from(["running", "created", "exited", "dead"]) + ); + assert!(error.to_string().contains("stop or remove")); + } + assert_eq!(runner.calls.load(Ordering::SeqCst), 2); + std::fs::remove_dir_all(root).expect("remove safety fixture"); + } + #[test] + fn compose_safety_query_ignores_unrelated_roots() { + let target = + std::env::temp_dir().join(format!("okena-compose-target-{}", std::process::id())); + let unrelated = + std::env::temp_dir().join(format!("okena-compose-unrelated-{}", std::process::id())); + std::fs::create_dir_all(&target).expect("create target fixture"); + std::fs::create_dir_all(&unrelated).expect("create unrelated fixture"); + let output = serde_json::json!({ + "ID": "unrelated", + "Names": "unrelated-web", + "Labels": format!( + "com.docker.compose.service=web,com.docker.compose.project.working_dir={}", + unrelated.display() + ), + "State": "running", + "Status": "Up", + "Ports": "", + }) + .to_string(); + let runner = FakeDockerPsRunner::output(output); + ensure_no_compose_containers_under_with(std::slice::from_ref(&target), &runner) + .expect("unrelated Compose project must not block"); + std::fs::remove_dir_all(target).expect("remove target fixture"); + std::fs::remove_dir_all(unrelated).expect("remove unrelated fixture"); + } + #[test] + fn compose_safety_query_fails_closed_on_inspection_errors() { + let command_error = FakeDockerPsRunner { + calls: AtomicUsize::new(0), + output: String::new(), + success: false, + stderr: "daemon unavailable".to_string(), + error_kind: None, + }; + let malformed = FakeDockerPsRunner::output("not json".to_string()); + let io_error = FakeDockerPsRunner { + calls: AtomicUsize::new(0), + output: String::new(), + success: true, + stderr: String::new(), + error_kind: Some(std::io::ErrorKind::TimedOut), + }; + for runner in [&command_error, &malformed, &io_error] { + let error = ensure_no_compose_containers_under_with(&[], runner) + .expect_err("inspection errors must block"); + assert!(matches!(error, ComposeContainerSafetyError::Inspection(_))); + } + } + + #[test] + fn compose_safety_query_allows_missing_docker_executable() { + let runner = FakeDockerPsRunner { + calls: AtomicUsize::new(0), + output: String::new(), + success: true, + stderr: String::new(), + error_kind: Some(std::io::ErrorKind::NotFound), + }; + + ensure_no_compose_containers_under_with(&[], &runner) + .expect("missing Docker means there is no local integration"); + } +} diff --git a/crates/okena-services/src/error.rs b/crates/okena-services/src/error.rs index 9f32f0d1e..387057e95 100644 --- a/crates/okena-services/src/error.rs +++ b/crates/okena-services/src/error.rs @@ -11,10 +11,7 @@ pub enum ServiceError { /// Failed to parse JSON or YAML output. #[error("{context}: {detail}")] - ParseError { - context: String, - detail: String, - }, + ParseError { context: String, detail: String }, /// Failed to read a config file. #[error("failed to read {path}: {source}")] diff --git a/crates/okena-services/src/manager/commands.rs b/crates/okena-services/src/manager/commands.rs index f2eebd7e6..af038a28a 100644 --- a/crates/okena-services/src/manager/commands.rs +++ b/crates/okena-services/src/manager/commands.rs @@ -1,22 +1,231 @@ //! Start / stop / restart individual services, plus PTY-exit handling. -use super::{MAX_RESTART_COUNT, ServiceKind, ServiceManager, ServiceStatus, is_process_alive}; +use super::{ + DockerMutation, DockerMutationKind, MAX_RESTART_COUNT, OkenaLaunchToken, ServiceAsyncCx, + ServiceCx, ServiceHandle, ServiceKind, ServiceManager, ServiceStatus, +}; use crate::port_detect; -use gpui::{Context, WeakEntity}; +use okena_core::process::is_process_alive; +use okena_terminal::backend::TerminalLaunchPlan; use okena_terminal::shell_config::ShellType; use okena_terminal::terminal::{Terminal, TerminalSize}; use std::path::Path; use std::sync::Arc; use std::time::Duration; +/// Compose mutations may legitimately pull or build images, but must not own +/// the mutation queue forever when Docker or a credential helper wedges. +const DOCKER_MUTATION_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +#[derive(Clone, Copy)] +pub(super) enum OkenaLaunchFailure { + Crashed, + Reconnect { auto_start: bool }, +} + +pub(super) trait DockerMutationRunner: Send + Sync { + fn run(&self, mutation: &DockerMutation) -> crate::ServiceResult<()>; +} + +pub(super) struct CommandDockerMutationRunner; + +impl DockerMutationRunner for CommandDockerMutationRunner { + fn run(&self, mutation: &DockerMutation) -> crate::ServiceResult<()> { + run_docker_mutation(mutation) + } +} + impl ServiceManager { + fn schedule_docker_mutation( + &mut self, + key: (String, String), + project_path: String, + compose_file: String, + kind: DockerMutationKind, + cx: &mut impl ServiceCx, + ) { + let project_incarnation = self.project_incarnation(&key.0, &project_path); + let Some(first) = self.docker_mutations.enqueue( + key.clone(), + project_incarnation, + project_path, + compose_file, + kind, + ) else { + return; + }; + let scope = first.scope(); + let runner = self.docker_mutation_runner.clone(); + + cx.spawn_main(async move |this, cx| { + let mut current = Some(first); + while let Some(mutation) = current { + let key = (mutation.project_id.clone(), mutation.service_name.clone()); + let is_current = this + .update(cx, |this, _| { + mutation + .project_incarnation + .as_ref() + .is_some_and(|incarnation| { + this.is_project_incarnation_current( + &mutation.project_id, + incarnation, + ) + }) + }) + .unwrap_or(false); + if !is_current { + current = this + .update(cx, |this, _| { + this.docker_mutations.finish(&scope, mutation.generation) + }) + .flatten(); + continue; + } + + let run = mutation.clone(); + let runner = runner.clone(); + let result = cx.spawn_blocking(move || runner.run(&run)).await; + crate::docker_compose::invalidate_status_snapshot(); + let failed = result.is_err(); + if let Err(error) = result { + log::error!( + "docker compose {} failed for '{}': {}", + mutation.kind.compose_argument(), + mutation.service_name, + error + ); + } + + current = this + .update(cx, |this, cx| { + let is_current = + mutation + .project_incarnation + .as_ref() + .is_some_and(|incarnation| { + this.is_project_incarnation_current( + &mutation.project_id, + incarnation, + ) + }); + let next = this.docker_mutations.finish(&scope, mutation.generation); + if is_current { + if failed + && !this.docker_mutations.has_service_mutation(&key.0, &key.1) + && let Some(instance) = this.instances.get_mut(&key) + && matches!( + instance.status, + ServiceStatus::Starting | ServiceStatus::Restarting + ) + { + instance.status = ServiceStatus::Stopped; + } + cx.notify(); + } + next + }) + .flatten(); + } + }); + } + + pub(super) fn complete_okena_terminal_launch( + &mut self, + key: &(String, String), + launch_token: &OkenaLaunchToken, + terminal_id: &str, + cwd: &str, + cx: &mut impl ServiceCx, + ) -> bool { + if !self.is_okena_launch_current(key, launch_token) { + return false; + } + + let running = self.instances.get(key).is_some_and(|instance| { + instance.status == ServiceStatus::Starting + && instance.terminal_id.as_deref() == Some(terminal_id) + }) && self.terminal_to_service.get(terminal_id) == Some(key); + let exited_without_restart = self.instances.get(key).is_some_and(|instance| { + matches!(&instance.status, ServiceStatus::Crashed { .. }) + && instance.terminal_id.as_deref() == Some(terminal_id) + }) && !self.terminal_to_service.contains_key(terminal_id); + if !running && !exited_without_restart { + return false; + } + + let terminal = Arc::new(Terminal::new( + terminal_id.to_string(), + TerminalSize::default(), + self.backend.transport(), + cwd.to_string(), + )); + self.terminals + .lock() + .insert(terminal_id.to_string(), terminal); + if running { + if let Some(instance) = self.instances.get_mut(key) { + instance.status = ServiceStatus::Running; + } + self.start_port_detection(&key.0, &key.1, cx); + } + self.finish_okena_launch(key, launch_token); + cx.notify(); + true + } + + pub(super) fn scheduled_okena_restart_is_current( + &self, + key: &(String, String), + restart_count: u32, + ) -> bool { + self.instances.get(key).is_some_and(|instance| { + instance.status == ServiceStatus::Restarting && instance.restart_count == restart_count + }) + } + + pub(super) fn schedule_okena_restart( + &self, + project_id: &str, + service_name: &str, + project_path: &str, + restart_delay_ms: u64, + cx: &mut impl ServiceCx, + ) { + let Some(project_incarnation) = self.project_incarnation(project_id, project_path) else { + return; + }; + let key = (project_id.to_string(), service_name.to_string()); + let Some(restart_count) = self.instances.get(&key).and_then(|instance| { + (instance.status == ServiceStatus::Restarting).then_some(instance.restart_count) + }) else { + return; + }; + let project_id = project_id.to_string(); + let service_name = service_name.to_string(); + let project_path = project_path.to_string(); + let delay = Duration::from_millis(restart_delay_ms); + + cx.spawn_main(async move |this, cx| { + cx.timer(delay).await; + let _ = this.update(cx, |this, cx| { + let key = (project_id.clone(), service_name.clone()); + if this.is_project_incarnation_current(&project_id, &project_incarnation) + && this.scheduled_okena_restart_is_current(&key, restart_count) + { + this.start_service(&project_id, &service_name, &project_path, cx); + } + }); + }); + } + /// Start a service by spawning a PTY (Okena) or running `docker compose start` (Docker). pub fn start_service( &mut self, 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) { @@ -40,29 +249,15 @@ impl ServiceManager { ServiceKind::DockerCompose { compose_file } => { let compose_file = compose_file.clone(); let path = project_path.to_string(); - let name = service_name.to_string(); instance.status = ServiceStatus::Starting; cx.notify(); - - // 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 { - let mut cmd = okena_core::process::command("docker"); - cmd.args(["compose", "-f", &compose_file, "start", &name]) - .current_dir(&path); - okena_core::process::safe_output(&mut cmd) - }) - .await; - if let Ok(output) = result - && !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::error!("docker compose start failed for '{}': {}", log_name, stderr.trim()); - } - // Trigger an immediate status poll - let _ = this.update(cx, |_this, cx| cx.notify()); - }).detach(); + self.schedule_docker_mutation( + key, + path, + compose_file, + DockerMutationKind::Start, + cx, + ); } ServiceKind::Okena => { self.start_okena_service(project_id, service_name, project_path, cx); @@ -76,9 +271,38 @@ 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()); + self.invalidate_okena_restart(&key, true); + let terminal_id = uuid::Uuid::new_v4().to_string(); + self.begin_okena_terminal_launch( + project_id, + service_name, + project_path, + terminal_id, + OkenaLaunchFailure::Crashed, + cx, + ); + } + + pub(super) fn begin_okena_terminal_launch( + &mut self, + project_id: &str, + service_name: &str, + project_path: &str, + terminal_id: String, + failure: OkenaLaunchFailure, + cx: &mut impl ServiceCx, ) { + if self.project_incarnation(project_id, project_path).is_none() { + return; + } let key = (project_id.to_string(), service_name.to_string()); + if !self.instances.contains_key(&key) { + return; + } + let launch_token = self.begin_okena_launch(&key, project_path); let instance = match self.instances.get_mut(&key) { Some(i) => i, None => return, @@ -97,64 +321,112 @@ impl ServiceManager { .to_string_lossy() .to_string(); - let shell = ShellType::for_command(command); + let launch_plan = TerminalLaunchPlan::for_shell(ShellType::for_command(command)) + .with_environment(instance.definition.env.clone().into_iter().collect()); instance.status = ServiceStatus::Starting; - - match self.backend.create_terminal(&cwd, Some(&shell)) { - Ok(terminal_id) => { - let terminal = Arc::new(Terminal::new( - terminal_id.clone(), - TerminalSize::default(), - self.backend.transport(), - cwd, - )); - self.terminals.lock().insert(terminal_id.clone(), terminal); - - #[allow( - clippy::expect_used, - reason = "instance set to Starting a few lines above, absence is a bug" - )] - let instance = self.instances.get_mut(&key).expect("bug: service instance must exist"); - instance.status = ServiceStatus::Running; - instance.terminal_id = Some(terminal_id.clone()); - self.terminal_to_service.insert( - terminal_id, - (project_id.to_string(), service_name.to_string()), - ); - } - Err(e) => { - log::error!( - "Failed to start service '{}' for project {}: {}", - service_name, - project_id, - e - ); - #[allow( - clippy::expect_used, - reason = "instance set to Starting a few lines above, absence is a bug" - )] - let instance = self.instances.get_mut(&key).expect("bug: service instance must exist"); - instance.status = ServiceStatus::Crashed { exit_code: None }; - } - } - + instance.terminal_id = Some(terminal_id.clone()); + self.terminal_to_service + .insert(terminal_id.clone(), key.clone()); cx.notify(); - // Start port detection if service is now running - if self.instances.get(&key).is_some_and(|i| i.status == ServiceStatus::Running) { - self.start_port_detection(project_id, service_name, cx); - } + let backend = self.backend.clone(); + let project_id = project_id.to_string(); + let service_name = service_name.to_string(); + let project_path = project_path.to_string(); + cx.spawn_main(async move |this, cx| { + let launch_backend = backend.clone(); + let launch_id = terminal_id.clone(); + let launch_cwd = cwd.clone(); + let result = cx + .spawn_blocking(move || { + launch_backend.reconnect_terminal_with_plan( + &launch_id, + &launch_cwd, + &launch_plan, + ) + }) + .await; + + let actual_id = result.as_ref().ok().cloned(); + let (accepted, cleanup_requested) = this + .update(cx, |this, cx| { + let key = (project_id.clone(), service_name.clone()); + if result + .as_ref() + .is_ok_and(|returned_id| returned_id == &terminal_id) + && this.complete_okena_terminal_launch( + &key, + &launch_token, + &terminal_id, + &cwd, + cx, + ) + { + return (true, false); + } + let current_launch = this.is_okena_launch_current(&key, &launch_token) + && this.instances.get(&key).is_some_and(|instance| { + instance.status == ServiceStatus::Starting + && instance.terminal_id.as_deref() == Some(&terminal_id) + }) + && this.terminal_to_service.get(&terminal_id) == Some(&key); + if !current_launch { + let cleanup_requested = + this.terminal_to_service.get(&terminal_id) != Some(&key); + return (false, cleanup_requested); + } + + match &result { + Ok(returned_id) if returned_id == &terminal_id => unreachable!(), + _ => { + this.terminal_to_service.remove(&terminal_id); + if let Some(instance) = this.instances.get_mut(&key) { + instance.terminal_id = None; + instance.status = match failure { + OkenaLaunchFailure::Crashed => { + ServiceStatus::Crashed { exit_code: None } + } + OkenaLaunchFailure::Reconnect { .. } => ServiceStatus::Stopped, + }; + } + this.finish_okena_launch(&key, &launch_token); + cx.notify(); + if matches!(failure, OkenaLaunchFailure::Reconnect { auto_start: true }) + { + this.start_service(&project_id, &service_name, &project_path, cx); + } + (false, true) + } + } + }) + .unwrap_or((false, true)); + + if !accepted { + let mut cleanup_ids: std::collections::HashSet = actual_id + .into_iter() + .filter(|actual_id| actual_id != &terminal_id || cleanup_requested) + .collect(); + if cleanup_requested { + cleanup_ids.insert(terminal_id); + } + for cleanup_id in cleanup_ids { + let cleanup_backend = backend.clone(); + cx.spawn_blocking(move || cleanup_backend.kill(&cleanup_id)) + .await; + } + } + }); } /// Stop a running service. - pub fn stop_service( - &mut self, - project_id: &str, - service_name: &str, - cx: &mut Context, - ) { + pub fn stop_service(&mut self, project_id: &str, service_name: &str, cx: &mut impl ServiceCx) { let key = (project_id.to_string(), service_name.to_string()); + if !self.instances.contains_key(&key) { + return; + } + self.invalidate_okena_launch(&key); + self.invalidate_okena_restart(&key, true); let instance = match self.instances.get_mut(&key) { Some(i) => i, None => return, @@ -170,31 +442,21 @@ impl ServiceManager { match &instance.kind { ServiceKind::DockerCompose { compose_file } => { let compose_file = compose_file.clone(); - let path = self.project_paths.get(project_id).cloned().unwrap_or_default(); - let name = service_name.to_string(); + let path = self + .project_paths + .get(project_id) + .cloned() + .unwrap_or_default(); instance.status = ServiceStatus::Stopped; instance.detected_ports.clear(); cx.notify(); - - let log_name = name.clone(); - cx.spawn(async move |_this: WeakEntity, cx| { - let result = cx.background_executor() - .spawn(async move { - let mut cmd = okena_core::process::command("docker"); - cmd.args(["compose", "-f", &compose_file, "stop", &name]) - .current_dir(&path); - okena_core::process::safe_output(&mut cmd) - }) - .await; - match result { - Ok(output) if !output.status.success() => { - let stderr = String::from_utf8_lossy(&output.stderr); - log::error!("docker compose stop failed for '{}': {}", log_name, stderr.trim()); - } - Err(e) => log::error!("docker compose stop failed to run for '{}': {}", log_name, e), - _ => {} - } - }).detach(); + self.schedule_docker_mutation( + key, + path, + compose_file, + DockerMutationKind::Stop, + cx, + ); } ServiceKind::Okena => { instance.status = ServiceStatus::Stopped; @@ -211,9 +473,14 @@ impl ServiceManager { project_id: &str, service_name: &str, project_path: &str, - cx: &mut Context, + cx: &mut impl ServiceCx, ) { + let project_incarnation = self.project_incarnation(project_id, project_path); let key = (project_id.to_string(), service_name.to_string()); + if !self.instances.contains_key(&key) { + return; + } + self.invalidate_okena_launch(&key); let instance = match self.instances.get_mut(&key) { Some(i) => i, None => return, @@ -223,7 +490,6 @@ impl ServiceManager { ServiceKind::DockerCompose { compose_file } => { let compose_file = compose_file.clone(); let path = project_path.to_string(); - let name = service_name.to_string(); // Kill log viewer PTY if any if let Some(terminal_id) = instance.terminal_id.take() { @@ -236,26 +502,13 @@ impl ServiceManager { instance.detected_ports.clear(); cx.notify(); - let log_name = name.clone(); - cx.spawn(async move |this: WeakEntity, cx| { - let result = cx.background_executor() - .spawn(async move { - let mut cmd = okena_core::process::command("docker"); - cmd.args(["compose", "-f", &compose_file, "restart", &name]) - .current_dir(&path); - okena_core::process::safe_output(&mut cmd) - }) - .await; - match result { - Ok(output) if !output.status.success() => { - let stderr = String::from_utf8_lossy(&output.stderr); - log::error!("docker compose restart failed for '{}': {}", log_name, stderr.trim()); - } - Err(e) => log::error!("docker compose restart failed to run for '{}': {}", log_name, e), - _ => {} - } - let _ = this.update(cx, |_this, cx| cx.notify()); - }).detach(); + self.schedule_docker_mutation( + key, + path, + compose_file, + DockerMutationKind::Restart, + cx, + ); } ServiceKind::Okena => { // Take terminal_id now to prevent concurrent access. @@ -267,39 +520,44 @@ impl ServiceManager { instance.detected_ports.clear(); cx.notify(); + let restart_token = + self.begin_okena_restart(&key, project_path, terminal_id.clone()); + let pid = project_id.to_string(); let name = service_name.to_string(); let path = project_path.to_string(); 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 { - backend_ref.get_service_pids(&tid) - .into_iter() - .flat_map(port_detect::get_descendant_pids) - .collect() - }) - .await + cx.spawn_blocking(move || { + backend_ref + .get_service_pids(&tid) + .into_iter() + .flat_map(port_detect::get_descendant_pids) + .collect() + }) + .await } else { Vec::new() }; - // Kill old terminal (backend.kill spawns a bg thread internally) - if let Some(ref tid) = terminal_id { - backend.kill(tid); - terminals.lock().remove(tid); - let tid = tid.clone(); - let _ = this.update(cx, |this, _cx| { - this.terminal_to_service.remove(&tid); - }); + let is_current = this + .update(cx, |this, _| { + let key = (pid.clone(), name.clone()); + this.finalize_okena_restart_terminal(&key, &restart_token) + && project_incarnation.as_ref().is_some_and(|incarnation| { + this.is_project_incarnation_current(&pid, incarnation) + }) + }) + .unwrap_or(false); + if !is_current { + return; } // Wait for old processes to die @@ -308,30 +566,34 @@ 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; } } let _ = this.update(cx, |this, cx| { + if !project_incarnation.as_ref().is_some_and(|incarnation| { + this.is_project_incarnation_current(&pid, incarnation) + }) { + return; + } let key = (pid.clone(), name.clone()); + if !this.is_okena_restart_current(&key, &restart_token) { + return; + } + this.finish_okena_restart(&key, &restart_token); if let Some(instance) = this.instances.get(&key) - && instance.status == ServiceStatus::Restarting { - this.start_service(&pid, &name, &path, cx); - } + && instance.status == ServiceStatus::Restarting + { + this.start_service(&pid, &name, &path, cx); + } }); - }) - .detach(); + }); } } } /// Start all services for a project. - pub fn start_all( - &mut self, - project_id: &str, - project_path: &str, - cx: &mut Context, - ) { + pub fn start_all(&mut self, project_id: &str, project_path: &str, cx: &mut impl ServiceCx) { let names: Vec = self .instances .keys() @@ -345,7 +607,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 +626,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, @@ -372,6 +634,7 @@ impl ServiceManager { }; let (project_id, service_name) = key.clone(); + let project_path = self.project_paths.get(&project_id).cloned(); let instance = match self.instances.get_mut(&key) { Some(i) => i, @@ -390,24 +653,35 @@ impl ServiceManager { // Okena service exit handling instance.detected_ports.clear(); - if instance.definition.restart_on_crash && instance.restart_count < MAX_RESTART_COUNT { + if exit_code == Some(0) { + instance.terminal_id = None; + instance.status = ServiceStatus::Stopped; + instance.restart_count = 0; + self.terminals.lock().remove(terminal_id); + self.invalidate_okena_launch(&key); + cx.notify(); + return true; + } + + let should_restart = + instance.definition.restart_on_crash && instance.restart_count < MAX_RESTART_COUNT; + if should_restart { // Auto-restart: clean up old terminal, will create new one instance.terminal_id = None; self.terminals.lock().remove(terminal_id); instance.status = ServiceStatus::Restarting; instance.restart_count += 1; - - let delay = Duration::from_millis(instance.definition.restart_delay_ms); - - cx.spawn(async move |this: WeakEntity, cx| { - cx.background_executor().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(); + let restart_delay_ms = instance.definition.restart_delay_ms; + self.invalidate_okena_launch(&key); + if let Some(project_path) = project_path { + self.schedule_okena_restart( + &project_id, + &service_name, + &project_path, + restart_delay_ms, + cx, + ); + } } else { // Crash without restart: keep terminal_id and Terminal in registry // so the user can see the crash output until they manually restart. @@ -418,3 +692,40 @@ impl ServiceManager { true } } + +fn run_docker_mutation(mutation: &DockerMutation) -> crate::ServiceResult<()> { + let mut command = okena_core::process::command("docker"); + match mutation.kind { + DockerMutationKind::Start => { + // `up -d` creates the service and its dependency graph; `start` does not. + command.args([ + "compose", + "-f", + &mutation.compose_file, + "up", + "-d", + &mutation.service_name, + ]); + } + DockerMutationKind::Stop | DockerMutationKind::Restart => { + command.args([ + "compose", + "-f", + &mutation.compose_file, + mutation.kind.compose_argument(), + &mutation.service_name, + ]); + } + } + command.current_dir(&mutation.project_path); + let output = + okena_core::process::safe_output_with_timeout(&mut command, DOCKER_MUTATION_TIMEOUT)?; + if output.status.success() { + Ok(()) + } else { + Err(crate::ServiceError::CommandExitError { + context: format!("docker compose {}", mutation.kind.compose_argument()), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } +} diff --git a/crates/okena-services/src/manager/context.rs b/crates/okena-services/src/manager/context.rs new file mode 100644 index 000000000..59813f2b2 --- /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: `smol::unblock(task)`. + fn spawn_blocking( + &self, + task: impl FnOnce() -> T + 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, + task: impl FnOnce() -> T + Send + 'static, + ) -> impl Future + where + T: Send + 'static, + { + smol::unblock(task) + } + + 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..85ab8f462 100644 --- a/crates/okena-services/src/manager/docker.rs +++ b/crates/okena-services/src/manager/docker.rs @@ -1,38 +1,135 @@ //! 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; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +const DOCKER_DISCOVERY_RETRY_DELAYS: [Duration; 5] = [ + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(20), + Duration::from_secs(40), + Duration::from_secs(60), +]; + +fn reconcile_docker_statuses( + instances: &mut HashMap<(String, String), ServiceInstance>, + project_id: &str, + compose_file: &str, + statuses: &[docker_compose::DockerServiceStatus], + protected_services: &HashSet, +) -> (bool, bool) { + let statuses_by_name: HashMap<&str, &docker_compose::DockerServiceStatus> = statuses + .iter() + .map(|status| (status.name.as_str(), status)) + .collect(); + let mut has_definitions = false; + let mut changed = false; + + for ((pid, service_name), instance) in instances.iter_mut() { + if pid != project_id + || !matches!( + &instance.kind, + ServiceKind::DockerCompose { + compose_file: instance_file + } if instance_file == compose_file + ) + { + continue; + } + has_definitions = true; + + // A snapshot taken across an active mutation can describe either side + // of that mutation, so let the mutation's requested state win for now. + if protected_services.contains(service_name) { + continue; + } + + let (new_status, new_ports) = match statuses_by_name.get(service_name.as_str()) { + Some(status) => ( + docker_compose::map_docker_state(&status.state, status.exit_code), + status.ports.clone(), + ), + None => (ServiceStatus::Stopped, Vec::new()), + }; + if instance.status != new_status { + instance.status = new_status; + changed = true; + } + if instance.detected_ports != new_ports { + instance.detected_ports = new_ports; + changed = true; + } + } + + (has_definitions, changed) +} + +fn reconcile_docker_poll_result( + instances: &mut HashMap<(String, String), ServiceInstance>, + mutations: &super::DockerMutationQueue, + project_id: &str, + compose_file: &str, + statuses: &[docker_compose::DockerServiceStatus], + protected_at_probe: &HashSet, + completed_generation_at_probe: u64, +) -> Option<(bool, bool)> { + if mutations.completed_generation(project_id) != completed_generation_at_probe { + return None; + } + let mut protected_services = protected_at_probe.clone(); + protected_services.extend( + mutations + .active_service_names(project_id) + .map(str::to_string), + ); + Some(reconcile_docker_statuses( + instances, + project_id, + compose_file, + statuses, + &protected_services, + )) +} + impl ServiceManager { - /// Load Docker Compose services for a project. - /// If `docker_config` is None, auto-detects compose file. - /// If `docker_config.enabled` is explicitly false, skips. - pub(super) fn load_docker_compose_services( + /// Load Docker Compose services from an off-reactor filesystem snapshot. + pub(super) fn load_docker_compose_services_prepared( &mut self, project_id: &str, project_path: &str, docker_config: Option<&crate::config::DockerComposeConfig>, - cx: &mut Context, + detected_compose_file: Option, + cx: &mut impl ServiceCx, ) { // Check if explicitly disabled - if docker_config.as_ref().is_some_and(|dc| dc.enabled == Some(false)) { + if docker_config + .as_ref() + .is_some_and(|dc| dc.enabled == Some(false)) + { return; } // Resolve compose file (fast filesystem check, OK on main thread) let compose_file = docker_config .and_then(|dc| dc.file.clone()) - .or_else(|| docker_compose::detect_compose_file(project_path)); + .or(detected_compose_file); - let Some(compose_file) = compose_file else { return }; + let Some(compose_file) = compose_file else { + return; + }; + let Some(incarnation) = self.project_incarnation(project_id, project_path) else { + return; + }; // Extract what we need from the reference before spawning let filter: Option> = docker_config @@ -43,11 +140,21 @@ impl ServiceManager { let project_path = project_path.to_string(); // Move docker subprocess calls to background executor - cx.spawn(async move |this: WeakEntity, cx| { - let service_names = { + cx.spawn_main(async move |this, cx| { + let mut retry_delays = DOCKER_DISCOVERY_RETRY_DELAYS.into_iter(); + let service_names = loop { + let is_current = this + .update(cx, |this, _| { + this.is_project_incarnation_current(&project_id, &incarnation) + }) + .unwrap_or(false); + if !is_current { + return; + } + let path = project_path.clone(); let file = compose_file.clone(); - smol::unblock(move || { + let discovered = smol::unblock(move || { if !docker_compose::is_docker_compose_available() { return None; } @@ -59,50 +166,73 @@ impl ServiceManager { } } }) - .await - }; + .await; - let Some(service_names) = service_names else { return }; + if let Some(service_names) = discovered { + break service_names; + } + let Some(delay) = retry_delays.next() else { + log::warn!( + "Giving up Docker Compose discovery for project {} after bounded retries", + project_id + ); + return; + }; + cx.timer(delay).await; + }; let _ = this.update(cx, |this, cx| { + if !this.is_project_incarnation_current(&project_id, &incarnation) { + return; + } for name in &service_names { let is_extra = filter.as_ref().is_some_and(|f| !f.contains(name)); let key = (project_id.clone(), name.clone()); - this.instances.entry(key).or_insert_with(|| ServiceInstance { - definition: ServiceDefinition { - name: name.clone(), - command: String::new(), - cwd: ".".to_string(), - env: HashMap::new(), - auto_start: false, - restart_on_crash: false, - restart_delay_ms: 0, - }, - kind: ServiceKind::DockerCompose { compose_file: compose_file.clone() }, - status: ServiceStatus::Stopped, - terminal_id: None, - restart_count: 0, - detected_ports: Vec::new(), - is_extra, - }); + this.instances + .entry(key) + .or_insert_with(|| ServiceInstance { + definition: ServiceDefinition { + name: name.clone(), + command: String::new(), + cwd: ".".to_string(), + env: HashMap::new(), + auto_start: false, + restart_on_crash: false, + restart_delay_ms: 0, + }, + kind: ServiceKind::DockerCompose { + compose_file: compose_file.clone(), + }, + status: ServiceStatus::Stopped, + terminal_id: None, + restart_count: 0, + detected_ports: Vec::new(), + is_extra, + }); } // Start status poller - this.start_docker_status_poller(&project_id, &project_path, &compose_file, cx); + this.start_docker_status_poller( + &project_id, + &project_path, + &compose_file, + incarnation, + cx, + ); cx.notify(); }); - }) - .detach(); + }); } - /// Reload Docker Compose services on config reload. - pub(super) fn reload_docker_compose_services( + /// Reload Docker Compose services from an off-reactor filesystem snapshot. + pub(super) fn reload_docker_compose_services_prepared( &mut self, project_id: &str, project_path: &str, docker_config: Option<&crate::config::DockerComposeConfig>, - cx: &mut Context, + detected_compose_file: Option, + cx: &mut impl ServiceCx, ) { // Stop existing poller if let Some(cancel) = self.docker_pollers.remove(project_id) { @@ -110,24 +240,34 @@ impl ServiceManager { } // Remove old Docker instances - let docker_keys: Vec<(String, String)> = self.instances + let docker_keys: Vec<(String, String)> = self + .instances .iter() - .filter(|((pid, _), inst)| pid == project_id && matches!(inst.kind, ServiceKind::DockerCompose { .. })) + .filter(|((pid, _), inst)| { + pid == project_id && matches!(inst.kind, ServiceKind::DockerCompose { .. }) + }) .map(|(k, _)| k.clone()) .collect(); for key in docker_keys { if let Some(instance) = self.instances.get(&key) - && let Some(terminal_id) = &instance.terminal_id { - self.backend.kill(terminal_id); - self.terminals.lock().remove(terminal_id); - self.terminal_to_service.remove(terminal_id); - } + && let Some(terminal_id) = &instance.terminal_id + { + self.backend.kill(terminal_id); + self.terminals.lock().remove(terminal_id); + self.terminal_to_service.remove(terminal_id); + } self.instances.remove(&key); } // Reload - self.load_docker_compose_services(project_id, project_path, docker_config, cx); + self.load_docker_compose_services_prepared( + project_id, + project_path, + docker_config, + detected_compose_file, + cx, + ); } /// Spawn a PTY running `docker compose logs -f --tail 200 `. @@ -136,7 +276,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) { @@ -168,36 +308,86 @@ impl ServiceManager { let shell = ShellType::for_command(command); - match self.backend.create_terminal(&project_path, Some(&shell)) { - Ok(terminal_id) => { - let terminal = Arc::new(Terminal::new( - terminal_id.clone(), - TerminalSize::default(), - self.backend.transport(), - project_path, - )); - self.terminals.lock().insert(terminal_id.clone(), terminal); - - #[allow( - clippy::expect_used, - reason = "Docker log instance ensured earlier in this function, absence is a bug" - )] - let instance = self.instances.get_mut(&key).expect("bug: service instance must exist"); - instance.terminal_id = Some(terminal_id.clone()); - self.terminal_to_service.insert( - terminal_id, - (project_id.to_string(), service_name.to_string()), - ); - } - Err(e) => { - log::error!( - "Failed to open Docker logs for '{}' in project {}: {}", - service_name, project_id, e - ); - } - } - + let _ = instance; + let Some(project_incarnation) = self.project_incarnation(project_id, &project_path) else { + return; + }; + let terminal_id = uuid::Uuid::new_v4().to_string(); + let Some(instance) = self.instances.get_mut(&key) else { + return; + }; + instance.terminal_id = Some(terminal_id.clone()); + self.terminal_to_service + .insert(terminal_id.clone(), key.clone()); cx.notify(); + + let backend = self.backend.clone(); + let terminals = self.terminals.clone(); + let project_id = project_id.to_string(); + let service_name = service_name.to_string(); + cx.spawn_main(async move |this, cx| { + let launch_backend = backend.clone(); + let launch_id = terminal_id.clone(); + let launch_path = project_path.clone(); + let result = cx + .spawn_blocking(move || { + launch_backend.reconnect_terminal(&launch_id, &launch_path, Some(&shell)) + }) + .await; + let actual_id = result.as_ref().ok().cloned(); + let (accepted, cleanup_requested) = this + .update(cx, |this, cx| { + let key = (project_id.clone(), service_name.clone()); + let current_launch = this.instances.get(&key).is_some_and(|instance| { + instance.terminal_id.as_deref() == Some(&terminal_id) + }) && this.terminal_to_service.get(&terminal_id) + == Some(&key); + if !this.is_project_incarnation_current(&project_id, &project_incarnation) + || !current_launch + { + return ( + false, + this.terminal_to_service.get(&terminal_id) != Some(&key), + ); + } + match &result { + Ok(returned_id) if returned_id == &terminal_id => { + let terminal = Arc::new(Terminal::new( + terminal_id.clone(), + TerminalSize::default(), + backend.transport(), + project_path.clone(), + )); + terminals.lock().insert(terminal_id.clone(), terminal); + cx.notify(); + (true, false) + } + _ => { + this.terminal_to_service.remove(&terminal_id); + if let Some(instance) = this.instances.get_mut(&key) { + instance.terminal_id = None; + } + cx.notify(); + (false, true) + } + } + }) + .unwrap_or((false, true)); + if !accepted { + let mut cleanup_ids: std::collections::HashSet = actual_id + .into_iter() + .filter(|actual_id| actual_id != &terminal_id || cleanup_requested) + .collect(); + if cleanup_requested { + cleanup_ids.insert(terminal_id); + } + for cleanup_id in cleanup_ids { + let cleanup_backend = backend.clone(); + cx.spawn_blocking(move || cleanup_backend.kill(&cleanup_id)) + .await; + } + } + }); } /// Start a background poller that updates Docker service statuses every 5s. @@ -206,7 +396,8 @@ impl ServiceManager { project_id: &str, project_path: &str, compose_file: &str, - cx: &mut Context, + incarnation: super::ProjectIncarnation, + cx: &mut impl ServiceCx, ) { // Cancel any existing poller for this project if let Some(old_cancel) = self.docker_pollers.remove(project_id) { @@ -214,15 +405,16 @@ impl ServiceManager { } let cancel = Arc::new(AtomicBool::new(false)); - self.docker_pollers.insert(project_id.to_string(), cancel.clone()); + self.docker_pollers + .insert(project_id.to_string(), cancel.clone()); let pid = project_id.to_string(); 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; @@ -230,6 +422,32 @@ impl ServiceManager { if cancel.load(Ordering::Relaxed) { return; } + let is_current = this + .update(cx, |this, _| { + this.is_project_incarnation_current(&pid, &incarnation) + }) + .unwrap_or(false); + if !is_current { + return; + } + + let poll_fence = this + .update(cx, |this, _| { + if !this.is_project_incarnation_current(&pid, &incarnation) { + return None; + } + Some(( + this.docker_mutations + .active_service_names(&pid) + .map(str::to_string) + .collect::>(), + this.docker_mutations.completed_generation(&pid), + )) + }) + .flatten(); + let Some((protected_at_probe, completed_generation_at_probe)) = poll_fence else { + return; + }; let path_clone = path.clone(); let file_clone = file.clone(); @@ -247,30 +465,28 @@ impl ServiceManager { match result { Ok(statuses) => { consecutive_failures = 0; - let should_stop = this.update(cx, |this, cx| { - let mut any_docker = false; - let mut changed = false; - for ds in &statuses { - let key = (pid.clone(), ds.name.clone()); - if let Some(inst) = this.instances.get_mut(&key) - && matches!(inst.kind, ServiceKind::DockerCompose { .. }) { - any_docker = true; - let new_status = docker_compose::map_docker_state(&ds.state, ds.exit_code); - if inst.status != new_status { - inst.status = new_status; - changed = true; - } - if inst.detected_ports != ds.ports { - inst.detected_ports = ds.ports.clone(); - changed = true; - } - } - } - if changed { - cx.notify(); - } - !any_docker - }).unwrap_or(true); + let should_stop = this + .update(cx, |this, cx| { + if !this.is_project_incarnation_current(&pid, &incarnation) { + return true; + } + let Some((has_definitions, changed)) = reconcile_docker_poll_result( + &mut this.instances, + &this.docker_mutations, + &pid, + &file, + &statuses, + &protected_at_probe, + completed_generation_at_probe, + ) else { + return false; + }; + if changed { + cx.notify(); + } + !has_definitions + }) + .unwrap_or(true); if should_stop { return; @@ -288,8 +504,162 @@ 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(); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + fn docker_instance(status: ServiceStatus, ports: Vec) -> ServiceInstance { + ServiceInstance { + definition: ServiceDefinition { + name: "web".to_string(), + command: String::new(), + cwd: ".".to_string(), + env: HashMap::new(), + auto_start: false, + restart_on_crash: false, + restart_delay_ms: 0, + }, + kind: ServiceKind::DockerCompose { + compose_file: "compose.yml".to_string(), + }, + status, + terminal_id: None, + restart_count: 0, + detected_ports: ports, + is_extra: false, + } + } + + #[test] + fn empty_snapshot_stops_known_service_without_stopping_poller() { + let key = ("project".to_string(), "web".to_string()); + let mut instances = HashMap::from([( + key.clone(), + docker_instance(ServiceStatus::Running, vec![8080]), + )]); + + let (has_definitions, changed) = reconcile_docker_statuses( + &mut instances, + "project", + "compose.yml", + &[], + &HashSet::new(), + ); + + assert!(has_definitions); + assert!(changed); + assert_eq!(instances[&key].status, ServiceStatus::Stopped); + assert!(instances[&key].detected_ports.is_empty()); + } + + #[test] + fn snapshot_does_not_override_active_mutation() { + let key = ("project".to_string(), "web".to_string()); + let mut instances = HashMap::from([( + key.clone(), + docker_instance(ServiceStatus::Starting, Vec::new()), + )]); + let protected = HashSet::from(["web".to_string()]); + + let (has_definitions, changed) = + reconcile_docker_statuses(&mut instances, "project", "compose.yml", &[], &protected); + + assert!(has_definitions); + assert!(!changed); + assert_eq!(instances[&key].status, ServiceStatus::Starting); + } + + #[test] + fn poll_started_before_completed_mutation_does_not_apply_stale_snapshot() { + let key = ("project".to_string(), "web".to_string()); + let instances = Arc::new(Mutex::new(HashMap::from([( + key.clone(), + docker_instance(ServiceStatus::Stopped, Vec::new()), + )]))); + let mutations = Arc::new(Mutex::new(super::super::DockerMutationQueue::default())); + let (probe_started_tx, probe_started_rx) = std::sync::mpsc::channel(); + let (release_probe_tx, release_probe_rx) = std::sync::mpsc::channel(); + + let poll_instances = instances.clone(); + let poll_mutations = mutations.clone(); + let poll = std::thread::spawn(move || { + let completed_generation_at_probe = poll_mutations + .lock() + .expect("mutation lock") + .completed_generation("project"); + probe_started_tx.send(()).expect("mark probe started"); + release_probe_rx.recv().expect("release stale probe"); + + let stale_statuses = [docker_compose::DockerServiceStatus { + name: "web".to_string(), + state: "running".to_string(), + exit_code: None, + ports: vec![8080], + }]; + let mutations = poll_mutations.lock().expect("mutation lock"); + let mut instances = poll_instances.lock().expect("instances lock"); + reconcile_docker_poll_result( + &mut instances, + &mutations, + "project", + "compose.yml", + &stale_statuses, + &HashSet::new(), + completed_generation_at_probe, + ) + }); + + probe_started_rx.recv().expect("probe started"); + { + let mut mutations = mutations.lock().expect("mutation lock"); + let mutation = mutations + .enqueue( + key, + None, + "/project".to_string(), + "compose.yml".to_string(), + super::super::DockerMutationKind::Stop, + ) + .expect("mutation starts immediately"); + assert!( + mutations + .finish(&mutation.scope(), mutation.generation) + .is_none() + ); + } + release_probe_tx.send(()).expect("release probe"); + + assert!(poll.join().expect("poll thread").is_none()); + let instances = instances.lock().expect("instances lock"); + assert_eq!( + instances[&("project".into(), "web".into())].status, + ServiceStatus::Stopped + ); + assert!( + instances[&("project".into(), "web".into())] + .detected_ports + .is_empty() + ); + } + + #[test] + fn discovery_retries_use_bounded_backoff() { + assert_eq!( + DOCKER_DISCOVERY_RETRY_DELAYS, + [ + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(20), + Duration::from_secs(40), + Duration::from_secs(60), + ] + ); } } diff --git a/crates/okena-services/src/manager/lifecycle.rs b/crates/okena-services/src/manager/lifecycle.rs index 53047b2ba..720872790 100644 --- a/crates/okena-services/src/manager/lifecycle.rs +++ b/crates/okena-services/src/manager/lifecycle.rs @@ -1,14 +1,12 @@ //! Project service set lifecycle: load, unload, reload `okena.yaml`. -use super::{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; -use std::path::Path; +use super::{ + ServiceCx, ServiceInstance, ServiceKind, ServiceLoadStatus, ServiceManager, ServiceStatus, + commands::OkenaLaunchFailure, +}; +use crate::config::{PreparedProjectConfig, load_project_config, prepare_project_config}; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; -use std::sync::Arc; impl ServiceManager { /// Parse `okena.yaml` for a project, create `ServiceInstance` entries, @@ -19,37 +17,175 @@ impl ServiceManager { project_id: &str, project_path: &str, saved_terminal_ids: &HashMap, - cx: &mut Context, - ) { - log::info!("[services] load_project_services project_id={} path={}", project_id, project_path); - let config = match load_project_config(project_path) { - Ok(Some(config)) => { - log::info!("[services] Found okena.yaml with {} services", config.services.len()); - config + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + self.load_project_services_with_auto_start( + project_id, + project_path, + saved_terminal_ids, + true, + cx, + ) + } + + /// Reload definitions for a backend migration, preserving explicit stopped state. + pub fn load_project_services_for_backend_migration( + &mut self, + project_id: &str, + project_path: &str, + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + self.load_project_services_with_auto_start( + project_id, + project_path, + &HashMap::new(), + false, + cx, + ) + } + + fn load_project_services_with_auto_start( + &mut self, + project_id: &str, + project_path: &str, + saved_terminal_ids: &HashMap, + start_auto_services: bool, + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + let prepared = match load_project_config(project_path) { + Ok(config) => { + let detected_compose_file = + crate::docker_compose::detect_compose_file(project_path); + PreparedProjectConfig::Loaded { + config, + detected_compose_file, + } + } + Err(error) => PreparedProjectConfig::Failed(error.to_string()), + }; + self.load_project_services_prepared_with_auto_start( + project_id, + project_path, + saved_terminal_ids, + prepared, + start_auto_services, + cx, + ) + } + + /// Apply a config previously read away from the owning reactor. + pub fn load_project_services_prepared( + &mut self, + project_id: &str, + project_path: &str, + saved_terminal_ids: &HashMap, + prepared: PreparedProjectConfig, + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + self.load_project_services_prepared_with_auto_start( + project_id, + project_path, + saved_terminal_ids, + prepared, + true, + cx, + ) + } + + /// Apply a prepared config without starting services from config defaults. + pub fn load_project_services_prepared_without_auto_start( + &mut self, + project_id: &str, + project_path: &str, + prepared: PreparedProjectConfig, + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + self.load_project_services_prepared_with_auto_start( + project_id, + project_path, + &HashMap::new(), + prepared, + false, + cx, + ) + } + + fn load_project_services_prepared_with_auto_start( + &mut self, + project_id: &str, + project_path: &str, + saved_terminal_ids: &HashMap, + prepared: PreparedProjectConfig, + start_auto_services: bool, + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + log::info!( + "[services] load_project_services project_id={} path={}", + project_id, + project_path + ); + let config = match prepared { + PreparedProjectConfig::Loaded { + config: Some(config), + detected_compose_file, + } => { + log::info!( + "[services] Found okena.yaml with {} services", + config.services.len() + ); + (config, detected_compose_file) } - Ok(None) => { + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file, + } => { log::info!("[services] No okena.yaml found at {}", project_path); // No okena.yaml — still try Docker Compose auto-detection + self.drain_okena_restarts_for_project(project_id, true); + self.begin_project_incarnation(project_id, project_path); self.project_paths .insert(project_id.to_string(), project_path.to_string()); - self.load_docker_compose_services(project_id, project_path, None, cx); - return; + self.load_docker_compose_services_prepared( + project_id, + project_path, + None, + detected_compose_file, + cx, + ); + cx.notify(); + return ServiceLoadStatus::Loaded; + } + PreparedProjectConfig::Missing => { + log::warn!("Project path disappeared before service apply: {project_path}"); + return ServiceLoadStatus::Failed; } - Err(e) => { - log::error!("Failed to load okena.yaml for project {}: {}", project_id, e); - return; + PreparedProjectConfig::Failed(error) => { + log::error!( + "Failed to load okena.yaml for project {}: {}", + project_id, + error + ); + cx.notify(); + return ServiceLoadStatus::Failed; } }; + self.drain_okena_restarts_for_project(project_id, true); + self.begin_project_incarnation(project_id, project_path); self.project_paths .insert(project_id.to_string(), project_path.to_string()); - let auto_start_names: Vec = config - .services - .iter() - .filter(|s| s.auto_start) - .map(|s| s.name.clone()) - .collect(); + let (config, detected_compose_file) = config; + let auto_start_names: Vec = if start_auto_services { + config + .services + .iter() + .filter(|s| s.auto_start) + .map(|s| s.name.clone()) + .collect() + } else { + Vec::new() + }; for def in &config.services { let key = (project_id.to_string(), def.name.clone()); @@ -67,8 +203,7 @@ impl ServiceManager { ); } - self.configs - .insert(project_id.to_string(), config.services); + self.configs.insert(project_id.to_string(), config.services); // Try to reconnect services that have saved terminal IDs for def in self.configs.get(project_id).cloned().unwrap_or_default() { @@ -81,15 +216,23 @@ impl ServiceManager { for name in auto_start_names { let key = (project_id.to_string(), name.clone()); if let Some(instance) = self.instances.get(&key) - && instance.status == ServiceStatus::Stopped { - self.start_service(project_id, &name, project_path, cx); - } + && instance.status == ServiceStatus::Stopped + { + self.start_service(project_id, &name, project_path, cx); + } } // Load Docker Compose services - self.load_docker_compose_services(project_id, project_path, config.docker_compose.as_ref(), cx); + self.load_docker_compose_services_prepared( + project_id, + project_path, + config.docker_compose.as_ref(), + detected_compose_file, + cx, + ); cx.notify(); + ServiceLoadStatus::Loaded } /// Try to reconnect a service to an existing session backend session. @@ -99,7 +242,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) { @@ -107,53 +250,69 @@ impl ServiceManager { None => return, }; - let command = instance.definition.command.clone(); - let cwd_relative = instance.definition.cwd.clone(); - let cwd = Path::new(project_path) - .join(&cwd_relative) - .to_string_lossy() - .to_string(); - - let shell = ShellType::for_command(command); - - match self.backend.reconnect_terminal(saved_terminal_id, &cwd, Some(&shell)) { - Ok(terminal_id) => { - let terminal = Arc::new(Terminal::new( - terminal_id.clone(), - TerminalSize::default(), - self.backend.transport(), - cwd, - )); - self.terminals.lock().insert(terminal_id.clone(), terminal); - - #[allow( - clippy::expect_used, - reason = "instance inserted earlier in this function, absence is a bug" - )] - let instance = self.instances.get_mut(&key).expect("bug: service instance must exist"); - instance.status = ServiceStatus::Running; - instance.terminal_id = Some(terminal_id.clone()); - self.terminal_to_service.insert( - terminal_id, - (project_id.to_string(), service_name.to_string()), - ); - log::info!("Reconnected service '{}' for project {} (terminal {})", service_name, project_id, saved_terminal_id); - self.start_port_detection(project_id, service_name, cx); - } - Err(e) => { - log::warn!( - "Failed to reconnect service '{}' for project {} (terminal {}): {}", - service_name, project_id, saved_terminal_id, e - ); - // Leave as Stopped — auto_start will create a fresh terminal if configured - } - } - - cx.notify(); + let auto_start = instance.definition.auto_start; + self.begin_okena_terminal_launch( + project_id, + service_name, + project_path, + saved_terminal_id.to_string(), + OkenaLaunchFailure::Reconnect { auto_start }, + cx, + ); } /// 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) { + self.unload_project_services_inner(project_id, &HashSet::new(), true, cx); + } + + /// Invalidate service lifecycles without killing PTYs on the reactor thread. + pub fn unload_project_services_for_backend_migration( + &mut self, + project_id: &str, + cx: &mut impl ServiceCx, + ) -> Vec { + self.unload_project_services_inner(project_id, &HashSet::new(), false, cx) + } + + /// Unload manager state while leaving selected persistent backend sessions alive. + /// Used only while reconciling a replacement workspace snapshot that will + /// immediately reconnect those same terminal IDs. + pub fn unload_project_services_preserving( + &mut self, + project_id: &str, + preserved_terminal_ids: &HashSet, + cx: &mut impl ServiceCx, + ) { + self.unload_project_services_inner(project_id, preserved_terminal_ids, true, cx); + } + + /// Finish a replacement reconciliation by killing preserved sessions that + /// the freshly loaded service set did not successfully reconnect. + pub fn kill_unclaimed_preserved_sessions( + &self, + project_id: &str, + preserved_terminal_ids: &HashSet, + ) { + let claimed: HashSet = self + .service_terminal_ids(project_id) + .into_values() + .collect(); + for terminal_id in preserved_terminal_ids.difference(&claimed) { + self.backend.kill(terminal_id); + } + } + + fn unload_project_services_inner( + &mut self, + project_id: &str, + preserved_terminal_ids: &HashSet, + kill_unpreserved: bool, + cx: &mut impl ServiceCx, + ) -> Vec { + self.invalidate_project_incarnation(project_id); + let mut terminal_ids = self.drain_okena_restarts_for_project(project_id, kill_unpreserved); + // Stop Docker status poller if let Some(cancel) = self.docker_pollers.remove(project_id) { cancel.store(true, Ordering::Relaxed); @@ -168,17 +327,26 @@ impl ServiceManager { for key in keys { if let Some(instance) = self.instances.get(&key) - && let Some(terminal_id) = &instance.terminal_id { + && let Some(terminal_id) = &instance.terminal_id + { + terminal_ids.push(terminal_id.clone()); + if kill_unpreserved && !preserved_terminal_ids.contains(terminal_id) { self.backend.kill(terminal_id); - self.terminals.lock().remove(terminal_id); - self.terminal_to_service.remove(terminal_id); } + self.terminals.lock().remove(terminal_id); + self.terminal_to_service.remove(terminal_id); + } + self.invalidate_okena_launch(&key); self.instances.remove(&key); } self.configs.remove(project_id); self.project_paths.remove(project_id); + self.project_writeback_owners.remove(project_id); + self.port_detection_active + .retain(|(pid, _), _| pid != project_id); cx.notify(); + terminal_ids } /// Re-read `okena.yaml`. Stop removed services, add new ones, @@ -187,24 +355,58 @@ 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, - Ok(None) => { + let prepared = prepare_project_config(project_path); + self.reload_project_services_prepared(project_id, project_path, prepared, cx); + } + + /// Apply a reload snapshot prepared away from the owning reactor. + pub fn reload_project_services_prepared( + &mut self, + project_id: &str, + project_path: &str, + prepared: PreparedProjectConfig, + cx: &mut impl ServiceCx, + ) -> ServiceLoadStatus { + let (new_config, detected_compose_file) = match prepared { + PreparedProjectConfig::Loaded { + config: Some(config), + detected_compose_file, + } => (config, detected_compose_file), + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file, + } => { self.unload_project_services(project_id, cx); - return; + return self.load_project_services_prepared( + project_id, + project_path, + &HashMap::new(), + PreparedProjectConfig::Loaded { + config: None, + detected_compose_file, + }, + cx, + ); } - Err(e) => { + PreparedProjectConfig::Missing => { + log::warn!("Project path disappeared before service reload: {project_path}"); + return ServiceLoadStatus::Failed; + } + PreparedProjectConfig::Failed(error) => { log::error!( "Failed to reload okena.yaml for project {}: {}", project_id, - e + error ); - return; + return ServiceLoadStatus::Failed; } }; + self.drain_okena_restarts_for_project(project_id, true); + self.begin_project_incarnation(project_id, project_path); + self.project_paths .insert(project_id.to_string(), project_path.to_string()); @@ -218,7 +420,9 @@ impl ServiceManager { .filter(|(pid, name)| { pid == project_id && !new_names.contains(name) - && self.instances.get(&(pid.clone(), name.clone())) + && self + .instances + .get(&(pid.clone(), name.clone())) .is_some_and(|i| i.kind == ServiceKind::Okena) }) .cloned() @@ -226,17 +430,31 @@ impl ServiceManager { for key in removed_keys { if let Some(instance) = self.instances.get(&key) - && let Some(terminal_id) = &instance.terminal_id { - self.backend.kill(terminal_id); - self.terminals.lock().remove(terminal_id); - self.terminal_to_service.remove(terminal_id); - } + && let Some(terminal_id) = &instance.terminal_id + { + self.backend.kill(terminal_id); + self.terminals.lock().remove(terminal_id); + self.terminal_to_service.remove(terminal_id); + } + self.invalidate_okena_launch(&key); self.instances.remove(&key); } // Add new services or update definitions for existing ones for def in &new_config.services { let key = (project_id.to_string(), def.name.clone()); + let replaces_docker = self + .instances + .get(&key) + .is_some_and(|instance| instance.kind != ServiceKind::Okena); + if replaces_docker + && let Some(instance) = self.instances.remove(&key) + && let Some(terminal_id) = instance.terminal_id + { + self.backend.kill(&terminal_id); + self.terminals.lock().remove(&terminal_id); + self.terminal_to_service.remove(&terminal_id); + } if let Some(instance) = self.instances.get_mut(&key) { instance.definition = def.clone(); } else { @@ -258,9 +476,47 @@ impl ServiceManager { self.configs .insert(project_id.to_string(), new_config.services.clone()); + // Re-arm project-scoped work; pending launches carry their own reload-safe token. + let runtime_to_rearm: Vec<(String, ServiceStatus, u64)> = self + .instances + .iter() + .filter(|((pid, _), instance)| pid == project_id && instance.kind == ServiceKind::Okena) + .map(|((_, name), instance)| { + ( + name.clone(), + instance.status.clone(), + instance.definition.restart_delay_ms, + ) + }) + .collect(); + for (service_name, status, restart_delay_ms) in runtime_to_rearm { + match status { + ServiceStatus::Running => { + self.start_port_detection(project_id, &service_name, cx); + } + ServiceStatus::Restarting => { + self.schedule_okena_restart( + project_id, + &service_name, + project_path, + restart_delay_ms, + cx, + ); + } + _ => {} + } + } + // Reload Docker Compose services - self.reload_docker_compose_services(project_id, project_path, new_config.docker_compose.as_ref(), cx); + self.reload_docker_compose_services_prepared( + project_id, + project_path, + new_config.docker_compose.as_ref(), + detected_compose_file, + cx, + ); cx.notify(); + ServiceLoadStatus::Loaded } } diff --git a/crates/okena-services/src/manager/mod.rs b/crates/okena-services/src/manager/mod.rs index 7b2394c3a..514320e48 100644 --- a/crates/okena-services/src/manager/mod.rs +++ b/crates/okena-services/src/manager/mod.rs @@ -7,33 +7,309 @@ //! - [`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; -use std::collections::HashMap; -use std::sync::atomic::AtomicBool; +use okena_terminal::backend::TerminalBackend; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +fn take_generation(next_generation: &mut u64) -> u64 { + let generation = (*next_generation).max(1); + *next_generation = generation.wrapping_add(1).max(1); + generation +} pub struct ServiceManager { pub(super) configs: HashMap>, pub(super) instances: HashMap<(String, String), ServiceInstance>, pub(super) terminal_to_service: HashMap, pub(super) project_paths: HashMap, + /// Workspace replacement epoch owning each project's persisted terminal map. + /// Daemon write-back uses this to reject notifications from an older snapshot. + project_writeback_owners: HashMap, + project_lifecycles: ProjectLifecycles, + pending_okena_launches: HashMap<(String, String), OkenaLaunchToken>, + next_okena_launch_generation: u64, + pending_okena_restarts: HashMap<(String, String), OkenaRestartToken>, + next_okena_restart_generation: u64, pub(super) backend: Arc, pub(super) terminals: TerminalsRegistry, /// Cancel tokens for Docker status pollers (project_id -> cancel flag) pub(super) docker_pollers: HashMap>, + docker_mutations: DockerMutationQueue, + docker_mutation_runner: Arc, /// Services currently undergoing port detection. pub(super) port_detection_active: HashMap<(String, String), PortDetectionState>, /// Whether the centralized port detection poller task is running. pub(super) port_detection_running: bool, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct OkenaLaunchToken { + generation: u64, + project_path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct OkenaRestartToken { + generation: u64, + project_path: String, + terminal_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ProjectIncarnation { + generation: u64, + path: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DockerMutationKind { + Start, + Stop, + Restart, +} + +impl DockerMutationKind { + fn compose_argument(self) -> &'static str { + match self { + Self::Start => "up", + Self::Stop => "stop", + Self::Restart => "restart", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) struct DockerMutationScope { + project_path: String, + compose_file: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct DockerMutation { + generation: u64, + project_incarnation: Option, + project_id: String, + project_path: String, + compose_file: String, + service_name: String, + kind: DockerMutationKind, +} + +impl DockerMutation { + fn scope(&self) -> DockerMutationScope { + DockerMutationScope { + project_path: self.project_path.clone(), + compose_file: self.compose_file.clone(), + } + } + + fn compose_identity(&self) -> ComposeProjectIdentity { + ComposeProjectIdentity::new(&self.project_path, &self.compose_file) + } +} + +/// Filesystem identity of one Compose project configuration. +/// +/// The identity resolves aliases for existing paths and keeps normalized +/// suffixes for paths that disappeared during an asynchronous operation. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ComposeProjectIdentity { + project_path: PathBuf, + compose_file: PathBuf, +} + +impl ComposeProjectIdentity { + pub fn new(project_path: impl AsRef, compose_file: impl AsRef) -> Self { + let project_path = project_path.as_ref(); + let compose_file = compose_file.as_ref(); + let compose_file = if compose_file.is_absolute() { + compose_file.to_path_buf() + } else { + project_path.join(compose_file) + }; + Self { + project_path: crate::docker_compose::physical_path(project_path), + compose_file: crate::docker_compose::physical_path(&compose_file), + } + } + + pub fn project_path(&self) -> &Path { + &self.project_path + } + + pub fn compose_file(&self) -> &Path { + &self.compose_file + } +} + +/// One active or queued external Compose mutation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComposeMutationActivity { + pub project_id: String, + pub project_path: String, + pub compose_file: String, + pub service_name: String, + pub kind: DockerMutationKind, + pub queued: bool, +} + +#[derive(Debug)] +struct ActiveDockerMutation { + current: DockerMutation, + pending: VecDeque, +} + +#[derive(Default)] +struct DockerMutationQueue { + active: HashMap, + next_generation: u64, + /// Fences status probes that overlap a completed external mutation. + completed_generation_by_project: HashMap, +} + +impl DockerMutationQueue { + fn enqueue( + &mut self, + key: (String, String), + project_incarnation: Option, + project_path: String, + compose_file: String, + kind: DockerMutationKind, + ) -> Option { + let generation = take_generation(&mut self.next_generation); + let mutation = DockerMutation { + generation, + project_incarnation, + project_id: key.0, + project_path, + compose_file, + service_name: key.1, + kind, + }; + let scope = mutation.scope(); + + if let Some(active) = self.active.get_mut(&scope) { + active.pending.push_back(mutation); + return None; + } + + self.active.insert( + scope, + ActiveDockerMutation { + current: mutation.clone(), + pending: VecDeque::new(), + }, + ); + Some(mutation) + } + + fn finish(&mut self, scope: &DockerMutationScope, generation: u64) -> Option { + let active = self.active.get_mut(scope)?; + if active.current.generation != generation { + return None; + } + + self.completed_generation_by_project + .insert(active.current.project_id.clone(), generation); + + if let Some(next) = active.pending.pop_front() { + active.current = next.clone(); + return Some(next); + } + + self.active.remove(scope); + None + } + + fn completed_generation(&self, project_id: &str) -> u64 { + self.completed_generation_by_project + .get(project_id) + .copied() + .unwrap_or(0) + } + + fn active_service_names<'a>(&'a self, project_id: &'a str) -> impl Iterator { + self.active + .values() + .flat_map(|active| std::iter::once(&active.current).chain(active.pending.iter())) + .filter(move |mutation| mutation.project_id == project_id) + .map(|mutation| mutation.service_name.as_str()) + } + + fn has_service_mutation(&self, project_id: &str, service_name: &str) -> bool { + self.active_service_names(project_id) + .any(|active_name| active_name == service_name) + } +} + +/// Opaque fence for preparing service state without holding the manager lock. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceProjectStateToken { + incarnation: Option, + next_generation: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceTerminalWriteback { + pub project_id: String, + pub project_path: String, + pub data_replacement_epoch: u64, + pub terminal_ids: HashMap, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceLoadStatus { + Loaded, + Failed, +} + +#[derive(Default)] +struct ProjectLifecycles { + current: HashMap, + next_generation: u64, +} + +impl ProjectLifecycles { + fn begin(&mut self, project_id: &str, project_path: &str) -> ProjectIncarnation { + let generation = take_generation(&mut self.next_generation); + let incarnation = ProjectIncarnation { + generation, + path: project_path.to_string(), + }; + self.current + .insert(project_id.to_string(), incarnation.clone()); + incarnation + } + + fn get(&self, project_id: &str, project_path: &str) -> Option { + self.current + .get(project_id) + .filter(|incarnation| incarnation.path == project_path) + .cloned() + } + + fn invalidate(&mut self, project_id: &str) { + self.current.remove(project_id); + } + + fn is_current(&self, project_id: &str, incarnation: &ProjectIncarnation) -> bool { + self.current.get(project_id) == Some(incarnation) + } +} + pub(super) struct PortDetectionState { + pub(super) project_incarnation: ProjectIncarnation, pub(super) polls_remaining: u32, pub(super) found_any: bool, pub(super) stable_count: u32, @@ -57,6 +333,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 +390,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 { @@ -88,9 +487,17 @@ impl ServiceManager { instances: HashMap::new(), terminal_to_service: HashMap::new(), project_paths: HashMap::new(), + project_writeback_owners: HashMap::new(), + project_lifecycles: ProjectLifecycles::default(), + pending_okena_launches: HashMap::new(), + next_okena_launch_generation: 1, + pending_okena_restarts: HashMap::new(), + next_okena_restart_generation: 1, backend, terminals, docker_pollers: HashMap::new(), + docker_mutations: DockerMutationQueue::default(), + docker_mutation_runner: Arc::new(commands::CommandDockerMutationRunner), port_detection_active: HashMap::new(), port_detection_running: false, } @@ -104,11 +511,102 @@ impl ServiceManager { .iter() .filter(|((pid, _), inst)| pid == project_id && inst.kind == ServiceKind::Okena) .filter_map(|((_, name), instance)| { - instance.terminal_id.as_ref().map(|tid| (name.clone(), tid.clone())) + instance + .terminal_id + .as_ref() + .map(|tid| (name.clone(), tid.clone())) }) .collect() } + /// Report Compose subprocesses and queued mutations owned by the targets. + /// + /// Physical identities keep an in-flight mutation visible after a project + /// reload or workspace replacement changes its logical project ID. + pub fn compose_mutations_for( + &self, + project_ids: &HashSet, + compose_identities: &HashSet, + ) -> Vec { + let mut activities = Vec::new(); + for active in self.docker_mutations.active.values() { + for (mutation, queued) in std::iter::once((&active.current, false)) + .chain(active.pending.iter().map(|mutation| (mutation, true))) + { + if !project_ids.contains(&mutation.project_id) + && !compose_identities.contains(&mutation.compose_identity()) + { + continue; + } + activities.push(ComposeMutationActivity { + project_id: mutation.project_id.clone(), + project_path: mutation.project_path.clone(), + compose_file: mutation.compose_file.clone(), + service_name: mutation.service_name.clone(), + kind: mutation.kind, + queued, + }); + } + } + activities.sort_by(|left, right| { + left.project_path + .cmp(&right.project_path) + .then_with(|| left.compose_file.cmp(&right.compose_file)) + .then_with(|| left.queued.cmp(&right.queued)) + .then_with(|| left.service_name.cmp(&right.service_name)) + }); + activities + } + + /// Okena services whose active intent must survive a backend migration. + pub fn active_okena_service_names(&self, project_id: &str) -> Vec { + let mut names: Vec = self + .instances + .iter() + .filter(|((pid, _), instance)| { + pid == project_id + && instance.kind == ServiceKind::Okena + && matches!( + instance.status, + ServiceStatus::Starting + | ServiceStatus::Running + | ServiceStatus::Restarting + ) + }) + .map(|((_, name), _)| name.clone()) + .collect(); + names.sort(); + names + } + + /// Associate future terminal ownership write-back with one workspace snapshot. + pub fn set_project_writeback_owner( + &mut self, + project_id: &str, + project_path: &str, + data_replacement_epoch: u64, + ) { + self.project_writeback_owners.insert( + project_id.to_string(), + (project_path.to_string(), data_replacement_epoch), + ); + } + + /// Snapshot every attempted project, including projects with no service instances. + pub fn service_terminal_writebacks(&self) -> Vec { + self.project_writeback_owners + .iter() + .map( + |(project_id, (project_path, data_replacement_epoch))| ServiceTerminalWriteback { + project_id: project_id.clone(), + project_path: project_path.clone(), + data_replacement_epoch: *data_replacement_epoch, + terminal_ids: self.service_terminal_ids(project_id), + }, + ) + .collect() + } + /// Get all service instances for a project (Okena in config order, then Docker). pub fn services_for_project(&self, project_id: &str) -> Vec<&ServiceInstance> { let mut result: Vec<&ServiceInstance> = Vec::new(); @@ -126,7 +624,9 @@ impl ServiceManager { } // Docker services (sorted by name, non-extra before extra) - let mut docker: Vec<&ServiceInstance> = self.instances.iter() + let mut docker: Vec<&ServiceInstance> = self + .instances + .iter() .filter(|((pid, name), inst)| { pid == project_id && matches!(inst.kind, ServiceKind::DockerCompose { .. }) @@ -135,7 +635,8 @@ impl ServiceManager { .map(|(_, inst)| inst) .collect(); docker.sort_by(|a, b| { - a.is_extra.cmp(&b.is_extra) + a.is_extra + .cmp(&b.is_extra) .then_with(|| a.definition.name.cmp(&b.definition.name)) }); result.extend(docker); @@ -153,20 +654,211 @@ impl ServiceManager { self.project_paths.get(project_id) } - /// Update the stored on-disk path for a project (e.g. after directory rename). - /// Only updates existing entries — projects that haven't been loaded yet will - /// pick up the new path when `load_project_services` is next called. - pub fn update_project_path(&mut self, project_id: &str, new_path: &str) { - if let Some(entry) = self.project_paths.get_mut(project_id) { - *entry = new_path.to_string(); + /// Capture the lifecycle state that must still own an async service apply. + pub fn project_state_token(&self, project_id: &str) -> ServiceProjectStateToken { + ServiceProjectStateToken { + incarnation: self.project_lifecycles.current.get(project_id).cloned(), + next_generation: self.project_lifecycles.next_generation, + } + } + + /// Revalidate a lifecycle snapshot after filesystem preparation completes. + pub fn is_project_state_token_current( + &self, + project_id: &str, + token: &ServiceProjectStateToken, + ) -> bool { + self.project_lifecycles.current.get(project_id) == token.incarnation.as_ref() + && self.project_lifecycles.next_generation == token.next_generation + } + + /// Reload an existing project's service lifecycle after an on-disk rename. + /// Projects that have not been loaded yet pick up their path during load. + /// Returns whether the manager has converged on `new_path`. + pub fn update_project_path( + &mut self, + project_id: &str, + new_path: &str, + cx: &mut impl ServiceCx, + ) -> bool { + match self.project_paths.get(project_id) { + None => true, + Some(path) if path == new_path => true, + Some(_) => { + self.reload_project_services(project_id, new_path, cx); + self.project_paths + .get(project_id) + .is_some_and(|path| path == new_path) + } + } + } + + pub(super) fn begin_project_incarnation( + &mut self, + project_id: &str, + project_path: &str, + ) -> ProjectIncarnation { + self.project_lifecycles.begin(project_id, project_path) + } + + pub(super) fn project_incarnation( + &self, + project_id: &str, + project_path: &str, + ) -> Option { + self.project_lifecycles.get(project_id, project_path) + } + + pub(super) fn invalidate_project_incarnation(&mut self, project_id: &str) { + self.project_lifecycles.invalidate(project_id); + } + + pub(super) fn is_project_incarnation_current( + &self, + project_id: &str, + incarnation: &ProjectIncarnation, + ) -> bool { + self.project_lifecycles.is_current(project_id, incarnation) + && self.project_paths.get(project_id) == Some(&incarnation.path) + } + + pub(super) fn begin_okena_launch( + &mut self, + key: &(String, String), + project_path: &str, + ) -> OkenaLaunchToken { + let generation = take_generation(&mut self.next_okena_launch_generation); + let token = OkenaLaunchToken { + generation, + project_path: project_path.to_string(), + }; + self.pending_okena_launches + .insert(key.clone(), token.clone()); + token + } + + pub(super) fn is_okena_launch_current( + &self, + key: &(String, String), + token: &OkenaLaunchToken, + ) -> bool { + self.pending_okena_launches.get(key) == Some(token) + && self.project_paths.get(&key.0) == Some(&token.project_path) + } + + pub(super) fn finish_okena_launch(&mut self, key: &(String, String), token: &OkenaLaunchToken) { + if self.pending_okena_launches.get(key) == Some(token) { + self.pending_okena_launches.remove(key); } } + pub(super) fn invalidate_okena_launch(&mut self, key: &(String, String)) { + self.pending_okena_launches.remove(key); + } + + pub(super) fn begin_okena_restart( + &mut self, + key: &(String, String), + project_path: &str, + terminal_id: Option, + ) -> OkenaRestartToken { + self.invalidate_okena_restart(key, true); + let generation = take_generation(&mut self.next_okena_restart_generation); + let token = OkenaRestartToken { + generation, + project_path: project_path.to_string(), + terminal_id, + }; + if let Some(terminal_id) = &token.terminal_id + && self.terminal_to_service.get(terminal_id) == Some(key) + { + self.terminal_to_service.remove(terminal_id); + } + self.pending_okena_restarts + .insert(key.clone(), token.clone()); + token + } + + pub(super) fn is_okena_restart_current( + &self, + key: &(String, String), + token: &OkenaRestartToken, + ) -> bool { + self.pending_okena_restarts.get(key).is_some_and(|pending| { + pending.generation == token.generation && pending.project_path == token.project_path + }) && self.project_paths.get(&key.0) == Some(&token.project_path) + } + + pub(super) fn finalize_okena_restart_terminal( + &mut self, + key: &(String, String), + token: &OkenaRestartToken, + ) -> bool { + if !self.is_okena_restart_current(key, token) { + return false; + } + let terminal_id = self + .pending_okena_restarts + .get_mut(key) + .and_then(|pending| pending.terminal_id.take()); + if let Some(terminal_id) = terminal_id { + self.backend.kill(&terminal_id); + self.terminals.lock().remove(&terminal_id); + if self.terminal_to_service.get(&terminal_id) == Some(key) { + self.terminal_to_service.remove(&terminal_id); + } + } + true + } + + pub(super) fn finish_okena_restart( + &mut self, + key: &(String, String), + token: &OkenaRestartToken, + ) { + if self.pending_okena_restarts.get(key).is_some_and(|pending| { + pending.generation == token.generation && pending.project_path == token.project_path + }) { + self.pending_okena_restarts.remove(key); + } + } + + pub(super) fn invalidate_okena_restart( + &mut self, + key: &(String, String), + kill_terminal: bool, + ) -> Option { + let token = self.pending_okena_restarts.remove(key)?; + let terminal_id = token.terminal_id?; + if kill_terminal { + self.backend.kill(&terminal_id); + } + self.terminals.lock().remove(&terminal_id); + if self.terminal_to_service.get(&terminal_id) == Some(key) { + self.terminal_to_service.remove(&terminal_id); + } + Some(terminal_id) + } + + pub(super) fn drain_okena_restarts_for_project( + &mut self, + project_id: &str, + kill_terminals: bool, + ) -> Vec { + let keys: Vec<(String, String)> = self + .pending_okena_restarts + .keys() + .filter(|(pending_project_id, _)| pending_project_id == project_id) + .cloned() + .collect(); + keys.into_iter() + .filter_map(|key| self.invalidate_okena_restart(&key, kill_terminals)) + .collect() + } + /// Whether the project has any service definitions loaded (Okena or Docker). pub fn has_services(&self, project_id: &str) -> bool { - self.configs - .get(project_id) - .is_some_and(|v| !v.is_empty()) + self.configs.get(project_id).is_some_and(|v| !v.is_empty()) || self.instances.keys().any(|(pid, _)| pid == project_id) } @@ -178,20 +870,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..44eb2c602 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,15 +15,27 @@ 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() { + if self + .instances + .get(&key) + .and_then(|i| i.terminal_id.as_ref()) + .is_none() + { return; } + let Some(project_path) = self.project_paths.get(project_id) else { + return; + }; + let Some(project_incarnation) = self.project_incarnation(project_id, project_path) else { + return; + }; self.port_detection_active.insert( key, PortDetectionState { + project_incarnation, polls_remaining: 10, found_any: false, stable_count: 0, @@ -34,30 +47,36 @@ 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 - let services: Vec<((String, String), String)> = this + let services: Vec<((String, String), String, super::ProjectIncarnation)> = this .update(cx, |this, _| { this.port_detection_active - .keys() - .filter_map(|key| { + .iter() + .filter_map(|(key, state)| { + if !this.is_project_incarnation_current( + &key.0, + &state.project_incarnation, + ) { + return None; + } let inst = this.instances.get(key)?; if inst.status != ServiceStatus::Running { return None; } let tid = inst.terminal_id.clone()?; - Some((key.clone(), tid)) + Some((key.clone(), tid, state.project_incarnation.clone())) }) .collect() }) @@ -74,22 +93,23 @@ impl ServiceManager { // Background: get PIDs per service, build process tree ONCE, // scan ports ONCE, distribute results. let backend_ref = backend.clone(); - let results: Vec<((String, String), Vec)> = cx - .background_executor() - .spawn(async move { + let results: Vec<((String, String), super::ProjectIncarnation, Vec)> = cx + .spawn_blocking(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> = - services.iter().map(|(_, tid)| tid.as_str()).collect(); + services.iter().map(|(_, tid, _)| tid.as_str()).collect(); let batch_pids = backend_ref.get_batch_service_pids(&terminal_ids); - let service_root_pids: Vec<((String, String), Vec)> = services + let service_root_pids: Vec<( + (String, String), + super::ProjectIncarnation, + Vec, + )> = services .iter() - .map(|(key, tid)| { - let pids = batch_pids - .get(tid.as_str()) - .cloned() - .unwrap_or_default(); - (key.clone(), pids) + .map(|(key, tid, incarnation)| { + let pids = + batch_pids.get(tid.as_str()).cloned().unwrap_or_default(); + (key.clone(), incarnation.clone(), pids) }) .collect(); @@ -97,26 +117,28 @@ impl ServiceManager { let tree = port_detect::build_process_tree(); // Expand to descendant PIDs per service - let mut all_pids: std::collections::HashSet = std::collections::HashSet::new(); + let mut all_pids: std::collections::HashSet = + std::collections::HashSet::new(); let service_pid_sets: Vec<( (String, String), + super::ProjectIncarnation, std::collections::HashSet, )> = service_root_pids .into_iter() - .map(|(key, roots)| { + .map(|(key, incarnation, roots)| { let mut pids = std::collections::HashSet::new(); for &pid in &roots { pids.extend(port_detect::descendants_from_tree(&tree, pid)); } all_pids.extend(&pids); - (key, pids) + (key, incarnation, pids) }) .collect(); if all_pids.is_empty() { return service_pid_sets .into_iter() - .map(|(k, _)| (k, Vec::new())) + .map(|(key, incarnation, _)| (key, incarnation, Vec::new())) .collect(); } @@ -126,9 +148,9 @@ impl ServiceManager { // Distribute ports to each service service_pid_sets .into_iter() - .map(|(key, pids)| { + .map(|(key, incarnation, pids)| { let ports = port_detect::ports_for_pids(&all_port_pairs, &pids); - (key, ports) + (key, incarnation, ports) }) .collect() }) @@ -140,27 +162,33 @@ impl ServiceManager { let mut changed = false; let mut keys_to_remove = Vec::new(); - for (key, ports) in results { + for (key, incarnation, ports) in results { + if !this.is_project_incarnation_current(&key.0, &incarnation) { + continue; + } let Some(state) = this.port_detection_active.get_mut(&key) else { continue; }; + if state.project_incarnation != incarnation { + continue; + } state.polls_remaining = state.polls_remaining.saturating_sub(1); if !ports.is_empty() { - let ports_changed = - if let Some(inst) = this.instances.get_mut(&key) { - if inst.status == ServiceStatus::Running - && inst.detected_ports != ports - { - inst.detected_ports = ports; - true - } else { - false - } + let ports_changed = if let Some(inst) = this.instances.get_mut(&key) + { + if inst.status == ServiceStatus::Running + && inst.detected_ports != ports + { + inst.detected_ports = ports; + true } else { false - }; + } + } else { + false + }; if state.found_any && !ports_changed { state.stable_count += 1; @@ -201,9 +229,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/manager/tests.rs b/crates/okena-services/src/manager/tests.rs index adf011377..5134e7f78 100644 --- a/crates/okena-services/src/manager/tests.rs +++ b/crates/okena-services/src/manager/tests.rs @@ -1,6 +1,399 @@ +use super::commands::OkenaLaunchFailure; use super::*; -use crate::config::ServiceDefinition; -use std::collections::HashMap; +use crate::config::{OkenaProjectConfig, PreparedProjectConfig, ServiceDefinition}; +use okena_terminal::backend::{LocalBackend, TerminalBackend, TerminalLaunchPlan}; +use okena_terminal::pty_manager::PtyManager; +use okena_terminal::session_backend::SessionBackend; +use okena_terminal::shell_config::ShellType; +use okena_terminal::terminal::TerminalTransport; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::future::{Future, ready}; +use std::path::PathBuf; +use std::rc::{Rc, Weak}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Duration; + +#[derive(Clone)] +struct NoopHandle; + +struct NoopAsyncCx; + +#[derive(Clone, Default)] +struct RecordingCx { + spawned: Arc, + notifications: Arc, +} + +impl ServiceCx for RecordingCx { + type Handle = NoopHandle; + type AsyncCx = NoopAsyncCx; + + fn notify(&mut self) { + self.notifications.fetch_add(1, Ordering::Relaxed); + } + + fn spawn_main(&self, _f: F) + where + F: AsyncFnOnce(Self::Handle, &mut Self::AsyncCx) + 'static, + { + self.spawned.fetch_add(1, Ordering::Relaxed); + } +} + +impl ServiceHandle for NoopHandle { + type AsyncCx = NoopAsyncCx; + + fn update( + &self, + _cx: &mut Self::AsyncCx, + _f: impl FnOnce(&mut ServiceManager, &mut ::ReentryCx<'_>) -> R, + ) -> Option { + None + } +} + +impl ServiceAsyncCx for NoopAsyncCx { + type ReentryCx<'a> = RecordingCx; + + fn spawn_blocking( + &self, + task: impl FnOnce() -> T + Send + 'static, + ) -> impl Future + where + T: Send + 'static, + { + smol::unblock(task) + } + + fn timer(&self, _duration: Duration) -> impl Future { + ready(()) + } +} + +#[derive(Clone)] +struct ExecutingHandle { + manager: Weak>, + executor: Rc>, + notifications: Arc, +} + +struct ExecutingAsyncCx; + +struct ExecutingCx { + handle: ExecutingHandle, +} + +impl ServiceCx for ExecutingCx { + type Handle = ExecutingHandle; + type AsyncCx = ExecutingAsyncCx; + + fn notify(&mut self) { + self.handle.notifications.fetch_add(1, Ordering::Relaxed); + } + + fn spawn_main(&self, f: F) + where + F: AsyncFnOnce(Self::Handle, &mut Self::AsyncCx) + 'static, + { + let handle = self.handle.clone(); + let executor = handle.executor.clone(); + executor + .spawn(async move { + f(handle, &mut ExecutingAsyncCx).await; + }) + .detach(); + } +} + +impl ServiceHandle for ExecutingHandle { + type AsyncCx = ExecutingAsyncCx; + + fn update( + &self, + _cx: &mut Self::AsyncCx, + f: impl FnOnce(&mut ServiceManager, &mut ::ReentryCx<'_>) -> R, + ) -> Option { + let manager = self.manager.upgrade()?; + let mut manager = manager.try_borrow_mut().ok()?; + let mut cx = ExecutingCx { + handle: self.clone(), + }; + Some(f(&mut manager, &mut cx)) + } +} + +impl ServiceAsyncCx for ExecutingAsyncCx { + type ReentryCx<'a> = ExecutingCx; + + fn spawn_blocking( + &self, + task: impl FnOnce() -> T + Send + 'static, + ) -> impl Future + where + T: Send + 'static, + { + smol::unblock(task) + } + + async fn timer(&self, duration: Duration) { + smol::Timer::after(duration).await; + } +} + +struct BarrierDockerRunner { + events: async_channel::Sender, + release_start: async_channel::Receiver<()>, +} + +struct TimeoutDockerRunner { + events: async_channel::Sender, +} + +struct ProjectBarrierDockerRunner { + events: async_channel::Sender<(String, String)>, + releases: async_channel::Receiver<()>, + in_flight: AtomicUsize, + max_in_flight: AtomicUsize, +} + +struct RecordingTerminalBackend { + local: LocalBackend, + plans: async_channel::Sender, +} + +#[test] +fn generation_counter_wraps_without_emitting_zero() { + let mut default_generation = 0; + let mut next_generation = u64::MAX; + + assert_eq!(take_generation(&mut default_generation), 1); + assert_eq!(default_generation, 2); + assert_eq!(take_generation(&mut next_generation), u64::MAX); + assert_eq!(next_generation, 1); + assert_eq!(take_generation(&mut next_generation), 1); + assert_eq!(next_generation, 2); +} + +struct BarrierRestartBackend { + local: LocalBackend, + pid_lookups: async_channel::Sender, + release_pid_lookup: async_channel::Receiver<()>, + kills: async_channel::Sender, + plans: async_channel::Sender, +} + +impl TerminalBackend for RecordingTerminalBackend { + fn transport(&self) -> Arc { + self.local.transport() + } + + fn create_terminal(&self, cwd: &str, shell: Option<&ShellType>) -> anyhow::Result { + self.local.create_terminal(cwd, shell) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + Ok(terminal_id.to_string()) + } + + fn reconnect_terminal_with_plan( + &self, + terminal_id: &str, + _cwd: &str, + plan: &TerminalLaunchPlan, + ) -> anyhow::Result { + self.plans + .send_blocking(plan.clone()) + .expect("record terminal launch plan"); + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.local.kill(terminal_id); + } + + fn capture_buffer(&self, terminal_id: &str) -> Option { + self.local.capture_buffer(terminal_id) + } + + fn supports_buffer_capture(&self) -> bool { + self.local.supports_buffer_capture() + } + + fn is_remote(&self) -> bool { + self.local.is_remote() + } + + fn get_shell_pid(&self, terminal_id: &str) -> Option { + self.local.get_shell_pid(terminal_id) + } + + fn get_service_pids(&self, terminal_id: &str) -> Vec { + self.local.get_service_pids(terminal_id) + } +} + +impl TerminalBackend for BarrierRestartBackend { + fn transport(&self) -> Arc { + self.local.transport() + } + + fn create_terminal(&self, cwd: &str, shell: Option<&ShellType>) -> anyhow::Result { + self.local.create_terminal(cwd, shell) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + _cwd: &str, + _shell: Option<&ShellType>, + ) -> anyhow::Result { + Ok(terminal_id.to_string()) + } + + fn reconnect_terminal_with_plan( + &self, + terminal_id: &str, + _cwd: &str, + plan: &TerminalLaunchPlan, + ) -> anyhow::Result { + self.plans + .send_blocking(plan.clone()) + .expect("record terminal launch plan"); + Ok(terminal_id.to_string()) + } + + fn kill(&self, terminal_id: &str) { + self.kills + .send_blocking(terminal_id.to_string()) + .expect("record terminal kill"); + } + + fn capture_buffer(&self, terminal_id: &str) -> Option { + self.local.capture_buffer(terminal_id) + } + + fn supports_buffer_capture(&self) -> bool { + self.local.supports_buffer_capture() + } + + fn is_remote(&self) -> bool { + self.local.is_remote() + } + + fn get_shell_pid(&self, terminal_id: &str) -> Option { + self.local.get_shell_pid(terminal_id) + } + + fn get_service_pids(&self, terminal_id: &str) -> Vec { + self.pid_lookups + .send_blocking(terminal_id.to_string()) + .expect("record PID lookup"); + self.release_pid_lookup + .recv_blocking() + .expect("release PID lookup"); + Vec::new() + } +} + +impl commands::DockerMutationRunner for BarrierDockerRunner { + fn run(&self, mutation: &DockerMutation) -> crate::ServiceResult<()> { + self.events + .send_blocking(mutation.kind) + .expect("record Docker mutation"); + if mutation.kind == DockerMutationKind::Start { + self.release_start + .recv_blocking() + .expect("release Docker start"); + } + Ok(()) + } +} + +impl commands::DockerMutationRunner for TimeoutDockerRunner { + fn run(&self, mutation: &DockerMutation) -> crate::ServiceResult<()> { + self.events + .send_blocking(mutation.kind) + .expect("record Docker mutation"); + if mutation.kind == DockerMutationKind::Start { + return Err(crate::ServiceError::CommandFailed(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "simulated Docker timeout", + ))); + } + Ok(()) + } +} + +impl commands::DockerMutationRunner for ProjectBarrierDockerRunner { + fn run(&self, mutation: &DockerMutation) -> crate::ServiceResult<()> { + let in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + self.max_in_flight.fetch_max(in_flight, Ordering::SeqCst); + self.events + .send_blocking((mutation.project_id.clone(), mutation.service_name.clone())) + .expect("record Docker mutation"); + self.releases + .recv_blocking() + .expect("release Docker mutation"); + self.in_flight.fetch_sub(1, Ordering::SeqCst); + Ok(()) + } +} + +struct ProjectDir(PathBuf); + +impl ProjectDir { + fn with_config(config: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(1); + let path = std::env::temp_dir().join(format!( + "okena-services-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).expect("create test project"); + std::fs::write(path.join("okena.yaml"), config).expect("write test config"); + Self(path) + } + + fn path(&self) -> String { + self.0.to_string_lossy().into_owned() + } + + fn write_config(&self, config: &str) { + std::fs::write(self.0.join("okena.yaml"), config).expect("rewrite test config"); + } +} + +impl Drop for ProjectDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove test project"); + } +} + +fn manager() -> ServiceManager { + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let backend = Arc::new(LocalBackend::new(Arc::new(pty_manager))); + let terminals: okena_terminal::TerminalsRegistry = Arc::new(Default::default()); + ServiceManager::new(backend, terminals) +} + +fn recording_manager() -> (ServiceManager, async_channel::Receiver) { + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let local = LocalBackend::new(Arc::new(pty_manager)); + let (plans_tx, plans_rx) = async_channel::unbounded(); + let backend = Arc::new(RecordingTerminalBackend { + local, + plans: plans_tx, + }); + let terminals: okena_terminal::TerminalsRegistry = Arc::new(Default::default()); + (ServiceManager::new(backend, terminals), plans_rx) +} fn make_instance( project_id: &str, @@ -65,22 +458,16 @@ fn handle_exit_caps_restarts() { simulate_exit(&mut instance, Some(1)); assert_eq!( instance.status, - ServiceStatus::Crashed { - exit_code: Some(1) - } + ServiceStatus::Crashed { exit_code: Some(1) } ); assert_eq!(instance.restart_count, MAX_RESTART_COUNT); } #[test] fn handle_exit_no_restart() { - let (_key, mut instance) = - make_instance("proj1", "svc1", false, 0, ServiceStatus::Running); + let (_key, mut instance) = make_instance("proj1", "svc1", false, 0, ServiceStatus::Running); simulate_exit(&mut instance, None); - assert_eq!( - instance.status, - ServiceStatus::Crashed { exit_code: None } - ); + assert_eq!(instance.status, ServiceStatus::Crashed { exit_code: None }); assert_eq!(instance.restart_count, 0); // Terminal should be preserved for viewing crash output assert!(instance.terminal_id.is_some()); @@ -95,6 +482,46 @@ fn handle_exit_restart_clears_terminal() { assert!(instance.terminal_id.is_none()); } +#[test] +fn clean_okena_exit_stops_without_scheduling_restart() { + let path = "/project"; + let mut manager = manager(); + manager.project_paths.insert("project".into(), path.into()); + manager.begin_project_incarnation("project", path); + let (key, instance) = make_instance("project", "web", true, 2, ServiceStatus::Running); + let terminal_id = instance.terminal_id.clone().expect("running terminal"); + manager.instances.insert(key.clone(), instance); + manager + .terminal_to_service + .insert(terminal_id.clone(), key.clone()); + let mut cx = RecordingCx::default(); + + assert!(manager.handle_service_exit(&terminal_id, Some(0), &mut cx)); + + assert_eq!(manager.instances[&key].status, ServiceStatus::Stopped); + assert_eq!(manager.instances[&key].terminal_id, None); + assert_eq!(manager.instances[&key].restart_count, 0); + assert!(!manager.terminal_to_service.contains_key(&terminal_id)); + assert_eq!(cx.spawned.load(Ordering::Relaxed), 0); +} + +#[test] +fn scheduled_restart_requires_the_same_restarting_state() { + let mut manager = manager(); + let (key, instance) = make_instance("project", "web", true, 1, ServiceStatus::Restarting); + let scheduled_restart_count = instance.restart_count; + manager.instances.insert(key.clone(), instance); + + assert!(manager.scheduled_okena_restart_is_current(&key, scheduled_restart_count)); + + manager.stop_service("project", "web", &mut RecordingCx::default()); + + assert!( + !manager.scheduled_okena_restart_is_current(&key, scheduled_restart_count), + "a manual stop must invalidate the pending restart" + ); +} + #[test] fn unload_removes_instances() { let mut instances: HashMap<(String, String), ServiceInstance> = HashMap::new(); @@ -145,7 +572,10 @@ fn service_terminal_ids_returns_running_services() { .iter() .filter(|((pid, _), _)| pid == "proj1") .filter_map(|((_, name), instance)| { - instance.terminal_id.as_ref().map(|tid| (name.clone(), tid.clone())) + instance + .terminal_id + .as_ref() + .map(|tid| (name.clone(), tid.clone())) }) .collect(); @@ -156,13 +586,34 @@ fn service_terminal_ids_returns_running_services() { #[test] fn from_api_maps_known_statuses() { - assert_eq!(ServiceStatus::from_api("running", None), ServiceStatus::Running); - assert_eq!(ServiceStatus::from_api("starting", None), ServiceStatus::Starting); - assert_eq!(ServiceStatus::from_api("restarting", None), ServiceStatus::Restarting); - assert_eq!(ServiceStatus::from_api("crashed", None), ServiceStatus::Crashed { exit_code: None }); - assert_eq!(ServiceStatus::from_api("crashed", Some(1)), ServiceStatus::Crashed { exit_code: Some(1) }); - assert_eq!(ServiceStatus::from_api("stopped", None), ServiceStatus::Stopped); - assert_eq!(ServiceStatus::from_api("unknown", None), ServiceStatus::Stopped); + assert_eq!( + ServiceStatus::from_api("running", None), + ServiceStatus::Running + ); + assert_eq!( + ServiceStatus::from_api("starting", None), + ServiceStatus::Starting + ); + assert_eq!( + ServiceStatus::from_api("restarting", None), + ServiceStatus::Restarting + ); + assert_eq!( + ServiceStatus::from_api("crashed", None), + ServiceStatus::Crashed { exit_code: None } + ); + assert_eq!( + ServiceStatus::from_api("crashed", Some(1)), + ServiceStatus::Crashed { exit_code: Some(1) } + ); + assert_eq!( + ServiceStatus::from_api("stopped", None), + ServiceStatus::Stopped + ); + assert_eq!( + ServiceStatus::from_api("unknown", None), + ServiceStatus::Stopped + ); assert_eq!(ServiceStatus::from_api("", None), ServiceStatus::Stopped); } @@ -184,7 +635,9 @@ fn make_docker_instance( (project_id.to_string(), name.to_string()), ServiceInstance { definition: def, - kind: ServiceKind::DockerCompose { compose_file: "docker-compose.yml".to_string() }, + kind: ServiceKind::DockerCompose { + compose_file: "docker-compose.yml".to_string(), + }, status, terminal_id: Some(format!("term-{}", name)), restart_count: 0, @@ -225,7 +678,10 @@ fn docker_service_terminal_ids_excluded() { .iter() .filter(|((pid, _), inst)| pid == "proj1" && inst.kind == ServiceKind::Okena) .filter_map(|((_, name), instance)| { - instance.terminal_id.as_ref().map(|tid| (name.clone(), tid.clone())) + instance + .terminal_id + .as_ref() + .map(|tid| (name.clone(), tid.clone())) }) .collect(); @@ -233,3 +689,1079 @@ fn docker_service_terminal_ids_excluded() { assert!(ids.contains_key("web")); assert!(!ids.contains_key("db")); // Docker service excluded } + +#[test] +fn docker_start_then_stop_dispatches_in_request_order() { + let path = "/project"; + let key = ("project".to_string(), "web".to_string()); + let mut manager = manager(); + manager.project_paths.insert(key.0.clone(), path.into()); + manager.begin_project_incarnation(&key.0, path); + let (_, mut instance) = make_docker_instance(&key.0, &key.1, ServiceStatus::Stopped); + instance.terminal_id = None; + manager.instances.insert(key.clone(), instance); + let mut cx = RecordingCx::default(); + + manager.start_service(&key.0, &key.1, path, &mut cx); + let scope = manager + .docker_mutations + .active + .keys() + .next() + .unwrap() + .clone(); + let active_generation = manager.docker_mutations.active[&scope].current.generation; + assert_eq!(cx.spawned.load(Ordering::Relaxed), 1); + + manager.stop_service(&key.0, &key.1, &mut cx); + + assert_eq!(cx.spawned.load(Ordering::Relaxed), 1); + assert_eq!(manager.instances[&key].status, ServiceStatus::Stopped); + assert_eq!( + manager.docker_mutations.active[&scope] + .pending + .iter() + .map(|mutation| mutation.kind) + .collect::>(), + vec![DockerMutationKind::Stop] + ); + + let stop = manager + .docker_mutations + .finish(&scope, active_generation) + .expect("stop must dispatch after start completes"); + assert_eq!(stop.kind, DockerMutationKind::Stop); + assert!( + manager + .docker_mutations + .finish(&scope, stop.generation) + .is_none() + ); + assert!(!manager.docker_mutations.active.contains_key(&scope)); +} + +#[test] +fn docker_start_then_immediate_stop_waits_for_running_compose_command() { + let path = "/project"; + let key = ("project".to_string(), "web".to_string()); + let (events_tx, events_rx) = async_channel::unbounded(); + let (release_tx, release_rx) = async_channel::bounded(1); + let mut service_manager = manager(); + service_manager.docker_mutation_runner = Arc::new(BarrierDockerRunner { + events: events_tx, + release_start: release_rx, + }); + service_manager + .project_paths + .insert(key.0.clone(), path.into()); + service_manager.begin_project_incarnation(&key.0, path); + let (_, mut instance) = make_docker_instance(&key.0, &key.1, ServiceStatus::Stopped); + instance.terminal_id = None; + service_manager.instances.insert(key.clone(), instance); + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + { + let mut manager = service_manager.borrow_mut(); + manager.start_service(&key.0, &key.1, path, &mut cx); + manager.stop_service(&key.0, &key.1, &mut cx); + } + + assert_eq!(events_rx.recv().await, Ok(DockerMutationKind::Start)); + assert!( + events_rx.try_recv().is_err(), + "stop must not dispatch while compose up is blocked" + ); + + release_tx.send(()).await.expect("release compose up"); + assert_eq!(events_rx.recv().await, Ok(DockerMutationKind::Stop)); + + while service_manager + .borrow() + .docker_mutations + .active + .values() + .any(|active| active.current.project_id == key.0) + { + smol::Timer::after(Duration::from_millis(1)).await; + } + assert_eq!( + service_manager.borrow().instances[&key].status, + ServiceStatus::Stopped + ); + })); +} + +#[test] +fn docker_mutation_timeout_releases_queue_without_overwriting_newer_intent() { + let path = "/project"; + let key = ("project".to_string(), "web".to_string()); + let (events_tx, events_rx) = async_channel::unbounded(); + let mut service_manager = manager(); + service_manager.docker_mutation_runner = Arc::new(TimeoutDockerRunner { events: events_tx }); + service_manager + .project_paths + .insert(key.0.clone(), path.into()); + service_manager.begin_project_incarnation(&key.0, path); + let (_, mut instance) = make_docker_instance(&key.0, &key.1, ServiceStatus::Stopped); + instance.terminal_id = None; + service_manager.instances.insert(key.clone(), instance); + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + { + let mut manager = service_manager.borrow_mut(); + manager.start_service(&key.0, &key.1, path, &mut cx); + manager.stop_service(&key.0, &key.1, &mut cx); + } + + assert_eq!(events_rx.recv().await, Ok(DockerMutationKind::Start)); + assert_eq!(events_rx.recv().await, Ok(DockerMutationKind::Stop)); + while service_manager + .borrow() + .docker_mutations + .active + .values() + .any(|active| active.current.project_id == key.0) + { + smol::Timer::after(Duration::from_millis(1)).await; + } + assert_eq!( + service_manager.borrow().instances[&key].status, + ServiceStatus::Stopped + ); + })); +} + +#[test] +fn docker_start_all_timeouts_restore_each_stopped_status() { + let path = "/project"; + let project_id = "project"; + let (events_tx, events_rx) = async_channel::unbounded(); + let mut service_manager = manager(); + service_manager.docker_mutation_runner = Arc::new(TimeoutDockerRunner { events: events_tx }); + service_manager + .project_paths + .insert(project_id.into(), path.into()); + service_manager.begin_project_incarnation(project_id, path); + for name in ["web", "worker"] { + let (key, instance) = make_docker_instance(project_id, name, ServiceStatus::Stopped); + service_manager.instances.insert(key, instance); + } + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + service_manager + .borrow_mut() + .start_all(project_id, path, &mut cx); + + assert_eq!(events_rx.recv().await, Ok(DockerMutationKind::Start)); + assert_eq!(events_rx.recv().await, Ok(DockerMutationKind::Start)); + while service_manager + .borrow() + .docker_mutations + .active + .values() + .any(|active| active.current.project_id == project_id) + { + smol::Timer::after(Duration::from_millis(1)).await; + } + for name in ["web", "worker"] { + assert_eq!( + service_manager.borrow().instances[&(project_id.into(), name.into())].status, + ServiceStatus::Stopped + ); + } + })); +} + +#[test] +fn docker_start_all_serializes_mutations_for_one_compose_project() { + let path = "/project"; + let project_id = "project"; + let (events_tx, events_rx) = async_channel::unbounded(); + let (release_tx, release_rx) = async_channel::unbounded(); + let runner = Arc::new(ProjectBarrierDockerRunner { + events: events_tx, + releases: release_rx, + in_flight: AtomicUsize::new(0), + max_in_flight: AtomicUsize::new(0), + }); + let mut service_manager = manager(); + service_manager.docker_mutation_runner = runner.clone(); + service_manager + .project_paths + .insert(project_id.into(), path.into()); + service_manager.begin_project_incarnation(project_id, path); + for name in ["web", "worker"] { + let (key, instance) = make_docker_instance(project_id, name, ServiceStatus::Stopped); + service_manager.instances.insert(key, instance); + } + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + service_manager + .borrow_mut() + .start_all(project_id, path, &mut cx); + + let first = events_rx.recv().await.expect("first Docker mutation"); + assert!(events_rx.try_recv().is_err()); + release_tx.send(()).await.expect("release first mutation"); + let second = events_rx.recv().await.expect("second Docker mutation"); + assert_ne!(first.1, second.1); + release_tx.send(()).await.expect("release second mutation"); + + while !service_manager.borrow().docker_mutations.active.is_empty() { + smol::Timer::after(Duration::from_millis(1)).await; + } + assert_eq!(runner.max_in_flight.load(Ordering::SeqCst), 1); + })); +} + +#[test] +fn docker_mutations_for_unrelated_compose_projects_can_overlap() { + let (events_tx, events_rx) = async_channel::unbounded(); + let (release_tx, release_rx) = async_channel::unbounded(); + let runner = Arc::new(ProjectBarrierDockerRunner { + events: events_tx, + releases: release_rx, + in_flight: AtomicUsize::new(0), + max_in_flight: AtomicUsize::new(0), + }); + let mut service_manager = manager(); + service_manager.docker_mutation_runner = runner.clone(); + for (project_id, path) in [("project-a", "/project-a"), ("project-b", "/project-b")] { + service_manager + .project_paths + .insert(project_id.into(), path.into()); + service_manager.begin_project_incarnation(project_id, path); + let (key, instance) = make_docker_instance(project_id, "web", ServiceStatus::Stopped); + service_manager.instances.insert(key, instance); + } + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + { + let mut manager = service_manager.borrow_mut(); + manager.start_service("project-a", "web", "/project-a", &mut cx); + manager.start_service("project-b", "web", "/project-b", &mut cx); + } + + let first = events_rx.recv().await.expect("first Docker mutation"); + let second = events_rx.recv().await.expect("overlapping Docker mutation"); + assert_ne!(first.0, second.0); + assert_eq!(runner.in_flight.load(Ordering::SeqCst), 2); + release_tx.send(()).await.expect("release first mutation"); + release_tx.send(()).await.expect("release second mutation"); + + while !service_manager.borrow().docker_mutations.active.is_empty() { + smol::Timer::after(Duration::from_millis(1)).await; + } + assert_eq!(runner.max_in_flight.load(Ordering::SeqCst), 2); + })); +} + +#[test] +fn project_unload_discards_queued_docker_mutations() { + let path = "/project"; + let project_id = "project"; + let (events_tx, events_rx) = async_channel::unbounded(); + let (release_tx, release_rx) = async_channel::unbounded(); + let runner = Arc::new(ProjectBarrierDockerRunner { + events: events_tx, + releases: release_rx, + in_flight: AtomicUsize::new(0), + max_in_flight: AtomicUsize::new(0), + }); + let mut service_manager = manager(); + service_manager.docker_mutation_runner = runner; + service_manager + .project_paths + .insert(project_id.into(), path.into()); + service_manager.begin_project_incarnation(project_id, path); + for name in ["web", "worker"] { + let (key, instance) = make_docker_instance(project_id, name, ServiceStatus::Stopped); + service_manager.instances.insert(key, instance); + } + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + service_manager + .borrow_mut() + .start_all(project_id, path, &mut cx); + events_rx.recv().await.expect("first Docker mutation"); + service_manager + .borrow_mut() + .unload_project_services(project_id, &mut cx); + let activities = service_manager.borrow().compose_mutations_for( + &HashSet::from([project_id.to_string()]), + &HashSet::from([ComposeProjectIdentity::new(path, "docker-compose.yml")]), + ); + assert_eq!(activities.len(), 2); + assert_eq!( + activities.iter().filter(|activity| activity.queued).count(), + 1 + ); + release_tx.send(()).await.expect("release active mutation"); + + while !service_manager.borrow().docker_mutations.active.is_empty() { + smol::Timer::after(Duration::from_millis(1)).await; + } + assert!( + service_manager + .borrow() + .compose_mutations_for( + &HashSet::from([project_id.to_string()]), + &HashSet::from([ComposeProjectIdentity::new(path, "docker-compose.yml")]), + ) + .is_empty() + ); + assert!(events_rx.try_recv().is_err()); + })); +} + +#[test] +fn docker_mutations_remain_serialized_across_project_incarnations() { + let key = ("project".to_string(), "web".to_string()); + let mut queue = DockerMutationQueue::default(); + let first_incarnation = ProjectIncarnation { + generation: 1, + path: "/repo".into(), + }; + let replacement_incarnation = ProjectIncarnation { + generation: 2, + path: "/repo".into(), + }; + + let start = queue + .enqueue( + key.clone(), + Some(first_incarnation), + "/repo".into(), + "compose.yml".into(), + DockerMutationKind::Start, + ) + .expect("first mutation dispatches immediately"); + assert!( + queue + .enqueue( + key.clone(), + Some(replacement_incarnation.clone()), + "/repo".into(), + "compose.yml".into(), + DockerMutationKind::Stop, + ) + .is_none(), + "replacement work must not race the active mutation" + ); + + let scope = start.scope(); + let stop = queue + .finish(&scope, start.generation) + .expect("replacement stop dispatches after the old mutation drains"); + assert_eq!(stop.kind, DockerMutationKind::Stop); + assert_eq!(stop.project_incarnation, Some(replacement_incarnation)); +} + +#[test] +fn docker_mutation_scope_follows_compose_path_across_workspace_projects() { + let mut queue = DockerMutationQueue::default(); + let first = queue + .enqueue( + ("project-a".into(), "web".into()), + None, + "/repo".into(), + "compose.yml".into(), + DockerMutationKind::Start, + ) + .expect("first Compose mutation dispatches"); + + assert!( + queue + .enqueue( + ("project-b".into(), "worker".into()), + None, + "/repo".into(), + "compose.yml".into(), + DockerMutationKind::Stop, + ) + .is_none(), + "the same Compose project must have one external mutation owner" + ); + assert_eq!(queue.active[&first.scope()].pending.len(), 1); +} + +#[test] +fn compose_mutation_activity_reports_active_and_queued_across_incarnations() { + let project = ProjectDir::with_config(""); + std::fs::write(project.0.join("compose.yml"), "services: {}").expect("write Compose config"); + let project_path = project.path(); + let mut manager = manager(); + let old_incarnation = manager.begin_project_incarnation("old-project", &project_path); + let active = manager + .docker_mutations + .enqueue( + ("old-project".into(), "web".into()), + Some(old_incarnation), + project_path.clone(), + "compose.yml".into(), + DockerMutationKind::Start, + ) + .expect("first mutation is active"); + manager.invalidate_project_incarnation("old-project"); + let replacement = manager.begin_project_incarnation("replacement", &project_path); + assert!( + manager + .docker_mutations + .enqueue( + ("replacement".into(), "worker".into()), + Some(replacement), + project_path.clone(), + "compose.yml".into(), + DockerMutationKind::Stop, + ) + .is_none() + ); + + let activities = manager.compose_mutations_for( + &HashSet::new(), + &HashSet::from([ComposeProjectIdentity::new(&project_path, "compose.yml")]), + ); + assert_eq!(activities.len(), 2); + assert_eq!(activities[0].project_id, "old-project"); + assert_eq!(activities[0].kind, DockerMutationKind::Start); + assert!(!activities[0].queued); + assert_eq!(activities[1].project_id, "replacement"); + assert_eq!(activities[1].kind, DockerMutationKind::Stop); + assert!(activities[1].queued); + + let scope = active.scope(); + let queued = manager + .docker_mutations + .finish(&scope, active.generation) + .expect("queued mutation becomes active"); + manager.docker_mutations.finish(&scope, queued.generation); + assert!( + manager + .compose_mutations_for( + &HashSet::from(["old-project".to_string(), "replacement".to_string()]), + &HashSet::new(), + ) + .is_empty() + ); +} + +#[test] +fn compose_mutation_activity_ignores_unrelated_targets() { + let mut queue = DockerMutationQueue::default(); + let active = queue + .enqueue( + ("project".into(), "web".into()), + None, + "/repo".into(), + "compose.yml".into(), + DockerMutationKind::Start, + ) + .expect("first mutation is active"); + let mut manager = manager(); + manager.docker_mutations = queue; + + assert!( + manager + .compose_mutations_for( + &HashSet::from(["other".to_string()]), + &HashSet::from([ComposeProjectIdentity::new("/other", "compose.yml")]), + ) + .is_empty() + ); + manager + .docker_mutations + .finish(&active.scope(), active.generation); +} + +#[test] +fn project_reload_invalidates_old_async_incarnation() { + let mut lifecycles = ProjectLifecycles::default(); + let old = lifecycles.begin("project", "/repo"); + assert!(lifecycles.is_current("project", &old)); + + let replacement = lifecycles.begin("project", "/repo"); + + assert!(!lifecycles.is_current("project", &old)); + assert!(lifecycles.is_current("project", &replacement)); + assert_eq!( + lifecycles.get("project", "/repo"), + Some(replacement.clone()) + ); + assert_ne!(old, replacement); +} + +#[test] +fn project_incarnation_rejects_reused_id_at_different_path() { + let mut lifecycles = ProjectLifecycles::default(); + let old = lifecycles.begin("project", "/old"); + let replacement = lifecycles.begin("project", "/new"); + + assert!(!lifecycles.is_current("project", &old)); + assert!(lifecycles.get("project", "/old").is_none()); + assert_eq!(lifecycles.get("project", "/new"), Some(replacement)); +} + +#[test] +fn project_unload_invalidates_delayed_callbacks() { + let mut lifecycles = ProjectLifecycles::default(); + let loaded = lifecycles.begin("project", "/repo"); + + lifecycles.invalidate("project"); + + assert!(!lifecycles.is_current("project", &loaded)); + assert!(lifecycles.get("project", "/repo").is_none()); +} + +#[test] +fn malformed_reload_preserves_current_incarnation() { + let project = ProjectDir::with_config("services: ["); + let path = project.path(); + let mut manager = manager(); + manager.project_paths.insert("project".into(), path.clone()); + let incarnation = manager.begin_project_incarnation("project", &path); + let mut cx = RecordingCx::default(); + + manager.reload_project_services("project", &path, &mut cx); + + assert_eq!( + manager.project_incarnation("project", &path), + Some(incarnation) + ); + assert_eq!(cx.spawned.load(Ordering::Relaxed), 0); +} + +#[test] +fn prepared_reload_does_not_probe_the_project_path() { + let path = "/path/that/does/not/exist"; + let mut manager = manager(); + manager.project_paths.insert("project".into(), path.into()); + manager.begin_project_incarnation("project", path); + let mut cx = RecordingCx::default(); + + let status = manager.reload_project_services_prepared( + "project", + path, + PreparedProjectConfig::Loaded { + config: Some(OkenaProjectConfig { + services: vec![ServiceDefinition { + name: "prepared".into(), + command: "echo prepared".into(), + cwd: ".".into(), + env: HashMap::new(), + auto_start: false, + restart_on_crash: false, + restart_delay_ms: 1000, + }], + docker_compose: None, + }), + detected_compose_file: None, + }, + &mut cx, + ); + + assert_eq!(status, ServiceLoadStatus::Loaded); + assert!( + manager + .instances() + .contains_key(&("project".into(), "prepared".into())) + ); +} + +#[test] +fn reload_replaces_docker_name_collision_with_okena_service() { + let path = "/project"; + let key = ("project".to_string(), "web".to_string()); + let mut manager = manager(); + manager.project_paths.insert(key.0.clone(), path.into()); + manager.begin_project_incarnation(&key.0, path); + let (_, docker) = make_docker_instance(&key.0, &key.1, ServiceStatus::Running); + let old_terminal_id = docker.terminal_id.clone().expect("Docker log terminal"); + manager.instances.insert(key.clone(), docker); + manager + .terminal_to_service + .insert(old_terminal_id.clone(), key.clone()); + let mut cx = RecordingCx::default(); + + let status = manager.reload_project_services_prepared( + &key.0, + path, + PreparedProjectConfig::Loaded { + config: Some(OkenaProjectConfig { + services: vec![ServiceDefinition { + name: key.1.clone(), + command: "bun run dev".into(), + cwd: ".".into(), + env: HashMap::new(), + auto_start: false, + restart_on_crash: false, + restart_delay_ms: 1000, + }], + docker_compose: None, + }), + detected_compose_file: None, + }, + &mut cx, + ); + + assert_eq!(status, ServiceLoadStatus::Loaded); + assert_eq!(manager.instances[&key].kind, ServiceKind::Okena); + assert_eq!(manager.instances[&key].terminal_id, None); + assert!(!manager.terminal_to_service.contains_key(&old_terminal_id)); +} + +#[test] +fn malformed_initial_load_reports_failure_without_claiming_path() { + let project = ProjectDir::with_config("services: ["); + let path = project.path(); + let mut manager = manager(); + let mut cx = RecordingCx::default(); + + let status = manager.load_project_services("project", &path, &HashMap::new(), &mut cx); + + assert_eq!(status, ServiceLoadStatus::Failed); + assert_eq!(manager.project_path("project"), None); + assert_eq!(manager.project_incarnation("project", &path), None); + assert_eq!(cx.notifications.load(Ordering::Relaxed), 1); +} + +#[test] +fn empty_loaded_project_publishes_an_explicit_empty_writeback() { + let project = ProjectDir::with_config("services: []\n"); + let path = project.path(); + let mut manager = manager(); + let mut cx = RecordingCx::default(); + manager.set_project_writeback_owner("project", &path, 7); + + let status = manager.load_project_services("project", &path, &HashMap::new(), &mut cx); + + assert_eq!(status, ServiceLoadStatus::Loaded); + assert_eq!( + manager.service_terminal_writebacks(), + vec![ServiceTerminalWriteback { + project_id: "project".into(), + project_path: path, + data_replacement_epoch: 7, + terminal_ids: HashMap::new(), + }] + ); + assert!(cx.notifications.load(Ordering::Relaxed) > 0); +} + +#[test] +fn backend_migration_reload_does_not_restart_a_stopped_auto_start_service() { + let project = ProjectDir::with_config( + "services:\n - name: web\n command: echo web\n auto_start: true\n", + ); + let path = project.path(); + let mut manager = manager(); + let mut cx = RecordingCx::default(); + + let status = manager.load_project_services_for_backend_migration("project", &path, &mut cx); + + assert_eq!(status, ServiceLoadStatus::Loaded); + assert_eq!( + manager + .instances() + .get(&("project".to_string(), "web".to_string())) + .map(|instance| &instance.status), + Some(&ServiceStatus::Stopped) + ); + assert_eq!(cx.spawned.load(Ordering::Relaxed), 0); +} + +#[test] +fn initial_okena_launch_applies_service_environment() { + let path = "/project"; + let (service_manager, plans) = recording_manager(); + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(service_manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + service_manager.borrow_mut().load_project_services_prepared( + "project", + path, + &HashMap::new(), + PreparedProjectConfig::Loaded { + config: Some(OkenaProjectConfig { + services: vec![ServiceDefinition { + name: "web".into(), + command: "bun run dev".into(), + cwd: ".".into(), + env: HashMap::from([ + ("PORT".into(), "4100".into()), + ("NODE_ENV".into(), "development".into()), + ]), + auto_start: true, + restart_on_crash: false, + restart_delay_ms: 1000, + }], + docker_compose: None, + }), + detected_compose_file: None, + }, + &mut cx, + ); + + let plan = plans.recv().await.expect("initial service launch plan"); + assert_eq!( + plan.environment, + vec![ + ("NODE_ENV".into(), "development".into()), + ("PORT".into(), "4100".into()), + ] + ); + })); +} + +#[test] +fn successful_reload_rearms_restart_and_port_detection() { + let project = ProjectDir::with_config( + "services:\n - name: running\n command: echo running\n - name: restarting\n command: echo restarting\n restart_on_crash: true\n restart_delay_ms: 25\n", + ); + let path = project.path(); + let mut manager = manager(); + manager.project_paths.insert("project".into(), path.clone()); + let old_incarnation = manager.begin_project_incarnation("project", &path); + let (running_key, running) = + make_instance("project", "running", false, 0, ServiceStatus::Running); + let (restarting_key, mut restarting) = + make_instance("project", "restarting", true, 1, ServiceStatus::Restarting); + restarting.terminal_id = None; + manager.instances.insert(running_key.clone(), running); + manager.instances.insert(restarting_key.clone(), restarting); + let mut cx = RecordingCx::default(); + + manager.reload_project_services("project", &path, &mut cx); + + let new_incarnation = manager + .project_incarnation("project", &path) + .expect("replacement incarnation"); + assert_ne!(new_incarnation, old_incarnation); + assert_eq!( + manager + .port_detection_active + .get(&running_key) + .map(|state| &state.project_incarnation), + Some(&new_incarnation) + ); + assert_eq!( + manager.instances[&restarting_key].status, + ServiceStatus::Restarting + ); + assert_eq!(cx.spawned.load(Ordering::Relaxed), 2); +} + +#[test] +fn starting_launch_completes_after_same_project_reload() { + let path = "/project"; + let definition = ServiceDefinition { + name: "web".into(), + command: "echo web".into(), + cwd: ".".into(), + env: HashMap::new(), + auto_start: false, + restart_on_crash: false, + restart_delay_ms: 1000, + }; + let prepared = || PreparedProjectConfig::Loaded { + config: Some(OkenaProjectConfig { + services: vec![definition.clone()], + docker_compose: None, + }), + detected_compose_file: None, + }; + let mut manager = manager(); + let mut cx = RecordingCx::default(); + manager.load_project_services_prepared("project", path, &HashMap::new(), prepared(), &mut cx); + + manager.start_service("project", "web", path, &mut cx); + let key = ("project".to_string(), "web".to_string()); + let terminal_id = manager.instances[&key] + .terminal_id + .clone() + .expect("pending terminal id"); + let launch_token = manager.pending_okena_launches[&key].clone(); + assert_eq!(manager.instances[&key].status, ServiceStatus::Starting); + + manager.reload_project_services_prepared("project", path, prepared(), &mut cx); + + assert_eq!( + manager.pending_okena_launches.get(&key), + Some(&launch_token) + ); + assert!(manager.complete_okena_terminal_launch( + &key, + &launch_token, + &terminal_id, + path, + &mut cx, + )); + assert_eq!(manager.instances[&key].status, ServiceStatus::Running); + assert!(manager.terminals.lock().contains_key(&terminal_id)); + assert_eq!(manager.terminal_to_service.get(&terminal_id), Some(&key)); + assert!(!manager.pending_okena_launches.contains_key(&key)); +} + +#[test] +fn reload_drains_restart_while_pid_lookup_is_pending() { + let path = "/project"; + let key = ("project".to_string(), "web".to_string()); + let old_terminal_id = "old-service-terminal".to_string(); + let (pty_manager, _events) = PtyManager::new(SessionBackend::None); + let (pid_lookups_tx, pid_lookups_rx) = async_channel::unbounded(); + let (release_pid_lookup_tx, release_pid_lookup_rx) = async_channel::bounded(1); + let (kills_tx, kills_rx) = async_channel::unbounded(); + let (plans_tx, plans_rx) = async_channel::unbounded(); + let backend = Arc::new(BarrierRestartBackend { + local: LocalBackend::new(Arc::new(pty_manager)), + pid_lookups: pid_lookups_tx, + release_pid_lookup: release_pid_lookup_rx, + kills: kills_tx, + plans: plans_tx, + }); + let terminals: okena_terminal::TerminalsRegistry = Arc::new(Default::default()); + let mut manager = ServiceManager::new(backend, terminals); + manager.project_paths.insert(key.0.clone(), path.into()); + manager.begin_project_incarnation(&key.0, path); + let (_, mut instance) = make_instance(&key.0, &key.1, false, 0, ServiceStatus::Running); + instance.terminal_id = Some(old_terminal_id.clone()); + manager.instances.insert(key.clone(), instance); + manager + .terminal_to_service + .insert(old_terminal_id.clone(), key.clone()); + + let executor = Rc::new(smol::LocalExecutor::new()); + let service_manager = Rc::new(RefCell::new(manager)); + let handle = ExecutingHandle { + manager: Rc::downgrade(&service_manager), + executor: executor.clone(), + notifications: Arc::new(AtomicUsize::new(0)), + }; + + smol::block_on(executor.run(async { + let mut cx = ExecutingCx { handle }; + service_manager + .borrow_mut() + .restart_service(&key.0, &key.1, path, &mut cx); + + assert_eq!(pid_lookups_rx.recv().await, Ok(old_terminal_id.clone())); + assert!( + service_manager + .borrow() + .pending_okena_restarts + .contains_key(&key) + ); + assert!( + !service_manager + .borrow() + .terminal_to_service + .contains_key(&old_terminal_id), + "the pending restart ledger must own the old mapping" + ); + + service_manager + .borrow_mut() + .reload_project_services_prepared( + &key.0, + path, + PreparedProjectConfig::Loaded { + config: Some(OkenaProjectConfig { + services: vec![ServiceDefinition { + name: key.1.clone(), + command: "echo web".into(), + cwd: ".".into(), + env: HashMap::new(), + auto_start: false, + restart_on_crash: false, + restart_delay_ms: 60_000, + }], + docker_compose: None, + }), + detected_compose_file: None, + }, + &mut cx, + ); + + assert_eq!(kills_rx.recv().await, Ok(old_terminal_id.clone())); + assert!( + !service_manager + .borrow() + .pending_okena_restarts + .contains_key(&key) + ); + + release_pid_lookup_tx + .send(()) + .await + .expect("release pending PID lookup"); + smol::Timer::after(Duration::from_millis(5)).await; + assert!( + kills_rx.try_recv().is_err(), + "the stale callback must not kill a replacement reusing the drained ID" + ); + assert!( + plans_rx.try_recv().is_err(), + "the stale manual restart must not launch a duplicate service" + ); + })); +} + +#[test] +fn replacement_launch_rejects_completion_from_reused_terminal_id() { + let path = "/project"; + let mut manager = manager(); + let mut cx = RecordingCx::default(); + manager.project_paths.insert("project".into(), path.into()); + manager.begin_project_incarnation("project", path); + let (key, mut instance) = make_instance("project", "web", false, 0, ServiceStatus::Stopped); + instance.terminal_id = None; + manager.instances.insert(key.clone(), instance); + + manager.begin_okena_terminal_launch( + "project", + "web", + path, + "reused-terminal".into(), + OkenaLaunchFailure::Crashed, + &mut cx, + ); + let stale_token = manager.pending_okena_launches[&key].clone(); + manager.begin_okena_terminal_launch( + "project", + "web", + path, + "reused-terminal".into(), + OkenaLaunchFailure::Crashed, + &mut cx, + ); + let current_token = manager.pending_okena_launches[&key].clone(); + + assert_ne!(stale_token, current_token); + assert!(!manager.complete_okena_terminal_launch( + &key, + &stale_token, + "reused-terminal", + path, + &mut cx, + )); + assert!(manager.complete_okena_terminal_launch( + &key, + ¤t_token, + "reused-terminal", + path, + &mut cx, + )); + assert_eq!(manager.instances[&key].status, ServiceStatus::Running); +} + +#[test] +fn project_path_update_reloads_runtime_under_new_path() { + let project = ProjectDir::with_config("services:\n - name: web\n command: echo web\n"); + let new_path = project.path(); + let mut manager = manager(); + manager + .project_paths + .insert("project".into(), "/old/project/path".into()); + let old_incarnation = manager.begin_project_incarnation("project", "/old/project/path"); + let (key, instance) = make_instance("project", "web", false, 0, ServiceStatus::Running); + manager.instances.insert(key.clone(), instance); + let mut cx = RecordingCx::default(); + + assert!(manager.update_project_path("project", &new_path, &mut cx)); + + let new_incarnation = manager + .project_incarnation("project", &new_path) + .expect("new-path incarnation"); + assert_ne!(new_incarnation, old_incarnation); + assert_eq!(manager.project_path("project"), Some(&new_path)); + assert_eq!( + manager + .port_detection_active + .get(&key) + .map(|state| &state.project_incarnation), + Some(&new_incarnation) + ); + assert_eq!(cx.spawned.load(Ordering::Relaxed), 1); +} + +#[test] +fn project_path_update_retries_after_parse_failure() { + let project = ProjectDir::with_config("services: ["); + let new_path = project.path(); + let mut manager = manager(); + manager + .project_paths + .insert("project".into(), "/old/project/path".into()); + manager.begin_project_incarnation("project", "/old/project/path"); + let mut cx = RecordingCx::default(); + + assert!(!manager.update_project_path("project", &new_path, &mut cx)); + assert_eq!( + manager.project_path("project").map(String::as_str), + Some("/old/project/path") + ); + + project.write_config("services: []\n"); + assert!(manager.update_project_path("project", &new_path, &mut cx)); + assert_eq!(manager.project_path("project"), Some(&new_path)); +} diff --git a/crates/okena-services/src/port_detect.rs b/crates/okena-services/src/port_detect.rs index b49fc9fc3..7eb2644b1 100644 --- a/crates/okena-services/src/port_detect.rs +++ b/crates/okena-services/src/port_detect.rs @@ -97,9 +97,10 @@ fn build_process_tree_linux() -> HashMap> { let fields: Vec<&str> = stat[after_comm + 2..].split_whitespace().collect(); // fields[0] = state, fields[1] = ppid if let Some(ppid_str) = fields.get(1) - && let Ok(ppid) = ppid_str.parse::() { - tree.entry(ppid).or_default().push(pid); - } + && let Ok(ppid) = ppid_str.parse::() + { + tree.entry(ppid).or_default().push(pid); + } } } @@ -245,7 +246,9 @@ fn extract_pids_from_ss_line(line: &str) -> Vec { let mut search = line; while let Some(pos) = search.find("pid=") { let after = &search[pos + 4..]; - let num_end = after.find(|c: char| !c.is_ascii_digit()).unwrap_or(after.len()); + let num_end = after + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(after.len()); if let Ok(pid) = after[..num_end].parse::() { pids.push(pid); } @@ -266,7 +269,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) { @@ -281,9 +287,10 @@ pub(crate) fn parse_lsof_output(stdout: &str) -> Vec<(u32, u16)> { // NAME field like "*:5173" or "127.0.0.1:3000" let name = fields[8]; if let Some(port_str) = name.rsplit(':').next() - && let Ok(port) = port_str.parse::() { - pairs.push((pid, port)); - } + && let Ok(port) = port_str.parse::() + { + pairs.push((pid, port)); + } } pairs } @@ -307,9 +314,10 @@ pub(crate) fn parse_netstat_output(stdout: &str) -> Vec<(u32, u16)> { }; let local_addr = fields[1]; if let Some(port_str) = local_addr.rsplit(':').next() - && let Ok(port) = port_str.parse::() { - pairs.push((pid, port)); - } + && let Ok(port) = port_str.parse::() + { + pairs.push((pid, port)); + } } pairs } @@ -402,13 +410,7 @@ Active Connections #[test] fn filtering_removes_debug_and_ephemeral_ports() { - let pairs = vec![ - (1, 5173), - (1, 9229), - (1, 36435), - (1, 37903), - (1, 3000), - ]; + let pairs = vec![(1, 5173), (1, 9229), (1, 36435), (1, 37903), (1, 3000)]; let pids: HashSet = [1].into_iter().collect(); let ports = ports_for_pids(&pairs, &pids); assert_eq!(ports, vec![3000, 5173]); @@ -428,11 +430,7 @@ LISTEN 0 128 127.0.0.1:3000 0.0.0.0:* users:((\"cargo\",pid #[test] fn ports_for_pids_shared_port_list() { // Two services sharing the same port scan results - let pairs = vec![ - (100, 5173), - (200, 3000), - (300, 8080), - ]; + let pairs = vec![(100, 5173), (200, 3000), (300, 8080)]; let pids_a: HashSet = [100].into_iter().collect(); let pids_b: HashSet = [200, 300].into_iter().collect(); assert_eq!(ports_for_pids(&pairs, &pids_a), vec![5173]); diff --git a/crates/okena-state/src/hooks_config.rs b/crates/okena-state/src/hooks_config.rs index bb273e948..803ace0b2 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,12 +64,69 @@ 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", - "pre_merge", "post_merge", - "before_worktree_remove", "worktree_removed", - "on_rebase_conflict", "on_dirty_worktree_close", + "on_project_open", + "on_project_close", + "on_worktree_create", + "on_worktree_close", + "pre_merge", + "post_merge", + "before_worktree_remove", + "worktree_removed", + "on_rebase_conflict", + "on_dirty_worktree_close", ]; impl<'de> Deserialize<'de> for HooksConfig { @@ -80,7 +137,9 @@ impl<'de> Deserialize<'de> for HooksConfig { None => return Ok(HooksConfig::default()), }; - let is_new_format = obj.contains_key("project") || obj.contains_key("terminal") || obj.contains_key("worktree"); + let is_new_format = obj.contains_key("project") + || obj.contains_key("terminal") + || obj.contains_key("worktree"); let has_flat_keys = !is_new_format && FLAT_HOOK_KEYS.iter().any(|k| obj.contains_key(*k)); if has_flat_keys { @@ -106,15 +165,21 @@ impl<'de> Deserialize<'de> for HooksConfig { }) } else { let deser = |key: &str| -> serde_json::Value { - obj.get(key).cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new())) + obj.get(key) + .cloned() + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())) }; - let project: ProjectHooks = serde_json::from_value(deser("project")) - .unwrap_or_default(); - let terminal: TerminalHooks = serde_json::from_value(deser("terminal")) - .unwrap_or_default(); - let worktree: WorktreeHooks = serde_json::from_value(deser("worktree")) - .unwrap_or_default(); - Ok(HooksConfig { project, terminal, worktree }) + let project: ProjectHooks = + serde_json::from_value(deser("project")).unwrap_or_default(); + let terminal: TerminalHooks = + serde_json::from_value(deser("terminal")).unwrap_or_default(); + let worktree: WorktreeHooks = + serde_json::from_value(deser("worktree")).unwrap_or_default(); + Ok(HooksConfig { + project, + terminal, + worktree, + }) } } } @@ -147,10 +212,22 @@ mod tests { assert_eq!(config.worktree.on_close.as_deref(), Some("echo wt-close")); assert_eq!(config.worktree.pre_merge.as_deref(), Some("echo pre")); assert_eq!(config.worktree.post_merge.as_deref(), Some("echo post")); - assert_eq!(config.worktree.before_remove.as_deref(), Some("echo before-rm")); - assert_eq!(config.worktree.after_remove.as_deref(), Some("echo after-rm")); - assert_eq!(config.worktree.on_rebase_conflict.as_deref(), Some("echo rebase")); - assert_eq!(config.worktree.on_dirty_close.as_deref(), Some("echo dirty")); + assert_eq!( + config.worktree.before_remove.as_deref(), + Some("echo before-rm") + ); + assert_eq!( + config.worktree.after_remove.as_deref(), + Some("echo after-rm") + ); + assert_eq!( + config.worktree.on_rebase_conflict.as_deref(), + Some("echo rebase") + ); + assert_eq!( + config.worktree.on_dirty_close.as_deref(), + Some("echo dirty") + ); // Terminal section never existed in the legacy format. assert_eq!(config.terminal, TerminalHooks::default()); } @@ -179,7 +256,10 @@ mod tests { assert_eq!(config.project.on_open.as_deref(), Some("echo open")); assert_eq!(config.project.on_close.as_deref(), Some("echo close")); assert_eq!(config.terminal.on_create.as_deref(), Some("echo tc")); - assert_eq!(config.terminal.shell_wrapper.as_deref(), Some("wrap {shell}")); + assert_eq!( + config.terminal.shell_wrapper.as_deref(), + Some("wrap {shell}") + ); assert_eq!(config.worktree.on_create.as_deref(), Some("echo wtc")); assert_eq!(config.worktree.pre_merge.as_deref(), Some("echo pm")); } @@ -216,11 +296,23 @@ mod tests { let json = serde_json::to_value(&config).unwrap(); // is_default subgroups are skipped on serialization. - assert!(json.get("terminal").is_none(), "default terminal should be omitted"); - assert!(json.get("worktree").is_none(), "default worktree should be omitted"); + assert!( + json.get("terminal").is_none(), + "default terminal should be omitted" + ); + assert!( + json.get("worktree").is_none(), + "default worktree should be omitted" + ); let project = json.get("project").expect("project group present"); - assert_eq!(project.get("on_open").and_then(|v| v.as_str()), Some("setup")); - assert!(project.get("on_close").is_none(), "None on_close should be omitted"); + assert_eq!( + project.get("on_open").and_then(|v| v.as_str()), + Some("setup") + ); + assert!( + project.get("on_close").is_none(), + "None on_close should be omitted" + ); } #[test] @@ -236,9 +328,18 @@ mod tests { let serialized = serde_json::to_string(&config).unwrap(); // The serialized output must be in the new grouped format. - assert!(serialized.contains("\"project\""), "expected grouped 'project' key"); - assert!(serialized.contains("\"worktree\""), "expected grouped 'worktree' key"); - assert!(!serialized.contains("\"on_project_open\""), "legacy keys must not survive"); + assert!( + serialized.contains("\"project\""), + "expected grouped 'project' key" + ); + assert!( + serialized.contains("\"worktree\""), + "expected grouped 'worktree' key" + ); + assert!( + !serialized.contains("\"on_project_open\""), + "legacy keys must not survive" + ); // And re-parsing must yield the same content. let reparsed: HooksConfig = serde_json::from_str(&serialized).unwrap(); @@ -268,4 +369,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..36dba40c8 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,88 @@ 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/window_state.rs b/crates/okena-state/src/window_state.rs index 891060764..f4a3d5ef6 100644 --- a/crates/okena-state/src/window_state.rs +++ b/crates/okena-state/src/window_state.rs @@ -5,6 +5,7 @@ //! filter, per-project column widths, sidebar folder-collapse map, and OS //! window bounds. Pure data — see PRD `plans/multi-window.md`. +use okena_core::types::SplitDirection; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; @@ -39,6 +40,15 @@ impl ProjectLayoutMode { pub fn is_rows(self) -> bool { matches!(self, ProjectLayoutMode::Rows) } + + /// Transform the daemon's canonical split direction for this window. + pub fn presented_split_direction(self, direction: SplitDirection) -> SplitDirection { + if self.is_rows() { + direction.flipped() + } else { + direction + } + } } /// How the sidebar orders projects in a window. @@ -219,7 +229,10 @@ mod tests { assert_eq!(reloaded.project_widths, original.project_widths); assert_eq!(reloaded.project_layout, original.project_layout); assert_eq!(reloaded.project_sort_mode, original.project_sort_mode); - assert_eq!(reloaded.show_attention_section, original.show_attention_section); + assert_eq!( + reloaded.show_attention_section, + original.show_attention_section + ); assert_eq!(reloaded.folder_collapsed, original.folder_collapsed); assert_eq!(reloaded.os_bounds, original.os_bounds); assert_eq!(reloaded.sidebar_open, original.sidebar_open); @@ -273,4 +286,20 @@ mod tests { assert_eq!(s.project_sort_mode, ProjectSortMode::Manual); assert!(!s.show_attention_section); } + + #[test] + fn rows_present_canonical_splits_on_the_opposite_axis() { + assert_eq!( + ProjectLayoutMode::Columns.presented_split_direction(SplitDirection::Horizontal), + SplitDirection::Horizontal + ); + assert_eq!( + ProjectLayoutMode::Rows.presented_split_direction(SplitDirection::Horizontal), + SplitDirection::Vertical + ); + assert_eq!( + ProjectLayoutMode::Rows.presented_split_direction(SplitDirection::Vertical), + SplitDirection::Horizontal + ); + } } diff --git a/crates/okena-state/src/windows.rs b/crates/okena-state/src/windows.rs index 1975e41e5..60fce9fb3 100644 --- a/crates/okena-state/src/windows.rs +++ b/crates/okena-state/src/windows.rs @@ -60,9 +60,8 @@ impl WorkspaceData { /// /// Inserts the (project_id, width) pair into the targeted window's /// `project_widths` map, overwriting any prior value for the same id. The - /// pair-shaped API matches the runtime shape of a column-resize event - /// (one column moves at a time), in contrast to the legacy entity method - /// `update_project_widths` that takes a wholesale `HashMap`. + /// pair-shaped API matches a single-column update, while the entity-level + /// `update_project_widths` method batches multiple pairs in one notification. /// Unknown extra ids are a silent no-op, matching the `window_mut` lookup /// contract. pub fn set_project_width(&mut self, id: WindowId, project_id: &str, width: f32) { @@ -132,9 +131,15 @@ impl WorkspaceData { /// Mirrors the inverse helper `delete_project_scrub_all_windows`, /// which removes the id from every window's per-project storage on /// project-delete so no orphan entries survive. - pub fn add_project_hide_in_other_windows(&mut self, project_id: &str, spawning_window: WindowId) { + pub fn add_project_hide_in_other_windows( + &mut self, + project_id: &str, + spawning_window: WindowId, + ) { if spawning_window != WindowId::Main { - self.main_window.hidden_project_ids.insert(project_id.to_string()); + self.main_window + .hidden_project_ids + .insert(project_id.to_string()); } for extra in &mut self.extra_windows { if spawning_window != WindowId::Extra(extra.id) { @@ -150,20 +155,20 @@ impl WorkspaceData { /// window that should see the project by default; every open viewport must /// start hidden. pub fn hide_project_in_all_windows(&mut self, project_id: &str) { - self.main_window.hidden_project_ids.insert(project_id.to_string()); + self.main_window + .hidden_project_ids + .insert(project_id.to_string()); for extra in &mut self.extra_windows { extra.hidden_project_ids.insert(project_id.to_string()); } } - /// Remove a project's id from every window's per-project storage. + /// Remove a project's id from every client-owned presentation store. /// /// Walks `main_window` plus every entry in `extra_windows`, and removes - /// `project_id` from each window's `hidden_project_ids` set and - /// `project_widths` map. Idempotent: a project absent from a given window - /// is a no-op for that window. Other per-window fields (`folder_filter`, - /// `folder_collapsed`, `os_bounds`) are not per-project storage and are - /// left untouched. + /// `project_id` is removed from each window's `hidden_project_ids` and + /// `project_widths`, plus the shared service/hook panel-height caches. + /// Other per-window fields are left untouched. /// /// Called from the project-delete path so no orphan per-project entries /// survive the delete on any window. @@ -174,6 +179,8 @@ impl WorkspaceData { extra.hidden_project_ids.remove(project_id); extra.project_widths.remove(project_id); } + self.service_panel_heights.remove(project_id); + self.hook_panel_heights.remove(project_id); } /// Remove a folder id from every window's per-folder storage. @@ -294,9 +301,7 @@ impl WorkspaceData { let valid_folders: std::collections::HashSet = self.folders.iter().map(|f| f.id.clone()).collect(); - for window in - std::iter::once(&mut self.main_window).chain(self.extra_windows.iter_mut()) - { + for window in std::iter::once(&mut self.main_window).chain(self.extra_windows.iter_mut()) { window .hidden_project_ids .retain(|id| valid_projects.contains(id)); diff --git a/crates/okena-state/src/workspace_data.rs b/crates/okena-state/src/workspace_data.rs index c72f73d36..5bbb75e68 100644 --- a/crates/okena-state/src/workspace_data.rs +++ b/crates/okena-state/src/workspace_data.rs @@ -2,9 +2,9 @@ use crate::hooks_config::HooksConfig; use crate::window_state::WindowState; +use okena_core::shell::ShellType; use okena_core::theme::FolderColor; use okena_layout::LayoutNode; -use okena_core::shell::ShellType; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -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). /// @@ -57,11 +74,15 @@ impl WorkspaceData { /// would silently miss any field added after this function was last /// touched. pub fn without_remote_projects(&self) -> Self { - let remote_ids: HashSet = self.projects.iter() + let remote_ids: HashSet = self + .projects + .iter() .filter(|p| p.is_remote) .map(|p| p.id.clone()) .collect(); - let remote_folder_ids: HashSet = self.folders.iter() + let remote_folder_ids: HashSet = self + .folders + .iter() .filter(|f| f.id.starts_with("remote:")) .map(|f| f.id.clone()) .collect(); @@ -72,9 +93,12 @@ impl WorkspaceData { let mut data = self.clone(); data.projects.retain(|p| !p.is_remote); - data.project_order.retain(|id| !id.starts_with("remote:") && !remote_ids.contains(id)); - data.service_panel_heights.retain(|id, _| !remote_ids.contains(id)); - data.hook_panel_heights.retain(|id, _| !remote_ids.contains(id)); + data.project_order + .retain(|id| !id.starts_with("remote:") && !remote_ids.contains(id)); + data.service_panel_heights + .retain(|id, _| !remote_ids.contains(id)); + data.hook_panel_heights + .retain(|id, _| !remote_ids.contains(id)); data.folders.retain(|f| !f.id.starts_with("remote:")); for project_id in &remote_ids { @@ -136,6 +160,60 @@ 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 { @@ -190,21 +268,43 @@ pub struct ProjectData { /// output volume is deliberately not treated as activity. #[serde(default)] pub last_activity_at: Option, + /// Explicit "worktree is still being checked out on disk" marker. Set while + /// the optimistic create registers the row (layout `None`, no terminals) and + /// cleared once the checkout finalizes or rolls back. Persisted so a daemon + /// killed mid-create can distinguish a genuinely interrupted checkout from a + /// deliberate `layout: None` bookmark (both otherwise look identical), and + /// mirrored over the wire so clients render the "Setting up worktree…" + /// placeholder instead of the empty-bookmark state. + #[serde(default)] + pub is_creating: bool, + /// Transient "a before_worktree_remove hook-gated close is in progress" + /// marker. Set by the daemon while it runs the before-remove hook + removal + /// and cleared when the close is aborted (hook failed / hook terminal + /// dismissed); mirrored over the wire so thin clients render the dimmed + /// "Closing…" row authoritatively instead of relying only on client-local + /// optimistic state that never heals on abort. + /// + /// Deliberately NOT persisted (`serde(skip)`), unlike `is_creating`: closing + /// is a live operation with no restart self-heal (the pending-close tracker + /// is in-memory only), so a daemon that died mid-close must not reload a + /// project stranded "Closing…" forever. + #[serde(skip)] + pub is_closing: bool, } 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) { - return title.clone(); - } + && !is_generated_terminal_title(title) + { + return title.clone(); + } self.directory_name() } @@ -237,6 +337,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 } @@ -266,19 +370,26 @@ mod tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } #[test] fn directory_name_from_path() { - assert_eq!(make_project("/home/user/myproject").directory_name(), "myproject"); + assert_eq!( + make_project("/home/user/myproject").directory_name(), + "myproject" + ); assert_eq!(make_project("/").directory_name(), "Terminal"); } #[test] fn terminal_display_name_prefers_custom_name() { let mut project = make_project("/home/user/myproject"); - project.terminal_names.insert("t1".to_string(), "My Terminal".to_string()); + project + .terminal_names + .insert("t1".to_string(), "My Terminal".to_string()); assert_eq!( project.terminal_display_name("t1", Some("osc-title".to_string())), "My Terminal" @@ -297,17 +408,17 @@ mod tests { #[test] fn terminal_display_name_falls_back_to_directory() { let project = make_project("/home/user/myproject"); - assert_eq!( - project.terminal_display_name("t1", None), - "myproject" - ); + assert_eq!(project.terminal_display_name("t1", None), "myproject"); } #[test] fn terminal_display_name_ignores_bash_prompt_title() { let project = make_project("/home/user/myproject"); assert_eq!( - project.terminal_display_name("t1", Some("matej21@matej21-hp: ~/projects/myproject".to_string())), + project.terminal_display_name( + "t1", + Some("matej21@matej21-hp: ~/projects/myproject".to_string()) + ), "myproject" ); assert_eq!( @@ -316,6 +427,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"); @@ -363,8 +483,14 @@ mod tests { assert!(project.layout.is_none()); // Legacy hooks should be mapped to the new grouped layout. assert_eq!(project.hooks.project.on_open.as_deref(), Some("init.sh")); - assert_eq!(project.hooks.worktree.pre_merge.as_deref(), Some("check.sh")); - assert_eq!(project.hooks.worktree.after_remove.as_deref(), Some("cleanup.sh")); + assert_eq!( + project.hooks.worktree.pre_merge.as_deref(), + Some("check.sh") + ); + assert_eq!( + project.hooks.worktree.after_remove.as_deref(), + Some("cleanup.sh") + ); // Untouched fields remain default. assert!(project.hooks.project.on_close.is_none()); assert!(project.hooks.worktree.on_create.is_none()); @@ -414,8 +540,14 @@ mod tests { let json = serde_json::to_string(&data).unwrap(); let reloaded: WorkspaceData = serde_json::from_str(&json).unwrap(); - assert_eq!(reloaded.main_window.hidden_project_ids, data.main_window.hidden_project_ids); - assert_eq!(reloaded.main_window.folder_filter, data.main_window.folder_filter); + assert_eq!( + reloaded.main_window.hidden_project_ids, + data.main_window.hidden_project_ids + ); + assert_eq!( + reloaded.main_window.folder_filter, + data.main_window.folder_filter + ); assert_eq!(reloaded.extra_windows.len(), 1); } @@ -435,9 +567,15 @@ mod tests { let saved = serde_json::to_string(&project).unwrap(); // After saving the migrated config, no legacy keys should remain. - assert!(!saved.contains("\"on_project_open\""), "legacy key must not survive a save"); + assert!( + !saved.contains("\"on_project_open\""), + "legacy key must not survive a save" + ); // The grouped key should be present. - assert!(saved.contains("\"project\""), "expected grouped project key"); + assert!( + saved.contains("\"project\""), + "expected grouped project key" + ); let reloaded: ProjectData = serde_json::from_str(&saved).unwrap(); assert_eq!(reloaded.hooks.project.on_open.as_deref(), Some("init.sh")); @@ -452,8 +590,10 @@ mod tests { let project = make_project("/tmp/test"); let saved = serde_json::to_string(&project).unwrap(); let value: serde_json::Value = serde_json::from_str(&saved).unwrap(); - assert!(!value.as_object().unwrap().contains_key("show_in_overview"), - "ProjectData.show_in_overview must not appear in serialized form (field removed)"); + assert!( + !value.as_object().unwrap().contains_key("show_in_overview"), + "ProjectData.show_in_overview must not appear in serialized form (field removed)" + ); } #[test] @@ -487,43 +627,100 @@ mod tests { }, ]; data.service_panel_heights.insert("local".to_string(), 1.0); - data.service_panel_heights.insert("remote:c1:p1".to_string(), 2.0); + data.service_panel_heights + .insert("remote:c1:p1".to_string(), 2.0); data.hook_panel_heights.insert("local".to_string(), 3.0); - data.hook_panel_heights.insert("remote:c1:p1".to_string(), 4.0); + data.hook_panel_heights + .insert("remote:c1:p1".to_string(), 4.0); - data.main_window.hidden_project_ids.insert("local".to_string()); - data.main_window.hidden_project_ids.insert("remote:c1:p1".to_string()); - data.main_window.project_widths.insert("local".to_string(), 0.25); - data.main_window.project_widths.insert("remote:c1:p1".to_string(), 0.75); + data.main_window + .hidden_project_ids + .insert("local".to_string()); + data.main_window + .hidden_project_ids + .insert("remote:c1:p1".to_string()); + data.main_window + .project_widths + .insert("local".to_string(), 0.25); + data.main_window + .project_widths + .insert("remote:c1:p1".to_string(), 0.75); data.main_window.folder_filter = Some("remote:c1:f1".to_string()); - data.main_window.folder_collapsed.insert("local-folder".to_string(), true); - data.main_window.folder_collapsed.insert("remote:c1:f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("local-folder".to_string(), true); + data.main_window + .folder_collapsed + .insert("remote:c1:f1".to_string(), true); let mut extra = WindowState::default(); let extra_id = extra.id; extra.hidden_project_ids.insert("remote:c1:p1".to_string()); - extra.project_widths.insert("remote:c1:p1".to_string(), 0.50); + extra + .project_widths + .insert("remote:c1:p1".to_string(), 0.50); extra.folder_filter = Some("remote:c1:f1".to_string()); - extra.folder_collapsed.insert("remote:c1:f1".to_string(), true); + extra + .folder_collapsed + .insert("remote:c1:f1".to_string(), true); data.extra_windows.push(extra); let saved = data.without_remote_projects(); - assert_eq!(saved.projects.iter().map(|p| p.id.as_str()).collect::>(), vec!["local"]); + assert_eq!( + saved + .projects + .iter() + .map(|p| p.id.as_str()) + .collect::>(), + vec!["local"] + ); assert_eq!(saved.project_order, vec!["local"]); - assert_eq!(saved.folders.iter().map(|f| f.id.as_str()).collect::>(), vec!["local-folder"]); + assert_eq!( + saved + .folders + .iter() + .map(|f| f.id.as_str()) + .collect::>(), + vec!["local-folder"] + ); assert_eq!(saved.service_panel_heights.get("local").copied(), Some(1.0)); assert!(!saved.service_panel_heights.contains_key("remote:c1:p1")); assert_eq!(saved.hook_panel_heights.get("local").copied(), Some(3.0)); assert!(!saved.hook_panel_heights.contains_key("remote:c1:p1")); assert!(saved.main_window.hidden_project_ids.contains("local")); - assert!(!saved.main_window.hidden_project_ids.contains("remote:c1:p1")); - assert_eq!(saved.main_window.project_widths.get("local").copied(), Some(0.25)); - assert!(!saved.main_window.project_widths.contains_key("remote:c1:p1")); + assert!( + !saved + .main_window + .hidden_project_ids + .contains("remote:c1:p1") + ); + assert_eq!( + saved.main_window.project_widths.get("local").copied(), + Some(0.25) + ); + assert!( + !saved + .main_window + .project_widths + .contains_key("remote:c1:p1") + ); assert!(saved.main_window.folder_filter.is_none()); - assert_eq!(saved.main_window.folder_collapsed.get("local-folder").copied(), Some(true)); - assert!(!saved.main_window.folder_collapsed.contains_key("remote:c1:f1")); + assert_eq!( + saved + .main_window + .folder_collapsed + .get("local-folder") + .copied(), + Some(true) + ); + assert!( + !saved + .main_window + .folder_collapsed + .contains_key("remote:c1:f1") + ); let saved_extra = saved.window(WindowId::Extra(extra_id)).unwrap(); assert!(!saved_extra.hidden_project_ids.contains("remote:c1:p1")); @@ -547,8 +744,10 @@ mod tests { }; let saved = serde_json::to_string(&folder).unwrap(); let value: serde_json::Value = serde_json::from_str(&saved).unwrap(); - assert!(!value.as_object().unwrap().contains_key("collapsed"), - "FolderData.collapsed must not appear in serialized form (field removed)"); + assert!( + !value.as_object().unwrap().contains_key("collapsed"), + "FolderData.collapsed must not appear in serialized form (field removed)" + ); } #[test] @@ -618,7 +817,9 @@ mod tests { // contract so the upcoming window-scoped setters can rely on Main // always producing a writable handle. let mut data = make_workspace(); - let target = data.window_mut(WindowId::Main).expect("main always present"); + let target = data + .window_mut(WindowId::Main) + .expect("main always present"); target.hidden_project_ids.insert("p1".to_string()); assert!(data.main_window.hidden_project_ids.contains("p1")); } @@ -661,10 +862,18 @@ mod tests { data.set_folder_filter(WindowId::Extra(a_id), Some("f1".to_string())); assert_eq!( - data.window(WindowId::Extra(a_id)).unwrap().folder_filter.as_deref(), + data.window(WindowId::Extra(a_id)) + .unwrap() + .folder_filter + .as_deref(), Some("f1"), ); - assert!(data.window(WindowId::Extra(b_id)).unwrap().folder_filter.is_none()); + assert!( + data.window(WindowId::Extra(b_id)) + .unwrap() + .folder_filter + .is_none() + ); assert!(data.main_window.folder_filter.is_none()); } @@ -682,7 +891,12 @@ mod tests { let unknown = uuid::Uuid::new_v4(); data.set_folder_filter(WindowId::Extra(unknown), Some("f1".to_string())); - assert!(data.window(WindowId::Extra(extra_id)).unwrap().folder_filter.is_none()); + assert!( + data.window(WindowId::Extra(extra_id)) + .unwrap() + .folder_filter + .is_none() + ); assert!(data.main_window.folder_filter.is_none()); } @@ -728,16 +942,19 @@ mod tests { data.toggle_hidden(WindowId::Extra(a_id), "p1"); - assert!(data - .window(WindowId::Extra(a_id)) - .unwrap() - .hidden_project_ids - .contains("p1")); - assert!(!data - .window(WindowId::Extra(b_id)) - .unwrap() - .hidden_project_ids - .contains("p1")); + assert!( + data.window(WindowId::Extra(a_id)) + .unwrap() + .hidden_project_ids + .contains("p1") + ); + assert!( + !data + .window(WindowId::Extra(b_id)) + .unwrap() + .hidden_project_ids + .contains("p1") + ); assert!(!data.main_window.hidden_project_ids.contains("p1")); } @@ -755,11 +972,12 @@ mod tests { let unknown = uuid::Uuid::new_v4(); data.toggle_hidden(WindowId::Extra(unknown), "p1"); - assert!(data - .window(WindowId::Extra(extra_id)) - .unwrap() - .hidden_project_ids - .is_empty()); + assert!( + data.window(WindowId::Extra(extra_id)) + .unwrap() + .hidden_project_ids + .is_empty() + ); assert!(data.main_window.hidden_project_ids.is_empty()); } @@ -770,7 +988,10 @@ mod tests { // single (project_id, width) pair lands on main_window.project_widths. let mut data = make_workspace(); data.set_project_width(WindowId::Main, "p1", 0.42); - assert_eq!(data.main_window.project_widths.get("p1").copied(), Some(0.42)); + assert_eq!( + data.main_window.project_widths.get("p1").copied(), + Some(0.42) + ); } #[test] @@ -782,7 +1003,10 @@ mod tests { let mut data = make_workspace(); data.set_project_width(WindowId::Main, "p1", 0.25); data.set_project_width(WindowId::Main, "p1", 0.75); - assert_eq!(data.main_window.project_widths.get("p1").copied(), Some(0.75)); + assert_eq!( + data.main_window.project_widths.get("p1").copied(), + Some(0.75) + ); } #[test] @@ -803,10 +1027,19 @@ mod tests { data.set_project_width(WindowId::Extra(a_id), "p1", 0.42); assert_eq!( - data.window(WindowId::Extra(a_id)).unwrap().project_widths.get("p1").copied(), + data.window(WindowId::Extra(a_id)) + .unwrap() + .project_widths + .get("p1") + .copied(), Some(0.42), ); - assert!(data.window(WindowId::Extra(b_id)).unwrap().project_widths.is_empty()); + assert!( + data.window(WindowId::Extra(b_id)) + .unwrap() + .project_widths + .is_empty() + ); assert!(data.main_window.project_widths.is_empty()); } @@ -824,7 +1057,12 @@ mod tests { let unknown = uuid::Uuid::new_v4(); data.set_project_width(WindowId::Extra(unknown), "p1", 0.42); - assert!(data.window(WindowId::Extra(extra_id)).unwrap().project_widths.is_empty()); + assert!( + data.window(WindowId::Extra(extra_id)) + .unwrap() + .project_widths + .is_empty() + ); assert!(data.main_window.project_widths.is_empty()); } @@ -835,7 +1073,10 @@ mod tests { // the smallest leg of the per-window folder-collapse contract. let mut data = make_workspace(); data.set_folder_collapsed(WindowId::Main, "f1", true); - assert_eq!(data.main_window.folder_collapsed.get("f1").copied(), Some(true)); + assert_eq!( + data.main_window.folder_collapsed.get("f1").copied(), + Some(true) + ); } #[test] @@ -848,7 +1089,9 @@ mod tests { // unconditionally (which would store explicit `false` entries and // diverge from the runtime convention). let mut data = make_workspace(); - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); data.set_folder_collapsed(WindowId::Main, "f1", false); assert!(!data.main_window.folder_collapsed.contains_key("f1")); } @@ -881,10 +1124,19 @@ mod tests { data.set_folder_collapsed(WindowId::Extra(a_id), "f1", true); assert_eq!( - data.window(WindowId::Extra(a_id)).unwrap().folder_collapsed.get("f1").copied(), + data.window(WindowId::Extra(a_id)) + .unwrap() + .folder_collapsed + .get("f1") + .copied(), Some(true), ); - assert!(data.window(WindowId::Extra(b_id)).unwrap().folder_collapsed.is_empty()); + assert!( + data.window(WindowId::Extra(b_id)) + .unwrap() + .folder_collapsed + .is_empty() + ); assert!(data.main_window.folder_collapsed.is_empty()); } @@ -902,7 +1154,12 @@ mod tests { let unknown = uuid::Uuid::new_v4(); data.set_folder_collapsed(WindowId::Extra(unknown), "f1", true); - assert!(data.window(WindowId::Extra(extra_id)).unwrap().folder_collapsed.is_empty()); + assert!( + data.window(WindowId::Extra(extra_id)) + .unwrap() + .folder_collapsed + .is_empty() + ); assert!(data.main_window.folder_collapsed.is_empty()); } @@ -965,7 +1222,12 @@ mod tests { data.window(WindowId::Extra(a_id)).unwrap().os_bounds, Some(bounds), ); - assert!(data.window(WindowId::Extra(b_id)).unwrap().os_bounds.is_none()); + assert!( + data.window(WindowId::Extra(b_id)) + .unwrap() + .os_bounds + .is_none() + ); assert!(data.main_window.os_bounds.is_none()); } @@ -989,7 +1251,12 @@ mod tests { }; data.set_os_bounds(WindowId::Extra(unknown), Some(bounds)); - assert!(data.window(WindowId::Extra(extra_id)).unwrap().os_bounds.is_none()); + assert!( + data.window(WindowId::Extra(extra_id)) + .unwrap() + .os_bounds + .is_none() + ); assert!(data.main_window.os_bounds.is_none()); } @@ -1003,12 +1270,18 @@ mod tests { // entries in workspace.json over time. let mut data = make_workspace(); data.main_window.hidden_project_ids.insert("p1".to_string()); - data.main_window.project_widths.insert("p1".to_string(), 0.42); + data.main_window + .project_widths + .insert("p1".to_string(), 0.42); + data.service_panel_heights.insert("p1".to_string(), 180.0); + data.hook_panel_heights.insert("p1".to_string(), 220.0); data.delete_project_scrub_all_windows("p1"); assert!(!data.main_window.hidden_project_ids.contains("p1")); assert!(!data.main_window.project_widths.contains_key("p1")); + assert!(!data.service_panel_heights.contains_key("p1")); + assert!(!data.hook_panel_heights.contains_key("p1")); } #[test] @@ -1047,8 +1320,12 @@ mod tests { let mut data = make_workspace(); data.main_window.hidden_project_ids.insert("p1".to_string()); data.main_window.hidden_project_ids.insert("p2".to_string()); - data.main_window.project_widths.insert("p1".to_string(), 0.25); - data.main_window.project_widths.insert("p2".to_string(), 0.75); + data.main_window + .project_widths + .insert("p1".to_string(), 0.25); + data.main_window + .project_widths + .insert("p2".to_string(), 0.75); let mut extra = WindowState::default(); extra.hidden_project_ids.insert("p2".to_string()); extra.project_widths.insert("p2".to_string(), 0.50); @@ -1058,7 +1335,10 @@ mod tests { data.delete_project_scrub_all_windows("p1"); assert!(data.main_window.hidden_project_ids.contains("p2")); - assert_eq!(data.main_window.project_widths.get("p2").copied(), Some(0.75)); + assert_eq!( + data.main_window.project_widths.get("p2").copied(), + Some(0.75) + ); let after = data.window(WindowId::Extra(extra_id)).unwrap(); assert!(after.hidden_project_ids.contains("p2")); assert_eq!(after.project_widths.get("p2").copied(), Some(0.50)); @@ -1074,7 +1354,9 @@ mod tests { // assertion checks "nothing was touched", not "everything is empty". let mut data = make_workspace(); data.main_window.hidden_project_ids.insert("p2".to_string()); - data.main_window.project_widths.insert("p2".to_string(), 0.42); + data.main_window + .project_widths + .insert("p2".to_string(), 0.42); let mut extra = WindowState::default(); extra.hidden_project_ids.insert("p2".to_string()); let extra_id = extra.id; @@ -1083,7 +1365,10 @@ mod tests { data.delete_project_scrub_all_windows("unknown_id"); assert!(data.main_window.hidden_project_ids.contains("p2")); - assert_eq!(data.main_window.project_widths.get("p2").copied(), Some(0.42)); + assert_eq!( + data.main_window.project_widths.get("p2").copied(), + Some(0.42) + ); let after = data.window(WindowId::Extra(extra_id)).unwrap(); assert!(after.hidden_project_ids.contains("p2")); } @@ -1099,8 +1384,12 @@ mod tests { // every project delete. let mut data = make_workspace(); data.main_window.hidden_project_ids.insert("p1".to_string()); - data.main_window.project_widths.insert("p1".to_string(), 0.42); - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .project_widths + .insert("p1".to_string(), 0.42); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); data.main_window.folder_filter = Some("f1".to_string()); data.main_window.os_bounds = Some(WindowBounds { origin_x: 1.0, @@ -1111,7 +1400,10 @@ mod tests { data.delete_project_scrub_all_windows("p1"); - assert_eq!(data.main_window.folder_collapsed.get("f1").copied(), Some(true)); + assert_eq!( + data.main_window.folder_collapsed.get("f1").copied(), + Some(true) + ); assert_eq!(data.main_window.folder_filter.as_deref(), Some("f1")); assert!(data.main_window.os_bounds.is_some()); } @@ -1134,12 +1426,24 @@ mod tests { }); // main_window: one live ref + several orphans across every storage. - data.main_window.hidden_project_ids.insert("present".to_string()); - data.main_window.hidden_project_ids.insert("ghost".to_string()); - data.main_window.project_widths.insert("present".to_string(), 0.4); - data.main_window.project_widths.insert("ghost".to_string(), 0.6); - data.main_window.folder_collapsed.insert("live-folder".to_string(), true); - data.main_window.folder_collapsed.insert("dead-folder".to_string(), true); + data.main_window + .hidden_project_ids + .insert("present".to_string()); + data.main_window + .hidden_project_ids + .insert("ghost".to_string()); + data.main_window + .project_widths + .insert("present".to_string(), 0.4); + data.main_window + .project_widths + .insert("ghost".to_string(), 0.6); + data.main_window + .folder_collapsed + .insert("live-folder".to_string(), true); + data.main_window + .folder_collapsed + .insert("dead-folder".to_string(), true); data.main_window.folder_filter = Some("dead-folder".to_string()); // An extra whose filter points at a live folder must be preserved. @@ -1156,8 +1460,17 @@ mod tests { assert!(!data.main_window.hidden_project_ids.contains("ghost")); assert!(data.main_window.project_widths.contains_key("present")); assert!(!data.main_window.project_widths.contains_key("ghost")); - assert!(data.main_window.folder_collapsed.contains_key("live-folder")); - assert!(!data.main_window.folder_collapsed.contains_key("dead-folder")); + assert!( + data.main_window + .folder_collapsed + .contains_key("live-folder") + ); + assert!( + !data + .main_window + .folder_collapsed + .contains_key("dead-folder") + ); assert_eq!(data.main_window.folder_filter, None); let after = data.window(WindowId::Extra(extra_id)).unwrap(); @@ -1190,8 +1503,10 @@ mod tests { let data = make_workspace(); let saved = serde_json::to_string(&data).unwrap(); let value: serde_json::Value = serde_json::from_str(&saved).unwrap(); - assert!(!value.as_object().unwrap().contains_key("project_widths"), - "top-level project_widths must not appear in serialized form (field removed)"); + assert!( + !value.as_object().unwrap().contains_key("project_widths"), + "top-level project_widths must not appear in serialized form (field removed)" + ); } #[test] @@ -1348,7 +1663,9 @@ mod tests { // hidden state to ensure the call doesn't accidentally touch other // entries on main. let mut data = make_workspace(); - data.main_window.hidden_project_ids.insert("sibling".to_string()); + data.main_window + .hidden_project_ids + .insert("sibling".to_string()); data.add_project_hide_in_other_windows("p1", WindowId::Main); @@ -1415,7 +1732,14 @@ mod tests { let after = data.window(WindowId::Extra(extra_id)).unwrap(); assert!(after.hidden_project_ids.contains("p1")); - assert_eq!(after.hidden_project_ids.iter().filter(|id| *id == "p1").count(), 1); + assert_eq!( + after + .hidden_project_ids + .iter() + .filter(|id| *id == "p1") + .count(), + 1 + ); } #[test] diff --git a/crates/okena-terminal/CLAUDE.md b/crates/okena-terminal/CLAUDE.md index df94f8c4c..e8e807187 100644 --- a/crates/okena-terminal/CLAUDE.md +++ b/crates/okena-terminal/CLAUDE.md @@ -36,5 +36,6 @@ See the doc comments on `pub struct Terminal` in `terminal.rs` for per-field thr - **`TerminalsRegistry`**: `Arc>>>` — shared registry for PTY event routing. - **Batched PTY processing**: The PTY reader thread sends `PtyEvent::Data` via `async_channel`. The GPUI thread drains all pending events before notifying, avoiding per-byte UI updates. -- **Remote output decoupling**: Remote tokio reader calls `enqueue_output` (just appends to `pending_output` + sets `dirty`). The GPUI thread drains via `drain_pending_output` inside `with_content`, so `term.lock()` is never held on the tokio thread. +- **Remote output decoupling**: Remote tokio readers call `enqueue_output` (append to `pending_output` + set `dirty`) and ring the manager's capacity-1 activity doorbell. The GPUI-thread activity pump drains/parses output, then emits targeted pane/sidebar notifications; `with_content` remains the fallback drain. Never restore per-pane polling or hold `term.lock()` on the tokio thread. +- **Persistent dtach teardown**: `SIGTERM` to the dtach master does not propagate to its PTY child tree. Teardown must keep the socket discoverable, revalidate PID birth markers, quiesce/reap descendants before the master, and unlink only after socket death is verified. - **Shell detection**: Auto-detects available shells on the system. On Windows, detects WSL distros and converts paths (`C:\` → `/mnt/c/`). diff --git a/crates/okena-terminal/src/backend.rs b/crates/okena-terminal/src/backend.rs index 4832da7a2..1fdebe355 100644 --- a/crates/okena-terminal/src/backend.rs +++ b/crates/okena-terminal/src/backend.rs @@ -1,18 +1,144 @@ -use crate::terminal::TerminalTransport; use crate::pty_manager::PtyManager; +#[cfg(windows)] +use crate::session_backend::ResolvedBackend; +use crate::session_backend::SessionBackend; use crate::shell_config::ShellType; +use crate::terminal::TerminalTransport; use anyhow::Result; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; + +/// Exact startup command carried separately from the shell used to route it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalLaunchCommand { + pub program: String, + pub args: Vec, +} + +/// Transient launch data; only `route` comes from persisted workspace state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalLaunchPlan { + pub route: ShellType, + pub initial_command: Option, + /// Environment owned by this launch, kept out of shell command strings. + pub environment: Vec<(String, String)>, +} + +impl TerminalLaunchPlan { + pub fn for_shell(route: ShellType) -> Self { + Self { + route, + initial_command: None, + environment: Vec::new(), + } + } + + pub fn with_environment(mut self, mut environment: Vec<(String, String)>) -> Self { + environment.sort_by(|a, b| a.0.cmp(&b.0)); + self.environment = environment; + self + } + + fn legacy_shell(&self) -> ShellType { + self.initial_command.as_ref().map_or_else( + || self.route.clone(), + |command| ShellType::Custom { + path: command.program.clone(), + args: command.args.clone(), + }, + ) + } +} + +/// Route needed to remove a persistent session when no live PTY handle exists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TerminalTeardownRoute { + Host, + #[cfg(windows)] + Wsl { + distro: Option, + backend: ResolvedBackend, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSessionTeardown { + pub terminal_id: String, + pub route: TerminalTeardownRoute, +} + +impl TerminalSessionTeardown { + pub fn host(terminal_id: String) -> Self { + Self { + terminal_id, + route: TerminalTeardownRoute::Host, + } + } +} + +impl PartialEq for TerminalSessionTeardown { + fn eq(&self, other: &String) -> bool { + self.terminal_id == *other + } +} /// Terminal lifecycle management trait. /// Used by TerminalPane and LayoutContainer. pub trait TerminalBackend: Send + Sync { fn transport(&self) -> Arc; fn create_terminal(&self, cwd: &str, shell: Option<&ShellType>) -> Result; - fn reconnect_terminal(&self, terminal_id: &str, cwd: &str, shell: Option<&ShellType>) -> Result; + fn create_terminal_with_plan(&self, cwd: &str, plan: &TerminalLaunchPlan) -> Result { + let shell = plan.legacy_shell(); + self.create_terminal(cwd, Some(&shell)) + } + fn reconnect_terminal( + &self, + terminal_id: &str, + cwd: &str, + shell: Option<&ShellType>, + ) -> Result; + fn reconnect_terminal_with_plan( + &self, + terminal_id: &str, + cwd: &str, + plan: &TerminalLaunchPlan, + ) -> Result { + let shell = plan.legacy_shell(); + self.reconnect_terminal(terminal_id, cwd, Some(&shell)) + } fn kill(&self, terminal_id: &str); + fn kill_session(&self, teardown: &TerminalSessionTeardown) { + self.kill(&teardown.terminal_id); + } + /// Wait for teardown work queued before this call to finish. + fn flush_teardown(&self) {} + /// Bounded teardown wait for destructive operations. `false` means the wait + /// timed out, or one of `terminal_ids` may still own its former working + /// directory. An empty slice asks only about the drain. + fn flush_teardown_with_timeout(&self, _timeout: Duration, _terminal_ids: &[String]) -> bool { + self.flush_teardown(); + true + } + /// Whether this backend can switch persistence routes without replacement. + fn supports_session_backend_reconfiguration(&self) -> bool { + false + } + /// Fence new PTY creation before draining the old persistence route. + fn begin_session_backend_reconfiguration(&self) -> Result<()> { + anyhow::bail!("terminal backend does not support live session route changes") + } + /// Verify that the old persistence route no longer owns live work. + fn ensure_session_backend_reconfigurable(&self) -> Result<()> { + anyhow::bail!("terminal backend does not support live session route changes") + } + /// Switch the live persistence route after old teardown and settings commit. + fn apply_session_backend(&self, _backend: SessionBackend) -> Result<()> { + anyhow::bail!("terminal backend does not support live session route changes") + } + /// Release a creation fence without changing the active route. + fn cancel_session_backend_reconfiguration(&self) {} fn capture_buffer(&self, terminal_id: &str) -> Option; fn supports_buffer_capture(&self) -> bool; fn is_remote(&self) -> bool; @@ -57,14 +183,68 @@ impl TerminalBackend for LocalBackend { self.pty_manager.create_terminal_with_shell(cwd, shell) } - fn reconnect_terminal(&self, terminal_id: &str, cwd: &str, shell: Option<&ShellType>) -> Result { - self.pty_manager.create_or_reconnect_terminal_with_shell(Some(terminal_id), cwd, shell) + fn create_terminal_with_plan(&self, cwd: &str, plan: &TerminalLaunchPlan) -> Result { + self.pty_manager.create_terminal_with_plan(cwd, plan) + } + + fn reconnect_terminal( + &self, + terminal_id: &str, + cwd: &str, + shell: Option<&ShellType>, + ) -> Result { + self.pty_manager + .create_or_reconnect_terminal_with_shell(Some(terminal_id), cwd, shell) + } + + fn reconnect_terminal_with_plan( + &self, + terminal_id: &str, + cwd: &str, + plan: &TerminalLaunchPlan, + ) -> Result { + self.pty_manager + .create_or_reconnect_terminal_with_plan(Some(terminal_id), cwd, plan) } fn kill(&self, terminal_id: &str) { self.pty_manager.kill(terminal_id) } + fn kill_session(&self, teardown: &TerminalSessionTeardown) { + self.pty_manager.kill_session(teardown) + } + + fn flush_teardown(&self) { + self.pty_manager.flush_teardown() + } + + fn flush_teardown_with_timeout(&self, timeout: Duration, terminal_ids: &[String]) -> bool { + self.pty_manager + .flush_teardown_with_timeout(timeout, terminal_ids) + } + + fn supports_session_backend_reconfiguration(&self) -> bool { + true + } + + fn begin_session_backend_reconfiguration(&self) -> Result<()> { + self.pty_manager.begin_session_backend_reconfiguration() + } + + fn ensure_session_backend_reconfigurable(&self) -> Result<()> { + self.pty_manager.ensure_session_backend_reconfigurable() + } + + fn apply_session_backend(&self, backend: SessionBackend) -> Result<()> { + self.pty_manager.apply_session_backend(backend); + Ok(()) + } + + fn cancel_session_backend_reconfiguration(&self) { + self.pty_manager.cancel_session_backend_reconfiguration(); + } + fn capture_buffer(&self, terminal_id: &str) -> Option { self.pty_manager.capture_buffer(terminal_id) } diff --git a/crates/okena-terminal/src/input.rs b/crates/okena-terminal/src/input.rs index aac6de3ce..5e62489b9 100644 --- a/crates/okena-terminal/src/input.rs +++ b/crates/okena-terminal/src/input.rs @@ -43,7 +43,8 @@ pub fn key_to_bytes( kitty: KittyKeyboardFlags, ) -> Option> { if kitty.disambiguate_escape_codes - && let Some(bytes) = kitty_disambiguate_bytes(event) { + && let Some(bytes) = kitty_disambiguate_bytes(event) + { return Some(bytes); } @@ -53,10 +54,12 @@ pub fn key_to_bytes( if mods.control && !mods.shift && !mods.alt && !mods.platform { let key = event.key.as_str(); if let Some(c) = key.chars().next() - && key.len() == 1 && c.is_ascii_alphabetic() { - let ctrl_char = (c.to_ascii_lowercase() as u8) - b'a' + 1; - return Some(vec![ctrl_char]); - } + && key.len() == 1 + && c.is_ascii_alphabetic() + { + let ctrl_char = (c.to_ascii_lowercase() as u8) - b'a' + 1; + return Some(vec![ctrl_char]); + } } // Handle Tab with modifiers @@ -263,15 +266,23 @@ mod tests { } fn ctrl() -> KeyModifiers { - KeyModifiers { control: true, ..Default::default() } + KeyModifiers { + control: true, + ..Default::default() + } } fn shift() -> KeyModifiers { - KeyModifiers { shift: true, ..Default::default() } + KeyModifiers { + shift: true, + ..Default::default() + } } fn on() -> KittyKeyboardFlags { - KittyKeyboardFlags { disambiguate_escape_codes: true } + KittyKeyboardFlags { + disambiguate_escape_codes: true, + } } #[test] diff --git a/crates/okena-terminal/src/macos_proc.rs b/crates/okena-terminal/src/macos_proc.rs index ab02dcd4f..f37505b5d 100644 --- a/crates/okena-terminal/src/macos_proc.rs +++ b/crates/okena-terminal/src/macos_proc.rs @@ -12,10 +12,10 @@ //! such cases. use libproc::libproc::bsd_info::BSDInfo; -use libproc::libproc::file_info::{pidfdinfo, ListFDs, ProcFDType}; +use libproc::libproc::file_info::{ListFDs, ProcFDType, pidfdinfo}; use libproc::libproc::net_info::{SocketFDInfo, SocketInfoKind, TcpSIState}; use libproc::libproc::proc_pid::{listpidinfo, name, pidinfo}; -use libproc::processes::{pids_by_type, ProcFilter}; +use libproc::processes::{ProcFilter, pids_by_type}; use std::collections::HashMap; use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -54,6 +54,12 @@ pub fn process_tree() -> HashMap> { tree } +/// Stable process birth marker used to revalidate a PID before signalling it. +pub fn process_start_time(pid: u32) -> Option<(u64, u64)> { + let info = pidinfo::(pid as i32, 0).ok()?; + Some((info.pbi_start_tvsec, info.pbi_start_tvusec)) +} + /// Map each given unix-socket path to the pids that have it open — equivalent to /// `lsof `. Scans every process's socket fds and matches the bound /// unix-domain address against the requested paths (exact match, like the lsof @@ -65,7 +71,10 @@ pub fn pids_holding_unix_sockets(socket_paths: &[PathBuf]) -> HashMap Vec<(u32, u16)> { let mut pairs = Vec::new(); for pid in pids_by_type(ProcFilter::All).unwrap_or_default() { for socket in socket_fds(pid) { - if !matches!(SocketInfoKind::from(socket.psi.soi_kind), SocketInfoKind::Tcp) { + if !matches!( + SocketInfoKind::from(socket.psi.soi_kind), + SocketInfoKind::Tcp + ) { continue; } // SAFETY: `soi_kind == Tcp` means `pri_tcp` is the active union arm. diff --git a/crates/okena-terminal/src/pty_manager.rs b/crates/okena-terminal/src/pty_manager.rs index b9f0c1b4d..85e43a1a8 100644 --- a/crates/okena-terminal/src/pty_manager.rs +++ b/crates/okena-terminal/src/pty_manager.rs @@ -1,52 +1,216 @@ -use crate::session_backend::{ResolvedBackend, SessionBackend}; +use crate::backend::{TerminalLaunchPlan, TerminalSessionTeardown, TerminalTeardownRoute}; +use crate::session_backend::SessionCommand; #[cfg(not(windows))] use crate::session_backend::get_extended_path; +use crate::session_backend::{ResolvedBackend, SessionBackend}; use crate::shell_config::{ShellCommandExt, ShellType}; use anyhow::Result; use async_channel::{Receiver, Sender}; -use parking_lot::Mutex; -use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use std::collections::HashMap; +use parking_lot::{Condvar, Mutex, RwLock}; +use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use std::collections::{HashMap, HashSet}; use std::io::{Read, Write}; use std::panic::AssertUnwindSafe; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; use std::thread::JoinHandle; +use std::time::Duration; + +#[cfg(windows)] +fn append_wsl_environment(cmd: &mut CommandBuilder, environment: &[(String, Option)]) { + if environment.is_empty() { + return; + } + cmd.arg("env"); + for (key, value) in environment { + match value { + Some(value) => { + cmd.arg(format!("{key}={value}")); + } + None => { + cmd.arg("-u"); + cmd.arg(key); + } + } + } +} /// Trait for broadcasting PTY output to external consumers (e.g. remote WebSocket clients). /// Implementations must be thread-safe as this is called from PTY reader threads. pub trait PtyOutputSink: Send + Sync { - fn publish(&self, terminal_id: String, data: Vec); + fn publish(&self, terminal_id: String, data: Vec) -> u64; /// `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 #[derive(Debug)] pub enum PtyEvent { /// Data received from PTY - Data { terminal_id: String, data: Vec }, + Data { + terminal_id: String, + generation: PtyGeneration, + data: Vec, + sequence: u64, + }, /// PTY process exited Exit { terminal_id: String, + generation: PtyGeneration, exit_code: Option, }, } +/// Identity of one concrete PTY process attached to a logical terminal ID. +/// +/// Logical IDs are reused when reconnecting persistent sessions. The generation +/// prevents delayed events from an older reader/writer pair from affecting the +/// newly attached process with the same terminal ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PtyGeneration(u64); + +#[derive(Default)] +struct PtyInstances { + current: HashMap, + creating: HashMap, + exited: HashMap, + pending_session_kills: HashMap, + backend_reconfiguration: bool, +} + +struct ExitedPty { + generation: PtyGeneration, + #[cfg(windows)] + wsl_distro: Option, + #[cfg(windows)] + wsl_backend: Option, +} + +impl ExitedPty { + fn new(generation: PtyGeneration) -> Self { + Self { + generation, + #[cfg(windows)] + wsl_distro: None, + #[cfg(windows)] + wsl_backend: None, + } + } +} + +impl PtyInstances { + fn begin_creation(&mut self, terminal_id: &str, generation: PtyGeneration) { + self.creating.insert(terminal_id.to_string(), generation); + self.exited.remove(terminal_id); + } + + fn is_creating(&self, terminal_id: &str, generation: PtyGeneration) -> bool { + self.creating.get(terminal_id).copied() == Some(generation) + } + + fn publish(&mut self, terminal_id: &str, generation: PtyGeneration) { + self.creating.remove(terminal_id); + self.current.insert(terminal_id.to_string(), generation); + } + + fn is_current(&self, terminal_id: &str, generation: PtyGeneration) -> bool { + self.current.get(terminal_id).copied() == Some(generation) + } + + /// Claim lifecycle handling for this generation exactly once. + fn claim_exit(&mut self, terminal_id: &str, generation: PtyGeneration) -> bool { + if !self.is_current(terminal_id, generation) { + return false; + } + self.current.remove(terminal_id); + self.exited + .insert(terminal_id.to_string(), ExitedPty::new(generation)); + true + } + + #[cfg(windows)] + fn record_exited_wsl_metadata( + &mut self, + terminal_id: &str, + generation: PtyGeneration, + wsl_distro: Option, + wsl_backend: Option, + ) { + if let Some(exited) = self.exited.get_mut(terminal_id) + && exited.generation == generation + { + exited.wsl_distro = wsl_distro; + exited.wsl_backend = wsl_backend; + } + } + + fn cancel_creation(&mut self, terminal_id: &str, generation: PtyGeneration) { + if self.is_creating(terminal_id, generation) { + self.creating.remove(terminal_id); + } + } + + fn remove_current(&mut self, terminal_id: &str, generation: PtyGeneration) { + if self.is_current(terminal_id, generation) { + self.current.remove(terminal_id); + } + } + + fn claim_exited(&mut self, terminal_id: &str, generation: PtyGeneration) -> Option { + if self.exited.get(terminal_id)?.generation != generation { + return None; + } + self.exited.remove(terminal_id) + } + + fn queue_session_kill(&mut self, terminal_id: &str) { + *self + .pending_session_kills + .entry(terminal_id.to_string()) + .or_default() += 1; + } + + fn complete_session_kill(&mut self, terminal_id: &str) { + let Some(pending) = self.pending_session_kills.get_mut(terminal_id) else { + return; + }; + *pending = pending.saturating_sub(1); + if *pending == 0 { + self.pending_session_kills.remove(terminal_id); + } + } + + fn has_pending_session_kill(&self, terminal_id: &str) -> bool { + self.pending_session_kills.contains_key(terminal_id) + } +} + /// Shared shutdown coordination between reader/writer threads struct PtyShutdownState { broken: AtomicBool, terminal_id: String, + generation: PtyGeneration, } impl PtyShutdownState { - fn new(terminal_id: String) -> Self { + fn new(terminal_id: String, generation: PtyGeneration) -> Self { Self { broken: AtomicBool::new(false), terminal_id, + generation, } } @@ -59,6 +223,31 @@ impl PtyShutdownState { } } +struct PtyReservation<'a> { + terminal_id: &'a str, + generation: PtyGeneration, + instances: &'a Mutex, + changed: &'a Condvar, + active: bool, +} + +impl PtyReservation<'_> { + fn publish(&mut self) { + self.active = false; + } +} + +impl Drop for PtyReservation<'_> { + fn drop(&mut self) { + if self.active { + self.instances + .lock() + .cancel_creation(self.terminal_id, self.generation); + self.changed.notify_all(); + } + } +} + /// Extract a human-readable message from a panic payload fn format_panic(payload: &dyn std::any::Any) -> String { if let Some(s) = payload.downcast_ref::<&str>() { @@ -70,12 +259,51 @@ fn format_panic(payload: &dyn std::any::Any) -> String { } } +/// Kill a persistent session and record the outcome against its terminal. A +/// later successful teardown of the same terminal clears an earlier failure. +fn record_session_kill( + tracker: &TeardownTracker, + terminal_id: &str, + session_backend: &ResolvedBackend, + session_name: &str, +) { + if session_backend.kill_session(session_name) { + tracker.mark_verified(terminal_id); + } else { + tracker.mark_unverified(terminal_id); + } +} + +fn join_reader_handle(reader_handle: Option>, terminal_id: &str) { + if let Some(handle) = reader_handle + && let Err(error) = handle.join() + { + log::warn!( + "PTY reader thread for {} panicked on join: {}", + terminal_id, + format_panic(&*error) + ); + } +} + /// Number of shared teardown worker threads. Bounds how many PTY teardowns /// (thread joins + `lsof`/`tmux kill-session`/SIGTERM subprocess calls) can run /// concurrently. On bulk shutdown we enqueue N jobs but only this many run at once, /// instead of spawning one detached OS thread per `kill()`/`cleanup_exited()` call. const TEARDOWN_WORKERS: usize = 4; +/// Number of workers allowed to wait for children that ignored termination. +/// This is deliberately separate from `TEARDOWN_WORKERS`: a stuck child must +/// not consume the normal teardown pool, and N stuck children must not create +/// N OS threads. +const REAPER_WORKERS: usize = 2; + +#[derive(Clone, Copy)] +struct SessionBackendSelection { + preference: SessionBackend, + resolved: ResolvedBackend, +} + /// What a teardown worker should do with a job. Modeling the two paths explicitly /// keeps the deliberate double-fire behavior (see `kill` / `cleanup_exited`) legible. enum TeardownKind { @@ -88,6 +316,9 @@ enum TeardownKind { /// "client already exited (cleanup_exited ran first), SIGTERM the lingering /// session/daemon" path — in that case the worker does ONLY the session kill. KillSession { + /// Owning terminal, so a failed kill can be attributed to the exact + /// terminal a destructive flush later asks about. + terminal_id: String, session_backend: ResolvedBackend, session_name: String, /// WSL distro for the session (Windows only). @@ -107,10 +338,96 @@ struct TeardownJob { /// kill runs. handle: Option, kind: TeardownKind, + pending_session_kill: Option, +} + +/// A child that ignored termination. The reaper retains the full handle until +/// the child exits and the reader can be joined; it is never dropped live. +struct ReaperJob { + handle: PtyHandle, +} + +struct PendingSessionKill { + terminal_id: String, + instances: Arc>, + changed: Arc, +} + +impl Drop for PendingSessionKill { + fn drop(&mut self) { + self.instances + .lock() + .complete_session_kill(&self.terminal_id); + self.changed.notify_all(); + } +} + +#[derive(Default)] +struct TeardownTracker { + pending: Mutex, + drained: Condvar, + /// Terminals whose persistent session could not be verified as gone, so a + /// process may still own their working directory. + /// + /// Deliberately keyed per terminal and never cleared by a flush: a global + /// one-shot flag both misattributed one terminal's failure to the next + /// destructive operation and discarded the signal the moment anything read + /// it, so a second waiter — or a later close of the terminal that actually + /// failed — saw success. An entry is dropped only when a later teardown of + /// the same terminal succeeds. + unverified: Mutex>, +} + +impl TeardownTracker { + fn queued(&self) { + *self.pending.lock() += 1; + } + + fn completed(&self) { + let mut pending = self.pending.lock(); + *pending = pending.saturating_sub(1); + if *pending == 0 { + self.drained.notify_all(); + } + } + + fn flush(&self) { + let mut pending = self.pending.lock(); + while *pending != 0 { + self.drained.wait(&mut pending); + } + } + + fn mark_unverified(&self, terminal_id: &str) { + self.unverified.lock().insert(terminal_id.to_string()); + } + + fn mark_verified(&self, terminal_id: &str) { + self.unverified.lock().remove(terminal_id); + } + + /// Wait for queued teardown to drain, then report whether every terminal in + /// `terminal_ids` released its session. An empty slice asks only about the + /// drain. Reading is non-destructive, so concurrent waiters agree. + fn flush_timeout(&self, timeout: Duration, terminal_ids: &[String]) -> bool { + let deadline = std::time::Instant::now() + timeout; + let mut pending = self.pending.lock(); + while *pending != 0 { + let now = std::time::Instant::now(); + if now >= deadline { + return false; + } + self.drained.wait_for(&mut pending, deadline - now); + } + drop(pending); + let unverified = self.unverified.lock(); + terminal_ids.iter().all(|id| !unverified.contains(id)) + } } /// Handle to a single PTY process struct PtyHandle { + generation: PtyGeneration, /// `Option` so teardown (`shutdown_handle`) and the `Drop` backstop can both /// `take()` it idempotently to close the PTY and unblock the reader thread. master: Option>, @@ -119,6 +436,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, @@ -161,12 +481,12 @@ impl Drop for PtyHandle { /// Manages all PTY processes pub struct PtyManager { terminals: Arc>>, + instances: Arc>, + instance_changed: Arc, + next_generation: AtomicU64, event_tx: Sender, - /// Session backend for persistence (tmux/screen/none) - session_backend: ResolvedBackend, - /// Raw user preference (needed for WSL per-terminal resolution) - #[cfg(windows)] - session_backend_preference: SessionBackend, + /// Live session route; changed only after every old session is drained. + session_backend: RwLock, /// Optional sink for streaming PTY output to external consumers (e.g. remote clients). /// Publishing happens directly from reader threads to avoid UI event loop latency. output_sink: Arc>>>, @@ -180,6 +500,14 @@ pub struct PtyManager { /// only so `Drop` can `take()` it and close the channel, signaling workers to /// drain remaining jobs and exit. teardown_tx: Option>, + /// Manager-owned fixed reaper pool for children that survive initial teardown. + /// The unbounded queue holds ownership, while `REAPER_WORKERS` bounds threads + /// that can wait forever. This keeps ordinary shutdown non-blocking while a + /// destructive flush can observe every live CWD-owning child through the tracker. + reaper_tx: Option>, + teardown_tracker: Arc, + #[cfg(test)] + reaper_worker_count: Arc, } impl PtyManager { @@ -200,9 +528,42 @@ impl PtyManager { .spawn(|| { crate::session_backend::cleanup_stale_dtach_sockets(); }) + { + log::warn!("failed to spawn dtach cleanup thread: {e}"); + } + + let teardown_tracker = Arc::new(TeardownTracker::default()); + + // A distinct fixed pool waits for stubborn children. Unlike the normal + // teardown workers, these may wait forever, so no job is allowed to make + // a new thread here. The tracker remains pending until each handle exits. + let (reaper_tx, reaper_rx) = async_channel::unbounded::(); + #[cfg(test)] + let reaper_worker_count = Arc::new(AtomicUsize::new(0)); + for i in 0..REAPER_WORKERS { + let rx = reaper_rx.clone(); + let tracker = Arc::clone(&teardown_tracker); + #[cfg(test)] + let worker_count = Arc::clone(&reaper_worker_count); + if let Err(e) = std::thread::Builder::new() + .name(format!("pty-reaper-{i}")) + .spawn(move || { + #[cfg(test)] + worker_count.fetch_add(1, Ordering::Release); + while let Ok(mut job) = rx.recv_blocking() { + let id = job.handle.shutdown.terminal_id.clone(); + if let Err(wait_error) = job.handle.child.wait() { + log::debug!("PTY child {} reaper wait failed: {}", id, wait_error); + } + join_reader_handle(job.handle.reader_handle.take(), &id); + tracker.completed(); + } + }) { - log::warn!("failed to spawn dtach cleanup thread: {e}"); + log::error!("failed to spawn PTY reaper worker {i}: {e}"); } + } + drop(reaper_rx); // Shared teardown worker pool. `async-channel` is MPMC, so all workers share // one `Receiver` and pull jobs via `recv_blocking`. Unbounded so enqueuing @@ -210,13 +571,20 @@ impl PtyManager { let (teardown_tx, teardown_rx) = async_channel::unbounded::(); for i in 0..TEARDOWN_WORKERS { let rx = teardown_rx.clone(); + let tracker = Arc::clone(&teardown_tracker); + let reaper_tx = reaper_tx.clone(); if let Err(e) = std::thread::Builder::new() .name(format!("pty-teardown-{i}")) .spawn(move || { // Exits when the channel is closed AND drained (Drop closes the // sender, then `recv_blocking` returns Err once buffered jobs run). while let Ok(job) = rx.recv_blocking() { - Self::run_teardown_job(job); + if let Err(panic) = std::panic::catch_unwind(AssertUnwindSafe(|| { + Self::run_teardown_job(job, &tracker, Some(&reaper_tx)); + })) { + log::error!("PTY teardown worker panicked: {}", format_panic(&*panic)); + } + tracker.completed(); } }) { @@ -229,29 +597,42 @@ impl PtyManager { ( Self { terminals: Arc::new(Mutex::new(HashMap::new())), + instances: Arc::new(Mutex::new(PtyInstances::default())), + instance_changed: Arc::new(Condvar::new()), + next_generation: AtomicU64::new(1), event_tx: tx, - session_backend, - #[cfg(windows)] - session_backend_preference: backend, + session_backend: RwLock::new(SessionBackendSelection { + preference: backend, + resolved: session_backend, + }), output_sink: Arc::new(Mutex::new(None)), extra_env: Mutex::new(Vec::new()), teardown_tx: Some(teardown_tx), + reaper_tx: Some(reaper_tx), + teardown_tracker, + #[cfg(test)] + reaper_worker_count, }, rx, ) } - /// Execute one teardown job on a worker thread. This is exactly what the old - /// per-call detached closures did: reap the handle's reader/writer threads (if a - /// handle is present), then run the session kill (only for `KillSession` jobs). - fn run_teardown_job(job: TeardownJob) { - if let Some(handle) = job.handle { - Self::shutdown_handle(handle); - } + /// Execute one teardown job on a worker thread. A persistent session is + /// stopped first, then the attach handle is reaped without blocking a worker + /// indefinitely on a child that ignores termination. + fn run_teardown_job( + mut job: TeardownJob, + tracker: &Arc, + reaper_tx: Option<&Sender>, + ) { + // End persistent sessions before waiting for the attach client. In + // particular, dtach can otherwise keep its shell (and checkout CWD) + // alive after the client has been signalled. match job.kind { // Process already EOF'd; nothing to SIGTERM from our side. TeardownKind::ReapOnly => {} TeardownKind::KillSession { + terminal_id, session_backend, session_name, #[cfg(windows)] @@ -262,19 +643,23 @@ impl PtyManager { // On Windows, if this was a WSL terminal with a session backend, // kill the session inside WSL instead of on the host. #[cfg(windows)] - { - if let Some(backend) = wsl_backend { - crate::session_backend::kill_wsl_session( - backend, - wsl_distro.as_deref(), - &session_name, - ); - return; - } + if let Some(backend) = wsl_backend { + crate::session_backend::kill_wsl_session( + backend, + wsl_distro.as_deref(), + &session_name, + ); + } else { + record_session_kill(tracker, &terminal_id, &session_backend, &session_name); } - session_backend.kill_session(&session_name); + #[cfg(not(windows))] + record_session_kill(tracker, &terminal_id, &session_backend, &session_name); } } + if let Some(handle) = job.handle.take() { + Self::shutdown_handle(handle, Some(tracker), reaper_tx); + } + drop(job.pending_session_kill.take()); } /// Set the output sink for streaming PTY output to external consumers. @@ -290,6 +675,80 @@ impl PtyManager { *self.extra_env.lock() = env; } + fn session_backend(&self) -> ResolvedBackend { + self.session_backend.read().resolved + } + + pub fn session_backend_preference(&self) -> SessionBackend { + self.session_backend.read().preference + } + + /// Prevent new PTYs from selecting the old route while it is drained. + pub fn begin_session_backend_reconfiguration(&self) -> Result<()> { + let mut instances = self.instances.lock(); + if instances.backend_reconfiguration { + anyhow::bail!("terminal backend reconfiguration already in progress"); + } + instances.backend_reconfiguration = true; + Ok(()) + } + + /// Confirm that every PTY and queued old-backend teardown has drained. + pub fn ensure_session_backend_reconfigurable(&self) -> Result<()> { + if !self.terminals.lock().is_empty() { + anyhow::bail!("cannot switch session backend while terminals are still active"); + } + let instances = self.instances.lock(); + if !instances.backend_reconfiguration { + anyhow::bail!("terminal backend reconfiguration has not started"); + } + if !instances.current.is_empty() + || !instances.creating.is_empty() + || !instances.pending_session_kills.is_empty() + { + anyhow::bail!("cannot switch session backend while terminal teardown is pending"); + } + Ok(()) + } + + /// Apply a new route after [`Self::ensure_session_backend_reconfigurable`]. + pub fn apply_session_backend(&self, preference: SessionBackend) { + let resolved = preference.resolve(); + *self.session_backend.write() = SessionBackendSelection { + preference, + resolved, + }; + self.cancel_session_backend_reconfiguration(); + if resolved.supports_persistence() { + log::info!("Session persistence switched to {:?}", resolved); + } + #[cfg(unix)] + if matches!(resolved, ResolvedBackend::Dtach) + && let Err(error) = std::thread::Builder::new() + .name("dtach-socket-gc".into()) + .spawn(crate::session_backend::cleanup_stale_dtach_sockets) + { + log::warn!("failed to spawn dtach cleanup thread: {error}"); + } + } + + /// Abort a route migration and allow PTY creation on the unchanged route. + pub fn cancel_session_backend_reconfiguration(&self) { + let mut instances = self.instances.lock(); + instances.backend_reconfiguration = false; + self.instance_changed.notify_all(); + } + + /// Return whether an event belongs to the currently attached PTY instance. + pub fn is_current_generation(&self, terminal_id: &str, generation: PtyGeneration) -> bool { + self.instances.lock().is_current(terminal_id, generation) + } + + /// Return the current generation for diagnostics and event-routing tests. + pub fn current_generation(&self, terminal_id: &str) -> Option { + self.instances.lock().current.get(terminal_id).copied() + } + /// Create a new terminal with a PTY process (uses system default shell) #[allow(dead_code)] // Kept for API compatibility, prefer create_terminal_with_shell pub fn create_terminal(&self, cwd: &str) -> Result { @@ -297,9 +756,22 @@ impl PtyManager { } /// Create a new terminal with a specific shell type - pub fn create_terminal_with_shell(&self, cwd: &str, shell: Option<&ShellType>) -> Result { + pub fn create_terminal_with_shell( + &self, + cwd: &str, + shell: Option<&ShellType>, + ) -> Result { + let plan = TerminalLaunchPlan::for_shell(shell.cloned().unwrap_or_default()); + self.create_terminal_with_plan(cwd, &plan) + } + + pub fn create_terminal_with_plan( + &self, + cwd: &str, + plan: &TerminalLaunchPlan, + ) -> Result { let terminal_id = uuid::Uuid::new_v4().to_string(); - self.create_terminal_with_id(&terminal_id, cwd, shell)?; + self.create_terminal_with_id(&terminal_id, cwd, plan)?; Ok(terminal_id) } @@ -321,6 +793,16 @@ impl PtyManager { terminal_id: Option<&str>, cwd: &str, shell: Option<&ShellType>, + ) -> Result { + let plan = TerminalLaunchPlan::for_shell(shell.cloned().unwrap_or_default()); + self.create_or_reconnect_terminal_with_plan(terminal_id, cwd, &plan) + } + + pub fn create_or_reconnect_terminal_with_plan( + &self, + terminal_id: Option<&str>, + cwd: &str, + plan: &TerminalLaunchPlan, ) -> Result { match terminal_id { Some(id) => { @@ -329,15 +811,49 @@ impl PtyManager { return Ok(id.to_string()); } // Try to reconnect or create with this ID - self.create_terminal_with_id(id, cwd, shell)?; + self.create_terminal_with_id(id, cwd, plan)?; Ok(id.to_string()) } - None => self.create_terminal_with_shell(cwd, shell), + None => self.create_terminal_with_plan(cwd, plan), } } /// Internal: create a terminal with a specific ID - fn create_terminal_with_id(&self, terminal_id: &str, cwd: &str, shell: Option<&ShellType>) -> Result<()> { + fn create_terminal_with_id( + &self, + terminal_id: &str, + cwd: &str, + plan: &TerminalLaunchPlan, + ) -> Result<()> { + let generation = PtyGeneration(self.next_generation.fetch_add(1, Ordering::Relaxed)); + let mut instances = self.instances.lock(); + if instances.backend_reconfiguration { + anyhow::bail!("terminal backend reconfiguration is in progress"); + } + while instances.has_pending_session_kill(terminal_id) + || instances.creating.contains_key(terminal_id) + { + self.instance_changed.wait(&mut instances); + if instances.backend_reconfiguration { + anyhow::bail!("terminal backend reconfiguration is in progress"); + } + } + if instances.current.contains_key(terminal_id) { + if self.terminals.lock().contains_key(terminal_id) { + return Ok(()); + } + instances.current.remove(terminal_id); + } + instances.begin_creation(terminal_id, generation); + drop(instances); + let mut reservation = PtyReservation { + terminal_id, + generation, + instances: &self.instances, + changed: &self.instance_changed, + active: true, + }; + let pty_system = native_pty_system(); let pair = pty_system.openpty(PtySize { rows: 24, @@ -346,17 +862,20 @@ impl PtyManager { pixel_height: 0, })?; + let launch_environment = self.launch_environment(plan); + // Build command based on session backend and shell config #[cfg(unix)] - let mut cmd = self.build_terminal_command(terminal_id, cwd, shell); + let mut cmd = self.build_terminal_command(terminal_id, cwd, plan, &launch_environment); #[cfg(windows)] - let (mut cmd, wsl_distro, wsl_backend) = self.build_terminal_command(terminal_id, cwd, shell); + let (mut cmd, wsl_distro, wsl_backend) = + self.build_terminal_command(terminal_id, cwd, plan, &launch_environment); // Apply caller-configured env overrides to the PTY unconditionally. // These are profile-scoped values (e.g. CLAUDE_CONFIG_DIR) that must // override whatever the user's shell rc or the parent process has set. // `None` removes the variable so a stale inherited value cannot leak in. - for (key, val) in &*self.extra_env.lock() { + for (key, val) in &launch_environment { match val { Some(val) => cmd.env(key, val), None => cmd.env_remove(key), @@ -366,11 +885,14 @@ 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 shutdown = Arc::new(PtyShutdownState::new(terminal_id.to_string(), generation)); let child_pid = child.process_id(); // Spawn reader thread with panic guard @@ -378,19 +900,36 @@ impl PtyManager { let id = terminal_id.to_string(); let reader_shutdown = Arc::clone(&shutdown); let output_sink = self.output_sink.lock().clone(); + let reader_instances = Arc::clone(&self.instances); + let (reader_start_tx, reader_start_rx) = mpsc::channel::<()>(); let reader_handle = std::thread::Builder::new() - .name(format!("pty-reader-{}", &terminal_id[..8.min(terminal_id.len())])) + .name(format!( + "pty-reader-{}", + &terminal_id[..8.min(terminal_id.len())] + )) .spawn(move || { + if reader_start_rx.recv().is_err() { + return; + } let tx_panic = tx.clone(); let shutdown_panic = Arc::clone(&reader_shutdown); let id_panic = id.clone(); if let Err(panic) = std::panic::catch_unwind(AssertUnwindSafe(|| { - Self::read_loop(id, reader, tx, reader_shutdown, child_pid, output_sink); + Self::read_loop( + id, + reader, + tx, + reader_shutdown, + child_pid, + output_sink, + reader_instances, + ); })) { log::error!("PTY reader thread panicked: {}", format_panic(&*panic)); shutdown_panic.mark_broken(); let _ = tx_panic.send_blocking(PtyEvent::Exit { terminal_id: id_panic, + generation: shutdown_panic.generation, exit_code: None, }); } @@ -401,31 +940,56 @@ impl PtyManager { let writer_shutdown = Arc::clone(&shutdown); let writer_event_tx = self.event_tx.clone(); let writer_id = terminal_id.to_string(); + let (writer_start_tx, writer_start_rx) = mpsc::channel::<()>(); + // 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())])) + .name(format!( + "pty-writer-{}", + &terminal_id[..8.min(terminal_id.len())] + )) .spawn(move || { + if writer_start_rx.recv().is_err() { + return; + } let tx_panic = writer_event_tx.clone(); 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(); let _ = tx_panic.send_blocking(PtyEvent::Exit { terminal_id: id_panic, + generation: shutdown_panic.generation, exit_code: None, }); } })?; - // Store the handle + // Publish the generation and handle before either worker can emit an event. + // Taking the locks in lifecycle order keeps exit cleanup from observing a + // generation without its handle. + let mut instances = self.instances.lock(); + if !instances.is_creating(terminal_id, generation) { + drop(instances); + anyhow::bail!("terminal creation was cancelled before publication"); + } self.terminals.lock().insert( terminal_id.to_string(), PtyHandle { + generation, master: Some(pair.master), child, input_tx: Some(input_tx), + writer: Some(writer), reader_handle: Some(reader_handle), writer_handle: Some(writer_handle), shutdown, @@ -435,47 +999,83 @@ impl PtyManager { wsl_backend, }, ); + instances.publish(terminal_id, generation); + drop(instances); + reservation.publish(); + self.instance_changed.notify_all(); + let _ = reader_start_tx.send(()); + let _ = writer_start_tx.send(()); Ok(()) } + fn launch_environment(&self, plan: &TerminalLaunchPlan) -> Vec<(String, Option)> { + let mut environment = self.extra_env.lock().clone(); + for (key, value) in &plan.environment { + environment.retain(|(existing, _)| existing != key); + environment.push((key.clone(), Some(value.clone()))); + } + environment + } + /// Build the command to run in the terminal. /// On Unix, returns just the CommandBuilder. /// On Windows, also returns WSL distro/backend info for session persistence. #[cfg(unix)] - fn build_terminal_command(&self, terminal_id: &str, cwd: &str, shell: Option<&ShellType>) -> CommandBuilder { + fn build_terminal_command( + &self, + terminal_id: &str, + cwd: &str, + plan: &TerminalLaunchPlan, + launch_environment: &[(String, Option)], + ) -> CommandBuilder { + let session_backend = self.session_backend(); // Extract custom command from ShellType::Custom{path:, args:["-c"/"-ic", cmd]} // so it can be passed to the session backend - let custom_command = match shell { - Some(ShellType::Custom { args, .. }) if args.len() == 2 && (args[0] == "-c" || args[0] == "-ic") => { - Some(args[1].as_str()) - } - _ => None, - }; + let custom_command = plan.initial_command.as_ref().map_or_else( + || match &plan.route { + ShellType::Custom { args, .. } + if args.len() == 2 && (args[0] == "-c" || args[0] == "-ic") => + { + Some(SessionCommand::ShellScript(args[1].as_str())) + } + _ => None, + }, + |command| { + Some(SessionCommand::Program { + program: command.program.as_str(), + args: &command.args, + }) + }, + ); - let extra_env = self.extra_env.lock().clone(); - let mut cmd = if let Some((program, args)) = self - .session_backend - .build_command(&self.session_backend.session_name(terminal_id), cwd, custom_command, &extra_env) - { + let mut cmd = if let Some((program, args)) = session_backend.build_command_with_custom( + &session_backend.session_name(terminal_id), + cwd, + custom_command, + launch_environment, + ) { let mut cmd = CommandBuilder::new(program); for arg in args { cmd.arg(arg); } // For screen, we need to set cwd separately as it doesn't have -c flag - if matches!(self.session_backend, ResolvedBackend::Screen) { + if matches!(session_backend, ResolvedBackend::Screen) { cmd.cwd(cwd); } cmd } else { // No session backend - use shell config or default - match shell { - Some(shell_type) => shell_type.build_command(cwd), - None => { - let mut cmd = CommandBuilder::new_default_prog(); + match &plan.initial_command { + Some(command) => { + let mut cmd = CommandBuilder::new(&command.program); + for arg in &command.args { + cmd.arg(arg); + } cmd.cwd(cwd); cmd } + None => plan.route.build_command(cwd), } }; @@ -490,29 +1090,46 @@ impl PtyManager { &self, terminal_id: &str, cwd: &str, - shell: Option<&ShellType>, + plan: &TerminalLaunchPlan, + launch_environment: &[(String, Option)], ) -> (CommandBuilder, Option, Option) { use crate::session_backend::resolve_for_wsl; use crate::shell_config::windows_path_to_wsl; - - // Extract custom command from ShellType::Custom{path:, args:["-c"/"-ic", cmd]} - let custom_command = match shell { - Some(ShellType::Custom { args, .. }) if args.len() == 2 && (args[0] == "-c" || args[0] == "-ic") => { - Some(args[1].as_str()) - } - _ => None, - }; + let session_backend = self.session_backend(); + let session_backend_preference = self.session_backend_preference(); + + // Preserve the exact executable and argv for psmux. In particular, + // keep-alive hooks require cmd.exe delayed expansion via `/V:ON`. + let custom_command = plan.initial_command.as_ref().map_or_else( + || match &plan.route { + ShellType::Custom { path, args } => Some(SessionCommand::Program { + program: path.as_str(), + args, + }), + _ => None, + }, + |command| { + Some(SessionCommand::Program { + program: command.program.as_str(), + args: &command.args, + }) + }, + ); // Wrap a non-WSL shell through the host session backend (psmux) when one // is available. WSL terminals get their own per-distro backend below // because the daemon must live inside WSL, not on the host. let wrap_with_host_backend = |fallback: CommandBuilder| -> CommandBuilder { - if !self.session_backend.supports_persistence() { + if !session_backend.supports_persistence() { return fallback; } - let session_name = self.session_backend.session_name(terminal_id); - let extra_env = self.extra_env.lock().clone(); - match self.session_backend.build_command(&session_name, cwd, custom_command, &extra_env) { + let session_name = session_backend.session_name(terminal_id); + match session_backend.build_command_with_custom( + &session_name, + cwd, + custom_command, + launch_environment, + ) { Some((program, args)) => { let mut cmd = CommandBuilder::new(program); for arg in args { @@ -524,9 +1141,9 @@ impl PtyManager { } }; - let (mut cmd, wsl_distro, wsl_backend) = match shell { - Some(ShellType::Wsl { distro }) => { - let wsl_backend = resolve_for_wsl(distro.as_deref(), self.session_backend_preference); + let (mut cmd, wsl_distro, wsl_backend) = match &plan.route { + ShellType::Wsl { distro } => { + let wsl_backend = resolve_for_wsl(distro.as_deref(), session_backend_preference); let session_name = wsl_backend.session_name(terminal_id); let wsl_cwd = windows_path_to_wsl(cwd); @@ -535,6 +1152,7 @@ impl PtyManager { &session_name, &wsl_cwd, custom_command, + launch_environment, ) { let mut cmd = CommandBuilder::new(program); for arg in args { @@ -542,18 +1160,34 @@ impl PtyManager { } (cmd, distro.clone(), Some(wsl_backend)) } else { - ( - ShellType::Wsl { distro: distro.clone() }.build_command(cwd), - distro.clone(), - None, - ) + let mut cmd = ShellType::Wsl { + distro: distro.clone(), + } + .build_command(cwd); + if let Some(command) = &plan.initial_command { + cmd.arg("--"); + append_wsl_environment(&mut cmd, launch_environment); + cmd.arg(&command.program); + for arg in &command.args { + cmd.arg(arg); + } + } + (cmd, distro.clone(), None) } } - Some(shell_type) => (wrap_with_host_backend(shell_type.build_command(cwd)), None, None), - None => { - let mut default_cmd = CommandBuilder::new_default_prog(); - default_cmd.cwd(cwd); - (wrap_with_host_backend(default_cmd), None, None) + shell_type => { + let fallback = match &plan.initial_command { + Some(command) => { + let mut cmd = CommandBuilder::new(&command.program); + for arg in &command.args { + cmd.arg(arg); + } + cmd.cwd(cwd); + cmd + } + None => shell_type.build_command(cwd), + }; + (wrap_with_host_backend(fallback), None, None) } }; @@ -594,6 +1228,7 @@ impl PtyManager { shutdown: Arc, child_pid: Option, output_sink: Option>, + instances: Arc>, ) { // Use larger buffer like alacritty (they use 1MB, we use 64KB) let mut buf = [0u8; 65536]; @@ -608,6 +1243,7 @@ impl PtyManager { let exit_code = child_pid.and_then(wait_for_exit_code); let _ = tx.send_blocking(PtyEvent::Exit { terminal_id, + generation: shutdown.generation, exit_code, }); break; @@ -617,16 +1253,33 @@ impl PtyManager { break; } let data = buf[..n].to_vec(); - log::debug!("PTY {} received {} bytes: {:?}", terminal_id, n, String::from_utf8_lossy(&data[..n.min(100)])); + log::debug!( + "PTY {} received {} bytes: {:?}", + terminal_id, + n, + String::from_utf8_lossy(&data[..n.min(100)]) + ); + okena_core::latency_probe::daemon_pty_output_received(&terminal_id); // Broadcast to external consumers immediately (bypasses UI event loop) - if let Some(ref sink) = output_sink { - sink.publish(terminal_id.clone(), data.clone()); - } + let sequence = { + let instances = instances.lock(); + if !instances.is_current(&terminal_id, shutdown.generation) { + break; + } + output_sink + .as_ref() + .map_or(0, |sink| sink.publish(terminal_id.clone(), data.clone())) + }; // send_blocking will block when channel is full (backpressure) - if tx.send_blocking(PtyEvent::Data { - terminal_id: terminal_id.clone(), - data, - }).is_err() { + if tx + .send_blocking(PtyEvent::Data { + terminal_id: terminal_id.clone(), + generation: shutdown.generation, + data, + sequence, + }) + .is_err() + { // Receiver dropped - app is shutting down break; } @@ -638,6 +1291,7 @@ impl PtyManager { let exit_code = child_pid.and_then(wait_for_exit_code); let _ = tx.send_blocking(PtyEvent::Exit { terminal_id, + generation: shutdown.generation, exit_code, }); break; @@ -646,9 +1300,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,15 +1319,18 @@ impl PtyManager { } // Write the batched data - if let Err(e) = writer.write_all(&batch) { + okena_core::latency_probe::daemon_pty_write_started(&terminal_id); + 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 { terminal_id, + generation: shutdown.generation, exit_code: None, }); break; } + okena_core::latency_probe::daemon_pty_write_completed(&terminal_id); } } @@ -680,11 +1339,33 @@ impl PtyManager { /// which batches writes for better performance. pub fn send_input(&self, terminal_id: &str, data: &[u8]) { if let Some(handle) = self.terminals.lock().get(terminal_id) - && let Some(input_tx) = handle.input_tx.as_ref() { + && let Some(input_tx) = handle.input_tx.as_ref() + { + okena_core::latency_probe::daemon_pty_queued(terminal_id); let _ = input_tx.send(data.to_vec()); } } + /// 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) @@ -694,39 +1375,122 @@ impl PtyManager { cols, pixel_width: 0, pixel_height: 0, - }) { - log::error!("Failed to resize PTY: {}", e); - } + }) + { + log::error!("Failed to resize PTY: {}", e); + } // Notify remote clients about the resize so they can update their grids. // Carry the current resize authority so a client knows whether this // 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, + ); } } /// Kill a terminal /// Also kills the underlying tmux/screen session if applicable pub fn kill(&self, terminal_id: &str) { - // Remove handle from map immediately (fast, non-blocking). - // `handle` may be `None` if `cleanup_exited` already took it on PTY EOF - // (the double-fire). In that case the enqueued job does ONLY the session - // kill below — SIGTERMing the lingering session/daemon after the client EOF'd. - let handle = self.terminals.lock().remove(terminal_id); - let session_backend = self.session_backend; + let (handle, exited) = { + let mut instances = self.instances.lock(); + while instances.creating.contains_key(terminal_id) { + self.instance_changed.wait(&mut instances); + } + let mut terminals = self.terminals.lock(); + let handle = terminals.remove(terminal_id); + if let Some(handle) = handle.as_ref() { + instances.remove_current(terminal_id, handle.generation); + } + let exited = instances.exited.remove(terminal_id); + instances.queue_session_kill(terminal_id); + (handle, exited) + }; + self.enqueue_session_kill(terminal_id, handle, exited); + } + + /// Kill a persisted session using its load-time route when no PTY exists. + pub fn kill_session(&self, teardown: &TerminalSessionTeardown) { + match &teardown.route { + TerminalTeardownRoute::Host => self.kill(&teardown.terminal_id), + #[cfg(windows)] + TerminalTeardownRoute::Wsl { distro, backend } => { + let (handle, exited) = { + let mut instances = self.instances.lock(); + while instances.creating.contains_key(&teardown.terminal_id) { + self.instance_changed.wait(&mut instances); + } + let mut terminals = self.terminals.lock(); + let handle = terminals.remove(&teardown.terminal_id); + if let Some(handle) = handle.as_ref() { + instances.remove_current(&teardown.terminal_id, handle.generation); + } + let exited = instances.exited.remove(&teardown.terminal_id); + instances.queue_session_kill(&teardown.terminal_id); + (handle, exited) + }; + self.enqueue_session_kill_with_route( + &teardown.terminal_id, + handle, + exited, + distro.clone(), + *backend, + ); + } + } + } + + /// Kill the persistent session only if this exact exited generation still + /// owns the logical terminal ID. A reconnect either replaces the exit claim + /// or waits for the queued backend kill to finish. + pub fn kill_exited(&self, terminal_id: &str, generation: PtyGeneration) -> bool { + let (handle, exited) = { + let mut instances = self.instances.lock(); + let Some(exited) = instances.claim_exited(terminal_id, generation) else { + return false; + }; + let mut terminals = self.terminals.lock(); + let handle = terminals + .get(terminal_id) + .is_some_and(|handle| handle.generation == generation) + .then(|| terminals.remove(terminal_id)) + .flatten(); + instances.queue_session_kill(terminal_id); + (handle, exited) + }; + self.enqueue_session_kill(terminal_id, handle, Some(exited)); + true + } + + fn enqueue_session_kill( + &self, + terminal_id: &str, + handle: Option, + _exited: Option, + ) { + let session_backend = self.session_backend(); let session_name = session_backend.session_name(terminal_id); // Read WSL info before moving the handle #[cfg(windows)] - let wsl_distro = handle.as_ref().and_then(|h| h.wsl_distro.clone()); - #[cfg(windows)] - let wsl_backend = handle.as_ref().and_then(|h| h.wsl_backend); + let (wsl_distro, wsl_backend) = + Self::wsl_teardown_target(handle.as_ref(), _exited.as_ref()); let job = TeardownJob { handle, kind: TeardownKind::KillSession { + terminal_id: terminal_id.to_string(), session_backend, session_name, #[cfg(windows)] @@ -734,30 +1498,110 @@ impl PtyManager { #[cfg(windows)] wsl_backend, }, + pending_session_kill: Some(PendingSessionKill { + terminal_id: terminal_id.to_string(), + instances: Arc::clone(&self.instances), + changed: Arc::clone(&self.instance_changed), + }), }; self.enqueue_teardown(job); } + #[cfg(windows)] + fn enqueue_session_kill_with_route( + &self, + terminal_id: &str, + handle: Option, + _exited: Option, + wsl_distro: Option, + wsl_backend: ResolvedBackend, + ) { + let session_name = wsl_backend.session_name(terminal_id); + self.enqueue_teardown(TeardownJob { + handle, + kind: TeardownKind::KillSession { + terminal_id: terminal_id.to_string(), + session_backend: self.session_backend(), + session_name, + wsl_distro, + wsl_backend: Some(wsl_backend), + }, + pending_session_kill: Some(PendingSessionKill { + terminal_id: terminal_id.to_string(), + instances: Arc::clone(&self.instances), + changed: Arc::clone(&self.instance_changed), + }), + }); + } + + #[cfg(windows)] + fn wsl_teardown_target( + handle: Option<&PtyHandle>, + exited: Option<&ExitedPty>, + ) -> (Option, Option) { + let distro = handle + .and_then(|handle| handle.wsl_distro.clone()) + .or_else(|| exited.and_then(|exited| exited.wsl_distro.clone())); + let backend = handle + .and_then(|handle| handle.wsl_backend) + .or_else(|| exited.and_then(|exited| exited.wsl_backend)); + (distro, backend) + } + /// Hand a teardown job to the shared worker pool. If the channel is closed /// (only happens once `Drop` has taken the sender during quit), run the teardown /// inline so a handle is never silently leaked — better to block briefly on the /// calling thread than to drop reader/writer threads on the floor. fn enqueue_teardown(&self, job: TeardownJob) { + self.teardown_tracker.queued(); match self.teardown_tx.as_ref() { Some(tx) => { if let Err(e) = tx.send_blocking(job) { log::warn!("teardown channel closed; running teardown inline"); - Self::run_teardown_job(e.into_inner()); + self.run_tracked_teardown(e.into_inner()); } } // Sender already taken by Drop — fall back to inline teardown. - None => Self::run_teardown_job(job), + None => { + self.run_tracked_teardown(job); + } } } + fn run_tracked_teardown(&self, job: TeardownJob) { + if let Err(panic) = std::panic::catch_unwind(AssertUnwindSafe(|| { + Self::run_teardown_job(job, &self.teardown_tracker, self.reaper_tx.as_ref()) + })) { + log::error!("PTY teardown panicked: {}", format_panic(&*panic)); + } + self.teardown_tracker.completed(); + } + + /// Block until every teardown job queued before this call has completed. + /// + /// Normal kill paths remain asynchronous; graceful shutdown calls this once + /// after enqueueing the persistent-session kills it must not abandon. + pub fn flush_teardown(&self) { + self.teardown_tracker.flush(); + } + + /// Wait only a bounded interval for queued teardown and detached reapers. + /// + /// `false` means the wait timed out, or one of `terminal_ids` could not be + /// verified as having released its persistent session — i.e. a process may + /// still own its former working directory. Pass the terminals the caller is + /// about to delete; an empty slice asks only about the drain. + pub fn flush_teardown_with_timeout(&self, timeout: Duration, terminal_ids: &[String]) -> bool { + self.teardown_tracker.flush_timeout(timeout, terminal_ids) + } + /// Perform coordinated shutdown of a single PTY handle - fn shutdown_handle(mut handle: PtyHandle) { - let id = &handle.shutdown.terminal_id; + fn shutdown_handle( + mut handle: PtyHandle, + tracker: Option<&Arc>, + reaper_tx: Option<&Sender>, + ) { + let id = handle.shutdown.terminal_id.clone(); // 1. Signal shutdown to threads handle.shutdown.mark_broken(); @@ -773,42 +1617,107 @@ impl PtyManager { // 4. Drop master - safety net to unblock reader if still stuck drop(handle.master.take()); - // 5. Join writer thread (should exit quickly after input_tx drop) + // 5. Join writer thread (should exit quickly after input_tx drop), then + // close the manager's synchronous-response writer clone. Keeping this + // clone alive while waiting for the child leaves the PTY master open and + // can prevent session clients such as dtach from completing their exit. if let Some(h) = handle.writer_handle.take() - && let Err(e) = h.join() { - log::warn!("PTY writer thread panicked on join: {}", format_panic(&*e)); + && let Err(e) = h.join() + { + log::warn!("PTY writer thread panicked on join: {}", format_panic(&*e)); + } + drop(handle.writer.take()); + + // 6. Decide whether the child has exited BEFORE joining the reader. A + // child that ignores termination can retain its slave PTY indefinitely, + // which in turn keeps the reader blocked; joining it here would consume + // one of the bounded teardown workers forever. + match handle.child.try_wait() { + Ok(Some(_)) => { + // Exit is confirmed, so EOF should be available after every + // manager-held master clone above was dropped. Joining here keeps + // normal teardown synchronous and catches reader panics. + join_reader_handle(handle.reader_handle.take(), &id); } - - // 6. Join reader thread (should exit after child kill + master drop) - if let Some(h) = handle.reader_handle.take() - && let Err(e) = h.join() { - log::warn!("PTY reader thread panicked on join: {}", format_panic(&*e)); + Err(e) => { + // An indeterminate child status must not synchronously join the + // reader: a transient wait error can still leave both child and + // reader live. Retain the handle in the bounded reaper instead. + log::debug!("PTY child {} status is indeterminate: {}", id, e); + Self::retain_in_reaper(handle, &id, tracker, reaper_tx); + } + Ok(None) => { + // Transfer the still-live handle to the manager-owned fixed reaper + // pool. The extra tracker count is the destructive-operation gate: + // worktree removal may proceed only after this child exits and its + // reader has joined. Normal manager Drop never flushes this count. + Self::retain_in_reaper(handle, &id, tracker, reaper_tx); } + } + } - // 7. Reap the child to prevent a zombie. The reader normally reaps via - // `wait_for_exit_code` on EOF, but that is a bounded `WNOHANG` poll that - // gives up if the SIGKILL'd child is briefly unreapable (e.g. stuck in - // D-state on slow IO). Now that the reader has joined, a blocking wait - // guarantees the PID is reaped. If the reader already reaped it via raw - // `waitpid`, this just returns ECHILD, which is harmless. - if let Err(e) = handle.child.wait() { - log::debug!("PTY child {} already reaped or wait failed: {}", id, e); + /// Hand a possibly-live handle to the bounded reaper pool. The tracker count + /// is taken only while a reaper owns the job, so `queued`/`completed` can + /// never disagree — an uncounted job would let a destructive flush finish + /// early, and an unmatched count would hang it forever. + fn retain_in_reaper( + handle: PtyHandle, + id: &str, + tracker: Option<&Arc>, + reaper_tx: Option<&Sender>, + ) { + let Some(tx) = reaper_tx else { + // Reachable from direct unit-test helpers and from teardown that + // races manager Drop. Keep the handle alive rather than silently + // dropping a CWD-owning child. + log::error!("PTY reaper unavailable for {}; retaining live handle", id); + std::mem::forget(handle); + return; + }; + if let Some(tracker) = tracker { + tracker.queued(); + } + if let Err(error) = tx.send_blocking(ReaperJob { handle }) { + // A live handle must never fall through to PtyHandle::Drop. A closed + // reaper queue can only occur during manager drop; retain it until + // process exit rather than blocking shutdown, and release the count + // again because no reaper will ever complete it. + if let Some(tracker) = tracker { + tracker.completed(); + } + log::error!("PTY reaper queue closed for {}; retaining live handle", id); + std::mem::forget(error.into_inner()); } } /// Detach from all terminals without killing sessions /// Sessions will persist and can be reconnected on next app start pub fn detach_all(&self) { - // Drain all handles while holding the lock, then release lock before joining + // Drain all handles in lifecycle lock order, then release both locks + // before joining worker threads. + let mut instances = self.instances.lock(); let handles: Vec = self.terminals.lock().drain().map(|(_, h)| h).collect(); + for handle in &handles { + instances.remove_current(&handle.shutdown.terminal_id, handle.generation); + } + drop(instances); for handle in handles { - Self::shutdown_handle(handle); + // Pass the tracker even though only `Drop` calls this today: a + // handle that reaches the reaper is always counted there, so the + // pair can never disagree if this is ever wired to a live path. + Self::shutdown_handle( + handle, + Some(&self.teardown_tracker), + self.reaper_tx.as_ref(), + ); } } /// Get the shell process PID for a terminal pub fn get_shell_pid(&self, terminal_id: &str) -> Option { - self.terminals.lock().get(terminal_id) + self.terminals + .lock() + .get(terminal_id) .and_then(|h| h.child.process_id()) } @@ -821,9 +1730,11 @@ impl PtyManager { pub fn get_foreground_shell_pid(&self, terminal_id: &str) -> Option { #[cfg(unix)] { - match self.session_backend { + match self.session_backend() { ResolvedBackend::Dtach => { - if let Some(daemon) = self.get_dtach_service_pids(terminal_id).into_iter().next() { + if let Some(daemon) = + self.get_dtach_service_pids(terminal_id).into_iter().next() + { return first_proc_child(daemon).or(Some(daemon)); } } @@ -844,7 +1755,7 @@ impl PtyManager { pub fn get_service_pids(&self, terminal_id: &str) -> Vec { #[cfg(unix)] { - match self.session_backend { + match self.session_backend() { ResolvedBackend::Dtach => { return self.get_dtach_service_pids(terminal_id); } @@ -862,8 +1773,9 @@ impl PtyManager { /// Linux — a per-poll `lsof -t` was ~1s each). #[cfg(unix)] fn get_dtach_service_pids(&self, terminal_id: &str) -> Vec { - let session_name = self.session_backend.session_name(terminal_id); - let socket_path = match self.session_backend.socket_path(&session_name) { + let session_backend = self.session_backend(); + let session_name = session_backend.session_name(terminal_id); + let socket_path = match session_backend.socket_path(&session_name) { Some(p) if p.exists() => p, _ => return self.get_shell_pid(terminal_id).into_iter().collect(), }; @@ -888,11 +1800,14 @@ impl PtyManager { /// Find the shell PID inside a tmux session pane. #[cfg(unix)] fn get_tmux_service_pids(&self, terminal_id: &str) -> Vec { - let session_name = self.session_backend.session_name(terminal_id); - let output = match crate::process::safe_output( - crate::process::command("tmux") - .args(["list-panes", "-t", &session_name, "-F", "#{pane_pid}"]), - ) { + let session_name = self.session_backend().session_name(terminal_id); + let output = match crate::process::safe_output(crate::process::command("tmux").args([ + "list-panes", + "-t", + &session_name, + "-F", + "#{pane_pid}", + ])) { Ok(o) if o.status.success() => o, _ => return self.get_shell_pid(terminal_id).into_iter().collect(), }; @@ -915,7 +1830,7 @@ impl PtyManager { pub fn get_batch_service_pids(&self, terminal_ids: &[&str]) -> HashMap> { #[cfg(unix)] { - if self.session_backend == ResolvedBackend::Dtach { + if self.session_backend() == ResolvedBackend::Dtach { return self.get_batch_dtach_service_pids(terminal_ids); } } @@ -930,29 +1845,30 @@ impl PtyManager { /// once for all sockets. On other Unix, falls back to lsof per terminal. #[cfg(unix)] fn get_batch_dtach_service_pids(&self, terminal_ids: &[&str]) -> HashMap> { + let session_backend = self.session_backend(); // Collect socket paths for all terminals let mut socket_to_terminal: HashMap = HashMap::new(); let mut attach_pids: HashMap<&str, Option> = HashMap::new(); for &tid in terminal_ids { - let session_name = self.session_backend.session_name(tid); - if let Some(p) = self.session_backend.socket_path(&session_name) - && p.exists() { - socket_to_terminal.insert(p, tid); - attach_pids.insert(tid, self.get_shell_pid(tid)); - } + let session_name = session_backend.session_name(tid); + if let Some(p) = session_backend.socket_path(&session_name) + && p.exists() + { + socket_to_terminal.insert(p, tid); + attach_pids.insert(tid, self.get_shell_pid(tid)); + } } // Resolve PIDs for all sockets at once - let socket_pids = find_pids_for_unix_sockets( - &socket_to_terminal.keys().cloned().collect::>(), - ); + let socket_pids = + find_pids_for_unix_sockets(&socket_to_terminal.keys().cloned().collect::>()); // Build result map let mut result: HashMap> = HashMap::new(); for &tid in attach_pids.keys() { - let session_name = self.session_backend.session_name(tid); - let socket_path = match self.session_backend.socket_path(&session_name) { + let session_name = session_backend.session_name(tid); + let socket_path = match session_backend.socket_path(&session_name) { Some(p) => p, None => { result.insert( @@ -996,7 +1912,7 @@ impl PtyManager { /// Check if the session backend handles mouse events (tmux with mouse on) pub fn uses_mouse_backend(&self) -> bool { - matches!(self.session_backend, ResolvedBackend::Tmux) + matches!(self.session_backend(), ResolvedBackend::Tmux) } /// Capture the terminal buffer to a file (only works with tmux backend) @@ -1021,7 +1937,14 @@ impl PtyManager { cmd.args(["-d", d]); } cmd.args([ - "--", "tmux", "capture-pane", "-t", &session_name, "-p", "-S", "-", + "--", + "tmux", + "capture-pane", + "-t", + &session_name, + "-p", + "-S", + "-", ]); return match crate::process::safe_output(&mut cmd) { Ok(output) if output.status.success() => { @@ -1052,23 +1975,26 @@ impl PtyManager { } } - if !matches!(self.session_backend, ResolvedBackend::Tmux) { + if !matches!(self.session_backend(), ResolvedBackend::Tmux) { log::warn!("Buffer capture only supported with tmux backend"); return None; } - let session_name = self.session_backend.session_name(terminal_id); - let output_path = std::env::temp_dir().join(format!("terminal-{}.txt", &terminal_id[..8.min(terminal_id.len())])); + let session_name = self.session_backend().session_name(terminal_id); + let output_path = std::env::temp_dir().join(format!( + "terminal-{}.txt", + &terminal_id[..8.min(terminal_id.len())] + )); // Use tmux capture-pane to get the entire scrollback buffer - let result = crate::process::safe_output( - crate::process::command("tmux").args([ - "capture-pane", - "-t", &session_name, - "-p", // output to stdout - "-S", "-", // start from beginning of scrollback - ]), - ); + let result = crate::process::safe_output(crate::process::command("tmux").args([ + "capture-pane", + "-t", + &session_name, + "-p", // output to stdout + "-S", + "-", // start from beginning of scrollback + ])); match result { Ok(output) if output.status.success() => { @@ -1084,7 +2010,10 @@ impl PtyManager { } } Ok(output) => { - log::error!("tmux capture-pane failed: {}", String::from_utf8_lossy(&output.stderr)); + log::error!( + "tmux capture-pane failed: {}", + String::from_utf8_lossy(&output.stderr) + ); None } Err(e) => { @@ -1096,13 +2025,37 @@ impl PtyManager { /// Check if buffer capture is supported (tmux backend) pub fn supports_buffer_capture(&self) -> bool { - matches!(self.session_backend, ResolvedBackend::Tmux) + matches!(self.session_backend(), ResolvedBackend::Tmux) } /// Clean up a PtyHandle after the process exited naturally (reader got EOF). /// Removes the handle from the internal map and joins threads in the background. - pub fn cleanup_exited(&self, terminal_id: &str) { - let handle = self.terminals.lock().remove(terminal_id); + pub fn cleanup_exited(&self, terminal_id: &str, generation: PtyGeneration) -> bool { + let mut instances = self.instances.lock(); + if !instances.claim_exit(terminal_id, generation) { + return false; + } + let handle = { + let mut terminals = self.terminals.lock(); + if terminals + .get(terminal_id) + .is_some_and(|handle| handle.generation == generation) + { + terminals.remove(terminal_id) + } else { + None + } + }; + #[cfg(windows)] + if let Some(handle) = handle.as_ref() { + instances.record_exited_wsl_metadata( + terminal_id, + generation, + handle.wsl_distro.clone(), + handle.wsl_backend, + ); + } + drop(instances); if let Some(handle) = handle { // Process already EOF'd — only reap the reader/writer threads. The later // `kill()` in the exit-events loop does the session kill (and finds the @@ -1110,8 +2063,10 @@ impl PtyManager { self.enqueue_teardown(TeardownJob { handle: Some(handle), kind: TeardownKind::ReapOnly, + pending_session_kill: None, }); } + true } } @@ -1120,6 +2075,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) } @@ -1143,6 +2102,7 @@ impl Drop for PtyManager { // teardown of already-enqueued jobs is best-effort at quit — the process may // exit before slow jobs finish, which is acceptable for graceful detach. drop(self.teardown_tx.take()); + drop(self.reaper_tx.take()); } } @@ -1246,9 +2206,10 @@ fn find_pids_for_unix_sockets_linux( if let Some(&orig) = canonical_paths.get(path) { inode_to_path.insert(inode, orig); } else if let Ok(canon) = std::fs::canonicalize(path) - && let Some(&orig) = canonical_paths.get(&canon) { - inode_to_path.insert(inode, orig); - } + && let Some(&orig) = canonical_paths.get(&canon) + { + inode_to_path.insert(inode, orig); + } } if inode_to_path.is_empty() { @@ -1290,14 +2251,12 @@ fn find_pids_for_unix_sockets_linux( .strip_prefix("socket:[") .and_then(|s| s.strip_suffix(']')) && let Ok(inode) = inode_str.parse::() - && let Some(&socket_path) = inode_to_path.get(&inode) { - result - .entry(socket_path.clone()) - .or_default() - .push(pid); - } - // Early exit if we found all inodes - // (not worth the bookkeeping for a small set) + && let Some(&socket_path) = inode_to_path.get(&inode) + { + result.entry(socket_path.clone()).or_default().push(pid); + } + // Early exit if we found all inodes + // (not worth the bookkeeping for a small set) } } @@ -1364,7 +2323,10 @@ fn find_pids_for_unix_sockets_lsof( fn first_proc_child(pid: u32) -> Option { let path = format!("/proc/{}/task/{}/children", pid, pid); let contents = std::fs::read_to_string(path).ok()?; - contents.split_whitespace().next().and_then(|s| s.parse().ok()) + contents + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) } #[cfg(target_os = "macos")] @@ -1391,3 +2353,525 @@ fn first_proc_child(pid: u32) -> Option { fn first_proc_child(_pid: u32) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn generation_rejects_delayed_and_duplicate_exits() { + let mut instances = PtyInstances::default(); + let old = PtyGeneration(1); + let current = PtyGeneration(2); + + instances.publish("t1", old); + instances.publish("t1", current); + + assert!(!instances.claim_exit("t1", old)); + assert!(instances.is_current("t1", current)); + assert!(instances.claim_exit("t1", current)); + assert!(!instances.claim_exit("t1", current)); + } + + #[cfg(windows)] + #[test] + fn exited_generation_retains_wsl_teardown_target() { + let mut instances = PtyInstances::default(); + let generation = PtyGeneration(1); + instances.publish("wsl-terminal", generation); + assert!(instances.claim_exit("wsl-terminal", generation)); + + instances.record_exited_wsl_metadata( + "wsl-terminal", + generation, + Some("Ubuntu".to_string()), + Some(ResolvedBackend::Dtach), + ); + let exited = instances + .claim_exited("wsl-terminal", generation) + .expect("exited generation"); + + assert_eq!(exited.wsl_distro.as_deref(), Some("Ubuntu")); + assert_eq!(exited.wsl_backend, Some(ResolvedBackend::Dtach)); + assert_eq!( + PtyManager::wsl_teardown_target(None, Some(&exited)), + (Some("Ubuntu".to_string()), Some(ResolvedBackend::Dtach)) + ); + } + + #[test] + fn reconnect_replaces_old_exit_claim_before_session_kill() { + let (manager, _events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let terminal_id = manager + .create_or_reconnect_terminal(Some("generation-reconnect"), &cwd) + .expect("create old generation"); + let old_generation = manager + .current_generation(&terminal_id) + .expect("old generation"); + + assert!(manager.cleanup_exited(&terminal_id, old_generation)); + manager + .create_or_reconnect_terminal(Some(&terminal_id), &cwd) + .expect("reserve new generation"); + let new_generation = manager + .current_generation(&terminal_id) + .expect("new generation"); + + assert_ne!(old_generation, new_generation); + assert!(!manager.kill_exited(&terminal_id, old_generation)); + assert!(manager.is_current_generation(&terminal_id, new_generation)); + manager.kill(&terminal_id); + manager.flush_teardown(); + } + + #[test] + #[cfg(unix)] + fn immediate_exit_is_observed_after_handle_publication() { + let (manager, events) = PtyManager::new(SessionBackend::None); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let shell = ShellType::Custom { + path: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 0".to_string()], + }; + let terminal_id = manager + .create_terminal_with_shell(&cwd, Some(&shell)) + .expect("create immediate-exit PTY"); + + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let generation = loop { + match events.try_recv() { + Ok(PtyEvent::Exit { + terminal_id: exited_id, + generation, + .. + }) if exited_id == terminal_id => break generation, + Ok(_) | Err(async_channel::TryRecvError::Empty) + if std::time::Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(_) | Err(async_channel::TryRecvError::Empty) => { + panic!("immediate process did not emit Exit") + } + Err(async_channel::TryRecvError::Closed) => panic!("PTY event channel closed"), + } + }; + + assert!(manager.cleanup_exited(&terminal_id, generation)); + assert!(!manager.terminals.lock().contains_key(&terminal_id)); + manager.flush_teardown(); + } + + #[cfg(unix)] + #[derive(Clone, Debug)] + struct DelayedTerminationChild { + release: Arc<(Mutex, Condvar)>, + try_wait_error: bool, + } + + #[cfg(unix)] + impl portable_pty::ChildKiller for DelayedTerminationChild { + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn clone_killer(&self) -> Box { + Box::new(self.clone()) + } + } + + #[cfg(unix)] + impl Child for DelayedTerminationChild { + fn try_wait(&mut self) -> std::io::Result> { + if self.try_wait_error { + return Err(std::io::Error::other("indeterminate child status")); + } + let released = *self.release.0.lock(); + Ok(released.then(|| portable_pty::ExitStatus::with_exit_code(0))) + } + + fn wait(&mut self) -> std::io::Result { + let mut released = self.release.0.lock(); + while !*released { + self.release.1.wait(&mut released); + } + Ok(portable_pty::ExitStatus::with_exit_code(0)) + } + + fn process_id(&self) -> Option { + None + } + } + + #[cfg(unix)] + #[test] + fn shutdown_transfers_try_wait_errors_with_blocking_wait_to_reaper() { + let child_release = Arc::new((Mutex::new(false), Condvar::new())); + let reader_release = Arc::new((Mutex::new(false), Condvar::new())); + let reader_wait = reader_release.clone(); + let (reader_started_tx, reader_started_rx) = mpsc::channel(); + let (reader_done_tx, reader_done_rx) = mpsc::channel(); + let reader_handle = std::thread::spawn(move || { + reader_started_tx.send(()).expect("reader starts"); + let mut released = reader_wait.0.lock(); + while !*released { + reader_wait.1.wait(&mut released); + } + reader_done_tx.send(()).expect("reader finishes"); + }); + reader_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("reader waits before teardown"); + let handle = PtyHandle { + generation: PtyGeneration(1), + master: None, + child: Box::new(DelayedTerminationChild { + release: child_release.clone(), + try_wait_error: true, + }), + input_tx: None, + writer: None, + reader_handle: Some(reader_handle), + writer_handle: None, + shutdown: Arc::new(PtyShutdownState::new( + "non-terminating".to_string(), + PtyGeneration(1), + )), + }; + let (manager, _events) = PtyManager::new(SessionBackend::None); + let started = std::time::Instant::now(); + PtyManager::shutdown_handle( + handle, + Some(&manager.teardown_tracker), + manager.reaper_tx.as_ref(), + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "PTY teardown must not wait forever for child or reader" + ); + assert!( + reader_done_rx.try_recv().is_err(), + "reader is still owned by the manager reaper until child/reader release" + ); + *child_release.0.lock() = true; + child_release.1.notify_all(); + *reader_release.0.lock() = true; + reader_release.1.notify_all(); + reader_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("manager reaper joins released reader"); + assert!(manager.flush_teardown_with_timeout(Duration::from_secs(1), &[])); + } + + #[cfg(unix)] + #[test] + fn shutdown_drops_manager_writer_clone_before_waiting_for_reader_eof() { + struct DropNotifyingWriter(Option>); + + impl Write for DropNotifyingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl Drop for DropNotifyingWriter { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + + let child_release = Arc::new((Mutex::new(true), Condvar::new())); + let (writer_dropped_tx, writer_dropped_rx) = mpsc::channel(); + let (reader_done_tx, reader_done_rx) = mpsc::channel(); + let reader_handle = std::thread::spawn(move || { + writer_dropped_rx + .recv_timeout(Duration::from_secs(1)) + .expect("manager writer clone is dropped before reader join"); + reader_done_tx.send(()).expect("reader reaches EOF"); + }); + let handle = PtyHandle { + generation: PtyGeneration(1), + master: None, + child: Box::new(DelayedTerminationChild { + release: child_release, + try_wait_error: false, + }), + input_tx: None, + writer: Some(Arc::new(Mutex::new(Box::new(DropNotifyingWriter(Some( + writer_dropped_tx, + )))))), + reader_handle: Some(reader_handle), + writer_handle: None, + shutdown: Arc::new(PtyShutdownState::new( + "writer-clone".to_string(), + PtyGeneration(1), + )), + }; + + PtyManager::shutdown_handle(handle, None, None); + reader_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("reader joins after observing writer clone closure"); + } + + #[cfg(unix)] + #[test] + fn stuck_children_use_fixed_reaper_pool_and_keep_flush_pending() { + const STUCK_CHILDREN: usize = REAPER_WORKERS + 5; + let (manager, _events) = PtyManager::new(SessionBackend::None); + let worker_deadline = std::time::Instant::now() + Duration::from_secs(1); + while manager.reaper_worker_count.load(Ordering::Acquire) != REAPER_WORKERS { + assert!( + std::time::Instant::now() < worker_deadline, + "fixed reaper workers did not start" + ); + std::thread::sleep(Duration::from_millis(5)); + } + + let child_release = Arc::new((Mutex::new(false), Condvar::new())); + let reader_release = Arc::new((Mutex::new(false), Condvar::new())); + let (reader_done_tx, reader_done_rx) = mpsc::channel(); + for index in 0..STUCK_CHILDREN { + let reader_wait = Arc::clone(&reader_release); + let reader_done_tx = reader_done_tx.clone(); + let reader_handle = std::thread::spawn(move || { + let mut released = reader_wait.0.lock(); + while !*released { + reader_wait.1.wait(&mut released); + } + reader_done_tx.send(index).expect("reader finishes"); + }); + let handle = PtyHandle { + generation: PtyGeneration(index as u64 + 1), + master: None, + child: Box::new(DelayedTerminationChild { + release: Arc::clone(&child_release), + try_wait_error: false, + }), + input_tx: None, + writer: None, + reader_handle: Some(reader_handle), + writer_handle: None, + shutdown: Arc::new(PtyShutdownState::new( + format!("stuck-{index}"), + PtyGeneration(index as u64 + 1), + )), + }; + PtyManager::shutdown_handle( + handle, + Some(&manager.teardown_tracker), + manager.reaper_tx.as_ref(), + ); + } + + assert_eq!( + manager.reaper_worker_count.load(Ordering::Acquire), + REAPER_WORKERS, + "N stuck children must not create N reaper threads" + ); + assert_eq!(*manager.teardown_tracker.pending.lock(), STUCK_CHILDREN); + assert!( + !manager.flush_teardown_with_timeout(Duration::from_millis(50), &[]), + "destructive flush must remain blocked while children may own a CWD" + ); + + *child_release.0.lock() = true; + child_release.1.notify_all(); + *reader_release.0.lock() = true; + reader_release.1.notify_all(); + for _ in 0..STUCK_CHILDREN { + reader_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("all reaper-owned readers finish"); + } + assert!(manager.flush_teardown_with_timeout(Duration::from_secs(1), &[])); + } + + #[cfg(unix)] + #[test] + fn dtach_kill_does_not_stall_teardown_flush() { + if SessionBackend::Dtach.resolve() != ResolvedBackend::Dtach { + return; + } + + let (manager, _events) = PtyManager::new(SessionBackend::Dtach); + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + let plan = TerminalLaunchPlan { + route: ShellType::Default, + initial_command: Some(crate::backend::TerminalLaunchCommand { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "sleep 30".to_string()], + }), + environment: Vec::new(), + }; + let terminal_id = manager + .create_terminal_with_plan(&cwd, &plan) + .expect("create dtach-backed PTY"); + let session_name = ResolvedBackend::Dtach.session_name(&terminal_id); + let socket_path = ResolvedBackend::Dtach + .socket_path(&session_name) + .expect("dtach socket path"); + let socket_deadline = std::time::Instant::now() + Duration::from_secs(2); + while !socket_path.exists() && std::time::Instant::now() < socket_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(socket_path.exists(), "dtach session socket was not created"); + + manager.kill(&terminal_id); + let (done_tx, done_rx) = mpsc::channel(); + std::thread::spawn(move || { + manager.flush_teardown(); + let _ = done_tx.send(()); + }); + + done_rx + .recv_timeout(Duration::from_secs(3)) + .expect("dtach teardown flush must complete"); + assert!( + !socket_path.exists(), + "dtach session socket must be removed" + ); + } + + #[derive(Default)] + struct RecordingSink { + published: Mutex)>>, + } + + impl PtyOutputSink for RecordingSink { + fn publish(&self, terminal_id: String, data: Vec) -> u64 { + self.published.lock().push((terminal_id, data)); + 1 + } + } + + #[test] + fn stale_generation_output_is_not_published_to_sink() { + let old = PtyGeneration(1); + let current = PtyGeneration(2); + let instances = Arc::new(Mutex::new(PtyInstances::default())); + instances.lock().publish("reconnected", current); + let shutdown = Arc::new(PtyShutdownState::new("reconnected".to_string(), old)); + let (tx, events) = async_channel::bounded(4); + let sink = Arc::new(RecordingSink::default()); + + PtyManager::read_loop( + "reconnected".to_string(), + Box::new(std::io::Cursor::new(b"stale".to_vec())), + tx, + shutdown, + None, + Some(sink.clone()), + instances, + ); + + assert!(sink.published.lock().is_empty()); + assert!(events.try_recv().is_err()); + } + + #[test] + fn unverified_teardown_is_scoped_to_its_own_terminal() { + let tracker = TeardownTracker::default(); + tracker.mark_unverified("term-a"); + + assert!( + !tracker.flush_timeout(Duration::ZERO, &["term-a".to_string()]), + "the terminal that failed must block its own destructive flush" + ); + assert!( + tracker.flush_timeout(Duration::ZERO, &["term-b".to_string()]), + "an unrelated terminal must not inherit that failure" + ); + assert!( + !tracker.flush_timeout(Duration::ZERO, &["term-a".to_string()]), + "reading the failure must not consume it" + ); + + // A plain drain must not erase the signal either. + tracker.flush(); + assert!(!tracker.flush_timeout(Duration::ZERO, &["term-a".to_string()])); + + tracker.mark_verified("term-a"); + assert!( + tracker.flush_timeout(Duration::ZERO, &["term-a".to_string()]), + "a later successful teardown clears the terminal" + ); + } + + #[test] + fn teardown_flush_waits_for_pending_work() { + let tracker = Arc::new(TeardownTracker::default()); + tracker.queued(); + + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let waiter_tracker = Arc::clone(&tracker); + let waiter = std::thread::spawn(move || { + started_tx + .send(()) + .expect("announce teardown flush waiter start"); + waiter_tracker.flush(); + done_tx + .send(()) + .expect("announce teardown flush waiter completion"); + }); + + started_rx.recv().expect("teardown flush waiter must start"); + assert!(matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + tracker.completed(); + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("flush returns after completion"); + waiter.join().expect("teardown flush waiter must join"); + } + + #[test] + fn drained_manager_can_switch_session_backend_preference() { + let (manager, _events) = PtyManager::new(SessionBackend::None); + + manager + .begin_session_backend_reconfiguration() + .expect("begin reconfiguration"); + manager + .ensure_session_backend_reconfigurable() + .expect("new manager is drained"); + manager.apply_session_backend(SessionBackend::Tmux); + + assert_eq!(manager.session_backend_preference(), SessionBackend::Tmux); + assert!(!manager.instances.lock().backend_reconfiguration); + } + + #[test] + fn backend_reconfiguration_fence_rejects_stale_creation_until_released() { + let (manager, _events) = PtyManager::new(SessionBackend::None); + + manager + .begin_session_backend_reconfiguration() + .expect("begin reconfiguration"); + manager + .ensure_session_backend_reconfigurable() + .expect("manager remains drained"); + + let error = manager + .create_or_reconnect_terminal_with_plan( + Some("stale-launch"), + ".", + &TerminalLaunchPlan::for_shell(ShellType::Default), + ) + .expect_err("stale creation must not select the old route"); + assert!(error.to_string().contains("reconfiguration is in progress")); + assert!(manager.terminals.lock().is_empty()); + assert!(manager.instances.lock().creating.is_empty()); + + manager.cancel_session_backend_reconfiguration(); + assert!(!manager.instances.lock().backend_reconfiguration); + } +} diff --git a/crates/okena-terminal/src/session_backend.rs b/crates/okena-terminal/src/session_backend.rs index fa93c826d..78facaa5d 100644 --- a/crates/okena-terminal/src/session_backend.rs +++ b/crates/okena-terminal/src/session_backend.rs @@ -164,6 +164,27 @@ pub enum ResolvedBackend { Psmux, } +/// Exact command requested for a newly-created persistent session. +/// +/// `ShellScript` preserves the historical API used by Unix backends. `Program` +/// keeps the executable and argv boundary intact for host-Windows psmux, where +/// rebuilding a cmd wrapper from only the script would lose flags such as +/// delayed expansion (`/V:ON`). +#[derive(Debug, Clone, Copy)] +pub enum SessionCommand<'a> { + ShellScript(&'a str), + Program { + program: &'a str, + args: &'a [String], + }, +} + +/// 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 +200,7 @@ impl ResolvedBackend { } else { terminal_id }; - format!("tm-{}", short_id) + format!("{SESSION_NAME_PREFIX}{short_id}") } /// Get the socket path for dtach sessions @@ -204,6 +225,22 @@ impl ResolvedBackend { cwd: &str, command: Option<&str>, extra_env: &[(String, Option)], + ) -> Option<(String, Vec)> { + self.build_command_with_custom( + session_name, + cwd, + command.map(SessionCommand::ShellScript), + extra_env, + ) + } + + /// Build a session command while preserving an exact custom executable and argv. + pub fn build_command_with_custom( + &self, + session_name: &str, + cwd: &str, + command: Option>, + extra_env: &[(String, Option)], ) -> Option<(String, Vec)> { match self { Self::None => None, @@ -222,10 +259,16 @@ impl ResolvedBackend { // rename-window: set meaningful window name from directory let window_name = extract_dir_name(cwd); let initial_program = match command { - Some(cmd) => { + Some(SessionCommand::ShellScript(cmd)) => { let sh = user_shell(); format!(" {} '-ic' {}", shell_escape(&sh), shell_escape(cmd)) } + Some(SessionCommand::Program { program, args }) => { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(shell_escape(program)); + parts.extend(args.iter().map(|arg| shell_escape(arg))); + format!(" {}", parts.join(" ")) + } None => String::new(), }; // -e KEY=VAL flags reach the shell even when attaching to a @@ -255,24 +298,28 @@ impl ResolvedBackend { shell_escape(&window_name), unset_args ); - Some(( - "sh".to_string(), - vec!["-c".to_string(), tmux_cmd], - )) + Some(("sh".to_string(), vec!["-c".to_string(), tmux_cmd])) } Self::Screen => { // screen -D -R // -D -R: reattach if exists, create if not (and detach other attached sessions) // Note: screen doesn't have a direct way to set cwd, we'll handle that separately - let mut args = vec![ - "-D".to_string(), - "-R".to_string(), - session_name.to_string(), - ]; - if let Some(cmd) = command { - args.push(user_shell()); - args.push("-ic".to_string()); - args.push(cmd.to_string()); + let mut args = vec!["-D".to_string(), "-R".to_string(), session_name.to_string()]; + if let Some(command) = command { + match command { + SessionCommand::ShellScript(cmd) => { + args.push(user_shell()); + args.push("-ic".to_string()); + args.push(cmd.to_string()); + } + SessionCommand::Program { + program, + args: command_args, + } => { + args.push(program.to_string()); + args.extend(command_args.iter().cloned()); + } + } } Some(("screen".to_string(), args)) } @@ -288,13 +335,17 @@ impl ResolvedBackend { // 3. Run dtach with the user's shell (or custom command) let socket_path = get_dtach_socket_path(session_name); let program = match command { - Some(cmd) => { + Some(SessionCommand::ShellScript(cmd)) => { let sh = user_shell(); format!("{} -ic {}", shell_escape(&sh), shell_escape(cmd)) } - None => { - shell_escape(&user_shell()) + Some(SessionCommand::Program { program, args }) => { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(shell_escape(program)); + parts.extend(args.iter().map(|arg| shell_escape(arg))); + parts.join(" ") } + None => shell_escape(&user_shell()), }; let parent = socket_path.parent().and_then(|p| p.to_str())?; @@ -334,12 +385,22 @@ impl ResolvedBackend { args.push(session_name.to_string()); args.push("-c".to_string()); args.push(cwd.to_string()); - if let Some(cmd) = command { - // Wrap custom command in cmd.exe so PATH lookup and quoting - // behave consistently regardless of psmux's default-shell. - args.push("cmd.exe".to_string()); - args.push("/c".to_string()); - args.push(cmd.to_string()); + if let Some(command) = command { + match command { + SessionCommand::ShellScript(cmd) => { + // Preserve the legacy script API through cmd.exe. + args.push("cmd.exe".to_string()); + args.push("/c".to_string()); + args.push(cmd.to_string()); + } + SessionCommand::Program { + program, + args: command_args, + } => { + args.push(program.to_string()); + args.extend(command_args.iter().cloned()); + } + } } let push_cmd = |args: &mut Vec, parts: &[&str]| { args.push(";".to_string()); @@ -365,94 +426,583 @@ impl ResolvedBackend { } } - /// Kill a session - pub fn kill_session(&self, session_name: &str) { + /// Stop a persistent session. Success means both the kill command and a + /// bounded liveness probe confirm that the session no longer exists. + pub fn kill_session(&self, session_name: &str) -> bool { match self { - Self::None => {} - Self::Tmux => { - #[cfg(target_os = "macos")] - let _ = crate::process::safe_output( - crate::process::command("tmux") - .args(["kill-session", "-t", session_name]) - .env("PATH", get_extended_path()), - ); - - #[cfg(all(unix, not(target_os = "macos")))] - let _ = crate::process::safe_output( - crate::process::command("tmux").args(["kill-session", "-t", session_name]), - ); - } - Self::Screen => { - #[cfg(target_os = "macos")] - let _ = crate::process::safe_output( - crate::process::command("screen") - .args(["-S", session_name, "-X", "quit"]) - .env("PATH", get_extended_path()), - ); - - #[cfg(all(unix, not(target_os = "macos")))] - let _ = crate::process::safe_output( - crate::process::command("screen").args(["-S", session_name, "-X", "quit"]), - ); - } + Self::None => true, + Self::Tmux | Self::Screen | Self::Psmux => self.kill_session_with_executor( + session_name, + session_backend_output, + std::time::Duration::from_secs(2), + ), Self::Dtach => { let socket_path = get_dtach_socket_path(session_name); if socket_path.exists() { #[cfg(unix)] + if !terminate_dtach_process_tree(&socket_path, session_name) { + // Keep the socket path as the durable retry handle. Unlinking + // it while either the master or its child tree survives is + // what made leaked agent trees invisible to later cleanup. + return false; + } + + if let Err(error) = std::fs::remove_file(&socket_path) + && error.kind() != std::io::ErrorKind::NotFound { - let my_pid = std::process::id() as i32; - // Discover the PIDs holding the dtach socket open. This is a - // best-effort, point-in-time snapshot (now via the /proc socket - // scan instead of an `lsof -t` spawn), with an inherent TOCTOU - // window between reading it here and signalling below. By the - // time we call `kill`, the dtach process may have already exited - // and its PID been recycled onto an unrelated process. We accept - // this risk because there is no portable, race-free way to - // atomically "signal whoever holds this socket"; the window is - // short and the dtach socket is user-private (see - // get_dtach_socket_dir). - let holders = crate::pty_manager::find_pids_for_unix_sockets( - std::slice::from_ref(&socket_path), - ); - for &pid in holders.get(&socket_path).into_iter().flatten() { - let pid = pid as i32; - if pid == my_pid { - log::debug!("Skipping own PID {} when killing dtach session {}", pid, session_name); - continue; - } - // SAFETY: `libc::kill` is a thin FFI wrapper over the - // `kill(2)` syscall. It takes two plain `i32` values - // (a pid and a signal number) by value, dereferences no - // pointers, and has no memory-safety preconditions, so - // the call itself cannot cause UB regardless of the - // argument values. The only hazard is *logical*, not a - // memory-safety one: per the TOCTOU note above, `pid` - // may have been recycled since the scan, so we could - // signal an unrelated process. We tolerate that as - // best-effort cleanup and intentionally ignore the - // return value (the process may already be gone). - unsafe { - libc::kill(pid, libc::SIGTERM); - } - log::debug!("Sent SIGTERM to dtach process {} for session {}", pid, session_name); - } + log::error!("failed to remove dtach socket {:?}: {}", socket_path, error); + return false; } - let _ = std::fs::remove_file(&socket_path); log::debug!("Removed dtach socket: {:?}", socket_path); } + true } - Self::Psmux => { - let mut cmd = crate::process::command("psmux"); - cmd.args(["kill-session", "-t", session_name]); - let _ = crate::process::safe_output(&mut cmd); - log::debug!("Killed psmux session {}", session_name); + } + } + + fn kill_session_with_executor( + &self, + session_name: &str, + mut execute: impl FnMut(&str, &[&str]) -> std::io::Result, + timeout: std::time::Duration, + ) -> bool { + let (program, kill_args, probe_args) = match self { + Self::Tmux => ( + "tmux", + vec!["kill-session", "-t", session_name], + vec!["has-session", "-t", session_name], + ), + Self::Screen => ( + "screen", + vec!["-S", session_name, "-X", "quit"], + vec!["-S", session_name, "-Q", "select", "."], + ), + Self::Psmux => ( + "psmux", + vec!["kill-session", "-t", session_name], + vec!["has-session", "-t", session_name], + ), + Self::None | Self::Dtach => unreachable!("backend does not use command verification"), + }; + verify_session_kill( + execute(program, &kill_args), + || execute(program, &probe_args).map(|output| output.status.success()), + timeout, + ) + } +} + +fn session_backend_output(program: &str, args: &[&str]) -> std::io::Result { + let mut command = crate::process::command(program); + command.args(args); + #[cfg(target_os = "macos")] + command.env("PATH", get_extended_path()); + crate::process::safe_output(&mut command) +} + +fn verify_session_kill( + kill_result: std::io::Result, + mut session_is_live: impl FnMut() -> std::io::Result, + timeout: std::time::Duration, +) -> bool { + match kill_result { + Ok(output) if output.status.success() => {} + Ok(output) => { + // A session that already ended makes the kill exit non-zero + // (`tmux`: "can't find session", `screen`: "No screen session + // found."). The liveness probe is authoritative for what teardown + // actually needs, so fall through instead of reporting a failure + // for work that is already done. + log::debug!( + "session kill command exited with {}; verifying liveness", + output.status + ); + } + Err(error) => { + log::error!("failed to run session kill command: {error}"); + return false; + } + } + + let deadline = std::time::Instant::now() + timeout; + loop { + match session_is_live() { + Ok(false) => return true, + Ok(true) if std::time::Instant::now() >= deadline => { + log::error!("session survived bounded kill verification"); + return false; + } + Ok(true) => std::thread::sleep(std::time::Duration::from_millis(20)), + Err(error) => { + log::error!("failed to verify session liveness: {error}"); + return false; + } + } + } +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct TrackedProcess { + pid: i32, + /// Platform process-birth marker. Revalidating this before every signal + /// prevents a recycled PID from targeting an unrelated process. + start_marker: Option, +} + +#[cfg(unix)] +fn raw_process_is_alive(pid: i32) -> bool { + // SAFETY: signal 0 performs existence/permission checking only and takes no + // pointers. All callers pass a positive PID discovered from process state. + unsafe { libc::kill(pid, 0) == 0 } +} + +#[cfg(target_os = "macos")] +fn process_start_marker(pid: i32) -> Option { + let (seconds, micros) = crate::macos_proc::process_start_time(pid as u32)?; + Some(((seconds as u128) << 64) | micros as u128) +} + +#[cfg(any(target_os = "linux", test))] +fn parse_linux_process_stat(stat: &str) -> Option<(i32, u8, u64)> { + // `comm` is parenthesized and may itself contain spaces or `)`; the final + // ") " delimiter is the only safe place to begin fixed-field parsing. + let suffix = stat.rsplit_once(") ")?.1; + let fields: Vec<&str> = suffix.split_whitespace().collect(); + let state = *fields.first()?.as_bytes().first()?; // field 3 + let parent_pid = fields.get(1)?.parse().ok()?; // field 4 + let start_ticks = fields.get(19)?.parse().ok()?; // field 22 + Some((parent_pid, state, start_ticks)) +} + +#[cfg(target_os = "linux")] +fn linux_process_stat(pid: i32) -> Option<(i32, u8, u64)> { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + parse_linux_process_stat(&stat) +} + +#[cfg(target_os = "linux")] +fn process_start_marker(pid: i32) -> Option { + linux_process_stat(pid).map(|(_, _, start_ticks)| start_ticks as u128) +} + +#[cfg(all(unix, not(target_os = "linux"), not(target_os = "macos")))] +fn process_start_marker(_pid: i32) -> Option { + None +} + +#[cfg(unix)] +fn tracked_process(pid: i32) -> Option { + let start_marker = process_start_marker(pid); + (start_marker.is_some() || raw_process_is_alive(pid)) + .then_some(TrackedProcess { pid, start_marker }) +} + +#[cfg(target_os = "linux")] +fn same_process_is_alive(process: TrackedProcess) -> bool { + let Some((_, state, start_ticks)) = linux_process_stat(process.pid) else { + return false; + }; + // A killed child remains in /proc as a zombie until its stopped dtach + // parent resumes and reaps it. It is already dead and must not make a + // verified teardown fail merely because the birth marker still matches. + state != b'Z' && process.start_marker == Some(start_ticks as u128) +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn same_process_is_alive(process: TrackedProcess) -> bool { + match process.start_marker { + Some(marker) => process_start_marker(process.pid) == Some(marker), + None => raw_process_is_alive(process.pid), + } +} + +#[cfg(target_os = "macos")] +fn process_tree_snapshot() -> std::collections::HashMap> { + crate::macos_proc::process_tree() + .into_iter() + .map(|(parent, children)| { + ( + parent as i32, + children.into_iter().map(|pid| pid as i32).collect(), + ) + }) + .collect() +} + +#[cfg(target_os = "linux")] +fn process_tree_snapshot() -> std::collections::HashMap> { + let mut tree = std::collections::HashMap::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return tree; + }; + for entry in entries.flatten() { + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + else { + continue; + }; + if let Some((parent_pid, _, _)) = linux_process_stat(pid) { + tree.entry(parent_pid).or_insert_with(Vec::new).push(pid); + } + } + tree +} + +#[cfg(all(unix, not(target_os = "linux"), not(target_os = "macos")))] +fn process_tree_snapshot() -> std::collections::HashMap> { + let mut tree = std::collections::HashMap::new(); + let Ok(output) = crate::process::command("ps") + .args(["-axo", "pid=,ppid="]) + .output() + else { + return tree; + }; + for line in String::from_utf8_lossy(&output.stdout).lines() { + let mut fields = line.split_whitespace(); + let (Some(pid), Some(parent)) = (fields.next(), fields.next()) else { + continue; + }; + if let (Ok(pid), Ok(parent)) = (pid.parse::(), parent.parse::()) { + tree.entry(parent).or_insert_with(Vec::new).push(pid); + } + } + tree +} + +#[cfg(unix)] +fn tracked_descendants(roots: &[TrackedProcess]) -> Vec { + fn visit( + pid: i32, + tree: &std::collections::HashMap>, + visited: &mut std::collections::HashSet, + descendants: &mut Vec, + ) { + let Some(children) = tree.get(&pid) else { + return; + }; + for &child in children { + if !visited.insert(child) { + continue; + } + visit(child, tree, visited, descendants); + if let Some(process) = tracked_process(child) { + descendants.push(process); + } + } + } + + let tree = process_tree_snapshot(); + let mut visited: std::collections::HashSet = + roots.iter().map(|process| process.pid).collect(); + let mut descendants = Vec::new(); + for &root in roots { + if same_process_is_alive(root) { + visit(root.pid, &tree, &mut visited, &mut descendants); + } + } + descendants +} + +#[cfg(unix)] +fn signal_tracked_processes(processes: &[TrackedProcess], signal: i32, session_name: &str) { + for &process in processes { + if !same_process_is_alive(process) { + continue; + } + // SAFETY: `kill(2)` takes plain integer values and no pointers. The PID + // has just been revalidated against its platform birth marker. + unsafe { + libc::kill(process.pid, signal); + } + log::debug!( + "Sent signal {signal} to process {} for dtach session {session_name}", + process.pid + ); + } +} + +#[cfg(unix)] +fn dtach_socket_holders(socket_path: &std::path::PathBuf) -> Vec { + let my_pid = std::process::id() as i32; + crate::pty_manager::find_pids_for_unix_sockets(std::slice::from_ref(socket_path)) + .remove(socket_path) + .unwrap_or_default() + .into_iter() + .map(|pid| pid as i32) + .filter(|pid| *pid != my_pid) + .collect() +} + +#[cfg(unix)] +fn tracked_dtach_socket_holders(socket_path: &std::path::PathBuf) -> Vec { + let tracked: Vec = dtach_socket_holders(socket_path) + .into_iter() + .filter_map(tracked_process) + .collect(); + let current: std::collections::HashSet = + dtach_socket_holders(socket_path).into_iter().collect(); + tracked + .into_iter() + .filter(|process| current.contains(&process.pid) && same_process_is_alive(*process)) + .collect() +} + +#[cfg(unix)] +fn dtach_socket_is_definitively_dead(socket_path: &std::path::Path) -> bool { + match std::os::unix::net::UnixStream::connect(socket_path) { + Ok(_) => false, + Err(error) => matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ), + } +} + +#[cfg(unix)] +fn terminate_dtach_process_tree(socket_path: &std::path::PathBuf, session_name: &str) -> bool { + let mut holders = tracked_dtach_socket_holders(socket_path); + if holders.is_empty() { + if dtach_socket_is_definitively_dead(socket_path) { + return true; + } + log::warn!( + "Refusing to unlink live dtach session {session_name}: no socket holder PID was discoverable" + ); + return false; + } + + // Freeze verified holders first. Besides pinning their PID identities, this + // keeps the dtach master as a stable parentage anchor while descendants are + // discovered and frozen below. + signal_tracked_processes(&holders, libc::SIGSTOP, session_name); + let stopped_holders = holders.clone(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let confirmed_holders: std::collections::HashSet = + tracked_dtach_socket_holders(socket_path) + .into_iter() + .collect(); + holders.retain(|holder| confirmed_holders.contains(holder)); + if holders.is_empty() { + signal_tracked_processes(&stopped_holders, libc::SIGCONT, session_name); + return dtach_socket_is_definitively_dead(socket_path); + } + + // dtach exits on SIGTERM without forwarding it to the child PTY process + // group. Iteratively freeze descendants parent-first until two snapshots are + // stable. Once every anchored process is SIGSTOPed, none can fork during the + // destructive pass and the socket remains a durable retry handle on failure. + let mut descendants = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut stable_snapshots = 0; + for _ in 0..8 { + let snapshot = tracked_descendants(&holders); + let mut newly_seen: Vec = snapshot + .into_iter() + .filter(|process| seen.insert(*process)) + .collect(); + if newly_seen.is_empty() { + stable_snapshots += 1; + if stable_snapshots == 2 { + break; } + } else { + stable_snapshots = 0; + // `tracked_descendants` is child-first; reverse it so spawning + // parents are stopped before their children. + newly_seen.reverse(); + signal_tracked_processes(&newly_seen, libc::SIGSTOP, session_name); + descendants.extend(newly_seen); } + std::thread::sleep(std::time::Duration::from_millis(10)); } + + if stable_snapshots < 2 { + signal_tracked_processes(&descendants, libc::SIGCONT, session_name); + signal_tracked_processes(&stopped_holders, libc::SIGCONT, session_name); + log::error!( + "Dtach session {session_name} descendant tree did not quiesce; preserving {:?} for retry", + socket_path + ); + return false; + } + + signal_tracked_processes(&descendants, libc::SIGKILL, session_name); + std::thread::sleep(std::time::Duration::from_millis(50)); + let surviving_descendants = descendants + .iter() + .filter(|process| same_process_is_alive(**process)) + .count(); + if surviving_descendants > 0 { + signal_tracked_processes(&descendants, libc::SIGCONT, session_name); + signal_tracked_processes(&stopped_holders, libc::SIGCONT, session_name); + log::error!( + "Dtach session {session_name} still has {surviving_descendants} live descendant(s); preserving {:?} for retry", + socket_path + ); + return false; + } + + // SIGTERM is queued while holders are stopped; SIGCONT lets dtach run its + // normal exit/unlink path. Escalate only freshly revalidated survivors. + signal_tracked_processes(&stopped_holders, libc::SIGTERM, session_name); + signal_tracked_processes(&stopped_holders, libc::SIGCONT, session_name); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let surviving_holders = tracked_dtach_socket_holders(socket_path); + if !surviving_holders.is_empty() { + signal_tracked_processes(&surviving_holders, libc::SIGKILL, session_name); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + let terminated = dtach_socket_holders(socket_path).is_empty() + && dtach_socket_is_definitively_dead(socket_path); + if !terminated { + log::error!( + "Dtach session {session_name} is still live after teardown; preserving {:?} for retry", + socket_path + ); + } + terminated +} + +/// Minimum age before a `tm-*.sock` file is a GC candidate. A socket created +/// just before this scan may not yet appear in the process/socket snapshot, so +/// treat recent files as live. +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)) +} + +#[cfg(unix)] +fn dtach_session_name_from_path(path: &std::path::Path) -> Option { + let file_name = path.file_name()?.to_str()?; + is_stale_gc_candidate(file_name) + .then(|| file_name.strip_suffix(".sock").map(str::to_owned)) + .flatten() +} + +#[cfg(unix)] +fn orphaned_dtach_session_names<'a>( + socket_paths: impl IntoIterator, + retained_session_names: &std::collections::HashSet, +) -> Vec { + let mut orphaned: Vec = socket_paths + .into_iter() + .filter_map(|path| dtach_session_name_from_path(path)) + .filter(|name| !retained_session_names.contains(name)) + .collect(); + orphaned.sort(); + orphaned.dedup(); + orphaned +} + +/// Reconcile live dtach sessions against the workspace that owns this profile. +/// Sessions absent from authoritative state are leftovers from an interrupted or +/// incomplete close and must not survive another daemon start. +#[cfg(unix)] +pub fn reconcile_dtach_sessions(retained_terminal_ids: &std::collections::HashSet) { + // Always reconcile dtach artifacts, even when the newly selected backend is + // tmux/screen/none or Auto now resolves differently. + let backend = ResolvedBackend::Dtach; + let dir = get_dtach_socket_dir(); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + let socket_paths: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(is_stale_gc_candidate) + }) + // A recently-created socket may belong to a terminal added after the + // caller's last disk snapshot. Reconciliation never destroys it. + .filter(|path| socket_age(path).is_some_and(|age| !is_too_fresh_to_gc(age))) + .collect(); + let retained_session_names: std::collections::HashSet = retained_terminal_ids + .iter() + .map(|terminal_id| backend.session_name(terminal_id)) + .collect(); + let orphaned = orphaned_dtach_session_names(socket_paths.iter(), &retained_session_names); + + for session_name in &orphaned { + backend.kill_session(session_name); + } + if !orphaned.is_empty() { + log::info!( + "Reconciled {} orphaned dtach session(s) in {:?}", + orphaned.len(), + dir + ); + } +} + +#[cfg(not(unix))] +pub fn reconcile_dtach_sessions(_retained_terminal_ids: &std::collections::HashSet) {} + +/// Tear down every persistent dtach session in a stopped profile before its +/// authoritative profile directory is deleted. +#[cfg(unix)] +pub fn reap_dtach_profile_sessions(profile_id: &str) -> std::io::Result { + let dir = okena_core::profiles::dtach_socket_dir_for_profile(profile_id); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error), + }; + let mut reaped = 0; + for path in entries.flatten().map(|entry| entry.path()) { + let Some(session_name) = dtach_session_name_from_path(&path) else { + continue; + }; + if !terminate_dtach_process_tree(&path, &session_name) { + return Err(std::io::Error::other(format!( + "persistent terminal session {session_name} did not terminate" + ))); + } + let _ = std::fs::remove_file(path); + reaped += 1; + } + Ok(reaped) +} + +#[cfg(not(unix))] +pub fn reap_dtach_profile_sessions(_profile_id: &str) -> std::io::Result { + Ok(0) } /// 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 +1014,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` @@ -474,7 +1031,7 @@ pub fn cleanup_stale_dtach_sockets() { let mut removed = 0; for path in &socket_paths { let has_listener = holders.get(path).map(|v| !v.is_empty()).unwrap_or(false); - if !has_listener { + if !has_listener && dtach_socket_is_definitively_dead(path) { let _ = std::fs::remove_file(path); removed += 1; } @@ -509,10 +1066,28 @@ pub fn resolve_for_wsl(distro: Option<&str>, preference: SessionBackend) -> Reso } drop(cache); - let result = match preference { + let result = + resolve_wsl_backend_with_probe(preference, |tool| is_wsl_tool_available(distro, tool)); + + CACHE + .lock() + .unwrap_or_else(|poisoned| { + log::warn!("WSL backend cache mutex was poisoned, recovering"); + poisoned.into_inner() + }) + .insert(key, result); + result +} + +#[cfg(windows)] +fn resolve_wsl_backend_with_probe( + preference: SessionBackend, + mut available: impl FnMut(&str) -> bool, +) -> ResolvedBackend { + match preference { SessionBackend::None => ResolvedBackend::None, SessionBackend::Tmux => { - if is_wsl_tool_available(distro, "tmux") { + if available("tmux") { ResolvedBackend::Tmux } else { log::warn!("tmux requested but not available in WSL, falling back to none"); @@ -520,7 +1095,7 @@ pub fn resolve_for_wsl(distro: Option<&str>, preference: SessionBackend) -> Reso } } SessionBackend::Screen => { - if is_wsl_tool_available(distro, "screen") { + if available("screen") { ResolvedBackend::Screen } else { log::warn!("screen requested but not available in WSL, falling back to none"); @@ -528,38 +1103,42 @@ pub fn resolve_for_wsl(distro: Option<&str>, preference: SessionBackend) -> Reso } } SessionBackend::Dtach => { - if is_wsl_tool_available(distro, "dtach") { + if wsl_dtach_available(&mut available) { ResolvedBackend::Dtach } else { - log::warn!("dtach requested but not available in WSL, falling back to none"); + log::warn!( + "dtach requested but its WSL teardown tools are unavailable, falling back to none" + ); ResolvedBackend::None } } SessionBackend::Psmux => { // psmux is a host-Windows backend; WSL terminals need a Unix tool inside the // distro. Pick the best available there instead of refusing persistence. - if is_wsl_tool_available(distro, "dtach") { + if wsl_dtach_available(&mut available) { log::info!("psmux requested but inside WSL — using dtach instead"); ResolvedBackend::Dtach - } else if is_wsl_tool_available(distro, "tmux") { + } else if available("tmux") { log::info!("psmux requested but inside WSL — using tmux instead"); ResolvedBackend::Tmux - } else if is_wsl_tool_available(distro, "screen") { + } else if available("screen") { log::info!("psmux requested but inside WSL — using screen instead"); ResolvedBackend::Screen } else { - log::warn!("psmux requested but no session tool available in WSL, falling back to none"); + log::warn!( + "psmux requested but no session tool available in WSL, falling back to none" + ); ResolvedBackend::None } } SessionBackend::Auto => { - if is_wsl_tool_available(distro, "dtach") { + if wsl_dtach_available(&mut available) { log::info!("Auto-detected dtach in WSL for session persistence"); ResolvedBackend::Dtach - } else if is_wsl_tool_available(distro, "tmux") { + } else if available("tmux") { log::info!("Auto-detected tmux in WSL for session persistence"); ResolvedBackend::Tmux - } else if is_wsl_tool_available(distro, "screen") { + } else if available("screen") { log::info!("Auto-detected screen in WSL for session persistence"); ResolvedBackend::Screen } else { @@ -567,13 +1146,12 @@ pub fn resolve_for_wsl(distro: Option<&str>, preference: SessionBackend) -> Reso ResolvedBackend::None } } - }; + } +} - CACHE.lock().unwrap_or_else(|poisoned| { - log::warn!("WSL backend cache mutex was poisoned, recovering"); - poisoned.into_inner() - }).insert(key, result); - result +#[cfg(windows)] +fn wsl_dtach_available(available: &mut impl FnMut(&str) -> bool) -> bool { + available("dtach") && available("lsof") && available("xargs") } /// Check if a tool is available inside a WSL distro using `command -v`. @@ -583,7 +1161,12 @@ fn is_wsl_tool_available(distro: Option<&str>, tool: &str) -> bool { if let Some(d) = distro { cmd.args(["-d", d]); } - cmd.args(["--", "sh", "-c", &format!("command -v {}", shell_escape(tool))]); + cmd.args([ + "--", + "sh", + "-c", + &format!("command -v {}", shell_escape(tool)), + ]); crate::process::safe_output(&mut cmd) .map(|o| o.status.success()) .unwrap_or(false) @@ -614,7 +1197,8 @@ impl ResolvedBackend { distro: Option<&str>, session_name: &str, wsl_cwd: &str, - command: Option<&str>, + command: Option>, + environment: &[(String, Option)], ) -> Option<(String, Vec)> { let inner_cmd = match self { // psmux runs on the Windows host, never inside WSL. resolve_for_wsl @@ -622,11 +1206,13 @@ impl ResolvedBackend { Self::None | Self::Psmux => return None, Self::Tmux => { // Tmux doesn't reference host paths or $SHELL, so delegate to build_command - let (_program, inner_args) = self.build_command(session_name, wsl_cwd, command, &[])?; + let (_program, inner_args) = + self.build_command_with_custom(session_name, wsl_cwd, command, environment)?; inner_args.last()?.to_string() } Self::Screen => { - let (_program, inner_args) = self.build_command(session_name, wsl_cwd, command, &[])?; + let (_program, inner_args) = + self.build_command_with_custom(session_name, wsl_cwd, command, &[])?; let mut parts = vec!["screen".to_string()]; parts.extend(inner_args.iter().map(|a| shell_escape(a))); parts.join(" ") @@ -636,7 +1222,15 @@ impl ResolvedBackend { // (can't delegate to build_command — it uses Windows temp dir and host $SHELL) let socket_path = get_wsl_dtach_socket_path(session_name); let program = match command { - Some(cmd) => format!("sh -c {}", shell_escape(cmd)), + Some(SessionCommand::ShellScript(cmd)) => { + format!("sh -c {}", shell_escape(cmd)) + } + Some(SessionCommand::Program { program, args }) => { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(shell_escape(program)); + parts.extend(args.iter().map(|arg| shell_escape(arg))); + parts.join(" ") + } // Use $SHELL (resolved inside WSL) — not shell_escape'd so it expands None => "\"$SHELL\"".to_string(), }; @@ -655,7 +1249,20 @@ impl ResolvedBackend { args.push("-d".to_string()); args.push(d.to_string()); } - args.extend(["--".to_string(), "sh".to_string(), "-c".to_string(), inner_cmd]); + args.push("--".to_string()); + if !environment.is_empty() { + args.push("env".to_string()); + for (key, value) in environment { + match value { + Some(value) => args.push(format!("{key}={value}")), + None => { + args.push("-u".to_string()); + args.push(key.clone()); + } + } + } + } + args.extend(["sh".to_string(), "-c".to_string(), inner_cmd]); Some(("wsl.exe".to_string(), args)) } @@ -702,34 +1309,71 @@ fn shell_escape(s: &str) -> String { /// Get the socket directory for dtach sessions #[allow(dead_code)] +fn profile_scoped_dtach_socket_dir( + base: std::path::PathBuf, + profile_id: Option<&str>, +) -> std::path::PathBuf { + let Some(profile_id) = profile_id else { + return base; + }; + let safe = !profile_id.is_empty() + && !profile_id.contains('/') + && !profile_id.contains('\\') + && !profile_id.contains("..") + && !profile_id.contains('\0'); + if profile_id == "default" || !safe { + base + } else { + base.join("profiles").join(profile_id) + } +} + +fn dtach_socket_base_dir() -> std::path::PathBuf { + okena_core::profiles::dtach_socket_base_dir() +} + +fn active_profile_id() -> Option { + okena_core::profiles::try_current() + .map(|profile| profile.id.clone()) + .or_else(|| std::env::var("OKENA_PROFILE").ok()) +} + fn get_dtach_socket_dir() -> std::path::PathBuf { - // Use XDG_RUNTIME_DIR if available (Linux), otherwise fall back to temp dir - // XDG_RUNTIME_DIR is preferred as it's user-specific and cleaned on logout - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - std::path::PathBuf::from(runtime_dir).join("okena") + // Keep the default profile in the legacy root so existing sessions survive + // the profile migration. Every named profile gets an isolated socket pool. + profile_scoped_dtach_socket_dir(dtach_socket_base_dir(), active_profile_id().as_deref()) +} + +fn profile_dtach_socket_path( + base: &std::path::Path, + profile_id: Option<&str>, + session_name: &str, +) -> std::path::PathBuf { + let file_name = format!("{session_name}.sock"); + let scoped = profile_scoped_dtach_socket_dir(base.to_path_buf(), profile_id).join(&file_name); + if scoped.exists() { + return scoped; + } + + let legacy = base.join(file_name); + if scoped != legacy && legacy.exists() { + legacy } else { - // Fallback: /tmp/okena- for security - #[cfg(unix)] - { - // SAFETY: `libc::getuid` is a thin FFI wrapper over the `getuid(2)` - // syscall. It takes no arguments, dereferences no pointers, always - // succeeds (it is documented as never failing), and returns a plain - // `uid_t` by value. There are no memory-safety preconditions, so the - // call cannot cause UB. - let uid = unsafe { libc::getuid() }; - std::path::PathBuf::from(format!("/tmp/okena-{}", uid)) - } - #[cfg(not(unix))] - { - std::env::temp_dir().join("okena") - } + scoped } } -/// Get the socket path for a specific dtach session +/// Get the socket path for a specific dtach session. A named profile first looks +/// in its isolated directory, then falls back to the pre-upgrade shared root so +/// retained legacy sessions remain attachable and closable. New sessions are +/// created in the isolated path once no legacy socket exists. #[allow(dead_code)] fn get_dtach_socket_path(session_name: &str) -> std::path::PathBuf { - get_dtach_socket_dir().join(format!("{}.sock", session_name)) + profile_dtach_socket_path( + &dtach_socket_base_dir(), + active_profile_id().as_deref(), + session_name, + ) } /// Extract directory name from a path for use as window name @@ -805,9 +1449,10 @@ pub fn get_extended_path() -> String { for dir in &candidates { if dir.is_dir() - && let Some(s) = dir.to_str() { - push(s.to_string()); - } + && let Some(s) = dir.to_str() + { + push(s.to_string()); + } } // Also resolve fnm's current Node version if fnm is installed @@ -818,9 +1463,10 @@ pub fn get_extended_path() -> String { let cargo_bin = Path::new(&extra).join("bin"); if cargo_bin.is_dir() && let Some(s) = cargo_bin.to_str() - && seen.insert(s.to_string()) { - result.push(s.to_string()); - } + && seen.insert(s.to_string()) + { + result.push(s.to_string()); + } } // Append inherited PATH entries (keeps system paths at the end) @@ -836,7 +1482,11 @@ pub fn get_extended_path() -> String { /// Try to find fnm's current Node bin directory. #[cfg(not(windows))] -fn resolve_fnm_path(home: &std::path::Path, result: &mut Vec, seen: &mut std::collections::HashSet) { +fn resolve_fnm_path( + home: &std::path::Path, + result: &mut Vec, + seen: &mut std::collections::HashSet, +) { // fnm stores the active version in $FNM_MULTISHELL_PATH or we can run `fnm env`. // But to avoid spawning processes, check the default symlink location. let fnm_dir = home.join(".local/share/fnm"); @@ -856,18 +1506,25 @@ fn resolve_fnm_path(home: &std::path::Path, result: &mut Vec, seen: &mut let node_bin = if version.is_absolute() { version.join("installation/bin") } else { - fnm_dir.join("node-versions").join(version.to_string_lossy().trim()).join("installation/bin") + fnm_dir + .join("node-versions") + .join(version.to_string_lossy().trim()) + .join("installation/bin") }; // Validate the resolved path stays within fnm directory to prevent symlink escape if let Ok(canonical_bin) = node_bin.canonicalize() { if !canonical_bin.starts_with(&fnm_canonical) { - log::warn!("fnm alias points outside fnm directory, skipping: {:?}", node_bin); + log::warn!( + "fnm alias points outside fnm directory, skipping: {:?}", + node_bin + ); return; } if let Some(s) = canonical_bin.to_str() - && seen.insert(s.to_string()) { - result.push(s.to_string()); - } + && seen.insert(s.to_string()) + { + result.push(s.to_string()); + } } } } @@ -905,9 +1562,9 @@ fn is_dtach_available() -> bool { .arg("-v") .env("PATH", get_extended_path()), ) - // dtach -v exits with 0 and prints version - .map(|o| o.status.success() || !o.stdout.is_empty() || !o.stderr.is_empty()) - .unwrap_or(false) + // dtach -v exits with 0 and prints version + .map(|o| o.status.success() || !o.stdout.is_empty() || !o.stderr.is_empty()) + .unwrap_or(false) } #[cfg(all(unix, not(target_os = "macos")))] @@ -934,8 +1591,8 @@ fn is_tmux_available() -> bool { .arg("-V") .env("PATH", get_extended_path()), ) - .map(|o| o.status.success()) - .unwrap_or(false) + .map(|o| o.status.success()) + .unwrap_or(false) } #[cfg(all(unix, not(target_os = "macos")))] @@ -981,8 +1638,8 @@ fn is_screen_available() -> bool { .arg("-v") .env("PATH", get_extended_path()), ) - .map(|o| o.status.success()) - .unwrap_or(false) + .map(|o| o.status.success()) + .unwrap_or(false) } #[cfg(all(unix, not(target_os = "macos")))] @@ -997,6 +1654,174 @@ fn is_screen_available() -> bool { mod tests { use super::*; + fn command_output(success: bool) -> std::process::Output { + #[cfg(windows)] + let mut command = { + let mut command = std::process::Command::new("cmd.exe"); + command.args(["/C", if success { "exit 0" } else { "exit 1" }]); + command + }; + #[cfg(not(windows))] + let mut command = std::process::Command::new(if success { "true" } else { "false" }); + command.output().expect("run command") + } + + fn successful_command_output() -> std::process::Output { + command_output(true) + } + + #[test] + fn linux_process_stat_parser_reports_zombies_and_birth_markers() { + let mut fields = vec!["Z", "42"]; + fields.extend(std::iter::repeat_n("0", 17)); + fields.push("987"); + let stat = format!("123 (worker ) name) {}", fields.join(" ")); + + assert_eq!(parse_linux_process_stat(&stat), Some((42, b'Z', 987))); + } + + #[test] + fn verified_session_kill_rejects_command_failure() { + assert!(!verify_session_kill( + Err(std::io::Error::other("kill command failed")), + || panic!("liveness must not be checked after command failure"), + std::time::Duration::ZERO, + )); + } + + #[test] + fn verified_session_kill_accepts_already_dead_session() { + // `tmux kill-session` / `screen -X quit` exit non-zero when the session + // is already gone — the common case after a shell exits on its own. + let mut probes = 0; + assert!(verify_session_kill( + Ok(command_output(false)), + || { + probes += 1; + Ok(false) + }, + std::time::Duration::ZERO, + )); + assert_eq!(probes, 1, "a failed kill is resolved by the probe"); + } + + #[test] + fn verified_session_kill_rejects_failed_kill_of_live_session() { + assert!(!verify_session_kill( + Ok(command_output(false)), + || Ok(true), + std::time::Duration::ZERO, + )); + } + + #[test] + fn verified_session_kill_rejects_session_that_survives() { + let mut probes = 0; + assert!(!verify_session_kill( + Ok(successful_command_output()), + || { + probes += 1; + Ok(true) + }, + std::time::Duration::ZERO, + )); + assert_eq!(probes, 1, "the live session was probed before failure"); + } + + #[test] + fn verified_session_kill_accepts_confirmed_disappearance() { + assert!(verify_session_kill( + Ok(successful_command_output()), + || Ok(false), + std::time::Duration::ZERO, + )); + } + + #[test] + fn command_backends_issue_exact_kill_and_probe_commands() { + let cases = [ + ( + ResolvedBackend::Tmux, + "tmux", + ["kill-session", "-t", "tm-test"].as_slice(), + ["has-session", "-t", "tm-test"].as_slice(), + ), + ( + ResolvedBackend::Screen, + "screen", + ["-S", "tm-test", "-X", "quit"].as_slice(), + ["-S", "tm-test", "-Q", "select", "."].as_slice(), + ), + ( + ResolvedBackend::Psmux, + "psmux", + ["kill-session", "-t", "tm-test"].as_slice(), + ["has-session", "-t", "tm-test"].as_slice(), + ), + ]; + + for (backend, program, kill_args, probe_args) in cases { + let mut calls = Vec::new(); + let mut invocation = 0; + assert!(backend.kill_session_with_executor( + "tm-test", + |actual_program, actual_args| { + calls.push(( + actual_program.to_string(), + actual_args + .iter() + .map(ToString::to_string) + .collect::>(), + )); + invocation += 1; + Ok(command_output(invocation == 1)) + }, + std::time::Duration::ZERO, + )); + assert_eq!( + calls, + vec![ + ( + program.to_string(), + kill_args.iter().map(ToString::to_string).collect(), + ), + ( + program.to_string(), + probe_args.iter().map(ToString::to_string).collect(), + ), + ], + "{backend:?} must verify its exact session after killing it" + ); + } + } + + #[test] + fn command_backend_kill_failure_preserves_dependent_checkout() { + for backend in [ + ResolvedBackend::Tmux, + ResolvedBackend::Screen, + ResolvedBackend::Psmux, + ] { + let mut calls = Vec::new(); + assert!(!backend.kill_session_with_executor( + "tm-test", + |program, args| { + calls.push(( + program.to_string(), + args.iter().map(ToString::to_string).collect::>(), + )); + Err(std::io::Error::other("kill failed")) + }, + std::time::Duration::ZERO, + )); + assert_eq!( + calls.len(), + 1, + "{backend:?} must not probe after kill failure" + ); + } + } + #[test] fn test_parse_backend() { assert_eq!(SessionBackend::parse_str("tmux"), SessionBackend::Tmux); @@ -1024,6 +1849,209 @@ 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 orphan_reconciliation_preserves_retained_sessions() { + let sockets = [ + std::path::PathBuf::from("/runtime/tm-keep1234.sock"), + std::path::PathBuf::from("/runtime/tm-drop5678.sock"), + std::path::PathBuf::from("/runtime/daemon.sock"), + ]; + let retained = std::collections::HashSet::from(["tm-keep1234".to_string()]); + + assert_eq!( + orphaned_dtach_session_names(sockets.iter(), &retained), + vec!["tm-drop5678".to_string()] + ); + } + + #[cfg(unix)] + #[test] + fn non_default_profiles_get_isolated_dtach_socket_directories() { + let base = std::path::PathBuf::from("/tmp/okena-501"); + + assert_eq!(profile_scoped_dtach_socket_dir(base.clone(), None), base); + assert_eq!( + profile_scoped_dtach_socket_dir(base.clone(), Some("default")), + base + ); + assert_eq!( + profile_scoped_dtach_socket_dir(base.clone(), Some("work-client")), + base.join("profiles").join("work-client") + ); + } + + #[cfg(unix)] + #[test] + fn named_profile_reuses_legacy_socket_before_creating_an_isolated_one() { + let base = std::env::temp_dir().join(format!( + "okena-profile-socket-test-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&base).unwrap(); + let legacy = base.join("tm-legacy.sock"); + std::fs::write(&legacy, b"").unwrap(); + + assert_eq!( + profile_dtach_socket_path(&base, Some("work"), "tm-legacy"), + legacy + ); + std::fs::remove_file(&legacy).unwrap(); + assert_eq!( + profile_dtach_socket_path(&base, Some("work"), "tm-legacy"), + base.join("profiles/work/tm-legacy.sock") + ); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(unix)] + #[test] + fn profile_reaping_refuses_live_sessions_and_removes_dead_sockets() { + let profile_id = format!("test-profile-{}", uuid::Uuid::new_v4()); + let dir = okena_core::profiles::dtach_socket_dir_for_profile(&profile_id); + std::fs::create_dir_all(&dir).unwrap(); + let socket_path = dir.join("tm-profile-test.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + + assert!(reap_dtach_profile_sessions(&profile_id).is_err()); + assert!(socket_path.exists()); + + drop(listener); + assert_eq!(reap_dtach_profile_sessions(&profile_id).unwrap(), 1); + assert!(!socket_path.exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[test] + fn dtach_teardown_preserves_socket_until_its_master_is_dead() { + let session_name = format!("tm-live-test-{}", std::process::id()); + let socket_path = get_dtach_socket_path(&session_name); + std::fs::create_dir_all(socket_path.parent().expect("socket parent")).unwrap(); + let _ = std::fs::remove_file(&socket_path); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + + ResolvedBackend::Dtach.kill_session(&session_name); + assert!( + socket_path.exists(), + "a socket that still accepts connections must stay discoverable" + ); + + drop(listener); + ResolvedBackend::Dtach.kill_session(&session_name); + assert!( + !socket_path.exists(), + "a dead socket should be removed once liveness is verified" + ); + } + + #[cfg(unix)] + #[test] + fn dtach_teardown_reaps_the_real_child_process_tree() { + if std::process::Command::new("dtach") + .arg("--help") + .output() + .is_err() + { + return; + } + + let unique = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4()); + let session_name = format!("tm-tree-test-{unique}"); + let socket_path = get_dtach_socket_path(&session_name); + std::fs::create_dir_all(socket_path.parent().expect("socket parent")).unwrap(); + let _ = std::fs::remove_file(&socket_path); + let temp_dir = std::env::temp_dir().join(format!("okena-dtach-tree-{unique}")); + std::fs::create_dir_all(&temp_dir).unwrap(); + let shell_pid_file = temp_dir.join("shell.pid"); + let child_pid_file = temp_dir.join("child.pid"); + let command = format!( + "echo $$ > {}; sleep 30 & echo $! > {}; wait", + shell_escape(&shell_pid_file.to_string_lossy()), + shell_escape(&child_pid_file.to_string_lossy()) + ); + let status = std::process::Command::new("dtach") + .args([ + "-n", + socket_path.to_str().unwrap(), + "-E", + "sh", + "-c", + &command, + ]) + .status() + .unwrap(); + assert!(status.success()); + + for _ in 0..100 { + if socket_path.exists() && shell_pid_file.exists() && child_pid_file.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let shell_pid: i32 = std::fs::read_to_string(&shell_pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + let child_pid: i32 = std::fs::read_to_string(&child_pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + + ResolvedBackend::Dtach.kill_session(&session_name); + for _ in 0..100 { + if !raw_process_is_alive(shell_pid) && !raw_process_is_alive(child_pid) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let shell_alive = raw_process_is_alive(shell_pid); + let child_alive = raw_process_is_alive(child_pid); + if shell_alive { + // SAFETY: positive PID was written by this test-owned shell; kill(2) + // takes no pointers and cleanup ignores a concurrent exit. + unsafe { libc::kill(shell_pid, libc::SIGKILL) }; + } + if child_alive { + // SAFETY: positive PID was written by this test-owned child; kill(2) + // takes no pointers and cleanup ignores a concurrent exit. + unsafe { libc::kill(child_pid, libc::SIGKILL) }; + } + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir_all(&temp_dir); + + assert!(!shell_alive, "dtach shell survived teardown"); + assert!(!child_alive, "dtach grandchild survived teardown"); + } + + #[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; @@ -1124,9 +2152,16 @@ mod tests { // No initial program, then ';' separators with set/rename commands assert!(args.contains(&";".to_string())); let semi_count = args.iter().filter(|a| a.as_str() == ";").count(); - assert_eq!(semi_count, 4, "expected four `;` separators (status, mouse, automatic-rename, rename-window)"); + assert_eq!( + semi_count, 4, + "expected four `;` separators (status, mouse, automatic-rename, rename-window)" + ); assert!(args.iter().any(|a| a == "rename-window")); - assert_eq!(args.last().unwrap(), "app", "window name = last cwd segment"); + assert_eq!( + args.last().unwrap(), + "app", + "window name = last cwd segment" + ); } #[test] @@ -1137,16 +2172,51 @@ mod tests { let (program, args) = result.unwrap(); assert_eq!(program, "psmux"); // custom command is wrapped via cmd.exe /c - let cmd_pos = args.iter().position(|a| a == "cmd.exe").expect("cmd.exe in args"); + let cmd_pos = args + .iter() + .position(|a| a == "cmd.exe") + .expect("cmd.exe in args"); assert_eq!(args[cmd_pos + 1], "/c"); assert_eq!(args[cmd_pos + 2], "npm run dev"); } + #[test] + fn test_psmux_preserves_exact_custom_command_argv() { + let backend = ResolvedBackend::Psmux; + let command_args = vec![ + "/V:ON".to_string(), + "/C".to_string(), + "echo !ERRORLEVEL! & cmd /K".to_string(), + ]; + let (_, args) = backend + .build_command_with_custom( + "tm-test", + "C:\\src", + Some(SessionCommand::Program { + program: "cmd.exe", + args: &command_args, + }), + &[], + ) + .unwrap(); + + let command_pos = args + .iter() + .position(|arg| arg == "cmd.exe") + .expect("custom executable in psmux argv"); + assert_eq!( + &args[command_pos..command_pos + 4], + ["cmd.exe", "/V:ON", "/C", "echo !ERRORLEVEL! & cmd /K"] + ); + } + #[test] fn test_psmux_build_command_with_extra_env() { let backend = ResolvedBackend::Psmux; let env = vec![("CLAUDE_CONFIG_DIR".to_string(), Some("C:\\tmp".to_string()))]; - let (_, args) = backend.build_command("tm-test", "C:\\tmp", None, &env).unwrap(); + let (_, args) = backend + .build_command("tm-test", "C:\\tmp", None, &env) + .unwrap(); // -e KEY=VAL must appear before -s so psmux applies it to the new session let e_pos = args.iter().position(|a| a == "-e").expect("-e in args"); let s_pos = args.iter().position(|a| a == "-s").expect("-s in args"); @@ -1168,14 +2238,25 @@ mod tests { #[test] fn test_none_build_command() { let backend = ResolvedBackend::None; - assert!(backend.build_command("test-session", "/home/user", None, &[]).is_none()); - assert!(backend.build_command("test-session", "/home/user", Some("echo hi"), &[]).is_none()); + assert!( + backend + .build_command("test-session", "/home/user", None, &[]) + .is_none() + ); + assert!( + backend + .build_command("test-session", "/home/user", Some("echo hi"), &[]) + .is_none() + ); } #[test] fn test_tmux_build_command_with_extra_env() { let backend = ResolvedBackend::Tmux; - let env = vec![("CLAUDE_CONFIG_DIR".to_string(), Some("/tmp/foo".to_string()))]; + let env = vec![( + "CLAUDE_CONFIG_DIR".to_string(), + Some("/tmp/foo".to_string()), + )]; let (_, args) = backend .build_command("tm-test", "/tmp", None, &env) .unwrap(); @@ -1219,6 +2300,7 @@ mod tests { "tm-12345678", "/home/user/project", None, + &[], ); assert!(result.is_some()); let (program, args) = result.unwrap(); @@ -1231,12 +2313,48 @@ mod tests { // The inner command should contain dtach with WSL-native socket path let inner_cmd = args.last().unwrap(); assert!(inner_cmd.contains("dtach -A"), "inner cmd: {}", inner_cmd); - assert!(inner_cmd.contains("-E -r winch"), "inner cmd: {}", inner_cmd); + assert!( + inner_cmd.contains("-E -r winch"), + "inner cmd: {}", + inner_cmd + ); // Must use WSL-native socket path, not Windows temp dir - assert!(inner_cmd.contains("/tmp/okena-dtach/"), "socket path should be WSL-native: {}", inner_cmd); + assert!( + inner_cmd.contains("/tmp/okena-dtach/"), + "socket path should be WSL-native: {}", + inner_cmd + ); // Must use $SHELL (resolved inside WSL), not /bin/sh - assert!(inner_cmd.contains("\"$SHELL\""), "should use $SHELL not /bin/sh: {}", inner_cmd); - assert!(!inner_cmd.contains("/bin/sh"), "should not contain /bin/sh: {}", inner_cmd); + assert!( + inner_cmd.contains("\"$SHELL\""), + "should use $SHELL not /bin/sh: {}", + inner_cmd + ); + assert!( + !inner_cmd.contains("/bin/sh"), + "should not contain /bin/sh: {}", + inner_cmd + ); + } + + #[test] + #[cfg(windows)] + fn wsl_dtach_requires_teardown_dependencies() { + let resolved = resolve_wsl_backend_with_probe(SessionBackend::Dtach, |tool| { + matches!(tool, "dtach" | "xargs") + }); + + assert_eq!(resolved, ResolvedBackend::None); + } + + #[test] + #[cfg(windows)] + fn wsl_auto_skips_unteardownable_dtach() { + let resolved = resolve_wsl_backend_with_probe(SessionBackend::Auto, |tool| { + matches!(tool, "dtach" | "tmux" | "xargs") + }); + + assert_eq!(resolved, ResolvedBackend::Tmux); } #[test] @@ -1248,13 +2366,69 @@ mod tests { "tm-12345678", "/home/user/project", None, + &[], ); assert!(result.is_some()); let (program, args) = result.unwrap(); assert_eq!(program, "wsl.exe"); let inner_cmd = args.last().unwrap(); - assert!(inner_cmd.contains("tmux new-session -A"), "inner cmd: {}", inner_cmd); - assert!(inner_cmd.contains("set status off"), "inner cmd: {}", inner_cmd); + assert!( + inner_cmd.contains("tmux new-session -A"), + "inner cmd: {}", + inner_cmd + ); + assert!( + inner_cmd.contains("set status off"), + "inner cmd: {}", + inner_cmd + ); + } + + #[test] + #[cfg(windows)] + fn wsl_session_environment_stays_in_argv() { + let backend = ResolvedBackend::Tmux; + let hostile = "quote\" & %PATH% !bang!\r\nnext".to_string(); + let (_, args) = backend + .build_wsl_session_command( + Some("Ubuntu"), + "tm-12345678", + "/home/user/project", + None, + &[("OKENA_PROJECT_NAME".to_string(), Some(hostile.clone()))], + ) + .expect("WSL command"); + + let env_index = args.iter().position(|arg| arg == "env").expect("env argv"); + assert_eq!( + args.get(env_index + 1), + Some(&format!("OKENA_PROJECT_NAME={hostile}")) + ); + } + + #[test] + #[cfg(windows)] + fn test_build_wsl_session_command_preserves_custom_argv() { + let backend = ResolvedBackend::Tmux; + let command_args = vec!["-lc".to_string(), "printf '%s' \"hello world\"".to_string()]; + let (_, args) = backend + .build_wsl_session_command( + Some("Ubuntu"), + "tm-12345678", + "/home/user/project", + Some(SessionCommand::Program { + program: "/bin/bash", + args: &command_args, + }), + &[], + ) + .unwrap(); + + let inner_cmd = args.last().unwrap(); + assert!( + inner_cmd.contains("'/bin/bash' '-lc' 'printf '\\''%s'\\'' \"hello world\"'"), + "inner cmd: {inner_cmd}" + ); } #[test] @@ -1266,6 +2440,7 @@ mod tests { "tm-12345678", "/home/user/project", None, + &[], ); assert!(result.is_none()); } @@ -1279,6 +2454,7 @@ mod tests { "tm-12345678", "/home/user/project", None, + &[], ); assert!(result.is_some()); let (program, args) = result.unwrap(); diff --git a/crates/okena-terminal/src/shell_config.rs b/crates/okena-terminal/src/shell_config.rs index c809f343d..3eee0c742 100644 --- a/crates/okena-terminal/src/shell_config.rs +++ b/crates/okena-terminal/src/shell_config.rs @@ -173,12 +173,11 @@ fn is_pwsh_available() -> bool { /// Detect installed WSL distributions #[cfg(windows)] pub fn detect_wsl_distros() -> Vec { - let output = match crate::process::safe_output( - crate::process::command("wsl.exe").args(["-l", "-q"]), - ) { - Ok(o) if o.status.success() => o, - _ => return Vec::new(), - }; + let output = + match crate::process::safe_output(crate::process::command("wsl.exe").args(["-l", "-q"])) { + Ok(o) if o.status.success() => o, + _ => return Vec::new(), + }; // WSL outputs UTF-16LE encoded text let stdout = &output.stdout; @@ -223,7 +222,8 @@ pub fn parse_wsl_unc_path(path: &str) -> Option<(String, String)> { let rest = normalized.strip_prefix("//")?; // Check for wsl.localhost/ or wsl$/ - let after_host = rest.strip_prefix("wsl.localhost/") + let after_host = rest + .strip_prefix("wsl.localhost/") .or_else(|| rest.strip_prefix("wsl$/"))?; // Next segment is the distro name @@ -273,10 +273,7 @@ mod tests { #[test] #[cfg(windows)] fn test_windows_path_to_wsl() { - assert_eq!( - windows_path_to_wsl("C:\\Users\\test"), - "/mnt/c/Users/test" - ); + assert_eq!(windows_path_to_wsl("C:\\Users\\test"), "/mnt/c/Users/test"); assert_eq!( windows_path_to_wsl("D:\\Projects\\app"), "/mnt/d/Projects/app" @@ -297,14 +294,8 @@ mod tests { "/home/user" ); // Forward-slash UNC paths - assert_eq!( - windows_path_to_wsl("//wsl.localhost/Debian/tmp"), - "/tmp" - ); - assert_eq!( - windows_path_to_wsl("//wsl$/Arch/etc/config"), - "/etc/config" - ); + assert_eq!(windows_path_to_wsl("//wsl.localhost/Debian/tmp"), "/tmp"); + assert_eq!(windows_path_to_wsl("//wsl$/Arch/etc/config"), "/etc/config"); } #[test] diff --git a/crates/okena-terminal/src/terminal/ansi_snapshot.rs b/crates/okena-terminal/src/terminal/ansi_snapshot.rs index 3c52d404a..b3bdb7b1d 100644 --- a/crates/okena-terminal/src/terminal/ansi_snapshot.rs +++ b/crates/okena-terminal/src/terminal/ansi_snapshot.rs @@ -66,8 +66,16 @@ pub(super) fn grid_to_ansi(term: &Term) -> Vec { underline: cell.flags.intersects(Flags::ALL_UNDERLINES), inverse: cell.flags.contains(Flags::INVERSE), strikeout: cell.flags.contains(Flags::STRIKEOUT), - fg: if cell.fg == default_fg { None } else { Some(cell.fg) }, - bg: if cell.bg == default_bg { None } else { Some(cell.bg) }, + fg: if cell.fg == default_fg { + None + } else { + Some(cell.fg) + }, + bg: if cell.bg == default_bg { + None + } else { + Some(cell.bg) + }, }; if desired != current { @@ -192,6 +200,10 @@ fn named_color_sgr_code(color: &NamedColor, is_fg: bool) -> Option { Some(if is_fg { 30 + code } else { 40 + code }) } else { // Bright colors: 90-97 / 100-107 - Some(if is_fg { 90 + (code - 8) } else { 100 + (code - 8) }) + Some(if is_fg { + 90 + (code - 8) + } else { + 100 + (code - 8) + }) } } diff --git a/crates/okena-terminal/src/terminal/child_processes.rs b/crates/okena-terminal/src/terminal/child_processes.rs index ee48719ce..b5f6c60e5 100644 --- a/crates/okena-terminal/src/terminal/child_processes.rs +++ b/crates/okena-terminal/src/terminal/child_processes.rs @@ -17,9 +17,10 @@ pub fn has_child_processes(pid: u32) -> bool { }; let path = format!("/proc/{}/task/{}/children", pid, tid); if let Ok(s) = std::fs::read_to_string(&path) - && !s.trim().is_empty() { - return true; - } + && !s.trim().is_empty() + { + return true; + } } false } @@ -31,11 +32,9 @@ pub fn has_child_processes(pid: u32) -> bool { #[cfg(all(unix, not(target_os = "linux"), not(target_os = "macos")))] pub fn has_child_processes(pid: u32) -> bool { - crate::process::safe_output( - crate::process::command("pgrep").args(["-P", &pid.to_string()]), - ) - .map(|o| o.status.success()) - .unwrap_or(false) + crate::process::safe_output(crate::process::command("pgrep").args(["-P", &pid.to_string()])) + .map(|o| o.status.success()) + .unwrap_or(false) } #[cfg(not(unix))] @@ -53,12 +52,16 @@ pub fn foreground_command(pid: u32) -> Option { let task_dir = format!("/proc/{pid}/task"); for entry in std::fs::read_dir(&task_dir).ok()?.flatten() { let file_name = entry.file_name(); - let Some(tid) = file_name.to_str() else { continue }; + let Some(tid) = file_name.to_str() else { + continue; + }; let Ok(children) = std::fs::read_to_string(format!("/proc/{pid}/task/{tid}/children")) else { continue; }; - let Some(child_pid) = children.split_whitespace().next() else { continue }; + let Some(child_pid) = children.split_whitespace().next() else { + continue; + }; let Ok(comm) = std::fs::read_to_string(format!("/proc/{child_pid}/comm")) else { continue; }; diff --git a/crates/okena-terminal/src/terminal/event_listener.rs b/crates/okena-terminal/src/terminal/event_listener.rs index c35e9e198..1a3539822 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()); + let reply = response_fn(alacritty_terminal::vte::ansi::Rgb { r, g, b }); + 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,19 @@ 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/io.rs b/crates/okena-terminal/src/terminal/io.rs index bffc7abd9..dc9df1824 100644 --- a/crates/okena-terminal/src/terminal/io.rs +++ b/crates/okena-terminal/src/terminal/io.rs @@ -1,14 +1,23 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::term::TermMode; use std::sync::atomic::Ordering; -use std::time::Instant; +use std::time::{Duration, Instant}; -use super::Terminal; use super::prompt_marks::advance_with_prompt_marks; +use super::{InputRepaintRequest, Terminal}; + +const INPUT_REPAINT_REQUEST_TTL: Duration = Duration::from_secs(5); impl Terminal { /// Process output from PTY pub fn process_output(&self, data: &[u8]) { + self.process_output_with_sequence(data, 0); + } + + /// Process PTY output and atomically associate it with its broadcast order. + pub fn process_output_with_sequence(&self, data: &[u8], sequence: u64) { + let output_epoch = + (!data.is_empty()).then(|| self.output_epoch.fetch_add(1, Ordering::AcqRel) + 1); let mut _slow = okena_core::timing::SlowGuard::with_detail( "Terminal::process_output", format!("{} bytes", data.len()), @@ -55,6 +64,14 @@ impl Terminal { self.dirty.store(true, Ordering::Relaxed); self.content_generation.fetch_add(1, Ordering::Relaxed); + if sequence != 0 { + self.processed_output_sequence + .store(sequence, Ordering::Release); + } + if let Some(output_epoch) = output_epoch { + self.processed_output_epoch + .fetch_max(output_epoch, Ordering::Release); + } *self.last_output_time.lock() = Instant::now(); } @@ -64,7 +81,14 @@ impl Terminal { /// `term.lock()`. The pending data is drained and parsed on the GPUI /// thread just before rendering (see `with_content`). pub fn enqueue_output(&self, data: &[u8]) { - self.pending_output.lock().extend_from_slice(data); + let mut pending = self.pending_output.lock(); + pending.extend_from_slice(data); + if !data.is_empty() { + let output_epoch = self.output_epoch.fetch_add(1, Ordering::AcqRel) + 1; + self.pending_output_epoch + .store(output_epoch, Ordering::Release); + } + drop(pending); self.dirty.store(true, Ordering::Relaxed); *self.last_output_time.lock() = Instant::now(); } @@ -77,10 +101,9 @@ impl Terminal { /// sidebar bell/idle indicators read `has_bell()` / `is_waiting_for_input()` /// *before* the `TerminalContent` child drains. For local terminals the /// equivalent state is set eagerly in `process_output`; remote terminals only - /// buffer via `enqueue_output`, so without an eager parse those indicators - /// render one frame stale and only appear once unrelated local input forces a - /// second repaint. The remote dirty loop calls this so the flags are current - /// when the frame is built. GPUI thread only. + /// buffer via `enqueue_output`. The remote manager's activity pump calls this + /// before emitting targeted pane/sidebar notifications, so derived state is + /// current when the frame is built without per-pane polling. GPUI thread only. pub fn process_pending_output(&self) { self.drain_pending_output(); } @@ -89,12 +112,13 @@ impl Terminal { /// /// Called automatically by `with_content` before rendering. pub(super) fn drain_pending_output(&self) { - let data = { + let (data, output_epoch) = { let mut pending = self.pending_output.lock(); if pending.is_empty() { return; } - std::mem::take(&mut *pending) + let output_epoch = self.pending_output_epoch.load(Ordering::Acquire); + (std::mem::take(&mut *pending), output_epoch) }; let _slow = okena_core::timing::SlowGuard::with_detail( "Terminal::drain_pending_output", @@ -125,6 +149,8 @@ impl Terminal { term.grid().topmost_line().0, ); self.content_generation.fetch_add(1, Ordering::Relaxed); + self.processed_output_epoch + .fetch_max(output_epoch, Ordering::Release); } /// Check if terminal has pending changes (and clear the flag). @@ -138,19 +164,73 @@ impl Terminal { self.content_generation.load(Ordering::Relaxed) } + fn mark_user_input(&self, has_payload: bool) { + self.had_user_input.store(true, Ordering::Relaxed); + if !has_payload { + return; + } + + // Synchronize with remote enqueue so output already buffered before the + // input cannot consume this request. The next enqueue receives this epoch. + let after_output_epoch = { + let _pending = self.pending_output.lock(); + self.output_epoch.load(Ordering::Acquire).saturating_add(1) + }; + *self.input_repaint_request.lock() = Some(InputRepaintRequest { + after_output_epoch, + expires_at: Instant::now() + INPUT_REPAINT_REQUEST_TTL, + }); + } + + /// Consume the one-shot request once parsed output crosses the output epoch + /// captured immediately before user input reached the transport. + pub fn take_input_repaint_request(&self) -> bool { + self.take_input_repaint_request_at(Instant::now()) + } + + pub(crate) fn take_input_repaint_request_at(&self, now: Instant) -> bool { + let processed_output_epoch = self.processed_output_epoch.load(Ordering::Acquire); + let mut request = self.input_repaint_request.lock(); + let Some(current) = *request else { + return false; + }; + if now >= current.expires_at { + *request = None; + return false; + } + if processed_output_epoch < current.after_output_epoch { + return false; + } + *request = None; + true + } + /// Send input to the PTY /// Automatically scrolls to bottom if scrolled into history pub fn send_input(&self, input: &str) { - self.had_user_input.store(true, Ordering::Relaxed); + self.send_input_inner(input, None); + } + + /// Send text input and associate latency samples with its originating viewer. + pub fn send_input_from_viewer(&self, input: &str, viewer: u64) { + self.send_input_inner(input, Some(viewer)); + } + + fn send_input_inner(&self, input: &str, viewer: Option) { + self.mark_user_input(!input.is_empty()); self.scroll_to_bottom(); - self.transport.send_input(&self.terminal_id, input.as_bytes()); + if let Some(viewer) = viewer { + okena_core::latency_probe::client_start(&self.terminal_id, viewer, input.as_bytes()); + } + self.transport + .send_input(&self.terminal_id, input.as_bytes()); } /// Send pasted text to the PTY, wrapping in bracketed paste sequences if the /// terminal application has enabled bracketed paste mode (DECSET 2004). /// This prevents shells from executing each line of a multi-line paste individually. pub fn send_paste(&self, text: &str) { - self.had_user_input.store(true, Ordering::Relaxed); + self.mark_user_input(!text.is_empty()); self.scroll_to_bottom(); let bracketed = self.term.lock().mode().contains(TermMode::BRACKETED_PASTE); @@ -160,7 +240,8 @@ impl Terminal { // No bracketed paste mode: convert all newlines to CR so each line lands // as Enter for the shell. (Multi-line content will execute line-by-line.) let normalized = text.replace("\r\n", "\r").replace('\n', "\r"); - self.transport.send_input(&self.terminal_id, normalized.as_bytes()); + self.transport + .send_input(&self.terminal_id, normalized.as_bytes()); } } @@ -174,7 +255,7 @@ impl Terminal { /// as literal text — annoying but recoverable, vs. multi-line content /// executing each line as a separate command. pub fn send_paste_force_bracketed(&self, text: &str) { - self.had_user_input.store(true, Ordering::Relaxed); + self.mark_user_input(!text.is_empty()); self.scroll_to_bottom(); self.write_bracketed_paste(text); } @@ -188,9 +269,7 @@ impl Terminal { let normalized = text.replace("\r\n", "\n"); // Strip any embedded paste markers so callers can't smuggle an early // `\x1b[201~` and break out into raw input. - let sanitized = normalized - .replace("\x1b[200~", "") - .replace("\x1b[201~", ""); + let sanitized = normalized.replace("\x1b[200~", "").replace("\x1b[201~", ""); let mut buf = Vec::with_capacity(sanitized.len() + 12); buf.extend_from_slice(b"\x1b[200~"); buf.extend_from_slice(sanitized.as_bytes()); @@ -201,8 +280,20 @@ impl Terminal { /// Send raw bytes to the PTY /// Automatically scrolls to bottom if scrolled into history pub fn send_bytes(&self, data: &[u8]) { - self.had_user_input.store(true, Ordering::Relaxed); + self.send_bytes_inner(data, None); + } + + /// Send raw input and associate latency samples with its originating viewer. + pub fn send_bytes_from_viewer(&self, data: &[u8], viewer: u64) { + self.send_bytes_inner(data, Some(viewer)); + } + + fn send_bytes_inner(&self, data: &[u8], viewer: Option) { + self.mark_user_input(!data.is_empty()); self.scroll_to_bottom(); + if let Some(viewer) = viewer { + okena_core::latency_probe::client_start(&self.terminal_id, viewer, data); + } self.transport.send_input(&self.terminal_id, data); } @@ -219,7 +310,10 @@ impl Terminal { let event = crate::input::KeyEvent { key: key.to_string(), key_char: None, - modifiers: crate::input::KeyModifiers { shift, ..Default::default() }, + modifiers: crate::input::KeyModifiers { + shift, + ..Default::default() + }, }; if let Some(bytes) = crate::input::key_to_bytes( &event, @@ -250,7 +344,8 @@ impl Terminal { // Send ANSI escape sequence to clear screen and move cursor to home // \x1b[2J = clear entire screen // \x1b[H = move cursor to home position (0,0) - self.transport.send_input(&self.terminal_id, b"\x1b[2J\x1b[H"); + self.transport + .send_input(&self.terminal_id, b"\x1b[2J\x1b[H"); self.scroll_to_bottom(); } } diff --git a/crates/okena-terminal/src/terminal/links.rs b/crates/okena-terminal/src/terminal/links.rs index 43527b6c2..2889ee8ed 100644 --- a/crates/okena-terminal/src/terminal/links.rs +++ b/crates/okena-terminal/src/terminal/links.rs @@ -17,7 +17,8 @@ impl Terminal { /// covers both halves together. pub fn detect_hyperlinks(&self) -> Vec { let mut result = Vec::new(); - let mut id_to_group: std::collections::HashMap = std::collections::HashMap::new(); + let mut id_to_group: std::collections::HashMap = + std::collections::HashMap::new(); self.with_content(|term| { let grid = term.grid(); @@ -76,7 +77,10 @@ impl Terminal { /// /// Returns a list of `DetectedLink` for each match. File paths are validated /// for existence by the caller (UrlDetector). - #[allow(clippy::expect_used, reason = "literal regex, compilation checked by unit test")] + #[allow( + clippy::expect_used, + reason = "literal regex, compilation checked by unit test" + )] pub fn detect_urls(&self) -> Vec { static LINK_REGEX: OnceLock = OnceLock::new(); let regex = LINK_REGEX.get_or_init(|| { @@ -89,7 +93,32 @@ impl Terminal { // Characters that can appear in a URL (for continuation detection) let url_char = |c: char| -> bool { - c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~' | ':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | '%') + c.is_ascii_alphanumeric() + || matches!( + c, + '-' | '.' + | '_' + | '~' + | ':' + | '/' + | '?' + | '#' + | '[' + | ']' + | '@' + | '!' + | '$' + | '&' + | '\'' + | '(' + | ')' + | '*' + | '+' + | ',' + | ';' + | '=' + | '%' + ) }; let mut matches = Vec::new(); @@ -193,7 +222,8 @@ impl Terminal { let seg_start = match_start.max(row_start_offset); let seg_end = trimmed_end.min(row_end_offset); - let col_start = combined_text[row_start_offset..seg_start].chars().count() + leading_stripped; + let col_start = combined_text[row_start_offset..seg_start].chars().count() + + leading_stripped; let len = combined_text[seg_start..seg_end].chars().count(); if len > 0 { @@ -238,9 +268,7 @@ impl Terminal { // Advance to the last segment of this wrap_group. let mut last_idx = idx; - while last_idx + 1 < phase1_len - && matches[last_idx + 1].wrap_group == group - { + while last_idx + 1 < phase1_len && matches[last_idx + 1].wrap_group == group { last_idx += 1; } let next_idx = last_idx + 1; @@ -265,8 +293,7 @@ impl Terminal { let m_col = matches[last_idx].col; let m_len = matches[last_idx].len; let match_buf_line = m_line - display_offset; - let match_last_cell = - &grid[Point::new(Line(match_buf_line), last_col)]; + let match_last_cell = &grid[Point::new(Line(match_buf_line), last_col)]; if match_last_cell.flags.contains(Flags::WRAPLINE) { idx = next_idx; continue; @@ -330,10 +357,7 @@ impl Terminal { } // Take URL-compatible chars as extension. - let ext_char_len = content - .chars() - .take_while(|c| url_char(*c)) - .count(); + let ext_char_len = content.chars().take_while(|c| url_char(*c)).count(); if ext_char_len == 0 { break; } @@ -408,16 +432,11 @@ impl Terminal { // Continue only if extension fills to near end of // visible content on this row. - let next_trimmed_len = - next_rtrimmed.chars().count(); + let next_trimmed_len = next_rtrimmed.chars().count(); if indent + ext_char_len + 3 < next_trimmed_len { break; } - if !remaining.is_empty() - && remaining - .chars() - .any(|c| c.is_alphanumeric()) - { + if !remaining.is_empty() && remaining.chars().any(|c| c.is_alphanumeric()) { break; } diff --git a/crates/okena-terminal/src/terminal/meta.rs b/crates/okena-terminal/src/terminal/meta.rs index 138be0cd2..0a9383d0a 100644 --- a/crates/okena-terminal/src/terminal/meta.rs +++ b/crates/okena-terminal/src/terminal/meta.rs @@ -13,8 +13,8 @@ impl Terminal { *self.has_bell.lock() } - /// Take any pending OSC 52 clipboard writes. Called by the GPUI thread - /// on each render; returns the texts to write to the system clipboard. + /// Take any pending OSC 52 clipboard writes. Called by the GPUI activity + /// handler (with render as a fallback); returns texts for the system clipboard. pub fn take_pending_clipboard_writes(&self) -> Vec { std::mem::take(&mut *self.pending_clipboard.lock()) } @@ -38,7 +38,8 @@ impl Terminal { let responders = std::mem::take(&mut *self.pending_clipboard_reads.lock()); for responder in responders { let reply = responder(content); - self.transport.send_input(&self.terminal_id, reply.as_bytes()); + self.transport + .send_input(&self.terminal_id, reply.as_bytes()); } } diff --git a/crates/okena-terminal/src/terminal/mod.rs b/crates/okena-terminal/src/terminal/mod.rs index 46d17811e..40ba429b7 100644 --- a/crates/okena-terminal/src/terminal/mod.rs +++ b/crates/okena-terminal/src/terminal/mod.rs @@ -1,9 +1,11 @@ use alacritty_terminal::term::test::TermSize; use alacritty_terminal::term::{Config as TermConfig, Term}; -use alacritty_terminal::vte::ansi::{CursorShape as VteCursorShape, CursorStyle as VteCursorStyle, Processor}; +use alacritty_terminal::vte::ansi::{ + CursorShape as VteCursorShape, CursorStyle as VteCursorStyle, Processor, +}; use parking_lot::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64}; use std::time::Instant; mod ansi_snapshot; @@ -14,8 +16,8 @@ mod idle; mod io; mod links; mod meta; -mod mouse; mod modes; +mod mouse; mod osc_sidecar; mod prompt_jump; mod prompt_marks; @@ -34,8 +36,11 @@ mod tests; pub use app_version::set_app_version; pub use child_processes::{foreground_command, has_child_processes}; +pub use event_listener::set_process_palette; 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 transport::TerminalTransport; pub use types::{ @@ -50,6 +55,12 @@ use osc_sidecar::OscSidecar; use prompt_marks::{PromptSidecar, PromptTracker}; use types::FocusReportState; +#[derive(Debug, Clone, Copy)] +pub(super) struct InputRepaintRequest { + after_output_epoch: u64, + expires_at: Instant, +} + /// A terminal instance wrapping alacritty_terminal /// Terminal emulator state. /// @@ -66,7 +77,7 @@ use types::FocusReportState; /// /// 2. **Tokio reader task** (remote connections only) — calls `enqueue_output` /// to buffer incoming data without holding `term.lock()`. Only touches -/// `pending_output`, `dirty`, and `last_output_time`. +/// `pending_output`, output epochs, `dirty`, and `last_output_time`. /// /// 3. **Resize debounce timer** — a short-lived `std::thread::spawn` that /// flushes a trailing-edge resize after the debounce window. Only touches @@ -89,13 +100,12 @@ use types::FocusReportState; /// threads contend. /// /// - **`AtomicBool` / `AtomicU64`** — lock-free signaling between the GPUI -/// thread and the tokio reader task (for `dirty`), or between the GPUI -/// thread's output path and its render path (for `content_generation`, +/// thread and the tokio reader task (for `dirty` and output epochs), or between +/// the GPUI thread's output path and its render path (for `content_generation`, /// `waiting_for_input`, `had_user_input`) to avoid mutex overhead on every /// frame. pub struct Terminal { // ── Immutable after construction ───────────────────────────────── - /// Unique identifier for this terminal instance. Immutable after /// construction; read freely from any thread. pub terminal_id: String, @@ -113,7 +123,6 @@ pub struct Terminal { // All fields below are accessed exclusively from the GPUI thread. // `Mutex` provides interior mutability for `&self` methods, not // cross-thread safety. - /// ANSI parser state (alacritty_terminal `Term`). Locked by /// `process_output`, `with_content`, `resize`, `scroll`, and selection /// methods — all on the GPUI thread. The `Arc` is structural: it doesn't @@ -159,8 +168,8 @@ pub struct Terminal { pub(super) has_notification: AtomicBool, /// Pending OSC 52 clipboard writes requested by the running app. `Arc` - /// shared with `ZedEventListener`: pushed during `process_output`, - /// drained by the GPUI render path via `drain_clipboard_writes`. + /// shared with `ZedEventListener`: pushed during `process_output`, then + /// drained by the GPUI activity handler (or render fallback). /// GPUI thread only. pub(super) pending_clipboard: Arc>>, @@ -255,7 +264,6 @@ pub struct Terminal { pub(super) last_viewed_time: Arc>, // ── GPUI + resize debounce timer ───────────────────────────────── - /// Terminal size, debounce state, and pending PTY resize. `Arc` is /// required: a clone is handed to the short-lived debounce timer thread /// (`std::thread::spawn` in `resize`) which flushes the trailing-edge @@ -266,7 +274,6 @@ pub struct Terminal { // These fields are touched by the remote-connection tokio reader task // via `enqueue_output`. The tokio task buffers data and sets flags; // the GPUI thread drains and clears them. - /// Buffer for remote-connection output. Written by the tokio reader /// task (`enqueue_output`), drained by the GPUI thread /// (`drain_pending_output` inside `with_content`). Decouples the tokio @@ -274,6 +281,22 @@ pub struct Terminal { /// freeze the UI. pub(super) pending_output: Mutex>, + /// Monotonic arrival epoch for local and remotely enqueued output. Input + /// captures the next epoch as its causal repaint boundary. + pub(super) output_epoch: AtomicU64, + + /// Last remote-output epoch represented by `pending_output`. Updated while + /// holding `pending_output` so a drain captures bytes and epoch atomically. + pub(super) pending_output_epoch: AtomicU64, + + /// Highest output epoch incorporated into the terminal model. + pub(super) processed_output_epoch: AtomicU64, + + /// Output-after-input promotion request. The epoch prevents pre-input + /// backlog from consuming it; expiry prevents an unanswered input from + /// promoting unrelated output indefinitely. + pub(super) input_repaint_request: Mutex>, + /// Content-changed flag. Set by `process_output` (GPUI) and /// `enqueue_output` (tokio). Cleared by `take_dirty` (GPUI render). /// `AtomicBool` for lock-free cross-thread signaling. @@ -290,7 +313,6 @@ pub struct Terminal { // ── Atomics (lock-free render reads) ───────────────────────────── // These use atomics so the GPUI render path can read them without // taking a mutex on every frame. - /// Monotonically-increasing counter bumped on every `process_output`, /// `drain_pending_output`, resize, scroll, and selection change. Used /// by `UrlDetector` and `SearchBar` to skip redundant work when @@ -298,6 +320,9 @@ pub struct Terminal { /// the atomic avoids locking, not cross-thread access). pub(super) content_generation: AtomicU64, + /// Latest broadcaster sequence incorporated into the terminal model. + pub(super) processed_output_sequence: AtomicU64, + /// Cached "waiting for input" state. Written by the GPUI idle-check /// loop (`set_waiting_for_input`), read lock-free by renderers /// (`is_waiting_for_input`). Atomic avoids mutex overhead in the @@ -399,8 +424,13 @@ impl Terminal { pending_clipboard_reads, palette, pending_output: Mutex::new(Vec::new()), + output_epoch: AtomicU64::new(0), + pending_output_epoch: AtomicU64::new(0), + processed_output_epoch: AtomicU64::new(0), + input_repaint_request: Mutex::new(None), dirty: AtomicBool::new(false), content_generation: AtomicU64::new(0), + processed_output_sequence: AtomicU64::new(0), initial_cwd, reported_cwd, pending_notifications, diff --git a/crates/okena-terminal/src/terminal/mouse.rs b/crates/okena-terminal/src/terminal/mouse.rs index 9e290b1a9..3e13e9217 100644 --- a/crates/okena-terminal/src/terminal/mouse.rs +++ b/crates/okena-terminal/src/terminal/mouse.rs @@ -13,7 +13,8 @@ impl Terminal { return true; } let term = self.term.lock(); - term.mode().intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) + term.mode() + .intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) } /// Forward a mouse button press or release to the PTY. @@ -40,7 +41,11 @@ impl Terminal { format!("\x1b[<{};{};{}{}", cb, col + 1, row + 1, action).into_bytes() } else { // Legacy X10/normal format: release reports button=3, no SGR distinction. - let legacy_cb = if pressed { cb } else { 3 | (modifiers & 0b1_1100) }; + let legacy_cb = if pressed { + cb + } else { + 3 | (modifiers & 0b1_1100) + }; vec![ 0x1b, b'[', @@ -165,7 +170,11 @@ impl Terminal { let arrow_seq: &[u8] = if going_right { if app_cursor { b"\x1bOC" } else { b"\x1b[C" } - } else if app_cursor { b"\x1bOD" } else { b"\x1b[D" }; + } else if app_cursor { + b"\x1bOD" + } else { + b"\x1b[D" + }; let mut buf = Vec::with_capacity(arrow_seq.len() * arrow_count); for _ in 0..arrow_count { diff --git a/crates/okena-terminal/src/terminal/osc_sidecar.rs b/crates/okena-terminal/src/terminal/osc_sidecar.rs index 9bb681dab..053bccc24 100644 --- a/crates/okena-terminal/src/terminal/osc_sidecar.rs +++ b/crates/okena-terminal/src/terminal/osc_sidecar.rs @@ -153,8 +153,7 @@ impl SidecarPerform { let decoded = base64::engine::general_purpose::STANDARD .decode(payload_raw.as_bytes()) .or_else(|_| { - base64::engine::general_purpose::STANDARD_NO_PAD - .decode(payload_raw.as_bytes()) + base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload_raw.as_bytes()) }); match decoded.ok().and_then(|b| String::from_utf8(b).ok()) { Some(s) => s, @@ -165,13 +164,15 @@ impl SidecarPerform { }; // Bound memory: ignore a brand-new id once too many are in flight. - if !self.osc99_pending.contains_key(&id) - && self.osc99_pending.len() >= OSC99_MAX_PENDING - { + if !self.osc99_pending.contains_key(&id) && self.osc99_pending.len() >= OSC99_MAX_PENDING { return; } let acc = self.osc99_pending.entry(id.clone()).or_default(); - let target = if ptype == "body" { &mut acc.body } else { &mut acc.title }; + let target = if ptype == "body" { + &mut acc.body + } else { + &mut acc.title + }; if target.len() + payload.len() <= OSC99_MAX_FIELD_LEN { target.push_str(&payload); } @@ -196,7 +197,10 @@ impl SidecarPerform { body: body.to_string(), } } else if !title.is_empty() { - TerminalNotification { title: None, body: title.to_string() } + TerminalNotification { + title: None, + body: title.to_string(), + } } else { return; // nothing to show }; @@ -321,10 +325,12 @@ impl Perform for SidecarPerform { .join(";"); let message = message.trim(); if !message.is_empty() { - self.pending_notifications.lock().push(TerminalNotification { - title: None, - body: message.to_string(), - }); + self.pending_notifications + .lock() + .push(TerminalNotification { + title: None, + body: message.to_string(), + }); } } b"777" => { @@ -350,10 +356,12 @@ impl Perform for SidecarPerform { .join(";"); let body = body.trim(); if !body.is_empty() { - self.pending_notifications.lock().push(TerminalNotification { - title: (!title.is_empty()).then(|| title.to_string()), - body: body.to_string(), - }); + self.pending_notifications + .lock() + .push(TerminalNotification { + title: (!title.is_empty()).then(|| title.to_string()), + body: body.to_string(), + }); } } b"99" => self.handle_osc99(params), @@ -383,7 +391,8 @@ impl Perform for SidecarPerform { return; } let response = format!("\x1bP>|okena({})\x1b\\", app_version()); - self.transport.send_input(&self.terminal_id, response.as_bytes()); + self.transport + .send_input(&self.terminal_id, response.as_bytes()); } } diff --git a/crates/okena-terminal/src/terminal/prompt_marks.rs b/crates/okena-terminal/src/terminal/prompt_marks.rs index 7c1d1a2bf..dc5981883 100644 --- a/crates/okena-terminal/src/terminal/prompt_marks.rs +++ b/crates/okena-terminal/src/terminal/prompt_marks.rs @@ -26,7 +26,10 @@ pub(crate) struct PromptTracker { impl PromptTracker { pub(super) fn new() -> Self { - Self { marks: VecDeque::with_capacity(64), capacity: 64 } + Self { + marks: VecDeque::with_capacity(64), + capacity: 64, + } } /// Record a new mark at the given grid point. Evicts the oldest mark @@ -80,7 +83,11 @@ pub(super) fn parse_osc133_kind(kind: u8, rest: &[&[u8]]) -> Option().ok() } + if s.is_empty() { + None + } else { + s.parse::().ok() + } }); Some(PromptMarkKind::CommandFinished { exit_code }) } @@ -117,8 +124,12 @@ impl Perform for PromptSidecarPerform { if params.first().copied() != Some(b"133".as_ref()) { return; } - let Some(kind_param) = params.get(1) else { return }; - let Some(&kind_byte) = kind_param.first() else { return }; + let Some(kind_param) = params.get(1) else { + return; + }; + let Some(&kind_byte) = kind_param.first() else { + return; + }; if let Some(kind) = parse_osc133_kind(kind_byte, ¶ms[2..]) { self.pending = Some(kind); } diff --git a/crates/okena-terminal/src/terminal/render.rs b/crates/okena-terminal/src/terminal/render.rs index d8589ecd8..2096005ec 100644 --- a/crates/okena-terminal/src/terminal/render.rs +++ b/crates/okena-terminal/src/terminal/render.rs @@ -7,9 +7,19 @@ impl Terminal { /// Produces a byte stream that, when fed to another terminal emulator, /// reproduces the current screen state including colors and attributes. pub fn render_snapshot(&self) -> Vec { + self.render_snapshot_with_sequence().0 + } + + /// Render a snapshot with the last PTY event incorporated into that grid. + pub fn render_snapshot_with_sequence(&self) -> (Vec, u64) { let mut slow = okena_core::timing::SlowGuard::new("Terminal::render_snapshot"); - let bytes = self.with_content(grid_to_ansi); + self.drain_pending_output(); + let term = self.term.lock(); + let bytes = grid_to_ansi(&term); + let sequence = self + .processed_output_sequence + .load(std::sync::atomic::Ordering::Acquire); slow.set_detail(format!("{} bytes", bytes.len())); - bytes + (bytes, sequence) } } diff --git a/crates/okena-terminal/src/terminal/resize.rs b/crates/okena-terminal/src/terminal/resize.rs index 69628e62a..9d72723ef 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; @@ -23,7 +24,11 @@ impl Terminal { // Clamp to at least 1 col/row - alacritty_terminal panics on zero dimensions let cols = new_size.cols.max(1); let rows = new_size.rows.max(1); - let new_size = TerminalSize { cols, rows, ..new_size }; + let new_size = TerminalSize { + cols, + rows, + ..new_size + }; // Always update local size immediately (optimistic UI) { @@ -50,7 +55,14 @@ impl Terminal { rs.pending_pty_resize = None; rs.last_pty_resize = now; drop(rs); - self.transport.resize(&self.terminal_id, new_size.cols, new_size.rows); + 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 rs.pending_pty_resize = Some((new_size.cols, new_size.rows)); @@ -104,24 +116,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..9217da6df 100644 --- a/crates/okena-terminal/src/terminal/resize_authority.rs +++ b/crates/okena-terminal/src/terminal/resize_authority.rs @@ -1,35 +1,157 @@ -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/search.rs b/crates/okena-terminal/src/terminal/search.rs index 8986a0825..ef589c22e 100644 --- a/crates/okena-terminal/src/terminal/search.rs +++ b/crates/okena-terminal/src/terminal/search.rs @@ -8,7 +8,12 @@ impl Terminal { /// Search the terminal grid for occurrences of a query string /// Returns a list of (line, col, length) for each match /// Supports case-sensitive and regex search, and searches through scrollback buffer - pub fn search_grid(&self, query: &str, case_sensitive: bool, is_regex: bool) -> Vec<(i32, usize, usize)> { + pub fn search_grid( + &self, + query: &str, + case_sensitive: bool, + is_regex: bool, + ) -> Vec<(i32, usize, usize)> { if query.is_empty() { return Vec::new(); } @@ -59,7 +64,8 @@ impl Terminal { // Convert a byte offset to a column index let col_at_byte = |byte_offset: usize| -> usize { - line_text.char_indices() + line_text + .char_indices() .enumerate() .find(|(_, (b, _))| *b == byte_offset) .map(|(col, _)| col) diff --git a/crates/okena-terminal/src/terminal/selection.rs b/crates/okena-terminal/src/terminal/selection.rs index cb005dcfb..33942c79c 100644 --- a/crates/okena-terminal/src/terminal/selection.rs +++ b/crates/okena-terminal/src/terminal/selection.rs @@ -52,7 +52,13 @@ impl Terminal { /// Start selection with a specific type /// Note: row is the visual row on screen (0 to screen_lines-1) /// We convert it to buffer coordinates by accounting for display_offset - fn start_selection_with_type(&self, col: usize, row: i32, selection_type: SelectionType, side: Side) { + fn start_selection_with_type( + &self, + col: usize, + row: i32, + selection_type: SelectionType, + side: Side, + ) { let mut term = self.term.lock(); // Convert visual row to buffer row @@ -128,11 +134,12 @@ impl Terminal { pub fn selection_bounds(&self) -> Option<((usize, i32), (usize, i32))> { let term = self.term.lock(); if let Some(ref selection) = term.selection - && let Some(range) = selection.to_range(&*term) { - let start = (range.start.column.0, range.start.line.0); - let end = (range.end.column.0, range.end.line.0); - return Some((start, end)); - } + && let Some(range) = selection.to_range(&*term) + { + let start = (range.start.column.0, range.start.line.0); + let end = (range.end.column.0, range.end.line.0); + return Some((start, end)); + } None } diff --git a/crates/okena-terminal/src/terminal/tests/helpers.rs b/crates/okena-terminal/src/terminal/tests/helpers.rs index 4815b6840..eb25b520e 100644 --- a/crates/okena-terminal/src/terminal/tests/helpers.rs +++ b/crates/okena-terminal/src/terminal/tests/helpers.rs @@ -5,7 +5,9 @@ pub(crate) struct NullTransport; impl TerminalTransport for NullTransport { 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 } + fn uses_mouse_backend(&self) -> bool { + false + } } /// Records every byte the sidecar writes back to the PTY so tests can @@ -16,7 +18,9 @@ pub(crate) struct CapturingTransport { impl CapturingTransport { pub(crate) fn new() -> Self { - Self { writes: Mutex::new(Vec::new()) } + Self { + writes: Mutex::new(Vec::new()), + } } pub(crate) fn writes(&self) -> Vec> { @@ -29,5 +33,34 @@ impl TerminalTransport for CapturingTransport { self.writes.lock().push(data.to_vec()); } fn resize(&self, _terminal_id: &str, _cols: u16, _rows: u16) {} - fn uses_mouse_backend(&self) -> bool { false } + 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/input_repaint.rs b/crates/okena-terminal/src/terminal/tests/input_repaint.rs new file mode 100644 index 000000000..52a8fce10 --- /dev/null +++ b/crates/okena-terminal/src/terminal/tests/input_repaint.rs @@ -0,0 +1,108 @@ +use super::super::Terminal; +use super::super::types::TerminalSize; +use super::NullTransport; +use std::sync::{Arc, Barrier}; +use std::thread; +use std::time::{Duration, Instant}; + +fn terminal() -> Terminal { + Terminal::new( + "input-repaint-test".to_string(), + TerminalSize::default(), + Arc::new(NullTransport), + "/tmp".to_string(), + ) +} + +#[test] +fn user_input_promotes_the_next_processed_output_once() { + let terminal = terminal(); + + terminal.send_input("a"); + assert!( + !terminal.take_input_repaint_request(), + "input alone does not promote an old terminal frame" + ); + + terminal.process_output(b"a"); + assert!(terminal.take_input_repaint_request()); + assert!( + !terminal.take_input_repaint_request(), + "the request is one-shot until more input arrives" + ); +} + +#[test] +fn pre_input_backlog_cannot_consume_the_request() { + let terminal = terminal(); + + terminal.enqueue_output(b"before input"); + terminal.send_input("a"); + terminal.process_pending_output(); + assert!(!terminal.take_input_repaint_request()); + + terminal.enqueue_output(b"after input"); + terminal.process_pending_output(); + assert!(terminal.take_input_repaint_request()); +} + +#[test] +fn concurrent_enqueue_and_input_never_lose_the_priority_request() { + for _ in 0..16 { + let terminal = Arc::new(terminal()); + let start = Arc::new(Barrier::new(3)); + + let enqueue = { + let terminal = terminal.clone(); + let start = start.clone(); + thread::spawn(move || { + start.wait(); + terminal.enqueue_output(b"racing output"); + }) + }; + let input = { + let terminal = terminal.clone(); + let start = start.clone(); + thread::spawn(move || { + start.wait(); + terminal.send_input("a"); + }) + }; + + start.wait(); + assert!(enqueue.join().is_ok(), "enqueue thread panicked"); + assert!(input.join().is_ok(), "input thread panicked"); + terminal.process_pending_output(); + + if !terminal.take_input_repaint_request() { + // The racing output was ordered before the input. The first output + // ordered after it must still consume the request. + terminal.enqueue_output(b"after input"); + terminal.process_pending_output(); + assert!(terminal.take_input_repaint_request()); + } + } +} + +#[test] +fn unanswered_and_empty_input_do_not_leave_stale_priority() { + let terminal = terminal(); + + terminal.send_input(""); + terminal.process_output(b"unrelated"); + assert!(!terminal.take_input_repaint_request()); + + terminal.send_bytes(b"x"); + assert!(!terminal.take_input_repaint_request_at(Instant::now() + Duration::from_secs(10))); + terminal.process_output(b"much later"); + assert!(!terminal.take_input_repaint_request()); +} + +#[test] +fn terminal_output_alone_does_not_request_input_priority() { + let terminal = terminal(); + + terminal.process_output(b"background output"); + + assert!(!terminal.take_input_repaint_request()); +} diff --git a/crates/okena-terminal/src/terminal/tests/kitty.rs b/crates/okena-terminal/src/terminal/tests/kitty.rs index 7b66a3bff..10a6f07c4 100644 --- a/crates/okena-terminal/src/terminal/tests/kitty.rs +++ b/crates/okena-terminal/src/terminal/tests/kitty.rs @@ -83,7 +83,10 @@ fn send_escape_and_backtab_actions_are_kitty_aware() { // Legacy before the protocol is enabled. t.send_escape(); t.send_backtab(); - assert_eq!(transport.writes(), vec![b"\x1b".to_vec(), b"\x1b[Z".to_vec()]); + assert_eq!( + transport.writes(), + vec![b"\x1b".to_vec(), b"\x1b[Z".to_vec()] + ); // After the app pushes disambiguate, the same actions emit CSI u. t.process_output(b"\x1b[>1u"); diff --git a/crates/okena-terminal/src/terminal/tests/mod.rs b/crates/okena-terminal/src/terminal/tests/mod.rs index 3863b129b..746729634 100644 --- a/crates/okena-terminal/src/terminal/tests/mod.rs +++ b/crates/okena-terminal/src/terminal/tests/mod.rs @@ -1,10 +1,12 @@ mod focus_report; mod helpers; +mod input_repaint; mod kitty; mod osc; mod prompt_jump; mod resize_authority; +mod snapshot_watermark; 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..e8bd5db59 100644 --- a/crates/okena-terminal/src/terminal/tests/osc.rs +++ b/crates/okena-terminal/src/terminal/tests/osc.rs @@ -2,9 +2,7 @@ use super::super::Terminal; use super::super::TerminalNotification; use super::super::app_version::set_app_version; use super::super::osc_sidecar::parse_osc7_file_uri; -use super::super::types::{ - PromptMarkKind, TerminalProgress, TerminalProgressState, TerminalSize, -}; +use super::super::types::{PromptMarkKind, TerminalProgress, TerminalProgressState, TerminalSize}; use super::{CapturingTransport, NullTransport}; use std::sync::Arc; @@ -272,12 +270,19 @@ fn test_osc_title_reset() { terminal.process_output(b"\x1b]0;\x07"); // After reset, title should be cleared or set to empty let title = terminal.title(); - assert!(title.is_none() || title.as_deref() == Some(""), "title should be empty or None, got: {:?}", title); + assert!( + title.is_none() || title.as_deref() == Some(""), + "title should be empty or None, got: {:?}", + title + ); } /// A title-less notification, the shape `OSC 9` produces. fn body(text: &str) -> TerminalNotification { - TerminalNotification { title: None, body: text.to_string() } + TerminalNotification { + title: None, + body: text.to_string(), + } } #[test] @@ -366,16 +371,18 @@ fn test_osc9_st_terminator() { // ST-terminated form (ESC \) is equally valid. terminal.process_output(b"\x1b]9;hello\x1b\\"); - assert_eq!( - terminal.take_pending_notifications(), - vec![body("hello")], - ); + assert_eq!(terminal.take_pending_notifications(), vec![body("hello")],); } #[test] fn test_osc9_4_st1_sets_normal_progress() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); assert_eq!(terminal.progress(), None); @@ -384,19 +391,30 @@ fn test_osc9_4_st1_sets_normal_progress() { assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Normal, value: 42 }), + Some(TerminalProgress { + state: TerminalProgressState::Normal, + value: 42 + }), ); // Progress is sticky (not drained on read). assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Normal, value: 42 }), + Some(TerminalProgress { + state: TerminalProgressState::Normal, + value: 42 + }), ); } #[test] fn test_osc9_4_st0_clears_progress() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); terminal.process_output(b"\x1b]9;4;1;50\x07"); assert!(terminal.progress().is_some()); @@ -409,35 +427,56 @@ fn test_osc9_4_st0_clears_progress() { #[test] fn test_osc9_4_st3_indeterminate_ignores_value() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // st=3 is a spinner — pr is meaningless and normalised to 0. terminal.process_output(b"\x1b]9;4;3;77\x07"); assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Indeterminate, value: 0 }), + Some(TerminalProgress { + state: TerminalProgressState::Indeterminate, + value: 0 + }), ); } #[test] fn test_osc9_4_clamps_value_over_100() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // pr above 100 is clamped to 100. terminal.process_output(b"\x1b]9;4;1;255\x07"); assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Normal, value: 100 }), + Some(TerminalProgress { + state: TerminalProgressState::Normal, + value: 100 + }), ); } #[test] fn test_osc9_4_st2_error_keeps_previous_value() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // Set a baseline, then signal an error without an explicit percent. terminal.process_output(b"\x1b]9;4;1;30\x07"); @@ -445,21 +484,32 @@ fn test_osc9_4_st2_error_keeps_previous_value() { assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Error, value: 30 }), + Some(TerminalProgress { + state: TerminalProgressState::Error, + value: 30 + }), ); // An explicit pr on the error state overrides the kept value. terminal.process_output(b"\x1b]9;4;2;80\x07"); assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Error, value: 80 }), + Some(TerminalProgress { + state: TerminalProgressState::Error, + value: 80 + }), ); } #[test] fn test_osc9_4_garbage_st_ignored() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); terminal.process_output(b"\x1b]9;4;1;25\x07"); // A non-numeric / out-of-range st must leave the current bar untouched and @@ -469,7 +519,10 @@ fn test_osc9_4_garbage_st_ignored() { assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Normal, value: 25 }), + Some(TerminalProgress { + state: TerminalProgressState::Normal, + value: 25 + }), ); assert!(terminal.take_pending_notifications().is_empty()); } @@ -477,7 +530,12 @@ fn test_osc9_4_garbage_st_ignored() { #[test] fn test_osc9_4_does_not_produce_notification() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // The progress subtype must NOT be treated as notification text... terminal.process_output(b"\x1b]9;4;1;42\x07"); @@ -487,17 +545,28 @@ fn test_osc9_4_does_not_produce_notification() { // ...while a plain OSC 9 message still produces a notification and leaves // progress untouched (no regression). terminal.process_output(b"\x1b]9;Build complete\x07"); - assert_eq!(terminal.take_pending_notifications(), vec![body("Build complete")]); + assert_eq!( + terminal.take_pending_notifications(), + vec![body("Build complete")] + ); assert_eq!( terminal.progress(), - Some(TerminalProgress { state: TerminalProgressState::Normal, value: 42 }), + Some(TerminalProgress { + state: TerminalProgressState::Normal, + value: 42 + }), ); } #[test] fn test_osc777_notify_title_and_body() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // urxvt-style: OSC 777 ; notify ; ; <body> terminal.process_output(b"\x1b]777;notify;Claude;Waiting for your input\x07"); @@ -514,7 +583,12 @@ fn test_osc777_notify_title_and_body() { #[test] fn test_osc777_body_keeps_semicolons() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // A body containing semicolons must be rejoined, not truncated. terminal.process_output(b"\x1b]777;notify;Build;done: a; b; c\x07"); @@ -531,7 +605,12 @@ fn test_osc777_body_keeps_semicolons() { #[test] fn test_osc777_non_notify_subcommand_ignored() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // 777 carries unrelated subcommands (e.g. precmd) — those must not queue. terminal.process_output(b"\x1b]777;precmd;something\x07"); @@ -544,7 +623,12 @@ fn test_osc777_non_notify_subcommand_ignored() { #[test] fn test_osc99_simple_single_chunk() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // Minimal kitty form: empty metadata, payload is the (title) text. With no // body it maps to a title-less notification, like OSC 9. @@ -552,18 +636,29 @@ fn test_osc99_simple_single_chunk() { assert_eq!( terminal.take_pending_notifications(), - vec![TerminalNotification { title: None, body: "Hello world".to_string() }], + vec![TerminalNotification { + title: None, + body: "Hello world".to_string() + }], ); } #[test] fn test_osc99_title_and_body_chunks() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // First chunk: title, d=0 (not complete) → nothing displayed yet. terminal.process_output(b"\x1b]99;i=x:d=0;My Title\x07"); - assert!(terminal.take_pending_notifications().is_empty(), "d=0 must not emit"); + assert!( + terminal.take_pending_notifications().is_empty(), + "d=0 must not emit" + ); // Second chunk: body, d defaults to 1 (complete) → emit assembled pair. terminal.process_output(b"\x1b]99;i=x:p=body;Body text\x07"); @@ -580,21 +675,34 @@ fn test_osc99_title_and_body_chunks() { fn test_osc99_base64_payload() { use base64::Engine as _; let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); let encoded = base64::engine::general_purpose::STANDARD.encode("Encoded msg"); terminal.process_output(format!("\x1b]99;e=1;{encoded}\x07").as_bytes()); assert_eq!( terminal.take_pending_notifications(), - vec![TerminalNotification { title: None, body: "Encoded msg".to_string() }], + vec![TerminalNotification { + title: None, + body: "Encoded msg".to_string() + }], ); } #[test] fn test_osc99_close_drops_pending() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // Start a chunked notification, then close it before completion. terminal.process_output(b"\x1b]99;i=x:d=0;Partial\x07"); @@ -609,7 +717,12 @@ fn test_osc99_close_drops_pending() { #[test] fn test_osc99_query_payload_ignored() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); // A capability query (p=?) carries no displayable text. terminal.process_output(b"\x1b]99;p=?;\x07"); @@ -619,7 +732,12 @@ fn test_osc99_query_payload_ignored() { #[test] fn test_bell_edge_is_one_shot() { let transport = Arc::new(NullTransport); - let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); assert!(!terminal.take_pending_bell(), "no bell yet"); @@ -629,7 +747,10 @@ fn test_bell_edge_is_one_shot() { assert!(!terminal.take_pending_bell(), "edge is consumed (one-shot)"); // The sticky UI flag is independent of the one-shot notification edge. - assert!(terminal.has_bell(), "has_bell stays set until focus clears it"); + assert!( + terminal.has_bell(), + "has_bell stays set until focus clears it" + ); } #[test] @@ -642,7 +763,10 @@ fn test_osc52_read_request_queues_responder() { "/tmp".into(), ); - assert!(!terminal.has_pending_clipboard_reads(), "nothing queued yet"); + assert!( + !terminal.has_pending_clipboard_reads(), + "nothing queued yet" + ); // OSC 52 ; c ; ? — the app asks to READ the clipboard. terminal.process_output(b"\x1b]52;c;?\x07"); @@ -685,7 +809,10 @@ fn test_osc52_answer_clipboard_reads_replies_and_drains() { assert_eq!(writes.len(), 1, "expected exactly one PTY reply"); assert!(!writes[0].is_empty(), "reply must not be empty"); let body = std::str::from_utf8(&writes[0]).unwrap(); - assert!(body.contains("52;"), "reply should be an OSC 52 sequence: {body:?}"); + assert!( + body.contains("52;"), + "reply should be an OSC 52 sequence: {body:?}" + ); } #[test] @@ -704,7 +831,10 @@ fn test_osc52_drop_clipboard_reads_clears_without_reply() { // Silent deny: drop the request without writing anything to the PTY. terminal.drop_clipboard_reads(); - assert!(!terminal.has_pending_clipboard_reads(), "queue must be cleared"); + assert!( + !terminal.has_pending_clipboard_reads(), + "queue must be cleared" + ); assert!( transport.writes().is_empty(), "dropping must not reply: {:?}", @@ -804,7 +934,10 @@ fn test_xtversion_responds_with_okena_name() { // set_app_version uses OnceLock, we can't rely on the exact string // across tests. Assert that *some* non-empty version is reported. assert!(body.contains("okena("), "got: {body:?}"); - assert!(!body.contains("okena()"), "version must not be empty: {body:?}"); + assert!( + !body.contains("okena()"), + "version must not be empty: {body:?}" + ); } #[test] @@ -915,7 +1048,9 @@ fn test_osc133_d_parses_nonzero_exit_code() { let marks = terminal.prompt_marks(); assert_eq!( marks[0].kind, - PromptMarkKind::CommandFinished { exit_code: Some(127) }, + PromptMarkKind::CommandFinished { + exit_code: Some(127) + }, ); } @@ -1029,12 +1164,23 @@ fn test_osc133_ring_buffer_evicts_oldest() { #[test] fn test_parse_osc133_kind() { use super::super::prompt_marks::parse_osc133_kind; - assert_eq!(parse_osc133_kind(b'A', &[]), Some(PromptMarkKind::PromptStart)); - assert_eq!(parse_osc133_kind(b'B', &[]), Some(PromptMarkKind::CommandStart)); - assert_eq!(parse_osc133_kind(b'C', &[]), Some(PromptMarkKind::CommandExecuted)); + assert_eq!( + parse_osc133_kind(b'A', &[]), + Some(PromptMarkKind::PromptStart) + ); + assert_eq!( + parse_osc133_kind(b'B', &[]), + Some(PromptMarkKind::CommandStart) + ); + assert_eq!( + parse_osc133_kind(b'C', &[]), + Some(PromptMarkKind::CommandExecuted) + ); assert_eq!( parse_osc133_kind(b'D', &[b"42"]), - Some(PromptMarkKind::CommandFinished { exit_code: Some(42) }), + Some(PromptMarkKind::CommandFinished { + exit_code: Some(42) + }), ); // Non-numeric extra params mean "unknown exit". assert_eq!( @@ -1043,3 +1189,64 @@ 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/prompt_jump.rs b/crates/okena-terminal/src/terminal/tests/prompt_jump.rs index 77a4a091c..5d2cc3096 100644 --- a/crates/okena-terminal/src/terminal/tests/prompt_jump.rs +++ b/crates/okena-terminal/src/terminal/tests/prompt_jump.rs @@ -196,9 +196,7 @@ fn test_jump_to_failed_jumps_to_failure() { // A single prompt whose command fails (exit code 1), then a fresh // prompt with enough output to scroll the failure into history. - terminal.process_output( - b"\x1b]133;A\x1b\\$ boom\r\nerr\r\nmore\r\n\x1b]133;D;1\x1b\\", - ); + terminal.process_output(b"\x1b]133;A\x1b\\$ boom\r\nerr\r\nmore\r\n\x1b]133;D;1\x1b\\"); terminal.process_output(b"\x1b]133;A\x1b\\$ ok\r\n"); // First Above press engages the walker and lands on the failure. 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:<connection>:<id>`. + 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/tests/snapshot_watermark.rs b/crates/okena-terminal/src/terminal/tests/snapshot_watermark.rs new file mode 100644 index 000000000..3ad3a5b1f --- /dev/null +++ b/crates/okena-terminal/src/terminal/tests/snapshot_watermark.rs @@ -0,0 +1,22 @@ +use super::super::Terminal; +use super::super::types::TerminalSize; +use super::NullTransport; +use std::sync::Arc; + +#[test] +fn snapshot_reports_the_last_incorporated_output_sequence() { + let terminal = Terminal::new( + "snapshot-sequence".to_string(), + TerminalSize::default(), + Arc::new(NullTransport), + "/tmp".to_string(), + ); + + terminal.process_output_with_sequence(b"first", 41); + let (_, first_watermark) = terminal.render_snapshot_with_sequence(); + assert_eq!(first_watermark, 41); + + terminal.process_output_with_sequence(b"second", 42); + let (_, second_watermark) = terminal.render_snapshot_with_sequence(); + assert_eq!(second_watermark, 42); +} diff --git a/crates/okena-terminal/src/terminal/tests/url_detect.rs b/crates/okena-terminal/src/terminal/tests/url_detect.rs index 968d22cf9..a7b5adde6 100644 --- a/crates/okena-terminal/src/terminal/tests/url_detect.rs +++ b/crates/okena-terminal/src/terminal/tests/url_detect.rs @@ -6,7 +6,12 @@ use std::sync::Arc; /// Helper: create a terminal and write text to it, returns detected URLs fn detect_urls_in(text: &str, cols: u16) -> Vec<DetectedLink> { let transport = Arc::new(NullTransport); - let size = TerminalSize { cols, rows: 24, cell_width: 8.0, cell_height: 16.0 }; + let size = TerminalSize { + cols, + rows: 24, + cell_width: 8.0, + cell_height: 16.0, + }; let terminal = Terminal::new("test".into(), size, transport, "/tmp".into()); terminal.process_output(text.as_bytes()); terminal.detect_urls() @@ -20,10 +25,7 @@ fn detect_url_wrapped_with_padding() { // Row 1: "- https://claude.ai/code/sess_ABC" (33 chars) // Row 2: " DEF123" + padding // cols=36 so row 1 is nearly full (33+3 >= 36). - let links = detect_urls_in( - "- https://claude.ai/code/sess_ABC\r\n DEF123\r\n", - 36, - ); + let links = detect_urls_in("- https://claude.ai/code/sess_ABC\r\n DEF123\r\n", 36); assert_eq!(links.len(), 2, "URL spans two rows: {:?}", links); assert_eq!(links[0].text, "https://claude.ai/code/sess_ABCDEF123"); assert_eq!(links[0].col, 2); @@ -38,10 +40,7 @@ fn detect_url_wrapped_with_leading_padding() { // Row 1: " https://claude.ai/code/sess_ABC" (33 chars) + padding // Row 2: " DEF123" + padding // cols=36 so row 1 is nearly full (33+3 >= 36). - let links = detect_urls_in( - " https://claude.ai/code/sess_ABC\r\n DEF123\r\n", - 36, - ); + let links = detect_urls_in(" https://claude.ai/code/sess_ABC\r\n DEF123\r\n", 36); assert_eq!(links.len(), 2, "URL spans two rows: {:?}", links); assert_eq!(links[0].text, "https://claude.ai/code/sess_ABCDEF123"); assert_eq!(links[0].col, 2); // starts after 2 spaces @@ -59,17 +58,19 @@ fn detect_url_not_wrapped_when_next_line_more_indented() { " 1. text https://api.postmarkapp.com\r\n (next line)\r\n", 50, ); - assert_eq!(links.len(), 1, "URL should NOT merge with next line: {:?}", links); + assert_eq!( + links.len(), + 1, + "URL should NOT merge with next line: {:?}", + links + ); assert_eq!(links[0].text, "https://api.postmarkapp.com"); } #[test] fn detect_url_single_line_not_affected() { // Single-line URL should still work normally - let links = detect_urls_in( - "visit https://example.com/path here\r\n", - 80, - ); + let links = detect_urls_in("visit https://example.com/path here\r\n", 80); assert_eq!(links.len(), 1); assert_eq!(links[0].text, "https://example.com/path"); assert_eq!(links[0].col, 6); @@ -124,19 +125,25 @@ fn detect_duplicate_url_wrapped_then_whole() { ), 50, ); - let url_links: Vec<&DetectedLink> = links.iter() - .filter(|l| l.text == url) - .collect(); + let url_links: Vec<&DetectedLink> = links.iter().filter(|l| l.text == url).collect(); // Wrapped URL produces 2 segments + standalone URL = 3 total - assert!(url_links.len() >= 3, "Expected wrapped (2 segments) + standalone (1): {:?}", url_links); + assert!( + url_links.len() >= 3, + "Expected wrapped (2 segments) + standalone (1): {:?}", + url_links + ); let wrapped_group = url_links[0].wrap_group; // All wrapped segments share the same group - assert_eq!(url_links[0].wrap_group, url_links[1].wrap_group, - "Wrapped segments should share wrap_group"); + assert_eq!( + url_links[0].wrap_group, url_links[1].wrap_group, + "Wrapped segments should share wrap_group" + ); // Standalone URL has a different group let standalone = url_links.last().unwrap(); - assert_ne!(wrapped_group, standalone.wrap_group, - "Standalone URL must have different wrap_group than wrapped one"); + assert_ne!( + wrapped_group, standalone.wrap_group, + "Standalone URL must have different wrap_group than wrapped one" + ); } #[test] @@ -154,10 +161,13 @@ fn detect_duplicate_url_after_colon_prefix() { ), 80, ); - let url_links: Vec<&DetectedLink> = links.iter() - .filter(|l| l.text == url) - .collect(); - assert_eq!(url_links.len(), 2, "Should have exactly 2 URL matches: {:?}", url_links); + let url_links: Vec<&DetectedLink> = links.iter().filter(|l| l.text == url).collect(); + assert_eq!( + url_links.len(), + 2, + "Should have exactly 2 URL matches: {:?}", + url_links + ); assert_ne!( url_links[0].wrap_group, url_links[1].wrap_group, "URLs must have different wrap_groups even when preceded by colon" @@ -189,14 +199,8 @@ fn detect_url_not_wrapped_when_next_line_word_after_wrapline() { &format!("{url}\r\nPress ENTER to open in the browser...\r\n"), 60, // force URL to wrap via WRAPLINE ); - let url_links: Vec<&DetectedLink> = links.iter() - .filter(|l| l.text == url) - .collect(); - assert!( - !url_links.is_empty(), - "Should detect the URL: {:?}", - links - ); + let url_links: Vec<&DetectedLink> = links.iter().filter(|l| l.text == url).collect(); + assert!(!url_links.is_empty(), "Should detect the URL: {:?}", links); // "Press" should NOT be part of any detected link assert!( links.iter().all(|l| !l.text.contains("Press")), @@ -214,7 +218,10 @@ fn detect_url_not_merged_with_remote_prefix() { 80, ); assert_eq!(links.len(), 1, "Should detect exactly one URL: {:?}", links); - assert_eq!(links[0].text, "https://github.com/contember/dotaz/pull/new/fixes"); + assert_eq!( + links[0].text, + "https://github.com/contember/dotaz/pull/new/fixes" + ); } #[test] @@ -225,8 +232,16 @@ fn detect_url_not_merged_with_label_suffix() { "https://github.com/contember/dotaz/pull/new/fixes\r\nremote:\r\n", 52, // URL is 50 chars, nearly fills 52-col terminal ); - assert_eq!(links.len(), 1, "Label-like 'remote:' must not be merged: {:?}", links); - assert_eq!(links[0].text, "https://github.com/contember/dotaz/pull/new/fixes"); + assert_eq!( + links.len(), + 1, + "Label-like 'remote:' must not be merged: {:?}", + links + ); + assert_eq!( + links[0].text, + "https://github.com/contember/dotaz/pull/new/fixes" + ); } #[test] @@ -239,7 +254,8 @@ fn detect_url_wrapped_with_trailing_text() { " - #61 https://github.com/contember/npi-infrastru\r\n cture/pull/61 \u{2014} S3 bucket\r\n", 55, ); - let url_links: Vec<&DetectedLink> = links.iter() + let url_links: Vec<&DetectedLink> = links + .iter() .filter(|l| l.text == "https://github.com/contember/npi-infrastructure/pull/61") .collect(); assert!( @@ -259,7 +275,8 @@ fn detect_url_wrapped_tui_narrow_layout() { "\u{2514} https://github.com/NPI-Cloud/npi-inf\r\n rastructure/pull/64\r\n", 55, ); - let url_links: Vec<&DetectedLink> = links.iter() + let url_links: Vec<&DetectedLink> = links + .iter() .filter(|l| l.text == "https://github.com/NPI-Cloud/npi-infrastructure/pull/64") .collect(); assert!( @@ -278,7 +295,12 @@ fn detect_url_not_extended_by_list_marker() { " https://github.com/contember/dotaz/pull/2\r\n - Format check passes\r\n", 55, ); - assert_eq!(links.len(), 1, "Should not extend into list marker: {:?}", links); + assert_eq!( + links.len(), + 1, + "Should not extend into list marker: {:?}", + links + ); assert_eq!(links[0].text, "https://github.com/contember/dotaz/pull/2"); } @@ -291,12 +313,14 @@ fn detect_url_extension_stops_after_trailing_trim() { " https://github.com/NPI-Cloud/npi-inf\r\n rastructure/pull/65)\r\n 2. next item\r\n", 42, ); - let url_links: Vec<&DetectedLink> = links.iter() + let url_links: Vec<&DetectedLink> = links + .iter() .filter(|l| l.text.starts_with("https://github.com/NPI-Cloud/npi-inf")) .collect(); // Should have 2 segments (line 0 + line 1), NOT 3 assert_eq!( - url_links.len(), 2, + url_links.len(), + 2, "Should not extend past trimmed ')' into '2.': {:?}", links ); @@ -315,7 +339,8 @@ fn detect_url_not_extended_into_numbered_list_item() { 46, ); // First URL should be exactly pull/2, not pull/22 - let first: Vec<&DetectedLink> = links.iter() + let first: Vec<&DetectedLink> = links + .iter() .filter(|l| l.text == "https://github.com/contember/dotaz/pull/2") .collect(); assert!( @@ -324,7 +349,8 @@ fn detect_url_not_extended_into_numbered_list_item() { links ); // Second URL should also be detected - let second: Vec<&DetectedLink> = links.iter() + let second: Vec<&DetectedLink> = links + .iter() .filter(|l| l.text.contains("npi-infrastructure/pull/65")) .collect(); assert!( @@ -357,7 +383,8 @@ fn detect_url_uuid_continuation_with_trailing_prose() { " http://localhost:19400/s/1f41d02d-6105-45fb-b3\r\n b1-4b56ae4d869f \u{2014} take your time.\r\n", 50, ); - let url_links: Vec<&DetectedLink> = links.iter() + let url_links: Vec<&DetectedLink> = links + .iter() .filter(|l| l.text == "http://localhost:19400/s/1f41d02d-6105-45fb-b3b1-4b56ae4d869f") .collect(); assert!( diff --git a/crates/okena-terminal/src/terminal/transport.rs b/crates/okena-terminal/src/terminal/transport.rs index 984893c50..525760ece 100644 --- a/crates/okena-terminal/src/terminal/transport.rs +++ b/crates/okena-terminal/src/terminal/transport.rs @@ -2,10 +2,26 @@ /// 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 } + 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-terminal/src/terminal/types.rs b/crates/okena-terminal/src/terminal/types.rs index c31fd28df..4cad14800 100644 --- a/crates/okena-terminal/src/terminal/types.rs +++ b/crates/okena-terminal/src/terminal/types.rs @@ -120,15 +120,13 @@ pub enum AppCursorShape { } /// Selection state for the terminal -#[derive(Clone, Debug)] -#[derive(Default)] +#[derive(Clone, Debug, Default)] pub struct SelectionState { pub start: Option<(usize, usize)>, pub end: Option<(usize, usize)>, pub is_selecting: bool, } - /// A detected link in terminal content (URL or file path) #[derive(Clone, Debug)] pub struct DetectedLink { 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..4e88b218c 100644 --- a/crates/okena-theme/src/app_theme.rs +++ b/crates/okena-theme/src/app_theme.rs @@ -1,5 +1,8 @@ +#[cfg(feature = "gpui")] use gpui::*; -use okena_core::theme::{ThemeColors, ThemeMode, DARK_THEME, LIGHT_THEME, PASTEL_DARK_THEME, HIGH_CONTRAST_THEME}; +use okena_core::theme::{ + DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, ThemeColors, ThemeMode, +}; /// Global theme state pub struct AppTheme { @@ -24,7 +27,11 @@ impl AppTheme { } } - fn colors_for_mode(mode: ThemeMode, system_is_dark: bool, custom: Option<ThemeColors>) -> ThemeColors { + fn colors_for_mode( + mode: ThemeMode, + system_is_dark: bool, + custom: Option<ThemeColors>, + ) -> ThemeColors { match mode { ThemeMode::Dark => DARK_THEME, ThemeMode::Light => LIGHT_THEME, @@ -63,7 +70,11 @@ impl AppTheme { /// Set preview colors temporarily (for live preview) pub fn set_preview(&mut self, mode: ThemeMode) { - self.preview_colors = Some(Self::colors_for_mode(mode, self.system_is_dark, self.custom_colors)); + self.preview_colors = Some(Self::colors_for_mode( + mode, + self.system_is_dark, + self.custom_colors, + )); } /// Set preview colors directly (for custom themes) @@ -87,11 +98,14 @@ impl AppTheme { } /// Wrapper for global theme entity +#[cfg(feature = "gpui")] pub struct GlobalTheme(pub Entity<AppTheme>); +#[cfg(feature = "gpui")] impl Global for GlobalTheme {} /// Get the theme entity for observation +#[cfg(feature = "gpui")] pub fn theme_entity(cx: &App) -> Entity<AppTheme> { cx.global::<GlobalTheme>().0.clone() } diff --git a/crates/okena-theme/src/custom.rs b/crates/okena-theme/src/custom.rs index a2e457321..a743ffedd 100644 --- a/crates/okena-theme/src/custom.rs +++ b/crates/okena-theme/src/custom.rs @@ -179,72 +179,204 @@ pub struct CustomThemeColors { } // Default color functions for serde (based on dark theme) -fn default_bg_primary() -> String { "#1e1e1e".to_string() } -fn default_bg_secondary() -> String { "#252526".to_string() } -fn default_bg_header() -> String { "#323233".to_string() } -fn default_bg_selection() -> String { "#264f78".to_string() } -fn default_bg_hover() -> String { "#2a2d2e".to_string() } -fn default_border() -> String { "#252526".to_string() } -fn default_border_active() -> String { "#007acc".to_string() } -fn default_border_focused() -> String { "#569cd6".to_string() } -fn default_border_bell() -> String { "#e69500".to_string() } -fn default_border_idle() -> String { "#e5a100".to_string() } -fn default_text_primary() -> String { "#cccccc".to_string() } -fn default_text_secondary() -> String { "#808080".to_string() } -fn default_text_muted() -> String { "#6a6a6a".to_string() } -fn default_selection_bg() -> String { "#264f78".to_string() } -fn default_selection_fg() -> String { "#ffffff".to_string() } -fn default_search_match_bg() -> String { "#613214".to_string() } -fn default_search_current_bg() -> String { "#a45a00".to_string() } -fn default_term_black() -> String { "#000000".to_string() } -fn default_term_red() -> String { "#cd3131".to_string() } -fn default_term_green() -> String { "#0dbc79".to_string() } -fn default_term_yellow() -> String { "#e5e510".to_string() } -fn default_term_blue() -> String { "#2472c8".to_string() } -fn default_term_magenta() -> String { "#bc3fbc".to_string() } -fn default_term_cyan() -> String { "#11a8cd".to_string() } -fn default_term_white() -> String { "#e5e5e5".to_string() } -fn default_term_bright_black() -> String { "#666666".to_string() } -fn default_term_bright_red() -> String { "#f14c4c".to_string() } -fn default_term_bright_green() -> String { "#23d18b".to_string() } -fn default_term_bright_yellow() -> String { "#f5f543".to_string() } -fn default_term_bright_blue() -> String { "#3b8eea".to_string() } -fn default_term_bright_magenta() -> String { "#d670d6".to_string() } -fn default_term_bright_cyan() -> String { "#29b8db".to_string() } -fn default_term_bright_white() -> String { "#ffffff".to_string() } -fn default_term_foreground() -> String { "#cccccc".to_string() } -fn default_term_background() -> String { "#1e1e1e".to_string() } -fn default_term_background_unfocused() -> String { "#252526".to_string() } -fn default_cursor() -> String { "#aeafad".to_string() } -fn default_scrollbar() -> String { "#5a5a5a".to_string() } -fn default_scrollbar_hover() -> String { "#7a7a7a".to_string() } -fn default_success() -> String { "#4ec9b0".to_string() } -fn default_warning() -> String { "#dcdcaa".to_string() } -fn default_error() -> String { "#f44747".to_string() } -fn default_button_primary_bg() -> String { "#007acc".to_string() } -fn default_button_primary_fg() -> String { "#ffffff".to_string() } -fn default_button_primary_hover() -> String { "#005a9e".to_string() } -fn default_folder_default() -> String { "#8a9199".to_string() } -fn default_folder_red() -> String { "#e06c75".to_string() } -fn default_folder_orange() -> String { "#d19a66".to_string() } -fn default_folder_yellow() -> String { "#e5c07b".to_string() } -fn default_folder_lime() -> String { "#a3d955".to_string() } -fn default_folder_green() -> String { "#98c379".to_string() } -fn default_folder_teal() -> String { "#2fbda0".to_string() } -fn default_folder_cyan() -> String { "#56d7e5".to_string() } -fn default_folder_blue() -> String { "#61afef".to_string() } -fn default_folder_indigo() -> String { "#818cf8".to_string() } -fn default_folder_purple() -> String { "#c678dd".to_string() } -fn default_folder_pink() -> String { "#e06c9f".to_string() } -fn default_metric_normal() -> String { "#0dbc79".to_string() } -fn default_metric_warning() -> String { "#e5e510".to_string() } -fn default_metric_critical() -> String { "#cd3131".to_string() } -fn default_diff_added_bg() -> String { "#1e3a1e".to_string() } -fn default_diff_removed_bg() -> String { "#3a1e1e".to_string() } -fn default_diff_added_fg() -> String { "#4ec9b0".to_string() } -fn default_diff_removed_fg() -> String { "#f14c4c".to_string() } -fn default_diff_hunk_header_bg() -> String { "#2d3748".to_string() } -fn default_diff_hunk_header_fg() -> String { "#569cd6".to_string() } +fn default_bg_primary() -> String { + "#1e1e1e".to_string() +} +fn default_bg_secondary() -> String { + "#252526".to_string() +} +fn default_bg_header() -> String { + "#323233".to_string() +} +fn default_bg_selection() -> String { + "#264f78".to_string() +} +fn default_bg_hover() -> String { + "#2a2d2e".to_string() +} +fn default_border() -> String { + "#252526".to_string() +} +fn default_border_active() -> String { + "#007acc".to_string() +} +fn default_border_focused() -> String { + "#569cd6".to_string() +} +fn default_border_bell() -> String { + "#e69500".to_string() +} +fn default_border_idle() -> String { + "#e5a100".to_string() +} +fn default_text_primary() -> String { + "#cccccc".to_string() +} +fn default_text_secondary() -> String { + "#808080".to_string() +} +fn default_text_muted() -> String { + "#6a6a6a".to_string() +} +fn default_selection_bg() -> String { + "#264f78".to_string() +} +fn default_selection_fg() -> String { + "#ffffff".to_string() +} +fn default_search_match_bg() -> String { + "#613214".to_string() +} +fn default_search_current_bg() -> String { + "#a45a00".to_string() +} +fn default_term_black() -> String { + "#000000".to_string() +} +fn default_term_red() -> String { + "#cd3131".to_string() +} +fn default_term_green() -> String { + "#0dbc79".to_string() +} +fn default_term_yellow() -> String { + "#e5e510".to_string() +} +fn default_term_blue() -> String { + "#2472c8".to_string() +} +fn default_term_magenta() -> String { + "#bc3fbc".to_string() +} +fn default_term_cyan() -> String { + "#11a8cd".to_string() +} +fn default_term_white() -> String { + "#e5e5e5".to_string() +} +fn default_term_bright_black() -> String { + "#666666".to_string() +} +fn default_term_bright_red() -> String { + "#f14c4c".to_string() +} +fn default_term_bright_green() -> String { + "#23d18b".to_string() +} +fn default_term_bright_yellow() -> String { + "#f5f543".to_string() +} +fn default_term_bright_blue() -> String { + "#3b8eea".to_string() +} +fn default_term_bright_magenta() -> String { + "#d670d6".to_string() +} +fn default_term_bright_cyan() -> String { + "#29b8db".to_string() +} +fn default_term_bright_white() -> String { + "#ffffff".to_string() +} +fn default_term_foreground() -> String { + "#cccccc".to_string() +} +fn default_term_background() -> String { + "#1e1e1e".to_string() +} +fn default_term_background_unfocused() -> String { + "#252526".to_string() +} +fn default_cursor() -> String { + "#aeafad".to_string() +} +fn default_scrollbar() -> String { + "#5a5a5a".to_string() +} +fn default_scrollbar_hover() -> String { + "#7a7a7a".to_string() +} +fn default_success() -> String { + "#4ec9b0".to_string() +} +fn default_warning() -> String { + "#dcdcaa".to_string() +} +fn default_error() -> String { + "#f44747".to_string() +} +fn default_button_primary_bg() -> String { + "#007acc".to_string() +} +fn default_button_primary_fg() -> String { + "#ffffff".to_string() +} +fn default_button_primary_hover() -> String { + "#005a9e".to_string() +} +fn default_folder_default() -> String { + "#8a9199".to_string() +} +fn default_folder_red() -> String { + "#e06c75".to_string() +} +fn default_folder_orange() -> String { + "#d19a66".to_string() +} +fn default_folder_yellow() -> String { + "#e5c07b".to_string() +} +fn default_folder_lime() -> String { + "#a3d955".to_string() +} +fn default_folder_green() -> String { + "#98c379".to_string() +} +fn default_folder_teal() -> String { + "#2fbda0".to_string() +} +fn default_folder_cyan() -> String { + "#56d7e5".to_string() +} +fn default_folder_blue() -> String { + "#61afef".to_string() +} +fn default_folder_indigo() -> String { + "#818cf8".to_string() +} +fn default_folder_purple() -> String { + "#c678dd".to_string() +} +fn default_folder_pink() -> String { + "#e06c9f".to_string() +} +fn default_metric_normal() -> String { + "#0dbc79".to_string() +} +fn default_metric_warning() -> String { + "#e5e510".to_string() +} +fn default_metric_critical() -> String { + "#cd3131".to_string() +} +fn default_diff_added_bg() -> String { + "#1e3a1e".to_string() +} +fn default_diff_removed_bg() -> String { + "#3a1e1e".to_string() +} +fn default_diff_added_fg() -> String { + "#4ec9b0".to_string() +} +fn default_diff_removed_fg() -> String { + "#f14c4c".to_string() +} +fn default_diff_hunk_header_bg() -> String { + "#2d3748".to_string() +} +fn default_diff_hunk_header_fg() -> String { + "#569cd6".to_string() +} impl CustomThemeColors { /// Parse a hex color string (e.g., "#1e1e1e" or "1e1e1e") to u32 @@ -517,22 +649,23 @@ pub fn load_custom_themes() -> Vec<(ThemeInfo, ThemeColors)> { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "json") && let Ok(content) = std::fs::read_to_string(&path) - && let Ok(config) = serde_json::from_str::<CustomThemeConfig>(&content) { - let theme_id = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("custom") - .to_string(); + && let Ok(config) = serde_json::from_str::<CustomThemeConfig>(&content) + { + let theme_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("custom") + .to_string(); - let info = ThemeInfo { - id: format!("custom:{}", theme_id), - name: config.name.clone(), - description: config.description.clone(), - is_dark: config.is_dark, - }; - let colors = config.colors.to_theme_colors(); - custom_themes.push((info, colors)); - } + let info = ThemeInfo { + id: format!("custom:{}", theme_id), + name: config.name.clone(), + description: config.description.clone(), + is_dark: config.is_dark, + }; + let colors = config.colors.to_theme_colors(); + custom_themes.push((info, colors)); + } } } diff --git a/crates/okena-theme/src/lib.rs b/crates/okena-theme/src/lib.rs index b28438d6d..b76c11bb4 100644 --- a/crates/okena-theme/src/lib.rs +++ b/crates/okena-theme/src/lib.rs @@ -2,14 +2,18 @@ // Re-export core theme types (source of truth is okena-core) pub use okena_core::theme::{ - ThemeColors, ThemeInfo, ThemeMode, FolderColor, - DARK_THEME, LIGHT_THEME, PASTEL_DARK_THEME, HIGH_CONTRAST_THEME, + DARK_THEME, FolderColor, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, ThemeColors, + ThemeInfo, ThemeMode, }; +mod app_theme; pub mod custom; +#[cfg(feature = "gpui")] mod gpui_helpers; -mod app_theme; -pub use gpui_helpers::{with_alpha, ansi_to_hsla, GlobalThemeProvider, theme}; -pub use app_theme::{AppTheme, GlobalTheme, theme_entity}; -pub use custom::{CustomThemeConfig, CustomThemeColors, get_themes_dir, load_custom_themes}; +pub use app_theme::AppTheme; +#[cfg(feature = "gpui")] +pub use app_theme::{GlobalTheme, theme_entity}; +pub use custom::{CustomThemeColors, CustomThemeConfig, get_themes_dir, load_custom_themes}; +#[cfg(feature = "gpui")] +pub use gpui_helpers::{GlobalThemeProvider, ansi_to_hsla, theme, with_alpha}; diff --git a/crates/okena-transport/Cargo.toml b/crates/okena-transport/Cargo.toml index 9c8e4e759..d7c1e6f8f 100644 --- a/crates/okena-transport/Cargo.toml +++ b/crates/okena-transport/Cargo.toml @@ -9,8 +9,12 @@ default = [] # Async client engine: WS connection manager, TLS certificate pinning (TOFU). client = ["dep:tokio", "dep:reqwest", "dep:tokio-tungstenite", "dep:async-channel", "dep:futures", "dep:rustls", "dep:rustls-pki-types", "dep:sha2"] # Blocking HTTP helper + remote_action over it (used by extensions and the -# file/git read paths). -blocking-http = ["dep:reqwest", "reqwest/blocking"] +# file/git read paths). TLS deps are shared with the async client so blocking +# remote actions enforce the same pinned-certificate policy. +blocking-http = ["dep:reqwest", "reqwest/blocking", "dep:rustls", "dep:rustls-pki-types", "dep:sha2"] +# Request-cancellable blocking facade used by remote content search. It runs +# only the HTTP future on Tokio without pulling in the WebSocket client stack. +cancellable-http = ["blocking-http", "dep:tokio"] # Exposes the in-memory HTTP mock to downstream crates' tests. test-support = [] diff --git a/crates/okena-transport/src/client/config.rs b/crates/okena-transport/src/client/config.rs index c47181a6a..6ebcb9292 100644 --- a/crates/okena-transport/src/client/config.rs +++ b/crates/okena-transport/src/client/config.rs @@ -1,45 +1 @@ -use serde::{Deserialize, Serialize}; - -/// Configuration for a single remote server connection. -/// Persisted in settings.json as part of `remote_connections`. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct RemoteConnectionConfig { - /// Stable UUID, unique across sessions - pub id: String, - /// User-friendly label (e.g. "Work Laptop") - pub name: String, - /// Hostname or IP address of the remote server - pub host: String, - /// Server port (typically 19100-19200) - pub port: u16, - /// Persisted bearer token (24h TTL on server side) - #[serde(default)] - pub saved_token: Option<String>, - /// Unix timestamp when the token was obtained (for refresh scheduling) - #[serde(default)] - pub token_obtained_at: Option<i64>, - /// Connect over TLS (https/wss). Default false for backward compatibility - /// with existing plain-http connections. - #[serde(default)] - pub tls: bool, - /// SHA-256 fingerprint (lowercase hex) of the server's TLS certificate, - /// pinned on first connect (TOFU). When set, the client refuses any cert - /// whose fingerprint differs — defeating an active MITM. `None` until the - /// first successful TLS handshake captures it. - #[serde(default)] - pub pinned_cert_sha256: Option<String>, -} - -impl RemoteConnectionConfig { - /// `http://host:port` or `https://host:port` depending on `tls`. - pub fn base_url(&self) -> String { - let scheme = if self.tls { "https" } else { "http" }; - format!("{}://{}:{}", scheme, self.host, self.port) - } - - /// `ws://host:port/v1/stream` or `wss://…` depending on `tls`. - pub fn ws_url(&self) -> String { - let scheme = if self.tls { "wss" } else { "ws" }; - format!("{}://{}:{}/v1/stream", scheme, self.host, self.port) - } -} +pub use crate::connection_config::*; diff --git a/crates/okena-transport/src/client/connection.rs b/crates/okena-transport/src/client/connection.rs index 2bf606a3b..11f230dc0 100644 --- a/crates/okena-transport/src/client/connection.rs +++ b/crates/okena-transport/src/client/connection.rs @@ -1,15 +1,175 @@ -use okena_core::api::StateResponse; -use crate::client::config::RemoteConnectionConfig; +use crate::client::config::{LOCAL_DAEMON_CONNECTION_ID, LocalEndpoint, RemoteConnectionConfig}; 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::state::{ + collect_all_terminal_ids, collect_state_terminal_ids, collect_terminal_sizes, diff_states, +}; use crate::client::types::{ - ConnectionEvent, ConnectionStatus, SessionError, WsClientMessage, TOKEN_REFRESH_AGE_SECS, + ConnectionEvent, ConnectionStatus, SessionError, TOKEN_REFRESH_AGE_SECS, WsClientMessage, }; +use okena_core::api::{ActionRequest, ApiSystemStats, StateResponse}; +use futures::{Sink, Stream}; use std::collections::HashMap; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; use tokio_tungstenite::tungstenite; +type TcpWsStream = + tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>; +#[cfg(unix)] +type UnixWsStream = tokio_tungstenite::WebSocketStream<tokio::net::UnixStream>; + +enum AnyWsStream { + Tcp(Box<TcpWsStream>), + #[cfg(unix)] + Unix(Box<UnixWsStream>), +} + +impl Stream for AnyWsStream { + type Item = Result<tungstenite::Message, tungstenite::Error>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { + 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<tungstenite::Message> for AnyWsStream { + type Error = tungstenite::Error; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { + 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<Result<(), Self::Error>> { + 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<Result<(), Self::Error>> { + 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) -> Result<reqwest::Client, String> { + reqwest::Client::builder() + .unix_socket(path) + .build() + .map_err(|error| format!("Cannot initialise Unix socket HTTP client: {error}")) +} + +#[cfg(not(unix))] +fn unix_http_client(_path: &str) -> Result<reqwest::Client, String> { + Err("Unix socket HTTP transport is not supported on this platform".to_string()) +} + +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) +} + +fn ws_message_channel() -> ( + async_channel::Sender<WsClientMessage>, + async_channel::Receiver<WsClientMessage>, +) { + async_channel::unbounded() +} + +async fn fetch_remote_settings( + client: &reqwest::Client, + base_url: &str, + token: &str, +) -> Result<serde_json::Value, String> { + crate::remote_action::post_action_async_with_client( + client, + base_url, + token, + ActionRequest::GetSettings, + ) + .await + .map_err(|error| format!("Failed to fetch settings: {error}"))? + .ok_or_else(|| "Settings fetch returned no payload".to_string()) +} + +/// 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`. @@ -46,7 +206,11 @@ pub trait ConnectionHandler: Send + Sync + 'static { /// Remove terminals for this connection that are NOT in the given set of /// (unprefixed) terminal IDs. Called on reconnect to clean up terminals /// that disappeared on the server while the client was offline. - fn remove_terminals_except(&self, connection_id: &str, keep_ids: &std::collections::HashSet<String>); + fn remove_terminals_except( + &self, + connection_id: &str, + keep_ids: &std::collections::HashSet<String>, + ); } /// Generic remote client state machine, parameterized by a platform handler. @@ -56,6 +220,7 @@ pub struct RemoteClient<H: ConnectionHandler> { runtime: Arc<tokio::runtime::Runtime>, ws_tx: Option<async_channel::Sender<WsClientMessage>>, remote_state: Option<StateResponse>, + system_stats: Option<ApiSystemStats>, stream_map: HashMap<String, u32>, reverse_stream_map: HashMap<u32, String>, handler: Arc<H>, @@ -72,13 +237,14 @@ impl<H: ConnectionHandler> RemoteClient<H> { handler: Arc<H>, event_tx: async_channel::Sender<ConnectionEvent>, ) -> 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 +286,14 @@ impl<H: ConnectionHandler> RemoteClient<H> { 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<ApiSystemStats>) { + 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 +317,10 @@ impl<H: ConnectionHandler> RemoteClient<H> { /// 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,11 +333,11 @@ impl<H: ConnectionHandler> RemoteClient<H> { // 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 - let (ws_tx, ws_rx) = async_channel::bounded::<WsClientMessage>(256); + let (ws_tx, ws_rx) = ws_message_channel(); self.ws_tx = Some(ws_tx.clone()); let task = self.runtime.spawn(async move { @@ -174,26 +348,79 @@ impl<H: ConnectionHandler> RemoteClient<H> { // 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<String> = 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_and_url = if let Some(path) = local_unix.as_deref() { + unix_http_client(path).map(|client| (client, config.http_origin())) + } else { + crate::client::tls::build_reqwest_client( + tls, + config.pinned_cert_sha256.clone(), + observed.clone(), + ) + .map(|client| { + let scheme = if tls { "https" } else { "http" }; + ( + client, + format!("{}://{}:{}", scheme, config.host, config.port), + ) + }) + }; + let (client, base_url) = match client_and_url { + Ok(client_and_url) => client_and_url, + Err(error) => { + last_connect_failure = Some(error); + continue; + } + }; + 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 +428,14 @@ impl<H: ConnectionHandler> RemoteClient<H> { 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 +447,8 @@ impl<H: ConnectionHandler> RemoteClient<H> { } }; 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,15 +456,11 @@ impl<H: ConnectionHandler> RemoteClient<H> { // 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(); - log::info!( - "Auto-upgraded {}:{} to TLS", - config.host, - config.port - ); + log::info!("Auto-upgraded {}:{} to TLS", config.host, config.port); let _ = event_tx .send(ConnectionEvent::TlsUpgraded { connection_id: config.id.clone(), @@ -240,8 +469,10 @@ impl<H: ConnectionHandler> RemoteClient<H> { .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 +481,31 @@ impl<H: ConnectionHandler> RemoteClient<H> { .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; + 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 @@ -273,10 +519,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { Ok(resp) => { // Transient server error (e.g. 500 during startup) — // token may still be valid, don't discard it. - let msg = format!( - "Token validation: unexpected HTTP {}", - resp.status() - ); + let msg = format!("Token validation: unexpected HTTP {}", resp.status()); log::warn!("{}", msg); let _ = event_tx .send(ConnectionEvent::StatusChanged { @@ -325,19 +568,37 @@ impl<H: ConnectionHandler> RemoteClient<H> { let shared_token = self.shared_token.clone(); // Create fresh WS message channel - let (ws_tx, ws_rx) = async_channel::bounded::<WsClientMessage>(256); + let (ws_tx, ws_rx) = ws_message_channel(); self.ws_tx = Some(ws_tx.clone()); 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(), + ) + }; + let client = match client { + Ok(client) => client, + Err(error) => { + let msg = format!("Pairing client initialisation failed: {error}"); + let _ = event_tx + .send(ConnectionEvent::StatusChanged { + connection_id: config.id.clone(), + status: ConnectionStatus::Error(msg), + }) + .await; + return; + } + }; // POST /v1/pair with the code let pair_body = serde_json::json!({ "code": code }); @@ -366,8 +627,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { // Capture the cert fingerprint observed during the // (TLS) pairing handshake so the manager can pin it. - let cert_fingerprint = - observed.lock().ok().and_then(|g| g.clone()); + let cert_fingerprint = observed.lock().ok().and_then(|g| g.clone()); // Notify manager to save the token (+ pin the cert) let _ = event_tx @@ -452,9 +712,18 @@ impl<H: ConnectionHandler> RemoteClient<H> { self.stream_map.clear(); self.reverse_stream_map.clear(); self.remote_state = None; + self.system_stats = None; self.status = ConnectionStatus::Disconnected; } + /// Restart the transport while retaining the last state and terminal + /// objects. The fresh state sync reconciles deletions after reconnect. + pub fn reconnect(&mut self) { + self.stream_map.clear(); + self.reverse_stream_map.clear(); + self.connect(); + } + /// Run the main WebSocket loop with reconnection. async fn run_ws_loop( config: RemoteConnectionConfig, @@ -466,12 +735,13 @@ impl<H: ConnectionHandler> RemoteClient<H> { shared_token: Arc<std::sync::RwLock<Option<String>>>, ) { 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 { - match Self::ws_session(&config, ¤t_token, &event_tx, &ws_tx, &ws_rx, &handler).await { + match Self::ws_session(&config, ¤t_token, &event_tx, &ws_tx, &ws_rx, &handler) + .await + { Ok(()) => { // Clean disconnect requested log::info!( @@ -514,12 +784,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { 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 {}/{})", @@ -544,9 +809,10 @@ impl<H: ConnectionHandler> RemoteClient<H> { // Read the latest token (may have been refreshed since last attempt) if let Ok(guard) = shared_token.read() - && let Some(ref latest) = *guard { - current_token = latest.clone(); - } + && let Some(ref latest) = *guard + { + current_token = latest.clone(); + } } } } @@ -570,19 +836,45 @@ impl<H: ConnectionHandler> RemoteClient<H> { // 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) - .await - .map_err(|e| SessionError::Transient(format!("WebSocket connect failed: {}", e)))? + 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)) + })?; + (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); @@ -615,10 +907,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { tungstenite::Message::Text(text) => { let parsed: serde_json::Value = serde_json::from_str(text) .map_err(|e| SessionError::Transient(format!("Invalid JSON: {}", e)))?; - let msg_type = parsed - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); if msg_type == "auth_ok" { log::info!("Authenticated with {}:{}", config.host, config.port); } else if msg_type == "auth_failed" { @@ -642,12 +931,17 @@ impl<H: ConnectionHandler> RemoteClient<H> { } // 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(), + ) + } + .map_err(SessionError::Transient)?; let state_resp = client .get(format!("{}/v1/state", base_url)) .header("Authorization", format!("Bearer {}", token)) @@ -697,6 +991,17 @@ impl<H: ConnectionHandler> RemoteClient<H> { state: state.clone(), }) .await; + match fetch_remote_settings(&client, &base_url, token).await { + Ok(settings) => { + let _ = event_tx + .send(ConnectionEvent::SettingsChanged { + connection_id: config.id.clone(), + settings, + }) + .await; + } + Err(error) => log::warn!("{error}"), + } // Step 5: Subscribe to all terminal streams if !terminal_ids.is_empty() { @@ -733,8 +1038,8 @@ impl<H: ConnectionHandler> RemoteClient<H> { let stream_map_for_writer = stream_map.clone(); let writer_handle = tokio::spawn(async move { while let Ok(msg) = ws_rx_clone.recv().await { - // For SendText, prefer binary frame when stream_id is known - if let WsClientMessage::SendText { terminal_id, text } = &msg { + // Prefer compact binary input when the subscription mapping is known. + if let WsClientMessage::SendInput { terminal_id, data } = &msg { let stream_id = stream_map_for_writer .read() .ok() @@ -743,7 +1048,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { let frame = okena_core::ws::build_binary_frame( okena_core::ws::FRAME_TYPE_INPUT, sid, - text.as_bytes(), + data, ); if let Err(e) = futures::SinkExt::send( &mut ws_write, @@ -759,11 +1064,11 @@ impl<H: ConnectionHandler> RemoteClient<H> { } let json = match &msg { - WsClientMessage::SendText { terminal_id, text } => { + WsClientMessage::SendInput { terminal_id, data } => { serde_json::json!({ - "type": "send_text", + "type": "send_bytes", "terminal_id": terminal_id, - "text": text, + "data": data, }) } WsClientMessage::Resize { @@ -819,7 +1124,8 @@ impl<H: ConnectionHandler> RemoteClient<H> { okena_core::ws::parse_binary_frame(&data) { match frame_type { - okena_core::ws::FRAME_TYPE_PTY | okena_core::ws::FRAME_TYPE_SNAPSHOT => { + okena_core::ws::FRAME_TYPE_PTY + | okena_core::ws::FRAME_TYPE_SNAPSHOT => { // Route PTY output or snapshot to the correct terminal if let Some(remote_tid) = reverse_stream_map.get(&stream_id) { let prefixed = make_prefixed_id(&config_id, remote_tid); @@ -836,54 +1142,57 @@ impl<H: ConnectionHandler> RemoteClient<H> { // JSON message match serde_json::from_str::<serde_json::Value>(&text) { Ok(value) => { - let msg_type = value - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); match msg_type { "subscribed" => { if let Some(mappings) = value.get("mappings") - && let Ok(map) = serde_json::from_value::< - HashMap<String, u32>, - >( - mappings.clone() - ) { - log::info!( - "Subscribed to {} terminal streams", - map.len() - ); + && let Ok(map) = + serde_json::from_value::<HashMap<String, u32>>( + mappings.clone(), + ) + { + log::info!("Subscribed to {} terminal streams", map.len()); + for (terminal_id, stream_id) in &map { + reverse_stream_map + .insert(*stream_id, terminal_id.clone()); + } + // Update shared stream_map for writer task + if let Ok(mut sm) = stream_map.write() { for (terminal_id, stream_id) in &map { - reverse_stream_map - .insert(*stream_id, terminal_id.clone()); + sm.insert(terminal_id.clone(), *stream_id); } - // Update shared stream_map for writer task - if let Ok(mut sm) = stream_map.write() { - for (terminal_id, stream_id) in &map { - sm.insert(terminal_id.clone(), *stream_id); - } + } + // Pre-resize terminals to server dimensions before snapshots arrive + if let Some(sizes) = value.get("sizes") + && let Ok(size_map) = serde_json::from_value::< + HashMap<String, (u16, u16)>, + >( + sizes.clone() + ) + { + for (terminal_id, (cols, rows)) in &size_map { + let prefixed = + make_prefixed_id(&config_id, terminal_id); + // Pre-resize only sizes the grid for the snapshot; + // it must not claim authority, so the client can + // still enforce its own window size after connect. + handler_clone.resize_terminal( + &prefixed, *cols, *rows, false, + ); } - // Pre-resize terminals to server dimensions before snapshots arrive - if let Some(sizes) = value.get("sizes") - && let Ok(size_map) = serde_json::from_value::< - HashMap<String, (u16, u16)>, - >(sizes.clone()) { - for (terminal_id, (cols, rows)) in &size_map { - let prefixed = make_prefixed_id(&config_id, terminal_id); - // Pre-resize only sizes the grid for the snapshot; - // it must not claim authority, so the client can - // still enforce its own window size after connect. - handler_clone.resize_terminal(&prefixed, *cols, *rows, false); - } - log::info!("Pre-resized {} terminals to server dimensions", size_map.len()); - } - - let _ = event_tx_clone - .send(ConnectionEvent::SubscriptionMappings { - connection_id: config_id.clone(), - mappings: map, - }) - .await; + log::info!( + "Pre-resized {} terminals to server dimensions", + size_map.len() + ); } + + let _ = event_tx_clone + .send(ConnectionEvent::SubscriptionMappings { + connection_id: config_id.clone(), + mappings: map, + }) + .await; + } } "state_changed" => { log::info!("State changed on remote server"); @@ -893,10 +1202,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { // of rebuilding a client per event. match client .get(format!("{}/v1/state", base_url)) - .header( - "Authorization", - format!("Bearer {}", token), - ) + .header("Authorization", format!("Bearer {}", token)) .timeout(std::time::Duration::from_secs(10)) .send() .await @@ -905,8 +1211,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { if let Ok(new_state) = resp.json::<StateResponse>().await { - let diff = - diff_states(&cached_state, &new_state); + let diff = diff_states(&cached_state, &new_state); let new_size_map = collect_terminal_sizes(&new_state); @@ -940,29 +1245,39 @@ impl<H: ConnectionHandler> RemoteClient<H> { // channel would leave the new terminals silently // never streaming output. if !diff.added_terminals.is_empty() - && let Err(e) = ws_tx_clone.send( - WsClientMessage::Subscribe { + && let Err(e) = ws_tx_clone + .send(WsClientMessage::Subscribe { terminal_ids: diff .added_terminals .clone(), - }, - ).await { - log::warn!("failed to send Subscribe for {} terminals: {}", diff.added_terminals.len(), e); - } + }) + .await + { + log::warn!( + "failed to send Subscribe for {} terminals: {}", + diff.added_terminals.len(), + e + ); + } // Unsubscribe from removed terminals. Likewise // blocking — a dropped Unsubscribe leaks a stream // for an already-gone terminal. if !diff.removed_terminals.is_empty() - && let Err(e) = ws_tx_clone.send( - WsClientMessage::Unsubscribe { + && let Err(e) = ws_tx_clone + .send(WsClientMessage::Unsubscribe { terminal_ids: diff .removed_terminals .clone(), - }, - ).await { - log::warn!("failed to send Unsubscribe for {} terminals: {}", diff.removed_terminals.len(), e); - } + }) + .await + { + log::warn!( + "failed to send Unsubscribe for {} terminals: {}", + diff.removed_terminals.len(), + e + ); + } cached_state = new_state.clone(); @@ -984,15 +1299,24 @@ impl<H: ConnectionHandler> RemoteClient<H> { log::warn!("State re-fetch failed: {}", e); } } + match fetch_remote_settings(&client, &base_url, token).await { + Ok(settings) => { + let _ = event_tx_clone + .send(ConnectionEvent::SettingsChanged { + connection_id: config_id.clone(), + settings, + }) + .await; + } + Err(error) => log::warn!("{error}"), + } } "pong" => { // Keep-alive response, ignore } "dropped" => { - let count = value - .get("count") - .and_then(|v| v.as_u64()) - .unwrap_or(0); + let count = + value.get("count").and_then(|v| v.as_u64()).unwrap_or(0); log::warn!( "Server dropped {} messages for {}:{}", count, @@ -1002,10 +1326,7 @@ impl<H: ConnectionHandler> RemoteClient<H> { let _ = event_tx_clone .send(ConnectionEvent::ServerWarning { connection_id: config_id.clone(), - message: format!( - "Server dropped {} messages", - count - ), + message: format!("Server dropped {} messages", count), }) .await; } @@ -1035,21 +1356,86 @@ impl<H: ConnectionHandler> RemoteClient<H> { .and_then(|v| v.as_bool()) .unwrap_or(false); let prefixed = make_prefixed_id(&config_id, terminal_id); - handler_clone.resize_terminal(&prefixed, cols as u16, rows as u16, server_owns); + handler_clone.resize_terminal( + &prefixed, + cols as u16, + rows as u16, + server_owns, + ); } } "git_status_changed" => { if let Some(projects) = value.get("projects") && let Ok(statuses) = serde_json::from_value::< HashMap<String, okena_core::api::ApiGitStatus>, - >(projects.clone()) { + >( + projects.clone() + ) + { + let _ = event_tx_clone + .send(ConnectionEvent::GitStatusChanged { + connection_id: config_id.clone(), + statuses, + }) + .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; + } + } + "terminal_focus_requested" => { + match serde_json::from_value::< + okena_core::api::ApiTerminalFocusRequest, + >(value.clone()) + { + Ok(request) => { + let _ = event_tx_clone + .send(ConnectionEvent::TerminalFocusRequested { + connection_id: config_id.clone(), + request, + }) + .await; + } + Err(e) => { + log::warn!( + "Failed to parse terminal focus request: {}", + e + ); + } + } + } + "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::<okena_core::api::ApiToast>( + value.clone(), + ) { + Ok(toast) => { let _ = event_tx_clone - .send(ConnectionEvent::GitStatusChanged { + .send(ConnectionEvent::Toast { connection_id: config_id.clone(), - statuses, + toast, }) .await; } + Err(e) => { + log::warn!("Failed to parse toast message: {}", e); + } + } } _ => { log::debug!("Unknown WS message type: {}", msg_type); @@ -1124,12 +1510,24 @@ 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(), + ) + }; + let client = match client { + Ok(client) => client, + Err(error) => { + log::warn!("Token refresh client initialisation failed: {error}"); + return; + } + }; match client .post(format!("{}/v1/refresh", base_url)) @@ -1147,7 +1545,7 @@ pub async fn try_refresh_token( } match resp.json::<RefreshResp>().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 +1581,159 @@ pub async fn try_refresh_token( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct TestHandler { + remove_all_calls: AtomicUsize, + } + + impl ConnectionHandler for TestHandler { + fn create_terminal( + &self, + _connection_id: &str, + _terminal_id: &str, + _prefixed_id: &str, + _ws_sender: async_channel::Sender<WsClientMessage>, + _cols: u16, + _rows: u16, + ) { + } + + fn on_terminal_output(&self, _prefixed_id: &str, _data: &[u8]) {} + + fn remove_terminal(&self, _prefixed_id: &str) {} + + fn resize_terminal(&self, _prefixed_id: &str, _cols: u16, _rows: u16, _server_owns: bool) {} + + fn remove_all_terminals(&self, _connection_id: &str) { + self.remove_all_calls.fetch_add(1, Ordering::Relaxed); + } + + fn remove_terminals_except( + &self, + _connection_id: &str, + _keep_ids: &std::collections::HashSet<String>, + ) { + } + } + + fn config(id: &str, local_endpoint: Option<LocalEndpoint>) -> 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<LocalEndpoint> { + 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"); + } + + #[test] + fn ws_message_channel_absorbs_input_bursts_without_dropping() { + let (tx, rx) = ws_message_channel(); + for byte in 0..=u8::MAX { + tx.try_send(WsClientMessage::SendInput { + terminal_id: "terminal".to_string(), + data: vec![byte], + }) + .unwrap(); + } + assert_eq!(rx.len(), usize::from(u8::MAX) + 1); + } + + #[test] + fn explicit_reconnect_retains_last_state_and_terminal_objects() { + let runtime = Arc::new( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(), + ); + let handler = Arc::new(TestHandler::default()); + let (event_tx, _event_rx) = async_channel::unbounded(); + let mut client = RemoteClient::new( + config("reconnect", None), + runtime, + handler.clone(), + event_tx, + ); + client.set_remote_state(Some(StateResponse { + state_version: 1, + projects: Vec::new(), + focused_project_id: None, + fullscreen_terminal: None, + project_order: Vec::new(), + folders: Vec::new(), + windows: Vec::new(), + hooks: Vec::new(), + })); + + client.reconnect(); + + assert!(client.remote_state().is_some()); + assert_eq!(handler.remove_all_calls.load(Ordering::Relaxed), 0); + } +} diff --git a/crates/okena-transport/src/client/id.rs b/crates/okena-transport/src/client/id.rs index 0b1bd1564..7cb5056eb 100644 --- a/crates/okena-transport/src/client/id.rs +++ b/crates/okena-transport/src/client/id.rs @@ -26,18 +26,12 @@ mod tests { #[test] fn make_prefixed_id_format() { - assert_eq!( - make_prefixed_id("conn-1", "term-a"), - "remote:conn-1:term-a" - ); + assert_eq!(make_prefixed_id("conn-1", "term-a"), "remote:conn-1:term-a"); } #[test] fn strip_prefix_valid() { - assert_eq!( - strip_prefix("remote:conn-1:term-a", "conn-1"), - "term-a" - ); + assert_eq!(strip_prefix("remote:conn-1:term-a", "conn-1"), "term-a"); } #[test] diff --git a/crates/okena-transport/src/client/mod.rs b/crates/okena-transport/src/client/mod.rs index 24632f7d0..480b3bf6e 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 crate::{LOCAL_DAEMON_CONNECTION_ID, LocalEndpoint, RemoteConnectionConfig}; 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 types::{ConnectionEvent, ConnectionStatus, WsClientMessage, TOKEN_REFRESH_AGE_SECS}; +pub use state::{ + StateDiff, collect_all_terminal_ids, collect_layout_terminal_ids, collect_state_terminal_ids, + collect_terminal_sizes, diff_states, +}; +pub use terminal::{ + REMOTE_TERMINAL_ANSWERS_QUERIES, REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS, + REMOTE_TERMINAL_USES_MOUSE_BACKEND, close_remote_terminal, resize_remote_terminal, + send_remote_terminal_input, +}; +pub use types::{ConnectionEvent, ConnectionStatus, TOKEN_REFRESH_AGE_SECS, WsClientMessage}; diff --git a/crates/okena-transport/src/client/state.rs b/crates/okena-transport/src/client/state.rs index 223ffb82c..3438db04e 100644 --- a/crates/okena-transport/src/client/state.rs +++ b/crates/okena-transport/src/client/state.rs @@ -20,23 +20,15 @@ pub fn diff_states(old: &StateResponse, new: &StateResponse) -> StateDiff { let old_terminals = collect_all_terminal_ids(old); let new_terminals = collect_all_terminal_ids(new); - let added_terminals: Vec<String> = new_terminals - .difference(&old_terminals) - .cloned() - .collect(); + let added_terminals: Vec<String> = new_terminals.difference(&old_terminals).cloned().collect(); - let removed_terminals: Vec<String> = old_terminals - .difference(&new_terminals) - .cloned() - .collect(); + let removed_terminals: Vec<String> = + old_terminals.difference(&new_terminals).cloned().collect(); // Detect projects with layout changes by comparing serialized layouts let mut changed_projects = Vec::new(); - let old_projects: std::collections::HashMap<&str, _> = old - .projects - .iter() - .map(|p| (p.id.as_str(), p)) - .collect(); + let old_projects: std::collections::HashMap<&str, _> = + old.projects.iter().map(|p| (p.id.as_str(), p)).collect(); for new_proj in &new.projects { let changed = match old_projects.get(new_proj.id.as_str()) { @@ -65,7 +57,14 @@ pub fn collect_all_terminal_ids(state: &StateResponse) -> HashSet<String> { 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)); + } + // Hook terminals live outside the layout tree (they render in the hook + // panel, not the pane grid), but they are real daemon PTYs — the client + // must subscribe to them too or their output never streams and the pane + // shows a live-but-empty terminal. + for hook in &project.hook_terminals { + ids.insert(hook.terminal_id.clone()); } } ids @@ -76,28 +75,24 @@ pub fn collect_state_terminal_ids(state: &StateResponse) -> Vec<String> { 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); + } + // See `collect_all_terminal_ids`: hook-terminal PTYs must be subscribed. + for hook in &project.hook_terminals { + ids.push(hook.terminal_id.clone()); } } ids } -fn collect_layout_terminal_ids(node: &ApiLayoutNode, ids: &mut HashSet<String>) { - 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<String> { + let mut ids = Vec::new(); + collect_layout_terminal_ids_into(node, &mut ids); + ids } -fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec<String>) { +fn collect_layout_terminal_ids_into(node: &ApiLayoutNode, ids: &mut Vec<String>) { match node { ApiLayoutNode::Terminal { terminal_id, .. } => { if let Some(id) = terminal_id { @@ -106,7 +101,7 @@ fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec<String>) { } ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => { for child in children { - collect_layout_ids_vec(child, ids); + collect_layout_terminal_ids_into(child, ids); } } } @@ -116,7 +111,9 @@ fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec<String>) { /// /// Returns a map of terminal_id → (cols, rows) for terminals that have /// size information in the layout tree. -pub fn collect_terminal_sizes(state: &StateResponse) -> std::collections::HashMap<String, (u16, u16)> { +pub fn collect_terminal_sizes( + state: &StateResponse, +) -> std::collections::HashMap<String, (u16, u16)> { let mut sizes = std::collections::HashMap::new(); for project in &state.projects { if let Some(ref layout) = project.layout { @@ -165,6 +162,7 @@ mod tests { project_order: vec![], folders: vec![], windows: vec![], + hooks: Vec::new(), } } @@ -176,6 +174,7 @@ mod tests { terminal_id: Some(terminal_ids[0].to_string()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }) @@ -189,6 +188,7 @@ mod tests { terminal_id: Some(tid.to_string()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }) @@ -207,6 +207,13 @@ 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(), + is_creating: false, + is_closing: false, } } @@ -228,6 +235,42 @@ mod tests { assert_eq!(diff.removed_terminals, vec!["t2"]); } + /// Hook terminals live outside the layout tree; the client must still + /// subscribe to them or their PTY output never streams (the pane renders a + /// live-but-empty terminal — e.g. an on_worktree_create hook). + #[test] + fn collectors_include_hook_terminal_ids() { + use okena_core::api::{ApiHookTerminalEntry, ApiHookTerminalStatus}; + let mut proj = make_project("p1", vec!["t1"]); + proj.hook_terminals.push(ApiHookTerminalEntry { + terminal_id: "hook-1".to_string(), + label: "on_worktree_create".to_string(), + status: ApiHookTerminalStatus::Running, + hook_type: "on_worktree_create".to_string(), + command: "echo hi".to_string(), + cwd: "/tmp".to_string(), + }); + let state = make_state(vec![proj]); + + assert!( + collect_state_terminal_ids(&state).contains(&"hook-1".to_string()), + "initial-subscribe seed must include hook terminal ids" + ); + assert!( + collect_all_terminal_ids(&state).contains("hook-1"), + "diff base must include hook terminal ids" + ); + + // A newly-appearing hook terminal must show up in diff.added_terminals so + // the client sends a Subscribe for it. + let before = make_state(vec![make_project("p1", vec!["t1"])]); + let diff = diff_states(&before, &state); + assert!( + diff.added_terminals.contains(&"hook-1".to_string()), + "a new hook terminal must be diffed as added so it gets subscribed" + ); + } + #[test] fn diff_states_detects_changed_projects() { let old = make_state(vec![make_project("p1", vec!["t1"])]); @@ -246,6 +289,55 @@ 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, + shell_type: Default::default(), + cols: None, + rows: None, + }, + ApiLayoutNode::Terminal { + terminal_id: None, + minimized: false, + detached: false, + shell_type: Default::default(), + 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, + shell_type: Default::default(), + cols: None, + rows: None, + }, + ApiLayoutNode::Terminal { + terminal_id: Some("t3".to_string()), + minimized: false, + detached: false, + shell_type: Default::default(), + 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 { @@ -261,6 +353,7 @@ mod tests { terminal_id: Some("t1".into()), minimized: false, detached: false, + shell_type: Default::default(), cols: Some(120), rows: Some(40), }, @@ -268,6 +361,7 @@ mod tests { terminal_id: Some("t2".into()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, }, @@ -279,6 +373,13 @@ 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(), + is_creating: false, + is_closing: false, }]); 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..03f2f0c04 --- /dev/null +++ b/crates/okena-transport/src/client/terminal.rs @@ -0,0 +1,104 @@ +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<WsClientMessage>, + connection_id: &str, + terminal_id: &str, + data: &[u8], +) { + let remote_id = strip_prefix(terminal_id, connection_id); + if ws_tx + .try_send(WsClientMessage::SendInput { + terminal_id: remote_id, + data: data.to_vec(), + }) + .is_err() + { + log::warn!("remote terminal input channel is closed"); + } +} + +pub fn resize_remote_terminal( + ws_tx: &async_channel::Sender<WsClientMessage>, + 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<WsClientMessage>, + 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_preserves_arbitrary_bytes() { + 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::SendInput { terminal_id, data } => { + assert_eq!(terminal_id, "term-a"); + assert_eq!(data, b"a\xff"); + } + 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/tls.rs b/crates/okena-transport/src/client/tls.rs index 909353aac..b63624c90 100644 --- a/crates/okena-transport/src/client/tls.rs +++ b/crates/okena-transport/src/client/tls.rs @@ -1,267 +1 @@ -//! Client-side TLS with certificate pinning (Trust On First Use). -//! -//! The remote server uses a self-signed cert (no CA). The client therefore -//! cannot validate it against a trust store; instead it **pins the cert's -//! SHA-256 fingerprint**: -//! -//! - On the first TLS handshake the pin is `None` → we accept the cert and -//! record its fingerprint (TOFU). The caller persists it and the user -//! verifies it out-of-band against the fingerprint shown on the host. -//! - On every later handshake the pin is `Some` → we require the presented -//! cert's fingerprint to match exactly, defeating an active MITM. -//! -//! Crucially, we still verify the handshake *signature* against the presented -//! cert's key (via the crypto provider), so an attacker who merely replays the -//! pinned cert without its private key cannot complete the handshake. -//! -//! This trust model is correct for a LAN self-signed server identified by a -//! pinned fingerprint. It is NOT appropriate for the public web (no hostname or -//! chain validation). - -use std::sync::{Arc, Mutex}; - -use rustls::DigitallySignedStruct; -use rustls::SignatureScheme; -use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -use rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature}; -use rustls_pki_types::{CertificateDer, ServerName, UnixTime}; -use sha2::{Digest, Sha256}; - -/// Lowercase-hex SHA-256 of a DER-encoded certificate. -pub fn cert_fingerprint(der: &[u8]) -> String { - use std::fmt::Write; - let digest = Sha256::digest(der); - let mut s = String::with_capacity(digest.len() * 2); - for b in digest { - let _ = write!(s, "{b:02x}"); - } - s -} - -/// Format a hex fingerprint as colon-separated byte pairs for readable -/// out-of-band comparison (e.g. `ab:cd:ef:01 23:45:67:89 …`). Pairs are grouped -/// four-to-a-block with spaces between blocks so the long hex string can -/// soft-wrap inside narrow containers instead of overflowing horizontally. -pub fn format_fingerprint(fp: &str) -> String { - fp.as_bytes() - .chunks(2) - .map(|pair| std::str::from_utf8(pair).unwrap_or("??")) - .collect::<Vec<_>>() - .chunks(4) - .map(|group| group.join(":")) - .collect::<Vec<_>>() - .join(" ") -} - -/// Shared slot the verifier writes the most recently observed fingerprint into, -/// so the connection task can read it back after a successful handshake and -/// persist a freshly-pinned cert. -pub type ObservedFingerprint = Arc<Mutex<Option<String>>>; - -/// Fresh empty observed-fingerprint slot for one connection attempt. -pub fn new_observed() -> ObservedFingerprint { - Arc::new(Mutex::new(None)) -} - -/// rustls verifier that trusts a server cert by its pinned SHA-256 fingerprint. -#[derive(Debug)] -pub struct PinnedCertVerifier { - /// Expected fingerprint (lowercase hex). `None` → TOFU: accept and record. - pinned: Option<String>, - /// Fingerprint observed on the last handshake (for TOFU capture). - observed: ObservedFingerprint, - provider: Arc<CryptoProvider>, -} - -impl PinnedCertVerifier { - pub fn new( - pinned: Option<String>, - observed: ObservedFingerprint, - provider: Arc<CryptoProvider>, - ) -> Self { - Self { - pinned, - observed, - provider, - } - } - - /// Pure pin-check: record the presented fingerprint, then enforce the pin. - /// Factored out so the decision is unit-testable without a live handshake. - fn check(&self, end_entity_der: &[u8]) -> Result<(), rustls::Error> { - let fp = cert_fingerprint(end_entity_der); - if let Ok(mut slot) = self.observed.lock() { - *slot = Some(fp.clone()); - } - match &self.pinned { - // Trust on first use: no pin yet, accept and let the caller persist. - None => Ok(()), - Some(expected) if expected.eq_ignore_ascii_case(&fp) => Ok(()), - Some(expected) => Err(rustls::Error::General(format!( - "remote certificate fingerprint mismatch: pinned {expected}, server presented {fp}" - ))), - } - } -} - -impl ServerCertVerifier for PinnedCertVerifier { - fn verify_server_cert( - &self, - end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result<ServerCertVerified, rustls::Error> { - self.check(end_entity.as_ref())?; - Ok(ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, - ) -> Result<HandshakeSignatureValid, rustls::Error> { - verify_tls12_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, - ) -> Result<HandshakeSignatureValid, rustls::Error> { - verify_tls13_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec<SignatureScheme> { - self.provider - .signature_verification_algorithms - .supported_schemes() - } -} - -fn provider() -> Arc<CryptoProvider> { - Arc::new(rustls::crypto::ring::default_provider()) -} - -/// Build a rustls `ClientConfig` that pins the server cert via [`PinnedCertVerifier`]. -fn pinned_client_config(pinned: Option<String>, observed: ObservedFingerprint) -> rustls::ClientConfig { - let provider = provider(); - let verifier = Arc::new(PinnedCertVerifier::new(pinned, observed, provider.clone())); - #[allow( - clippy::expect_used, - reason = "ring default provider always supports the default protocol versions" - )] - let builder = rustls::ClientConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .expect("ring default provider supports default protocol versions"); - builder - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_no_client_auth() -} - -/// Build a reqwest client for a connection. When `tls` is false this is a plain -/// `reqwest::Client`; when true it uses the pinned rustls config (TOFU/enforce). -pub fn build_reqwest_client( - tls: bool, - pinned: Option<String>, - observed: ObservedFingerprint, -) -> reqwest::Client { - if !tls { - return reqwest::Client::new(); - } - let config = pinned_client_config(pinned, observed); - reqwest::Client::builder() - .use_preconfigured_tls(config) - .build() - .unwrap_or_else(|e| { - log::error!("Failed to build pinned TLS client, falling back to default: {e}"); - reqwest::Client::new() - }) -} - -/// Build a tokio-tungstenite connector for the WebSocket. Returns `None` when -/// `tls` is false (the caller uses the plain `connect_async` path). -pub fn ws_connector( - tls: bool, - pinned: Option<String>, - observed: ObservedFingerprint, -) -> Option<tokio_tungstenite::Connector> { - if !tls { - return None; - } - let config = pinned_client_config(pinned, observed); - Some(tokio_tungstenite::Connector::Rustls(Arc::new(config))) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn verifier(pinned: Option<&str>) -> (PinnedCertVerifier, ObservedFingerprint) { - let observed: ObservedFingerprint = Arc::new(Mutex::new(None)); - let v = PinnedCertVerifier::new(pinned.map(str::to_string), observed.clone(), provider()); - (v, observed) - } - - #[test] - fn tofu_accepts_and_records_when_no_pin() { - let (v, observed) = verifier(None); - assert!(v.check(b"fake-der-bytes").is_ok()); - assert_eq!( - observed.lock().unwrap().as_deref(), - Some(cert_fingerprint(b"fake-der-bytes").as_str()) - ); - } - - #[test] - fn matching_pin_is_accepted() { - let fp = cert_fingerprint(b"server-cert"); - let (v, _o) = verifier(Some(&fp)); - assert!(v.check(b"server-cert").is_ok()); - } - - #[test] - fn mismatched_pin_is_rejected() { - let wrong = cert_fingerprint(b"other-cert"); - let (v, observed) = verifier(Some(&wrong)); - assert!(v.check(b"server-cert").is_err()); - // Still records what the server actually presented (for diagnostics). - assert_eq!( - observed.lock().unwrap().as_deref(), - Some(cert_fingerprint(b"server-cert").as_str()) - ); - } - - #[test] - fn pin_comparison_is_case_insensitive() { - let fp = cert_fingerprint(b"server-cert").to_uppercase(); - let (v, _o) = verifier(Some(&fp)); - assert!(v.check(b"server-cert").is_ok()); - } - - #[test] - fn format_fingerprint_groups_pairs_with_colons_and_spaces() { - // 12 hex chars = 6 byte pairs → colons within 4-pair blocks, space between. - assert_eq!(format_fingerprint("aabbccddeeff"), "aa:bb:cc:dd ee:ff"); - // A full 64-char fingerprint round-trips to the same hex when separators stripped. - let fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; - let formatted = format_fingerprint(fp); - assert_eq!(formatted.replace([':', ' '], ""), fp); - // Spaces every 4 pairs (8 hex chars) → 32 pairs / 4 = 8 blocks → 7 spaces. - assert_eq!(formatted.matches(' ').count(), 7); - } -} +pub use crate::tls::*; diff --git a/crates/okena-transport/src/client/types.rs b/crates/okena-transport/src/client/types.rs index aad6a755e..6a6135d07 100644 --- a/crates/okena-transport/src/client/types.rs +++ b/crates/okena-transport/src/client/types.rs @@ -22,8 +22,8 @@ pub enum ConnectionStatus { /// Messages sent from the UI thread to the WebSocket writer task. #[derive(Debug)] pub enum WsClientMessage { - /// Send text input to a remote terminal - SendText { terminal_id: String, text: String }, + /// Send byte-exact input to a remote terminal. + SendInput { terminal_id: String, data: Vec<u8> }, /// Resize a remote terminal Resize { terminal_id: String, @@ -72,6 +72,11 @@ pub enum ConnectionEvent { connection_id: String, state: StateResponse, }, + /// Daemon-authoritative settings snapshot changed. + SettingsChanged { + connection_id: String, + settings: serde_json::Value, + }, /// Stream subscription mappings received SubscriptionMappings { connection_id: String, @@ -87,6 +92,24 @@ pub enum ConnectionEvent { connection_id: String, statuses: HashMap<String, okena_core::api::ApiGitStatus>, }, + /// 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, + }, + /// One-shot request for the desktop client to focus and raise an exact + /// terminal. IDs are server-local and are prefixed by the manager. + TerminalFocusRequested { + connection_id: String, + request: okena_core::api::ApiTerminalFocusRequest, + }, /// Token was refreshed — save new token and update timestamp TokenRefreshed { connection_id: String, @@ -122,11 +145,11 @@ mod tests { #[test] fn ws_client_message_debug() { - let msg = WsClientMessage::SendText { + let msg = WsClientMessage::SendInput { terminal_id: "t1".to_string(), - text: "hello".to_string(), + data: b"hello".to_vec(), }; let debug = format!("{:?}", msg); - assert!(debug.contains("SendText")); + assert!(debug.contains("SendInput")); } } diff --git a/crates/okena-transport/src/connection_config.rs b/crates/okena-transport/src/connection_config.rs new file mode 100644 index 000000000..4fa0f67db --- /dev/null +++ b/crates/okena-transport/src/connection_config.rs @@ -0,0 +1,95 @@ +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)] +pub struct RemoteConnectionConfig { + /// Stable UUID, unique across sessions + pub id: String, + /// User-friendly label (e.g. "Work Laptop") + pub name: String, + /// Hostname or IP address of the remote server + pub host: String, + /// Server port (typically 19100-19200) + pub port: u16, + /// Persisted bearer token (24h TTL on server side) + #[serde(default)] + pub saved_token: Option<String>, + /// Unix timestamp when the token was obtained (for refresh scheduling) + #[serde(default)] + pub token_obtained_at: Option<i64>, + /// Connect over TLS (https/wss). Default false for backward compatibility + /// with existing plain-http connections. + #[serde(default)] + pub tls: bool, + /// SHA-256 fingerprint (lowercase hex) of the server's TLS certificate, + /// pinned on first connect (TOFU). When set, the client refuses any cert + /// whose fingerprint differs — defeating an active MITM. `None` until the + /// first successful TLS handshake captures it. + #[serde(default)] + pub pinned_cert_sha256: Option<String>, + /// 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<LocalEndpoint>, +} + +impl RemoteConnectionConfig { + /// `http://host:port` or `https://host:port` depending on `tls`. + pub fn base_url(&self) -> String { + let scheme = if self.tls { "https" } else { "http" }; + format!("{}://{}:{}", scheme, self.host, self.port) + } + + /// `ws://host:port/v1/stream` or `wss://…` depending on `tls`. + pub fn ws_url(&self) -> String { + 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<String> { + self.saved_token + .clone() + .or_else(|| self.is_trusted_local_transport().then(String::new)) + } +} diff --git a/crates/okena-transport/src/http.rs b/crates/okena-transport/src/http.rs index c00d1718f..7c0eb887d 100644 --- a/crates/okena-transport/src/http.rs +++ b/crates/okena-transport/src/http.rs @@ -477,7 +477,9 @@ impl HttpBus { let status = resp.status().as_u16(); log::debug!(target: "okena::http", "stream {detail} -> {status} ({elapsed}ms)"); } - Err(e) => log::warn!(target: "okena::http", "stream {detail} failed: {e} ({elapsed}ms)"), + Err(e) => { + log::warn!(target: "okena::http", "stream {detail} failed: {e} ({elapsed}ms)") + } } let resp = result?; Ok(HttpStream { @@ -552,10 +554,11 @@ mod tests { .label("test.post"); assert_eq!(req.method, Method::Post); assert_eq!(req.audit_detail(), "test.post: POST https://example.test/x"); - assert!(req - .headers - .iter() - .any(|(k, v)| k == "Authorization" && v == "Bearer tok")); + assert!( + req.headers + .iter() + .any(|(k, v)| k == "Authorization" && v == "Bearer tok") + ); } #[test] @@ -595,14 +598,8 @@ mod tests { // First call admitted, the next two (immediate) are short-circuited // before ever reaching the mock. assert!(send(make()).is_ok()); - assert!(matches!( - send(make()), - Err(HttpError::Throttled { .. }) - )); - assert!(matches!( - send(make()), - Err(HttpError::Throttled { .. }) - )); + assert!(matches!(send(make()), Err(HttpError::Throttled { .. }))); + assert!(matches!(send(make()), Err(HttpError::Throttled { .. }))); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); } diff --git a/crates/okena-transport/src/lib.rs b/crates/okena-transport/src/lib.rs index cf1625b1a..0791964fa 100644 --- a/crates/okena-transport/src/lib.rs +++ b/crates/okena-transport/src/lib.rs @@ -8,9 +8,17 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] +mod connection_config; +#[cfg(any(feature = "client", feature = "blocking-http"))] +pub mod tls; + #[cfg(feature = "client")] pub mod client; #[cfg(feature = "blocking-http")] pub mod http; -#[cfg(feature = "blocking-http")] +#[cfg(any(feature = "client", feature = "blocking-http"))] pub mod remote_action; +#[cfg(any(feature = "client", feature = "blocking-http"))] +pub mod remote_http; + +pub use connection_config::{LOCAL_DAEMON_CONNECTION_ID, LocalEndpoint, RemoteConnectionConfig}; diff --git a/crates/okena-transport/src/remote_action.rs b/crates/okena-transport/src/remote_action.rs index 8a94ed675..4c509b607 100644 --- a/crates/okena-transport/src/remote_action.rs +++ b/crates/okena-transport/src/remote_action.rs @@ -1,6 +1,12 @@ -//! Shared blocking HTTP helper for posting ActionRequests to a remote server. +//! Shared async and blocking clients for actions sent to an Okena daemon. +use crate::RemoteConnectionConfig; use okena_core::api::ActionRequest; +#[cfg(feature = "blocking-http")] +use std::sync::{Arc, OnceLock}; + +#[cfg(feature = "cancellable-http")] +const CANCELLATION_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25); /// Total request timeout for "fast" actions (terminal control, listings, /// metadata). 10 s is generous for these; longer would mask real failures. @@ -11,6 +17,16 @@ const FAST_TIMEOUT_SECS: u64 = 10; /// wire alone, which would time out the fast client with no useful signal. const BYTES_TIMEOUT_SECS: u64 = 90; +/// Total request timeout for repository content searches. Unlike metadata +/// actions, these may need to walk and inspect an entire large checkout. +const SEARCH_TIMEOUT_SECS: u64 = 90; + +/// Total request timeout for synchronous filesystem mutations. Direct +/// worktree removal may run two sequential five-minute close hooks before Git. +const LONG_MUTATION_TIMEOUT_SECS: u64 = 11 * 60; + +const ACTIONS_PATH: &str = "/v1/actions"; + /// Hard ceiling on response body size accepted by the remote bridge. Cuts /// off arbitrarily large or runaway responses before they're buffered into /// memory (peak resident is ~4× the file size while the base64 + JSON + @@ -18,97 +34,330 @@ const BYTES_TIMEOUT_SECS: u64 = 90; /// `src/workspace/actions/execute/files.rs`. const MAX_RESPONSE_BYTES: u64 = 32 * 1024 * 1024; -/// Build a reqwest blocking client. Returns Err on TLS backend init -/// failure (corrupt cert store, sandboxed Keychain denial, FIPS mode -/// rejecting default ciphers, container without ca-certificates). Caller -/// is expected to surface the error to the user — a panic here would -/// abort the whole app at first remote probe and OnceLock-poison every -/// retry. -fn build_client(timeout_secs: u64) -> Result<reqwest::blocking::Client, String> { - reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(timeout_secs)) - .connect_timeout(std::time::Duration::from_secs(5)) - .build() - .map_err(|e| format!("Cannot initialise HTTP client: {}", e)) -} - -/// Cached fast-action client. We store the build result so a TLS init -/// failure surfaces as a recoverable error rather than poisoning the -/// OnceLock and panicking every retry. -fn shared_client() -> Result<&'static reqwest::blocking::Client, String> { - use std::sync::OnceLock; - static CLIENT: OnceLock<Result<reqwest::blocking::Client, String>> = OnceLock::new(); - match CLIENT.get_or_init(|| build_client(FAST_TIMEOUT_SECS)) { - Ok(c) => Ok(c), - Err(e) => Err(e.clone()), - } -} - -/// Cached bytes-action client with a longer timeout, for ReadFileBytes. -fn bytes_client() -> Result<&'static reqwest::blocking::Client, String> { - use std::sync::OnceLock; - static CLIENT: OnceLock<Result<reqwest::blocking::Client, String>> = OnceLock::new(); - match CLIENT.get_or_init(|| build_client(BYTES_TIMEOUT_SECS)) { - Ok(c) => Ok(c), - Err(e) => Err(e.clone()), - } -} - -/// Pick the right client for the action. Bytes-bearing actions get the -/// larger timeout so a slow remote doesn't surface as a generic transport -/// error mid-download. -fn client_for(action: &ActionRequest) -> Result<&'static reqwest::blocking::Client, String> { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActionClientKind { + Fast, + Bytes, + Search, + LongMutation, +} + +fn client_kind_for(action: &ActionRequest) -> ActionClientKind { match action { - ActionRequest::ReadFileBytes { .. } => bytes_client(), - _ => shared_client(), + ActionRequest::ReadFileBytes { .. } => ActionClientKind::Bytes, + ActionRequest::SearchContent { .. } => ActionClientKind::Search, + ActionRequest::RemoveWorktreeProject { .. } + | ActionRequest::RenameProjectDirectory { .. } => ActionClientKind::LongMutation, + _ => ActionClientKind::Fast, + } +} + +fn timeout_for(action: &ActionRequest) -> u64 { + match client_kind_for(action) { + ActionClientKind::Fast => FAST_TIMEOUT_SECS, + ActionClientKind::Bytes => BYTES_TIMEOUT_SECS, + ActionClientKind::Search => SEARCH_TIMEOUT_SECS, + ActionClientKind::LongMutation => LONG_MUTATION_TIMEOUT_SECS, + } +} + +fn action_url(base_url: &str) -> String { + format!("{base_url}{ACTIONS_PATH}") +} + +#[cfg(feature = "blocking-http")] +type ClientAndUrl = (reqwest::blocking::Client, String); + +#[cfg(feature = "cancellable-http")] +type AsyncClientAndUrl = (reqwest::Client, String); + +#[cfg(feature = "blocking-http")] +struct RemoteActionClientInner { + config: RemoteConnectionConfig, + token: String, + fast: OnceLock<Result<ClientAndUrl, String>>, + bytes: OnceLock<Result<ClientAndUrl, String>>, + search: OnceLock<Result<ClientAndUrl, String>>, + long_mutation: OnceLock<Result<ClientAndUrl, String>>, + #[cfg(feature = "cancellable-http")] + async_search: OnceLock<Result<AsyncClientAndUrl, String>>, +} + +/// Cloneable blocking action client backed by one complete connection config. +/// Every value-returning provider uses this type so local sockets, TLS scheme, +/// certificate pinning, auth, and timeouts cannot diverge between features. +#[derive(Clone)] +#[cfg(feature = "blocking-http")] +pub struct RemoteActionClient { + inner: Arc<RemoteActionClientInner>, +} + +#[cfg(feature = "blocking-http")] +impl RemoteActionClient { + pub fn new(config: RemoteConnectionConfig, token: String) -> Self { + Self { + inner: Arc::new(RemoteActionClientInner { + config, + token, + fast: OnceLock::new(), + bytes: OnceLock::new(), + search: OnceLock::new(), + long_mutation: OnceLock::new(), + #[cfg(feature = "cancellable-http")] + async_search: OnceLock::new(), + }), + } + } + + /// Post an action and return its optional JSON payload. + pub fn post_action(&self, action: ActionRequest) -> Result<Option<serde_json::Value>, String> { + let transport = match client_kind_for(&action) { + ActionClientKind::Fast => &self.inner.fast, + ActionClientKind::Bytes => &self.inner.bytes, + ActionClientKind::Search => &self.inner.search, + ActionClientKind::LongMutation => &self.inner.long_mutation, + }; + let timeout = std::time::Duration::from_secs(timeout_for(&action)); + let client_and_url = transport.get_or_init(|| { + crate::remote_http::blocking_client_and_url(&self.inner.config, ACTIONS_PATH, timeout) + }); + let (client, url) = match client_and_url { + Ok(client_and_url) => client_and_url, + Err(error) => return Err(error.clone()), + }; + post_action_inner(client, url, &self.inner.token, action) + } + + /// Post a content search while observing the request-local cancellation flag. + /// + /// The HTTP future runs on a shared async runtime, while this blocking API + /// waits through a bounded channel so synchronous filesystem providers can + /// still cancel an in-flight request. Dropping the future closes the HTTP + /// response and, in turn, the daemon bridge reply receiver. + #[cfg(feature = "cancellable-http")] + pub fn post_action_cancellable( + &self, + action: ActionRequest, + cancelled: &std::sync::atomic::AtomicBool, + ) -> Result<Option<serde_json::Value>, String> { + if !matches!(action, ActionRequest::SearchContent { .. }) { + return Err("cancellable remote actions only support content search".to_string()); + } + if cancelled.load(std::sync::atomic::Ordering::Relaxed) { + return Err("content search cancelled".to_string()); + } + + let runtime = cancellable_runtime()?; + let client_and_url = self + .inner + .async_search + .get_or_init(|| crate::remote_http::async_client_and_url(&self.inner.config, "")); + let (client, url) = match client_and_url { + Ok((client, url)) => (client.clone(), url.clone()), + Err(error) => return Err(error.clone()), + }; + let token = self.inner.token.clone(); + let timeout = std::time::Duration::from_secs(timeout_for(&action)); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let mut cancellation = RequestCancellation::new(cancel_tx); + + runtime.spawn(async move { + let request = post_action_async_with_client(&client, &url, &token, action); + let result = await_cancellable_request(request, cancel_rx, timeout).await; + let _ = result_tx.send(result); + }); + + loop { + match result_rx.recv_timeout(CANCELLATION_POLL_INTERVAL) { + Ok(result) => { + cancellation.disarm(); + return result; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if cancelled.load(std::sync::atomic::Ordering::Relaxed) { + cancellation.cancel(); + return result_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap_or_else(|_| { + Err("content search cancellation failed".to_string()) + }); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + cancellation.disarm(); + return Err("content search request task stopped".to_string()); + } + } + } + } +} + +#[cfg(feature = "cancellable-http")] +fn cancellable_runtime() -> Result<&'static tokio::runtime::Handle, String> { + static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, String>> = OnceLock::new(); + match RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .thread_name("okena-remote-search") + .build() + .map_err(|error| format!("Cannot initialise content search runtime: {error}")) + }) { + Ok(runtime) => Ok(runtime.handle()), + Err(error) => Err(error.clone()), + } +} + +#[cfg(feature = "cancellable-http")] +struct RequestCancellation(Option<tokio::sync::oneshot::Sender<()>>); + +#[cfg(feature = "cancellable-http")] +impl RequestCancellation { + fn new(sender: tokio::sync::oneshot::Sender<()>) -> Self { + Self(Some(sender)) + } + + fn cancel(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + + fn disarm(&mut self) { + self.0 = None; + } +} + +#[cfg(feature = "cancellable-http")] +impl Drop for RequestCancellation { + fn drop(&mut self) { + self.cancel(); + } +} + +#[cfg(feature = "cancellable-http")] +async fn await_cancellable_request<F, T>( + request: F, + cancel_rx: tokio::sync::oneshot::Receiver<()>, + timeout: std::time::Duration, +) -> Result<T, String> +where + F: std::future::Future<Output = Result<T, String>>, +{ + tokio::select! { + result = request => result, + _ = cancel_rx => Err("content search cancelled".to_string()), + _ = tokio::time::sleep(timeout) => Err("content search request timed out".to_string()), + } +} + +/// Post an action asynchronously using the same connection-aware transport and +/// response contract as [`RemoteActionClient`]. +#[cfg(feature = "client")] +pub async fn post_action_async( + config: &RemoteConnectionConfig, + token: &str, + action: ActionRequest, +) -> Result<Option<serde_json::Value>, String> { + let (client, base_url) = crate::remote_http::async_client_and_url(config, "")?; + post_action_async_with_client(&client, &base_url, token, action).await +} + +/// Post through an already-selected async client. Connection setup uses this +/// while it is still negotiating the final config, but keeps action response +/// parsing and size limits on the same path as every other caller. +#[cfg(any(feature = "client", feature = "cancellable-http"))] +pub async fn post_action_async_with_client( + client: &reqwest::Client, + base_url: &str, + token: &str, + action: ActionRequest, +) -> Result<Option<serde_json::Value>, String> { + let url = action_url(base_url); + let mut response = client + .post(url) + .bearer_auth(token) + .json(&action) + .timeout(std::time::Duration::from_secs(timeout_for(&action))) + .send() + .await + .map_err(|e| format!("HTTP request failed: {e}"))?; + + reject_declared_oversize(response.content_length())?; + let status = response.status(); + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("Failed to read response: {e}"))? + { + if body.len() + chunk.len() > MAX_RESPONSE_BYTES as usize { + return Err(response_too_large_error()); + } + body.extend_from_slice(&chunk); } + parse_action_response(status, &body) } -/// Post an action request to a remote server and return the JSON response body. -pub fn post_action( - host: &str, - port: u16, +#[cfg(feature = "blocking-http")] +fn post_action_inner( + client: &reqwest::blocking::Client, + url: &str, token: &str, action: ActionRequest, ) -> Result<Option<serde_json::Value>, String> { - let url = format!("http://{}:{}/v1/actions", host, port); - let client = client_for(&action)?; let resp = client - .post(&url) + .post(url) .bearer_auth(token) .json(&action) .send() .map_err(|e| format!("HTTP request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().unwrap_or_default(); - return Err(format!("Server returned {}: {}", status, body)); - } - - // Cap how much body we buffer. A hostile / older / desync'd server can't - // make us swallow a multi-GB JSON+base64 stream into memory. Content-Length - // is only a hint; we still bound the actual read. - if let Some(len) = resp.content_length() - && len > MAX_RESPONSE_BYTES { - return Err(format!( - "Response too large ({:.1} MB). Max {} MB.", - len as f64 / 1024.0 / 1024.0, - MAX_RESPONSE_BYTES / 1024 / 1024 - )); - } + reject_declared_oversize(resp.content_length())?; + let status = resp.status(); use std::io::Read as _; let mut body_bytes = Vec::new(); resp.take(MAX_RESPONSE_BYTES + 1) .read_to_end(&mut body_bytes) .map_err(|e| format!("Failed to read response: {}", e))?; if body_bytes.len() as u64 > MAX_RESPONSE_BYTES { + return Err(response_too_large_error()); + } + parse_action_response(status, &body_bytes) +} + +fn reject_declared_oversize(content_length: Option<u64>) -> Result<(), String> { + if let Some(len) = content_length + && len > MAX_RESPONSE_BYTES + { return Err(format!( - "Response too large (>{} MB).", + "Response too large ({:.1} MB). Max {} MB.", + len as f64 / 1024.0 / 1024.0, MAX_RESPONSE_BYTES / 1024 / 1024 )); } - let body: serde_json::Value = serde_json::from_slice(&body_bytes) + Ok(()) +} + +fn response_too_large_error() -> String { + format!( + "Response too large (>{} MB).", + MAX_RESPONSE_BYTES / 1024 / 1024 + ) +} + +fn parse_action_response( + status: reqwest::StatusCode, + body_bytes: &[u8], +) -> Result<Option<serde_json::Value>, String> { + if !status.is_success() { + return Err(format!( + "Server returned {status}: {}", + String::from_utf8_lossy(body_bytes) + )); + } + let body: serde_json::Value = serde_json::from_slice(body_bytes) .map_err(|e| format!("Failed to parse response: {}", e))?; if let Some(error) = body.get("error").and_then(|e| e.as_str()) { @@ -122,3 +371,235 @@ pub fn post_action( Ok(Some(body)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "cancellable-http")] + use std::sync::atomic::{AtomicBool, Ordering}; + + fn search_action() -> ActionRequest { + ActionRequest::SearchContent { + project_id: "project".to_string(), + query: "needle".to_string(), + case_sensitive: false, + mode: "literal".to_string(), + max_results: 1000, + file_glob: None, + context_lines: 0, + show_ignored: false, + } + } + + fn remove_worktree_action() -> ActionRequest { + ActionRequest::RemoveWorktreeProject { + project_id: "project".to_string(), + force: false, + } + } + + fn rename_project_directory_action() -> ActionRequest { + ActionRequest::RenameProjectDirectory { + project_id: "project".to_string(), + new_name: "renamed".to_string(), + } + } + + fn ordinary_fast_action() -> ActionRequest { + ActionRequest::ReadFile { + project_id: "project".to_string(), + relative_path: "README.md".to_string(), + } + } + + #[cfg(feature = "cancellable-http")] + fn remote_config(port: u16) -> RemoteConnectionConfig { + RemoteConnectionConfig { + id: "test".to_string(), + name: "Test".to_string(), + host: "127.0.0.1".to_string(), + port, + saved_token: None, + token_obtained_at: None, + tls: false, + pinned_cert_sha256: None, + local_endpoint: None, + } + } + + #[test] + fn content_search_uses_long_timeout() { + assert_eq!(timeout_for(&search_action()), SEARCH_TIMEOUT_SECS); + assert_eq!(SEARCH_TIMEOUT_SECS, 90); + } + + #[test] + fn synchronous_long_mutations_use_dedicated_clients() { + for action in [remove_worktree_action(), rename_project_directory_action()] { + assert_eq!(client_kind_for(&action), ActionClientKind::LongMutation); + assert_eq!(timeout_for(&action), LONG_MUTATION_TIMEOUT_SECS); + } + assert_eq!(LONG_MUTATION_TIMEOUT_SECS, 660); + } + + #[test] + fn ordinary_actions_keep_the_fast_timeout() { + let action = ordinary_fast_action(); + assert_eq!(client_kind_for(&action), ActionClientKind::Fast); + assert_eq!(timeout_for(&action), FAST_TIMEOUT_SECS); + assert_eq!(FAST_TIMEOUT_SECS, 10); + } + + #[test] + fn action_posts_use_the_canonical_route() { + assert_eq!(ACTIONS_PATH, "/v1/actions"); + assert_eq!( + action_url("https://okena.example"), + "https://okena.example/v1/actions" + ); + } + + #[test] + fn content_search_has_a_dedicated_cached_client() { + assert_eq!(client_kind_for(&search_action()), ActionClientKind::Search); + assert_eq!( + client_kind_for(&ordinary_fast_action()), + ActionClientKind::Fast + ); + assert_eq!( + client_kind_for(&ActionRequest::ReadFileBytes { + project_id: "project".to_string(), + relative_path: "image.png".to_string(), + }), + ActionClientKind::Bytes + ); + } + + #[cfg(feature = "cancellable-http")] + struct PendingRequest { + dropped: Arc<AtomicBool>, + } + + #[cfg(feature = "cancellable-http")] + impl std::future::Future for PendingRequest { + type Output = Result<(), String>; + + fn poll( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<Self::Output> { + std::task::Poll::Pending + } + } + + #[cfg(feature = "cancellable-http")] + impl Drop for PendingRequest { + fn drop(&mut self) { + self.dropped.store(true, Ordering::Relaxed); + } + } + + #[cfg(feature = "cancellable-http")] + #[tokio::test] + async fn cancellation_drops_the_request_future() { + let dropped = Arc::new(AtomicBool::new(false)); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let request = PendingRequest { + dropped: dropped.clone(), + }; + + cancel_tx.send(()).unwrap(); + let error = + await_cancellable_request(request, cancel_rx, std::time::Duration::from_secs(3600)) + .await + .unwrap_err(); + + assert_eq!(error, "content search cancelled"); + assert!(dropped.load(Ordering::Relaxed)); + } + + #[cfg(feature = "cancellable-http")] + #[tokio::test] + async fn timeout_drops_the_request_future() { + let dropped = Arc::new(AtomicBool::new(false)); + let (_cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let request = PendingRequest { + dropped: dropped.clone(), + }; + + let error = + await_cancellable_request(request, cancel_rx, std::time::Duration::from_millis(1)) + .await + .unwrap_err(); + + assert_eq!(error, "content search request timed out"); + assert!(dropped.load(Ordering::Relaxed)); + } + + #[cfg(feature = "cancellable-http")] + #[test] + fn blocking_facade_closes_the_in_flight_request_on_cancellation() { + use std::io::Read as _; + + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let (request_started_tx, request_started_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..read]); + if let Some(header_end) = + request.windows(4).position(|window| window == b"\r\n\r\n") + { + let headers = std::str::from_utf8(&request[..header_end]).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::<usize>().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + let request_line = std::str::from_utf8(&request) + .unwrap() + .lines() + .next() + .unwrap(); + assert_eq!(request_line, "POST /v1/actions HTTP/1.1"); + request_started_tx.send(()).unwrap(); + stream.read(&mut buffer).unwrap_or_default() == 0 + }); + + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = cancelled.clone(); + let client = RemoteActionClient::new(remote_config(port), "token".to_string()); + let request = std::thread::spawn(move || { + client.post_action_cancellable(search_action(), &worker_cancelled) + }); + + request_started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + cancelled.store(true, Ordering::Relaxed); + let error = request.join().unwrap().unwrap_err(); + + assert_eq!(error, "content search cancelled"); + assert!( + server.join().unwrap(), + "cancelled request must close its socket" + ); + } +} diff --git a/crates/okena-transport/src/remote_http.rs b/crates/okena-transport/src/remote_http.rs new file mode 100644 index 000000000..dbfaaf9d2 --- /dev/null +++ b/crates/okena-transport/src/remote_http.rs @@ -0,0 +1,111 @@ +//! Connection-aware HTTP client construction for Okena's remote protocol. + +use crate::RemoteConnectionConfig; + +/// Build an async HTTP client and URL using the connection's complete +/// transport policy: local socket, plain TCP, or pinned TLS. +#[cfg(any(feature = "client", feature = "cancellable-http"))] +pub fn async_client_and_url( + config: &RemoteConnectionConfig, + path: &str, +) -> Result<(reqwest::Client, String), String> { + #[cfg(unix)] + if let Some(crate::LocalEndpoint::UnixSocket { path: socket_path }) = &config.local_endpoint { + let client = reqwest::Client::builder() + .unix_socket(socket_path.as_str()) + .build() + .map_err(|error| format!("Cannot initialise Unix socket HTTP client: {error}"))?; + return Ok((client, config.http_url(path))); + } + + let client = crate::tls::build_reqwest_client( + config.tls, + config.pinned_cert_sha256.clone(), + crate::tls::new_observed(), + )?; + Ok((client, config.http_url(path))) +} + +/// Build a blocking HTTP client and URL with the same transport policy as the +/// async connection manager. +#[cfg(feature = "blocking-http")] +pub fn blocking_client_and_url( + config: &RemoteConnectionConfig, + path: &str, + timeout: std::time::Duration, +) -> Result<(reqwest::blocking::Client, String), String> { + #[cfg(unix)] + if let Some(crate::LocalEndpoint::UnixSocket { path: socket_path }) = &config.local_endpoint { + let client = reqwest::blocking::Client::builder() + .unix_socket(socket_path.as_str()) + .timeout(timeout) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| format!("Cannot initialise Unix socket HTTP client: {e}"))?; + return Ok((client, config.http_url(path))); + } + + let client = crate::tls::build_blocking_reqwest_client( + config.tls, + config.pinned_cert_sha256.clone(), + crate::tls::new_observed(), + timeout, + )?; + Ok((client, config.http_url(path))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LocalEndpoint; + + fn config(tls: bool, local_endpoint: Option<LocalEndpoint>) -> RemoteConnectionConfig { + RemoteConnectionConfig { + id: "test".to_string(), + name: "Test".to_string(), + host: "remote.example".to_string(), + port: 19100, + saved_token: Some("secret".to_string()), + token_obtained_at: None, + tls, + pinned_cert_sha256: Some("00".repeat(32)), + local_endpoint, + } + } + + #[cfg(any(feature = "client", feature = "cancellable-http"))] + #[test] + fn async_remote_url_uses_tls_from_connection_config() { + let (_, url) = async_client_and_url(&config(true, None), "/v1/actions").unwrap(); + assert_eq!(url, "https://remote.example:19100/v1/actions"); + } + + #[cfg(feature = "blocking-http")] + #[test] + fn blocking_remote_url_uses_tls_from_connection_config() { + let (_, url) = blocking_client_and_url( + &config(true, None), + "/v1/actions", + std::time::Duration::from_secs(10), + ) + .unwrap(); + assert_eq!(url, "https://remote.example:19100/v1/actions"); + } + + #[cfg(all(unix, feature = "blocking-http"))] + #[test] + fn blocking_local_url_uses_unix_socket_origin() { + let (_, url) = blocking_client_and_url( + &config( + false, + Some(LocalEndpoint::UnixSocket { + path: "/tmp/okena-test.sock".to_string(), + }), + ), + "/v1/actions", + std::time::Duration::from_secs(10), + ) + .unwrap(); + assert_eq!(url, "http://okena.local/v1/actions"); + } +} diff --git a/crates/okena-transport/src/tls.rs b/crates/okena-transport/src/tls.rs new file mode 100644 index 000000000..0d6566427 --- /dev/null +++ b/crates/okena-transport/src/tls.rs @@ -0,0 +1,290 @@ +//! Client-side TLS with certificate pinning (Trust On First Use). +//! +//! The remote server uses a self-signed cert (no CA). The client therefore +//! cannot validate it against a trust store; instead it **pins the cert's +//! SHA-256 fingerprint**: +//! +//! - On the first TLS handshake the pin is `None` → we accept the cert and +//! record its fingerprint (TOFU). The caller persists it and the user +//! verifies it out-of-band against the fingerprint shown on the host. +//! - On every later handshake the pin is `Some` → we require the presented +//! cert's fingerprint to match exactly, defeating an active MITM. +//! +//! Crucially, we still verify the handshake *signature* against the presented +//! cert's key (via the crypto provider), so an attacker who merely replays the +//! pinned cert without its private key cannot complete the handshake. +//! +//! This trust model is correct for a LAN self-signed server identified by a +//! pinned fingerprint. It is NOT appropriate for the public web (no hostname or +//! chain validation). + +use std::sync::{Arc, Mutex}; + +use rustls::DigitallySignedStruct; +use rustls::SignatureScheme; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature}; +use rustls_pki_types::{CertificateDer, ServerName, UnixTime}; +use sha2::{Digest, Sha256}; + +/// Lowercase-hex SHA-256 of a DER-encoded certificate. +pub fn cert_fingerprint(der: &[u8]) -> String { + use std::fmt::Write; + let digest = Sha256::digest(der); + let mut s = String::with_capacity(digest.len() * 2); + for b in digest { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Format a hex fingerprint as colon-separated byte pairs for readable +/// out-of-band comparison (e.g. `ab:cd:ef:01 23:45:67:89 …`). Pairs are grouped +/// four-to-a-block with spaces between blocks so the long hex string can +/// soft-wrap inside narrow containers instead of overflowing horizontally. +pub fn format_fingerprint(fp: &str) -> String { + fp.as_bytes() + .chunks(2) + .map(|pair| std::str::from_utf8(pair).unwrap_or("??")) + .collect::<Vec<_>>() + .chunks(4) + .map(|group| group.join(":")) + .collect::<Vec<_>>() + .join(" ") +} + +/// Shared slot the verifier writes the most recently observed fingerprint into, +/// so the connection task can read it back after a successful handshake and +/// persist a freshly-pinned cert. +pub type ObservedFingerprint = Arc<Mutex<Option<String>>>; + +/// Fresh empty observed-fingerprint slot for one connection attempt. +pub fn new_observed() -> ObservedFingerprint { + Arc::new(Mutex::new(None)) +} + +/// rustls verifier that trusts a server cert by its pinned SHA-256 fingerprint. +#[derive(Debug)] +pub struct PinnedCertVerifier { + /// Expected fingerprint (lowercase hex). `None` → TOFU: accept and record. + pinned: Option<String>, + /// Fingerprint observed on the last handshake (for TOFU capture). + observed: ObservedFingerprint, + provider: Arc<CryptoProvider>, +} + +impl PinnedCertVerifier { + pub fn new( + pinned: Option<String>, + observed: ObservedFingerprint, + provider: Arc<CryptoProvider>, + ) -> Self { + Self { + pinned, + observed, + provider, + } + } + + /// Pure pin-check: record the presented fingerprint, then enforce the pin. + /// Factored out so the decision is unit-testable without a live handshake. + fn check(&self, end_entity_der: &[u8]) -> Result<(), rustls::Error> { + let fp = cert_fingerprint(end_entity_der); + if let Ok(mut slot) = self.observed.lock() { + *slot = Some(fp.clone()); + } + match &self.pinned { + // Trust on first use: no pin yet, accept and let the caller persist. + None => Ok(()), + Some(expected) if expected.eq_ignore_ascii_case(&fp) => Ok(()), + Some(expected) => Err(rustls::Error::General(format!( + "remote certificate fingerprint mismatch: pinned {expected}, server presented {fp}" + ))), + } + } +} + +impl ServerCertVerifier for PinnedCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result<ServerCertVerified, rustls::Error> { + self.check(end_entity.as_ref())?; + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result<HandshakeSignatureValid, rustls::Error> { + verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result<HandshakeSignatureValid, rustls::Error> { + verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec<SignatureScheme> { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +fn provider() -> Arc<CryptoProvider> { + Arc::new(rustls::crypto::ring::default_provider()) +} + +/// Build a rustls `ClientConfig` that pins the server cert via [`PinnedCertVerifier`]. +fn pinned_client_config( + pinned: Option<String>, + observed: ObservedFingerprint, +) -> rustls::ClientConfig { + let provider = provider(); + let verifier = Arc::new(PinnedCertVerifier::new(pinned, observed, provider.clone())); + #[allow( + clippy::expect_used, + reason = "ring default provider always supports the default protocol versions" + )] + let builder = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("ring default provider supports default protocol versions"); + builder + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth() +} + +/// Build a reqwest client for a connection. When `tls` is false this is a plain +/// `reqwest::Client`; when true it uses the pinned rustls config (TOFU/enforce). +#[cfg(any(feature = "client", feature = "cancellable-http"))] +pub fn build_reqwest_client( + tls: bool, + pinned: Option<String>, + observed: ObservedFingerprint, +) -> Result<reqwest::Client, String> { + if !tls { + return Ok(reqwest::Client::new()); + } + let config = pinned_client_config(pinned, observed); + reqwest::Client::builder() + .use_preconfigured_tls(config) + .build() + .map_err(|error| format!("Cannot initialise pinned TLS client: {error}")) +} + +/// Build a blocking reqwest client for a connection. This applies the same +/// certificate pinning policy as the async client and lets callers choose the +/// timeout appropriate for their payload size. +#[cfg(feature = "blocking-http")] +pub fn build_blocking_reqwest_client( + tls: bool, + pinned: Option<String>, + observed: ObservedFingerprint, + timeout: std::time::Duration, +) -> Result<reqwest::blocking::Client, String> { + let mut builder = reqwest::blocking::Client::builder() + .timeout(timeout) + .connect_timeout(std::time::Duration::from_secs(5)); + if tls { + builder = builder.use_preconfigured_tls(pinned_client_config(pinned, observed)); + } + builder + .build() + .map_err(|e| format!("Cannot initialise HTTP client: {e}")) +} + +/// Build a tokio-tungstenite connector for the WebSocket. Returns `None` when +/// `tls` is false (the caller uses the plain `connect_async` path). +#[cfg(feature = "client")] +pub fn ws_connector( + tls: bool, + pinned: Option<String>, + observed: ObservedFingerprint, +) -> Option<tokio_tungstenite::Connector> { + if !tls { + return None; + } + let config = pinned_client_config(pinned, observed); + Some(tokio_tungstenite::Connector::Rustls(Arc::new(config))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verifier(pinned: Option<&str>) -> (PinnedCertVerifier, ObservedFingerprint) { + let observed: ObservedFingerprint = Arc::new(Mutex::new(None)); + let v = PinnedCertVerifier::new(pinned.map(str::to_string), observed.clone(), provider()); + (v, observed) + } + + #[test] + fn tofu_accepts_and_records_when_no_pin() { + let (v, observed) = verifier(None); + assert!(v.check(b"fake-der-bytes").is_ok()); + assert_eq!( + observed.lock().unwrap().as_deref(), + Some(cert_fingerprint(b"fake-der-bytes").as_str()) + ); + } + + #[test] + fn matching_pin_is_accepted() { + let fp = cert_fingerprint(b"server-cert"); + let (v, _o) = verifier(Some(&fp)); + assert!(v.check(b"server-cert").is_ok()); + } + + #[test] + fn mismatched_pin_is_rejected() { + let wrong = cert_fingerprint(b"other-cert"); + let (v, observed) = verifier(Some(&wrong)); + assert!(v.check(b"server-cert").is_err()); + // Still records what the server actually presented (for diagnostics). + assert_eq!( + observed.lock().unwrap().as_deref(), + Some(cert_fingerprint(b"server-cert").as_str()) + ); + } + + #[test] + fn pin_comparison_is_case_insensitive() { + let fp = cert_fingerprint(b"server-cert").to_uppercase(); + let (v, _o) = verifier(Some(&fp)); + assert!(v.check(b"server-cert").is_ok()); + } + + #[test] + fn format_fingerprint_groups_pairs_with_colons_and_spaces() { + // 12 hex chars = 6 byte pairs → colons within 4-pair blocks, space between. + assert_eq!(format_fingerprint("aabbccddeeff"), "aa:bb:cc:dd ee:ff"); + // A full 64-char fingerprint round-trips to the same hex when separators stripped. + let fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + let formatted = format_fingerprint(fp); + assert_eq!(formatted.replace([':', ' '], ""), fp); + // Spaces every 4 pairs (8 hex chars) → 32 pairs / 4 = 8 blocks → 7 spaces. + assert_eq!(formatted.matches(' ').count(), 7); + } +} 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..efe8e8a2e --- /dev/null +++ b/crates/okena-tui/src/main.rs @@ -0,0 +1,869 @@ +#![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, + REMOTE_TERMINAL_ANSWERS_QUERIES, REMOTE_TERMINAL_RESIZE_DEBOUNCE_MS, + REMOTE_TERMINAL_USES_MOUSE_BACKEND, RemoteClient, RemoteConnectionConfig, WsClientMessage, + is_remote_terminal, make_prefixed_id, resize_remote_terminal, send_remote_terminal_input, + strip_prefix, +}; +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<String>, + + /// Remote port. Defaults to discovered local daemon port when omitted. + #[arg(long)] + port: Option<u16>, + + /// Bearer token for TCP remotes. Also read from OKENA_TOKEN. + #[arg(long, env = "OKENA_TOKEN")] + token: Option<String>, + + /// Pairing code to exchange for a token for this run. + #[arg(long)] + pair: Option<String>, + + /// Force TLS for TCP remotes. + #[arg(long)] + tls: bool, + + /// Connect over a same-user Unix socket. + #[arg(long)] + socket: Option<PathBuf>, + + /// Profile id used only for local remote.json discovery. + #[arg(long, env = "OKENA_PROFILE")] + profile: Option<String>, + + /// Start focused on this terminal id. + #[arg(long)] + terminal: Option<String>, +} + +struct TuiRemoteTransport { + ws_tx: async_channel::Sender<WsClientMessage>, + 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<RwLock<HashMap<String, Arc<Terminal>>>>, + dirty_tx: async_channel::Sender<()>, +} + +impl TuiConnectionHandler { + fn new( + terminals: Arc<RwLock<HashMap<String, Arc<Terminal>>>>, + 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<WsClientMessage>, + 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<String> = 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<String>, + ) { + let mut terminals = self.terminals.write(); + let to_remove: Vec<String> = 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<StateResponse>, + active_terminal: Option<String>, + terminal_request: Option<String>, + message: Option<String>, + last_resize: Option<(String, u16, u16)>, +} + +impl TuiState { + fn new(terminal_request: Option<String>) -> Self { + Self { + status: ConnectionStatus::Disconnected, + state: None, + active_terminal: terminal_request.clone(), + terminal_request, + message: None, + last_resize: None, + } + } + + fn entries(&self) -> Vec<TerminalEntry> { + 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<Self> { + 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<tokio::runtime::Runtime>, +) -> 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::<ConnectionEvent>(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<TuiConnectionHandler>, + state: &mut TuiState, + event_rx: &async_channel::Receiver<ConnectionEvent>, + 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 <code>, --token <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<TuiConnectionHandler>, + 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::SettingsChanged { .. } => {} + 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::TerminalFocusRequested { .. } => {} + 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<RwLock<HashMap<String, Arc<Terminal>>>>, + state: &mut TuiState, + key: CrosstermKeyEvent, +) -> Result<LoopControl> { + 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<Vec<u8>> { + 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<RwLock<HashMap<String, Arc<Terminal>>>>, + 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<RwLock<HashMap<String, Arc<Terminal>>>>, + 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<TerminalEntry> { + 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<TerminalEntry>, +) { + 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<RemoteConnectionConfig> { + 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<LocalEndpoint>, +} + +fn discover_local(profile: Option<&str>) -> Option<Result<DiscoveredDaemon>> { + 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<DiscoveredDaemon> { + 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-ui/src/activity_repaint.rs b/crates/okena-ui/src/activity_repaint.rs new file mode 100644 index 000000000..0028898e7 --- /dev/null +++ b/crates/okena-ui/src/activity_repaint.rs @@ -0,0 +1,117 @@ +use std::collections::HashSet; +use std::hash::Hash; + +/// Immediate presentation work produced by one activity event. +#[derive(Debug)] +pub struct ActivityRepaintDecision<T> { + /// Keys to present synchronously for either the leading edge or input priority. + pub immediate: HashSet<T>, + /// Whether this event transitioned from idle and must start the sole timer loop. + pub start_timer: bool, +} + +/// Collects exact repaint keys behind a leading-edge presentation batch. +/// +/// [`queue_activity`](Self::queue_activity) presents the idle-to-active edge +/// immediately and tells the caller to start one frame timer. While that timer +/// is active, ordinary activity is deduplicated for +/// [`take_scheduled`](Self::take_scheduled), but selected input-response keys can +/// be promoted to immediate presentation without starting another timer. +#[derive(Debug)] +pub struct ActivityRepaintBatch<T> { + pending: HashSet<T>, + scheduled: bool, +} + +impl<T> Default for ActivityRepaintBatch<T> { + fn default() -> Self { + Self { + pending: HashSet::new(), + scheduled: false, + } + } +} + +impl<T: Eq + Hash> ActivityRepaintBatch<T> { + pub fn queue_activity( + &mut self, + keys: impl IntoIterator<Item = T>, + promoted: impl IntoIterator<Item = T>, + ) -> ActivityRepaintDecision<T> { + self.pending.extend(keys); + if !self.scheduled && !self.pending.is_empty() { + self.scheduled = true; + return ActivityRepaintDecision { + immediate: std::mem::take(&mut self.pending), + start_timer: true, + }; + } + + let mut immediate = HashSet::new(); + for key in promoted { + if self.pending.remove(&key) { + immediate.insert(key); + } + } + ActivityRepaintDecision { + immediate, + start_timer: false, + } + } + + /// Drain one scheduled frame, or end the frame window when activity is idle. + pub fn take_scheduled(&mut self) -> Option<HashSet<T>> { + if self.pending.is_empty() { + self.scheduled = false; + None + } else { + Some(std::mem::take(&mut self.pending)) + } + } +} + +#[cfg(test)] +mod tests { + use super::ActivityRepaintBatch; + use std::collections::HashSet; + + #[test] + fn leading_edge_is_immediate_and_starts_one_timer() { + let mut batch = ActivityRepaintBatch::default(); + + let decision = batch.queue_activity(["a", "b"], []); + + assert!(decision.start_timer); + assert_eq!(decision.immediate, HashSet::from(["a", "b"])); + } + + #[test] + fn input_response_is_promoted_while_unrelated_activity_stays_batched() { + let mut batch = ActivityRepaintBatch::default(); + let leading = batch.queue_activity(["initial"], []); + assert!(leading.start_timer); + + let busy = batch.queue_activity(["input", "background"], ["input"]); + + assert!(!busy.start_timer, "promotion must not start a second timer"); + assert_eq!(busy.immediate, HashSet::from(["input"])); + assert_eq!( + batch.take_scheduled(), + Some(HashSet::from(["background"])), + "promoted ids must not repaint again on the scheduled frame" + ); + assert_eq!(batch.take_scheduled(), None, "an empty frame rearms idle"); + + let next = batch.queue_activity(["next"], []); + assert!(next.start_timer); + assert_eq!(next.immediate, HashSet::from(["next"])); + } + + #[test] + fn empty_activity_does_not_schedule() { + let mut batch = ActivityRepaintBatch::<&str>::default(); + let decision = batch.queue_activity([], []); + assert!(!decision.start_timer); + assert!(decision.immediate.is_empty()); + } +} diff --git a/crates/okena-ui/src/badge.rs b/crates/okena-ui/src/badge.rs index 1c9e6311d..021324d90 100644 --- a/crates/okena-ui/src/badge.rs +++ b/crates/okena-ui/src/badge.rs @@ -30,16 +30,17 @@ pub fn kbd(key: impl Into<SharedString>, t: &ThemeColors) -> Div { } /// Keyboard key badge + description text (e.g., `[Enter] to select`). -pub fn keyboard_hint(key: impl Into<SharedString>, description: impl Into<SharedString>, t: &ThemeColors) -> Div { - h_flex() - .gap(SPACE_XS) - .child(kbd(key, t)) - .child( - div() - .text_size(TEXT_SM) - .text_color(rgb(t.text_muted)) - .child(description.into()), - ) +pub fn keyboard_hint( + key: impl Into<SharedString>, + description: impl Into<SharedString>, + t: &ThemeColors, +) -> Div { + h_flex().gap(SPACE_XS).child(kbd(key, t)).child( + div() + .text_size(TEXT_SM) + .text_color(rgb(t.text_muted)) + .child(description.into()), + ) } /// Footer bar with a row of keyboard hints. diff --git a/crates/okena-ui/src/click_detector.rs b/crates/okena-ui/src/click_detector.rs index c38c3bd8c..a55f40b8d 100644 --- a/crates/okena-ui/src/click_detector.rs +++ b/crates/okena-ui/src/click_detector.rs @@ -69,8 +69,6 @@ impl<K: Eq + Clone> ClickDetector<K> { false } } - - } #[cfg(test)] diff --git a/crates/okena-ui/src/color_dot.rs b/crates/okena-ui/src/color_dot.rs index 0b2524404..70b09d539 100644 --- a/crates/okena-ui/src/color_dot.rs +++ b/crates/okena-ui/src/color_dot.rs @@ -18,11 +18,7 @@ use gpui::*; /// color_dot(0x4EC9B0, true) /// ``` pub fn color_dot(color: u32, hollow: bool) -> Div { - let base = div() - .flex_shrink_0() - .w(px(8.0)) - .h(px(8.0)) - .rounded(px(4.0)); + let base = div().flex_shrink_0().w(px(8.0)).h(px(8.0)).rounded(px(4.0)); if hollow { base.border_1().border_color(rgb(color)) diff --git a/crates/okena-ui/src/context_menu_backdrop.rs b/crates/okena-ui/src/context_menu_backdrop.rs index 255706734..7db7bb750 100644 --- a/crates/okena-ui/src/context_menu_backdrop.rs +++ b/crates/okena-ui/src/context_menu_backdrop.rs @@ -5,10 +5,7 @@ use gpui::*; /// Backdrop for context menus — absolute, inset-0, transparent. /// /// Closes on left-click and right-click. Caller adds `.child(deferred(anchored()...))`. -pub fn context_menu_backdrop<F>( - id: impl Into<SharedString>, - on_close: F, -) -> Stateful<Div> +pub fn context_menu_backdrop<F>(id: impl Into<SharedString>, on_close: F) -> Stateful<Div> where F: Fn(&MouseDownEvent, &mut Window, &mut App) + Clone + 'static, { diff --git a/crates/okena-ui/src/dialog_actions.rs b/crates/okena-ui/src/dialog_actions.rs index 7e074ac65..280c50140 100644 --- a/crates/okena-ui/src/dialog_actions.rs +++ b/crates/okena-ui/src/dialog_actions.rs @@ -22,12 +22,6 @@ where .flex() .gap(px(8.0)) .justify_end() - .child( - button("dialog-cancel-btn", cancel_label, t) - .on_click(on_cancel), - ) - .child( - button_primary("dialog-confirm-btn", confirm_label, t) - .on_click(on_confirm), - ) + .child(button("dialog-cancel-btn", cancel_label, t).on_click(on_cancel)) + .child(button_primary("dialog-confirm-btn", confirm_label, t).on_click(on_confirm)) } diff --git a/crates/okena-ui/src/dropdown.rs b/crates/okena-ui/src/dropdown.rs index a57af63b8..cf4eab5b5 100644 --- a/crates/okena-ui/src/dropdown.rs +++ b/crates/okena-ui/src/dropdown.rs @@ -2,10 +2,10 @@ //! //! Provides a reusable dropdown button with overlay list. -use crate::theme::{with_alpha, ThemeColors}; -use crate::tokens::{ui_text_sm, ui_text_md}; -use gpui::*; +use crate::theme::{ThemeColors, with_alpha}; +use crate::tokens::{ui_text_md, ui_text_sm}; use gpui::prelude::*; +use gpui::*; /// Create a dropdown trigger button that tracks its own bounds for overlay positioning. /// @@ -50,17 +50,17 @@ pub fn dropdown_button( pub fn dropdown_anchored_below(bounds: Bounds<Pixels>, child: impl IntoElement) -> Deferred { deferred( anchored() - .position(point(bounds.origin.x, bounds.origin.y + bounds.size.height + px(2.0))) + .position(point( + bounds.origin.x, + bounds.origin.y + bounds.size.height + px(2.0), + )) .snap_to_window() - .child(child) + .child(child), ) } /// Create a dropdown overlay container. -pub fn dropdown_overlay( - id: impl Into<SharedString>, - t: &ThemeColors, -) -> Stateful<Div> { +pub fn dropdown_overlay(id: impl Into<SharedString>, t: &ThemeColors) -> Stateful<Div> { div() .id(ElementId::Name(id.into())) .occlude() diff --git a/crates/okena-ui/src/file_icon.rs b/crates/okena-ui/src/file_icon.rs index 2fb6524ac..1d62a3fb7 100644 --- a/crates/okena-ui/src/file_icon.rs +++ b/crates/okena-ui/src/file_icon.rs @@ -20,94 +20,238 @@ fn icon_style_for(filename: &str) -> Option<FileIconStyle> { let ext = filename.rsplit('.').next().unwrap_or(""); let style = match ext.to_ascii_lowercase().as_str() { // Rust - "rs" => FileIconStyle { color: 0xf0813a, label: "rs" }, + "rs" => FileIconStyle { + color: 0xf0813a, + label: "rs", + }, // JavaScript - "js" | "mjs" | "cjs" => FileIconStyle { color: 0xd4b830, label: "js" }, + "js" | "mjs" | "cjs" => FileIconStyle { + color: 0xd4b830, + label: "js", + }, // TypeScript - "ts" => FileIconStyle { color: 0x3178c6, label: "ts" }, - "tsx" => FileIconStyle { color: 0x3178c6, label: "tx" }, - "jsx" => FileIconStyle { color: 0x51b5d0, label: "jx" }, + "ts" => FileIconStyle { + color: 0x3178c6, + label: "ts", + }, + "tsx" => FileIconStyle { + color: 0x3178c6, + label: "tx", + }, + "jsx" => FileIconStyle { + color: 0x51b5d0, + label: "jx", + }, // Component frameworks - "astro" => FileIconStyle { color: 0xff5d01, label: "as" }, - "svelte" => FileIconStyle { color: 0xff3e00, label: "sv" }, - "vue" => FileIconStyle { color: 0x42b883, label: "vu" }, + "astro" => FileIconStyle { + color: 0xff5d01, + label: "as", + }, + "svelte" => FileIconStyle { + color: 0xff3e00, + label: "sv", + }, + "vue" => FileIconStyle { + color: 0x42b883, + label: "vu", + }, // Schema / IaC / RPC - "graphql" | "gql" | "graphqls" => FileIconStyle { color: 0xe10098, label: "gq" }, - "prisma" => FileIconStyle { color: 0x5a67d8, label: "pr" }, - "tf" | "tfvars" | "hcl" => FileIconStyle { color: 0x7b42bc, label: "tf" }, - "proto" | "protobuf" => FileIconStyle { color: 0x4285f4, label: "pb" }, + "graphql" | "gql" | "graphqls" => FileIconStyle { + color: 0xe10098, + label: "gq", + }, + "prisma" => FileIconStyle { + color: 0x5a67d8, + label: "pr", + }, + "tf" | "tfvars" | "hcl" => FileIconStyle { + color: 0x7b42bc, + label: "tf", + }, + "proto" | "protobuf" => FileIconStyle { + color: 0x4285f4, + label: "pb", + }, // CSS / SCSS - "css" => FileIconStyle { color: 0x569cd6, label: "cs" }, - "scss" | "sass" => FileIconStyle { color: 0xcd6799, label: "sc" }, - "less" => FileIconStyle { color: 0x569cd6, label: "le" }, + "css" => FileIconStyle { + color: 0x569cd6, + label: "cs", + }, + "scss" | "sass" => FileIconStyle { + color: 0xcd6799, + label: "sc", + }, + "less" => FileIconStyle { + color: 0x569cd6, + label: "le", + }, // HTML - "html" | "htm" => FileIconStyle { color: 0xe44d26, label: "h" }, + "html" | "htm" => FileIconStyle { + color: 0xe44d26, + label: "h", + }, // JSON - "json" | "jsonc" => FileIconStyle { color: 0xcba63c, label: "{}" }, + "json" | "jsonc" => FileIconStyle { + color: 0xcba63c, + label: "{}", + }, // YAML / TOML - "yaml" | "yml" => FileIconStyle { color: 0xc678dd, label: "ym" }, - "toml" => FileIconStyle { color: 0x6dbfb0, label: "tm" }, + "yaml" | "yml" => FileIconStyle { + color: 0xc678dd, + label: "ym", + }, + "toml" => FileIconStyle { + color: 0x6dbfb0, + label: "tm", + }, // Markdown - "md" | "mdx" => FileIconStyle { color: 0x569cd6, label: "md" }, + "md" | "mdx" => FileIconStyle { + color: 0x569cd6, + label: "md", + }, // Python - "py" => FileIconStyle { color: 0x4b8bbe, label: "py" }, + "py" => FileIconStyle { + color: 0x4b8bbe, + label: "py", + }, // Go - "go" => FileIconStyle { color: 0x00add8, label: "go" }, + "go" => FileIconStyle { + color: 0x00add8, + label: "go", + }, // C / C++ / C# - "c" => FileIconStyle { color: 0x6295cb, label: "c" }, - "cpp" | "cc" | "cxx" => FileIconStyle { color: 0x6295cb, label: "c+" }, - "h" | "hpp" => FileIconStyle { color: 0x6295cb, label: ".h" }, - "cs" => FileIconStyle { color: 0x9b4dca, label: "c#" }, + "c" => FileIconStyle { + color: 0x6295cb, + label: "c", + }, + "cpp" | "cc" | "cxx" => FileIconStyle { + color: 0x6295cb, + label: "c+", + }, + "h" | "hpp" => FileIconStyle { + color: 0x6295cb, + label: ".h", + }, + "cs" => FileIconStyle { + color: 0x9b4dca, + label: "c#", + }, // Java / Kotlin - "java" => FileIconStyle { color: 0xe76f00, label: "jv" }, - "kt" | "kts" => FileIconStyle { color: 0x7f52ff, label: "kt" }, + "java" => FileIconStyle { + color: 0xe76f00, + label: "jv", + }, + "kt" | "kts" => FileIconStyle { + color: 0x7f52ff, + label: "kt", + }, // Ruby - "rb" => FileIconStyle { color: 0xcc342d, label: "rb" }, + "rb" => FileIconStyle { + color: 0xcc342d, + label: "rb", + }, // PHP - "php" => FileIconStyle { color: 0x8892be, label: "ph" }, + "php" => FileIconStyle { + color: 0x8892be, + label: "ph", + }, // Shell / PowerShell - "sh" | "bash" | "zsh" | "fish" => FileIconStyle { color: 0x4eaa25, label: "$" }, - "ps1" | "psm1" | "psd1" => FileIconStyle { color: 0x5391fe, label: "ps" }, + "sh" | "bash" | "zsh" | "fish" => FileIconStyle { + color: 0x4eaa25, + label: "$", + }, + "ps1" | "psm1" | "psd1" => FileIconStyle { + color: 0x5391fe, + label: "ps", + }, // SQL - "sql" => FileIconStyle { color: 0xe38c00, label: "sq" }, + "sql" => FileIconStyle { + color: 0xe38c00, + label: "sq", + }, // XML / SVG - "xml" => FileIconStyle { color: 0xe44d26, label: "<>" }, - "svg" => FileIconStyle { color: 0xffb13b, label: "sv" }, + "xml" => FileIconStyle { + color: 0xe44d26, + label: "<>", + }, + "svg" => FileIconStyle { + color: 0xffb13b, + label: "sv", + }, // Lua - "lua" => FileIconStyle { color: 0x306998, label: "lu" }, + "lua" => FileIconStyle { + color: 0x306998, + label: "lu", + }, // Swift - "swift" => FileIconStyle { color: 0xf05138, label: "sw" }, + "swift" => FileIconStyle { + color: 0xf05138, + label: "sw", + }, // Dart - "dart" => FileIconStyle { color: 0x0175c2, label: "dt" }, + "dart" => FileIconStyle { + color: 0x0175c2, + label: "dt", + }, // Zig - "zig" => FileIconStyle { color: 0xf7a41d, label: "zg" }, + "zig" => FileIconStyle { + color: 0xf7a41d, + label: "zg", + }, // R - "r" | "rmd" => FileIconStyle { color: 0x276dc3, label: "R" }, + "r" | "rmd" => FileIconStyle { + color: 0x276dc3, + label: "R", + }, // Elixir / Erlang - "ex" | "exs" => FileIconStyle { color: 0x6e4a7e, label: "ex" }, - "erl" => FileIconStyle { color: 0xa90533, label: "er" }, + "ex" | "exs" => FileIconStyle { + color: 0x6e4a7e, + label: "ex", + }, + "erl" => FileIconStyle { + color: 0xa90533, + label: "er", + }, // Nix - "nix" => FileIconStyle { color: 0x7ebae4, label: "nx" }, + "nix" => FileIconStyle { + color: 0x7ebae4, + label: "nx", + }, // Haskell - "hs" => FileIconStyle { color: 0x5e5086, label: "hs" }, + "hs" => FileIconStyle { + color: 0x5e5086, + label: "hs", + }, // Lock files - "lock" => FileIconStyle { color: 0x808080, label: "lk" }, + "lock" => FileIconStyle { + color: 0x808080, + label: "lk", + }, // Config / env - "env" | "ini" | "cfg" | "conf" | "desktop" | "service" => { - FileIconStyle { color: 0x808080, label: "cf" } - } + "env" | "ini" | "cfg" | "conf" | "desktop" | "service" => FileIconStyle { + color: 0x808080, + label: "cf", + }, // Plain text / log - "txt" | "text" | "log" => FileIconStyle { color: 0x808080, label: "tx" }, + "txt" | "text" | "log" => FileIconStyle { + color: 0x808080, + label: "tx", + }, // Archives - "gz" | "tar" | "zip" | "bz2" | "xz" | "7z" | "rar" => { - FileIconStyle { color: 0x808080, label: "ar" } - } + "gz" | "tar" | "zip" | "bz2" | "xz" | "7z" | "rar" => FileIconStyle { + color: 0x808080, + label: "ar", + }, // Images - "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "bmp" => { - FileIconStyle { color: 0x4eaa25, label: "im" } - } + "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "bmp" => FileIconStyle { + color: 0x4eaa25, + label: "im", + }, // Fonts - "ttf" | "otf" | "woff" | "woff2" => FileIconStyle { color: 0x808080, label: "ft" }, + "ttf" | "otf" | "woff" | "woff2" => FileIconStyle { + color: 0x808080, + label: "ft", + }, // Default — no match _ => return None, }; @@ -117,26 +261,57 @@ fn icon_style_for(filename: &str) -> Option<FileIconStyle> { /// Check for special filenames (case-insensitive) before falling back to extension. fn icon_style_for_special(filename: &str) -> Option<FileIconStyle> { let lower = filename.to_ascii_lowercase(); + if lower.ends_with(".test.ts") { + return Some(FileIconStyle { + color: 0x3178c6, + label: "✓", + }); + } if lower == "dockerfile" || lower.starts_with("dockerfile.") { - return Some(FileIconStyle { color: 0x2496ed, label: "dk" }); + return Some(FileIconStyle { + color: 0x2496ed, + label: "dk", + }); } if lower == "makefile" || lower == "gnumakefile" { - return Some(FileIconStyle { color: 0x4eaa25, label: "mk" }); + return Some(FileIconStyle { + color: 0x4eaa25, + label: "mk", + }); } if lower == "cargo.toml" || lower == "cargo.lock" { - return Some(FileIconStyle { color: 0xf0813a, label: "cg" }); + return Some(FileIconStyle { + color: 0xf0813a, + label: "cg", + }); } if lower == "package.json" || lower == "package-lock.json" { - return Some(FileIconStyle { color: 0xcc3534, label: "np" }); + return Some(FileIconStyle { + color: 0xcc3534, + label: "np", + }); } if lower == ".gitignore" || lower == ".gitattributes" || lower == ".gitmodules" { - return Some(FileIconStyle { color: 0xe44d26, label: "gi" }); + return Some(FileIconStyle { + color: 0xe44d26, + label: "gi", + }); } - if lower == "license" || lower == "licence" || lower.starts_with("license.") || lower.starts_with("licence.") { - return Some(FileIconStyle { color: 0xcba63c, label: "li" }); + if lower == "license" + || lower == "licence" + || lower.starts_with("license.") + || lower.starts_with("licence.") + { + return Some(FileIconStyle { + color: 0xcba63c, + label: "li", + }); } if lower == "readme" || lower.starts_with("readme.") { - return Some(FileIconStyle { color: 0x569cd6, label: "rm" }); + return Some(FileIconStyle { + color: 0x569cd6, + label: "rm", + }); } None } @@ -154,16 +329,12 @@ pub fn file_icon(filename: &str, t: &ThemeColors, cx: &App) -> Div { None => (t.text_muted, ""), }; - let container = div() - .relative() - .size(px(16.0)) - .flex_shrink_0() - .child( - svg() - .path("icons/file-filled.svg") - .size(px(16.0)) - .text_color(rgb(color)), - ); + let container = div().relative().size(px(16.0)).flex_shrink_0().child( + svg() + .path("icons/file-filled.svg") + .size(px(16.0)) + .text_color(rgb(color)), + ); if label.is_empty() { container @@ -187,3 +358,25 @@ pub fn file_icon(filename: &str, t: &ThemeColors, cx: &App) -> Div { ) } } + +#[cfg(test)] +mod tests { + use super::{icon_style_for, icon_style_for_special}; + + #[test] + fn typescript_test_file_uses_test_icon() { + let style = icon_style_for_special("file.test.ts").expect("test style"); + + assert_eq!(style.color, 0x3178c6); + assert_eq!(style.label, "✓"); + } + + #[test] + fn regular_typescript_file_keeps_typescript_icon() { + assert!(icon_style_for_special("file.ts").is_none()); + let style = icon_style_for("file.ts").expect("TypeScript style"); + + assert_eq!(style.color, 0x3178c6); + assert_eq!(style.label, "ts"); + } +} diff --git a/crates/okena-ui/src/input.rs b/crates/okena-ui/src/input.rs index 2864362b4..a610ebae0 100644 --- a/crates/okena-ui/src/input.rs +++ b/crates/okena-ui/src/input.rs @@ -1,6 +1,6 @@ //! Input field components. -use crate::theme::{with_alpha, ThemeColors}; +use crate::theme::{ThemeColors, with_alpha}; use crate::tokens::*; use gpui::*; use gpui_component::v_flex; @@ -23,14 +23,12 @@ pub fn input_container(t: &ThemeColors, focused: Option<bool>) -> Div { /// Labeled input field with a label above the input container. pub fn labeled_input(label: impl Into<SharedString>, t: &ThemeColors) -> Div { - v_flex() - .gap(SPACE_XS) - .child( - div() - .text_size(TEXT_MS) - .text_color(rgb(t.text_muted)) - .child(label.into()), - ) + v_flex().gap(SPACE_XS).child( + div() + .text_size(TEXT_MS) + .text_color(rgb(t.text_muted)) + .child(label.into()), + ) } /// Search input area with ">" prefix prompt and query/placeholder display. @@ -39,7 +37,12 @@ pub fn search_input_area(query: &str, placeholder: &str, t: &ThemeColors) -> Div } /// Search input area with optional text selection highlight. -pub fn search_input_area_selected(query: &str, placeholder: &str, selected: bool, t: &ThemeColors) -> Div { +pub fn search_input_area_selected( + query: &str, + placeholder: &str, + selected: bool, + t: &ThemeColors, +) -> Div { search_input_area_impl(query, placeholder, selected, t) } diff --git a/crates/okena-ui/src/lib.rs b/crates/okena-ui/src/lib.rs index 19752c62f..b6532ff5a 100644 --- a/crates/okena-ui/src/lib.rs +++ b/crates/okena-ui/src/lib.rs @@ -4,23 +4,22 @@ //! //! Reusable UI components, design tokens, and theme helpers for the Okena terminal. +pub mod activity_repaint; pub mod badge; pub mod button; pub mod chip; -pub mod simple_input; -pub mod text_utils; pub mod click_detector; -pub mod color_utils; -pub mod header_buttons; pub mod code_block; pub mod color_dot; +pub mod color_utils; pub mod context_menu_backdrop; pub mod dialog_actions; pub mod dropdown; pub mod empty_state; +pub mod expand; pub mod file_icon; mod focusable; -pub mod expand; +pub mod header_buttons; pub mod icon_action_button; pub mod icon_button; pub mod input; @@ -30,8 +29,12 @@ pub mod modal; pub mod overlay; pub mod popover; pub mod rename_state; +pub mod resizable_sidebar; +pub mod resize_handle; pub mod selectable_list; pub mod settings; +pub mod simple_input; +pub mod text_utils; pub mod theme; pub mod title_subtitle; pub mod toggle; diff --git a/crates/okena-ui/src/list_row.rs b/crates/okena-ui/src/list_row.rs index f16a2390b..27a2422bf 100644 --- a/crates/okena-ui/src/list_row.rs +++ b/crates/okena-ui/src/list_row.rs @@ -18,11 +18,7 @@ use gpui::*; /// .child(color_dot(0x4EC9B0, false)) /// .child(div().child("My Project")) /// ``` -pub fn list_row( - id: impl Into<ElementId>, - left_padding: f32, - t: &ThemeColors, -) -> Stateful<Div> { +pub fn list_row(id: impl Into<ElementId>, left_padding: f32, t: &ThemeColors) -> Stateful<Div> { div() .id(id) .h(px(24.0)) diff --git a/crates/okena-ui/src/menu.rs b/crates/okena-ui/src/menu.rs index 36a73c25c..d74355886 100644 --- a/crates/okena-ui/src/menu.rs +++ b/crates/okena-ui/src/menu.rs @@ -46,12 +46,7 @@ pub fn menu_item_with_color( .text_size(MENU_TEXT) .text_color(rgb(text_color)) .hover(|s| s.bg(rgb(t.bg_hover))) - .child( - svg() - .path(icon) - .size(MENU_ICON) - .text_color(rgb(icon_color)), - ) + .child(svg().path(icon).size(MENU_ICON).text_color(rgb(icon_color))) .child(label.into()) } @@ -114,13 +109,12 @@ pub fn menu_item_conditional( .rounded(RADIUS_STD) .text_size(MENU_TEXT) .text_color(rgb(text_color)) - .cursor(if enabled { CursorStyle::PointingHand } else { CursorStyle::Arrow }) - .child( - svg() - .path(icon) - .size(MENU_ICON) - .text_color(rgb(icon_color)), - ) + .cursor(if enabled { + CursorStyle::PointingHand + } else { + CursorStyle::Arrow + }) + .child(svg().path(icon).size(MENU_ICON).text_color(rgb(icon_color))) .child(label.into()); if enabled { @@ -157,9 +151,5 @@ pub fn context_menu_panel(id: impl Into<ElementId>, t: &ThemeColors) -> Stateful /// Menu separator - 1px horizontal line. pub fn menu_separator(t: &ThemeColors) -> Div { - div() - .h(px(1.0)) - .mx(SPACE_XL) - .my(SPACE_SM) - .bg(rgb(t.border)) + div().h(px(1.0)).mx(SPACE_XL).my(SPACE_SM).bg(rgb(t.border)) } diff --git a/crates/okena-ui/src/modal.rs b/crates/okena-ui/src/modal.rs index 4ccfe9754..f7bb4c81e 100644 --- a/crates/okena-ui/src/modal.rs +++ b/crates/okena-ui/src/modal.rs @@ -50,12 +50,14 @@ pub fn window_drag_spacer(enabled: bool) -> Stateful<Div> { .flex_1() .h_full() .when(enabled, |d| { - d.window_control_area(WindowControlArea::Drag) - .when(cfg!(any(target_os = "linux", target_os = "macos")), |d| { + d.window_control_area(WindowControlArea::Drag).when( + cfg!(any(target_os = "linux", target_os = "macos")), + |d| { d.on_mouse_down(MouseButton::Left, |_, window, _cx| { window.start_window_move(); }) - }) + }, + ) }) } diff --git a/crates/okena-ui/src/rename_state.rs b/crates/okena-ui/src/rename_state.rs index 800d41180..3567f8a8b 100644 --- a/crates/okena-ui/src/rename_state.rs +++ b/crates/okena-ui/src/rename_state.rs @@ -51,17 +51,18 @@ where let subscription = cx.on_blur(&focus_handle, window, on_blur); window.focus(&focus_handle, cx); - RenameState { target, input, _blur_subscription: Some(subscription) } + RenameState { + target, + input, + _blur_subscription: Some(subscription), + } } /// Finish a rename operation and get the result. /// /// Returns `Some((target, new_name))` if the rename was active and the name is not empty. /// Returns `None` if the state was `None` or the input was empty. -pub fn finish_rename<Id>( - state: &mut Option<RenameState<Id>>, - cx: &App, -) -> Option<(Id, String)> { +pub fn finish_rename<Id>(state: &mut Option<RenameState<Id>>, cx: &App) -> Option<(Id, String)> { let rename_state = state.take()?; let new_name = rename_state.value(cx); diff --git a/crates/okena-ui/src/resizable_sidebar.rs b/crates/okena-ui/src/resizable_sidebar.rs new file mode 100644 index 000000000..990fc8388 --- /dev/null +++ b/crates/okena-ui/src/resizable_sidebar.rs @@ -0,0 +1,147 @@ +use crate::resize_handle::ResizeHandle; +use gpui::*; + +pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.0; +pub const MIN_SIDEBAR_WIDTH: f32 = 150.0; +pub const MAX_SIDEBAR_WIDTH: f32 = 500.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct ResizeDrag { + start_x: f32, + start_width: f32, +} + +/// Width and transient drag state shared by resizable sidebars. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResizableSidebarState { + width: f32, + drag: Option<ResizeDrag>, +} + +impl Default for ResizableSidebarState { + fn default() -> Self { + Self { + width: DEFAULT_SIDEBAR_WIDTH, + drag: None, + } + } +} + +impl ResizableSidebarState { + pub fn width(&self) -> f32 { + self.width + } + + pub fn start_resize(&mut self, mouse_x: f32) { + self.drag = Some(ResizeDrag { + start_x: mouse_x, + start_width: self.width, + }); + } + + /// Update the width from the active drag. Returns whether it changed. + pub fn update_resize(&mut self, mouse_x: f32) -> bool { + let Some(drag) = self.drag else { + return false; + }; + let width = + (drag.start_width + mouse_x - drag.start_x).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); + if width == self.width { + return false; + } + self.width = width; + true + } + + pub fn end_resize(&mut self) { + self.drag = None; + } +} + +/// Render a fixed-width sidebar and its vertical resize divider. +pub fn resizable_sidebar( + width: f32, + background_color: u32, + border_color: u32, + border_active_color: u32, + children: Vec<AnyElement>, + on_drag_start: impl FnOnce(Point<Pixels>, &mut App) + 'static, + on_drag_end: impl FnOnce(&mut App) + 'static, +) -> Div { + div() + .relative() + .h_full() + .flex() + .flex_shrink_0() + .child( + div() + .w(px(width)) + .h_full() + .bg(rgb(background_color)) + .flex() + .flex_col() + .children(children), + ) + .child(ResizeHandle::new( + false, + border_color, + border_active_color, + on_drag_start, + )) + // Window-level mouse-up survives the divider's blocking hitbox. + .child( + canvas( + |_bounds, _window, _cx| {}, + move |_bounds, _state, window, _cx| { + let mut on_drag_end = Some(on_drag_end); + window.on_mouse_event(move |event: &MouseUpEvent, phase, _window, cx| { + if phase == DispatchPhase::Bubble + && event.button == MouseButton::Left + && let Some(callback) = on_drag_end.take() + { + callback(cx); + } + }); + }, + ) + .absolute() + .size_full(), + ) +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, ResizableSidebarState, + }; + + #[test] + fn resize_tracks_mouse_from_initial_width() { + let mut state = ResizableSidebarState::default(); + state.start_resize(100.0); + + assert!(state.update_resize(145.0)); + assert_eq!(state.width(), DEFAULT_SIDEBAR_WIDTH + 45.0); + } + + #[test] + fn resize_clamps_to_bounds() { + let mut state = ResizableSidebarState::default(); + state.start_resize(100.0); + + assert!(state.update_resize(-1_000.0)); + assert_eq!(state.width(), MIN_SIDEBAR_WIDTH); + assert!(state.update_resize(1_000.0)); + assert_eq!(state.width(), MAX_SIDEBAR_WIDTH); + } + + #[test] + fn resize_stops_after_mouse_up() { + let mut state = ResizableSidebarState::default(); + state.start_resize(100.0); + state.end_resize(); + + assert!(!state.update_resize(200.0)); + assert_eq!(state.width(), DEFAULT_SIDEBAR_WIDTH); + } +} diff --git a/crates/okena-ui/src/resize_handle.rs b/crates/okena-ui/src/resize_handle.rs new file mode 100644 index 000000000..c4c9976aa --- /dev/null +++ b/crates/okena-ui/src/resize_handle.rs @@ -0,0 +1,148 @@ +use gpui::*; +use std::cell::RefCell; +use std::rc::Rc; + +const DIVIDER_SIZE: f32 = 1.0; +const HANDLE_HITBOX_SIZE: f32 = 9.0; + +type DragStartCallback = Rc<RefCell<Option<Box<dyn FnOnce(Point<Pixels>, &mut App)>>>>; + +/// Thin divider with a larger resize hitbox and the matching resize cursor. +pub struct ResizeHandle { + is_horizontal: bool, + border_color: u32, + border_active_color: u32, + on_drag_start: DragStartCallback, +} + +impl ResizeHandle { + pub fn new( + is_horizontal: bool, + border_color: u32, + border_active_color: u32, + on_drag_start: impl FnOnce(Point<Pixels>, &mut App) + 'static, + ) -> Self { + Self { + is_horizontal, + border_color, + border_active_color, + on_drag_start: Rc::new(RefCell::new(Some(Box::new(on_drag_start)))), + } + } +} + +impl IntoElement for ResizeHandle { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizeHandle { + type RequestLayoutState = (); + type PrepaintState = Hitbox; + + fn id(&self) -> Option<ElementId> { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let style = if self.is_horizontal { + Style { + size: Size { + width: relative(1.0).into(), + height: px(DIVIDER_SIZE).into(), + }, + flex_shrink: 0.0, + ..Default::default() + } + } else { + Style { + size: Size { + width: px(DIVIDER_SIZE).into(), + height: relative(1.0).into(), + }, + flex_shrink: 0.0, + ..Default::default() + } + }; + + let layout_id = window.request_layout(style, [], cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _state: &mut Self::RequestLayoutState, + window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + let expand = px((HANDLE_HITBOX_SIZE - DIVIDER_SIZE) / 2.0); + let hitbox_bounds = if self.is_horizontal { + Bounds::new( + point(bounds.origin.x, bounds.origin.y - expand), + size(bounds.size.width, px(HANDLE_HITBOX_SIZE)), + ) + } else { + Bounds::new( + point(bounds.origin.x - expand, bounds.origin.y), + size(px(HANDLE_HITBOX_SIZE), bounds.size.height), + ) + }; + + window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _state: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + let color = if hitbox.is_hovered(window) { + rgb(self.border_active_color) + } else { + rgb(self.border_color) + }; + window.paint_quad(fill(bounds, color)); + + let cursor = if self.is_horizontal { + CursorStyle::ResizeUpDown + } else { + CursorStyle::ResizeLeftRight + }; + window.set_cursor_style(cursor, hitbox); + + let on_drag_start = self.on_drag_start.clone(); + let hitbox_id = hitbox.id; + window.on_mouse_event(move |e: &MouseDownEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble + && e.button == MouseButton::Left + && hitbox_id.is_hovered(window) + { + if let Some(cb) = on_drag_start.borrow_mut().take() { + cb(e.position, cx); + } + cx.stop_propagation(); + } + }); + } +} diff --git a/crates/okena-ui/src/settings.rs b/crates/okena-ui/src/settings.rs index 58bed6800..1de2cf854 100644 --- a/crates/okena-ui/src/settings.rs +++ b/crates/okena-ui/src/settings.rs @@ -1,7 +1,7 @@ //! Settings panel components. use crate::theme::ThemeColors; -use crate::tokens::{ui_text, ui_text_sm, ui_text_ms, ui_text_xl}; +use crate::tokens::{ui_text, ui_text_ms, ui_text_sm, ui_text_xl}; use gpui::*; use gpui_component::v_flex; @@ -28,7 +28,13 @@ pub fn section_container(t: &ThemeColors) -> Div { } /// Render a settings row container. -pub fn settings_row(id: impl Into<SharedString>, label: &str, t: &ThemeColors, cx: &App, has_border: bool) -> Stateful<Div> { +pub fn settings_row( + id: impl Into<SharedString>, + label: &str, + t: &ThemeColors, + cx: &App, + has_border: bool, +) -> Stateful<Div> { let row = div() .id(ElementId::Name(id.into())) .px(px(12.0)) @@ -51,7 +57,14 @@ pub fn settings_row(id: impl Into<SharedString>, label: &str, t: &ThemeColors, c } /// Render a settings row with label and description. -pub fn settings_row_with_desc(id: impl Into<SharedString>, label: &str, desc: &str, t: &ThemeColors, cx: &App, has_border: bool) -> Stateful<Div> { +pub fn settings_row_with_desc( + id: impl Into<SharedString>, + label: &str, + desc: &str, + t: &ThemeColors, + cx: &App, + has_border: bool, +) -> Stateful<Div> { let row = div() .id(ElementId::Name(id.into())) .px(px(12.0)) @@ -84,7 +97,12 @@ pub fn settings_row_with_desc(id: impl Into<SharedString>, label: &str, desc: &s } /// Render a +/- stepper button. -pub fn stepper_button(id: impl Into<SharedString>, label: &str, t: &ThemeColors, cx: &App) -> Stateful<Div> { +pub fn stepper_button( + id: impl Into<SharedString>, + label: &str, + t: &ThemeColors, + cx: &App, +) -> Stateful<Div> { div() .id(ElementId::Name(id.into())) .cursor_pointer() diff --git a/crates/okena-ui/src/simple_input.rs b/crates/okena-ui/src/simple_input.rs index a3a3ddfc7..a287925ad 100644 --- a/crates/okena-ui/src/simple_input.rs +++ b/crates/okena-ui/src/simple_input.rs @@ -1,5 +1,5 @@ -use crate::theme::theme; use crate::text_utils::find_word_boundaries; +use crate::theme::theme; use gpui::prelude::*; use gpui::*; @@ -160,11 +160,7 @@ impl SimpleInputState { /// Run a delete operation: clears selection if present, otherwise calls `delete_fn`, /// then emits `InputChangedEvent` only if the value actually changed. - fn delete_with( - &mut self, - delete_fn: impl FnOnce(&mut Self), - cx: &mut Context<Self>, - ) { + fn delete_with(&mut self, delete_fn: impl FnOnce(&mut Self), cx: &mut Context<Self>) { let old_len = self.value.len(); if let Some(range) = self.selection.take() { self.value @@ -181,67 +177,85 @@ impl SimpleInputState { } fn delete_backward(&mut self, cx: &mut Context<Self>) { - self.delete_with(|this| { - if this.cursor_position > 0 { - let prev_pos = this.cursor_position - 1; - let byte_range = this.byte_range_for_chars(&(prev_pos..this.cursor_position)); - this.value.replace_range(byte_range, ""); - this.cursor_position = prev_pos; - } - }, cx); + self.delete_with( + |this| { + if this.cursor_position > 0 { + let prev_pos = this.cursor_position - 1; + let byte_range = this.byte_range_for_chars(&(prev_pos..this.cursor_position)); + this.value.replace_range(byte_range, ""); + this.cursor_position = prev_pos; + } + }, + cx, + ); } fn delete_forward(&mut self, cx: &mut Context<Self>) { - self.delete_with(|this| { - let char_count = this.value.chars().count(); - if this.cursor_position < char_count { - let next_pos = this.cursor_position + 1; - let byte_range = this.byte_range_for_chars(&(this.cursor_position..next_pos)); - this.value.replace_range(byte_range, ""); - } - }, cx); + self.delete_with( + |this| { + let char_count = this.value.chars().count(); + if this.cursor_position < char_count { + let next_pos = this.cursor_position + 1; + let byte_range = this.byte_range_for_chars(&(this.cursor_position..next_pos)); + this.value.replace_range(byte_range, ""); + } + }, + cx, + ); } fn delete_word_backward(&mut self, cx: &mut Context<Self>) { - self.delete_with(|this| { - if this.cursor_position > 0 { - let pos = this.word_boundary_left(); - let byte_range = this.byte_range_for_chars(&(pos..this.cursor_position)); - this.value.replace_range(byte_range, ""); - this.cursor_position = pos; - } - }, cx); + self.delete_with( + |this| { + if this.cursor_position > 0 { + let pos = this.word_boundary_left(); + let byte_range = this.byte_range_for_chars(&(pos..this.cursor_position)); + this.value.replace_range(byte_range, ""); + this.cursor_position = pos; + } + }, + cx, + ); } fn delete_word_forward(&mut self, cx: &mut Context<Self>) { - self.delete_with(|this| { - let chars_len = this.value.chars().count(); - if this.cursor_position < chars_len { - let pos = this.word_boundary_right(); - let byte_range = this.byte_range_for_chars(&(this.cursor_position..pos)); - this.value.replace_range(byte_range, ""); - } - }, cx); + self.delete_with( + |this| { + let chars_len = this.value.chars().count(); + if this.cursor_position < chars_len { + let pos = this.word_boundary_right(); + let byte_range = this.byte_range_for_chars(&(this.cursor_position..pos)); + this.value.replace_range(byte_range, ""); + } + }, + cx, + ); } fn delete_to_start(&mut self, cx: &mut Context<Self>) { - self.delete_with(|this| { - if this.cursor_position > 0 { - let byte_range = this.byte_range_for_chars(&(0..this.cursor_position)); - this.value.replace_range(byte_range, ""); - this.cursor_position = 0; - } - }, cx); + self.delete_with( + |this| { + if this.cursor_position > 0 { + let byte_range = this.byte_range_for_chars(&(0..this.cursor_position)); + this.value.replace_range(byte_range, ""); + this.cursor_position = 0; + } + }, + cx, + ); } fn delete_to_end(&mut self, cx: &mut Context<Self>) { - self.delete_with(|this| { - let char_count = this.value.chars().count(); - if this.cursor_position < char_count { - let byte_range = this.byte_range_for_chars(&(this.cursor_position..char_count)); - this.value.replace_range(byte_range, ""); - } - }, cx); + self.delete_with( + |this| { + let char_count = this.value.chars().count(); + if this.cursor_position < char_count { + let byte_range = this.byte_range_for_chars(&(this.cursor_position..char_count)); + this.value.replace_range(byte_range, ""); + } + }, + cx, + ); } fn move_cursor_left(&mut self, extend_selection: bool, cx: &mut Context<Self>) { @@ -441,7 +455,9 @@ impl SimpleInputState { let byte_pos = self.byte_position_for_char(self.cursor_position); let before = &self.value[..byte_pos]; let line_idx = before.matches('\n').count(); - let col_bytes = before.rfind('\n').map_or(before.len(), |i| before.len() - i - 1); + let col_bytes = before + .rfind('\n') + .map_or(before.len(), |i| before.len() - i - 1); let col_chars = before[before.len() - col_bytes..].chars().count(); (line_idx, col_chars) } @@ -639,19 +655,20 @@ impl SimpleInputState { } "v" if modifiers.platform || modifiers.control => { if let Some(clipboard_item) = cx.read_from_clipboard() - && let Some(text) = clipboard_item.text() { - if self.multiline { - if !text.is_empty() { - self.insert_text(&text, cx); - } - } else { - // Only insert first line (no newlines in single-line input) - let line = text.lines().next().unwrap_or(""); - if !line.is_empty() { - self.insert_text(line, cx); - } + && let Some(text) = clipboard_item.text() + { + if self.multiline { + if !text.is_empty() { + self.insert_text(&text, cx); + } + } else { + // Only insert first line (no newlines in single-line input) + let line = text.lines().next().unwrap_or(""); + if !line.is_empty() { + self.insert_text(line, cx); } } + } return KeyHandled::Handled; } "c" if modifiers.platform || modifiers.control => { @@ -700,9 +717,9 @@ impl SimpleInputState { return KeyHandled::Handled; } // Skip modifier-only and function keys - "shift" | "control" | "alt" | "meta" | "capslock" - | "f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" - | "f12" | "up" | "down" | "pageup" | "pagedown" => { + "shift" | "control" | "alt" | "meta" | "capslock" | "f1" | "f2" | "f3" | "f4" + | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12" | "up" | "down" + | "pageup" | "pagedown" => { return KeyHandled::Ignored; } _ => {} @@ -719,7 +736,6 @@ impl SimpleInputState { KeyHandled::Ignored } - } /// Whether a character is a "word" character (alphanumeric or underscore). @@ -736,16 +752,17 @@ fn var_segments(text: &str) -> Vec<(&str, bool)> { while i < bytes.len() { if bytes[i] == b'{' - && let Some(close) = text[i..].find('}') { - if i > last_end { - segments.push((&text[last_end..i], false)); - } - let end = i + close + 1; - segments.push((&text[i..end], true)); - last_end = end; - i = end; - continue; + && let Some(close) = text[i..].find('}') + { + if i > last_end { + segments.push((&text[last_end..i], false)); } + let end = i + close + 1; + segments.push((&text[i..end], true)); + last_end = end; + i = end; + continue; + } i += 1; } if last_end < text.len() { @@ -784,10 +801,13 @@ impl Render for SimpleInputState { for (seg, is_var) in var_segments(&placeholder) { if is_var { let start = seg.as_ptr() as usize - placeholder.as_ptr() as usize; - highlights.push((start..start + seg.len(), HighlightStyle { - color: Some(rgb(var_color).into()), - ..Default::default() - })); + highlights.push(( + start..start + seg.len(), + HighlightStyle { + color: Some(rgb(var_color).into()), + ..Default::default() + }, + )); } } div() @@ -809,7 +829,11 @@ impl Render for SimpleInputState { let mut byte_offset = 0; for (line_idx, line_text) in lines.iter().enumerate() { - let display = if line_text.is_empty() { "\u{200B}".to_string() } else { line_text.to_string() }; + let display = if line_text.is_empty() { + "\u{200B}".to_string() + } else { + line_text.to_string() + }; let line_char_count = line_text.chars().count(); // Compute per-line selection highlights @@ -818,15 +842,29 @@ impl Render for SimpleInputState { let line_end = line_start + line_char_count; let sel_start_in_line = sel.start.max(line_start).saturating_sub(line_start); let sel_end_in_line = sel.end.min(line_end).saturating_sub(line_start); - if sel_start_in_line < sel_end_in_line && sel.end > line_start && sel.start < line_end { + if sel_start_in_line < sel_end_in_line + && sel.end > line_start + && sel.start < line_end + { // Compute byte offsets within this line's text - let sel_start_byte: usize = line_text.char_indices().nth(sel_start_in_line).map(|(i, _)| i).unwrap_or(line_text.len()); - let sel_end_byte: usize = line_text.char_indices().nth(sel_end_in_line).map(|(i, _)| i).unwrap_or(line_text.len()); - let highlights = vec![(sel_start_byte..sel_end_byte, HighlightStyle { - background_color: Some(rgb(t.selection_bg).into()), - color: Some(rgb(t.selection_fg).into()), - ..Default::default() - })]; + let sel_start_byte: usize = line_text + .char_indices() + .nth(sel_start_in_line) + .map(|(i, _)| i) + .unwrap_or(line_text.len()); + let sel_end_byte: usize = line_text + .char_indices() + .nth(sel_end_in_line) + .map(|(i, _)| i) + .unwrap_or(line_text.len()); + let highlights = vec![( + sel_start_byte..sel_end_byte, + HighlightStyle { + background_color: Some(rgb(t.selection_bg).into()), + color: Some(rgb(t.selection_fg).into()), + ..Default::default() + }, + )]; StyledText::new(display).with_highlights(highlights) } else { StyledText::new(display) @@ -840,15 +878,17 @@ impl Render for SimpleInputState { let local_cursor_byte = cursor_byte.saturating_sub(byte_offset); let is_cursor_line = line_idx == cursor_line; - container = container.child( - div() - .relative() - .min_h(px(18.0)) - .child(styled) - .when(is_cursor_line, |d| { - d.child(cursor_canvas(layout, local_cursor_byte, cursor_visible, cursor_color)) - }) - ); + container = container.child(div().relative().min_h(px(18.0)).child(styled).when( + is_cursor_line, + |d| { + d.child(cursor_canvas( + layout, + local_cursor_byte, + cursor_visible, + cursor_color, + )) + }, + )); byte_offset += line_text.len() + 1; // +1 for '\n' } @@ -858,21 +898,27 @@ impl Render for SimpleInputState { let styled = if let Some(ref sel) = selection { let sel_start_byte = self.byte_position_for_char(sel.start); let sel_end_byte = self.byte_position_for_char(sel.end); - let highlights = vec![(sel_start_byte..sel_end_byte, HighlightStyle { - background_color: Some(rgb(t.selection_bg).into()), - color: Some(rgb(t.selection_fg).into()), - ..Default::default() - })]; + let highlights = vec![( + sel_start_byte..sel_end_byte, + HighlightStyle { + background_color: Some(rgb(t.selection_bg).into()), + color: Some(rgb(t.selection_fg).into()), + ..Default::default() + }, + )]; StyledText::new(value.clone()).with_highlights(highlights) } else if highlight_vars { let mut highlights = Vec::new(); for (seg, is_var) in var_segments(&value) { if is_var { let start = seg.as_ptr() as usize - value.as_ptr() as usize; - highlights.push((start..start + seg.len(), HighlightStyle { - color: Some(rgb(var_color).into()), - ..Default::default() - })); + highlights.push(( + start..start + seg.len(), + HighlightStyle { + color: Some(rgb(var_color).into()), + ..Default::default() + }, + )); } } StyledText::new(value.clone()).with_highlights(highlights) @@ -887,7 +933,12 @@ impl Render for SimpleInputState { .relative() .text_color(cursor_color) .child(styled) - .child(cursor_canvas(layout, cursor_byte, cursor_visible, cursor_color)) + .child(cursor_canvas( + layout, + cursor_byte, + cursor_visible, + cursor_color, + )) .into_any_element() }; @@ -904,38 +955,48 @@ impl Render for SimpleInputState { .when(!multiline, |d| d.h(px(24.0))) .px(px(8.0)) .cursor_text() - .child(canvas({ - let entity = cx.entity().downgrade(); - move |bounds, _, cx: &mut App| { - if let Some(entity) = entity.upgrade() { - entity.update(cx, |this, _| { - this.input_bounds = Some(bounds); - }); - } - } - }, |_, _, _, _| {}).absolute().size_full()) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { - this.focus(window, cx); - let pos = this.char_position_for_mouse(event.position); + .child( + canvas( + { + let entity = cx.entity().downgrade(); + move |bounds, _, cx: &mut App| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, _| { + this.input_bounds = Some(bounds); + }); + } + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + this.focus(window, cx); + let pos = this.char_position_for_mouse(event.position); - if event.click_count >= 3 { - // Triple-click: select line (multiline) or all - this.is_selecting = false; - this.select_line_at(pos, cx); - } else if event.click_count == 2 { - // Double-click: select word - this.is_selecting = false; - this.select_word_at(pos, cx); - } else { - // Single click: position cursor, start drag selection - this.cursor_position = pos; - this.selection = None; - this.is_selecting = true; - this.select_anchor = pos; - this.reset_cursor_blink(); - cx.notify(); - } - })) + if event.click_count >= 3 { + // Triple-click: select line (multiline) or all + this.is_selecting = false; + this.select_line_at(pos, cx); + } else if event.click_count == 2 { + // Double-click: select word + this.is_selecting = false; + this.select_word_at(pos, cx); + } else { + // Single click: position cursor, start drag selection + this.cursor_position = pos; + this.selection = None; + this.is_selecting = true; + this.select_anchor = pos; + this.reset_cursor_blink(); + cx.notify(); + } + }), + ) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { if this.is_selecting { if event.pressed_button != Some(MouseButton::Left) { @@ -954,9 +1015,12 @@ impl Render for SimpleInputState { cx.notify(); } })) - .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, _cx| { - this.is_selecting = false; - })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, _cx| { + this.is_selecting = false; + }), + ) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { if this.handle_key_down(event, cx) == KeyHandled::Handled { cx.stop_propagation(); @@ -968,7 +1032,7 @@ impl Render for SimpleInputState { svg() .path(icon_path) .size(px(12.0)) - .text_color(rgb(t.text_muted)) + .text_color(rgb(t.text_muted)), ) }) .child(content) @@ -994,16 +1058,12 @@ fn cursor_canvas( }, // Paint: draw the 1px cursor line, vertically centered within the text line move |_bounds, (cursor_pos, line_h), window, _cx| { - if visible - && let Some(pos) = cursor_pos { - let cursor_h = px(14.0).min(line_h); - let y_offset = (line_h - cursor_h) * 0.5; - let adjusted = point(pos.x, pos.y + y_offset); - window.paint_quad(fill( - Bounds::new(adjusted, size(px(1.0), cursor_h)), - color, - )); - } + if visible && let Some(pos) = cursor_pos { + let cursor_h = px(14.0).min(line_h); + let y_offset = (line_h - cursor_h) * 0.5; + let adjusted = point(pos.x, pos.y + y_offset); + window.paint_quad(fill(Bounds::new(adjusted, size(px(1.0), cursor_h)), color)); + } }, ) .absolute() diff --git a/crates/okena-ui/src/theme.rs b/crates/okena-ui/src/theme.rs index 8f0716ed6..c4d3214b3 100644 --- a/crates/okena-ui/src/theme.rs +++ b/crates/okena-ui/src/theme.rs @@ -1,8 +1,17 @@ //! Theme helpers — re-exported from okena-theme. pub use okena_theme::{ + DARK_THEME, // Core types (via okena-theme which re-exports from okena-core) - FolderColor, ThemeColors, ThemeInfo, ThemeMode, - DARK_THEME, HIGH_CONTRAST_THEME, LIGHT_THEME, PASTEL_DARK_THEME, + FolderColor, + GlobalThemeProvider, + HIGH_CONTRAST_THEME, + LIGHT_THEME, + PASTEL_DARK_THEME, + ThemeColors, + ThemeInfo, + ThemeMode, + ansi_to_hsla, + theme, // GPUI helpers - with_alpha, ansi_to_hsla, GlobalThemeProvider, theme, + with_alpha, }; diff --git a/crates/okena-ui/src/toggle.rs b/crates/okena-ui/src/toggle.rs index a10ea73a9..24d0f56f7 100644 --- a/crates/okena-ui/src/toggle.rs +++ b/crates/okena-ui/src/toggle.rs @@ -52,7 +52,11 @@ pub fn toggle_switch(id: impl Into<SharedString>, enabled: bool, t: &ThemeColors .w(px(40.0)) .h(px(22.0)) .rounded(px(11.0)) - .bg(if enabled { rgb(t.border_active) } else { rgb(t.bg_secondary) }) + .bg(if enabled { + rgb(t.border_active) + } else { + rgb(t.bg_secondary) + }) .flex() .items_center() .child( diff --git a/crates/okena-ui/src/tokens.rs b/crates/okena-ui/src/tokens.rs index 321ca2697..b12be9d96 100644 --- a/crates/okena-ui/src/tokens.rs +++ b/crates/okena-ui/src/tokens.rs @@ -3,7 +3,7 @@ //! This module defines named constants for common UI values to ensure //! consistency across the application and make global adjustments easier. -use gpui::{px, App, Global}; +use gpui::{App, Global, px}; // ============================================================================= // Global UI font size provider diff --git a/crates/okena-usage/src/lib.rs b/crates/okena-usage/src/lib.rs index 230cbdac3..dab3d922a 100644 --- a/crates/okena-usage/src/lib.rs +++ b/crates/okena-usage/src/lib.rs @@ -223,9 +223,7 @@ pub fn reset_aligned_segments( && (2..=8).contains(&day_units); // If no working day lands in the window, `working_day_reshape` returns // `None` and we fall through to the plain grid below. - if remap - && let Some(seg) = working_day_reshape(start_epoch, reset_epoch, working, now_epoch) - { + if remap && let Some(seg) = working_day_reshape(start_epoch, reset_epoch, working, now_epoch) { return seg; } @@ -268,8 +266,7 @@ pub fn reset_aligned_segments( .collect(); let epochs: Vec<f64> = bounds.iter().map(epoch_of).collect(); let current = epochs.windows(2).find_map(|w| { - (now_epoch >= w[0] && now_epoch < w[1]) - .then(|| (frac(w[0]).max(0.0), frac(w[1]).min(1.0))) + (now_epoch >= w[0] && now_epoch < w[1]).then(|| (frac(w[0]).max(0.0), frac(w[1]).min(1.0))) }); Segments { dividers, @@ -515,7 +512,9 @@ pub fn usage_popover_header( .on_click(move |_, _, _cx| { okena_core::process::open_url(settings_url); }) - .tooltip(move |window, cx| Tooltip::new(settings_tooltip).build(window, cx)), + .tooltip(move |window, cx| { + Tooltip::new(settings_tooltip).build(window, cx) + }), ), ) } @@ -563,9 +562,7 @@ pub fn render_usage_row( working: WorkingDays, ) -> impl IntoElement { let seg = match (row.unit, row.reset_epoch) { - (Some(unit), Some(reset)) => { - reset_aligned_segments(reset, row.period_secs, unit, working) - } + (Some(unit), Some(reset)) => reset_aligned_segments(reset, row.period_secs, unit, working), _ => Segments::default(), }; // Working-day reshaping overrides the linear pace value when active. @@ -1018,7 +1015,12 @@ mod tests { fn all_days_keeps_calendar_grid() { // All days selected → no reshaping → linear grid, no working-time override. let period = 7.0 * 86_400.0; - let seg = reset_aligned_segments(1_700_000_000.0, period, SegmentUnit::Day, WorkingDays::all()); + let seg = reset_aligned_segments( + 1_700_000_000.0, + period, + SegmentUnit::Day, + WorkingDays::all(), + ); assert!(seg.time_pct.is_none(), "all-days must not override pace"); assert!( (5..=7).contains(&seg.dividers.len()), @@ -1038,13 +1040,24 @@ mod tests { days: [true, true, true, true, true, false, false], }; let seg = reset_aligned_segments(1_700_000_000.0, period, SegmentUnit::Day, mon_fri); - assert_eq!(seg.dividers.len(), 4, "5 blocks → 4 dividers, got {:?}", seg.dividers); + assert_eq!( + seg.dividers.len(), + 4, + "5 blocks → 4 dividers, got {:?}", + seg.dividers + ); // Equal blocks at 1/5, 2/5, 3/5, 4/5. for (i, &d) in seg.dividers.iter().enumerate() { let expected = (i + 1) as f32 / 5.0; - assert!((d - expected).abs() < 1e-4, "divider {i} = {d}, expected {expected}"); + assert!( + (d - expected).abs() < 1e-4, + "divider {i} = {d}, expected {expected}" + ); } - assert!(seg.time_pct.is_some(), "reshaping must provide a working-time pace"); + assert!( + seg.time_pct.is_some(), + "reshaping must provide a working-time pace" + ); let tp = seg.time_pct.unwrap(); assert!((0.0..=100.0).contains(&tp)); } @@ -1064,7 +1077,9 @@ mod tests { }; let seg = working_day_reshape(start, reset, mon_fri, now).expect("reshape"); assert_eq!(seg.dividers, vec![0.2, 0.4, 0.6, 0.8], "5 equal blocks"); - let (cs, ce) = seg.current.expect("Thursday is a working day → highlighted"); + let (cs, ce) = seg + .current + .expect("Thursday is a working day → highlighted"); assert!( (cs - 0.6).abs() < 1e-4 && (ce - 0.8).abs() < 1e-4, "Thursday must be the 4th block, got ({cs}, {ce})" @@ -1094,7 +1109,8 @@ mod tests { let mon_fri = WorkingDays { days: [true, true, true, true, true, false, false], }; - let seg = reset_aligned_segments(1_700_000_000.0, 5.0 * 3_600.0, SegmentUnit::Hour, mon_fri); + let seg = + reset_aligned_segments(1_700_000_000.0, 5.0 * 3_600.0, SegmentUnit::Hour, mon_fri); assert!(seg.time_pct.is_none(), "hour windows keep linear pace"); } @@ -1102,7 +1118,8 @@ mod tests { fn guards_reject_bad_input() { let seg = reset_aligned_segments(0.0, 7.0 * 86_400.0, SegmentUnit::Day, WorkingDays::all()); assert!(seg.dividers.is_empty() && seg.current.is_none()); - let seg = reset_aligned_segments(1_700_000_000.0, 0.0, SegmentUnit::Day, WorkingDays::all()); + let seg = + reset_aligned_segments(1_700_000_000.0, 0.0, SegmentUnit::Day, WorkingDays::all()); assert!(seg.dividers.is_empty() && seg.current.is_none()); } @@ -1121,10 +1138,16 @@ mod tests { fn divider_overlay_contrasts_background() { // Dark track (neumie-contrast bg_secondary) → light line. let on_dark = divider_overlay(0x252526); - assert!(on_dark.r > 0.5 && on_dark.a > 0.0, "dark bg → light divider"); + assert!( + on_dark.r > 0.5 && on_dark.a > 0.0, + "dark bg → light divider" + ); // Pale fill (neumie-contrast metric_normal/warning) → dark line. let on_pale = divider_overlay(0xa0a0a0); - assert!(on_pale.r < 0.5 && on_pale.a > 0.0, "light bg → dark divider"); + assert!( + on_pale.r < 0.5 && on_pale.a > 0.0, + "light bg → dark divider" + ); // Bright yellow fill → still a dark line (the earlier complaint). let on_yellow = divider_overlay(0xe5e510); assert!(on_yellow.r < 0.5, "bright bg → dark divider"); diff --git a/crates/okena-views-git/src/blame.rs b/crates/okena-views-git/src/blame.rs index 7f4270e41..9562e03c9 100644 --- a/crates/okena-views-git/src/blame.rs +++ b/crates/okena-views-git/src/blame.rs @@ -1,79 +1,28 @@ -//! `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<Vec<BlameLine>, 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<WireBlameLine>` (no `Arc` sharing); this /// impl re-dedups commits client-side so the rendered gutter keeps one /// `Arc<BlameCommit>` per unique hash. pub struct RemoteBlameProvider { - host: String, - port: u16, - token: String, + client: okena_transport::remote_action::RemoteActionClient, 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( + client: okena_transport::remote_action::RemoteActionClient, + project_id: String, + ) -> Self { + Self { client, project_id } } } @@ -90,13 +39,10 @@ impl BlameProvider for RemoteBlameProvider { project_id: self.project_id.clone(), relative_path: relative_path.to_string(), }; - let value = okena_transport::remote_action::post_action( - &self.host, - self.port, - &self.token, - action, - ) - .map_err(BlameError::Backend)?; + let value = self + .client + .post_action(action) + .map_err(BlameError::Backend)?; let Some(value) = value else { return Ok(Vec::new()); diff --git a/crates/okena-views-git/src/close_worktree_dialog.rs b/crates/okena-views-git/src/close_worktree_dialog.rs index a0f9b16f4..c37ecfbd2 100644 --- a/crates/okena-views-git/src/close_worktree_dialog.rs +++ b/crates/okena-views-git/src/close_worktree_dialog.rs @@ -5,13 +5,12 @@ //! `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; +use serde::Deserialize; mod execute; mod view; @@ -26,34 +25,46 @@ pub enum CloseWorktreeDialogEvent { impl EventEmitter<CloseWorktreeDialogEvent> for CloseWorktreeDialog {} impl okena_ui::overlay::CloseEvent for CloseWorktreeDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Closed) } + 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, +} + +#[derive(Deserialize)] +struct CloseInfo { + is_dirty: bool, + branch: Option<String>, + default_branch: Option<String>, + unpushed_count: usize, } /// Confirmation dialog shown when closing a worktree. /// Checks for dirty state and optionally merges the branch back. pub struct CloseWorktreeDialog { + pub(super) client: okena_transport::remote_action::RemoteActionClient, + pub(super) daemon_project_id: String, + /// Client-side (prefixed) project id + handle to the client workspace, used + /// to optimistically mark the project "closing" while the daemon runs the + /// before_remove hook and removal, so the sidebar row shows a busy state. pub(super) workspace: Entity<Workspace>, - pub(super) focus_manager: Entity<okena_workspace::focus::FocusManager>, + pub(super) client_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<String>, pub(super) default_branch: Option<String>, - pub(super) main_repo_path: Option<String>, pub(super) is_dirty: bool, pub(super) merge_enabled: bool, pub(super) stash_enabled: bool, @@ -61,18 +72,25 @@ pub struct CloseWorktreeDialog { pub(super) delete_branch_enabled: bool, pub(super) push_enabled: bool, pub(super) unpushed_count: usize, + pub(super) loading_info: bool, pub(super) error_message: Option<String>, pub(super) processing: ProcessingState, - pub(super) hooks_config: HooksConfig, } impl CloseWorktreeDialog { + #[allow(clippy::too_many_arguments)] pub fn new( + client: okena_transport::remote_action::RemoteActionClient, + daemon_project_id: String, workspace: Entity<Workspace>, - focus_manager: Entity<okena_workspace::focus::FocusManager>, + // 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<okena_workspace::focus::FocusManager>, 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>, ) -> Self { let ws = workspace.read(cx); @@ -80,37 +98,64 @@ 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); - - Self { - workspace, - focus_manager, + + let mut dialog = Self { + client, + daemon_project_id, + workspace: workspace.clone(), + client_project_id: project_id.clone(), focus_handle: cx.focus_handle(), - project_id, project_name, project_path, - branch, - default_branch, - main_repo_path, - is_dirty, + branch: None, + default_branch: None, + is_dirty: false, merge_enabled: worktree_config.default_merge, stash_enabled: worktree_config.default_stash, fetch_enabled: worktree_config.default_fetch, delete_branch_enabled: worktree_config.default_delete_branch, push_enabled: worktree_config.default_push, - unpushed_count, + unpushed_count: 0, + loading_info: true, error_message: None, processing: ProcessingState::Idle, - hooks_config, - } + }; + dialog.load_close_info(cx); + dialog + } + + fn load_close_info(&mut self, cx: &mut Context<Self>) { + let client = self.client.clone(); + let project_id = self.daemon_project_id.clone(); + cx.spawn(async move |this, cx| { + let result = smol::unblock(move || Self::fetch_close_info(&client, project_id)).await; + let _ = this.update(cx, |this, cx| { + this.loading_info = false; + match result { + Ok(info) => { + this.is_dirty = info.is_dirty; + this.branch = info.branch; + this.default_branch = info.default_branch; + this.unpushed_count = info.unpushed_count; + } + Err(error) => this.error_message = Some(error), + } + cx.notify(); + }); + }) + .detach(); + } + + fn fetch_close_info( + client: &okena_transport::remote_action::RemoteActionClient, + project_id: String, + ) -> Result<CloseInfo, String> { + let action = okena_core::api::ActionRequest::WorktreeCloseInfo { project_id }; + let value = client + .post_action(action)? + .ok_or_else(|| "Missing worktree close info response".to_string())?; + serde_json::from_value(value) + .map_err(|error| format!("Invalid worktree close info response: {error}")) } pub(super) fn close(&mut self, cx: &mut Context<Self>) { 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..47ccf01db 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,74 @@ -//! 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<Self>) { - if self.processing != ProcessingState::Idle { + if self.processing != ProcessingState::Idle || self.loading_info { 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(); + + // Optimistically mark the project "closing" so the sidebar row shows a + // busy/dimmed state immediately, covering the daemon-side before_remove + // hook + removal window. On success the mirror drops the project (the + // row vanishes); on dispatch failure we clear the flag below. + let client_project_id = self.client_project_id.clone(); + self.workspace.update(cx, |ws, wcx| { + ws.mark_closing_project(&client_project_id); + wcx.notify(); + }); + + let client = self.client.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, - ) - } + let result = smol::unblock(move || { + client.post_action(okena_core::api::ActionRequest::CloseWorktree { + project_id, + merge, + stash, + fetch, + push, + delete_branch, }) - .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; + }) + .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); } - - // 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(); - }) + Err(e) => { + // Dispatch never reached the daemon — clear the optimistic + // closing flag so the row isn't stuck busy. + let pid = this.client_project_id.clone(); + this.workspace.update(cx, |ws, wcx| { + ws.finish_closing_project(&pid); + wcx.notify(); }); - return; + this.error_message = Some(e); + this.processing = ProcessingState::Idle; + cx.notify(); } - - // 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(); - }); - } - } - }); - } + }); }) .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..d121c012a 100644 --- a/crates/okena-views-git/src/close_worktree_dialog/view.rs +++ b/crates/okena-views-git/src/close_worktree_dialog/view.rs @@ -24,15 +24,10 @@ impl Render for CloseWorktreeDialog { } let is_processing = self.processing != ProcessingState::Idle; + let is_unavailable = is_processing || self.loading_info; 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, }; @@ -120,6 +115,14 @@ impl Render for CloseWorktreeDialog { .flex() .flex_col() .gap(px(8.0)) + .when(self.loading_info, |d| { + d.child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_muted)) + .child("Loading worktree info\u{2026}") + ) + }) // Project info .child( div() @@ -573,12 +576,12 @@ impl Render for CloseWorktreeDialog { button_primary("confirm-close-wt-btn", confirm_label, &t) .px(px(16.0)) .py(px(8.0)) - .when(!is_processing, |d| { + .when(!is_unavailable, |d| { d.on_click(cx.listener(|this, _, _, cx| { this.execute(cx); })) }) - .when(is_processing, |d| { + .when(is_unavailable, |d| { d.opacity(0.5).cursor(CursorStyle::default()) }), ), diff --git a/crates/okena-views-git/src/commit_send.rs b/crates/okena-views-git/src/commit_send.rs index edb1ab11c..dcc3ba780 100644 --- a/crates/okena-views-git/src/commit_send.rs +++ b/crates/okena-views-git/src/commit_send.rs @@ -1,7 +1,7 @@ //! Shared formatting for "Send commit to terminal" — produces a `git log`-style block //! used by the diff viewer and the commit graph context menu. -use okena_git::{format_relative_time, CommitLogEntry}; +use okena_git::{CommitLogEntry, format_relative_time}; /// Format a commit reference for pasting into a terminal. Mirrors the /// `git log` default output: diff --git a/crates/okena-views-git/src/diff_viewer/context_menu.rs b/crates/okena-views-git/src/diff_viewer/context_menu.rs index 20ab7016c..e7908a370 100644 --- a/crates/okena-views-git/src/diff_viewer/context_menu.rs +++ b/crates/okena-views-git/src/diff_viewer/context_menu.rs @@ -10,10 +10,10 @@ use gpui::prelude::*; use gpui::{ClipboardItem, *}; use gpui_component::h_flex; use okena_core::theme::ThemeColors; +use okena_files::selection::Selection2DNonEmpty; use okena_git::DiffMode; use okena_ui::button::button; use okena_ui::icon_button::icon_button_sized; -use okena_files::selection::Selection2DNonEmpty; use okena_ui::menu::{context_menu_panel, menu_item, menu_item_with_color, menu_separator}; use okena_ui::modal::{modal_backdrop, modal_content}; use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_xl}; @@ -85,7 +85,11 @@ impl DiffViewer { kind: DiffTargetKind, cx: &mut Context<Self>, ) { - self.context_menu = Some(DiffContextMenu { position, path, kind }); + self.context_menu = Some(DiffContextMenu { + position, + path, + kind, + }); cx.notify(); } @@ -111,7 +115,11 @@ impl DiffViewer { &[], ), }; - self.commit_hash_menu = Some(CommitHashContextMenu { position, hash, send_text }); + self.commit_hash_menu = Some(CommitHashContextMenu { + position, + hash, + send_text, + }); cx.notify(); } @@ -162,15 +170,13 @@ impl DiffViewer { let mode = self.diff_mode.clone(); cx.spawn(async move |this, cx| { let result = smol::unblock(move || op(&*provider)).await; - let _ = this.update(cx, |this, cx| { - match result { - Ok(()) => { - ToastManager::success(success_msg, cx); - this.load_diff_async(mode, None, cx); - } - Err(e) => { - ToastManager::error(format!("Git operation failed: {}", e), cx); - } + let _ = this.update(cx, |this, cx| match result { + Ok(()) => { + ToastManager::success(success_msg, cx); + this.load_diff_async(mode, None, cx); + } + Err(e) => { + ToastManager::error(format!("Git operation failed: {}", e), cx); } }); }) @@ -184,11 +190,7 @@ impl DiffViewer { let path = menu.path.clone(); let msg = format!("Staged {}", path); let path_for_op = path.clone(); - self.spawn_mutation( - move |provider| provider.stage_file(&path_for_op), - msg, - cx, - ); + self.spawn_mutation(move |provider| provider.stage_file(&path_for_op), msg, cx); } pub(super) fn unstage_from_menu(&mut self, cx: &mut Context<Self>) { @@ -198,11 +200,7 @@ impl DiffViewer { let path = menu.path.clone(); let msg = format!("Unstaged {}", path); let path_for_op = path.clone(); - self.spawn_mutation( - move |provider| provider.unstage_file(&path_for_op), - msg, - cx, - ); + self.spawn_mutation(move |provider| provider.unstage_file(&path_for_op), msg, cx); } // ── Discard flow ──────────────────────────────────────────────────────── @@ -226,11 +224,7 @@ impl DiffViewer { let path = confirm.path.clone(); let msg = format!("Discarded changes in {}", path); let path_for_op = path.clone(); - self.spawn_mutation( - move |provider| provider.discard_file(&path_for_op), - msg, - cx, - ); + self.spawn_mutation(move |provider| provider.discard_file(&path_for_op), msg, cx); } pub(super) fn cancel_discard(&mut self, cx: &mut Context<Self>) { @@ -261,11 +255,7 @@ impl DiffViewer { let file_path = confirm.file_path.clone(); let msg = format!("Deleted {}", file_path); let path_for_op = file_path.clone(); - self.spawn_mutation( - move |provider| provider.delete_file(&path_for_op), - msg, - cx, - ); + self.spawn_mutation(move |provider| provider.delete_file(&path_for_op), msg, cx); } pub(super) fn cancel_delete(&mut self, cx: &mut Context<Self>) { @@ -322,11 +312,12 @@ impl DiffViewer { ) .child(menu_separator(t)) .child( - menu_item("dv-sel-ctx-copy", "icons/copy.svg", "Copy", t) - .on_click(cx.listener(|this, _, _, cx| { + menu_item("dv-sel-ctx-copy", "icons/copy.svg", "Copy", t).on_click(cx.listener( + |this, _, _, cx| { this.selection_context_menu = None; this.copy_selection(cx); - })), + }, + )), ); Some( @@ -382,11 +373,12 @@ impl DiffViewer { ) .child(menu_separator(t)) .child( - menu_item("dv-ctx-copy-commit-hash", "icons/copy.svg", "Copy Hash", t) - .on_click(cx.listener(move |this, _, _, cx| { + menu_item("dv-ctx-copy-commit-hash", "icons/copy.svg", "Copy Hash", t).on_click( + cx.listener(move |this, _, _, cx| { cx.write_to_clipboard(ClipboardItem::new_string(hash_for_copy.clone())); this.close_commit_hash_menu(cx); - })), + }), + ), ); Some( @@ -413,11 +405,7 @@ impl DiffViewer { ) } - fn render_context_menu( - &self, - t: &ThemeColors, - cx: &mut Context<Self>, - ) -> Option<AnyElement> { + fn render_context_menu(&self, t: &ThemeColors, cx: &mut Context<Self>) -> Option<AnyElement> { let menu = self.context_menu.as_ref()?; let position = menu.position; let path = menu.path.clone(); @@ -564,11 +552,7 @@ impl DiffViewer { ) } - fn render_delete_confirm( - &self, - t: &ThemeColors, - cx: &mut Context<Self>, - ) -> Option<AnyElement> { + fn render_delete_confirm(&self, t: &ThemeColors, cx: &mut Context<Self>) -> Option<AnyElement> { let confirm = self.delete_confirm.as_ref()?; let file_path = confirm.file_path.clone(); let display_name = file_path @@ -633,9 +617,11 @@ impl DiffViewer { 14.0, t, ) - .on_click(cx.listener(|this, _, _, cx| { - this.cancel_delete(cx); - })), + .on_click(cx.listener( + |this, _, _, cx| { + this.cancel_delete(cx); + }, + )), ), ) .child( @@ -719,11 +705,7 @@ impl DiffViewer { let confirm = self.discard_confirm.as_ref()?; let path = confirm.path.clone(); let kind = confirm.kind; - let display_name = path - .rsplit('/') - .next() - .unwrap_or(&path) - .to_string(); + let display_name = path.rsplit('/').next().unwrap_or(&path).to_string(); let error_msg = confirm.error_message.clone(); let title = format!("Discard {} Changes", kind.noun()); let prompt = format!( @@ -787,9 +769,11 @@ impl DiffViewer { 14.0, t, ) - .on_click(cx.listener(|this, _, _, cx| { - this.cancel_discard(cx); - })), + .on_click(cx.listener( + |this, _, _, cx| { + this.cancel_discard(cx); + }, + )), ), ) .child( diff --git a/crates/okena-views-git/src/diff_viewer/data.rs b/crates/okena-views-git/src/diff_viewer/data.rs index 7aee1a10d..b01f0764e 100644 --- a/crates/okena-views-git/src/diff_viewer/data.rs +++ b/crates/okena-views-git/src/diff_viewer/data.rs @@ -1,9 +1,9 @@ //! Async loading and processing for the diff viewer: fetching the diff, //! syntax-highlighting the selected file, and (re-)building the file tree. +use super::DiffViewer; use super::syntax::process_file; use super::types::{DiffDisplayFile, DisplayItem, FileStats, FileTreeNode}; -use super::DiffViewer; use okena_files::file_tree::build_file_tree; use okena_git::{DiffMode, DiffResult}; @@ -40,9 +40,7 @@ impl DiffViewer { cx.spawn(async move |this, cx| { let mode_for_fallback = mode.clone(); - let result = smol::unblock(move || { - provider.get_diff(mode, ignore_whitespace) - }).await; + let result = smol::unblock(move || provider.get_diff(mode, ignore_whitespace)).await; let _ = this.update(cx, |this, cx| { this.loading = false; @@ -54,16 +52,21 @@ impl DiffViewer { this.load_diff_async(DiffMode::Staged, select_file, cx); return; } - this.error_message = Some(format!("No {} changes", mode_for_fallback.display_name().to_lowercase())); + this.error_message = Some(format!( + "No {} changes", + mode_for_fallback.display_name().to_lowercase() + )); } else { this.store_diff_result(diff_result); this.build_file_tree(); // Select specific file if requested if let Some(ref file_path) = select_file - && let Some(index) = this.file_stats.iter().position(|f| f.path == *file_path) { - this.selected_file_index = index; - } + && let Some(index) = + this.file_stats.iter().position(|f| f.path == *file_path) + { + this.selected_file_index = index; + } this.process_current_file_async(cx); } @@ -74,7 +77,8 @@ impl DiffViewer { } cx.notify(); }); - }).detach(); + }) + .detach(); } /// Store raw diff data and extract lightweight stats (no syntax highlighting). @@ -103,8 +107,9 @@ impl DiffViewer { let is_dark = self.is_dark; cx.spawn(async move |this, cx| { - let (old_content, new_content, display_file, max_line_num) = smol::unblock(move || { - let (old_content, new_content) = provider.get_file_contents(&file_path, diff_mode); + let result = smol::unblock(move || { + let (old_content, new_content) = + provider.get_file_contents(&file_path, diff_mode)?; let mut max_line_num = 0usize; let display_file = process_file( &raw_file, @@ -114,19 +119,29 @@ impl DiffViewer { new_content.clone(), is_dark, ); - (old_content, new_content, display_file, max_line_num) - }).await; + Ok::<_, String>((old_content, new_content, display_file, max_line_num)) + }) + .await; let _ = this.update(cx, |this, cx| { - this.current_file_old_content = old_content; - this.current_file_new_content = new_content; - this.line_num_width = max_line_num.to_string().len().max(3); - this.max_line_chars = Self::calc_max_line_chars(&display_file); - this.current_file = Some(display_file); - this.update_side_by_side_cache(); + match result { + Ok((old_content, new_content, display_file, max_line_num)) => { + this.current_file_old_content = old_content; + this.current_file_new_content = new_content; + this.line_num_width = max_line_num.to_string().len().max(3); + this.max_line_chars = Self::calc_max_line_chars(&display_file); + this.current_file = Some(display_file); + this.update_side_by_side_cache(); + } + Err(error) => { + this.current_file = None; + this.error_message = Some(error); + } + } cx.notify(); }); - }).detach(); + }) + .detach(); } /// Re-highlight current file using cached content (for theme changes). @@ -152,7 +167,10 @@ impl DiffViewer { pub(super) fn build_file_tree(&mut self) { self.file_tree = build_file_tree( - self.file_stats.iter().enumerate().map(|(i, f)| (i, &f.path)) + self.file_stats + .iter() + .enumerate() + .map(|(i, f)| (i, &f.path)), ); // Auto-expand all folders in diff view self.expanded_folders.clear(); @@ -161,7 +179,11 @@ impl DiffViewer { fn collect_folder_paths(node: &FileTreeNode, parent: &str, out: &mut HashSet<String>) { for (name, child) in &node.children { - let path = if parent.is_empty() { name.clone() } else { format!("{parent}/{name}") }; + let path = if parent.is_empty() { + name.clone() + } else { + format!("{parent}/{name}") + }; out.insert(path.clone()); Self::collect_folder_paths(child, &path, out); } diff --git a/crates/okena-views-git/src/diff_viewer/line_render.rs b/crates/okena-views-git/src/diff_viewer/line_render.rs index c3610d548..501bca46e 100644 --- a/crates/okena-views-git/src/diff_viewer/line_render.rs +++ b/crates/okena-views-git/src/diff_viewer/line_render.rs @@ -3,15 +3,17 @@ //! Also contains shared rendering helpers, constants, and methods used by //! both the unified and side-by-side diff views. -use super::types::{DisplayItem, DisplayLine, ExpanderRow, HighlightedSpan}; use super::DiffViewer; -use okena_git::DiffLineType; -use okena_core::theme::ThemeColors; -use okena_files::code_view::{build_styled_text_with_backgrounds, find_word_boundaries, selection_bg_ranges}; -use okena_files::selection::Selection2DNonEmpty; +use super::types::{DisplayItem, DisplayLine, ExpanderRow, HighlightedSpan}; use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; +use okena_core::theme::ThemeColors; +use okena_files::code_view::{ + build_styled_text_with_backgrounds, find_word_boundaries, selection_bg_ranges, +}; +use okena_files::selection::Selection2DNonEmpty; +use okena_git::DiffLineType; // ── Shared constants ──────────────────────────────────────────────────── @@ -297,39 +299,38 @@ impl DiffViewer { .text_size(px(font_size)) .font_family("monospace") .when_some(line_bg, |d, bg| d.bg(bg)) - .on_mouse_down( - MouseButton::Left, - { - let text_layout = text_layout.clone(); - let plain_text = plain_text.clone(); - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - let col = text_layout.index_for_position(event.position) - .unwrap_or_else(|ix| ix) - .min(line_len); - if event.click_count >= 3 { - this.selection.start = Some((line_index, 0)); - this.selection.end = Some((line_index, line_len)); - this.selection.finish(); - } else if event.click_count == 2 { - let (start, end) = find_word_boundaries(&plain_text, col); - this.selection.start = Some((line_index, start)); - this.selection.end = Some((line_index, end)); - this.selection.finish(); - } else { - this.selection.start = Some((line_index, col)); - this.selection.end = Some((line_index, col)); - this.selection.is_selecting = true; - } - this.selection_side = None; - cx.notify(); - }) - }, - ) + .on_mouse_down(MouseButton::Left, { + let text_layout = text_layout.clone(); + let plain_text = plain_text.clone(); + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let col = text_layout + .index_for_position(event.position) + .unwrap_or_else(|ix| ix) + .min(line_len); + if event.click_count >= 3 { + this.selection.start = Some((line_index, 0)); + this.selection.end = Some((line_index, line_len)); + this.selection.finish(); + } else if event.click_count == 2 { + let (start, end) = find_word_boundaries(&plain_text, col); + this.selection.start = Some((line_index, start)); + this.selection.end = Some((line_index, end)); + this.selection.finish(); + } else { + this.selection.start = Some((line_index, col)); + this.selection.end = Some((line_index, col)); + this.selection.is_selecting = true; + } + this.selection_side = None; + cx.notify(); + }) + }) .on_mouse_move({ let text_layout = text_layout.clone(); cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { if this.selection.is_selecting { - let col = text_layout.index_for_position(event.position) + let col = text_layout + .index_for_position(event.position) .unwrap_or_else(|ix| ix) .min(line_len); this.selection.end = Some((line_index, col)); @@ -411,12 +412,12 @@ impl DiffViewer { range .filter_map(|i| { file.items.get(i).map(|item| match item { - DisplayItem::Line(line) => { - self.render_line(i, line, t, gutter_width, cx).into_any_element() - } - DisplayItem::Expander(expander) => { - self.render_expander_row(i, expander, t, cx).into_any_element() - } + DisplayItem::Line(line) => self + .render_line(i, line, t, gutter_width, cx) + .into_any_element(), + DisplayItem::Expander(expander) => self + .render_expander_row(i, expander, t, cx) + .into_any_element(), }) }) .collect() diff --git a/crates/okena-views-git/src/diff_viewer/mod.rs b/crates/okena-views-git/src/diff_viewer/mod.rs index d5ad9a57c..d307aa00a 100644 --- a/crates/okena-views-git/src/diff_viewer/mod.rs +++ b/crates/okena-views-git/src/diff_viewer/mod.rs @@ -16,17 +16,21 @@ mod side_by_side; mod syntax; mod types; -use okena_git::{CommitLogEntry, DiffMode, FileDiff}; +use gpui::prelude::*; +use gpui::*; use okena_core::selection::SelectionState; use okena_core::types::DiffViewMode; use okena_files::syntax::load_syntax_set; -use gpui::prelude::*; -use gpui::*; +use okena_git::{CommitLogEntry, DiffMode, FileDiff}; +use okena_ui::resizable_sidebar::ResizableSidebarState; use std::collections::HashSet; use std::sync::Arc; use syntect::parsing::SyntaxSet; -use types::{DiffDisplayFile, FileStats, FileTreeNode, HScrollbarDrag, ScrollbarDrag, SideBySideLine, SideBySideSide}; +use types::{ + DiffDisplayFile, FileStats, FileTreeNode, HScrollbarDrag, ScrollbarDrag, SideBySideLine, + SideBySideSide, +}; // Re-export for use in settings (and use locally) pub use types::DiffViewMode as DiffViewModeReexport; @@ -52,9 +56,6 @@ type DiffSearchSig = ( bool, ); -/// Width of file tree sidebar. -const SIDEBAR_WIDTH: f32 = 240.0; - use crate::settings::git_settings; /// Git diff viewer overlay. @@ -80,6 +81,8 @@ pub struct DiffViewer { pub(super) selection: Selection, pub(super) scroll_handle: UniformListScrollHandle, pub(super) tree_scroll_handle: ScrollHandle, + /// Width and active resize gesture for the file tree sidebar. + pub(super) sidebar_resize: ResizableSidebarState, pub(super) error_message: Option<String>, pub(super) line_num_width: usize, pub(super) syntax_set: std::sync::Arc<SyntaxSet>, @@ -164,6 +167,7 @@ impl DiffViewer { selection: Selection::default(), scroll_handle: UniformListScrollHandle::new(), tree_scroll_handle: ScrollHandle::new(), + sidebar_resize: ResizableSidebarState::default(), error_message: None, line_num_width: 4, syntax_set: load_syntax_set(), @@ -202,10 +206,14 @@ impl DiffViewer { } /// Current diff view mode (for persisting on close). - pub fn view_mode(&self) -> DiffViewMode { self.view_mode } + pub fn view_mode(&self) -> DiffViewMode { + self.view_mode + } /// Current ignore-whitespace setting (for persisting on close). - pub fn ignore_whitespace(&self) -> bool { self.ignore_whitespace } + pub fn ignore_whitespace(&self) -> bool { + self.ignore_whitespace + } /// Update configuration (font size, theme) from outside. pub fn update_config(&mut self, font_size: f32, is_dark: bool) { @@ -232,5 +240,7 @@ pub enum DiffViewerEvent { impl EventEmitter<DiffViewerEvent> for DiffViewer {} impl okena_ui::overlay::CloseEvent for DiffViewerEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } diff --git a/crates/okena-views-git/src/diff_viewer/nav.rs b/crates/okena-views-git/src/diff_viewer/nav.rs index 98582d510..06b9cfefd 100644 --- a/crates/okena-views-git/src/diff_viewer/nav.rs +++ b/crates/okena-views-git/src/diff_viewer/nav.rs @@ -1,17 +1,24 @@ //! Navigation actions for the diff viewer: file/commit/folder selection, //! view-mode toggles, detach handling, close. -use super::side_by_side; use super::DiffViewer; use super::DiffViewerEvent; +use super::side_by_side; use crate::settings::{git_settings, set_git_settings}; use okena_core::types::DiffViewMode; +use okena_files::file_tree::{ + FileTreeNavigationDirection, FileTreeRow, adjacent_file_tree_item, indexed_file_tree_rows, +}; use okena_git::DiffMode; use gpui::*; impl DiffViewer { + pub(super) fn file_tree_rows(&self, include_collapsed: bool) -> Vec<FileTreeRow<usize>> { + indexed_file_tree_rows(&self.file_tree, &self.expanded_folders, include_collapsed) + } + pub(super) fn toggle_folder(&mut self, path: &str, cx: &mut Context<Self>) { if self.expanded_folders.contains(path) { self.expanded_folders.remove(path); @@ -78,16 +85,26 @@ impl DiffViewer { cx.notify(); } - pub(super) fn prev_file(&mut self, cx: &mut Context<Self>) { - if self.selected_file_index > 0 { - self.select_file(self.selected_file_index - 1, cx); + fn navigate_file_tree( + &mut self, + direction: FileTreeNavigationDirection, + cx: &mut Context<Self>, + ) { + let visible = self.file_tree_rows(false); + let all = self.file_tree_rows(true); + if let Some(index) = + adjacent_file_tree_item(&visible, &all, Some(&self.selected_file_index), direction) + { + self.select_file(index, cx); } } + pub(super) fn prev_file(&mut self, cx: &mut Context<Self>) { + self.navigate_file_tree(FileTreeNavigationDirection::Previous, cx); + } + pub(super) fn next_file(&mut self, cx: &mut Context<Self>) { - if self.selected_file_index + 1 < self.file_stats.len() { - self.select_file(self.selected_file_index + 1, cx); - } + self.navigate_file_tree(FileTreeNavigationDirection::Next, cx); } pub(super) fn close(&self, cx: &mut Context<Self>) { @@ -125,13 +142,17 @@ impl DiffViewer { } pub(super) fn prev_commit(&mut self, cx: &mut Context<Self>) { - if !self.can_prev_commit() { return; } + if !self.can_prev_commit() { + return; + } self.commit_index -= 1; self.navigate_to_current_commit(cx); } pub(super) fn next_commit(&mut self, cx: &mut Context<Self>) { - if !self.can_next_commit() { return; } + if !self.can_next_commit() { + return; + } self.commit_index += 1; self.navigate_to_current_commit(cx); } diff --git a/crates/okena-views-git/src/diff_viewer/provider.rs b/crates/okena-views-git/src/diff_viewer/provider.rs index 6f22e2332..133aef8a5 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 { @@ -12,18 +13,30 @@ pub trait GitProvider: Send + Sync + 'static { true } fn get_diff(&self, mode: DiffMode, ignore_whitespace: bool) -> Result<DiffResult, String>; - fn get_file_contents(&self, file_path: &str, mode: DiffMode) -> (Option<String>, Option<String>); - fn get_diff_file_summary(&self) -> Vec<FileDiffSummary>; - fn get_commit_graph(&self, count: usize, branch: Option<&str>) -> Vec<CommitLogEntry>; - fn list_branches(&self) -> Vec<String>; + fn get_file_contents( + &self, + file_path: &str, + mode: DiffMode, + ) -> Result<(Option<String>, Option<String>), String>; + fn get_diff_file_summary(&self) -> Result<Vec<FileDiffSummary>, String>; + fn get_commit_graph( + &self, + count: usize, + branch: Option<&str>, + ) -> Result<Vec<CommitLogEntry>, String>; + fn list_branches(&self) -> Result<Vec<String>, String>; /// List branches split into local/remote with the current branch name. /// Default implementation falls back to [`list_branches`] and classifies /// remote refs as anything containing a `/`. - fn list_branches_classified(&self) -> BranchList { - let all = self.list_branches(); + fn list_branches_classified(&self) -> Result<BranchList, String> { + let all = self.list_branches()?; let (remote, local): (Vec<String>, Vec<String>) = all.into_iter().partition(|n| n.contains('/')); - BranchList { local, remote, current: None } + Ok(BranchList { + local, + remote, + current: None, + }) } // ── Mutations (Phase 1: per-file) ────────────────────────────────────── @@ -53,116 +66,45 @@ pub trait GitProvider: Send + Sync + 'static { fn absolute_file_path(&self, file_path: &str) -> Option<String>; } -/// Local git provider — wraps existing git functions. -pub struct LocalGitProvider { - path: String, -} - -impl LocalGitProvider { - pub fn new(path: String) -> Self { - Self { path } - } +/// Remote git provider — fetches git data via HTTP from a remote server. +pub struct RemoteGitProvider { + client: okena_transport::remote_action::RemoteActionClient, + project_id: String, + root: String, } -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<DiffResult, String> { - 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<String>, Option<String>) { - okena_git::get_file_contents_for_diff(std::path::Path::new(&self.path), file_path, mode) - } - - fn get_diff_file_summary(&self) -> Vec<FileDiffSummary> { - okena_git::get_diff_file_summary(std::path::Path::new(&self.path)) - } - - fn get_commit_graph(&self, count: usize, branch: Option<&str>) -> Vec<CommitLogEntry> { - okena_git::fetch_commit_log(std::path::Path::new(&self.path), count, branch) - } - - fn list_branches(&self) -> Vec<String> { - 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()) +impl RemoteGitProvider { + pub fn new( + client: okena_transport::remote_action::RemoteActionClient, + project_id: String, + root: String, + ) -> Self { + Self { + client, + project_id, + root, + } } - fn create_and_checkout_branch( + fn post_action( &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<String> { - Some( - std::path::Path::new(&self.path) - .join(file_path) - .to_string_lossy() - .to_string(), - ) + action: okena_core::api::ActionRequest, + ) -> Result<Option<serde_json::Value>, String> { + self.client.post_action(action) } -} - -/// Remote git provider — fetches git data via HTTP from a remote server. -pub struct RemoteGitProvider { - host: String, - port: u16, - token: String, - project_id: String, -} -impl RemoteGitProvider { - pub fn new(host: String, port: u16, token: String, project_id: String) -> Self { - Self { host, port, token, project_id } + fn post_json<T>(&self, action: okena_core::api::ActionRequest, label: &str) -> Result<T, String> + where + T: DeserializeOwned, + { + let value = self + .post_action(action)? + .ok_or_else(|| format!("Missing {label} response"))?; + serde_json::from_value(value).map_err(|error| format!("Invalid {label} response: {error}")) } - fn post_action(&self, action: okena_core::api::ActionRequest) -> Result<Option<serde_json::Value>, String> { - okena_transport::remote_action::post_action(&self.host, self.port, &self.token, action) + fn post_unit(&self, action: okena_core::api::ActionRequest) -> Result<(), String> { + self.post_action(action).map(|_| ()) } } @@ -172,7 +114,7 @@ impl GitProvider for RemoteGitProvider { } fn supports_mutations(&self) -> bool { - false + true } fn get_diff(&self, mode: DiffMode, ignore_whitespace: bool) -> Result<DiffResult, String> { @@ -181,68 +123,65 @@ impl GitProvider for RemoteGitProvider { mode, ignore_whitespace, }; - let result = self.post_action(action)?; - match result { - Some(value) => serde_json::from_value(value).map_err(|e| format!("Failed to deserialize DiffResult: {}", e)), - None => Ok(DiffResult::default()), - } + self.post_json(action, "diff") } - fn get_file_contents(&self, file_path: &str, mode: DiffMode) -> (Option<String>, Option<String>) { + fn get_file_contents( + &self, + file_path: &str, + mode: DiffMode, + ) -> Result<(Option<String>, Option<String>), String> { let action = okena_core::api::ActionRequest::GitFileContents { project_id: self.project_id.clone(), file_path: file_path.to_string(), mode, }; - match self.post_action(action) { - Ok(Some(value)) => { - let old = value.get("old_content").and_then(|v| v.as_str()).map(String::from); - let new = value.get("new_content").and_then(|v| v.as_str()).map(String::from); - (old, new) - } - _ => (None, None), - } - } - - fn get_diff_file_summary(&self) -> Vec<FileDiffSummary> { + let value = self + .post_action(action)? + .ok_or_else(|| "Missing file contents response".to_string())?; + let old = value + .get("old_content") + .and_then(|v| v.as_str()) + .map(String::from); + let new = value + .get("new_content") + .and_then(|v| v.as_str()) + .map(String::from); + Ok((old, new)) + } + + fn get_diff_file_summary(&self) -> Result<Vec<FileDiffSummary>, String> { 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(action, "diff summary") } - fn get_commit_graph(&self, count: usize, branch: Option<&str>) -> Vec<CommitLogEntry> { + fn get_commit_graph( + &self, + count: usize, + branch: Option<&str>, + ) -> Result<Vec<CommitLogEntry>, String> { let action = okena_core::api::ActionRequest::GitCommitGraph { project_id: self.project_id.clone(), 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(action, "commit graph") } - fn list_branches(&self) -> Vec<String> { + fn list_branches(&self) -> Result<Vec<String>, String> { 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(action, "branch list") + } + + fn list_branches_classified(&self) -> Result<BranchList, String> { + let action = okena_core::api::ActionRequest::GitListBranchesClassified { + project_id: self.project_id.clone(), + }; + self.post_json(action, "classified branch list") } fn stage_file(&self, file_path: &str) -> Result<(), String> { @@ -250,7 +189,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 +197,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 +205,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 +213,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 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<String> { - None + fn absolute_file_path(&self, file_path: &str) -> Option<String> { + 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/diff_viewer/render.rs b/crates/okena-views-git/src/diff_viewer/render.rs index d22bd8065..cc517fea8 100644 --- a/crates/okena-views-git/src/diff_viewer/render.rs +++ b/crates/okena-views-git/src/diff_viewer/render.rs @@ -1,20 +1,22 @@ //! Render trait impl and helper methods for the diff viewer. -use super::types::{DiffViewMode, FileTreeNode}; -use super::{Cancel, DiffViewer, SIDEBAR_WIDTH}; +use super::types::DiffViewMode; +use super::{Cancel, DiffViewer}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; use okena_core::theme::ThemeColors; +use okena_files::file_tree::{FileTreeRow, expandable_file_row, expandable_folder_row}; use okena_files::selection::Selection2DNonEmpty; use okena_files::theme::theme; +use okena_git::DiffMode; use okena_ui::modal::{ detached_needs_controls, fullscreen_overlay, fullscreen_panel, window_drag_spacer, window_min_max_controls, }; +use okena_ui::resizable_sidebar::resizable_sidebar; use okena_ui::toggle::segmented_toggle; -use okena_ui::tokens::{ui_text_sm, ui_text_ms, ui_text_md, ui_text_xl, ui_text}; -use okena_git::DiffMode; -use gpui::prelude::*; -use gpui::*; -use gpui_component::h_flex; +use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; use std::sync::Arc; impl DiffViewer { @@ -35,7 +37,10 @@ impl DiffViewer { cx: &mut Context<Self>, ) -> impl IntoElement { let is_working = *diff_mode == DiffMode::WorkingTree; - let hide_mode_toggle = matches!(diff_mode, DiffMode::Commit(_) | DiffMode::BranchCompare { .. }); + let hide_mode_toggle = matches!( + diff_mode, + DiffMode::Commit(_) | DiffMode::BranchCompare { .. } + ); let is_unified = self.view_mode == DiffViewMode::Unified; let detached = self.is_detached; @@ -54,7 +59,9 @@ impl DiffViewer { .child({ let title = match diff_mode { DiffMode::Commit(_) => commit_message.unwrap_or("Commit").to_string(), - DiffMode::BranchCompare { base, head } => format!("{base} \u{2192} {head}"), + DiffMode::BranchCompare { base, head } => { + format!("{base} \u{2192} {head}") + } _ => "Changes".to_string(), }; div() @@ -75,7 +82,11 @@ impl DiffViewer { None }, |d, hash| { - let short = if hash.len() > 7 { hash[..7].to_string() } else { hash.clone() }; + let short = if hash.len() > 7 { + hash[..7].to_string() + } else { + hash.clone() + }; let hash_for_click = hash.clone(); let hash_for_rclick = hash.clone(); d.child( @@ -90,15 +101,26 @@ impl DiffViewer { .rounded(px(4.0)) .hover(|s| s.bg(rgb(t.bg_hover))) .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(hash_for_click.clone())); + cx.write_to_clipboard(ClipboardItem::new_string( + hash_for_click.clone(), + )); }) .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, _, cx| { - this.open_commit_hash_menu(event.position, hash_for_rclick.clone(), cx); + this.open_commit_hash_menu( + event.position, + hash_for_rclick.clone(), + cx, + ); }), ) - .tooltip(|_window, cx| gpui_component::tooltip::Tooltip::new("Click: copy hash \u{00B7} Right-click: more").build(_window, cx)) + .tooltip(|_window, cx| { + gpui_component::tooltip::Tooltip::new( + "Click: copy hash \u{00B7} Right-click: more", + ) + .build(_window, cx) + }) .child(short), ) }, @@ -160,9 +182,11 @@ impl DiffViewer { t.bg_secondary })) .hover(|s| s.opacity(0.85)) - .on_click(cx.listener(|this, _, _window, cx| { - this.toggle_ignore_whitespace(cx) - })) + .on_click( + cx.listener(|this, _, _window, cx| { + this.toggle_ignore_whitespace(cx) + }), + ) .child( div() .text_size(ui_text_md(cx)) @@ -175,13 +199,7 @@ impl DiffViewer { ), ) // Separator - .child( - div() - .w(px(1.0)) - .h(px(20.0)) - .bg(rgb(t.border)) - .mx(px(4.0)), - ) + .child(div().w(px(1.0)).h(px(20.0)).bg(rgb(t.border)).mx(px(4.0))) // View mode toggle .child( div() @@ -207,13 +225,7 @@ impl DiffViewer { ) }) // Separator - .child( - div() - .w(px(1.0)) - .h(px(20.0)) - .bg(rgb(t.border)) - .mx(px(4.0)), - ) + .child(div().w(px(1.0)).h(px(20.0)).bg(rgb(t.border)).mx(px(4.0))) // Window min/max controls (only when detached + client decorations) .when(detached, |d| { d.child(window_min_max_controls(needs_controls, is_maximized, t, cx)) @@ -232,9 +244,12 @@ impl DiffViewer { .rounded(px(6.0)) .hover(|s| s.bg(rgb(t.bg_hover))) .tooltip(|window, cx| { - gpui_component::tooltip::Tooltip::new("Open in new window").build(window, cx) + gpui_component::tooltip::Tooltip::new("Open in new window") + .build(window, cx) }) - .on_click(cx.listener(|this, _, _window, cx| this.request_detach(cx))) + .on_click( + cx.listener(|this, _, _window, cx| this.request_detach(cx)), + ) .child( svg() .path("icons/external-link.svg") @@ -267,7 +282,11 @@ impl DiffViewer { } /// Commit navigation bar: prev/next arrows, author, date, hash, position indicator. - pub(super) fn render_commit_info_bar(&self, t: &ThemeColors, cx: &mut Context<Self>) -> impl IntoElement { + pub(super) fn render_commit_info_bar( + &self, + t: &ThemeColors, + cx: &mut Context<Self>, + ) -> impl IntoElement { use gpui_component::tooltip::Tooltip; let commit = self.commits.get(self.commit_index); @@ -287,7 +306,11 @@ impl DiffViewer { .child( div() .id("commit-nav-prev") - .cursor(if can_prev { CursorStyle::PointingHand } else { CursorStyle::default() }) + .cursor(if can_prev { + CursorStyle::PointingHand + } else { + CursorStyle::default() + }) .w(px(24.0)) .h(px(22.0)) .flex() @@ -296,12 +319,18 @@ impl DiffViewer { .rounded(px(4.0)) .when(can_prev, |d| d.hover(|s| s.bg(rgb(t.bg_hover)))) .text_size(ui_text_md(cx)) - .text_color(rgb(if can_prev { t.text_secondary } else { t.text_muted })) + .text_color(rgb(if can_prev { + t.text_secondary + } else { + t.text_muted + })) .when(can_prev, |d| { d.on_click(cx.listener(|this, _, _window, cx| this.prev_commit(cx))) }) .child("\u{25C0}") - .tooltip(move |_window, cx| Tooltip::new("Previous commit [").build(_window, cx)), + .tooltip(move |_window, cx| { + Tooltip::new("Previous commit [").build(_window, cx) + }), ) // Position .child( @@ -316,7 +345,11 @@ impl DiffViewer { .child( div() .id("commit-nav-next") - .cursor(if can_next { CursorStyle::PointingHand } else { CursorStyle::default() }) + .cursor(if can_next { + CursorStyle::PointingHand + } else { + CursorStyle::default() + }) .w(px(24.0)) .h(px(22.0)) .flex() @@ -325,7 +358,11 @@ impl DiffViewer { .rounded(px(4.0)) .when(can_next, |d| d.hover(|s| s.bg(rgb(t.bg_hover)))) .text_size(ui_text_md(cx)) - .text_color(rgb(if can_next { t.text_secondary } else { t.text_muted })) + .text_color(rgb(if can_next { + t.text_secondary + } else { + t.text_muted + })) .when(can_next, |d| { d.on_click(cx.listener(|this, _, _window, cx| this.next_commit(cx))) }) @@ -337,7 +374,11 @@ impl DiffViewer { // Commit metadata .when_some(commit.cloned(), |d, commit| { let hash = commit.hash.clone(); - let short = if hash.len() > 7 { hash[..7].to_string() } else { hash.clone() }; + let short = if hash.len() > 7 { + hash[..7].to_string() + } else { + hash.clone() + }; let hash_for_click = hash.clone(); let hash_for_rclick = hash.clone(); let time_str = okena_git::format_relative_time(commit.timestamp); @@ -355,15 +396,24 @@ impl DiffViewer { .rounded(px(3.0)) .hover(|s| s.bg(rgb(t.bg_hover))) .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(hash_for_click.clone())); + cx.write_to_clipboard(ClipboardItem::new_string( + hash_for_click.clone(), + )); }) .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, _, cx| { - this.open_commit_hash_menu(event.position, hash_for_rclick.clone(), cx); + this.open_commit_hash_menu( + event.position, + hash_for_rclick.clone(), + cx, + ); }), ) - .tooltip(|_window, cx| Tooltip::new("Click: copy hash \u{00B7} Right-click: more").build(_window, cx)) + .tooltip(|_window, cx| { + Tooltip::new("Click: copy hash \u{00B7} Right-click: more") + .build(_window, cx) + }) .child(short), ) // Author @@ -406,37 +456,27 @@ impl DiffViewer { .min_h_0() .when(loading, |d| { d.child( - div() - .flex_1() - .flex() - .items_center() - .justify_center() - .child( - div() - .text_size(ui_text_xl(cx)) - .text_color(rgb(t.text_muted)) - .child("Loading..."), - ), + div().flex_1().flex().items_center().justify_center().child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_muted)) + .child("Loading..."), + ), ) }) .when(!loading && has_error, |d| { d.child( - div() - .flex_1() - .flex() - .items_center() - .justify_center() - .child( - div() - .text_size(ui_text_xl(cx)) - .text_color(rgb(t.text_muted)) - .child(error_message.unwrap_or_default()), - ), + div().flex_1().flex().items_center().justify_center().child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_muted)) + .child(error_message.unwrap_or_default()), + ), ) }) .when(!loading && !has_error && has_files, |d| { - d.child(self.render_sidebar(t, tree_elements, cx)).child( - self.render_diff_pane( + d.child(self.render_sidebar(t, tree_elements, cx)) + .child(self.render_diff_pane( t, is_binary, file_path, @@ -444,8 +484,7 @@ impl DiffViewer { gutter_width, theme_colors, cx, - ), - ) + )) }) } @@ -453,37 +492,47 @@ impl DiffViewer { &self, t: &ThemeColors, tree_elements: Vec<AnyElement>, - cx: &App, + cx: &mut Context<Self>, ) -> impl IntoElement { - div() - .w(px(SIDEBAR_WIDTH)) - .h_full() - .border_r_1() + let header = div() + .px(px(16.0)) + .py(px(10.0)) + .border_b_1() .border_color(rgb(t.border)) - .bg(rgb(t.bg_primary)) - .flex() - .flex_col() - .child( - div() - .px(px(16.0)) - .py(px(10.0)) - .border_b_1() - .border_color(rgb(t.border)) - .text_size(ui_text_ms(cx)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(t.text_muted)) - .line_height(px(11.0)) - .child("Files"), - ) - .child( - div() - .id("file-tree") - .flex_1() - .overflow_y_scroll() - .track_scroll(&self.tree_scroll_handle) - .py(px(6.0)) - .children(tree_elements), - ) + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_muted)) + .line_height(px(11.0)) + .child("Files"); + let tree = div() + .id("file-tree") + .flex_1() + .overflow_y_scroll() + .track_scroll(&self.tree_scroll_handle) + .py(px(6.0)) + .children(tree_elements); + + let entity = cx.entity().downgrade(); + let entity_for_end = entity.clone(); + resizable_sidebar( + self.sidebar_resize.width(), + t.bg_primary, + t.border, + t.border_active, + vec![header.into_any_element(), tree.into_any_element()], + move |mouse_pos, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, _| { + this.sidebar_resize.start_resize(f32::from(mouse_pos.x)); + }); + } + }, + move |cx| { + if let Some(entity) = entity_for_end.upgrade() { + entity.update(cx, |this, _| this.sidebar_resize.end_resize()); + } + }, + ) } // GPUI render helper: params are render inputs (theme, flags, callbacks). @@ -544,17 +593,12 @@ impl DiffViewer { .children(search_bar) .when(is_binary, |d| { d.child( - div() - .flex_1() - .flex() - .items_center() - .justify_center() - .child( - div() - .text_size(ui_text_xl(cx)) - .text_color(rgb(t.text_muted)) - .child("Binary file - cannot display diff"), - ), + div().flex_1().flex().items_center().justify_center().child( + div() + .text_size(ui_text_xl(cx)) + .text_color(rgb(t.text_muted)) + .child("Binary file - cannot display diff"), + ), ) }) .when(!is_binary, |d| { @@ -571,14 +615,12 @@ impl DiffViewer { .child( uniform_list("diff-lines", item_count, move |range, _window, cx| { let tc = tc.clone(); - view.update(cx, |this, cx| { - match view_mode { - DiffViewMode::Unified => { - this.render_visible_lines(range, &tc, gutter_width, cx) - } - DiffViewMode::SideBySide => { - this.render_side_by_side_lines(range, &tc, cx) - } + view.update(cx, |this, cx| match view_mode { + DiffViewMode::Unified => { + this.render_visible_lines(range, &tc, gutter_width, cx) + } + DiffViewMode::SideBySide => { + this.render_side_by_side_lines(range, &tc, cx) } }) }) @@ -592,9 +634,11 @@ impl DiffViewer { list.style().restrict_scroll_to_axis = Some(true); list }) - .on_scroll_wheel(cx.listener(move |this, event: &ScrollWheelEvent, _window, cx| { - this.handle_scroll_x(event, cx); - })) + .on_scroll_wheel(cx.listener( + move |this, event: &ScrollWheelEvent, _window, cx| { + this.handle_scroll_x(event, cx); + }, + )) .track_scroll(&self.scroll_handle), ) .when_some(scrollbar_geometry, |d, (_, _, thumb_y, thumb_height)| { @@ -813,112 +857,108 @@ impl DiffViewer { ) } - pub(super) fn render_tree_node( + pub(super) fn render_file_tree( &self, - node: &FileTreeNode, - depth: usize, - parent_path: &str, t: &ThemeColors, cx: &mut Context<Self>, ) -> Vec<AnyElement> { - use okena_files::file_tree::{expandable_folder_row, expandable_file_row}; use super::context_menu::DiffTargetKind; let mut elements: Vec<AnyElement> = Vec::new(); - for (name, child) in &node.children { - let folder_path = if parent_path.is_empty() { - name.clone() - } else { - format!("{parent_path}/{name}") - }; - let is_expanded = self.expanded_folders.contains(&folder_path); - - let fp_toggle = folder_path.clone(); - let fp_menu = folder_path.clone(); - elements.push( - expandable_folder_row(name, depth, is_expanded, t, cx) - .id(ElementId::Name(format!("dv-folder-{}", folder_path).into())) - .on_click(cx.listener(move |this, _, _window, cx| { - this.toggle_folder(&fp_toggle, cx); - })) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, event: &MouseDownEvent, _, cx| { - this.open_context_menu( - event.position, - fp_menu.clone(), - DiffTargetKind::Folder, - cx, - ); - }), - ) - .into_any_element(), - ); - - if is_expanded { - elements.extend(self.render_tree_node(child, depth + 1, &folder_path, t, cx)); - } - } - - for &file_index in &node.files { - if let Some(file) = self.file_stats.get(file_index) { - let filename = file.path.rsplit('/').next().unwrap_or(&file.path); - let is_selected = file_index == self.selected_file_index; - let file_path_for_menu = file.path.clone(); - - let name_color = if file.is_new { - Some(t.diff_added_fg) - } else if file.is_deleted { - Some(t.diff_removed_fg) - } else { - None - }; + for row in self.file_tree_rows(false) { + match row { + FileTreeRow::Folder { + path, + name, + depth, + is_expanded, + } => { + let path_for_toggle = path.clone(); + let path_for_menu = path.clone(); + elements.push( + expandable_folder_row(&name, depth, is_expanded, t, cx) + .id(ElementId::Name(format!("dv-folder-{path}").into())) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_folder(&path_for_toggle, cx); + })) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, _, cx| { + this.open_context_menu( + event.position, + path_for_menu.clone(), + DiffTargetKind::Folder, + cx, + ); + }), + ) + .into_any_element(), + ); + } + FileTreeRow::File { + item: file_index, + depth, + } => { + let Some(file) = self.file_stats.get(file_index) else { + continue; + }; + let filename = file.path.rsplit('/').next().unwrap_or(&file.path); + let is_selected = file_index == self.selected_file_index; + let file_path_for_menu = file.path.clone(); + let name_color = if file.is_new { + Some(t.diff_added_fg) + } else if file.is_deleted { + Some(t.diff_removed_fg) + } else { + None + }; - elements.push( - expandable_file_row(filename, depth, name_color, false, t, cx) - .id(ElementId::Name(format!("tree-file-{}", file_index).into())) - .when(is_selected, |d| d.bg(rgb(t.bg_selection))) - .on_click(cx.listener(move |this, _, _window, cx| { - this.select_file(file_index, cx); - })) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, event: &MouseDownEvent, _, cx| { + elements.push( + expandable_file_row(filename, depth, name_color, false, t, cx) + .id(ElementId::Name(format!("tree-file-{file_index}").into())) + .when(is_selected, |d| d.bg(rgb(t.bg_selection))) + .on_click(cx.listener(move |this, _, _window, cx| { this.select_file(file_index, cx); - this.open_context_menu( - event.position, - file_path_for_menu.clone(), - DiffTargetKind::File, - cx, - ); - }), - ) - // Line counts - .when(file.added > 0 || file.removed > 0, |d| { - d.child( - h_flex() - .gap(px(4.0)) - .text_size(ui_text_ms(cx)) - .flex_shrink_0() - .when(file.added > 0, |d| { - d.child( - div() - .text_color(rgb(t.diff_added_fg)) - .child(format!("+{}", file.added)), - ) - }) - .when(file.removed > 0, |d| { - d.child( - div() - .text_color(rgb(t.diff_removed_fg)) - .child(format!("-{}", file.removed)), - ) - }), + })) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, _, cx| { + this.select_file(file_index, cx); + this.open_context_menu( + event.position, + file_path_for_menu.clone(), + DiffTargetKind::File, + cx, + ); + }), ) - }) - .into_any_element(), - ); + .when(file.added > 0 || file.removed > 0, |d| { + d.child( + h_flex() + .gap(px(4.0)) + .text_size(ui_text_ms(cx)) + .flex_shrink_0() + .when(file.added > 0, |d| { + d.child( + div() + .text_color(rgb(t.diff_added_fg)) + .child(format!("+{}", file.added)), + ) + }) + .when(file.removed > 0, |d| { + d.child( + div() + .text_color(rgb(t.diff_removed_fg)) + .child(format!("-{}", file.removed)), + ) + }), + ) + }) + .into_any_element(), + ); + } + FileTreeRow::Loading { .. } => {} } } @@ -956,10 +996,14 @@ impl Render for DiffViewer { let current_stats = self.file_stats.get(self.selected_file_index); let file_path = current_stats.map(|f| f.path.clone()).unwrap_or_default(); let is_binary = current_stats.map(|f| f.is_binary).unwrap_or(false); - let line_count = self.current_file.as_ref().map(|f| f.items.len()).unwrap_or(0); + let line_count = self + .current_file + .as_ref() + .map(|f| f.items.len()) + .unwrap_or(0); let tree_elements = if has_files { - self.render_tree_node(&self.file_tree.clone(), 0, "", &t, cx) + self.render_file_tree(&t, cx) } else { Vec::new() }; @@ -1033,6 +1077,10 @@ impl Render for DiffViewer { } })) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + let x = f32::from(event.position.x); + if this.sidebar_resize.update_resize(x) { + cx.notify(); + } if this.scrollbar_drag.is_some() { let y = f32::from(event.position.y); this.update_scrollbar_drag(y, cx); @@ -1063,13 +1111,38 @@ impl Render for DiffViewer { .child({ let needs_controls = self.is_detached && detached_needs_controls(window); let is_maximized = window.is_maximized(); - self.render_header(&t, has_files, self.file_stats.len(), total_added, total_removed, &diff_mode, self.ignore_whitespace, self.commit_message.as_deref(), needs_controls, is_maximized, cx) + self.render_header( + &t, + has_files, + self.file_stats.len(), + total_added, + total_removed, + &diff_mode, + self.ignore_whitespace, + self.commit_message.as_deref(), + needs_controls, + is_maximized, + cx, + ) }) // Commit info bar (when viewing a commit with navigation) .when(self.has_commits(), |d| { d.child(self.render_commit_info_bar(&t, cx)) }) - .child(self.render_content(&t, self.loading, has_error, error_message, has_files, is_binary, file_path, line_count, gutter_width, tree_elements, theme_colors, cx)) + .child(self.render_content( + &t, + self.loading, + has_error, + error_message, + has_files, + is_binary, + file_path, + line_count, + gutter_width, + tree_elements, + theme_colors, + cx, + )) .child(self.render_footer(&t, cx)) .children(self.render_context_overlays(&t, cx)) } diff --git a/crates/okena-views-git/src/diff_viewer/scrollbar.rs b/crates/okena-views-git/src/diff_viewer/scrollbar.rs index 369d7fe52..90c4044a1 100644 --- a/crates/okena-views-git/src/diff_viewer/scrollbar.rs +++ b/crates/okena-views-git/src/diff_viewer/scrollbar.rs @@ -1,7 +1,7 @@ //! Scrollbar handling for the diff viewer. -use super::types::ScrollbarDrag; use super::DiffViewer; +use super::types::ScrollbarDrag; use gpui::*; impl DiffViewer { @@ -48,7 +48,8 @@ impl DiffViewer { let Some(drag) = self.scrollbar_drag else { return; }; - let Some((viewport_height, content_height, _, thumb_height)) = self.get_scrollbar_geometry() + let Some((viewport_height, content_height, _, thumb_height)) = + self.get_scrollbar_geometry() else { return; }; @@ -65,7 +66,9 @@ impl DiffViewer { let new_scroll = (drag.start_scroll_y + delta_scroll).clamp(0.0, scrollable_content); let state = self.scroll_handle.0.borrow_mut(); - state.base_handle.set_offset(point(px(0.0), px(-new_scroll))); + state + .base_handle + .set_offset(point(px(0.0), px(-new_scroll))); drop(state); cx.notify(); @@ -140,11 +143,7 @@ impl DiffViewer { /// On Linux, Shift+scroll sends horizontal delta (delta.x) while delta.y /// is zero. We also handle the case where delta.y carries the scroll amount /// with shift held (some platforms/backends). - pub(super) fn handle_scroll_x( - &mut self, - event: &ScrollWheelEvent, - cx: &mut Context<Self>, - ) { + pub(super) fn handle_scroll_x(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) { let delta = event.delta.pixel_delta(px(17.0)); // Shift+scroll: use whichever axis has the delta let delta_x = if event.modifiers.shift && f32::from(delta.x).abs() < 0.5 { diff --git a/crates/okena-views-git/src/diff_viewer/search.rs b/crates/okena-views-git/src/diff_viewer/search.rs index 397f4766b..9c8e9bf40 100644 --- a/crates/okena-views-git/src/diff_viewer/search.rs +++ b/crates/okena-views-git/src/diff_viewer/search.rs @@ -16,8 +16,8 @@ use okena_ui::simple_input::InputChangedEvent; use std::ops::Range; use std::rc::Rc; -use super::types::{DisplayItem, SideBySideSide}; use super::DiffViewer; +use super::types::{DisplayItem, SideBySideSide}; impl DiffViewer { /// Open (or refocus) the in-page search bar. @@ -87,7 +87,8 @@ impl DiffViewer { DiffViewMode::Unified => cell, DiffViewMode::SideBySide => cell / 2, }; - self.scroll_handle.scroll_to_item(item, ScrollStrategy::Center); + self.scroll_handle + .scroll_to_item(item, ScrollStrategy::Center); } /// Recompute matches if content / query / case changed, then render the bar. @@ -103,7 +104,11 @@ impl DiffViewer { .as_ref() .map(|s| (s.input.read(cx).value().to_string(), s.case_sensitive()))?; - let items_len = self.current_file.as_ref().map(|f| f.items.len()).unwrap_or(0); + let items_len = self + .current_file + .as_ref() + .map(|f| f.items.len()) + .unwrap_or(0); let sbs_len = self.side_by_side_lines.len(); let sig = ( self.selected_file_index, @@ -138,7 +143,12 @@ impl DiffViewer { )) } - fn compute_matches(&self, view_mode: DiffViewMode, query: &str, case: bool) -> Vec<SearchMatch> { + fn compute_matches( + &self, + view_mode: DiffViewMode, + query: &str, + case: bool, + ) -> Vec<SearchMatch> { match view_mode { DiffViewMode::Unified => { let items = self @@ -160,8 +170,14 @@ impl DiffViewer { case, self.side_by_side_lines.iter().flat_map(|sbs| { [ - sbs.left.as_ref().map(|c| c.plain_text.as_str()).unwrap_or(""), - sbs.right.as_ref().map(|c| c.plain_text.as_str()).unwrap_or(""), + sbs.left + .as_ref() + .map(|c| c.plain_text.as_str()) + .unwrap_or(""), + sbs.right + .as_ref() + .map(|c| c.plain_text.as_str()) + .unwrap_or(""), ] }), ), diff --git a/crates/okena-views-git/src/diff_viewer/selection_ops.rs b/crates/okena-views-git/src/diff_viewer/selection_ops.rs index a47eb68cf..1165b21bc 100644 --- a/crates/okena-views-git/src/diff_viewer/selection_ops.rs +++ b/crates/okena-views-git/src/diff_viewer/selection_ops.rs @@ -1,11 +1,11 @@ //! Context expansion and text selection ops for the diff viewer. -use super::types::{self, DisplayItem, SideBySideSide}; use super::DiffViewer; +use super::types::{self, DisplayItem, SideBySideSide}; use okena_core::types::DiffViewMode; use okena_files::code_view::extract_selected_text; -use okena_files::selection::{copy_to_clipboard, Selection2DNonEmpty}; +use okena_files::selection::{Selection2DNonEmpty, copy_to_clipboard}; use gpui::*; @@ -52,11 +52,13 @@ impl DiffViewer { self.selection.clear(); self.selection_side = None; - let old_lines: Vec<&str> = self.current_file_old_content + let old_lines: Vec<&str> = self + .current_file_old_content .as_deref() .map(|c| c.lines().collect()) .unwrap_or_default(); - let new_lines: Vec<&str> = self.current_file_new_content + let new_lines: Vec<&str> = self + .current_file_new_content .as_deref() .map(|c| c.lines().collect()) .unwrap_or_default(); @@ -68,20 +70,31 @@ impl DiffViewer { let new_ln = new_start + i; let old_ln = old_start + i; - let spans = file.new_highlighted.get(&new_ln) + let spans = file + .new_highlighted + .get(&new_ln) .or_else(|| file.old_highlighted.get(&old_ln)) .cloned() .unwrap_or_default(); - let plain_text = new_lines.get(new_ln - 1) + let plain_text = new_lines + .get(new_ln - 1) .or_else(|| old_lines.get(old_ln - 1)) .unwrap_or(&"") .replace('\t', " "); new_items.push(DisplayItem::Line(types::DisplayLine { line_type: okena_git::DiffLineType::Context, - old_line_num: if old_ln >= 1 && old_ln <= file.old_line_count { Some(old_ln) } else { None }, - new_line_num: if new_ln >= 1 && new_ln <= file.new_line_count { Some(new_ln) } else { None }, + old_line_num: if old_ln >= 1 && old_ln <= file.old_line_count { + Some(old_ln) + } else { + None + }, + new_line_num: if new_ln >= 1 && new_ln <= file.new_line_count { + Some(new_ln) + } else { + None + }, spans, plain_text, })); @@ -109,7 +122,10 @@ impl DiffViewer { SideBySideSide::Left => &sbs_line.left, SideBySideSide::Right => &sbs_line.right, }; - content.as_ref().map(|c| c.plain_text.as_str()).unwrap_or("") + content + .as_ref() + .map(|c| c.plain_text.as_str()) + .unwrap_or("") }) } else { let file = self.current_file.as_ref()?; @@ -129,7 +145,9 @@ impl DiffViewer { /// Build a single-block code payload from the current file's selection. /// Returns None for empty/header-only selections, or when the current file /// is binary or pure-deletion. - pub(super) fn selection_to_send_payload(&self) -> Option<okena_core::send_payload::SendPayload> { + pub(super) fn selection_to_send_payload( + &self, + ) -> Option<okena_core::send_payload::SendPayload> { use okena_core::send_payload::{CodeBlock, SendPayload}; use std::path::PathBuf; @@ -174,7 +192,9 @@ impl DiffViewer { let mut last_num: usize = 0; let mut texts: Vec<String> = Vec::new(); for i in start..=end { - let DisplayItem::Line(line) = file.items.get(i)? else { continue }; + let DisplayItem::Line(line) = file.items.get(i)? else { + continue; + }; let line_num = line.new_line_num.or(line.old_line_num)?; if first_num.is_none() { first_num = Some(line_num); diff --git a/crates/okena-views-git/src/diff_viewer/side_by_side.rs b/crates/okena-views-git/src/diff_viewer/side_by_side.rs index 7fe8473f2..4750626c6 100644 --- a/crates/okena-views-git/src/diff_viewer/side_by_side.rs +++ b/crates/okena-views-git/src/diff_viewer/side_by_side.rs @@ -1,14 +1,16 @@ //! Side-by-side diff view transformation and rendering. -use super::line_render::{rgba, ACCENT_WIDTH}; -use super::types::{ChangedRange, DisplayItem, DisplayLine, SideBySideLine, SideBySideSide, SideContent}; use super::DiffViewer; -use okena_git::DiffLineType; -use okena_core::theme::ThemeColors; -use okena_files::selection::{Selection2DExtension, Selection2DNonEmpty}; -use okena_files::code_view::{find_word_boundaries, selection_bg_ranges}; +use super::line_render::{ACCENT_WIDTH, rgba}; +use super::types::{ + ChangedRange, DisplayItem, DisplayLine, SideBySideLine, SideBySideSide, SideContent, +}; use gpui::prelude::*; use gpui::*; +use okena_core::theme::ThemeColors; +use okena_files::code_view::{find_word_boundaries, selection_bg_ranges}; +use okena_files::selection::{Selection2DExtension, Selection2DNonEmpty}; +use okena_git::DiffLineType; /// Compute the changed character ranges between two strings. /// Returns (old_ranges, new_ranges) - the ranges in each string that differ. @@ -127,7 +129,9 @@ pub fn to_side_by_side(items: &[DisplayItem]) -> Vec<SideBySideLine> { // Collect consecutive removed lines let mut removed_lines = Vec::new(); while let Some(l) = get_line(i) { - if l.line_type != DiffLineType::Removed { break; } + if l.line_type != DiffLineType::Removed { + break; + } removed_lines.push(l); i += 1; } @@ -135,7 +139,9 @@ pub fn to_side_by_side(items: &[DisplayItem]) -> Vec<SideBySideLine> { // Collect following consecutive added lines let mut added_lines = Vec::new(); while let Some(l) = get_line(i) { - if l.line_type != DiffLineType::Added { break; } + if l.line_type != DiffLineType::Added { + break; + } added_lines.push(l); i += 1; } @@ -143,14 +149,13 @@ pub fn to_side_by_side(items: &[DisplayItem]) -> Vec<SideBySideLine> { // Pair them up with word-level diff let max_len = removed_lines.len().max(added_lines.len()); for j in 0..max_len { - let (old_ranges, new_ranges) = - if let (Some(old_line), Some(new_line)) = - (removed_lines.get(j), added_lines.get(j)) - { - compute_changed_ranges(&old_line.plain_text, &new_line.plain_text) - } else { - (vec![], vec![]) - }; + let (old_ranges, new_ranges) = if let (Some(old_line), Some(new_line)) = + (removed_lines.get(j), added_lines.get(j)) + { + compute_changed_ranges(&old_line.plain_text, &new_line.plain_text) + } else { + (vec![], vec![]) + }; let left = removed_lines.get(j).map(|l| SideContent { line_num: l.old_line_num.unwrap_or(0), @@ -218,9 +223,10 @@ impl DiffViewer { ) -> Vec<AnyElement> { range .filter_map(|i| { - self.side_by_side_lines - .get(i) - .map(|line| self.render_side_by_side_line(i, line, t, cx).into_any_element()) + self.side_by_side_lines.get(i).map(|line| { + self.render_side_by_side_line(i, line, t, cx) + .into_any_element() + }) }) .collect() } @@ -256,7 +262,14 @@ impl DiffViewer { .text_size(px(font_size)) .font_family("monospace") .flex() - .child(self.render_side_column_content(&left, SideBySideSide::Left, idx, t, line_height, cx)) + .child(self.render_side_column_content( + &left, + SideBySideSide::Left, + idx, + t, + line_height, + cx, + )) .child( div() .w(px(1.0)) @@ -264,7 +277,14 @@ impl DiffViewer { .bg(rgb(border_color)) .flex_shrink_0(), ) - .child(self.render_side_column_content(&right, SideBySideSide::Right, idx, t, line_height, cx)) + .child(self.render_side_column_content( + &right, + SideBySideSide::Right, + idx, + t, + line_height, + cx, + )) } /// Render one column (left or right) of a side-by-side line. @@ -327,45 +347,46 @@ impl DiffViewer { }; let mut column = div() - .id(ElementId::Name(format!("sbs-{}-{}", side_label, sbs_line_index).into())) + .id(ElementId::Name( + format!("sbs-{}-{}", side_label, sbs_line_index).into(), + )) .flex_1() .h(px(line_height)) .flex() .items_center() .overflow_hidden() - .on_mouse_down( - MouseButton::Left, - { - let text_layout = text_layout.clone(); - let sbs_plain_text = sbs_plain_text.clone(); - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - let col = text_layout.index_for_position(event.position) - .unwrap_or_else(|ix| ix) - .min(sbs_line_len); - if event.click_count >= 3 { - this.selection.start = Some((sbs_line_index, 0)); - this.selection.end = Some((sbs_line_index, sbs_line_len)); - this.selection.finish(); - } else if event.click_count == 2 { - let (start, end) = find_word_boundaries(&sbs_plain_text, col); - this.selection.start = Some((sbs_line_index, start)); - this.selection.end = Some((sbs_line_index, end)); - this.selection.finish(); - } else { - this.selection.start = Some((sbs_line_index, col)); - this.selection.end = Some((sbs_line_index, col)); - this.selection.is_selecting = true; - } - this.selection_side = Some(side); - cx.notify(); - }) - }, - ) + .on_mouse_down(MouseButton::Left, { + let text_layout = text_layout.clone(); + let sbs_plain_text = sbs_plain_text.clone(); + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let col = text_layout + .index_for_position(event.position) + .unwrap_or_else(|ix| ix) + .min(sbs_line_len); + if event.click_count >= 3 { + this.selection.start = Some((sbs_line_index, 0)); + this.selection.end = Some((sbs_line_index, sbs_line_len)); + this.selection.finish(); + } else if event.click_count == 2 { + let (start, end) = find_word_boundaries(&sbs_plain_text, col); + this.selection.start = Some((sbs_line_index, start)); + this.selection.end = Some((sbs_line_index, end)); + this.selection.finish(); + } else { + this.selection.start = Some((sbs_line_index, col)); + this.selection.end = Some((sbs_line_index, col)); + this.selection.is_selecting = true; + } + this.selection_side = Some(side); + cx.notify(); + }) + }) .on_mouse_move({ let text_layout = text_layout.clone(); cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { if this.selection.is_selecting && this.selection_side == Some(side) { - let col = text_layout.index_for_position(event.position) + let col = text_layout + .index_for_position(event.position) .unwrap_or_else(|ix| ix) .min(sbs_line_len); this.selection.end = Some((sbs_line_index, col)); @@ -429,16 +450,20 @@ impl DiffViewer { None => { // Empty side - very subtle background, with drag-through support div() - .id(ElementId::Name(format!("sbs-{}-{}", side_label, sbs_line_index).into())) + .id(ElementId::Name( + format!("sbs-{}-{}", side_label, sbs_line_index).into(), + )) .flex_1() .h(px(line_height)) .bg(rgba(t.bg_secondary, 0.5)) - .on_mouse_move(cx.listener(move |this, _event: &MouseMoveEvent, _window, cx| { - if this.selection.is_selecting && this.selection_side == Some(side) { - this.selection.end = Some((sbs_line_index, 0)); - cx.notify(); - } - })) + .on_mouse_move(cx.listener( + move |this, _event: &MouseMoveEvent, _window, cx| { + if this.selection.is_selecting && this.selection_side == Some(side) { + this.selection.end = Some((sbs_line_index, 0)); + cx.notify(); + } + }, + )) .on_mouse_up( MouseButton::Left, cx.listener(|this, _, _window, cx| { diff --git a/crates/okena-views-git/src/diff_viewer/syntax.rs b/crates/okena-views-git/src/diff_viewer/syntax.rs index 4cd19daa3..b8fa3cd99 100644 --- a/crates/okena-views-git/src/diff_viewer/syntax.rs +++ b/crates/okena-views-git/src/diff_viewer/syntax.rs @@ -1,13 +1,13 @@ //! Syntax highlighting for the diff viewer. use super::types::{DiffDisplayFile, DisplayItem, DisplayLine, ExpanderRow, HighlightedSpan}; -use okena_git::{DiffLineType, FileDiff}; -use okena_git::diff::DiffHunk; +use gpui::Rgba; +use okena_files::markdown_highlight::{highlight_markdown_file, is_markdown_path}; use okena_files::syntax::{ default_text_color, get_syntax_for_path, highlight_line, load_syntax_theme, }; -use okena_files::markdown_highlight::{highlight_markdown_file, is_markdown_path}; -use gpui::Rgba; +use okena_git::diff::DiffHunk; +use okena_git::{DiffLineType, FileDiff}; use std::collections::HashMap; use syntect::easy::HighlightLines; use syntect::parsing::SyntaxSet; @@ -77,11 +77,19 @@ pub fn process_file( let t1 = std::time::Instant::now(); let old_highlighted = old_content.as_deref().map(&highlight).unwrap_or_default(); - log::debug!("[process_file] highlight old: {:?}, lines: {}", t1.elapsed(), old_highlighted.len()); + log::debug!( + "[process_file] highlight old: {:?}, lines: {}", + t1.elapsed(), + old_highlighted.len() + ); let t2 = std::time::Instant::now(); let new_highlighted = new_content.as_deref().map(&highlight).unwrap_or_default(); - log::debug!("[process_file] highlight new: {:?}, lines: {}", t2.elapsed(), new_highlighted.len()); + log::debug!( + "[process_file] highlight new: {:?}, lines: {}", + t2.elapsed(), + new_highlighted.len() + ); let old_line_count = old_content.as_ref().map(|c| c.lines().count()).unwrap_or(0); let new_line_count = new_content.as_ref().map(|c| c.lines().count()).unwrap_or(0); @@ -158,7 +166,10 @@ pub fn process_file( } // Pre-compute per-hunk last line numbers (before draining) - let hunk_last_lines: Vec<(usize, usize)> = file.hunks.iter().enumerate() + let hunk_last_lines: Vec<(usize, usize)> = file + .hunks + .iter() + .enumerate() .map(|(idx, hunk)| last_line_nums(&hunk_items[idx], hunk)) .collect(); @@ -211,7 +222,12 @@ pub fn process_file( } } - log::debug!("[process_file] total: {:?}, display items: {}, file: {}", t_total.elapsed(), items.len(), path); + log::debug!( + "[process_file] total: {:?}, display items: {}, file: {}", + t_total.elapsed(), + items.len(), + path + ); DiffDisplayFile { items, old_highlighted, diff --git a/crates/okena-views-git/src/diff_viewer/types.rs b/crates/okena-views-git/src/diff_viewer/types.rs index 17221a67a..29dfa2442 100644 --- a/crates/okena-views-git/src/diff_viewer/types.rs +++ b/crates/okena-views-git/src/diff_viewer/types.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; -use okena_git::{DiffLineType, FileDiff}; -pub use okena_files::syntax::HighlightedSpan; pub use okena_core::types::DiffViewMode; +pub use okena_files::syntax::HighlightedSpan; +use okena_git::{DiffLineType, FileDiff}; pub use okena_files::file_tree::FileTreeNode; diff --git a/crates/okena-views-git/src/git_header.rs b/crates/okena-views-git/src/git_header.rs index c17e786e3..ce46e05d4 100644 --- a/crates/okena-views-git/src/git_header.rs +++ b/crates/okena-views-git/src/git_header.rs @@ -13,13 +13,13 @@ use crate::diff_viewer::provider::GitProvider; use crate::watcher::GitStatusWatcher; use gpui::*; -use std::sync::atomic::AtomicU64; use std::sync::Arc; +use std::sync::atomic::AtomicU64; mod branch_picker; +mod ci_checks_popover; mod commit_log; mod diff_popover; -mod ci_checks_popover; mod status_pill; #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -91,6 +91,7 @@ pub struct GitHeader { // ── Diff popover state ────────────────────────────────────────── diff_popover_visible: bool, diff_file_summaries: Vec<FileDiffSummary>, + diff_popover_error: Option<String>, hover_token: Arc<AtomicU64>, diff_stats_bounds: Bounds<Pixels>, @@ -98,6 +99,7 @@ pub struct GitHeader { commit_log_visible: bool, commit_log_entries: Vec<CommitLogEntry>, commit_log_loading: bool, + commit_log_error: Option<String>, commit_log_bounds: Bounds<Pixels>, commit_log_count: usize, commit_log_has_more: bool, @@ -151,9 +153,8 @@ impl GitHeader { .placeholder("Filter branches\u{2026}") .icon("icons/search.svg") }); - let branch_picker_create_name = cx.new(|cx| { - SimpleInputState::new(cx).placeholder("New branch name") - }); + let branch_picker_create_name = + cx.new(|cx| SimpleInputState::new(cx).placeholder("New branch name")); // Re-filter the branch list (and reset the keyboard selection) as the // user types. Without this the parent `GitHeader` wouldn't re-run its // own filtering when only the child input entity notifies. @@ -175,11 +176,13 @@ impl GitHeader { current_branch: None, diff_popover_visible: false, diff_file_summaries: Vec::new(), + diff_popover_error: None, hover_token: Arc::new(AtomicU64::new(0)), diff_stats_bounds: Bounds::default(), commit_log_visible: false, commit_log_entries: Vec::new(), commit_log_loading: false, + commit_log_error: None, commit_log_bounds: Bounds::default(), commit_log_count: 0, commit_log_has_more: false, @@ -218,10 +221,12 @@ impl GitHeader { pub fn set_git_provider(&mut self, provider: Arc<dyn GitProvider>, cx: &mut Context<Self>) { self.git_provider = provider; self.diff_file_summaries.clear(); + self.diff_popover_error = None; self.commit_log_entries.clear(); self.commit_log_count = 0; self.commit_log_has_more = false; self.commit_log_loading = false; + self.commit_log_error = None; self.commit_log_branches.clear(); cx.notify(); } diff --git a/crates/okena-views-git/src/git_header/branch_picker.rs b/crates/okena-views-git/src/git_header/branch_picker.rs index 31a1f0559..32160f414 100644 --- a/crates/okena-views-git/src/git_header/branch_picker.rs +++ b/crates/okena-views-git/src/git_header/branch_picker.rs @@ -56,11 +56,19 @@ impl GitHeader { let provider = self.git_provider.clone(); cx.spawn(async move |this: WeakEntity<Self>, cx| { - let list = smol::unblock(move || provider.list_branches_classified()).await; + let result = smol::unblock(move || provider.list_branches_classified()).await; let _ = this.update(cx, |this, cx| { - this.branch_picker_list = list; - if matches!(this.branch_picker_status, BranchPickerStatus::Loading) { - this.branch_picker_status = BranchPickerStatus::Idle; + match result { + Ok(list) => { + this.branch_picker_list = list; + if matches!(this.branch_picker_status, BranchPickerStatus::Loading) { + this.branch_picker_status = BranchPickerStatus::Idle; + } + } + Err(error) => { + this.branch_picker_list = BranchList::default(); + this.branch_picker_status = BranchPickerStatus::Error(error); + } } this.recompute_branch_filtered(cx); cx.notify(); @@ -251,10 +259,8 @@ impl GitHeader { let provider = self.git_provider.clone(); let name = raw.clone(); cx.spawn(async move |this: WeakEntity<Self>, cx| { - let result = smol::unblock(move || { - provider.create_and_checkout_branch(&name, None) - }) - .await; + let result = + smol::unblock(move || provider.create_and_checkout_branch(&name, None)).await; let _ = this.update(cx, |this, cx| match result { Ok(()) => { @@ -309,8 +315,12 @@ impl GitHeader { // Flat, display-ordered nav list (local-first). Cloned up-front so row // building can borrow `cx` mutably without also holding a borrow on // `self.branch_picker_filtered`. - let nav: Vec<(usize, BranchNavItem)> = - self.branch_picker_filtered.iter().cloned().enumerate().collect(); + let nav: Vec<(usize, BranchNavItem)> = self + .branch_picker_filtered + .iter() + .cloned() + .enumerate() + .collect(); let local: Vec<(usize, BranchNavItem)> = nav .iter() .filter(|(_, b)| b.kind == BranchKind::Local) @@ -322,10 +332,8 @@ impl GitHeader { .cloned() .collect(); let is_create = self.branch_picker_create_mode; - let is_working = - matches!(self.branch_picker_status, BranchPickerStatus::Working); - let is_loading = - matches!(self.branch_picker_status, BranchPickerStatus::Loading); + let is_working = matches!(self.branch_picker_status, BranchPickerStatus::Working); + let is_loading = matches!(self.branch_picker_status, BranchPickerStatus::Loading); let error = match &self.branch_picker_status { BranchPickerStatus::Error(msg) => Some(msg.clone()), _ => None, @@ -348,7 +356,11 @@ impl GitHeader { .items_center() .cursor_pointer() .text_size(ui_text_ms(cx)) - .text_color(rgb(if is_current { t.text_primary } else { t.text_secondary })) + .text_color(rgb(if is_current { + t.text_primary + } else { + t.text_secondary + })) .when(is_current, |d| d.font_weight(FontWeight::SEMIBOLD)) .when(is_selected, |d| d.bg(with_alpha(t.border_active, 0.15))) .hover(|s| s.bg(rgb(t.bg_hover))) @@ -356,9 +368,20 @@ impl GitHeader { svg() .path("icons/git-branch.svg") .size(px(10.0)) - .text_color(rgb(if is_remote { t.term_green } else { t.text_muted })), + .text_color(rgb(if is_remote { + t.term_green + } else { + t.text_muted + })), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .overflow_hidden() + .child(name), ) - .child(div().flex_1().min_w_0().text_ellipsis().overflow_hidden().child(name)) .when(is_current, |d| { d.child( div() @@ -386,60 +409,36 @@ impl GitHeader { }; deferred( - anchored() - .position(position) - .snap_to_window() - .child( - v_flex() - .id("branch-picker-popover") - .occlude() - .w(px(320.0)) - .max_h(px(420.0)) - .bg(rgb(t.bg_primary)) - .border_1() - .border_color(rgb(t.border)) - .rounded(px(8.0)) - .shadow_lg() - .on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.hide_branch_picker(cx); - })) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_scroll_wheel(|_, _, cx| { - cx.stop_propagation(); - }) - // Keyboard navigation. The focused filter/create input - // leaves arrows, Enter and Escape unhandled (it returns - // `KeyHandled::Ignored`/`NotHandled` without stopping - // propagation), so they bubble up to this popover. - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - let key = event.keystroke.key.as_str(); - if this.branch_picker_create_mode { - match key { - "enter" => { - this.create_branch_from_current(cx); - cx.stop_propagation(); - } - "escape" => { - this.hide_branch_picker(cx); - cx.stop_propagation(); - } - _ => {} - } - return; - } + anchored().position(position).snap_to_window().child( + v_flex() + .id("branch-picker-popover") + .occlude() + .w(px(320.0)) + .max_h(px(420.0)) + .bg(rgb(t.bg_primary)) + .border_1() + .border_color(rgb(t.border)) + .rounded(px(8.0)) + .shadow_lg() + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.hide_branch_picker(cx); + })) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + // Keyboard navigation. The focused filter/create input + // leaves arrows, Enter and Escape unhandled (it returns + // `KeyHandled::Ignored`/`NotHandled` without stopping + // propagation), so they bubble up to this popover. + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + if this.branch_picker_create_mode { match key { - "up" => { - this.select_prev_branch(cx); - cx.stop_propagation(); - } - "down" => { - this.select_next_branch(cx); - cx.stop_propagation(); - } "enter" => { - this.confirm_branch_selection(cx); + this.create_branch_from_current(cx); cx.stop_propagation(); } "escape" => { @@ -448,180 +447,200 @@ impl GitHeader { } _ => {} } - })) - // Filter / create input - .child( + return; + } + match key { + "up" => { + this.select_prev_branch(cx); + cx.stop_propagation(); + } + "down" => { + this.select_next_branch(cx); + cx.stop_propagation(); + } + "enter" => { + this.confirm_branch_selection(cx); + cx.stop_propagation(); + } + "escape" => { + this.hide_branch_picker(cx); + cx.stop_propagation(); + } + _ => {} + } + })) + // Filter / create input + .child( + div() + .px(px(10.0)) + .py(px(8.0)) + .border_b_1() + .border_color(rgb(t.border)) + .child(if is_create { + v_flex() + .gap(px(6.0)) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(format!( + "New branch from {}", + current + .clone() + .unwrap_or_else(|| "HEAD".to_string()) + )), + ) + .child( + SimpleInput::new(&self.branch_picker_create_name) + .text_size(ui_text_md(cx)), + ) + .into_any_element() + } else { + SimpleInput::new(&self.branch_picker_filter) + .text_size(ui_text_md(cx)) + .into_any_element() + }), + ) + // Error banner + .when_some(error, |d, msg| { + d.child( div() .px(px(10.0)) - .py(px(8.0)) - .border_b_1() - .border_color(rgb(t.border)) - .child(if is_create { - v_flex() - .gap(px(6.0)) - .child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child(format!( - "New branch from {}", - current.clone().unwrap_or_else(|| "HEAD".to_string()) - )), - ) - .child( - SimpleInput::new(&self.branch_picker_create_name) - .text_size(ui_text_md(cx)), - ) - .into_any_element() - } else { - SimpleInput::new(&self.branch_picker_filter) - .text_size(ui_text_md(cx)) - .into_any_element() - }), + .py(px(4.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.term_red)) + .child(msg), ) - // Error banner - .when_some(error, |d, msg| { - d.child( - div() - .px(px(10.0)) - .py(px(4.0)) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.term_red)) - .child(msg), - ) - }) - .when(!is_create, |d| { - let total = nav.len(); - let local_rows: Vec<AnyElement> = local - .iter() - .map(|(flat, b)| { - row( - b.name.clone(), - b.is_current, - *flat == selected, - BranchKind::Local, - format!("branch-picker-row-{}", flat), - cx, + }) + .when(!is_create, |d| { + let total = nav.len(); + let local_rows: Vec<AnyElement> = local + .iter() + .map(|(flat, b)| { + row( + b.name.clone(), + b.is_current, + *flat == selected, + BranchKind::Local, + format!("branch-picker-row-{}", flat), + cx, + ) + }) + .collect(); + let remote_rows: Vec<AnyElement> = remote + .iter() + .map(|(flat, b)| { + row( + b.name.clone(), + false, + *flat == selected, + BranchKind::Remote, + format!("branch-picker-row-{}", flat), + cx, + ) + }) + .collect(); + d.child( + v_flex() + .id("branch-picker-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&scroll) + .py(px(4.0)) + .when(is_loading && total == 0, |d| { + d.child( + div() + .px(px(10.0)) + .py(px(8.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child("Loading\u{2026}"), ) }) - .collect(); - let remote_rows: Vec<AnyElement> = remote - .iter() - .map(|(flat, b)| { - row( - b.name.clone(), - false, - *flat == selected, - BranchKind::Remote, - format!("branch-picker-row-{}", flat), - cx, + .when(!is_loading && total == 0, |d| { + d.child( + div() + .px(px(10.0)) + .py(px(8.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(if filter_text.is_empty() { + "No branches".to_string() + } else { + format!("No matches for \"{}\"", filter_text) + }), ) }) - .collect(); - d.child( - v_flex() - .id("branch-picker-list") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .track_scroll(&scroll) - .py(px(4.0)) - .when(is_loading && total == 0, |d| { - d.child( - div() - .px(px(10.0)) - .py(px(8.0)) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child("Loading\u{2026}"), - ) - }) - .when(!is_loading && total == 0, |d| { - d.child( - div() - .px(px(10.0)) - .py(px(8.0)) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child(if filter_text.is_empty() { - "No branches".to_string() - } else { - format!("No matches for \"{}\"", filter_text) - }), - ) - }) - .when(!local_rows.is_empty(), |d| { - d.child(section_header("LOCAL", cx)) - .children(local_rows) + .when(!local_rows.is_empty(), |d| { + d.child(section_header("LOCAL", cx)).children(local_rows) + }) + .when(!remote_rows.is_empty(), |d| { + d.child(section_header("REMOTE", cx)).children(remote_rows) + }), + ) + }) + .child( + h_flex() + .px(px(10.0)) + .py(px(6.0)) + .gap(px(8.0)) + .border_t_1() + .border_color(rgb(t.border)) + .items_center() + .child({ + let label = if is_create { "Cancel" } else { "+ New branch" }; + div() + .id("branch-picker-toggle-create") + .cursor_pointer() + .px(px(6.0)) + .py(px(3.0)) + .rounded(px(4.0)) + .hover(|s| s.bg(rgb(t.bg_hover))) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_secondary)) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); }) - .when(!remote_rows.is_empty(), |d| { - d.child(section_header("REMOTE", cx)) - .children(remote_rows) - }), - ) - }) - .child( - h_flex() - .px(px(10.0)) - .py(px(6.0)) - .gap(px(8.0)) - .border_t_1() - .border_color(rgb(t.border)) - .items_center() - .child({ - let label = if is_create { "Cancel" } else { "+ New branch" }; + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_branch_create_mode(window, cx); + })) + .child(label) + }) + .when(is_create, |d| { + d.child( div() - .id("branch-picker-toggle-create") + .id("branch-picker-create-confirm") .cursor_pointer() - .px(px(6.0)) + .px(px(8.0)) .py(px(3.0)) .rounded(px(4.0)) - .hover(|s| s.bg(rgb(t.bg_hover))) + .bg(rgb(t.term_cyan)) .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_secondary)) + .text_color(rgb(t.bg_primary)) + .opacity(if is_working { 0.5 } else { 1.0 }) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_branch_create_mode(window, cx); + .on_click(cx.listener(|this, _, _window, cx| { + this.create_branch_from_current(cx); })) - .child(label) - }) - .when(is_create, |d| { - d.child( - div() - .id("branch-picker-create-confirm") - .cursor_pointer() - .px(px(8.0)) - .py(px(3.0)) - .rounded(px(4.0)) - .bg(rgb(t.term_cyan)) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.bg_primary)) - .opacity(if is_working { 0.5 } else { 1.0 }) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_click(cx.listener(|this, _, _window, cx| { - this.create_branch_from_current(cx); - })) - .child("Create & checkout"), - ) - }) - .when(is_working, |d| { - d.child( - div() - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child("Working\u{2026}"), - ) - }), - ), - ), + .child("Create & checkout"), + ) + }) + .when(is_working, |d| { + d.child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child("Working\u{2026}"), + ) + }), + ), + ), ) .into_any_element() } - } /// Map a flat selection index (local-first) to its child position within the diff --git a/crates/okena-views-git/src/git_header/ci_checks_popover.rs b/crates/okena-views-git/src/git_header/ci_checks_popover.rs index 62fdf123c..be1ced01c 100644 --- a/crates/okena-views-git/src/git_header/ci_checks_popover.rs +++ b/crates/okena-views-git/src/git_header/ci_checks_popover.rs @@ -251,56 +251,54 @@ impl GitHeader { }); deferred( - anchored() - .position(position) - .snap_to_window() - .child( - v_flex() - .id("ci-checks-popover") - .occlude() - .w(px(360.0)) - .max_h(px(420.0)) - .bg(rgb(t.bg_primary)) - .border_1() - .border_color(rgb(t.border)) - .rounded(px(8.0)) - .shadow_lg() - .on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.hide_ci_checks(cx); - })) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_scroll_wheel(|_, _, cx| { - cx.stop_propagation(); - }) - .child(header) - .child({ - let body = v_flex() - .id("ci-checks-scroll") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .py(px(4.0)); - if checks.is_empty() { - body.child( - div() - .px(px(10.0)) - .py(px(8.0)) - .text_size(ui_text_sm(cx)) - .text_color(rgb(t.text_muted)) - .child("No checks reported"), - ) - } else { - body.children( - checks.into_iter().enumerate().map(|(i, c)| { - row(c, format!("ci-check-{}", i), cx) - }), - ) - } - }) - .when_some(footer, |d, f| d.child(f)), - ), + anchored().position(position).snap_to_window().child( + v_flex() + .id("ci-checks-popover") + .occlude() + .w(px(360.0)) + .max_h(px(420.0)) + .bg(rgb(t.bg_primary)) + .border_1() + .border_color(rgb(t.border)) + .rounded(px(8.0)) + .shadow_lg() + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.hide_ci_checks(cx); + })) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child(header) + .child({ + let body = v_flex() + .id("ci-checks-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .py(px(4.0)); + if checks.is_empty() { + body.child( + div() + .px(px(10.0)) + .py(px(8.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child("No checks reported"), + ) + } else { + body.children( + checks + .into_iter() + .enumerate() + .map(|(i, c)| row(c, format!("ci-check-{}", i), cx)), + ) + } + }) + .when_some(footer, |d, f| d.child(f)), + ), ) .into_any_element() } diff --git a/crates/okena-views-git/src/git_header/commit_log.rs b/crates/okena-views-git/src/git_header/commit_log.rs index 04a2798e8..1860cd256 100644 --- a/crates/okena-views-git/src/git_header/commit_log.rs +++ b/crates/okena-views-git/src/git_header/commit_log.rs @@ -29,6 +29,7 @@ impl GitHeader { self.commit_log_visible = true; self.commit_log_loading = true; + self.commit_log_error = None; self.commit_log_entries.clear(); self.commit_log_count = 0; self.commit_log_has_more = false; @@ -53,11 +54,21 @@ impl GitHeader { let _ = this.update(cx, |this, cx| { this.commit_log_loading = false; - let commit_count = entries.len(); - this.commit_log_has_more = commit_count >= page; - this.commit_log_count = commit_count; - this.commit_log_entries = entries; - this.commit_log_branches = branches; + let mut errors = Vec::new(); + match entries { + Ok(entries) => { + let commit_count = entries.len(); + this.commit_log_has_more = commit_count >= page; + this.commit_log_count = commit_count; + this.commit_log_entries = entries; + } + Err(error) => errors.push(error), + } + match branches { + Ok(branches) => this.commit_log_branches = branches, + Err(error) => errors.push(error), + } + this.commit_log_error = (!errors.is_empty()).then(|| errors.join("; ")); cx.notify(); }); }) @@ -69,6 +80,7 @@ impl GitHeader { self.commit_log_branch_picker = false; self.commit_log_branch_filter.clear(); self.commit_log_loading = true; + self.commit_log_error = None; self.commit_log_entries.clear(); self.commit_log_count = 0; self.commit_log_has_more = false; @@ -78,17 +90,20 @@ impl GitHeader { let page = COMMIT_PAGE_SIZE; cx.spawn(async move |this: WeakEntity<Self>, cx| { - let entries = smol::unblock(move || { - provider.get_commit_graph(page, branch.as_deref()) - }) - .await; + let result = + smol::unblock(move || provider.get_commit_graph(page, branch.as_deref())).await; let _ = this.update(cx, |this, cx| { this.commit_log_loading = false; - let commit_count = entries.len(); - this.commit_log_has_more = commit_count >= page; - this.commit_log_count = commit_count; - this.commit_log_entries = entries; + match result { + Ok(entries) => { + let commit_count = entries.len(); + this.commit_log_has_more = commit_count >= page; + this.commit_log_count = commit_count; + this.commit_log_entries = entries; + } + Err(error) => this.commit_log_error = Some(error), + } cx.notify(); }); }) @@ -101,6 +116,7 @@ impl GitHeader { } self.commit_log_loading = true; + self.commit_log_error = None; cx.notify(); let provider = self.git_provider.clone(); @@ -110,17 +126,21 @@ impl GitHeader { let new_total = already_loaded + page; cx.spawn(async move |this: WeakEntity<Self>, cx| { - let entries = smol::unblock(move || { - provider.get_commit_graph(new_total, branch.as_deref()) - }) - .await; + let result = + smol::unblock(move || provider.get_commit_graph(new_total, branch.as_deref())) + .await; let _ = this.update(cx, |this, cx| { this.commit_log_loading = false; - let commit_count = entries.len(); - this.commit_log_has_more = commit_count >= new_total; - this.commit_log_count = commit_count; - this.commit_log_entries = entries; + match result { + Ok(entries) => { + let commit_count = entries.len(); + this.commit_log_has_more = commit_count >= new_total; + this.commit_log_count = commit_count; + this.commit_log_entries = entries; + } + Err(error) => this.commit_log_error = Some(error), + } cx.notify(); }); }) @@ -164,56 +184,84 @@ impl GitHeader { None } else { let all_commits: Vec<CommitLogEntry> = self.commit_log_entries.clone(); - Some(Arc::new(move |hash: &str, msg: &str, commit_idx: usize, _window: &mut Window, cx: &mut App| { - let commit_hash = hash.to_string(); - let commit_msg = msg.to_string(); - let commits_vec = all_commits.clone(); - entity_handle.update(cx, |this: &mut GitHeader, cx| { - this.hide_commit_log(cx); - }); - request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(OverlayRequest::Project(ProjectOverlay { - project_id: project_id.clone(), - kind: ProjectOverlayKind::DiffViewer { - file: None, - mode: Some(DiffMode::Commit(commit_hash)), - commit_message: Some(commit_msg), - commits: Some(commits_vec), - commit_index: Some(commit_idx), - }, - }), cx); - }); - })) + Some(Arc::new( + move |hash: &str, + msg: &str, + commit_idx: usize, + _window: &mut Window, + cx: &mut App| { + let commit_hash = hash.to_string(); + let commit_msg = msg.to_string(); + let commits_vec = all_commits.clone(); + entity_handle.update(cx, |this: &mut GitHeader, cx| { + this.hide_commit_log(cx); + }); + request_broker.update(cx, |broker, cx| { + broker.push_overlay_request( + OverlayRequest::Project(ProjectOverlay { + project_id: project_id.clone(), + kind: ProjectOverlayKind::DiffViewer { + file: None, + mode: Some(DiffMode::Commit(commit_hash)), + commit_message: Some(commit_msg), + commits: Some(commits_vec), + commit_index: Some(commit_idx), + }, + }), + cx, + ); + }); + }, + )) }; let on_commit_right_click: crate::project_header::CommitRightClickCallback = { let entity_handle = cx.entity().clone(); - Some(Arc::new(move |hash: &str, position: Point<Pixels>, _window: &mut Window, cx: &mut App| { - let hash = hash.to_string(); - entity_handle.update(cx, |this, cx| { - let entry = this - .commit_log_entries - .iter() - .find(|e| e.hash == hash) - .cloned(); - if let Some(entry) = entry { - this.commit_row_menu = Some(CommitRowContextMenu { - position, - hash: entry.hash.clone(), - send_text: crate::commit_send::format_commit_entry(&entry), - }); - cx.notify(); - } - }); - })) + Some(Arc::new( + move |hash: &str, + position: Point<Pixels>, + _window: &mut Window, + cx: &mut App| { + let hash = hash.to_string(); + entity_handle.update(cx, |this, cx| { + let entry = this + .commit_log_entries + .iter() + .find(|e| e.hash == hash) + .cloned(); + if let Some(entry) = entry { + this.commit_row_menu = Some(CommitRowContextMenu { + position, + hash: entry.hash.clone(), + send_text: crate::commit_send::format_commit_entry(&entry), + }); + cx.notify(); + } + }); + }, + )) }; - project_header::render_commit_log_content( + let graph = project_header::render_commit_log_content( &self.commit_log_entries, self.commit_log_loading, on_commit_click, on_commit_right_click, t, cx, - ) + ); + div() + .when_some(self.commit_log_error.clone(), |d, error| { + d.child( + div() + .px(px(12.0)) + .py(px(8.0)) + .bg(rgba(0xff00001a)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.error)) + .child(error), + ) + }) + .child(graph) + .into_any_element() }; let popover = deferred( @@ -617,12 +665,13 @@ impl GitHeader { ) .child(menu_separator(t)) .child( - menu_item("commit-row-ctx-copy", "icons/copy.svg", "Copy Hash", t) - .on_click(cx.listener(move |this, _, _, cx| { + menu_item("commit-row-ctx-copy", "icons/copy.svg", "Copy Hash", t).on_click( + cx.listener(move |this, _, _, cx| { cx.write_to_clipboard(ClipboardItem::new_string(hash_for_copy.clone())); this.commit_row_menu = None; cx.notify(); - })), + }), + ), ); Some( diff --git a/crates/okena-views-git/src/git_header/diff_popover.rs b/crates/okena-views-git/src/git_header/diff_popover.rs index 907f0a0f8..192f5effa 100644 --- a/crates/okena-views-git/src/git_header/diff_popover.rs +++ b/crates/okena-views-git/src/git_header/diff_popover.rs @@ -5,6 +5,7 @@ use super::GitHeader; use crate::project_header; use okena_core::theme::ThemeColors; +use okena_ui::tokens::ui_text_ms; use okena_workspace::requests::{OverlayRequest, ProjectOverlay, ProjectOverlayKind}; use gpui::prelude::*; @@ -33,14 +34,25 @@ impl GitHeader { return; } - let summaries = smol::unblock(move || provider.get_diff_file_summary()).await; + let result = smol::unblock(move || provider.get_diff_file_summary()).await; let _ = this.update(cx, |this, cx| { - if hover_token.load(Ordering::SeqCst) == token && !summaries.is_empty() { - this.diff_file_summaries = summaries; - this.diff_popover_visible = true; - cx.notify(); + if hover_token.load(Ordering::SeqCst) != token { + return; + } + match result { + Ok(summaries) if summaries.is_empty() => return, + Ok(summaries) => { + this.diff_file_summaries = summaries; + this.diff_popover_error = None; + } + Err(error) => { + this.diff_file_summaries.clear(); + this.diff_popover_error = Some(error); + } } + this.diff_popover_visible = true; + cx.notify(); }); }) .detach(); @@ -74,37 +86,54 @@ impl GitHeader { /// Render the diff summary popover (anchored below the diff stats badge). pub fn render_diff_popover(&self, t: &ThemeColors, cx: &mut Context<Self>) -> AnyElement { - if !self.diff_popover_visible || self.diff_file_summaries.is_empty() { + if !self.diff_popover_visible + || (self.diff_file_summaries.is_empty() && self.diff_popover_error.is_none()) + { return div().size_0().into_any_element(); } let entity_handle = cx.entity().clone(); let request_broker = self.request_broker.clone(); let project_id = self.project_id.clone(); - let tree_elements = project_header::render_diff_file_list_interactive( - &self.diff_file_summaries, - move |file_path, _window, cx| { - let file_path = file_path.to_string(); - let pid = project_id.clone(); - entity_handle.update(cx, |this: &mut GitHeader, cx| { - this.hide_diff_popover(cx); - }); - request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(OverlayRequest::Project(ProjectOverlay { - project_id: pid, - kind: ProjectOverlayKind::DiffViewer { - file: Some(file_path), - mode: None, - commit_message: None, - commits: None, - commit_index: None, - }, - }), cx); - }); - }, - t, - cx, - ); + let body = if let Some(error) = &self.diff_popover_error { + div() + .px(px(12.0)) + .py(px(10.0)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.error)) + .child(error.clone()) + .into_any_element() + } else { + div() + .children(project_header::render_diff_file_list_interactive( + &self.diff_file_summaries, + move |file_path, _window, cx| { + let file_path = file_path.to_string(); + let pid = project_id.clone(); + entity_handle.update(cx, |this: &mut GitHeader, cx| { + this.hide_diff_popover(cx); + }); + request_broker.update(cx, |broker, cx| { + broker.push_overlay_request( + OverlayRequest::Project(ProjectOverlay { + project_id: pid, + kind: ProjectOverlayKind::DiffViewer { + file: Some(file_path), + mode: None, + commit_message: None, + commits: None, + commit_index: None, + }, + }), + cx, + ); + }); + }, + t, + cx, + )) + .into_any_element() + }; let bounds = self.diff_stats_bounds; let position = point( @@ -113,38 +142,35 @@ impl GitHeader { ); deferred( - anchored() - .position(position) - .snap_to_window() - .child( - div() - .id("diff-summary-popover") - .occlude() - .min_w(px(280.0)) - .max_w(px(400.0)) - .max_h(px(300.0)) - .overflow_y_scroll() - .bg(rgb(t.bg_primary)) - .border_1() - .border_color(rgb(t.border)) - .rounded(px(6.0)) - .shadow_lg() - .py(px(6.0)) - .on_hover(cx.listener(|this, hovered: &bool, _window, cx| { - if *hovered { - this.hover_token.fetch_add(1, Ordering::SeqCst); - } else { - this.hide_diff_popover(cx); - } - })) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_scroll_wheel(|_, _, cx| { - cx.stop_propagation(); - }) - .children(tree_elements), - ), + anchored().position(position).snap_to_window().child( + div() + .id("diff-summary-popover") + .occlude() + .min_w(px(280.0)) + .max_w(px(400.0)) + .max_h(px(300.0)) + .overflow_y_scroll() + .bg(rgb(t.bg_primary)) + .border_1() + .border_color(rgb(t.border)) + .rounded(px(6.0)) + .shadow_lg() + .py(px(6.0)) + .on_hover(cx.listener(|this, hovered: &bool, _window, cx| { + if *hovered { + this.hover_token.fetch_add(1, Ordering::SeqCst); + } else { + this.hide_diff_popover(cx); + } + })) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child(body), + ), ) .into_any_element() } diff --git a/crates/okena-views-git/src/git_header/status_pill.rs b/crates/okena-views-git/src/git_header/status_pill.rs index 53b56a873..037a0585f 100644 --- a/crates/okena-views-git/src/git_header/status_pill.rs +++ b/crates/okena-views-git/src/git_header/status_pill.rs @@ -58,56 +58,52 @@ impl GitHeader { let supports_switch = self.git_provider.supports_mutations(); let has_ci = status.ci_checks.is_some(); let pr_url = status.pr_info.as_ref().map(|p| p.url.clone()); - let on_branch_click: project_header::ClickCallback = - if supports_switch { - Some(Arc::new(move |window, app| { - entity_for_branch_click.update(app, |this, cx| { - if this.branch_picker_visible { - this.hide_branch_picker(cx); - } else { - this.show_branch_picker(window, cx); - } - }); - })) - } else { - None - }; + let on_branch_click: project_header::ClickCallback = if supports_switch { + Some(Arc::new(move |window, app| { + entity_for_branch_click.update(app, |this, cx| { + if this.branch_picker_visible { + this.hide_branch_picker(cx); + } else { + this.show_branch_picker(window, cx); + } + }); + })) + } else { + None + }; let on_pr_click: project_header::ClickCallback = pr_url.map(|url| -> project_header::ClickHandler { Arc::new(move |_window, _app| { open_url(&url); }) }); - let on_ci_click: project_header::ClickCallback = - if has_ci { - Some(Arc::new(move |_window, app| { - entity_for_ci_click.update(app, |this, cx| { - this.toggle_ci_checks(cx); - }); - })) - } else { - None - }; - let on_branch_bounds: project_header::BoundsCallback = - if supports_switch { - Some(Arc::new(move |bounds, app| { - entity_for_branch_bounds.update(app, |this, _cx| { - this.set_branch_chip_bounds(bounds); - }); - })) - } else { - None - }; - let on_ci_bounds: project_header::BoundsCallback = - if has_ci { - Some(Arc::new(move |bounds, app| { - entity_for_ci_bounds.update(app, |this, _cx| { - this.set_ci_badge_bounds(bounds); - }); - })) - } else { - None - }; + let on_ci_click: project_header::ClickCallback = if has_ci { + Some(Arc::new(move |_window, app| { + entity_for_ci_click.update(app, |this, cx| { + this.toggle_ci_checks(cx); + }); + })) + } else { + None + }; + let on_branch_bounds: project_header::BoundsCallback = if supports_switch { + Some(Arc::new(move |bounds, app| { + entity_for_branch_bounds.update(app, |this, _cx| { + this.set_branch_chip_bounds(bounds); + }); + })) + } else { + None + }; + let on_ci_bounds: project_header::BoundsCallback = if has_ci { + Some(Arc::new(move |bounds, app| { + entity_for_ci_bounds.update(app, |this, _cx| { + this.set_ci_badge_bounds(bounds); + }); + })) + } else { + None + }; project_header::render_branch_status( &status, project_header::BranchStatusCallbacks { @@ -124,7 +120,9 @@ impl GitHeader { .child({ let entity_for_bounds = entity_handle.clone(); div() - .id(ElementId::Name(format!("commit-log-btn-{}", project_id).into())) + .id(ElementId::Name( + format!("commit-log-btn-{}", project_id).into(), + )) .relative() .cursor_pointer() .flex() @@ -150,16 +148,21 @@ impl GitHeader { .child( canvas( move |bounds, _window, app| { - entity_for_bounds.update(app, |this: &mut GitHeader, _cx| { - this.commit_log_bounds = bounds; - }); + entity_for_bounds.update( + app, + |this: &mut GitHeader, _cx| { + this.commit_log_bounds = bounds; + }, + ); }, |_, _, _, _| {}, ) .absolute() .size_full(), ) - .tooltip(move |_window, cx| Tooltip::new("Commit Log").build(_window, cx)) + .tooltip(move |_window, cx| { + Tooltip::new("Commit Log").build(_window, cx) + }) }) // Diff stats (clickable, only if there are changes) .when(has_changes, |d: Div| { @@ -167,7 +170,9 @@ impl GitHeader { let project_id_for_click = self.project_id.clone(); d.child( project_header::render_diff_stats_badge(lines_added, lines_removed, t) - .id(ElementId::Name(format!("git-diff-stats-{}", project_id).into())) + .id(ElementId::Name( + format!("git-diff-stats-{}", project_id).into(), + )) .relative() .cursor_pointer() .rounded(px(3.0)) @@ -186,30 +191,37 @@ impl GitHeader { cx.stop_propagation(); this.hide_diff_popover(cx); request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(OverlayRequest::Project(ProjectOverlay { - project_id: project_id_for_click.clone(), - kind: ProjectOverlayKind::DiffViewer { - file: None, - mode: None, - commit_message: None, - commits: None, - commit_index: None, - }, - }), cx); + broker.push_overlay_request( + OverlayRequest::Project(ProjectOverlay { + project_id: project_id_for_click.clone(), + kind: ProjectOverlayKind::DiffViewer { + file: None, + mode: None, + commit_message: None, + commits: None, + commit_index: None, + }, + }), + cx, + ); }); })) // Invisible canvas to capture bounds for popover positioning - .child(canvas( - { - let entity_handle = entity_handle.clone(); - move |bounds, _window, app| { - entity_handle.update(app, |this, _cx| { - this.diff_stats_bounds = bounds; - }); - } - }, - |_, _, _, _| {}, - ).absolute().size_full()) + .child( + canvas( + { + let entity_handle = entity_handle.clone(); + move |bounds, _window, app| { + entity_handle.update(app, |this, _cx| { + this.diff_stats_bounds = bounds; + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ), ) }) // Commits to push (vs origin/<branch>): the standard green @@ -242,7 +254,9 @@ impl GitHeader { let head = review_head.clone().unwrap_or_else(|| "HEAD".to_string()); d.child( h_flex() - .id(ElementId::Name(format!("git-review-{}", project_id).into())) + .id(ElementId::Name( + format!("git-review-{}", project_id).into(), + )) .cursor_pointer() .items_center() .gap(px(3.0)) @@ -258,16 +272,22 @@ impl GitHeader { let base = base.clone(); let head = head.clone(); request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(OverlayRequest::Project(ProjectOverlay { - project_id: project_id_for_review.clone(), - kind: ProjectOverlayKind::DiffViewer { - file: None, - mode: Some(DiffMode::BranchCompare { base, head }), - commit_message: None, - commits: None, - commit_index: None, - }, - }), cx); + broker.push_overlay_request( + OverlayRequest::Project(ProjectOverlay { + project_id: project_id_for_review.clone(), + kind: ProjectOverlayKind::DiffViewer { + file: None, + mode: Some(DiffMode::BranchCompare { + base, + head, + }), + commit_message: None, + commits: None, + commit_index: None, + }, + }), + cx, + ); }); })) .child( diff --git a/crates/okena-views-git/src/lib.rs b/crates/okena-views-git/src/lib.rs index 78a60660a..cd79eea13 100644 --- a/crates/okena-views-git/src/lib.rs +++ b/crates/okena-views-git/src/lib.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod blame; +pub mod close_worktree_dialog; pub mod commit_send; pub mod diff_viewer; pub mod git_header; @@ -9,6 +10,5 @@ pub mod settings; pub mod simple_input; pub mod watcher; pub mod worktree_dialog; -pub mod close_worktree_dialog; gpui::actions!(okena_views_git, [Cancel]); diff --git a/crates/okena-views-git/src/project_header.rs b/crates/okena-views-git/src/project_header.rs index 2376d119f..20f3e0f60 100644 --- a/crates/okena-views-git/src/project_header.rs +++ b/crates/okena-views-git/src/project_header.rs @@ -20,7 +20,7 @@ mod graph; mod lane_layout; pub use branch_status::{ - render_branch_status, BoundsCallback, BranchStatusCallbacks, ClickCallback, ClickHandler, + BoundsCallback, BranchStatusCallbacks, ClickCallback, ClickHandler, render_branch_status, }; pub use commit_log::render_commit_log_content; pub use diff_tree::render_diff_file_list_interactive; @@ -224,6 +224,11 @@ pub fn render_diff_stats_badge(lines_added: usize, lines_removed: usize, t: &The d.child(render_sign_count("+", lines_added, t.term_green, 0.7)) }) .when(lines_removed > 0, |d| { - d.child(render_sign_count("\u{2212}", lines_removed, t.term_red, 0.7)) + d.child(render_sign_count( + "\u{2212}", + lines_removed, + t.term_red, + 0.7, + )) }) } diff --git a/crates/okena-views-git/src/project_header/branch_status.rs b/crates/okena-views-git/src/project_header/branch_status.rs index 9c59264fe..af2e09d19 100644 --- a/crates/okena-views-git/src/project_header/branch_status.rs +++ b/crates/okena-views-git/src/project_header/branch_status.rs @@ -175,12 +175,7 @@ pub fn render_branch_status( .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .child( - svg() - .path(icon) - .size(px(10.0)) - .text_color(rgb(color)), - ) + .child(svg().path(icon).size(px(10.0)).text_color(rgb(color))) .tooltip(move |_window, cx| Tooltip::new(tooltip.clone()).build(_window, cx)); if let Some(bcb) = on_ci_bounds { el = el.child( diff --git a/crates/okena-views-git/src/project_header/diff_tree.rs b/crates/okena-views-git/src/project_header/diff_tree.rs index 50eccd1e9..202bbd17d 100644 --- a/crates/okena-views-git/src/project_header/diff_tree.rs +++ b/crates/okena-views-git/src/project_header/diff_tree.rs @@ -1,13 +1,17 @@ //! Click-through file list rendered inside the diff summary popover. use okena_core::theme::ThemeColors; -use okena_files::file_tree::{build_file_tree, expandable_file_row, expandable_folder_row, FileTreeNode}; +use okena_files::file_tree::{ + FileTreeRow, build_file_tree, expandable_file_row, expandable_folder_row, + indexed_file_tree_rows, +}; use okena_git::FileDiffSummary; use okena_ui::tokens::ui_text_ms; use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; +use std::collections::HashSet; use std::sync::Arc; /// Called with the clicked file path inside the diff file list. @@ -25,12 +29,17 @@ pub fn render_diff_file_list_interactive( ) -> Vec<AnyElement> { let tree = build_file_tree(summaries.iter().enumerate().map(|(i, f)| (i, &f.path))); let on_file_click: FileClickCallback = Arc::new(on_file_click); - render_diff_tree_node(&tree, 0, summaries, &on_file_click, t, cx) + render_diff_tree_rows( + indexed_file_tree_rows(&tree, &HashSet::new(), true), + summaries, + &on_file_click, + t, + cx, + ) } -fn render_diff_tree_node( - node: &FileTreeNode, - depth: usize, +fn render_diff_tree_rows( + rows: Vec<FileTreeRow<usize>>, summaries: &[FileDiffSummary], on_file_click: &FileClickCallback, t: &ThemeColors, @@ -38,60 +47,62 @@ fn render_diff_tree_node( ) -> Vec<AnyElement> { let mut elements: Vec<AnyElement> = Vec::new(); - for (name, child) in &node.children { - elements.push( - expandable_folder_row(name, depth, true, t, cx) - .into_any_element(), - ); - elements.extend(render_diff_tree_node(child, depth + 1, summaries, on_file_click, t, cx)); - } - - for &file_index in &node.files { - if let Some(summary) = summaries.get(file_index) { - let filename = summary.path.rsplit('/').next().unwrap_or(&summary.path); - let is_deleted = summary.removed > 0 && summary.added == 0; - - let name_color = if summary.is_new { - Some(t.diff_added_fg) - } else if is_deleted { - Some(t.diff_removed_fg) - } else { - None - }; + for row in rows { + match row { + FileTreeRow::Folder { name, depth, .. } => { + elements.push(expandable_folder_row(&name, depth, true, t, cx).into_any_element()); + } + FileTreeRow::File { + item: file_index, + depth, + } => { + let Some(summary) = summaries.get(file_index) else { + continue; + }; + let filename = summary.path.rsplit('/').next().unwrap_or(&summary.path); + let is_deleted = summary.removed > 0 && summary.added == 0; + let name_color = if summary.is_new { + Some(t.diff_added_fg) + } else if is_deleted { + Some(t.diff_removed_fg) + } else { + None + }; + let file_path = summary.path.clone(); + let cb = on_file_click.clone(); - let file_path = summary.path.clone(); - let cb = on_file_click.clone(); - elements.push( - expandable_file_row(filename, depth, name_color, false, t, cx) - .id(ElementId::Name(format!("diff-file-{}", file_index).into())) - .on_click(move |_, window, cx| { - cb(&file_path, window, cx); - }) - // Line counts - .when(summary.added > 0 || summary.removed > 0, |d| { - d.child( - h_flex() - .gap(px(4.0)) - .text_size(ui_text_ms(cx)) - .flex_shrink_0() - .when(summary.added > 0, |d| { - d.child( - div() - .text_color(rgb(t.diff_added_fg)) - .child(format!("+{}", summary.added)), - ) - }) - .when(summary.removed > 0, |d| { - d.child( - div() - .text_color(rgb(t.diff_removed_fg)) - .child(format!("-{}", summary.removed)), - ) - }), - ) - }) - .into_any_element(), - ); + elements.push( + expandable_file_row(filename, depth, name_color, false, t, cx) + .id(ElementId::Name(format!("diff-file-{file_index}").into())) + .on_click(move |_, window, cx| { + cb(&file_path, window, cx); + }) + .when(summary.added > 0 || summary.removed > 0, |d| { + d.child( + h_flex() + .gap(px(4.0)) + .text_size(ui_text_ms(cx)) + .flex_shrink_0() + .when(summary.added > 0, |d| { + d.child( + div() + .text_color(rgb(t.diff_added_fg)) + .child(format!("+{}", summary.added)), + ) + }) + .when(summary.removed > 0, |d| { + d.child( + div() + .text_color(rgb(t.diff_removed_fg)) + .child(format!("-{}", summary.removed)), + ) + }), + ) + }) + .into_any_element(), + ); + } + FileTreeRow::Loading { .. } => {} } } diff --git a/crates/okena-views-git/src/project_header/graph.rs b/crates/okena-views-git/src/project_header/graph.rs index 4d4162d27..d4fed3c91 100644 --- a/crates/okena-views-git/src/project_header/graph.rs +++ b/crates/okena-views-git/src/project_header/graph.rs @@ -127,6 +127,7 @@ fn paint_rails( /// Render a ref label pill (e.g. "HEAD -> main", "origin/main", "tag: v1.0"). fn render_ref_label(ref_name: &str, t: &ThemeColors, cx: &App) -> AnyElement { + let line_h = ui_text_md(cx) * 1.25; let color = if ref_name.contains("HEAD") { t.term_cyan } else if ref_name.starts_with("tag:") { @@ -142,7 +143,9 @@ fn render_ref_label(ref_name: &str, t: &ThemeColors, cx: &App) -> AnyElement { }; div() .px(px(4.0)) - .py(px(1.0)) + .h(line_h) + .flex() + .items_center() .rounded(px(3.0)) .bg(bg) .text_size(ui_text_sm(cx)) @@ -177,6 +180,7 @@ pub(super) fn render_lane_row( cx: &App, ) -> AnyElement { let row_h = COMMIT_ROW_H; + let line_h = ui_text_md(cx) * 1.25; let graph_width = (max_col + 1) as f32 * GRAPH_CELL_W; let rails = row.rails.clone(); @@ -235,13 +239,18 @@ pub(super) fn render_lane_row( .min_w_0() .h(px(row_h)) .items_center() + .flex_nowrap() + .overflow_hidden() .gap(px(6.0)) .child( div() + .h(line_h) + .line_height(line_h) .text_size(ui_text_md(cx)) .text_color(rgb(t.text_primary)) .text_ellipsis() .overflow_hidden() + .whitespace_nowrap() .flex_shrink(1.0) .min_w_0() .child(entry.message.clone()), @@ -249,9 +258,12 @@ pub(super) fn render_lane_row( .children(entry.refs.iter().map(|r| render_ref_label(r, t, cx))) .child( div() + .h(line_h) + .line_height(line_h) .text_size(ui_text_ms(cx)) .text_color(rgb(t.text_muted)) .flex_shrink_0() + .ml_auto() .child(entry.author.clone()), ), ); diff --git a/crates/okena-views-git/src/project_header/lane_layout.rs b/crates/okena-views-git/src/project_header/lane_layout.rs index fcdc65a30..7b1b348d7 100644 --- a/crates/okena-views-git/src/project_header/lane_layout.rs +++ b/crates/okena-views-git/src/project_header/lane_layout.rs @@ -101,7 +101,10 @@ pub fn compute(commits: &[CommitLogEntry]) -> LaneLayout { let (dot_col, dot_lane) = if let Some(&first) = arriving.first() { // `arriving` indices come from a filter_map that only keeps Some lanes. #[allow(clippy::expect_used)] - (first, active[first].as_ref().expect("arriving lane is Some").id) + ( + first, + active[first].as_ref().expect("arriving lane is Some").id, + ) } else { (leftmost_free(&active), alloc(&mut next_id)) }; @@ -193,7 +196,11 @@ pub fn compute(commits: &[CommitLogEntry]) -> LaneLayout { // forked columns slant out of the dot column. for (i, lane) in next_active.iter().enumerate() { if let Some(lane) = lane { - let from = if new_fork_cols.contains(&i) { dot_col } else { i }; + let from = if new_fork_cols.contains(&i) { + dot_col + } else { + i + }; rails.push(Rail { lane_id: lane.id, from_col: from, @@ -204,14 +211,15 @@ pub fn compute(commits: &[CommitLogEntry]) -> LaneLayout { } // Dot lane merging into an existing lane: explicit slant. if let Some(cc) = cont_col - && cc != dot_col { - rails.push(Rail { - lane_id: dot_lane, - from_col: dot_col, - to_col: cc, - half: Half::Lower, - }); - } + && cc != dot_col + { + rails.push(Rail { + lane_id: dot_lane, + from_col: dot_col, + to_col: cc, + half: Half::Lower, + }); + } // Merges into already-existing lanes (extra parents sharing a target). for (id, ec) in extra_slants { rails.push(Rail { @@ -239,7 +247,10 @@ pub fn compute(commits: &[CommitLogEntry]) -> LaneLayout { } fn leftmost_free(active: &[Option<Lane>]) -> usize { - active.iter().position(|s| s.is_none()).unwrap_or(active.len()) + active + .iter() + .position(|s| s.is_none()) + .unwrap_or(active.len()) } fn ensure_slot(active: &mut Vec<Option<Lane>>, col: usize) { diff --git a/crates/okena-views-git/src/watcher.rs b/crates/okena-views-git/src/watcher.rs index a0da033ed..ce9558a3a 100644 --- a/crates/okena-views-git/src/watcher.rs +++ b/crates/okena-views-git/src/watcher.rs @@ -1,9 +1,10 @@ -use okena_git::{self as git, GitStatus}; -use okena_workspace::state::Workspace; use gpui::prelude::*; use gpui::*; use okena_core::api::ApiGitStatus; -use okena_core::process::{with_lane, Lane}; +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 std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, RwLock}; @@ -22,11 +23,15 @@ 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(), } } /// How often to poll git status (seconds) const GIT_POLL_INTERVAL: u64 = 5; +/// Cheap HEAD-only cadence used to wake the full status refresh after commits and checkouts. +const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(250); /// How many git poll cycles between PR URL checks (~60s) const PR_POLL_EVERY_N_CYCLES: u64 = 12; /// How many git poll cycles between CI check polls when checks are pending (~15s) @@ -37,6 +42,7 @@ const CI_SETTLED_POLL_EVERY_N_CYCLES: u64 = 12; /// Centralized git status poller. /// /// Polls git status for all locally visible and remotely subscribed (non-remote) projects every 5 seconds. +/// Polls HEAD metadata every 250ms to refresh immediately after commits and checkouts. /// Polls PR URLs less frequently (~60 seconds). /// Pushes changes to: /// - Local UI via `cx.notify()` (ProjectColumn observes this entity) @@ -50,6 +56,8 @@ pub struct GitStatusWatcher { ci_checks: HashMap<String, Option<okena_git::CiCheckSummary>>, /// Whether any project has pending CI checks (drives adaptive polling) any_pending_ci: bool, + /// Invalidates async results captured before a project's HEAD changed. + head_generations: HashMap<String, u64>, /// Watch channel sender for remote WS push remote_tx: Arc<tokio::sync::watch::Sender<HashMap<String, ApiGitStatus>>>, /// Per-connection set of subscribed terminal IDs from remote clients @@ -69,10 +77,12 @@ impl GitStatusWatcher { pr_infos: HashMap::new(), ci_checks: HashMap::new(), any_pending_ci: false, + head_generations: HashMap::new(), remote_tx, remote_subscribed_terminals, }; watcher.spawn_branch_warmup(cx); + watcher.spawn_head_poll(cx); watcher.spawn_refresh(cx); watcher } @@ -85,7 +95,10 @@ impl GitStatusWatcher { let workspace = self.workspace.clone(); cx.spawn(async move |_, cx| { let paths: Vec<String> = cx.update(|cx| { - workspace.read(cx).projects().iter() + workspace + .read(cx) + .projects() + .iter() .filter(|p| !p.is_remote) .map(|p| p.path.clone()) .collect() @@ -97,7 +110,80 @@ impl GitStatusWatcher { }) }); futures::future::join_all(futures).await; - }).detach(); + }) + .detach(); + } + + /// Watch visible repositories for HEAD changes without reading their index or worktree. + fn spawn_head_poll(&self, cx: &mut Context<Self>) { + let remote_subscribed_terminals = self.remote_subscribed_terminals.clone(); + + cx.spawn(async move |this: WeakEntity<Self>, cx| { + let mut previous = HashMap::<String, git::HeadSnapshot>::new(); + loop { + let projects: Vec<(String, String)> = match this.update(cx, |this, cx| { + let ws = this.workspace.read(cx); + let mut visible_ids = ws.all_visible_project_ids(); + if let Ok(remote_terminals) = remote_subscribed_terminals.read() { + for terminal_ids in remote_terminals.values() { + for terminal_id in terminal_ids { + if let Some(project) = ws.find_project_for_terminal(terminal_id) + && !project.is_remote + { + visible_ids.insert(project.id.clone()); + } + } + } + } + ws.projects() + .iter() + .filter(|project| !project.is_remote && visible_ids.contains(&project.id)) + .map(|project| (project.id.clone(), project.path.clone())) + .collect() + }) { + Ok(projects) => projects, + Err(_) => break, + }; + let active_ids: HashSet<String> = + projects.iter().map(|(id, _)| id.clone()).collect(); + let snapshots: HashMap<String, git::HeadSnapshot> = smol::unblock(move || { + projects + .into_iter() + .filter_map(|(id, path)| { + with_lane(Lane::Poll, || git::get_head_snapshot(Path::new(&path))) + .map(|snapshot| (id, snapshot)) + }) + .collect() + }) + .await; + let changed = update_head_snapshots(&mut previous, &active_ids, snapshots); + if !changed.is_empty() + && this + .update(cx, |this, cx| { + for (id, reference_changed) in changed { + *this.head_generations.entry(id.clone()).or_default() += 1; + this.ci_checks.remove(&id); + if reference_changed { + this.refresh_project(id, cx); + } else { + this.refresh_visible_project(id, cx); + } + } + this.any_pending_ci = this.ci_checks.values().any(|checks| { + checks + .as_ref() + .map(|s| s.status.is_pending()) + .unwrap_or(false) + }); + }) + .is_err() + { + break; + } + smol::Timer::after(HEAD_POLL_INTERVAL).await; + } + }) + .detach(); } /// Get cached git status for a project. @@ -122,6 +208,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<Self>) { cx.notify(); let api_statuses: HashMap<String, ApiGitStatus> = self .statuses @@ -133,12 +223,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>) { + 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>) { + 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<Self>) { + 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<Self>) { + 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<String> = 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<Self>, + ) { let path = self .workspace .read(cx) @@ -148,42 +304,128 @@ 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 + 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); + let head_generation = self + .head_generations .get(&project_id) - .and_then(|p| p.as_ref()) - .and_then(|p| p.base.clone()); + .copied() + .unwrap_or_default(); cx.spawn(async move |this: WeakEntity<Self>, cx| { + let status_path = path.clone(); let new_status = smol::unblock(move || { with_lane(Lane::Poll, || { - git::refresh_git_status_with_pr_base(Path::new(&path), pr_base.as_deref()) + git::refresh_git_status_with_pr_base( + Path::new(&status_path), + pr_base.as_deref(), + ) + }) + }) + .await; + + let applied = this + .update(cx, |this, cx| { + if this + .head_generations + .get(&project_id) + .copied() + .unwrap_or_default() + != head_generation + { + return false; + } + let mut new_status = new_status; + 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(); + } + + let changed = this.statuses.get(&project_id) != Some(&new_status); + this.statuses.insert(project_id.clone(), new_status); + + if changed { + this.publish_statuses(cx); + } + true + }) + .unwrap_or(false); + if !applied || !poll_github { + return; + } + + let (fetched_pr_info, fetched_ci_checks) = smol::unblock(move || { + with_lane(Lane::Poll, || { + let path = Path::new(&path); + if !git::repository::has_github_remote(path) { + return (None, None); + } + let pr_info = git::repository::get_pr_info(path); + let pr_number = pr_info.as_ref().map(|pr| pr.number); + let ci_checks = git::repository::get_ci_checks(path, pr_number); + (pr_info, ci_checks) }) }) .await; let _ = this.update(cx, |this, cx| { - let mut new_status = new_status; - 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(); + if this + .head_generations + .get(&project_id) + .copied() + .unwrap_or_default() + != head_generation + { + return; } + this.pr_infos.insert(project_id.clone(), fetched_pr_info); + this.ci_checks.insert(project_id.clone(), fetched_ci_checks); + this.any_pending_ci = this.ci_checks.values().any(|checks| { + checks + .as_ref() + .map(|s| s.status.is_pending()) + .unwrap_or(false) + }); - let changed = this.statuses.get(&project_id) != Some(&new_status); - this.statuses.insert(project_id, new_status); - + let mut changed = false; + if let Some(Some(status)) = this.statuses.get_mut(&project_id) { + let pr_info = this.pr_infos.get(&project_id).cloned().flatten(); + let ci_checks = this.ci_checks.get(&project_id).cloned().flatten(); + changed = status.pr_info != pr_info || status.ci_checks != ci_checks; + status.pr_info = pr_info; + status.ci_checks = ci_checks; + } if changed { - cx.notify(); - let api_statuses: HashMap<String, ApiGitStatus> = 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); } }); }) @@ -216,34 +458,37 @@ impl GitStatusWatcher { // // `gh_ids` is the same set; the expensive `gh` PR/CI fan-out is // gated further by cycle cadence below. - let (projects, gh_ids): (Vec<(String, String)>, HashSet<String>) = cx.update(|cx| { - let ws = workspace.read(cx); + let (projects, gh_ids): (Vec<(String, String)>, HashSet<String>) = + cx.update(|cx| { + let ws = workspace.read(cx); - let mut gh_ids = ws.all_visible_project_ids(); + let mut gh_ids = ws.all_visible_project_ids(); - // Add projects with remotely subscribed terminals — they're - // shown on a remote client, so poll their status + PR/CI too. - if let Ok(remote_terminals) = remote_subscribed_terminals.read() { - for terminal_ids in remote_terminals.values() { - for tid in terminal_ids { - if let Some(p) = ws.find_project_for_terminal(tid) - && !p.is_remote { + // Add projects with remotely subscribed terminals — they're + // shown on a remote client, so poll their status + PR/CI too. + if let Ok(remote_terminals) = remote_subscribed_terminals.read() { + for terminal_ids in remote_terminals.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()); } + } } } - } - // Resolve to (id, path) pairs — non-remote only (git status - // is local-only; `all_visible_project_ids` may include remote - // projects, which we must not run gix against). - let projects = ws.projects() - .iter() - .filter(|p| !p.is_remote && gh_ids.contains(&p.id)) - .map(|p| (p.id.clone(), p.path.clone())) - .collect(); - (projects, gh_ids) - }); + // Resolve to (id, path) pairs — non-remote only (git status + // is local-only; `all_visible_project_ids` may include remote + // projects, which we must not run gix against). + let projects = ws + .projects() + .iter() + .filter(|p| !p.is_remote && gh_ids.contains(&p.id)) + .map(|p| (p.id.clone(), p.path.clone())) + .collect(); + (projects, gh_ids) + }); // Skip the cycle-0 `gh` fan-out: at startup the app is already // busy spawning terminals/hooks, and git status (phase 1, gix) @@ -251,44 +496,76 @@ impl GitStatusWatcher { // and then follow their normal cadence — so the badges fill in // shortly after launch without a thundering herd of `gh` at the // worst moment. - let ci_poll_interval = if this.update(cx, |this, _| this.any_pending_ci).unwrap_or(false) { + let ci_poll_interval = if this + .update(cx, |this, _| this.any_pending_ci) + .unwrap_or(false) + { CI_PENDING_POLL_EVERY_N_CYCLES } 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 // the status fetch below re-points ahead/behind at what the PR // actually targets (e.g. `develop`) instead of the repo default // — recomputed every cycle from local refs, so no extra `gh`. - let pr_bases: HashMap<String, String> = this.update(cx, |this, _| { - this.pr_infos - .iter() - .filter_map(|(id, pr)| { - pr.as_ref().and_then(|p| p.base.clone()).map(|b| (id.clone(), b)) - }) - .collect() - }).unwrap_or_default(); + let pr_bases: HashMap<String, String> = this + .update(cx, |this, _| { + this.pr_infos + .iter() + .filter_map(|(id, pr)| { + pr.as_ref() + .and_then(|p| p.base.clone()) + .map(|b| (id.clone(), b)) + }) + .collect() + }) + .unwrap_or_default(); + let poll_generations: HashMap<String, u64> = this + .update(cx, |this, _| { + projects + .iter() + .map(|(id, _)| { + ( + id.clone(), + this.head_generations.get(id).copied().unwrap_or_default(), + ) + }) + .collect() + }) + .unwrap_or_default(); // Phase 1: Fetch git status for all projects in parallel - let status_futures: Vec<_> = projects.iter().map(|(id, path)| { - let id = id.clone(); - let path = path.clone(); - let pr_base = pr_bases.get(&id).cloned(); - async move { - let status = smol::unblock(move || { - with_lane(Lane::Poll, || { - git::refresh_git_status_with_pr_base(Path::new(&path), pr_base.as_deref()) + let status_futures: Vec<_> = projects + .iter() + .map(|(id, path)| { + let id = id.clone(); + let path = path.clone(); + let pr_base = pr_bases.get(&id).cloned(); + async move { + let status = smol::unblock(move || { + with_lane(Lane::Poll, || { + git::refresh_git_status_with_pr_base( + Path::new(&path), + pr_base.as_deref(), + ) + }) }) - }).await; - (id, status) - } - }).collect(); + .await; + (id, status) + } + }) + .collect(); let mut new_statuses: HashMap<String, Option<GitStatus>> = - futures::future::join_all(status_futures).await.into_iter().collect(); + futures::future::join_all(status_futures) + .await + .into_iter() + .collect(); // Commit git status NOW, before the slow `gh` PR/CI calls below. // git status comes from gix (fast, in-process); PR/CI come from @@ -296,7 +573,34 @@ 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 status_generations = poll_generations.clone(); + let force_gh_ids = match this.update(cx, |this, cx| { + new_statuses.retain(|id, _| { + this.head_generations.get(id).copied().unwrap_or_default() + == status_generations.get(id).copied().unwrap_or_default() + }); + let branch_changes: HashSet<String> = 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,105 +608,143 @@ 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<String, Option<okena_git::PrInfo>> = if check_prs { - let pr_futures: Vec<_> = projects.iter() - .filter(|(id, _)| gh_ids.contains(id)) - .map(|(id, path)| { - let id = id.clone(); - let path = path.clone(); - async move { - let pr_info = smol::unblock(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; - (id, pr_info) - } - }).collect(); - futures::future::join_all(pr_futures).await.into_iter().collect() - } else { - HashMap::new() - }; - - // Phase 3: Fetch CI check status — adaptive interval based on pending state. - // Runs for every project; uses `gh pr checks` when a PR is known, - // falls back to branch-level `check-runs`/`status` otherwise. - let new_ci_checks: HashMap<String, Option<okena_git::CiCheckSummary>> = if check_ci { - let pr_infos_snapshot: HashMap<String, Option<okena_git::PrInfo>> = if check_prs { - // Use freshly fetched PR info - new_pr_infos.clone() - } else { - // Use cached PR info - this.update(cx, |this, _| this.pr_infos.clone()).unwrap_or_default() - }; - let ci_futures: Vec<_> = projects.iter() - .filter(|(id, _)| gh_ids.contains(id)) + let mut new_pr_infos: HashMap<String, Option<okena_git::PrInfo>> = if check_prs { + let pr_futures: Vec<_> = projects + .iter() + .filter(|(id, _)| { + gh_ids.contains(id) && (pr_cadence || force_gh_ids.contains(id)) + }) .map(|(id, path)| { let id = id.clone(); let path = path.clone(); - let pr_number = pr_infos_snapshot.get(&id).and_then(|p| p.as_ref()).map(|p| p.number); async move { - let checks = smol::unblock(move || { + let pr_info = smol::unblock(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) + git::repository::get_pr_info(Path::new(&path)) }) - }).await; - (id, checks) + }) + .await; + (id, pr_info) } - }).collect(); - futures::future::join_all(ci_futures).await.into_iter().collect() + }) + .collect(); + futures::future::join_all(pr_futures) + .await + .into_iter() + .collect() } else { HashMap::new() }; + // Phase 3: Fetch CI check status — adaptive interval based on pending state. + // Runs for every project; uses `gh pr checks` when a PR is known, + // falls back to branch-level `check-runs`/`status` otherwise. + let mut new_ci_checks: HashMap<String, Option<okena_git::CiCheckSummary>> = + if check_ci { + let pr_infos_snapshot: HashMap<String, Option<okena_git::PrInfo>> = + if check_prs { + // Use freshly fetched PR info + new_pr_infos.clone() + } else { + // Use cached PR info + this.update(cx, |this, _| this.pr_infos.clone()) + .unwrap_or_default() + }; + let ci_futures: Vec<_> = projects + .iter() + .filter(|(id, _)| { + gh_ids.contains(id) && (ci_cadence || force_gh_ids.contains(id)) + }) + .map(|(id, path)| { + let id = id.clone(); + let path = path.clone(); + let pr_number = pr_infos_snapshot + .get(&id) + .and_then(|p| p.as_ref()) + .map(|p| p.number); + async move { + let checks = smol::unblock(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; + (id, checks) + } + }) + .collect(); + futures::future::join_all(ci_futures) + .await + .into_iter() + .collect() + } else { + HashMap::new() + }; + // Merge freshly-fetched PR/CI into the caches and re-commit the // statuses with that richer data. `commit_statuses` no-ops when // nothing changed since the early commit above. - let should_continue = this.update(cx, |this, cx| { - // Merge into caches rather than replace: when fullscreen narrows - // the visible set to a single project, the un-polled projects - // should keep their last-known values until they're polled again. - if check_prs { - for (id, pr) in new_pr_infos { - this.pr_infos.insert(id, pr); + let should_continue = this + .update(cx, |this, cx| { + let is_current = |id: &String| { + this.head_generations.get(id).copied().unwrap_or_default() + == poll_generations.get(id).copied().unwrap_or_default() + }; + new_statuses.retain(|id, _| is_current(id)); + new_pr_infos.retain(|id, _| is_current(id)); + new_ci_checks.retain(|id, _| is_current(id)); + // Merge into caches rather than replace: when fullscreen narrows + // the visible set to a single project, the un-polled projects + // should keep their last-known values until they're polled again. + if check_prs { + for (id, pr) in new_pr_infos { + this.pr_infos.insert(id, pr); + } } - } - if check_ci { - for (id, checks) in new_ci_checks { - this.ci_checks.insert(id, checks); + if check_ci { + for (id, checks) in new_ci_checks { + this.ci_checks.insert(id, checks); + } + this.any_pending_ci = this.ci_checks.values().any(|c| { + c.as_ref().map(|s| s.status.is_pending()).unwrap_or(false) + }); } - this.any_pending_ci = this.ci_checks.values() - .any(|c| c.as_ref().map(|s| s.status.is_pending()).unwrap_or(false)); - } - // Inject cached PR info + CI checks into statuses - for (id, status) in new_statuses.iter_mut() { - if let Some(status) = status.as_mut() { - status.pr_info = this.pr_infos.get(id).cloned().flatten(); - status.ci_checks = this.ci_checks.get(id).cloned().flatten(); + // Inject cached PR info + CI checks into statuses + for (id, status) in new_statuses.iter_mut() { + if let Some(status) = status.as_mut() { + status.pr_info = this.pr_infos.get(id).cloned().flatten(); + status.ci_checks = this.ci_checks.get(id).cloned().flatten(); + } } - } - this.commit_statuses(&new_statuses, cx); - true - }).unwrap_or(false); + this.commit_statuses(&new_statuses, cx); + true + }) + .unwrap_or(false); if !should_continue { break; @@ -411,6 +753,31 @@ impl GitStatusWatcher { cycle += 1; smol::Timer::after(Duration::from_secs(GIT_POLL_INTERVAL)).await; } - }).detach(); + }) + .detach(); } } + +fn update_head_snapshots( + previous: &mut HashMap<String, git::HeadSnapshot>, + active_ids: &HashSet<String>, + snapshots: HashMap<String, git::HeadSnapshot>, +) -> Vec<(String, bool)> { + previous.retain(|id, _| active_ids.contains(id)); + snapshots + .into_iter() + .filter_map(|(id, snapshot)| { + let changed = previous.get(&id).is_some_and(|old| old != &snapshot); + let reference_changed = previous + .get(&id) + .is_some_and(|old| snapshot.reference_changed(old)); + if changed { + previous.insert(id.clone(), snapshot); + Some((id, reference_changed)) + } else { + previous.insert(id, snapshot); + None + } + }) + .collect() +} diff --git a/crates/okena-views-git/src/worktree_dialog.rs b/crates/okena-views-git/src/worktree_dialog.rs index 142cc835b..05da057b8 100644 --- a/crates/okena-views-git/src/worktree_dialog.rs +++ b/crates/okena-views-git/src/worktree_dialog.rs @@ -3,126 +3,88 @@ //! //! 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}; +use okena_core::api::{ActionRequest, WorktreePullRequest}; +use okena_transport::remote_action::RemoteActionClient; use crate::simple_input::SimpleInputState; use gpui::prelude::*; use gpui::*; -use std::path::{Path, PathBuf}; - mod view; -#[derive(Clone, Debug)] -pub(super) struct PrInfo { - pub(super) number: u32, - pub(super) title: String, - pub(super) branch: String, -} - /// Events emitted by the worktree dialog #[derive(Clone)] 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<WorktreeDialogEvent> 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<Workspace>, - /// 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, + client: RemoteActionClient, + daemon_project_id: String, 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<String>, pub(super) filtered_branches: Vec<usize>, pub(super) selected_branch_index: Option<usize>, pub(super) branch_search_input: Entity<SimpleInputState>, pub(super) error_message: Option<String>, + pub(super) loading_branches: bool, pub(super) focus_handle: FocusHandle, pub(super) initialized: bool, pub(super) last_search_query: String, pub(super) pr_mode: bool, - pub(super) pr_list: Vec<PrInfo>, + pub(super) pr_list: Vec<WorktreePullRequest>, pub(super) loading_prs: bool, pub(super) pr_error: Option<String>, pub(super) selected_pr_branch: Option<String>, pub(super) prs_loaded_once: bool, - pub(super) path_template: String, - pub(super) hooks_config: HooksConfig, } impl WorktreeDialog { pub fn new( - workspace: Entity<Workspace>, + client: RemoteActionClient, + daemon_project_id: String, project_id: String, - project_path: String, - worktree_config: WorktreeConfig, - hooks_config: HooksConfig, - window_id: WindowId, cx: &mut Context<Self>, ) -> 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`. - 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); - - // Pre-generate a branch name suggestion - let generated_branch = okena_git::branch_names::generate_branch_name(&git_root); - let branch_search_input = cx.new(|cx| { - let mut input = SimpleInputState::new(cx) + SimpleInputState::new(cx) .placeholder("Search or create branch...") - .icon("icons/search.svg"); - input.set_value(&generated_branch, cx); - input + .icon("icons/search.svg") }); - let filtered_branches: Vec<usize> = (0..branches.len()).collect(); let focus_handle = cx.focus_handle(); - let path_template = worktree_config.path_template; - Self { - workspace, - window_id, + let mut dialog = Self { + client, + daemon_project_id, project_id, - project_path, - git_root, - subdir, - branches, - filtered_branches, + branches: Vec::new(), + filtered_branches: Vec::new(), selected_branch_index: None, branch_search_input, error_message: None, + loading_branches: true, focus_handle, initialized: false, last_search_query: String::new(), @@ -132,9 +94,70 @@ impl WorktreeDialog { pr_error: None, selected_pr_branch: None, prs_loaded_once: false, - path_template, - hooks_config, - } + }; + dialog.load_initial_data(cx); + dialog + } + + fn load_initial_data(&mut self, cx: &mut Context<Self>) { + let client = self.client.clone(); + let project_id = self.daemon_project_id.clone(); + cx.spawn(async move |this, cx| { + let (branches, generated_branch) = smol::unblock(move || { + let branches = client + .post_action(ActionRequest::GitBranches { + project_id: project_id.clone(), + }) + .and_then(|value| value.ok_or_else(|| "Missing branch list".to_string())) + .and_then(|value| { + serde_json::from_value::<Vec<String>>(value) + .map_err(|error| format!("Invalid branch list: {error}")) + }); + let generated_branch = client + .post_action(ActionRequest::GenerateWorktreeBranchName { project_id }) + .and_then(|value| { + value.ok_or_else(|| "Missing generated branch name".to_string()) + }) + .and_then(|value| { + value + .get("branch") + .and_then(serde_json::Value::as_str) + .map(String::from) + .ok_or_else(|| "Invalid generated branch name".to_string()) + }); + (branches, generated_branch) + }) + .await; + + let _ = cx.update(|cx| { + this.update(cx, |this, cx| { + let mut errors = Vec::new(); + match branches { + Ok(branches) => { + this.filtered_branches = (0..branches.len()).collect(); + this.branches = branches; + } + Err(error) => errors.push(error), + } + match generated_branch { + Ok(branch) => { + if this.branch_search_input.read(cx).value().is_empty() { + this.branch_search_input.update(cx, |input, cx| { + input.set_value(&branch, cx); + }); + } + } + Err(error) => errors.push(error), + } + if !errors.is_empty() { + this.error_message = Some(errors.join("; ")); + } + this.loading_branches = false; + cx.notify(); + }) + }); + }) + .detach(); } pub(super) fn filter_branches(&mut self, cx: &App) { @@ -149,7 +172,8 @@ impl WorktreeDialog { if query.is_empty() { self.filtered_branches = (0..self.branches.len()).collect(); } else { - self.filtered_branches = self.branches + self.filtered_branches = self + .branches .iter() .enumerate() .filter(|(_, b)| b.to_lowercase().contains(&query)) @@ -164,14 +188,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<Self>) { let (branch, create_branch) = if self.pr_mode { // PR mode: use selected PR branch @@ -201,7 +217,8 @@ impl WorktreeDialog { // No branch selected — use input text as new branch name let name = self.branch_search_input.read(cx).value().trim().to_string(); if name.is_empty() { - self.error_message = Some("Please select a branch or type a new branch name".to_string()); + self.error_message = + Some("Please select a branch or type a new branch name".to_string()); cx.notify(); return; } @@ -213,26 +230,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<Self>) { @@ -240,42 +248,20 @@ impl WorktreeDialog { self.pr_error = None; cx.notify(); - let project_path = self.project_path.clone(); + let client = self.client.clone(); + let project_id = self.daemon_project_id.clone(); cx.spawn(async move |this, cx| { let result = smol::unblock(move || { - let output = okena_core::process::safe_output( - command("gh") - .args(["pr", "list", "--json", "number,title,headRefName", "--limit", "20"]) - .current_dir(&project_path), - ); - - match output { - Ok(output) if output.status.success() => { - let stdout = String::from_utf8_lossy(&output.stdout); - let parsed: Result<Vec<serde_json::Value>, _> = serde_json::from_str(&stdout); - match parsed { - Ok(items) => { - let prs: Vec<PrInfo> = items - .into_iter() - .filter_map(|v| { - Some(PrInfo { - number: v.get("number")?.as_u64()? as u32, - title: v.get("title")?.as_str()?.to_string(), - branch: v.get("headRefName")?.as_str()?.to_string(), - }) - }) - .collect(); - Ok(prs) - } - Err(e) => Err(format!("Failed to parse PR data: {}", e)), - } - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(stderr.trim().to_string()) - } - Err(_) => Err("GitHub CLI not found. Install gh: https://cli.github.com".to_string()), - } + client + .post_action(ActionRequest::GitListPullRequests { + project_id, + limit: 20, + }) + .and_then(|value| value.ok_or_else(|| "Missing pull request list".to_string())) + .and_then(|value| { + serde_json::from_value::<Vec<WorktreePullRequest>>(value) + .map_err(|error| format!("Invalid pull request list: {error}")) + }) }) .await; diff --git a/crates/okena-views-git/src/worktree_dialog/view.rs b/crates/okena-views-git/src/worktree_dialog/view.rs index 4c4e2c0e9..366e2c0b9 100644 --- a/crates/okena-views-git/src/worktree_dialog/view.rs +++ b/crates/okena-views-git/src/worktree_dialog/view.rs @@ -2,8 +2,8 @@ //! input, list of branches or PRs, footer buttons. use super::WorktreeDialog; -use crate::simple_input::SimpleInput; use crate::Cancel; +use crate::simple_input::SimpleInput; use okena_core::theme::ThemeColors; use okena_files::theme::theme; @@ -54,55 +54,53 @@ impl WorktreeDialog { .flex_col() .max_h(px(200.0)) .overflow_y_scroll() - .children( - self.pr_list.iter().enumerate().map(|(idx, pr)| { - let is_selected = self.selected_pr_branch.as_deref() == Some(&pr.branch); - let branch = pr.branch.clone(); + .children(self.pr_list.iter().enumerate().map(|(idx, pr)| { + let is_selected = self.selected_pr_branch.as_deref() == Some(&pr.branch); + let branch = pr.branch.clone(); - div() - .id(ElementId::Name(format!("pr-{}", idx).into())) - .px(px(12.0)) - .py(px(6.0)) - .flex() - .flex_col() - .gap(px(2.0)) - .cursor_pointer() - .when(is_selected, |d| d.bg(rgb(t.bg_selection))) - .hover(|s| s.bg(rgb(t.bg_hover))) - .on_click(cx.listener(move |this, _, _window, cx| { - this.selected_pr_branch = Some(branch.clone()); - this.selected_branch_index = None; - cx.notify(); - })) - .child( - h_flex() - .gap(px(6.0)) - .items_center() - .child( - div() - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .child(format!("#{}", pr.number)) - ) - .child( - div() - .text_size(ui_text_md(cx)) - .text_color(rgb(t.text_primary)) - .flex_1() - .overflow_x_hidden() - .whitespace_nowrap() - .child(pr.title.clone()) - ) - ) - .child( - div() - .pl(px(28.0)) - .text_size(ui_text_ms(cx)) - .text_color(rgb(t.text_muted)) - .child(pr.branch.clone()) - ) - }) - ) + div() + .id(ElementId::Name(format!("pr-{}", idx).into())) + .px(px(12.0)) + .py(px(6.0)) + .flex() + .flex_col() + .gap(px(2.0)) + .cursor_pointer() + .when(is_selected, |d| d.bg(rgb(t.bg_selection))) + .hover(|s| s.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.selected_pr_branch = Some(branch.clone()); + this.selected_branch_index = None; + cx.notify(); + })) + .child( + h_flex() + .gap(px(6.0)) + .items_center() + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(format!("#{}", pr.number)), + ) + .child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_primary)) + .flex_1() + .overflow_x_hidden() + .whitespace_nowrap() + .child(pr.title.clone()), + ), + ) + .child( + div() + .pl(px(28.0)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(pr.branch.clone()), + ) + })) .into_any_element() } @@ -111,6 +109,15 @@ impl WorktreeDialog { t: ThemeColors, cx: &mut Context<Self>, ) -> impl IntoElement { + if self.loading_branches { + return div() + .p(px(12.0)) + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_muted)) + .child("Loading branches...") + .into_any_element(); + } + let search_empty = self.branch_search_input.read(cx).value().is_empty(); if self.filtered_branches.is_empty() { @@ -132,8 +139,8 @@ impl WorktreeDialog { .flex_col() .max_h(px(200.0)) .overflow_y_scroll() - .children( - self.filtered_branches.iter().enumerate().map(|(filtered_idx, &branch_idx)| { + .children(self.filtered_branches.iter().enumerate().map( + |(filtered_idx, &branch_idx)| { let is_selected = self.selected_branch_index == Some(filtered_idx); let branch_name = self.branches[branch_idx].clone(); @@ -153,15 +160,15 @@ impl WorktreeDialog { svg() .path("icons/git-branch.svg") .size(px(14.0)) - .text_color(rgb(t.text_secondary)) + .text_color(rgb(t.text_secondary)), ) .child(branch_name) .on_click(cx.listener(move |this, _, _window, cx| { this.selected_branch_index = Some(filtered_idx); cx.notify(); })) - }) - ) + }, + )) .into_any_element() } } @@ -190,7 +197,11 @@ impl Render for WorktreeDialog { self.filter_branches(cx); let branch_search_input = self.branch_search_input.clone(); - let search_input_focused = self.branch_search_input.read(cx).focus_handle(cx).is_focused(window); + let search_input_focused = self + .branch_search_input + .read(cx) + .focus_handle(cx) + .is_focused(window); let pr_mode = self.pr_mode; div() @@ -201,30 +212,34 @@ impl Render for WorktreeDialog { this.close(cx); })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - let search_focused = this.branch_search_input.read(cx).focus_handle(cx).is_focused(window); + let search_focused = this + .branch_search_input + .read(cx) + .focus_handle(cx) + .is_focused(window); match event.keystroke.key.as_str() { "up" => { if search_focused && let Some(idx) = this.selected_branch_index - && idx > 0 { - this.selected_branch_index = Some(idx - 1); - cx.notify(); - } + && idx > 0 + { + this.selected_branch_index = Some(idx - 1); + cx.notify(); + } } - "down" - if search_focused => { - let max = this.filtered_branches.len().saturating_sub(1); - if let Some(idx) = this.selected_branch_index { - if idx < max { - this.selected_branch_index = Some(idx + 1); - cx.notify(); - } - } else if !this.filtered_branches.is_empty() { - this.selected_branch_index = Some(0); + "down" if search_focused => { + let max = this.filtered_branches.len().saturating_sub(1); + if let Some(idx) = this.selected_branch_index { + if idx < max { + this.selected_branch_index = Some(idx + 1); cx.notify(); } + } else if !this.filtered_branches.is_empty() { + this.selected_branch_index = Some(0); + cx.notify(); } + } "enter" => { this.create_worktree(cx); } @@ -237,9 +252,12 @@ impl Render for WorktreeDialog { .items_center() .justify_center() .bg(rgba(0x00000080)) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child( div() .id("worktree-dialog") @@ -272,15 +290,15 @@ impl Render for WorktreeDialog { svg() .path("icons/git-branch.svg") .size(px(16.0)) - .text_color(rgb(t.border_active)) + .text_color(rgb(t.border_active)), ) .child( div() .text_size(ui_text_xl(cx)) .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(t.text_primary)) - .child("Create Worktree") - ) + .child("Create Worktree"), + ), ) .child( div() @@ -297,111 +315,103 @@ impl Render for WorktreeDialog { svg() .path("icons/close.svg") .size(px(14.0)) - .text_color(rgb(t.text_secondary)) + .text_color(rgb(t.text_secondary)), ) .on_click(cx.listener(|this, _, _window, cx| { this.close(cx); - })) - ) + })), + ), ) // Content .child( - div() - .flex_1() - .overflow_hidden() - .flex() - .flex_col() - .child( - div() - .px(px(16.0)) - .py(px(12.0)) - .flex() - .flex_col() - .gap(px(8.0)) - // Mode toggle tabs - .child( - h_flex() - .gap(px(0.0)) - .border_1() - .border_color(rgb(t.border)) - .rounded(px(4.0)) - .overflow_hidden() - .child( - div() - .id("tab-branches") - .flex_1() - .px(px(12.0)) - .py(px(6.0)) - .flex() - .items_center() - .justify_center() - .text_size(ui_text_md(cx)) - .cursor_pointer() - .when(!pr_mode, |d| { - d.bg(rgb(t.bg_selection)) - .text_color(rgb(t.text_primary)) - .font_weight(FontWeight::SEMIBOLD) - }) - .when(pr_mode, |d| { - d.text_color(rgb(t.text_muted)) - .hover(|s| s.bg(rgb(t.bg_hover))) - }) - .child("Branches") - .on_click(cx.listener(|this, _, _window, cx| { - this.pr_mode = false; - this.selected_pr_branch = None; - cx.notify(); - })) - ) - .child( - div() - .w(px(1.0)) - .h_full() - .bg(rgb(t.border)) - ) - .child( - div() - .id("tab-from-pr") - .flex_1() - .px(px(12.0)) - .py(px(6.0)) - .flex() - .items_center() - .justify_center() - .text_size(ui_text_md(cx)) - .cursor_pointer() - .when(pr_mode, |d| { - d.bg(rgb(t.bg_selection)) - .text_color(rgb(t.text_primary)) - .font_weight(FontWeight::SEMIBOLD) - }) - .when(!pr_mode, |d| { - d.text_color(rgb(t.text_muted)) - .hover(|s| s.bg(rgb(t.bg_hover))) - }) - .child("From PR") - .on_click(cx.listener(|this, _, _window, cx| { - this.pr_mode = true; - this.selected_branch_index = None; - if !this.prs_loaded_once { - this.prs_loaded_once = true; - this.load_prs(cx); - } - cx.notify(); - })) - ) - ) - // Search input (only in branch mode) - .when(!pr_mode, |d| { - d.child( - input_container(&t, Some(search_input_focused)) - .child(SimpleInput::new(&branch_search_input).text_size(ui_text_md(cx))), + div().flex_1().overflow_hidden().flex().flex_col().child( + div() + .px(px(16.0)) + .py(px(12.0)) + .flex() + .flex_col() + .gap(px(8.0)) + // Mode toggle tabs + .child( + h_flex() + .gap(px(0.0)) + .border_1() + .border_color(rgb(t.border)) + .rounded(px(4.0)) + .overflow_hidden() + .child( + div() + .id("tab-branches") + .flex_1() + .px(px(12.0)) + .py(px(6.0)) + .flex() + .items_center() + .justify_center() + .text_size(ui_text_md(cx)) + .cursor_pointer() + .when(!pr_mode, |d| { + d.bg(rgb(t.bg_selection)) + .text_color(rgb(t.text_primary)) + .font_weight(FontWeight::SEMIBOLD) + }) + .when(pr_mode, |d| { + d.text_color(rgb(t.text_muted)) + .hover(|s| s.bg(rgb(t.bg_hover))) + }) + .child("Branches") + .on_click(cx.listener(|this, _, _window, cx| { + this.pr_mode = false; + this.selected_pr_branch = None; + cx.notify(); + })), ) - }) - // Branch list or PR list - .when(!pr_mode, |d| d.child(self.render_branch_list(t, cx))) - .when(pr_mode, |d| d.child(self.render_pr_list(t, cx))) - ) + .child(div().w(px(1.0)).h_full().bg(rgb(t.border))) + .child( + div() + .id("tab-from-pr") + .flex_1() + .px(px(12.0)) + .py(px(6.0)) + .flex() + .items_center() + .justify_center() + .text_size(ui_text_md(cx)) + .cursor_pointer() + .when(pr_mode, |d| { + d.bg(rgb(t.bg_selection)) + .text_color(rgb(t.text_primary)) + .font_weight(FontWeight::SEMIBOLD) + }) + .when(!pr_mode, |d| { + d.text_color(rgb(t.text_muted)) + .hover(|s| s.bg(rgb(t.bg_hover))) + }) + .child("From PR") + .on_click(cx.listener(|this, _, _window, cx| { + this.pr_mode = true; + this.selected_branch_index = None; + if !this.prs_loaded_once { + this.prs_loaded_once = true; + this.load_prs(cx); + } + cx.notify(); + })), + ), + ) + // Search input (only in branch mode) + .when(!pr_mode, |d| { + d.child( + input_container(&t, Some(search_input_focused)).child( + SimpleInput::new(&branch_search_input) + .text_size(ui_text_md(cx)), + ), + ) + }) + // Branch list or PR list + .when(!pr_mode, |d| d.child(self.render_branch_list(t, cx))) + .when(pr_mode, |d| d.child(self.render_pr_list(t, cx))), + ), ) // Error message .when_some(self.error_message.clone(), |d, msg| { @@ -412,7 +422,7 @@ impl Render for WorktreeDialog { .bg(rgba(0xff00001a)) .text_size(ui_text_md(cx)) .text_color(rgb(t.error)) - .child(msg) + .child(msg), ) }) // Footer @@ -441,7 +451,7 @@ impl Render for WorktreeDialog { this.create_worktree(cx); })), ), - ) + ), ) } } diff --git a/crates/okena-views-remote/src/lib.rs b/crates/okena-views-remote/src/lib.rs index 15375f67b..580c516c1 100644 --- a/crates/okena-views-remote/src/lib.rs +++ b/crates/okena-views-remote/src/lib.rs @@ -1,11 +1,11 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod remote_connect_dialog; -pub mod remote_pair_dialog; pub mod remote_context_menu; +pub mod remote_pair_dialog; pub use remote_connect_dialog::{RemoteConnectDialog, RemoteConnectDialogEvent}; -pub use remote_pair_dialog::{RemotePairDialog, RemotePairDialogEvent}; pub use remote_context_menu::{RemoteContextMenu, RemoteContextMenuEvent}; +pub use remote_pair_dialog::{RemotePairDialog, RemotePairDialogEvent}; gpui::actions!(okena_views_remote, [Cancel]); diff --git a/crates/okena-views-remote/src/remote_connect_dialog.rs b/crates/okena-views-remote/src/remote_connect_dialog.rs index 9bb47c1b4..dedaa97f9 100644 --- a/crates/okena-views-remote/src/remote_connect_dialog.rs +++ b/crates/okena-views-remote/src/remote_connect_dialog.rs @@ -1,17 +1,17 @@ //! Remote connection dialog overlay. use crate::Cancel; -use okena_transport::client::tls::format_fingerprint; -use okena_transport::client::RemoteConnectionConfig; +use gpui::prelude::*; +use gpui::*; use okena_remote_client::RemoteConnectionManager; +use okena_transport::client::RemoteConnectionConfig; +use okena_transport::client::tls::format_fingerprint; use okena_ui::button::{button, button_primary}; use okena_ui::input::{input_container, labeled_input}; use okena_ui::modal::{modal_backdrop, modal_content, modal_header}; use okena_ui::simple_input::{SimpleInput, SimpleInputState}; use okena_ui::theme::theme; -use okena_ui::tokens::{ui_text_ms, ui_text_md, ui_text_sm}; -use gpui::prelude::*; -use gpui::*; +use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; use std::sync::Arc; pub struct RemoteConnectDialog { @@ -68,15 +68,18 @@ async fn detect_scheme( let observed = observed.clone(); let probe = runtime .spawn(async move { - let client = - okena_transport::client::tls::build_reqwest_client(tls, None, observed); let scheme = if tls { "https" } else { "http" }; let base_url = format!("{}://{}:{}", scheme, host, port); - let resp = client - .get(format!("{}/health", base_url)) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await; + let resp = + match okena_transport::client::tls::build_reqwest_client(tls, None, observed) { + Ok(client) => client + .get(format!("{}/health", base_url)) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .map_err(|error| error.to_string()), + Err(error) => Err(error), + }; (base_url, resp) }) .await; @@ -102,13 +105,13 @@ async fn detect_scheme( pub enum RemoteConnectDialogEvent { Close, - Connected { - config: RemoteConnectionConfig, - }, + Connected { config: RemoteConnectionConfig }, } impl okena_ui::overlay::CloseEvent for RemoteConnectDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl EventEmitter<RemoteConnectDialogEvent> for RemoteConnectDialog {} @@ -228,6 +231,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); @@ -240,17 +244,17 @@ impl RemoteConnectDialog { let mut config = config; // Auto-detect the scheme (TLS first, plain-http fallback) and adopt it. - let detected = - match detect_scheme(&runtime, host.clone(), port, observed.clone()).await { - Ok(d) => d, - Err(e) => { - let _ = this.update(cx, |this, cx| { - this.status = ConnectionDialogStatus::ConnectFailed(e); - cx.notify(); - }); - return; - } - }; + let detected = match detect_scheme(&runtime, host.clone(), port, observed.clone()).await + { + Ok(d) => d, + Err(e) => { + let _ = this.update(cx, |this, cx| { + this.status = ConnectionDialogStatus::ConnectFailed(e); + cx.notify(); + }); + return; + } + }; let use_tls = detected.tls; let base_url = detected.base_url; config.tls = use_tls; @@ -263,7 +267,7 @@ impl RemoteConnectDialog { async move { let client = okena_transport::client::tls::build_reqwest_client( use_tls, None, observed, - ); + )?; let pair_body = serde_json::json!({ "code": code }); client .post(format!("{}/v1/pair", base_url)) @@ -271,6 +275,7 @@ impl RemoteConnectDialog { .timeout(std::time::Duration::from_secs(10)) .send() .await + .map_err(|error| error.to_string()) } }) .await; @@ -303,8 +308,7 @@ impl RemoteConnectDialog { // trust the pin and persist the connection. Some(fp) => { this.pending_config = Some(config); - this.status = - ConnectionDialogStatus::VerifyFingerprint(fp); + this.status = ConnectionDialogStatus::VerifyFingerprint(fp); cx.notify(); } // Plain http (or no cert captured): nothing to diff --git a/crates/okena-views-remote/src/remote_context_menu.rs b/crates/okena-views-remote/src/remote_context_menu.rs index 5a7317d78..30baf1e66 100644 --- a/crates/okena-views-remote/src/remote_context_menu.rs +++ b/crates/okena-views-remote/src/remote_context_menu.rs @@ -1,10 +1,10 @@ //! Context menu for remote connections in the sidebar. use crate::Cancel; -use okena_ui::menu::{context_menu_panel, menu_item, menu_item_with_color, menu_separator}; -use okena_ui::theme::theme; use gpui::prelude::*; use gpui::*; +use okena_ui::menu::{context_menu_panel, menu_item, menu_item_with_color, menu_separator}; +use okena_ui::theme::theme; /// Event emitted by RemoteContextMenu pub enum RemoteContextMenuEvent { @@ -16,7 +16,9 @@ pub enum RemoteContextMenuEvent { } impl okena_ui::overlay::CloseEvent for RemoteContextMenuEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } /// Context menu for remote connections @@ -100,55 +102,74 @@ impl Render for RemoteContextMenu { .absolute() .inset_0() .id("remote-context-menu-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child( - context_menu_panel("remote-context-menu", &t) - .when(self.is_pairing, |el| { - el.child( - menu_item("remote-ctx-pair", "icons/keyboard.svg", "Pair", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.pair(cx); - })), - ) - }) - .child( - menu_item("remote-ctx-reconnect", "icons/terminal.svg", "Reconnect", &t) + anchored().position(position).snap_to_window().child( + context_menu_panel("remote-context-menu", &t) + .when(self.is_pairing, |el| { + el.child( + menu_item("remote-ctx-pair", "icons/keyboard.svg", "Pair", &t) .on_click(cx.listener(|this, _, _window, cx| { - this.reconnect(cx); + this.pair(cx); })), ) - .when(!self.tls, |el| { - el.child( - menu_item("remote-ctx-upgrade", "icons/keyboard.svg", "Upgrade to TLS", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.upgrade_to_tls(cx); - })), - ) - }) - .child(menu_separator(&t)) - .child( - menu_item_with_color( - "remote-ctx-remove", - "icons/trash.svg", - format!("Remove \"{}\"", self.connection_name), - t.error, - t.error, + }) + .child( + menu_item( + "remote-ctx-reconnect", + "icons/terminal.svg", + "Reconnect", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.reconnect(cx); + }, + )), + ) + .when(!self.tls, |el| { + el.child( + menu_item( + "remote-ctx-upgrade", + "icons/keyboard.svg", + "Upgrade to TLS", &t, ) - .on_click(cx.listener(|this, _, _window, cx| { + .on_click(cx.listener( + |this, _, _window, cx| { + this.upgrade_to_tls(cx); + }, + )), + ) + }) + .child(menu_separator(&t)) + .child( + menu_item_with_color( + "remote-ctx-remove", + "icons/trash.svg", + format!("Remove \"{}\"", self.connection_name), + t.error, + t.error, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { this.remove(cx); - })), - ), - ), + }, + )), + ), + ), )) } } diff --git a/crates/okena-views-remote/src/remote_pair_dialog.rs b/crates/okena-views-remote/src/remote_pair_dialog.rs index 82c5ffff3..e20339322 100644 --- a/crates/okena-views-remote/src/remote_pair_dialog.rs +++ b/crates/okena-views-remote/src/remote_pair_dialog.rs @@ -1,14 +1,14 @@ //! Re-pair dialog for existing remote connections. use crate::Cancel; +use gpui::prelude::*; +use gpui::*; use okena_ui::dialog_actions::dialog_actions; use okena_ui::input::{input_container, labeled_input}; use okena_ui::modal::{modal_backdrop, modal_content, modal_header}; use okena_ui::simple_input::{SimpleInput, SimpleInputState}; use okena_ui::theme::theme; use okena_ui::tokens::{ui_text_md, ui_text_sm}; -use gpui::prelude::*; -use gpui::*; pub struct RemotePairDialog { connection_id: String, @@ -24,17 +24,15 @@ pub enum RemotePairDialogEvent { } impl okena_ui::overlay::CloseEvent for RemotePairDialogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl EventEmitter<RemotePairDialogEvent> for RemotePairDialog {} impl RemotePairDialog { - pub fn new( - connection_id: String, - connection_name: String, - cx: &mut Context<Self>, - ) -> Self { + pub fn new(connection_id: String, connection_name: String, cx: &mut Context<Self>) -> Self { let code_input = cx.new(|cx| SimpleInputState::new(cx).placeholder("Pairing code from remote...")); diff --git a/crates/okena-views-services/src/lib.rs b/crates/okena-views-services/src/lib.rs index f4fee361d..21c2b10a4 100644 --- a/crates/okena-views-services/src/lib.rs +++ b/crates/okena-views-services/src/lib.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] -pub mod types; -pub mod sidebar; pub mod panel; pub mod service_panel; +pub mod sidebar; +pub mod types; diff --git a/crates/okena-views-services/src/panel.rs b/crates/okena-views-services/src/panel.rs index 2c140ce5f..ab657397f 100644 --- a/crates/okena-views-services/src/panel.rs +++ b/crates/okena-views-services/src/panel.rs @@ -1,13 +1,13 @@ //! Pure render functions for the per-project service panel in ProjectColumn. use crate::types::{ServiceSnapshot, status_color, status_label}; -use gpui::*; use gpui::prelude::*; +use gpui::*; use gpui_component::tooltip::Tooltip; use okena_services::manager::ServiceStatus; use okena_ui::icon_action_button::icon_action_button; use okena_ui::theme::ThemeColors; -use okena_ui::tokens::{ui_text_xs, ui_text_sm, ui_text_ms, ui_text_md, ui_text}; +use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_sm, ui_text_xs}; use std::sync::Arc; /// Callback taking a service name (e.g. start/stop/restart a named service). @@ -81,8 +81,7 @@ pub fn render_service_panel_header( .flex_shrink_0() .text_size(ui_text_md(cx)) .when(is_overview, |d| { - d.bg(rgb(t.bg_primary)) - .text_color(rgb(t.text_primary)) + d.bg(rgb(t.bg_primary)).text_color(rgb(t.text_primary)) }) .when(!is_overview, |d| { d.text_color(rgb(t.text_secondary)) @@ -97,9 +96,7 @@ pub fn render_service_panel_header( .children( services .iter() - .filter(|svc| { - !svc.is_extra || active_service_name == Some(&svc.name) - }) + .filter(|svc| !svc.is_extra || active_service_name == Some(&svc.name)) .map(|svc| { let name = svc.name.clone(); let is_active = active_service_name == Some(&name); @@ -107,9 +104,7 @@ pub fn render_service_panel_header( let on_tab_click = on_tab_click.clone(); div() - .id(ElementId::Name( - format!("svc-tab-{}", name).into(), - )) + .id(ElementId::Name(format!("svc-tab-{}", name).into())) .cursor_pointer() .h(px(34.0)) .px(px(12.0)) @@ -119,8 +114,7 @@ pub fn render_service_panel_header( .gap(px(6.0)) .text_size(ui_text_md(cx)) .when(is_active, |d| { - d.bg(rgb(t.bg_primary)) - .text_color(rgb(t.text_primary)) + d.bg(rgb(t.bg_primary)).text_color(rgb(t.text_primary)) }) .when(!is_active, |d| { d.text_color(rgb(t.text_secondary)) @@ -158,36 +152,50 @@ pub fn render_service_panel_header( d // Start All .child( - icon_action_button("svc-panel-start-all", "\u{25B6}\u{25B6}", t.term_green, t, cx) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - on_start_all(window, cx); - }) - .tooltip(|_window, cx| { - Tooltip::new("Start All").build(_window, cx) - }), + icon_action_button( + "svc-panel-start-all", + "\u{25B6}\u{25B6}", + t.term_green, + t, + cx, + ) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_start_all(window, cx); + }) + .tooltip(|_window, cx| Tooltip::new("Start All").build(_window, cx)), ) // Stop All .child( - icon_action_button("svc-panel-stop-all", "\u{25A0}\u{25A0}", t.term_red, t, cx) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - on_stop_all(window, cx); - }) - .tooltip(|_window, cx| { - Tooltip::new("Stop All").build(_window, cx) - }), + icon_action_button( + "svc-panel-stop-all", + "\u{25A0}\u{25A0}", + t.term_red, + t, + cx, + ) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_stop_all(window, cx); + }) + .tooltip(|_window, cx| Tooltip::new("Stop All").build(_window, cx)), ) // Reload .child( - icon_action_button("svc-panel-reload", "\u{27F3}", t.text_secondary, t, cx) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - on_reload(window, cx); - }) - .tooltip(|_window, cx| { - Tooltip::new("Reload Services").build(_window, cx) - }), + icon_action_button( + "svc-panel-reload", + "\u{27F3}", + t.text_secondary, + t, + cx, + ) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_reload(window, cx); + }) + .tooltip(|_window, cx| { + Tooltip::new("Reload Services").build(_window, cx) + }), ) }) // --- Detail tab actions --- @@ -212,27 +220,35 @@ pub fn render_service_panel_header( // Start button (when stopped/crashed) .when(active_is_stopped, |d| { d.child( - icon_action_button("svc-panel-start", "\u{25B6}", t.term_green, t, cx) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - on_start(window, cx); - }) - .tooltip(|_window, cx| { - Tooltip::new("Start").build(_window, cx) - }), + icon_action_button( + "svc-panel-start", + "\u{25B6}", + t.term_green, + t, + cx, + ) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_start(window, cx); + }) + .tooltip(|_window, cx| Tooltip::new("Start").build(_window, cx)), ) }) // Restart button (when running) .when(active_is_running, |d| { d.child( - icon_action_button("svc-panel-restart", "\u{27F3}", t.text_secondary, t, cx) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - on_restart(window, cx); - }) - .tooltip(|_window, cx| { - Tooltip::new("Restart").build(_window, cx) - }), + icon_action_button( + "svc-panel-restart", + "\u{27F3}", + t.text_secondary, + t, + cx, + ) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_restart(window, cx); + }) + .tooltip(|_window, cx| Tooltip::new("Restart").build(_window, cx)), ) }) // Stop button (when running) @@ -243,9 +259,7 @@ pub fn render_service_panel_header( cx.stop_propagation(); on_stop(window, cx); }) - .tooltip(|_window, cx| { - Tooltip::new("Stop").build(_window, cx) - }), + .tooltip(|_window, cx| Tooltip::new("Stop").build(_window, cx)), ) }) }), @@ -434,7 +448,11 @@ fn render_overview_row( let sc = status_color(&status, t); let sl = status_label(&status); - let name_color = if is_extra { t.text_muted } else { t.text_primary }; + let name_color = if is_extra { + t.text_muted + } else { + t.text_primary + }; let project_id = project_id.to_string(); div() @@ -461,9 +479,7 @@ fn render_overview_row( let on_service_click = on_service_click.clone(); let name = name.clone(); div() - .id(ElementId::Name( - format!("svc-overview-name-{}", idx).into(), - )) + .id(ElementId::Name(format!("svc-overview-name-{}", idx).into())) .cursor_pointer() .flex_1() .min_w(px(80.0)) @@ -488,25 +504,20 @@ fn render_overview_row( ) // Type column .when(has_docker, |d| { - d.child( - div() - .flex_shrink_0() - .w(px(56.0)) - .when(is_docker, |d| { - d.child( - div() - .px(px(3.0)) - .h(px(14.0)) - .flex() - .items_center() - .rounded(px(2.0)) - .bg(rgb(t.bg_secondary)) - .text_size(ui_text_xs(cx)) - .text_color(rgb(t.text_muted)) - .child("docker"), - ) - }), - ) + d.child(div().flex_shrink_0().w(px(56.0)).when(is_docker, |d| { + d.child( + div() + .px(px(3.0)) + .h(px(14.0)) + .flex() + .items_center() + .rounded(px(2.0)) + .bg(rgb(t.bg_secondary)) + .text_size(ui_text_xs(cx)) + .text_color(rgb(t.text_muted)) + .child("docker"), + ) + })) }) // Ports column .when(has_ports, |d| { @@ -530,11 +541,8 @@ fn render_overview_row( let on_port_click = on_port_click.clone(); div() .id(ElementId::Name( - format!( - "svc-overview-port-{}-{}-{}", - project_id, name, port - ) - .into(), + format!("svc-overview-port-{}-{}-{}", project_id, name, port) + .into(), )) .flex_shrink_0() .cursor_pointer() @@ -548,9 +556,7 @@ fn render_overview_row( .text_size(ui_text_sm(cx)) .text_color(rgb(t.text_muted)) .child(format!(":{}", port)) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation() - }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(move |_, _, _cx| { on_port_click(port); }) @@ -579,9 +585,7 @@ fn render_overview_row( let on_start = on_start.clone(); d.child( icon_action_button( - ElementId::Name( - format!("svc-overview-play-{}", idx).into(), - ), + ElementId::Name(format!("svc-overview-play-{}", idx).into()), "\u{25B6}", t.term_green, t, @@ -599,9 +603,7 @@ fn render_overview_row( let on_stop = on_stop.clone(); d.child( icon_action_button( - ElementId::Name( - format!("svc-overview-restart-{}", idx).into(), - ), + ElementId::Name(format!("svc-overview-restart-{}", idx).into()), "\u{27F3}", t.text_secondary, t, @@ -611,15 +613,11 @@ fn render_overview_row( cx.stop_propagation(); on_restart(name_for_restart.clone(), window, cx); }) - .tooltip(|_window, cx| { - Tooltip::new("Restart").build(_window, cx) - }), + .tooltip(|_window, cx| Tooltip::new("Restart").build(_window, cx)), ) .child( icon_action_button( - ElementId::Name( - format!("svc-overview-stop-{}", idx).into(), - ), + ElementId::Name(format!("svc-overview-stop-{}", idx).into()), "\u{25A0}", t.term_red, t, @@ -629,9 +627,7 @@ fn render_overview_row( cx.stop_propagation(); on_stop(name_for_stop.clone(), window, cx); }) - .tooltip(|_window, cx| { - Tooltip::new("Stop").build(_window, cx) - }), + .tooltip(|_window, cx| Tooltip::new("Stop").build(_window, cx)), ) }) }) @@ -653,15 +649,16 @@ pub fn render_service_indicator( } // Compute aggregate status color - let has_running = services - .iter() - .any(|s| s.status == ServiceStatus::Running); + let has_running = services.iter().any(|s| s.status == ServiceStatus::Running); let has_crashed = services .iter() .any(|s| matches!(s.status, ServiceStatus::Crashed { .. })); - let has_starting = services - .iter() - .any(|s| matches!(s.status, ServiceStatus::Starting | ServiceStatus::Restarting)); + let has_starting = services.iter().any(|s| { + matches!( + s.status, + ServiceStatus::Starting | ServiceStatus::Restarting + ) + }); let dot_color = if has_crashed { t.term_red diff --git a/crates/okena-views-services/src/service_panel.rs b/crates/okena-views-services/src/service_panel.rs index 36c7dc3da..9698ede66 100644 --- a/crates/okena-views-services/src/service_panel.rs +++ b/crates/okena-views-services/src/service_panel.rs @@ -10,12 +10,12 @@ use crate::types::ServiceSnapshot; use okena_core::api::ActionRequest; use okena_core::process::open_url; use okena_services::manager::{ServiceKind, ServiceManager, ServiceStatus}; -use okena_terminal::backend::TerminalBackend; use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +use okena_views_terminal::ActionDispatch; +use okena_views_terminal::elements::resize_handle::ResizeHandle; use okena_views_terminal::layout::split_pane::{ActiveDrag, DragState}; use okena_views_terminal::layout::terminal_pane::TerminalPane; -use okena_views_terminal::elements::resize_handle::ResizeHandle; -use okena_views_terminal::ActionDispatch; use okena_workspace::request_broker::RequestBroker; use okena_workspace::state::{WindowId, Workspace}; @@ -99,14 +99,19 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { pub fn set_service_manager(&mut self, manager: Entity<ServiceManager>, cx: &mut Context<Self>) { let project_id = self.project_id.clone(); cx.observe(&manager, move |this, sm, cx| { - let Some(ref active_name) = this.active_service_name else { return }; - let current_tid = sm.read(cx) + let Some(ref active_name) = this.active_service_name else { + return; + }; + let current_tid = sm + .read(cx) .terminal_id_for(&project_id, active_name) .cloned(); match current_tid { Some(new_tid) => { - let pane_tid = this.service_terminal_pane.as_ref() + let pane_tid = this + .service_terminal_pane + .as_ref() .and_then(|p| p.read(cx).terminal_id()); if pane_tid.as_deref() != Some(&new_tid) { let name = active_name.clone(); @@ -114,12 +119,16 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { } } None => { - let is_active_docker = sm.read(cx) + let is_active_docker = sm + .read(cx) .instances() .get(&(project_id.clone(), active_name.clone())) .is_some_and(|i| { matches!(i.kind, ServiceKind::DockerCompose { .. }) - && matches!(i.status, ServiceStatus::Running | ServiceStatus::Restarting) + && matches!( + i.status, + ServiceStatus::Running | ServiceStatus::Restarting + ) }); if is_active_docker { @@ -131,7 +140,8 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { } } } - }).detach(); + }) + .detach(); self.service_manager = Some(manager); } @@ -145,10 +155,15 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { pub fn show_service(&mut self, service_name: &str, cx: &mut Context<Self>) { // For Docker services with no terminal_id, spawn a log viewer PTY on demand if let Some(ref sm) = self.service_manager { - let is_docker = sm.read(cx).instances() + let is_docker = sm + .read(cx) + .instances() .get(&(self.project_id.clone(), service_name.to_string())) .is_some_and(|i| matches!(i.kind, ServiceKind::DockerCompose { .. })); - let has_terminal = sm.read(cx).terminal_id_for(&self.project_id, service_name).is_some(); + let has_terminal = sm + .read(cx) + .terminal_id_for(&self.project_id, service_name) + .is_some(); if is_docker && !has_terminal { let pid = self.project_id.clone(); let name = service_name.to_string(); @@ -160,11 +175,16 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { // Look up terminal_id from either ServiceManager or remote services let terminal_id = if let Some(ref sm) = self.service_manager { - sm.read(cx).terminal_id_for(&self.project_id, service_name).cloned() + sm.read(cx) + .terminal_id_for(&self.project_id, service_name) + .cloned() } else { - self.workspace.read(cx).remote_snapshot(&self.project_id) + self.workspace + .read(cx) + .remote_snapshot(&self.project_id) .and_then(|snap| { - snap.services.iter() + snap.services + .iter() .find(|s| s.name == service_name) .and_then(|s| s.terminal_id.clone()) }) @@ -174,10 +194,14 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { self.service_panel_open = true; if let Some(tid) = terminal_id { - let project_path = self.service_manager.as_ref() + let project_path = self + .service_manager + .as_ref() .and_then(|sm| sm.read(cx).project_path(&self.project_id).cloned()) .or_else(|| { - self.workspace.read(cx).project(&self.project_id) + self.workspace + .read(cx) + .project(&self.project_id) .map(|p| p.path.clone()) }) .unwrap_or_default(); @@ -245,21 +269,29 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { } /// Observe workspace for remote service state changes (used for remote project columns). - pub fn observe_remote_services(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) { + pub fn observe_remote_services( + &mut self, + workspace: Entity<Workspace>, + cx: &mut Context<Self>, + ) { let project_id = self.project_id.clone(); cx.observe(&workspace, move |this, ws, cx| { - let Some(ref active_name) = this.active_service_name else { return }; - - let current_tid = ws.read(cx).remote_snapshot(&project_id) - .and_then(|snap| { - snap.services.iter() - .find(|s| s.name == *active_name) - .and_then(|s| s.terminal_id.clone()) - }); + let Some(ref active_name) = this.active_service_name else { + return; + }; + + let current_tid = ws.read(cx).remote_snapshot(&project_id).and_then(|snap| { + snap.services + .iter() + .find(|s| s.name == *active_name) + .and_then(|s| s.terminal_id.clone()) + }); match current_tid { Some(new_tid) => { - let pane_tid = this.service_terminal_pane.as_ref() + let pane_tid = this + .service_terminal_pane + .as_ref() .and_then(|p| p.read(cx).terminal_id()); if pane_tid.as_deref() != Some(&new_tid) { let name = active_name.clone(); @@ -271,7 +303,8 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { cx.notify(); } } - }).detach(); + }) + .detach(); } /// Get the list of services for this project, from either ServiceManager (local) @@ -280,26 +313,34 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { if let Some(ref sm) = self.service_manager { let services = sm.read(cx).services_for_project(&self.project_id); if !services.is_empty() { - return services.iter().map(|inst| ServiceSnapshot { - name: inst.definition.name.clone(), - status: inst.status.clone(), - terminal_id: inst.terminal_id.clone(), - ports: inst.detected_ports.clone(), - is_docker: matches!(inst.kind, ServiceKind::DockerCompose { .. }), - is_extra: inst.is_extra, - }).collect(); + return services + .iter() + .map(|inst| ServiceSnapshot { + name: inst.definition.name.clone(), + status: inst.status.clone(), + terminal_id: inst.terminal_id.clone(), + ports: inst.detected_ports.clone(), + is_docker: matches!(inst.kind, ServiceKind::DockerCompose { .. }), + is_extra: inst.is_extra, + }) + .collect(); } } let ws = self.workspace.read(cx); ws.remote_snapshot(&self.project_id) - .map(|snap| snap.services.iter().map(|api_svc| ServiceSnapshot { - name: api_svc.name.clone(), - status: ServiceStatus::from_api(&api_svc.status, api_svc.exit_code), - terminal_id: api_svc.terminal_id.clone(), - ports: api_svc.ports.clone(), - is_docker: api_svc.kind == "docker_compose", - is_extra: api_svc.is_extra, - }).collect()) + .map(|snap| { + snap.services + .iter() + .map(|api_svc| ServiceSnapshot { + name: api_svc.name.clone(), + status: ServiceStatus::from_api(&api_svc.status, api_svc.exit_code), + terminal_id: api_svc.terminal_id.clone(), + ports: api_svc.ports.clone(), + is_docker: api_svc.kind == "docker_compose", + is_extra: api_svc.is_extra, + }) + .collect() + }) .unwrap_or_default() } @@ -317,21 +358,17 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { let services = self.get_service_list(cx); let entity = cx.entity().downgrade(); - panel::render_service_indicator( - &services, - t, - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - if this.service_panel_open { - this.close(cx); - } else { - this.show_overview(cx); - } - }); - } - }, - ) + panel::render_service_indicator(&services, t, move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + if this.service_panel_open { + this.close(cx); + } else { + this.show_overview(cx); + } + }); + } + }) } /// Render the per-project service log panel (resize handle + tab header + terminal pane). @@ -349,7 +386,8 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { let is_overview = active_name.is_none(); let active_status = active_name.as_ref().and_then(|name| { - services.iter() + services + .iter() .find(|s| s.name == *name) .map(|s| s.status.clone()) }); @@ -365,147 +403,162 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { .flex_col() .h(px(panel_height)) .flex_shrink_0() - .child( - ResizeHandle::new( - true, - t.border, - t.border_active, - move |mouse_pos, _cx| { - *active_drag.borrow_mut() = Some(DragState::ServicePanel { - project_id: project_id.clone(), - initial_mouse_y: f32::from(mouse_pos.y), - initial_height: panel_height, - }); - }, - ), - ) - .child( - panel::render_service_panel_header( - &services, - active_name.as_deref(), - t, - cx, - // on_overview_click - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| this.show_overview(cx)); - } + .child(ResizeHandle::new( + true, + t.border, + t.border_active, + move |mouse_pos, _cx| { + *active_drag.borrow_mut() = Some(DragState::ServicePanel { + project_id: project_id.clone(), + initial_mouse_y: f32::from(mouse_pos.y), + initial_height: panel_height, + }); + }, + )) + .child(panel::render_service_panel_header( + &services, + active_name.as_deref(), + t, + cx, + // on_overview_click + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| this.show_overview(cx)); } - }, - // on_tab_click - { - let entity = entity.clone(); - move |name: String, _window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| this.show_service(&name, cx)); - } + } + }, + // on_tab_click + { + let entity = entity.clone(); + move |name: String, _window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| this.show_service(&name, cx)); } - }, - // on_start_all - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - this.dispatch_service_action(ActionRequest::StartAllServices { + } + }, + // on_start_all + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + this.dispatch_service_action( + ActionRequest::StartAllServices { project_id: this.project_id.clone(), - }, cx); - }); - } + }, + cx, + ); + }); } - }, - // on_stop_all - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - this.dispatch_service_action(ActionRequest::StopAllServices { + } + }, + // on_stop_all + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + this.dispatch_service_action( + ActionRequest::StopAllServices { project_id: this.project_id.clone(), - }, cx); - }); - } + }, + cx, + ); + }); } - }, - // on_reload - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - this.dispatch_service_action(ActionRequest::ReloadServices { + } + }, + // on_reload + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + this.dispatch_service_action( + ActionRequest::ReloadServices { project_id: this.project_id.clone(), - }, cx); - }); - } + }, + cx, + ); + }); } - }, - // on_start (active service) - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - if let Some(name) = this.active_service_name.clone() { - this.dispatch_service_action(ActionRequest::StartService { + } + }, + // on_start (active service) + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + if let Some(name) = this.active_service_name.clone() { + this.dispatch_service_action( + ActionRequest::StartService { project_id: this.project_id.clone(), service_name: name, - }, cx); - } - }); - } + }, + cx, + ); + } + }); } - }, - // on_stop (active service) - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - if let Some(name) = this.active_service_name.clone() { - this.dispatch_service_action(ActionRequest::StopService { + } + }, + // on_stop (active service) + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + if let Some(name) = this.active_service_name.clone() { + this.dispatch_service_action( + ActionRequest::StopService { project_id: this.project_id.clone(), service_name: name, - }, cx); - } - }); - } + }, + cx, + ); + } + }); } - }, - // on_restart (active service) - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - if let Some(name) = this.active_service_name.clone() { - this.dispatch_service_action(ActionRequest::RestartService { + } + }, + // on_restart (active service) + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + if let Some(name) = this.active_service_name.clone() { + this.dispatch_service_action( + ActionRequest::RestartService { project_id: this.project_id.clone(), service_name: name, - }, cx); - } - }); - } + }, + cx, + ); + } + }); } - }, - // on_close - { - let entity = entity.clone(); - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| this.close(cx)); - } + } + }, + // on_close + { + let entity = entity.clone(); + move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| this.close(cx)); } - }, - active_status.as_ref(), - ), - ) + } + }, + active_status.as_ref(), + )) .child( // Content area if is_overview { - self.render_overview_content(t, &services, cx).into_any_element() + self.render_overview_content(t, &services, cx) + .into_any_element() } else if self.service_terminal_pane.is_some() { div() .flex_1() @@ -516,32 +569,45 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { .into_any_element() } else { let entity = entity.clone(); - panel::render_not_running_placeholder( - t, - cx, - move |_window, cx| { - if let Some(e) = entity.upgrade() { - e.update(cx, |this, cx| { - if let Some(name) = this.active_service_name.clone() { - this.dispatch_service_action(ActionRequest::StartService { + panel::render_not_running_placeholder(t, cx, move |_window, cx| { + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + if let Some(name) = this.active_service_name.clone() { + this.dispatch_service_action( + ActionRequest::StartService { project_id: this.project_id.clone(), service_name: name, - }, cx); - } - }); - } - }, - ).into_any_element() + }, + cx, + ); + } + }); + } + }) + .into_any_element() }, ) .into_any_element() } /// Render the overview content showing all services in a table layout. - fn render_overview_content(&self, t: &ThemeColors, services: &[ServiceSnapshot], cx: &mut Context<Self>) -> impl IntoElement { - - let remote_host = self.workspace.read(cx).remote_snapshot(&self.project_id) + fn render_overview_content( + &self, + t: &ThemeColors, + services: &[ServiceSnapshot], + cx: &mut Context<Self>, + ) -> impl IntoElement { + 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(); @@ -567,10 +633,13 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { move |name: String, _window, cx| { if let Some(e) = entity.upgrade() { e.update(cx, |this, cx| { - this.dispatch_service_action(ActionRequest::StartService { - project_id: this.project_id.clone(), - service_name: name.clone(), - }, cx); + this.dispatch_service_action( + ActionRequest::StartService { + project_id: this.project_id.clone(), + service_name: name.clone(), + }, + cx, + ); }); } } @@ -581,10 +650,13 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { move |name: String, _window, cx| { if let Some(e) = entity.upgrade() { e.update(cx, |this, cx| { - this.dispatch_service_action(ActionRequest::StopService { - project_id: this.project_id.clone(), - service_name: name.clone(), - }, cx); + this.dispatch_service_action( + ActionRequest::StopService { + project_id: this.project_id.clone(), + service_name: name.clone(), + }, + cx, + ); }); } } @@ -595,17 +667,20 @@ impl<D: ActionDispatch + Send + Sync> ServicePanel<D> { move |name: String, _window, cx| { if let Some(e) = entity.upgrade() { e.update(cx, |this, cx| { - this.dispatch_service_action(ActionRequest::RestartService { - project_id: this.project_id.clone(), - service_name: name.clone(), - }, cx); + this.dispatch_service_action( + ActionRequest::RestartService { + project_id: this.project_id.clone(), + service_name: name.clone(), + }, + cx, + ); }); } } }, // 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-services/src/sidebar.rs b/crates/okena-views-services/src/sidebar.rs index 427c352d5..e211f1ebe 100644 --- a/crates/okena-views-services/src/sidebar.rs +++ b/crates/okena-views-services/src/sidebar.rs @@ -1,13 +1,13 @@ //! Pure render functions for service items in the sidebar. use crate::types::{ServiceSnapshot, status_color}; -use gpui::*; use gpui::prelude::*; +use gpui::*; use gpui_component::tooltip::Tooltip; use okena_services::manager::ServiceStatus; use okena_ui::icon_action_button::icon_action_button_sized; use okena_ui::theme::ThemeColors; -use okena_ui::tokens::{ui_text_xs, ui_text_sm, ui_text_md}; +use okena_ui::tokens::{ui_text_md, ui_text_sm, ui_text_xs}; /// Render the action buttons for the services group header. /// @@ -225,9 +225,7 @@ pub fn render_service_item( .when(!is_running && !is_starting, |d| { d.child( icon_action_button_sized( - ElementId::Name( - format!("svc-play-{}-{}", pid, service_name).into(), - ), + ElementId::Name(format!("svc-play-{}-{}", pid, service_name).into()), "\u{25B6}", t.term_green, 18.0, @@ -244,9 +242,7 @@ pub fn render_service_item( .when(is_running, |d| { d.child( icon_action_button_sized( - ElementId::Name( - format!("svc-restart-{}-{}", pid, service_name).into(), - ), + ElementId::Name(format!("svc-restart-{}-{}", pid, service_name).into()), "\u{27F3}", t.text_secondary, 18.0, @@ -261,9 +257,7 @@ pub fn render_service_item( ) .child( icon_action_button_sized( - ElementId::Name( - format!("svc-stop-{}-{}", pid, service_name).into(), - ), + ElementId::Name(format!("svc-stop-{}-{}", pid, service_name).into()), "\u{25A0}", t.term_red, 18.0, diff --git a/crates/okena-views-services/src/types.rs b/crates/okena-views-services/src/types.rs index 0c7fc3a9d..19bb2322b 100644 --- a/crates/okena-views-services/src/types.rs +++ b/crates/okena-views-services/src/types.rs @@ -28,7 +28,11 @@ pub fn status_label(status: &ServiceStatus) -> &'static str { match status { ServiceStatus::Running => "running", ServiceStatus::Crashed { exit_code } => { - if exit_code.is_some() { "exited" } else { "crashed" } + if exit_code.is_some() { + "exited" + } else { + "crashed" + } } ServiceStatus::Stopped => "stopped", ServiceStatus::Starting => "starting", diff --git a/crates/okena-views-sidebar/Cargo.toml b/crates/okena-views-sidebar/Cargo.toml index 9480aa123..9d485d08a 100644 --- a/crates/okena-views-sidebar/Cargo.toml +++ b/crates/okena-views-sidebar/Cargo.toml @@ -6,8 +6,7 @@ license = "MIT" [dependencies] okena-core = { path = "../okena-core" } -okena-transport = { path = "../okena-transport", features = ["client"] } -okena-git = { path = "../okena-git" } +okena-transport = { path = "../okena-transport", features = ["client", "blocking-http"] } okena-services = { path = "../okena-services" } okena-terminal = { path = "../okena-terminal" } okena-ui = { path = "../okena-ui" } @@ -20,3 +19,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/activity_order.rs b/crates/okena-views-sidebar/src/activity_order.rs index 2f9b196a6..2d995f902 100644 --- a/crates/okena-views-sidebar/src/activity_order.rs +++ b/crates/okena-views-sidebar/src/activity_order.rs @@ -163,7 +163,11 @@ mod tests { assert_eq!(ids(&out), vec!["att", "run", "rest"]); assert_eq!( out.iter().map(|(t, _)| *t).collect::<Vec<_>>(), - vec![ActivityTier::Attention, ActivityTier::Running, ActivityTier::Rest], + vec![ + ActivityTier::Attention, + ActivityTier::Running, + ActivityTier::Rest + ], ); } diff --git a/crates/okena-views-sidebar/src/color_picker.rs b/crates/okena-views-sidebar/src/color_picker.rs index 0dd1396ec..05a5d806c 100644 --- a/crates/okena-views-sidebar/src/color_picker.rs +++ b/crates/okena-views-sidebar/src/color_picker.rs @@ -3,13 +3,13 @@ //! Shows a color swatch grid for project or folder color selection. //! Rendered at WindowView level via OverlayManager, like context menus. +use gpui::prelude::*; +use gpui::*; use okena_core::theme::FolderColor; use okena_ui::overlay::CloseEvent; use okena_ui::theme::theme; use okena_ui::tokens::ui_text_ms; use okena_workspace::state::Workspace; -use gpui::*; -use gpui::prelude::*; use crate::Cancel; @@ -24,11 +24,25 @@ pub enum ColorPickerTarget { pub enum ColorPickerPopoverEvent { Close, /// Color was set on a project — sidebar should handle remote sync. - ProjectColorChanged { project_id: String, color: FolderColor }, + 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 { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl EventEmitter<ColorPickerPopoverEvent> for ColorPickerPopover {} @@ -49,7 +63,12 @@ impl ColorPickerPopover { cx: &mut Context<Self>, ) -> Self { let focus_handle = cx.focus_handle(); - Self { workspace, target, position, focus_handle } + Self { + workspace, + target, + position, + focus_handle, + } } fn close(&self, cx: &mut Context<Self>) { @@ -63,7 +82,12 @@ fn color_swatch_grid( current_color: FolderColor, t: &okena_core::theme::ThemeColors, cx: &mut Context<ColorPickerPopover>, - on_select: impl Fn(&mut ColorPickerPopover, FolderColor, &mut Window, &mut Context<ColorPickerPopover>) + 'static, + on_select: impl Fn( + &mut ColorPickerPopover, + FolderColor, + &mut Window, + &mut Context<ColorPickerPopover>, + ) + 'static, ) -> Div { let colors: Vec<(FolderColor, u32)> = FolderColor::all() .iter() @@ -91,9 +115,7 @@ fn color_swatch_grid( .when(is_selected, |d| { d.border_2().border_color(rgb(t.text_primary)) }) - .when(!is_selected, |d| { - d.border_1().border_color(rgb(t.border)) - }) + .when(!is_selected, |d| d.border_1().border_color(rgb(t.border))) .hover(|s| s.opacity(0.8)) .on_mouse_down(MouseButton::Left, { let on_select = on_select.clone(); @@ -115,10 +137,13 @@ impl Render for ColorPickerPopover { let panel = match &self.target { ColorPickerTarget::Project { project_id } => { let ws = self.workspace.read(cx); - let (current_color, has_color_override) = ws.project(project_id) + let (current_color, has_color_override) = ws + .project(project_id) .map(|p| { let color = ws.effective_folder_color(p); - let has_override = p.worktree_info.as_ref() + let has_override = p + .worktree_info + .as_ref() .and_then(|wt| wt.color_override) .is_some(); (color, has_override) @@ -130,16 +155,19 @@ impl Render for ColorPickerPopover { okena_ui::popover::popover_panel("color-picker-panel", &t) .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, - }); - this.close(cx); - }) + color_swatch_grid( + "color", + current_color, + &t, + cx, + move |this, color, _window, cx| { + cx.emit(ColorPickerPopoverEvent::ProjectColorChanged { + project_id: pid.clone(), + color, + }); + this.close(cx); + }, + ) }) .when(has_color_override, |panel| { let project_id_clone = project_id_owned.clone(); @@ -161,21 +189,30 @@ impl Render for ColorPickerPopover { .cursor_pointer() .text_size(ui_text_ms(cx)) .text_color(rgb(t.text_secondary)) - .hover(|s| s.text_color(rgb(t.text_primary)).bg(rgb(t.bg_hover))) + .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); - }); - this.close(cx); - })) - ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _window, cx| { + cx.emit( + ColorPickerPopoverEvent::WorktreeColorReset { + project_id: project_id_clone.clone(), + }, + ); + this.close(cx); + }), + ), + ), ) }) .into_any_element() } ColorPickerTarget::Folder { folder_id } => { - let current_color = self.workspace.read(cx) + let current_color = self + .workspace + .read(cx) .folder(folder_id) .map(|f| f.folder_color) .unwrap_or_default(); @@ -185,12 +222,19 @@ impl Render for ColorPickerPopover { okena_ui::popover::popover_panel("folder-color-picker-panel", &t) .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); - }); - this.close(cx); - }) + color_swatch_grid( + "folder-color", + current_color, + &t, + cx, + move |this, color, _window, cx| { + cx.emit(ColorPickerPopoverEvent::FolderColorChanged { + folder_id: fid.clone(), + color, + }); + this.close(cx); + }, + ) }) .into_any_element() } @@ -208,18 +252,23 @@ impl Render for ColorPickerPopover { .inset_0() .occlude() .id("color-picker-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_scroll_wheel(|_, _, cx| { cx.stop_propagation(); }) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child(panel) + anchored().position(position).snap_to_window().child(panel), )) } } diff --git a/crates/okena-views-sidebar/src/context_menu.rs b/crates/okena-views-sidebar/src/context_menu.rs index 3e8413d9d..363dac5d5 100644 --- a/crates/okena-views-sidebar/src/context_menu.rs +++ b/crates/okena-views-sidebar/src/context_menu.rs @@ -1,13 +1,12 @@ //! Project context menu overlay. use crate::Cancel; -use okena_git; +use gpui::prelude::*; +use gpui::*; 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; use okena_workspace::state::{WindowId, Workspace}; -use gpui::prelude::*; -use gpui::*; /// Pick the hide/show menu label given (a) whether any extra windows exist and /// (b) whether the project is currently hidden in the window hosting the menu. @@ -47,7 +46,10 @@ mod tests { #[test] fn multi_window_visible_reads_hide_from_this_window() { - assert_eq!(hide_project_menu_label(true, false), "Hide from this window"); + assert_eq!( + hide_project_menu_label(true, false), + "Hide from this window" + ); } #[test] @@ -59,26 +61,66 @@ mod tests { /// Event emitted by ContextMenu pub enum ContextMenuEvent { Close, - AddTerminal { project_id: String }, - CreateWorktree { project_id: String, project_path: String }, - QuickCreateWorktree { project_id: String }, - ManageWorktrees { project_id: String, position: gpui::Point<gpui::Pixels> }, - RenameProject { project_id: String, project_name: String }, - RenameDirectory { project_id: String, project_path: String }, - CloseWorktree { project_id: String }, - DeleteProject { project_id: String }, - ConfigureHooks { project_id: String }, - ReloadServices { project_id: String }, - FocusParent { project_id: String }, - CopyPath { path: String }, - BrowseFiles { project_id: String }, - ShowDiff { project_id: String }, - FocusProject { project_id: String }, - HideProject { project_id: String }, + AddTerminal { + project_id: String, + }, + CreateWorktree { + project_id: String, + }, + QuickCreateWorktree { + project_id: String, + }, + ManageWorktrees { + project_id: String, + position: gpui::Point<gpui::Pixels>, + }, + RenameProject { + project_id: String, + project_name: String, + }, + RenameDirectory { + project_id: String, + project_path: String, + }, + CloseWorktree { + project_id: String, + }, + DeleteProject { + project_id: String, + }, + ConfigureHooks { + project_id: String, + }, + ReloadServices { + project_id: String, + }, + FocusParent { + project_id: String, + }, + CopyPath { + path: String, + }, + BrowseFiles { + project_id: String, + }, + ShowDiff { + project_id: String, + }, + FocusProject { + project_id: String, + }, + HideProject { + project_id: String, + }, + ToggleProjectPinned { + project_id: String, + }, } impl okena_ui::overlay::CloseEvent for ContextMenuEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } /// Project context menu component @@ -120,10 +162,9 @@ impl ContextMenu { }); } - fn create_worktree(&self, project_path: String, cx: &mut Context<Self>) { + fn create_worktree(&self, cx: &mut Context<Self>) { cx.emit(ContextMenuEvent::CreateWorktree { project_id: self.request.project_id.clone(), - project_path, }); } @@ -153,14 +194,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<Self>) { 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,8 +284,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 project_path_for_worktree = project_path.clone(); + 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_rename_dir = project_path.clone(); let project_name_for_rename = project_name.clone(); let extras_exist = !ws.data().extra_windows.is_empty(); @@ -270,174 +313,279 @@ impl Render for ContextMenu { .inset_0() .occlude() .id("context-menu-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child( - context_menu_panel("project-context-menu", &t) - // Add Terminal option - .child( - menu_item("context-menu-add-terminal", "icons/plus.svg", "Add Terminal", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.add_terminal(cx); - })), - ) - // Browse Files - .child( - menu_item("context-menu-browse-files", "icons/file.svg", "Browse Files", &t) + anchored().position(position).snap_to_window().child( + context_menu_panel("project-context-menu", &t) + // Add Terminal option + .child( + menu_item( + "context-menu-add-terminal", + "icons/plus.svg", + "Add Terminal", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.add_terminal(cx); + }, + )), + ) + // Browse Files + .child( + menu_item( + "context-menu-browse-files", + "icons/file.svg", + "Browse Files", + &t, + ) .on_click(cx.listener({ let project_id = self.request.project_id.clone(); move |this, _, _window, cx| { this.browse_files(project_id.clone(), cx); } })), - ) - // Show Diff - .when(is_git_repo, |d| { - d.child( - menu_item("context-menu-show-diff", "icons/git-commit.svg", "Show Diff", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.show_diff(cx); - })), ) - }) - .child(menu_separator(&t)) - // Copy Path - .child( - menu_item("context-menu-copy-path", "icons/copy.svg", "Copy Path", &t) - .on_click(cx.listener({ - let project_path = project_path.clone(); - move |this, _, _window, cx| { - this.copy_path(project_path.clone(), cx); - } - })), - ) - // Focus Project - .child( - menu_item("context-menu-focus-project", "icons/fullscreen.svg", "Focus Project", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.focus_project(cx); - })), - ) - // Pin / Unpin (anchors the project to the top of the - // activity-sorted view; harmless in manual mode) - .child( - menu_item( - "context-menu-toggle-pinned", - "icons/bookmark.svg", - if is_pinned { "Unpin" } else { "Pin to top" }, - &t, - ) - .on_click(cx.listener(|this, _, _window, cx| { - this.toggle_pinned(cx); - })), - ) - // Hide / Show Project (label + icon depend on extras presence and per-window hidden state) - .child( - menu_item("context-menu-hide-project", hide_project_icon, hide_project_label, &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.hide_project(cx); - })), - ) - .child(menu_separator(&t)) - // Create Worktree option (only for git repos that are not already worktrees) - .when(is_git_repo && !is_worktree, |d| { - d.child( - menu_item("context-menu-create-worktree", "icons/git-branch.svg", "Create Worktree...", &t) + // Show Diff + .when(is_git_repo, |d| { + d.child( + menu_item( + "context-menu-show-diff", + "icons/git-commit.svg", + "Show Diff", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.show_diff(cx); + }, + )), + ) + }) + .child(menu_separator(&t)) + // Copy Path + .child( + menu_item("context-menu-copy-path", "icons/copy.svg", "Copy Path", &t) .on_click(cx.listener({ - let project_path = project_path_for_worktree.clone(); + let project_path = project_path.clone(); move |this, _, _window, cx| { - this.create_worktree(project_path.clone(), cx); + this.copy_path(project_path.clone(), cx); } })), ) - }) - // Quick Create Worktree (only for git repos that are not already worktrees) - .when(is_git_repo && !is_worktree, |d| { - d.child( - menu_item("context-menu-quick-create-wt", "icons/plus.svg", "Quick Create Worktree", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.quick_create_worktree(cx); - })), + // Focus Project + .child( + menu_item( + "context-menu-focus-project", + "icons/fullscreen.svg", + "Focus Project", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.focus_project(cx); + }, + )), ) - }) - // Manage Worktrees (only for git repos that are not already worktrees) - .when(is_git_repo && !is_worktree, |d| { - d.child( - menu_item("context-menu-manage-wt", "icons/git-branch.svg", "Manage Worktrees", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.manage_worktrees(cx); - })), + // Pin / Unpin (anchors the project to the top of the + // activity-sorted view; harmless in manual mode) + .child( + menu_item( + "context-menu-toggle-pinned", + "icons/bookmark.svg", + if is_pinned { "Unpin" } else { "Pin to top" }, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.toggle_pinned(cx); + }, + )), + ) + // Hide / Show Project (label + icon depend on extras presence and per-window hidden state) + .child( + menu_item( + "context-menu-hide-project", + hide_project_icon, + hide_project_label, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.hide_project(cx); + }, + )), ) - }) - // Separator (only if worktree items above were shown) - .when(is_git_repo && !is_worktree, |d| d.child(menu_separator(&t))) - // Rename option - .child( - menu_item("context-menu-rename", "icons/edit.svg", "Rename Project", &t) + .child(menu_separator(&t)) + // Create Worktree option (only for git repos that are not already worktrees) + .when(is_git_repo && !is_worktree, |d| { + d.child( + menu_item( + "context-menu-create-worktree", + "icons/git-branch.svg", + "Create Worktree...", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.create_worktree(cx); + }, + )), + ) + }) + // Quick Create Worktree (only for git repos that are not already worktrees) + .when(is_git_repo && !is_worktree, |d| { + d.child( + menu_item( + "context-menu-quick-create-wt", + "icons/plus.svg", + "Quick Create Worktree", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.quick_create_worktree(cx); + }, + )), + ) + }) + // Manage Worktrees (only for git repos that are not already worktrees) + .when(is_git_repo && !is_worktree, |d| { + d.child( + menu_item( + "context-menu-manage-wt", + "icons/git-branch.svg", + "Manage Worktrees", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.manage_worktrees(cx); + }, + )), + ) + }) + // Separator (only if worktree items above were shown) + .when(is_git_repo && !is_worktree, |d| d.child(menu_separator(&t))) + // Rename option + .child( + menu_item( + "context-menu-rename", + "icons/edit.svg", + "Rename Project", + &t, + ) .on_click(cx.listener({ let project_name = project_name_for_rename.clone(); move |this, _, _window, cx| { this.rename_project(project_name.clone(), cx); } })), - ) - // Rename Directory option - .child( - menu_item("context-menu-rename-dir", "icons/folder.svg", "Rename Directory...", &t) + ) + // Rename Directory option + .child( + menu_item( + "context-menu-rename-dir", + "icons/folder.svg", + "Rename Directory...", + &t, + ) .on_click(cx.listener({ let project_path = project_path_for_rename_dir.clone(); move |this, _, _window, cx| { this.rename_directory(project_path.clone(), cx); } })), - ) - // Configure Hooks option - .child( - menu_item("context-menu-configure-hooks", "icons/terminal.svg", "Configure Hooks...", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.configure_hooks(cx); - })), - ) - // Reload Services option - .child( - menu_item("context-menu-reload-services", "icons/file.svg", "Reload Services", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.reload_services(cx); - })), - ) - // Focus Parent Project option (only for worktree projects) - .when(is_worktree, |d| { - d.child( - menu_item("context-menu-focus-parent", "icons/chevron-up.svg", "Focus Parent Project", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.focus_parent(cx); - })), ) - }) - // Close Worktree option (only for worktree projects) - .when(is_worktree, |d| { - d.child( - menu_item_with_color("context-menu-close-worktree", "icons/git-branch.svg", "Close Worktree", t.warning, t.warning, &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.close_worktree(cx); - })), + // Configure Hooks option + .child( + menu_item( + "context-menu-configure-hooks", + "icons/terminal.svg", + "Configure Hooks...", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.configure_hooks(cx); + }, + )), ) - }) - // Delete option - .child( - menu_item_with_color("context-menu-delete", "icons/trash.svg", "Delete Project", t.error, t.error, &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.delete_project(cx); - })), - ), + // Reload Services option + .child( + menu_item( + "context-menu-reload-services", + "icons/file.svg", + "Reload Services", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.reload_services(cx); + }, + )), + ) + // Focus Parent Project option (only for worktree projects) + .when(is_worktree, |d| { + d.child( + menu_item( + "context-menu-focus-parent", + "icons/chevron-up.svg", + "Focus Parent Project", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.focus_parent(cx); + }, + )), + ) + }) + // Close Worktree option (only for worktree projects) + .when(is_worktree, |d| { + d.child( + menu_item_with_color( + "context-menu-close-worktree", + "icons/git-branch.svg", + "Close Worktree", + t.warning, + t.warning, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.close_worktree(cx); + }, + )), + ) + }) + // Delete option + .child( + menu_item_with_color( + "context-menu-delete", + "icons/trash.svg", + "Delete Project", + t.error, + t.error, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.delete_project(cx); + }, + )), + ), ), )) } diff --git a/crates/okena-views-sidebar/src/drag.rs b/crates/okena-views-sidebar/src/drag.rs index d7d767b25..c7995074a 100644 --- a/crates/okena-views-sidebar/src/drag.rs +++ b/crates/okena-views-sidebar/src/drag.rs @@ -61,7 +61,7 @@ impl Render for WorktreeDragView { svg() .path("icons/git-branch.svg") .size(px(12.0)) - .text_color(rgb(0xcccccc)) + .text_color(rgb(0xcccccc)), ) .child(self.name.clone()) } @@ -98,7 +98,7 @@ impl Render for FolderDragView { svg() .path("icons/folder.svg") .size(px(12.0)) - .text_color(rgb(0xcccccc)) + .text_color(rgb(0xcccccc)), ) .child(self.name.clone()) } diff --git a/crates/okena-views-sidebar/src/folder_context_menu.rs b/crates/okena-views-sidebar/src/folder_context_menu.rs index 0db094a4b..18a7a58e6 100644 --- a/crates/okena-views-sidebar/src/folder_context_menu.rs +++ b/crates/okena-views-sidebar/src/folder_context_menu.rs @@ -1,23 +1,32 @@ //! Folder context menu overlay. use crate::Cancel; +use gpui::prelude::*; +use gpui::*; use okena_ui::menu::{context_menu_panel, menu_item, menu_item_with_color, menu_separator}; use okena_ui::theme::theme; use okena_workspace::requests::FolderContextMenuRequest; use okena_workspace::state::{WindowId, Workspace}; -use gpui::prelude::*; -use gpui::*; /// Event emitted by FolderContextMenu pub enum FolderContextMenuEvent { Close, - RenameFolder { folder_id: String, folder_name: String }, - DeleteFolder { folder_id: String }, - FilterToFolder { folder_id: String }, + RenameFolder { + folder_id: String, + folder_name: String, + }, + DeleteFolder { + folder_id: String, + }, + FilterToFolder { + folder_id: String, + }, } impl okena_ui::overlay::CloseEvent for FolderContextMenuEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } /// Folder context menu component @@ -108,7 +117,8 @@ impl Render for FolderContextMenu { let ws = self.workspace.read(cx); let folder = ws.folder(&self.request.folder_id); let project_count = folder.map(|f| f.project_ids.len()).unwrap_or(0); - let is_active_filter = ws.active_folder_filter(self.window_id) == Some(&self.request.folder_id); + let is_active_filter = + ws.active_folder_filter(self.window_id) == Some(&self.request.folder_id); div() .track_focus(&self.focus_handle) @@ -119,57 +129,75 @@ impl Render for FolderContextMenu { .absolute() .inset_0() .id("folder-context-menu-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child( - context_menu_panel("folder-context-menu", &t) - // Filter option - .child( - menu_item( - "folder-ctx-filter", - if is_active_filter { "icons/eye-off.svg" } else { "icons/eye.svg" }, - if is_active_filter { "Show All Projects" } else { "Show Only This Folder" }, - &t, + anchored().position(position).snap_to_window().child( + context_menu_panel("folder-context-menu", &t) + // Filter option + .child( + menu_item( + "folder-ctx-filter", + if is_active_filter { + "icons/eye-off.svg" + } else { + "icons/eye.svg" + }, + if is_active_filter { + "Show All Projects" + } else { + "Show Only This Folder" + }, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.toggle_folder_filter(cx); + }, + )), ) - .on_click(cx.listener(|this, _, _window, cx| { - this.toggle_folder_filter(cx); - })), - ) - // Rename option - .child( - menu_item("folder-ctx-rename", "icons/edit.svg", "Rename Folder", &t) - .on_click(cx.listener(|this, _, _window, cx| { - this.rename_folder(cx); - })), - ) - // Separator - .child(menu_separator(&t)) - // Delete option - .child( - menu_item_with_color( - "folder-ctx-delete", - "icons/trash.svg", - if project_count > 0 { - format!("Delete Folder ({} projects will be ungrouped)", project_count) - } else { - "Delete Folder".to_string() - }, - t.error, - t.error, - &t, + // Rename option + .child( + menu_item("folder-ctx-rename", "icons/edit.svg", "Rename Folder", &t) + .on_click(cx.listener(|this, _, _window, cx| { + this.rename_folder(cx); + })), ) - .on_click(cx.listener(|this, _, _window, cx| { - this.delete_folder(cx); - })), - ), + // Separator + .child(menu_separator(&t)) + // Delete option + .child( + menu_item_with_color( + "folder-ctx-delete", + "icons/trash.svg", + if project_count > 0 { + format!( + "Delete Folder ({} projects will be ungrouped)", + project_count + ) + } else { + "Delete Folder".to_string() + }, + t.error, + t.error, + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + this.delete_folder(cx); + }, + )), + ), ), )) } diff --git a/crates/okena-views-sidebar/src/folder_list.rs b/crates/okena-views-sidebar/src/folder_list.rs index 6222d7505..7e06e3e2b 100644 --- a/crates/okena-views-sidebar/src/folder_list.rs +++ b/crates/okena-views-sidebar/src/folder_list.rs @@ -1,42 +1,19 @@ //! Folder list rendering for the sidebar - -use okena_ui::theme::theme; -use okena_ui::rename_state::is_renaming; -use gpui::*; use gpui::prelude::*; +use gpui::*; use gpui_component::tooltip::Tooltip; use okena_ui::color_dot::color_dot; use okena_ui::icon_button::icon_button; +use okena_ui::rename_state::is_renaming; +use okena_ui::theme::theme; +use crate::drag::{FolderDrag, FolderDragView, ProjectDrag, ProjectDragView}; use crate::item_widgets::*; use crate::sidebar::{Sidebar, SidebarProjectInfo}; -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). #[allow(clippy::too_many_arguments)] @@ -87,9 +64,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 +78,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 +206,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)) @@ -265,10 +245,12 @@ impl Sidebar { }; div() - .id(ElementId::Name(format!("folder-project-row-{}", project.id).into())) + .id(ElementId::Name( + format!("folder-project-row-{}", project.id).into(), + )) .group("folder-project-item") .h(px(24.0)) - .pl(px(20.0)) // Indented for folder nesting + .pl(px(20.0)) // Indented for folder nesting .pr(px(8.0)) .flex() .items_center() @@ -276,12 +258,22 @@ impl Sidebar { .cursor_pointer() .hover(|s| s.bg(rgb(t.bg_hover))) .when(is_focused_project, |d| d.bg(rgb(t.bg_hover))) - .when(is_cursor, |d| d.border_l_2().border_color(rgb(t.border_active))) + .when(is_cursor, |d| { + d.border_l_2().border_color(rgb(t.border_active)) + }) .when(!project.show_in_overview, |d| d.opacity(0.75)) // Drag source - .on_drag(ProjectDrag { project_id: project_id.clone(), project_name: project_name.clone() }, move |drag, _position, _window, cx| { - cx.new(|_| ProjectDragView { name: drag.project_name.clone() }) - }) + .on_drag( + ProjectDrag { + project_id: project_id.clone(), + project_name: project_name.clone(), + }, + move |drag, _position, _window, cx| { + cx.new(|_| ProjectDragView { + name: drag.project_name.clone(), + }) + }, + ) // Drop target for reordering within folder .drag_over::<ProjectDrag>(move |style, _, _, _| { style.border_t_2().border_color(rgb(t.border_active)) @@ -291,20 +283,24 @@ impl Sidebar { let project_id = project_id.clone(); move |this, drag: &ProjectDrag, _window, cx| { if drag.project_id != project_id { - 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); + let pos = + this.workspace.read(cx).folder(&folder_id).and_then(|f| { + f.project_ids.iter().position(|id| id == &project_id) }); - // 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); - } - } + if let Some(pos) = pos { + // 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,17 +310,25 @@ 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); - }); - })) - .on_mouse_down(MouseButton::Right, cx.listener({ - let project_id = project_id.clone(); - move |this, event: &MouseDownEvent, _window, cx| { - this.request_context_menu(project_id.clone(), event.position, cx); - cx.stop_propagation(); - } + 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(); + move |this, event: &MouseDownEvent, _window, cx| { + this.request_context_menu(project_id.clone(), event.position, cx); + cx.stop_propagation(); + } + }), + ) .on_click(cx.listener({ let project_id = project_id.clone(); move |this, _, _window, cx| { @@ -339,7 +343,8 @@ impl Sidebar { } })) .child({ - let has_expandable_content = has_layout || has_worktrees || !project.services.is_empty(); + let has_expandable_content = + has_layout || has_worktrees || !project.services.is_empty(); if has_expandable_content { sidebar_expand_arrow( ElementId::Name(format!("expand-fp-{}", project.id).into()), @@ -356,7 +361,11 @@ impl Sidebar { })) .into_any_element() } else { - div().flex_shrink_0().w(px(12.0)).h(px(16.0)).into_any_element() + div() + .flex_shrink_0() + .w(px(12.0)) + .h(px(16.0)) + .into_any_element() } }) .child({ @@ -367,10 +376,13 @@ impl Sidebar { ElementId::Name(format!("fp-folder-icon-{}", project.id).into()), color_dot(folder_color, project.is_worktree), ) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.show_color_picker(project_id.clone(), event.position, cx); - cx.stop_propagation(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.show_color_picker(project_id.clone(), event.position, cx); + cx.stop_propagation(); + }), + ) }) .child( // Project name (or input if renaming) @@ -390,7 +402,12 @@ impl Sidebar { let project_name = project_name.clone(); move |this, _event: &ClickEvent, window, cx| { if this.check_project_double_click(&project_id) { - this.start_project_rename(project_id.clone(), project_name.clone(), window, cx); + this.start_project_rename( + project_id.clone(), + project_name.clone(), + window, + cx, + ); } else { this.cursor_index = None; let workspace = this.workspace.clone(); @@ -405,7 +422,14 @@ impl Sidebar { cx.stop_propagation(); } })); - sidebar_name_or_badge(name_label, &project_name, is_expanded || project.show_in_overview, project.terminal_ids.len(), &t, cx) + sidebar_name_or_badge( + name_label, + &project_name, + is_expanded || project.show_in_overview, + project.terminal_ids.len(), + &t, + cx, + ) }, ) .when(idle_count > 0, |d| d.child(sidebar_idle_dot(&t))) @@ -414,7 +438,11 @@ impl Sidebar { ElementId::Name(format!("fp-visibility-{}", project.id).into()), project.show_in_overview, "folder-project-item", - if project.show_in_overview { "Hide Project" } else { "Show Project" }, + if project.show_in_overview { + "Hide Project" + } else { + "Show Project" + }, &t, ) .on_click(cx.listener({ @@ -424,12 +452,17 @@ impl Sidebar { let workspace = this.workspace.clone(); this.focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| { - ws.toggle_project_overview_visibility(fm, window_id, &project_id, cx); + ws.toggle_project_overview_visibility( + fm, + window_id, + &project_id, + cx, + ); }); }); cx.stop_propagation(); } - })) + })), ) } } diff --git a/crates/okena-views-sidebar/src/hook_list.rs b/crates/okena-views-sidebar/src/hook_list.rs index 10412817b..f33b5da40 100644 --- a/crates/okena-views-sidebar/src/hook_list.rs +++ b/crates/okena-views-sidebar/src/hook_list.rs @@ -1,14 +1,15 @@ //! Hook terminal list rendering for the sidebar +use gpui::prelude::*; +use gpui::*; +use okena_core::api::ActionRequest; +use okena_ui::icon_button::icon_button; use okena_ui::theme::theme; use okena_ui::tokens::ui_text_md; use okena_workspace::state::HookTerminalStatus; -use gpui::*; -use gpui::prelude::*; -use okena_ui::icon_button::icon_button; -use crate::sidebar::{Sidebar, SidebarProjectInfo, SidebarHookInfo, GroupKind}; use crate::item_widgets::sidebar_group_header; +use crate::sidebar::{GroupKind, Sidebar, SidebarHookInfo, SidebarProjectInfo}; impl Sidebar { /// Render the "Hooks" group header with collapse chevron. @@ -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); })), ) }) @@ -161,71 +154,42 @@ impl Sidebar { let terminal_id = terminal_id.clone(); move |this, _, _window, cx| { cx.stop_propagation(); - if let Some(monitor) = cx.try_global::<okena_workspace::hook_monitor::HookMonitor>() { - monitor.notify_exit(&terminal_id, None); - } - this.workspace.update(cx, |ws, cx| { - ws.cancel_pending_worktree_close(&terminal_id); - ws.remove_hook_terminal(&terminal_id, cx); - }); - let terminals = this.terminals.clone(); - terminals.lock().remove(&terminal_id); + this.dismiss_hook_terminal(&project_id, &terminal_id, cx); } })), ), ) } - /// Rerun a hook by killing the old PTY and creating a new one with the same command. - pub fn rerun_hook_terminal( + /// 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>) { + self.dispatch_action_for_project( + project_id, + ActionRequest::RerunHook { + project_id: project_id.to_string(), + terminal_id: terminal_id.to_string(), + }, + cx, + ); + } + + /// Ask the authoritative daemon to stop and remove a hook terminal. + pub fn dismiss_hook_terminal( &self, project_id: &str, terminal_id: &str, - command: &str, - cwd: &str, cx: &mut Context<Self>, ) { - let Some(runner) = cx.try_global::<okena_workspace::hooks::HookRunner>().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); - } - } + self.dispatch_action_for_project( + project_id, + ActionRequest::DismissHook { + project_id: project_id.to_string(), + terminal_id: terminal_id.to_string(), + }, + cx, + ); } } diff --git a/crates/okena-views-sidebar/src/hook_log.rs b/crates/okena-views-sidebar/src/hook_log.rs index ac53a7e5b..29d796ad0 100644 --- a/crates/okena-views-sidebar/src/hook_log.rs +++ b/crates/okena-views-sidebar/src/hook_log.rs @@ -1,15 +1,15 @@ //! Hook log overlay — shows recent hook execution history. use crate::Cancel; +use gpui::prelude::*; +use gpui::*; +use gpui_component::v_flex; +use okena_core::theme::ThemeColors; use okena_ui::badge::keyboard_hints_footer; use okena_ui::modal::{modal_backdrop, modal_content, modal_header}; use okena_ui::theme::theme; -use okena_ui::tokens::{ui_text_ms, ui_text_md, ui_text}; +use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms}; use okena_workspace::hook_monitor::{HookExecution, HookMonitor, HookStatus}; -use okena_core::theme::ThemeColors; -use gpui::*; -use gpui::prelude::*; -use gpui_component::v_flex; use std::time::Duration; /// Hook log overlay — shows recent hook execution history. @@ -52,7 +52,11 @@ impl HookLog { }) .detach(); - Self { focus_handle, history, last_version } + Self { + focus_handle, + history, + last_version, + } } fn close(&self, cx: &mut Context<Self>) { @@ -65,7 +69,9 @@ pub enum HookLogEvent { } impl okena_ui::overlay::CloseEvent for HookLogEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl EventEmitter<HookLogEvent> for HookLog {} @@ -128,13 +134,15 @@ impl Render for HookLog { .flex_1() .overflow_y_scroll() .children(if self.history.is_empty() { - vec![div() - .px(px(16.0)) - .py(px(24.0)) - .text_size(ui_text(13.0, cx)) - .text_color(rgb(t.text_muted)) - .child("No hooks have been executed yet.") - .into_any_element()] + vec![ + div() + .px(px(16.0)) + .py(px(24.0)) + .text_size(ui_text(13.0, cx)) + .text_color(rgb(t.text_muted)) + .child("No hooks have been executed yet.") + .into_any_element(), + ] } else { self.history .iter() @@ -169,9 +177,9 @@ fn render_hook_row( let command = exec.command.clone(); let error_detail = match &exec.status { - HookStatus::Failed { stderr, exit_code, .. } => { - Some(format!("Exit {}: {}", exit_code, stderr)) - } + HookStatus::Failed { + stderr, exit_code, .. + } => Some(format!("Exit {}: {}", exit_code, stderr)), HookStatus::SpawnError { message } => Some(message.clone()), _ => None, }; diff --git a/crates/okena-views-sidebar/src/item_widgets.rs b/crates/okena-views-sidebar/src/item_widgets.rs index 8b77b07b8..622e5e045 100644 --- a/crates/okena-views-sidebar/src/item_widgets.rs +++ b/crates/okena-views-sidebar/src/item_widgets.rs @@ -3,14 +3,14 @@ //! Each helper returns a partially-built element that the caller can chain //! additional handlers onto (e.g. `.on_click()`). -use okena_core::theme::ThemeColors; -use okena_ui::rename_state::{rename_input, RenameState}; -use okena_ui::simple_input::SimpleInput; -use okena_ui::tokens::{ui_text_xs, ui_text_sm, ui_text_md}; -use gpui::*; use gpui::prelude::*; +use gpui::*; use gpui_component::tooltip::Tooltip; +use okena_core::theme::ThemeColors; use okena_ui::icon_button::icon_button; +use okena_ui::rename_state::{RenameState, rename_input}; +use okena_ui::simple_input::SimpleInput; +use okena_ui::tokens::{ui_text_md, ui_text_sm, ui_text_xs}; /// Expand/collapse arrow (chevron-down/right, 16x16). /// @@ -44,10 +44,7 @@ pub fn sidebar_expand_arrow( /// /// `child` is the inner element -- either a colored dot or a folder SVG. /// Caller chains `.on_click()` to show color picker. -pub fn sidebar_color_indicator( - id: impl Into<ElementId>, - child: impl IntoElement, -) -> Stateful<Div> { +pub fn sidebar_color_indicator(id: impl Into<ElementId>, child: impl IntoElement) -> Stateful<Div> { div() .id(id) .flex_shrink_0() @@ -135,7 +132,9 @@ pub fn sidebar_group_header( .gap(px(4.0)) .cursor_pointer() .hover(|s| s.bg(rgb(t.bg_hover))) - .when(is_cursor, |d: Stateful<Div>| d.border_l_2().border_color(rgb(t.border_active))) + .when(is_cursor, |d: Stateful<Div>| { + d.border_l_2().border_color(rgb(t.border_active)) + }) .child( // Expand/collapse chevron (smaller than project arrow) svg() diff --git a/crates/okena-views-sidebar/src/lib.rs b/crates/okena-views-sidebar/src/lib.rs index 3020a4d63..d1f383f01 100644 --- a/crates/okena-views-sidebar/src/lib.rs +++ b/crates/okena-views-sidebar/src/lib.rs @@ -1,20 +1,20 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod activity_order; -pub mod sidebar; -pub mod project_list; -pub mod folder_list; -pub mod worktree_list; -pub mod hook_list; -pub mod remote_list; -pub mod service_list; -pub mod item_widgets; pub mod color_picker; pub mod context_menu; +pub mod drag; pub mod folder_context_menu; -pub mod rename_directory_dialog; +pub mod folder_list; +pub mod hook_list; pub mod hook_log; -pub mod drag; +pub mod item_widgets; +pub mod project_list; +pub mod remote_list; +pub mod rename_directory_dialog; +pub mod service_list; +pub mod sidebar; +pub mod worktree_list; pub use sidebar::Sidebar; @@ -22,23 +22,28 @@ pub use sidebar::Sidebar; pub use sidebar::{DispatchActionFn, GetSettingsFn, SidebarSettings}; // Re-export remote manager callback types -pub use sidebar::{RemoteConnectionSnapshot, GetRemoteConnectionsFn, SendRemoteActionFn, GetRemoteFolderFn}; +pub use sidebar::{ + GetRemoteConnectionsFn, GetRemoteFolderFn, RemoteConnectionSnapshot, SendRemoteActionFn, +}; // Re-export context menu types pub use context_menu::{ContextMenu, ContextMenuEvent}; pub use folder_context_menu::{FolderContextMenu, FolderContextMenuEvent}; -pub use rename_directory_dialog::{RenameDirectoryDialog, RenameDirectoryDialogEvent}; pub use hook_log::{HookLog, HookLogEvent}; +pub use rename_directory_dialog::{RenameDirectoryDialog, RenameDirectoryDialogEvent}; // Re-export popover types -pub use worktree_list::{WorktreeListPopover, WorktreeListPopoverEvent}; pub use color_picker::{ColorPickerPopover, ColorPickerPopoverEvent, ColorPickerTarget}; +pub use worktree_list::{WorktreeListPopover, WorktreeListPopoverEvent}; -gpui::actions!(okena_views_sidebar, [ - SidebarUp, - SidebarDown, - SidebarConfirm, - SidebarToggleExpand, - SidebarEscape, - Cancel, -]); +gpui::actions!( + okena_views_sidebar, + [ + SidebarUp, + SidebarDown, + SidebarConfirm, + SidebarToggleExpand, + SidebarEscape, + Cancel, + ] +); diff --git a/crates/okena-views-sidebar/src/project_list.rs b/crates/okena-views-sidebar/src/project_list.rs index 43477374e..36c9ecda2 100644 --- a/crates/okena-views-sidebar/src/project_list.rs +++ b/crates/okena-views-sidebar/src/project_list.rs @@ -1,19 +1,19 @@ //! Project and terminal list rendering for the sidebar -use okena_views_terminal::actions::{MinimizeTerminal, ToggleFullscreen}; -use okena_ui::theme::theme; -use okena_ui::tokens::ui_text_sm; -use okena_ui::rename_state::is_renaming; -use gpui::*; use gpui::prelude::*; +use gpui::*; use gpui_component::tooltip::Tooltip; use okena_core::api::ActionRequest; use okena_ui::color_dot::color_dot; use okena_ui::icon_button::icon_button; +use okena_ui::rename_state::is_renaming; +use okena_ui::theme::theme; +use okena_ui::tokens::ui_text_sm; +use okena_views_terminal::actions::{MinimizeTerminal, ToggleFullscreen}; +use crate::drag::{FolderDrag, ProjectDrag, ProjectDragView, WorktreeDrag, WorktreeDragView}; use crate::item_widgets::*; use crate::sidebar::{Sidebar, SidebarProjectInfo}; -use crate::drag::{ProjectDrag, ProjectDragView, FolderDrag, WorktreeDrag, WorktreeDragView}; use std::collections::HashMap; /// Drag/drop configuration for group header rendering. @@ -30,7 +30,11 @@ pub enum ProjectRowStyle { /// Standard project: clickable color dot, worktree badge, rename support. Project, /// Worktree item: plain hollow dot, optional busy state, rename support. - Worktree { is_orphan: bool, is_busy: bool, busy_label: &'static str }, + Worktree { + is_orphan: bool, + is_busy: bool, + busy_label: &'static str, + }, /// Child under a group header: plain solid dot, no rename. GroupChild, } @@ -57,35 +61,45 @@ impl Sidebar { let supports_rename = !matches!(style, ProjectRowStyle::GroupChild); let has_expandable = match style { - ProjectRowStyle::Project => project.has_layout || project.worktree_count > 0 || !project.services.is_empty(), + ProjectRowStyle::Project => { + project.has_layout || project.worktree_count > 0 || !project.services.is_empty() + } _ => project.has_layout || !project.services.is_empty(), }; - let idle_count = if !is_expanded { self.count_waiting_terminals(&project.terminal_ids) } else { 0 }; + let idle_count = if !is_expanded { + self.count_waiting_terminals(&project.terminal_ids) + } else { + 0 + }; // Hide the terminal count badge when expanded (terminals are visible), busy, or shown in overview let hide_terminal_badge = is_expanded || is_busy || project.show_in_overview; - let (vis_tooltip_show, vis_tooltip_hide): (&'static str, &'static str) = if is_worktree_style { - ("Show Worktree", "Hide Worktree") - } else { - ("Show Project", "Hide Project") - }; + let (vis_tooltip_show, vis_tooltip_hide): (&'static str, &'static str) = + if is_worktree_style { + ("Show Worktree", "Hide Worktree") + } else { + ("Show Project", "Hide Project") + }; row // 1. Expand arrow .child(if has_expandable { sidebar_expand_arrow( ElementId::Name(format!("expand-{}-{}", id_prefix, project.id).into()), - is_expanded, &t, - ).on_click(cx.listener({ + is_expanded, + &t, + ) + .on_click(cx.listener({ let project_id = project_id.clone(); move |this, _, _window, cx| { this.toggle_expanded(&project_id); cx.notify(); cx.stop_propagation(); } - })).into_any_element() + })) + .into_any_element() } else { sidebar_expand_spacer().into_any_element() }) @@ -98,10 +112,13 @@ impl Sidebar { ElementId::Name(format!("{}-icon-{}", id_prefix, project.id).into()), color_dot(folder_color, project.is_worktree), ) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.show_color_picker(pid.clone(), event.position, cx); - cx.stop_propagation(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.show_color_picker(pid.clone(), event.position, cx); + cx.stop_propagation(); + }), + ) .into_any_element() } ProjectRowStyle::Worktree { is_orphan, .. } => { @@ -112,10 +129,13 @@ impl Sidebar { ElementId::Name(format!("{}-icon-{}", id_prefix, project.id).into()), color_dot(dot_color, true), ) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.show_color_picker(pid.clone(), event.position, cx); - cx.stop_propagation(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.show_color_picker(pid.clone(), event.position, cx); + cx.stop_propagation(); + }), + ) .into_any_element() } ProjectRowStyle::GroupChild => { @@ -125,10 +145,13 @@ impl Sidebar { ElementId::Name(format!("{}-icon-{}", id_prefix, project.id).into()), color_dot(folder_color, false), ) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.show_color_picker(pid.clone(), event.position, cx); - cx.stop_propagation(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.show_color_picker(pid.clone(), event.position, cx); + cx.stop_propagation(); + }), + ) .into_any_element() } }) @@ -136,27 +159,40 @@ impl Sidebar { .child(if is_renaming_now { sidebar_rename_input( ElementId::Name(format!("{}-rename-input", id_prefix).into()), - &self.project_rename, &t, cx, + &self.project_rename, + &t, + cx, ) .map(|el| el.into_any_element()) .unwrap_or_else(|| div().flex_1().into_any_element()) } else { let name_label = sidebar_name_label( ElementId::Name(format!("{}-name-{}", id_prefix, project.id).into()), - project_name.clone(), &t, cx, + project_name.clone(), + &t, + cx, ) .on_click(cx.listener({ let project_id = project_id.clone(); let project_name = project_name.clone(); move |this, _event: &ClickEvent, window, cx| { if supports_rename && this.check_project_double_click(&project_id) { - this.start_project_rename(project_id.clone(), project_name.clone(), window, cx); + this.start_project_rename( + project_id.clone(), + project_name.clone(), + window, + cx, + ); } else { this.cursor_index = None; let workspace = this.workspace.clone(); this.focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| { - ws.set_focused_project_individual(fm, Some(project_id.clone()), cx); + ws.set_focused_project_individual( + fm, + Some(project_id.clone()), + cx, + ); }); cx.notify(); }); @@ -164,7 +200,14 @@ impl Sidebar { cx.stop_propagation(); } })); - sidebar_name_or_badge(name_label, &project_name, hide_terminal_badge, project.terminal_ids.len(), &t, cx) + sidebar_name_or_badge( + name_label, + &project_name, + hide_terminal_badge, + project.terminal_ids.len(), + &t, + cx, + ) }) // 3b. Pin marker .when(project.pinned, |d| { @@ -176,11 +219,14 @@ impl Sidebar { ) }) // 4. Idle dot - .when(idle_count > 0 && !is_busy, |d| d.child(sidebar_idle_dot(&t))) - // 5. Worktree badge (Project style only) - .when(matches!(style, ProjectRowStyle::Project) && project.worktree_count > 0, |d| { - d.child(sidebar_worktree_badge(project.worktree_count, &t, cx)) + .when(idle_count > 0 && !is_busy, |d| { + d.child(sidebar_idle_dot(&t)) }) + // 5. Worktree badge (Project style only) + .when( + matches!(style, ProjectRowStyle::Project) && project.worktree_count > 0, + |d| d.child(sidebar_worktree_badge(project.worktree_count, &t, cx)), + ) // 6. Busy label (Worktree busy only) .when(is_busy, |d| { let label = match style { @@ -188,7 +234,11 @@ impl Sidebar { _ => "", }; d.child( - div().ml_auto().text_size(ui_text_sm(cx)).text_color(rgb(t.text_secondary)).child(label) + div() + .ml_auto() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_secondary)) + .child(label), ) }) // 7. Visibility button @@ -196,8 +246,13 @@ impl Sidebar { d.child( sidebar_visibility_button( ElementId::Name(format!("{}-vis-{}", id_prefix, project_id).into()), - project.show_in_overview, group_name, - if project.show_in_overview { vis_tooltip_hide } else { vis_tooltip_show }, + project.show_in_overview, + group_name, + if project.show_in_overview { + vis_tooltip_hide + } else { + vis_tooltip_show + }, &t, ) .on_click(cx.listener({ @@ -210,24 +265,39 @@ impl Sidebar { if is_worktree_style { ws.toggle_worktree_visibility(window_id, &project_id, cx); } else { - ws.toggle_project_overview_visibility(fm, window_id, &project_id, cx); + ws.toggle_project_overview_visibility( + fm, + window_id, + &project_id, + cx, + ); } }); }); cx.stop_propagation(); } - })) + })), ) }) } - pub fn render_project_item(&self, project: &SidebarProjectInfo, index: usize, is_cursor: bool, is_focused_project: bool, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + pub fn render_project_item( + &self, + project: &SidebarProjectInfo, + index: usize, + is_cursor: bool, + is_focused_project: bool, + _window: &mut Window, + cx: &mut Context<Self>, + ) -> impl IntoElement { let t = theme(cx); let project_id = project.id.clone(); let project_name = project.name.clone(); let row = div() - .id(ElementId::Name(format!("project-row-{}", project.id).into())) + .id(ElementId::Name( + format!("project-row-{}", project.id).into(), + )) .group("project-item") .h(px(24.0)) .pl(px(4.0)) @@ -238,11 +308,21 @@ impl Sidebar { .cursor_pointer() .hover(|s| s.bg(rgb(t.bg_hover))) .when(is_focused_project, |d| d.bg(rgb(t.bg_hover))) - .when(is_cursor, |d| d.border_l_2().border_color(rgb(t.border_active))) - .when(!project.show_in_overview, |d| d.opacity(0.75)) - .on_drag(ProjectDrag { project_id: project_id.clone(), project_name: project_name.clone() }, move |drag, _position, _window, cx| { - cx.new(|_| ProjectDragView { name: drag.project_name.clone() }) + .when(is_cursor, |d| { + d.border_l_2().border_color(rgb(t.border_active)) }) + .when(!project.show_in_overview, |d| d.opacity(0.75)) + .on_drag( + ProjectDrag { + project_id: project_id.clone(), + project_name: project_name.clone(), + }, + move |drag, _position, _window, cx| { + cx.new(|_| ProjectDragView { + name: drag.project_name.clone(), + }) + }, + ) .drag_over::<ProjectDrag>(move |style, _, _, _| { style.border_t_2().border_color(rgb(t.border_active)) }) @@ -250,9 +330,14 @@ 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,17 +345,25 @@ 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); - }); - })) - .on_mouse_down(MouseButton::Right, cx.listener({ - let project_id = project_id.clone(); - move |this, event: &MouseDownEvent, _window, cx| { - this.request_context_menu(project_id.clone(), event.position, cx); - cx.stop_propagation(); - } + 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(); + move |this, event: &MouseDownEvent, _window, cx| { + this.request_context_menu(project_id.clone(), event.position, cx); + cx.stop_propagation(); + } + }), + ) .on_click(cx.listener({ let project_id = project_id.clone(); move |this, _, _window, cx| { @@ -285,14 +378,30 @@ impl Sidebar { } })); - self.append_project_row_content(row, project, "project", "project-item", &ProjectRowStyle::Project, cx) + self.append_project_row_content( + row, + project, + "project", + "project-item", + &ProjectRowStyle::Project, + cx, + ) } /// Renders a worktree project row. Promoted worktrees use the same indent as their parent /// (solid dot, conditional expand arrow). Nested worktrees are indented with a hollow circle. // GPUI render helper: params are render inputs (indent, indices, state flags). #[allow(clippy::too_many_arguments)] - pub fn render_worktree_item(&self, project: &SidebarProjectInfo, indent: f32, worktree_index: usize, is_cursor: bool, is_focused_project: bool, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + pub fn render_worktree_item( + &self, + project: &SidebarProjectInfo, + indent: f32, + worktree_index: usize, + is_cursor: bool, + is_focused_project: bool, + _window: &mut Window, + cx: &mut Context<Self>, + ) -> impl IntoElement { let t = theme(cx); let is_closing = project.is_closing; let is_creating = project.is_creating; @@ -302,7 +411,9 @@ impl Sidebar { let parent_id = project.parent_project_id.clone().unwrap_or_default(); let row = div() - .id(ElementId::Name(format!("worktree-row-{}", project.id).into())) + .id(ElementId::Name( + format!("worktree-row-{}", project.id).into(), + )) .group("worktree-item") .h(px(24.0)) .pl(px(indent)) @@ -314,15 +425,26 @@ impl Sidebar { .when(is_busy, |d| d.opacity(0.5)) .when(!is_busy, |d| d.hover(|s| s.bg(rgb(t.bg_hover)))) .when(is_focused_project && !is_busy, |d| d.bg(rgb(t.bg_hover))) - .when(is_cursor, |d| d.border_l_2().border_color(rgb(t.border_active))) + .when(is_cursor, |d| { + d.border_l_2().border_color(rgb(t.border_active)) + }) .when(!project.show_in_overview && !is_busy, |d| d.opacity(0.75)) .when(!parent_id.is_empty(), |d| { let wt_id = project_id.clone(); let wt_name = project_name.clone(); let pid = parent_id.clone(); - d.on_drag(WorktreeDrag { worktree_id: wt_id, parent_id: pid, worktree_name: wt_name }, move |drag, _position, _window, cx| { - cx.new(|_| WorktreeDragView { name: drag.worktree_name.clone() }) - }) + d.on_drag( + WorktreeDrag { + worktree_id: wt_id, + parent_id: pid, + worktree_name: wt_name, + }, + move |drag, _position, _window, cx| { + cx.new(|_| WorktreeDragView { + name: drag.worktree_name.clone(), + }) + }, + ) }) .drag_over::<WorktreeDrag>(move |style, _, _, _| { style.border_t_2().border_color(rgb(t.border_active)) @@ -332,19 +454,28 @@ 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, + ); } } })) - .on_mouse_down(MouseButton::Right, cx.listener({ - let project_id = project_id.clone(); - move |this, event: &MouseDownEvent, _window, cx| { - this.request_context_menu(project_id.clone(), event.position, cx); - cx.stop_propagation(); - } - })) + .on_mouse_down( + MouseButton::Right, + cx.listener({ + let project_id = project_id.clone(); + move |this, event: &MouseDownEvent, _window, cx| { + this.request_context_menu(project_id.clone(), event.position, cx); + cx.stop_propagation(); + } + }), + ) .on_click(cx.listener({ let project_id = project_id.clone(); move |this, _, _window, cx| { @@ -359,10 +490,21 @@ impl Sidebar { } })); - let busy_label = if is_creating { "Creating\u{2026}" } else { "Closing\u{2026}" }; + let busy_label = if is_creating { + "Creating\u{2026}" + } else { + "Closing\u{2026}" + }; self.append_project_row_content( - row, project, "wt", "worktree-item", - &ProjectRowStyle::Worktree { is_orphan: project.is_orphan, is_busy, busy_label }, + row, + project, + "wt", + "worktree-item", + &ProjectRowStyle::Worktree { + is_orphan: project.is_orphan, + is_busy, + busy_label, + }, cx, ) } @@ -403,12 +545,19 @@ impl Sidebar { }; let bell = terminal.is_some_and(|t| t.has_bell()); let waiting = terminal.is_some_and(|t| t.is_waiting_for_input()); - let idle = if waiting { terminal.map(|t| t.idle_duration_display()) } else { None }; + let idle = if waiting { + terminal.map(|t| t.idle_duration_display()) + } else { + None + }; (name, bell, waiting, idle) }; // Check if this terminal is being renamed - let is_renaming = is_renaming(&self.terminal_rename, &(project_id.clone(), terminal_id.clone())); + let is_renaming = is_renaming( + &self.terminal_rename, + &(project_id.clone(), terminal_id.clone()), + ); // Check if this terminal is currently focused let is_focused = { @@ -416,7 +565,8 @@ impl Sidebar { let fm = self.focus_manager.read(cx); fm.focused_terminal_state().is_some_and(|ft| { if let Some(proj) = ws.project(&project_id) { - proj.layout.as_ref() + proj.layout + .as_ref() .and_then(|l| l.find_terminal_path(&terminal_id)) .is_some_and(|path| ft.project_id == project_id && ft.layout_path == path) } else { @@ -426,7 +576,9 @@ impl Sidebar { }; div() - .id(ElementId::Name(format!("{}terminal-item-{}", id_prefix, terminal_id).into())) + .id(ElementId::Name( + format!("{}terminal-item-{}", id_prefix, terminal_id).into(), + )) .group("terminal-item") .h(px(22.0)) .when(is_in_tab_group, |d| { @@ -445,7 +597,9 @@ impl Sidebar { .when(is_minimized, |d| d.opacity(0.5)) .when(is_inactive_tab && !is_minimized, |d| d.opacity(0.5)) .when(is_focused, |d| d.bg(rgb(t.bg_selection))) - .when(is_cursor && !is_in_tab_group, |d| d.border_l_2().border_color(rgb(t.border_active))) + .when(is_cursor && !is_in_tab_group, |d| { + d.border_l_2().border_color(rgb(t.border_active)) + }) // Click to focus this terminal .on_click(cx.listener({ let project_id = project_id.clone(); @@ -488,7 +642,7 @@ impl Sidebar { rgb(t.text_muted) } else { rgb(t.success) - }) + }), ), ) .child( @@ -500,41 +654,52 @@ impl Sidebar { &t, cx, ) - .map(|el| el.into_any_element()) - .unwrap_or_else(|| div().flex_1().min_w_0().into_any_element()) + .map(|el| el.into_any_element()) + .unwrap_or_else(|| div().flex_1().min_w_0().into_any_element()) } else { sidebar_name_label( - ElementId::Name(format!("{}terminal-name-{}", id_prefix, terminal_id).into()), + ElementId::Name( + format!("{}terminal-name-{}", id_prefix, terminal_id).into(), + ), terminal_name.clone(), &t, cx, ) - .on_mouse_down(MouseButton::Left, cx.listener(|_this, _, _, cx| { + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _, _, cx| { cx.stop_propagation(); - })) - .on_click(cx.listener({ - let project_id = project_id.clone(); - let terminal_id = terminal_id.clone(); - let terminal_name = terminal_name.clone(); - move |this, _event: &ClickEvent, window, cx| { - if this.check_double_click(&terminal_id) { - this.start_rename(project_id.clone(), terminal_id.clone(), terminal_name.clone(), window, cx); - } else { - this.cursor_index = None; - let workspace = this.workspace.clone(); - let pid = project_id.clone(); - let tid = terminal_id.clone(); - this.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.focus_terminal_by_id(fm, &pid, &tid, cx); - }); - cx.notify(); + }), + ) + .on_click(cx.listener({ + let project_id = project_id.clone(); + let terminal_id = terminal_id.clone(); + let terminal_name = terminal_name.clone(); + move |this, _event: &ClickEvent, window, cx| { + if this.check_double_click(&terminal_id) { + this.start_rename( + project_id.clone(), + terminal_id.clone(), + terminal_name.clone(), + window, + cx, + ); + } else { + this.cursor_index = None; + let workspace = this.workspace.clone(); + let pid = project_id.clone(); + let tid = terminal_id.clone(); + this.focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + ws.focus_terminal_by_id(fm, &pid, &tid, cx); }); - } - cx.stop_propagation(); + cx.notify(); + }); } - })) - .into_any_element() + cx.stop_propagation(); + } + })) + .into_any_element() }, ) .children(idle_label.map(|d| { @@ -555,60 +720,78 @@ impl Sidebar { .child( // Minimize/restore button icon_button( - ElementId::Name(format!("{}minimize-{}", id_prefix, terminal_id).into()), + ElementId::Name( + format!("{}minimize-{}", id_prefix, terminal_id).into(), + ), "icons/minimize.svg", &t, ) - .on_mouse_down(MouseButton::Left, cx.listener(|_this, _, _, cx| { + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_click(cx.listener({ + let project_id = project_id.clone(); + let terminal_id = terminal_id.clone(); + move |this, _, _window, cx| { cx.stop_propagation(); - })) - .on_click(cx.listener({ - let project_id = project_id.clone(); - let terminal_id = terminal_id.clone(); - move |this, _, _window, cx| { - cx.stop_propagation(); - this.dispatch_action_for_project(&project_id, ActionRequest::ToggleMinimized { + this.dispatch_action_for_project( + &project_id, + ActionRequest::ToggleMinimized { project_id: project_id.clone(), terminal_id: terminal_id.clone(), - }, cx); - } - })) - .tooltip({ - let tooltip_text = if is_minimized { "Restore" } else { "Minimize" }; - move |_window, cx| { - Tooltip::new(tooltip_text) - .action(&MinimizeTerminal as &dyn Action, None) - .build(_window, cx) - } - }), + }, + cx, + ); + } + })) + .tooltip({ + let tooltip_text = if is_minimized { "Restore" } else { "Minimize" }; + move |_window, cx| { + Tooltip::new(tooltip_text) + .action(&MinimizeTerminal as &dyn Action, None) + .build(_window, cx) + } + }), ) .child( // Fullscreen button icon_button( - ElementId::Name(format!("{}fullscreen-{}", id_prefix, terminal_id).into()), + ElementId::Name( + format!("{}fullscreen-{}", id_prefix, terminal_id).into(), + ), "icons/fullscreen.svg", &t, ) - .on_mouse_down(MouseButton::Left, cx.listener(|_this, _, _, cx| { + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_click(cx.listener({ + let project_id = project_id.clone(); + let terminal_id = terminal_id.clone(); + move |this, _, _window, cx| { cx.stop_propagation(); - })) - .on_click(cx.listener({ - let project_id = project_id.clone(); - let terminal_id = terminal_id.clone(); - move |this, _, _window, cx| { - cx.stop_propagation(); - this.dispatch_action_for_project(&project_id, ActionRequest::SetFullscreen { + this.dispatch_action_for_project( + &project_id, + ActionRequest::SetFullscreen { project_id: project_id.clone(), terminal_id: Some(terminal_id.clone()), window: None, - }, cx); - } - })) - .tooltip(|_window, cx| { - Tooltip::new("Fullscreen") - .action(&ToggleFullscreen as &dyn Action, None) - .build(_window, cx) - }), + }, + cx, + ); + } + })) + .tooltip(|_window, cx| { + Tooltip::new("Fullscreen") + .action(&ToggleFullscreen as &dyn Action, None) + .build(_window, cx) + }), ), ) } @@ -636,10 +819,16 @@ impl Sidebar { let project_name = project.name.clone(); let is_renaming = is_renaming(&self.project_rename, &project.id); - let idle_count = if !is_expanded { self.count_waiting_terminals(&project.terminal_ids) } else { 0 }; + let idle_count = if !is_expanded { + self.count_waiting_terminals(&project.terminal_ids) + } else { + 0 + }; let base = div() - .id(ElementId::Name(format!("{}-{}", id_prefix, project.id).into())) + .id(ElementId::Name( + format!("{}-{}", id_prefix, project.id).into(), + )) .group(group_name) .h(px(24.0)) .pl(px(left_padding)) @@ -650,137 +839,178 @@ impl Sidebar { .cursor_pointer() .hover(|s| s.bg(rgb(t.bg_hover))) .when(is_focused_project, |d| d.bg(rgb(t.bg_hover))) - .when(is_cursor, |d| d.border_l_2().border_color(rgb(t.border_active))) - .when(all_hidden, |d| d.opacity(0.75)) - .on_drag(ProjectDrag { project_id: project_id.clone(), project_name: project_name.clone() }, move |drag, _position, _window, cx| { - cx.new(|_| ProjectDragView { name: drag.project_name.clone() }) + .when(is_cursor, |d| { + d.border_l_2().border_color(rgb(t.border_active)) }) + .when(all_hidden, |d| d.opacity(0.75)) + .on_drag( + ProjectDrag { + project_id: project_id.clone(), + project_name: project_name.clone(), + }, + move |drag, _position, _window, cx| { + cx.new(|_| ProjectDragView { + name: drag.project_name.clone(), + }) + }, + ) .drag_over::<ProjectDrag>(move |style, _, _, _| { style.border_t_2().border_color(rgb(t.border_active)) }); let base = match drag_config { - GroupHeaderDragConfig::TopLevel { index } => { - base - .on_drop(cx.listener({ - 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); }); - } + GroupHeaderDragConfig::TopLevel { index } => base + .on_drop(cx.listener({ + let project_id = project_id.clone(); + move |this, drag: &ProjectDrag, _window, cx| { + if drag.project_id != project_id { + this.dispatch_action_for_project( + &drag.project_id, + ActionRequest::MoveProject { + project_id: drag.project_id.clone(), + new_index: index, + }, + cx, + ); } - })) - .drag_over::<FolderDrag>(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, index, cx); }); - })) - } - GroupHeaderDragConfig::InFolder { folder_id } => { - base - .on_drop(cx.listener({ - let folder_id = folder_id.clone(); - let project_id = project_id.clone(); - move |this, drag: &ProjectDrag, _window, cx| { - if drag.project_id != project_id { - 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); }); - } - } + } + })) + .drag_over::<FolderDrag>(move |style, _, _, _| { + style.border_t_2().border_color(rgb(t.border_active)) + }) + .on_drop(cx.listener(move |this, drag: &FolderDrag, _window, 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 } => base.on_drop(cx.listener({ + let folder_id = folder_id.clone(); + let project_id = project_id.clone(); + move |this, drag: &ProjectDrag, _window, cx| { + if drag.project_id != project_id { + 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.dispatch_action_for_project( + &drag.project_id, + ActionRequest::MoveProjectToFolder { + project_id: drag.project_id.clone(), + folder_id: folder_id.clone(), + position: Some(pos), + }, + cx, + ); } - })) - } + } + } + })), }; - base - .on_mouse_down(MouseButton::Right, cx.listener({ + base.on_mouse_down( + MouseButton::Right, + cx.listener({ let project_id = project_id.clone(); move |this, event: &MouseDownEvent, _window, cx| { this.request_context_menu(project_id.clone(), event.position, cx); cx.stop_propagation(); } - })) + }), + ) + .on_click(cx.listener({ + let project_id = project_id.clone(); + move |this, _, _window, cx| { + this.cursor_index = None; + let workspace = this.workspace.clone(); + this.focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + ws.set_focused_project(fm, Some(project_id.clone()), cx); + }); + cx.notify(); + }); + } + })) + .child( + sidebar_expand_arrow( + ElementId::Name(format!("expand-{}-{}", id_prefix, project.id).into()), + is_expanded, + &t, + ) .on_click(cx.listener({ let project_id = project_id.clone(); move |this, _, _window, cx| { - this.cursor_index = None; - let workspace = this.workspace.clone(); - this.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.set_focused_project(fm, Some(project_id.clone()), cx); - }); - cx.notify(); - }); + this.toggle_worktrees_collapsed(&project_id); + cx.notify(); + cx.stop_propagation(); } - })) - .child( - sidebar_expand_arrow( - ElementId::Name(format!("expand-{}-{}", id_prefix, project.id).into()), - is_expanded, - &t, - ) - .on_click(cx.listener({ - let project_id = project_id.clone(); - move |this, _, _window, cx| { - this.toggle_worktrees_collapsed(&project_id); - cx.notify(); - cx.stop_propagation(); - } - })) + })), + ) + .child({ + let folder_color = t.get_folder_color(project.folder_color); + let project_id = project.id.clone(); + sidebar_color_indicator( + ElementId::Name(format!("{}-icon-{}", id_prefix, project.id).into()), + color_dot(folder_color, false), ) - .child({ - let folder_color = t.get_folder_color(project.folder_color); - let project_id = project.id.clone(); - sidebar_color_indicator( - ElementId::Name(format!("{}-icon-{}", id_prefix, project.id).into()), - color_dot(folder_color, false), - ) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { this.show_color_picker(project_id.clone(), event.position, cx); cx.stop_propagation(); - })) - }) - .child( - if is_renaming { - sidebar_rename_input( - ElementId::Name(format!("{}-rename-input", id_prefix).into()), - &self.project_rename, &t, cx, - ) - .map(|el| el.into_any_element()) - .unwrap_or_else(|| div().flex_1().into_any_element()) - } else { - sidebar_name_label( - ElementId::Name(format!("{}-name-{}", id_prefix, project.id).into()), - project_name.clone(), &t, cx, - ) - .font_weight(FontWeight::MEDIUM) - .on_click(cx.listener({ - let project_id = project_id.clone(); - let project_name = project_name.clone(); - move |this, _event: &ClickEvent, window, cx| { - if this.check_project_double_click(&project_id) { - this.start_project_rename(project_id.clone(), project_name.clone(), window, cx); - } else { - this.cursor_index = None; - let workspace = this.workspace.clone(); - let pid = project_id.clone(); - this.focus_manager.update(cx, |fm, cx| { - workspace.update(cx, |ws, cx| { - ws.set_focused_project(fm, Some(pid), cx); - }); - cx.notify(); - }); - } - cx.stop_propagation(); - } - })) - .into_any_element() - }, + }), + ) + }) + .child(if is_renaming { + sidebar_rename_input( + ElementId::Name(format!("{}-rename-input", id_prefix).into()), + &self.project_rename, + &t, + cx, + ) + .map(|el| el.into_any_element()) + .unwrap_or_else(|| div().flex_1().into_any_element()) + } else { + sidebar_name_label( + ElementId::Name(format!("{}-name-{}", id_prefix, project.id).into()), + project_name.clone(), + &t, + cx, ) - .when(idle_count > 0, |d| d.child(sidebar_idle_dot(&t))) + .font_weight(FontWeight::MEDIUM) + .on_click(cx.listener({ + let project_id = project_id.clone(); + let project_name = project_name.clone(); + move |this, _event: &ClickEvent, window, cx| { + if this.check_project_double_click(&project_id) { + this.start_project_rename( + project_id.clone(), + project_name.clone(), + window, + cx, + ); + } else { + this.cursor_index = None; + let workspace = this.workspace.clone(); + let pid = project_id.clone(); + this.focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + ws.set_focused_project(fm, Some(pid), cx); + }); + cx.notify(); + }); + } + cx.stop_propagation(); + } + })) + .into_any_element() + }) + .when(idle_count > 0, |d| d.child(sidebar_idle_dot(&t))) } /// Render main project as a child row under a group header. @@ -802,7 +1032,9 @@ impl Sidebar { let project_id = project.id.clone(); let row = div() - .id(ElementId::Name(format!("{}-{}", id_prefix, project.id).into())) + .id(ElementId::Name( + format!("{}-{}", id_prefix, project.id).into(), + )) .group(group_name) .h(px(24.0)) .pl(px(left_padding)) @@ -813,7 +1045,9 @@ impl Sidebar { .cursor_pointer() .hover(|s| s.bg(rgb(t.bg_hover))) .when(is_focused_project, |d| d.bg(rgb(t.bg_hover))) - .when(is_cursor, |d| d.border_l_2().border_color(rgb(t.border_active))) + .when(is_cursor, |d| { + d.border_l_2().border_color(rgb(t.border_active)) + }) .when(!project.show_in_overview, |d| d.opacity(0.75)) .on_click(cx.listener({ let project_id = project_id.clone(); @@ -828,16 +1062,24 @@ impl Sidebar { }); } })) - .on_mouse_down(MouseButton::Right, cx.listener({ - let project_id = project_id.clone(); - move |this, event: &MouseDownEvent, _window, cx| { - this.request_context_menu(project_id.clone(), event.position, cx); - cx.stop_propagation(); - } - })); + .on_mouse_down( + MouseButton::Right, + cx.listener({ + let project_id = project_id.clone(); + move |this, event: &MouseDownEvent, _window, cx| { + this.request_context_menu(project_id.clone(), event.position, cx); + cx.stop_propagation(); + } + }), + ); - self.append_project_row_content(row, project, id_prefix, group_name, &ProjectRowStyle::GroupChild, cx) + self.append_project_row_content( + row, + project, + id_prefix, + group_name, + &ProjectRowStyle::GroupChild, + cx, + ) } - - } diff --git a/crates/okena-views-sidebar/src/remote_list.rs b/crates/okena-views-sidebar/src/remote_list.rs index db183c15b..b964d7a0c 100644 --- a/crates/okena-views-sidebar/src/remote_list.rs +++ b/crates/okena-views-sidebar/src/remote_list.rs @@ -1,8 +1,10 @@ -use okena_transport::client::{ConnectionStatus, RemoteConnectionConfig}; +use gpui::*; +use okena_transport::client::{ + ConnectionStatus, LOCAL_DAEMON_CONNECTION_ID, RemoteConnectionConfig, +}; use okena_ui::theme::theme; -use okena_ui::tokens::{ui_text_ms, ui_text_md, ui_text_sm, ui_text_xl}; +use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm, ui_text_xl}; use okena_workspace::requests::OverlayRequest; -use gpui::*; use crate::sidebar::Sidebar; @@ -23,15 +25,24 @@ impl Sidebar { return div().into_any_element(); } - // Snapshot connection data via callback - let snapshots: Vec<ConnectionSnapshot> = 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() - } else { - Vec::new() - }; + // 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<ConnectionSnapshot> = + if let Some(ref get_connections) = self.get_remote_connections { + (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() + }; if snapshots.is_empty() { return div() @@ -96,9 +107,7 @@ impl Sidebar { let status_text = match status { ConnectionStatus::Connecting => "Connecting...", ConnectionStatus::Pairing => "Pairing...", - ConnectionStatus::Reconnecting { .. } => { - "Reconnecting..." - } + ConnectionStatus::Reconnecting { .. } => "Reconnecting...", ConnectionStatus::Disconnected => "Disconnected", ConnectionStatus::Error(_) => "Error", ConnectionStatus::Connected => "Connected", @@ -106,15 +115,16 @@ impl Sidebar { let conn_id_for_ctx = config.id.clone(); let conn_name_for_ctx = config.name.clone(); - let is_pairing = matches!(status, ConnectionStatus::Pairing | ConnectionStatus::Error(_) | ConnectionStatus::Disconnected); + let is_pairing = matches!( + status, + ConnectionStatus::Pairing | ConnectionStatus::Error(_) | ConnectionStatus::Disconnected + ); // Flag plain-http connections so the user can spot (and upgrade) them. let insecure = !config.tls; let conn_tls = config.tls; div() - .id(ElementId::Name( - format!("remote-conn-{}", config.id).into(), - )) + .id(ElementId::Name(format!("remote-conn-{}", config.id).into())) .h(px(28.0)) .px(px(12.0)) .flex() @@ -122,9 +132,11 @@ impl Sidebar { .gap(px(6.0)) .cursor_pointer() .hover(|s| s.bg(rgb(t.bg_hover))) - .on_mouse_down(MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.request_broker.update(cx, |broker, cx| { - broker.push_overlay_request( + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.request_broker.update(cx, |broker, cx| { + broker.push_overlay_request( okena_workspace::requests::OverlayRequest::RemoteConnectionContextMenu { connection_id: conn_id_for_ctx.clone(), connection_name: conn_name_for_ctx.clone(), @@ -134,9 +146,10 @@ impl Sidebar { }, cx, ); - }); - cx.stop_propagation(); - })) + }); + cx.stop_propagation(); + }), + ) .child( // Status dot div() diff --git a/crates/okena-views-sidebar/src/rename_directory_dialog.rs b/crates/okena-views-sidebar/src/rename_directory_dialog.rs index c9b519352..74cffcc5a 100644 --- a/crates/okena-views-sidebar/src/rename_directory_dialog.rs +++ b/crates/okena-views-sidebar/src/rename_directory_dialog.rs @@ -1,16 +1,15 @@ //! Dialog for renaming a project's directory on disk. use crate::Cancel; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; use okena_ui::button::{button, button_primary}; use okena_ui::input::input_container; 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; +use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms, ui_text_xl}; use std::path::Path; /// Events emitted by the rename directory dialog @@ -18,19 +17,24 @@ 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<RenameDirectoryDialogEvent> for RenameDirectoryDialog {} /// Dialog for renaming a project's directory on disk. pub struct RenameDirectoryDialog { - workspace: Entity<Workspace>, project_id: String, project_path: String, name_input: Entity<SimpleInputState>, @@ -40,12 +44,7 @@ pub struct RenameDirectoryDialog { } impl RenameDirectoryDialog { - pub fn new( - workspace: Entity<Workspace>, - project_id: String, - project_path: String, - cx: &mut Context<Self>, - ) -> Self { + pub fn new(project_id: String, project_path: String, cx: &mut Context<Self>) -> Self { let current_name = Path::new(&project_path) .file_name() .and_then(|n| n.to_str()) @@ -53,14 +52,12 @@ impl RenameDirectoryDialog { .to_string(); let name_input = cx.new(|cx| { - let mut input = SimpleInputState::new(cx) - .placeholder("Directory name..."); + let mut input = SimpleInputState::new(cx).placeholder("Directory name..."); input.set_value(¤t_name, cx); input }); 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); } } @@ -164,9 +135,12 @@ impl Render for RenameDirectoryDialog { this.confirm(cx); } })) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.close(cx); + }), + ) .child( modal_content("rename-dir-dialog", &t) .w(px(420.0)) @@ -237,8 +211,9 @@ impl Render for RenameDirectoryDialog { .child(path_display), ) .child( - input_container(&t, Some(input_focused)) - .child(SimpleInput::new(&name_input).text_size(ui_text(13.0, cx))), + input_container(&t, Some(input_focused)).child( + SimpleInput::new(&name_input).text_size(ui_text(13.0, cx)), + ), ), ) .when_some(error_msg, |d, msg| { diff --git a/crates/okena-views-sidebar/src/service_list.rs b/crates/okena-views-sidebar/src/service_list.rs index a6fff6ffb..513a8cb13 100644 --- a/crates/okena-views-sidebar/src/service_list.rs +++ b/crates/okena-views-sidebar/src/service_list.rs @@ -1,12 +1,12 @@ //! Service list rendering for the sidebar -use okena_ui::theme::theme; use gpui::*; use okena_core::api::ActionRequest; +use okena_ui::theme::theme; use okena_views_services::types::ServiceSnapshot; -use crate::sidebar::{Sidebar, SidebarProjectInfo, SidebarServiceInfo, GroupKind}; use crate::item_widgets::sidebar_group_header; +use crate::sidebar::{GroupKind, Sidebar, SidebarProjectInfo, SidebarServiceInfo}; impl Sidebar { /// Render the "Services" group header with collapse chevron + Start All / Stop All / Reload buttons. @@ -37,52 +37,62 @@ impl Sidebar { // Spacer to push action buttons to the right div().flex_1(), ) - .child( - okena_views_services::sidebar::render_service_group_actions( - &project_id, - &t, - cx, - { - let entity = entity.clone(); - let project_id = project_id.clone(); - move |_window, cx| { - if let Some(entity) = entity.upgrade() { - entity.update(cx, |this, cx| { - this.dispatch_action_for_project(&project_id, ActionRequest::StartAllServices { + .child(okena_views_services::sidebar::render_service_group_actions( + &project_id, + &t, + cx, + { + let entity = entity.clone(); + let project_id = project_id.clone(); + move |_window, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, cx| { + this.dispatch_action_for_project( + &project_id, + ActionRequest::StartAllServices { project_id: project_id.clone(), - }, cx); - }); - } + }, + cx, + ); + }); } - }, - { - let entity = entity.clone(); - let project_id = project_id.clone(); - move |_window, cx| { - if let Some(entity) = entity.upgrade() { - entity.update(cx, |this, cx| { - this.dispatch_action_for_project(&project_id, ActionRequest::StopAllServices { + } + }, + { + let entity = entity.clone(); + let project_id = project_id.clone(); + move |_window, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, cx| { + this.dispatch_action_for_project( + &project_id, + ActionRequest::StopAllServices { project_id: project_id.clone(), - }, cx); - }); - } + }, + cx, + ); + }); } - }, - { - let entity = entity.clone(); - let project_id = project_id.clone(); - move |_window, cx| { - if let Some(entity) = entity.upgrade() { - entity.update(cx, |this, cx| { - this.dispatch_action_for_project(&project_id, ActionRequest::ReloadServices { + } + }, + { + let entity = entity.clone(); + let project_id = project_id.clone(); + move |_window, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, cx| { + this.dispatch_action_for_project( + &project_id, + ActionRequest::ReloadServices { project_id: project_id.clone(), - }, cx); - }); - } + }, + cx, + ); + }); } - }, - ), - ) + } + }, + )) .on_click(cx.listener({ let project_id = project_id.clone(); move |this, _, _window, cx| { @@ -132,10 +142,14 @@ impl Sidebar { move |_window, cx| { if let Some(entity) = entity.upgrade() { entity.update(cx, |this, cx| { - this.dispatch_action_for_project(&project_id, ActionRequest::StartService { - project_id: project_id.clone(), - service_name: service_name.clone(), - }, cx); + this.dispatch_action_for_project( + &project_id, + ActionRequest::StartService { + project_id: project_id.clone(), + service_name: service_name.clone(), + }, + cx, + ); }); } } @@ -148,10 +162,14 @@ impl Sidebar { move |_window, cx| { if let Some(entity) = entity.upgrade() { entity.update(cx, |this, cx| { - this.dispatch_action_for_project(&project_id, ActionRequest::StopService { - project_id: project_id.clone(), - service_name: service_name.clone(), - }, cx); + this.dispatch_action_for_project( + &project_id, + ActionRequest::StopService { + project_id: project_id.clone(), + service_name: service_name.clone(), + }, + cx, + ); }); } } @@ -164,10 +182,14 @@ impl Sidebar { move |_window, cx| { if let Some(entity) = entity.upgrade() { entity.update(cx, |this, cx| { - this.dispatch_action_for_project(&project_id, ActionRequest::RestartService { - project_id: project_id.clone(), - service_name: service_name.clone(), - }, cx); + this.dispatch_action_for_project( + &project_id, + ActionRequest::RestartService { + project_id: project_id.clone(), + service_name: service_name.clone(), + }, + cx, + ); }); } } diff --git a/crates/okena-views-sidebar/src/sidebar/cursor.rs b/crates/okena-views-sidebar/src/sidebar/cursor.rs index a4d23b79c..db4beb221 100644 --- a/crates/okena-views-sidebar/src/sidebar/cursor.rs +++ b/crates/okena-views-sidebar/src/sidebar/cursor.rs @@ -19,14 +19,15 @@ impl Sidebar { let focused_id = self.focus_manager.read(cx).focused_project_id().cloned(); if let Some(ref focused_id) = focused_id && let Some(pos) = items.iter().position(|item| match item { - SidebarCursorItem::Project { project_id } | - SidebarCursorItem::WorktreeProject { project_id } => project_id == focused_id, + SidebarCursorItem::Project { project_id } + | SidebarCursorItem::WorktreeProject { project_id } => project_id == focused_id, _ => false, - }) { - self.cursor_index = Some(pos); - cx.notify(); - return; - } + }) + { + self.cursor_index = Some(pos); + cx.notify(); + return; + } self.cursor_index = Some(0); cx.notify(); } @@ -42,19 +43,31 @@ impl Sidebar { return self.activity_cursor_items.clone(); } let workspace = self.workspace.read(cx); - let all_projects: HashMap<&str, &ProjectData> = workspace.data().projects.iter() + let all_projects: HashMap<&str, &ProjectData> = workspace + .data() + .projects + .iter() .map(|p| (p.id.as_str(), p)) .collect(); - let all_project_ids: HashSet<&str> = workspace.data().projects.iter() - .map(|p| p.id.as_str()).collect(); + let all_project_ids: HashSet<&str> = workspace + .data() + .projects + .iter() + .map(|p| p.id.as_str()) + .collect(); // Pre-collect service names per project (avoids borrow issues with cx) - let service_names: HashMap<String, Vec<String>> = if let Some(ref sm) = self.service_manager { + let service_names: HashMap<String, Vec<String>> = if let Some(ref sm) = self.service_manager + { let sm = sm.read(cx); - workspace.data().projects.iter() + workspace + .data() + .projects + .iter() .filter(|p| sm.has_services(&p.id)) .map(|p| { - let names = sm.services_for_project(&p.id) + let names = sm + .services_for_project(&p.id) .into_iter() .map(|inst| inst.definition.name.clone()) .collect(); @@ -66,7 +79,10 @@ impl Sidebar { }; // Pre-collect hook terminal IDs per project - let hook_terminal_ids: HashMap<String, Vec<String>> = workspace.data().projects.iter() + let hook_terminal_ids: HashMap<String, Vec<String>> = workspace + .data() + .projects + .iter() .filter(|p| !p.hook_terminals.is_empty()) .map(|p| { let ids = p.hook_terminals.keys().cloned().collect(); @@ -95,7 +111,9 @@ impl Sidebar { for id in &workspace.data().project_order { // Check if this is a folder if let Some(folder) = workspace.data().folders.iter().find(|f| &f.id == id) { - cursor_items.push(SidebarCursorItem::Folder { folder_id: folder.id.clone() }); + cursor_items.push(SidebarCursorItem::Folder { + folder_id: folder.id.clone(), + }); if !workspace.is_folder_collapsed(self.window_id, &folder.id) { for pid in &folder.project_ids { @@ -106,7 +124,13 @@ impl Sidebar { }) { continue; } - self.push_project_cursor_items(project, &worktree_children_map, &service_names, &hook_terminal_ids, &mut cursor_items); + self.push_project_cursor_items( + project, + &worktree_children_map, + &service_names, + &hook_terminal_ids, + &mut cursor_items, + ); } } } @@ -115,12 +139,20 @@ impl Sidebar { // Top-level project (not a worktree child of another) if let Some(&project) = all_projects.get(id.as_str()) { - if project.worktree_info.as_ref().is_some_and(|w| { - all_project_ids.contains(w.parent_project_id.as_str()) - }) { + if project + .worktree_info + .as_ref() + .is_some_and(|w| all_project_ids.contains(w.parent_project_id.as_str())) + { continue; } - self.push_project_cursor_items(project, &worktree_children_map, &service_names, &hook_terminal_ids, &mut cursor_items); + self.push_project_cursor_items( + project, + &worktree_children_map, + &service_names, + &hook_terminal_ids, + &mut cursor_items, + ); } } @@ -136,38 +168,66 @@ impl Sidebar { hook_terminal_ids: &HashMap<String, Vec<String>>, cursor_items: &mut Vec<SidebarCursorItem>, ) { - let has_worktrees = worktree_children_map.get(&project.id).is_some_and(|c| !c.is_empty()); + let has_worktrees = worktree_children_map + .get(&project.id) + .is_some_and(|c| !c.is_empty()); let is_orphan = project.worktree_info.is_some(); if has_worktrees && !is_orphan { // Group header mode: Project = group header, WorktreeProject = main project child - cursor_items.push(SidebarCursorItem::Project { project_id: project.id.clone() }); + cursor_items.push(SidebarCursorItem::Project { + project_id: project.id.clone(), + }); let is_expanded = self.is_project_expanded(&project.id, true); if is_expanded { // Main project as first child - cursor_items.push(SidebarCursorItem::WorktreeProject { project_id: project.id.clone() }); + cursor_items.push(SidebarCursorItem::WorktreeProject { + project_id: project.id.clone(), + }); if self.expanded_projects.contains(&project.id) { - self.push_group_cursor_items(&project.id, &project.layout, service_names, hook_terminal_ids, cursor_items); + self.push_group_cursor_items( + &project.id, + &project.layout, + service_names, + hook_terminal_ids, + cursor_items, + ); } // Worktree children as siblings if let Some(children) = worktree_children_map.get(&project.id) { for child in children { - cursor_items.push(SidebarCursorItem::WorktreeProject { project_id: child.id.clone() }); + cursor_items.push(SidebarCursorItem::WorktreeProject { + project_id: child.id.clone(), + }); if self.expanded_projects.contains(&child.id) { - self.push_group_cursor_items(&child.id, &child.layout, service_names, hook_terminal_ids, cursor_items); + self.push_group_cursor_items( + &child.id, + &child.layout, + service_names, + hook_terminal_ids, + cursor_items, + ); } } } } } else { // Standard mode: no worktrees - cursor_items.push(SidebarCursorItem::Project { project_id: project.id.clone() }); + cursor_items.push(SidebarCursorItem::Project { + project_id: project.id.clone(), + }); if self.expanded_projects.contains(&project.id) { - self.push_group_cursor_items(&project.id, &project.layout, service_names, hook_terminal_ids, cursor_items); + self.push_group_cursor_items( + &project.id, + &project.layout, + service_names, + hook_terminal_ids, + cursor_items, + ); } } } @@ -203,39 +263,41 @@ impl Sidebar { // Services group if let Some(names) = service_names.get(project_id) - && !names.is_empty() { - cursor_items.push(SidebarCursorItem::GroupHeader { - project_id: project_id.to_string(), - group: GroupKind::Services, - }); - - if !self.is_group_collapsed(project_id, &GroupKind::Services) { - for name in names { - cursor_items.push(SidebarCursorItem::Service { - project_id: project_id.to_string(), - service_name: name.clone(), - }); - } + && !names.is_empty() + { + cursor_items.push(SidebarCursorItem::GroupHeader { + project_id: project_id.to_string(), + group: GroupKind::Services, + }); + + if !self.is_group_collapsed(project_id, &GroupKind::Services) { + for name in names { + cursor_items.push(SidebarCursorItem::Service { + project_id: project_id.to_string(), + service_name: name.clone(), + }); } } + } // Hooks group if let Some(tids) = hook_terminal_ids.get(project_id) - && !tids.is_empty() { - cursor_items.push(SidebarCursorItem::GroupHeader { - project_id: project_id.to_string(), - group: GroupKind::Hooks, - }); - - if !self.is_group_collapsed(project_id, &GroupKind::Hooks) { - for tid in tids { - cursor_items.push(SidebarCursorItem::Hook { - project_id: project_id.to_string(), - terminal_id: tid.clone(), - }); - } + && !tids.is_empty() + { + cursor_items.push(SidebarCursorItem::GroupHeader { + project_id: project_id.to_string(), + group: GroupKind::Hooks, + }); + + if !self.is_group_collapsed(project_id, &GroupKind::Hooks) { + for tid in tids { + cursor_items.push(SidebarCursorItem::Hook { + project_id: project_id.to_string(), + terminal_id: tid.clone(), + }); } } + } } /// Clamp cursor to valid range @@ -243,9 +305,10 @@ impl Sidebar { if item_count == 0 { self.cursor_index = None; } else if let Some(ref mut idx) = self.cursor_index - && *idx >= item_count { - *idx = item_count - 1; - } + && *idx >= item_count + { + *idx = item_count - 1; + } } /// Check if any rename is active (blocks keyboard nav) @@ -268,10 +331,19 @@ impl Sidebar { .unwrap_or(false) } - pub(super) fn handle_sidebar_up(&mut self, _: &SidebarUp, _window: &mut Window, cx: &mut Context<Self>) { - if self.is_interactive_mode_active() { return; } + pub(super) fn handle_sidebar_up( + &mut self, + _: &SidebarUp, + _window: &mut Window, + cx: &mut Context<Self>, + ) { + if self.is_interactive_mode_active() { + return; + } let items = self.build_cursor_items(cx); - if items.is_empty() { return; } + if items.is_empty() { + return; + } match self.cursor_index { Some(idx) if idx > 0 => self.cursor_index = Some(idx - 1), None => self.cursor_index = Some(items.len() - 1), @@ -281,10 +353,19 @@ impl Sidebar { cx.notify(); } - pub(super) fn handle_sidebar_down(&mut self, _: &SidebarDown, _window: &mut Window, cx: &mut Context<Self>) { - if self.is_interactive_mode_active() { return; } + pub(super) fn handle_sidebar_down( + &mut self, + _: &SidebarDown, + _window: &mut Window, + cx: &mut Context<Self>, + ) { + if self.is_interactive_mode_active() { + return; + } let items = self.build_cursor_items(cx); - if items.is_empty() { return; } + if items.is_empty() { + return; + } match self.cursor_index { Some(idx) if idx < items.len() - 1 => self.cursor_index = Some(idx + 1), None => self.cursor_index = Some(0), @@ -294,7 +375,12 @@ impl Sidebar { cx.notify(); } - pub(super) fn handle_sidebar_confirm(&mut self, _: &SidebarConfirm, window: &mut Window, cx: &mut Context<Self>) { + pub(super) fn handle_sidebar_confirm( + &mut self, + _: &SidebarConfirm, + window: &mut Window, + cx: &mut Context<Self>, + ) { if self.project_rename.is_some() { self.finish_project_rename(cx); return; @@ -307,7 +393,9 @@ impl Sidebar { self.finish_rename(cx); return; } - if self.is_interactive_mode_active() { return; } + if self.is_interactive_mode_active() { + return; + } let items = self.build_cursor_items(cx); let Some(idx) = self.cursor_index else { return }; let Some(item) = items.get(idx) else { return }; @@ -315,7 +403,11 @@ impl Sidebar { match item.clone() { SidebarCursorItem::Project { project_id } => { // Project may be a group header (has worktrees) → non-individual focus - let has_worktrees = !self.workspace.read(cx).worktree_child_ids(&project_id).is_empty(); + let has_worktrees = !self + .workspace + .read(cx) + .worktree_child_ids(&project_id) + .is_empty(); let workspace = self.workspace.clone(); if has_worktrees { self.focus_manager.update(cx, |fm, cx| { @@ -352,7 +444,10 @@ impl Sidebar { } self.saved_focus = None; } - SidebarCursorItem::Terminal { project_id, terminal_id } => { + SidebarCursorItem::Terminal { + project_id, + terminal_id, + } => { let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| { @@ -375,15 +470,18 @@ impl Sidebar { SidebarCursorItem::GroupHeader { project_id, group } => { self.toggle_group(&project_id, group); } - SidebarCursorItem::Service { project_id, service_name } => { + SidebarCursorItem::Service { + project_id, + service_name, + } => { // Toggle start/stop for the service if let Some(ref sm) = self.service_manager { sm.update(cx, |sm, cx| { let key = (project_id.clone(), service_name.clone()); if let Some(inst) = sm.instances().get(&key) { match inst.status { - okena_services::manager::ServiceStatus::Running | - okena_services::manager::ServiceStatus::Starting => { + okena_services::manager::ServiceStatus::Running + | okena_services::manager::ServiceStatus::Starting => { sm.stop_service(&project_id, &service_name, cx); } _ => { @@ -398,7 +496,11 @@ impl Sidebar { } } SidebarCursorItem::RemoteConnection { connection_id } => { - let collapsed = self.collapsed_connections.get(&connection_id).copied().unwrap_or(false); + let collapsed = self + .collapsed_connections + .get(&connection_id) + .copied() + .unwrap_or(false); self.collapsed_connections.insert(connection_id, !collapsed); } SidebarCursorItem::RemoteProject { project_id, .. } => { @@ -416,7 +518,10 @@ impl Sidebar { } self.saved_focus = None; } - SidebarCursorItem::Hook { project_id, terminal_id } => { + SidebarCursorItem::Hook { + project_id, + terminal_id, + } => { let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| { @@ -434,8 +539,15 @@ impl Sidebar { cx.notify(); } - pub(super) fn handle_sidebar_toggle_expand(&mut self, _: &SidebarToggleExpand, _window: &mut Window, cx: &mut Context<Self>) { - if self.is_interactive_mode_active() { return; } + pub(super) fn handle_sidebar_toggle_expand( + &mut self, + _: &SidebarToggleExpand, + _window: &mut Window, + cx: &mut Context<Self>, + ) { + if self.is_interactive_mode_active() { + return; + } let items = self.build_cursor_items(cx); let Some(idx) = self.cursor_index else { return }; let Some(item) = items.get(idx) else { return }; @@ -450,8 +562,11 @@ impl Sidebar { SidebarCursorItem::Project { project_id } => { // Mirror mouse behavior: toggle worktree collapse for parent projects, // terminal details for projects without worktrees - let has_worktrees = !self.workspace.read(cx) - .worktree_child_ids(&project_id).is_empty(); + let has_worktrees = !self + .workspace + .read(cx) + .worktree_child_ids(&project_id) + .is_empty(); if has_worktrees { self.toggle_worktrees_collapsed(&project_id); } else { @@ -464,9 +579,15 @@ impl Sidebar { SidebarCursorItem::GroupHeader { project_id, group } => { self.toggle_group(&project_id, group); } - SidebarCursorItem::Terminal { .. } | SidebarCursorItem::Service { .. } | SidebarCursorItem::Hook { .. } => {} + SidebarCursorItem::Terminal { .. } + | SidebarCursorItem::Service { .. } + | SidebarCursorItem::Hook { .. } => {} SidebarCursorItem::RemoteConnection { connection_id } => { - let collapsed = self.collapsed_connections.get(&connection_id).copied().unwrap_or(false); + let collapsed = self + .collapsed_connections + .get(&connection_id) + .copied() + .unwrap_or(false); self.collapsed_connections.insert(connection_id, !collapsed); } SidebarCursorItem::RemoteProject { .. } => {} @@ -474,7 +595,12 @@ impl Sidebar { cx.notify(); } - pub(super) fn handle_sidebar_escape(&mut self, _: &SidebarEscape, window: &mut Window, cx: &mut Context<Self>) { + pub(super) fn handle_sidebar_escape( + &mut self, + _: &SidebarEscape, + window: &mut Window, + cx: &mut Context<Self>, + ) { if self.project_rename.is_some() { self.cancel_project_rename(cx); return; diff --git a/crates/okena-views-sidebar/src/sidebar/from_project_test.rs b/crates/okena-views-sidebar/src/sidebar/from_project_test.rs index d70122e88..216802242 100644 --- a/crates/okena-views-sidebar/src/sidebar/from_project_test.rs +++ b/crates/okena-views-sidebar/src/sidebar/from_project_test.rs @@ -7,7 +7,9 @@ use super::SidebarProjectInfo; use okena_core::theme::FolderColor; use okena_workspace::settings::HooksConfig; -use okena_workspace::state::{LayoutNode, ProjectData, WindowId, WindowState, Workspace, WorkspaceData}; +use okena_workspace::state::{ + LayoutNode, ProjectData, WindowId, WindowState, Workspace, WorkspaceData, +}; use std::collections::HashMap; fn make_project(id: &str) -> ProjectData { @@ -29,6 +31,8 @@ fn make_project(id: &str) -> ProjectData { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } diff --git a/crates/okena-views-sidebar/src/sidebar/mod.rs b/crates/okena-views-sidebar/src/sidebar/mod.rs index b7b1e375c..65b1805f5 100644 --- a/crates/okena-views-sidebar/src/sidebar/mod.rs +++ b/crates/okena-views-sidebar/src/sidebar/mod.rs @@ -8,28 +8,28 @@ //! - Organizing projects into collapsible folders mod cursor; -mod render; mod renames; +mod render; mod worktree; #[cfg(test)] mod from_project_test; +use gpui::prelude::FluentBuilder; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::tooltip::Tooltip; use okena_core::api::ActionRequest; -use okena_transport::client::{ConnectionStatus, RemoteConnectionConfig}; use okena_core::theme::FolderColor; use okena_services::manager::ServiceManager; use okena_terminal::TerminalsRegistry; +use okena_transport::client::{ConnectionStatus, RemoteConnectionConfig}; use okena_ui::click_detector::ClickDetector; use okena_ui::rename_state::RenameState; use okena_ui::theme::theme; use okena_ui::tokens::{ui_text_ms, ui_text_xl, ui_text_xs}; use okena_workspace::request_broker::RequestBroker; use okena_workspace::state::{FolderData, ProjectData, WindowId, Workspace}; -use gpui::*; -use gpui::prelude::FluentBuilder; -use gpui_component::h_flex; -use gpui_component::tooltip::Tooltip; use std::collections::{HashMap, HashSet}; /// Callback for dispatching actions for a given project. @@ -59,6 +59,12 @@ pub type GetRemoteConnectionsFn = Box<dyn Fn(&App) -> Vec<RemoteConnectionSnapsh /// Arguments: (conn_id, action, cx) pub type SendRemoteActionFn = Box<dyn Fn(&str, ActionRequest, &mut App)>; +/// 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<dyn Fn(&str, ActionRequest, &mut App)>; + /// Callback to get the server folder ID for a remote folder reorder operation. /// Arguments: (conn_id, prefixed_project_id, cx) -> Option<folder_id> pub type GetRemoteFolderFn = Box<dyn Fn(&str, &str, &App) -> Option<String>>; @@ -84,18 +90,41 @@ impl GroupKind { /// Identifies each visible row in the sidebar for keyboard cursor navigation. #[derive(Clone, Debug)] pub enum SidebarCursorItem { - Folder { folder_id: String }, - Project { project_id: String }, - WorktreeProject { project_id: String }, - GroupHeader { project_id: String, group: GroupKind }, - Terminal { project_id: String, terminal_id: String }, - Service { project_id: String, service_name: String }, + Folder { + folder_id: String, + }, + Project { + project_id: String, + }, + WorktreeProject { + project_id: String, + }, + GroupHeader { + project_id: String, + group: GroupKind, + }, + Terminal { + project_id: String, + terminal_id: String, + }, + Service { + project_id: String, + service_name: String, + }, #[allow(dead_code)] - Hook { project_id: String, terminal_id: String }, + Hook { + project_id: String, + terminal_id: String, + }, #[allow(dead_code)] - RemoteConnection { connection_id: String }, + RemoteConnection { + connection_id: String, + }, #[allow(dead_code)] - RemoteProject { connection_id: String, project_id: String }, + RemoteProject { + connection_id: String, + project_id: String, + }, } /// Sidebar view with project and terminal list @@ -152,12 +181,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<String>, - /// Callback to get settings - pub(crate) get_settings: Option<GetSettingsFn>, /// Callback to get remote connections pub(crate) get_remote_connections: Option<GetRemoteConnectionsFn>, /// Callback to send remote actions pub(crate) send_remote_action: Option<SendRemoteActionFn>, + /// 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<DispatchForConnectionFn>, /// Callback to get remote folder ID for reordering pub(crate) get_remote_folder: Option<GetRemoteFolderFn>, /// Last computed activity-view ordering `(tier, project_id)`. Reused while @@ -184,7 +214,14 @@ pub struct Sidebar { } impl Sidebar { - pub fn new(window_id: WindowId, workspace: Entity<Workspace>, focus_manager: Entity<okena_workspace::focus::FocusManager>, request_broker: Entity<RequestBroker>, terminals: TerminalsRegistry, cx: &mut Context<Self>) -> Self { + pub fn new( + window_id: WindowId, + workspace: Entity<Workspace>, + focus_manager: Entity<okena_workspace::focus::FocusManager>, + request_broker: Entity<RequestBroker>, + terminals: TerminalsRegistry, + cx: &mut Context<Self>, + ) -> Self { // Observe RequestBroker to drain sidebar requests outside of render(). // Requests are stored in pending_sidebar_requests and applied in render() // where Window access is available (needed for focus/rename). @@ -192,12 +229,26 @@ impl Sidebar { if !this.request_broker.read(cx).has_sidebar_requests() { return; } - let requests = this.request_broker.update(cx, |broker, _cx| { - broker.drain_sidebar_requests() - }); + let requests = this + .request_broker + .update(cx, |broker, _cx| broker.drain_sidebar_requests()); this.pending_sidebar_requests.extend(requests); cx.notify(); - }).detach(); + }) + .detach(); + + // The Sidebar renders inside a `.cached()` view (window render), so it + // only repaints when a notify comes from an entity it observes. It + // derives per-project state straight from the Workspace — project data, + // git status, and transient lifecycle flags (`is_closing`/`is_creating`) + // read via `is_project_closing`/`is_creating_project`. Without observing + // the Workspace, a workspace-only mutation (e.g. `mark_closing_project`, + // which notifies the Workspace but pushes no remote snapshot) never + // invalidates the cache, so the row stays stale until an unrelated + // snapshot happens to repaint. Matches status_bar/project_column, which + // observe the workspace for the same reason. + cx.observe(&workspace, |_this, _ws, cx| cx.notify()) + .detach(); // Hook terminals are displayed in the dedicated HookPanel, so we no // longer auto-expand the sidebar project when hooks appear. @@ -226,9 +277,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 +309,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,21 +319,58 @@ 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); } /// Dispatch an action for a project using the dispatch callback. - pub(crate) fn dispatch_action_for_project(&self, project_id: &str, action: ActionRequest, cx: &mut App) { + pub(crate) fn dispatch_action_for_project( + &self, + project_id: &str, + action: ActionRequest, + cx: &mut App, + ) { if let Some(ref dispatch) = self.dispatch_action { (dispatch)(project_id, action, cx); } } - /// 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:<conn>:<id>`; 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 @@ -331,15 +414,28 @@ impl Sidebar { } pub(crate) fn is_group_collapsed(&self, project_id: &str, group: &GroupKind) -> bool { - self.collapsed_groups.contains(&(project_id.to_string(), group.clone())) + self.collapsed_groups + .contains(&(project_id.to_string(), group.clone())) } - pub(crate) fn request_context_menu(&mut self, project_id: String, position: Point<Pixels>, cx: &mut Context<Self>) { + pub(crate) fn request_context_menu( + &mut self, + project_id: String, + position: Point<Pixels>, + cx: &mut Context<Self>, + ) { self.request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(okena_workspace::requests::OverlayRequest::Project(okena_workspace::requests::ProjectOverlay { - project_id, - kind: okena_workspace::requests::ProjectOverlayKind::ContextMenu { position }, - }), cx); + broker.push_overlay_request( + okena_workspace::requests::OverlayRequest::Project( + okena_workspace::requests::ProjectOverlay { + project_id, + kind: okena_workspace::requests::ProjectOverlayKind::ContextMenu { + position, + }, + }, + ), + cx, + ); }); } @@ -361,7 +457,8 @@ impl Sidebar { pub fn set_service_manager(&mut self, manager: Entity<ServiceManager>, cx: &mut Context<Self>) { cx.observe(&manager, |_this, _sm, cx| { cx.notify(); - }).detach(); + }) + .detach(); self.service_manager = Some(manager); cx.notify(); } @@ -369,8 +466,13 @@ impl Sidebar { /// Count how many terminals from the given IDs are currently waiting for input pub fn count_waiting_terminals(&self, terminal_ids: &[String]) -> usize { let terminals = self.terminals.lock(); - terminal_ids.iter() - .filter(|id| terminals.get(id.as_str()).is_some_and(|t| t.is_waiting_for_input())) + terminal_ids + .iter() + .filter(|id| { + terminals + .get(id.as_str()) + .is_some_and(|t| t.is_waiting_for_input()) + }) .count() } @@ -408,7 +510,7 @@ impl Sidebar { svg() .path("icons/folder.svg") .size(px(14.0)) - .text_color(rgb(t.text_secondary)) + .text_color(rgb(t.text_secondary)), ) .on_click(cx.listener(|this, _, window, cx| { this.create_folder(window, cx); @@ -497,7 +599,11 @@ impl Sidebar { // (Custom) view — the Activity view already has a NEEDS // ATTENTION tier — so it's hidden in activity mode. .when(!activity_mode, |row| { - let attn_color = if show_attention { t.border_active } else { t.text_secondary }; + let attn_color = if show_attention { + t.border_active + } else { + t.text_secondary + }; row.child( div() .id("attention-toggle") @@ -535,40 +641,46 @@ impl Sidebar { } /// Segmented `Custom | Activity` control for the projects header. - fn render_sort_mode_toggle(&self, activity_mode: bool, cx: &mut Context<Self>) -> impl IntoElement { + fn render_sort_mode_toggle( + &self, + activity_mode: bool, + cx: &mut Context<Self>, + ) -> impl IntoElement { let t = theme(cx); let window_id = self.window_id; - let segment = |label: &'static str, id: &'static str, active: bool, cx: &mut Context<Self>| { - let mut seg = div() - .id(id) - .px(px(8.0)) - .py(px(1.0)) - .rounded(px(4.0)) - .text_size(ui_text_xs(cx)) - .cursor_pointer(); - if active { - seg = seg - .bg(rgb(t.bg_primary)) - .text_color(rgb(t.text_primary)) - .shadow_sm(); - } else { - seg = seg - .text_color(rgb(t.text_muted)) - .hover(|s| s.text_color(rgb(t.text_secondary))); - } - seg.child(label).on_click(cx.listener(move |this, _, _window, cx| { - cx.stop_propagation(); - // Only flip when switching to the other mode; clicking the - // already-active segment is a no-op. + let segment = + |label: &'static str, id: &'static str, active: bool, cx: &mut Context<Self>| { + let mut seg = div() + .id(id) + .px(px(8.0)) + .py(px(1.0)) + .rounded(px(4.0)) + .text_size(ui_text_xs(cx)) + .cursor_pointer(); if active { - return; + seg = seg + .bg(rgb(t.bg_primary)) + .text_color(rgb(t.text_primary)) + .shadow_sm(); + } else { + seg = seg + .text_color(rgb(t.text_muted)) + .hover(|s| s.text_color(rgb(t.text_secondary))); } - this.workspace.update(cx, |ws, cx| { - ws.toggle_project_sort_mode(window_id, cx); - }); - })) - }; + seg.child(label) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + // Only flip when switching to the other mode; clicking the + // already-active segment is a no-op. + if active { + return; + } + this.workspace.update(cx, |ws, cx| { + ws.toggle_project_sort_mode(window_id, cx); + }); + })) + }; h_flex() .gap(px(2.0)) @@ -646,12 +758,21 @@ impl SidebarProjectInfo { /// viewport model — each window has its own `hidden_project_ids`). The /// `window_id` is the id of the window that owns the sidebar instance /// rendering this projection. - pub(crate) fn from_project(project: &ProjectData, workspace: &Workspace, window_id: WindowId) -> Self { + 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() @@ -679,19 +800,29 @@ impl SidebarProjectInfo { terminal_names: project.terminal_names.clone(), is_orphan: false, worktree_count: 0, - parent_project_id: project.worktree_info.as_ref().map(|w| w.parent_project_id.clone()), + parent_project_id: project + .worktree_info + .as_ref() + .map(|w| w.parent_project_id.clone()), services: Vec::new(), - hook_terminals: project.hook_terminals.iter().map(|(tid, entry)| { - SidebarHookInfo { + hook_terminals: project + .hook_terminals + .iter() + .map(|(tid, entry)| SidebarHookInfo { terminal_id: tid.clone(), label: entry.label.clone(), status: entry.status.clone(), command: entry.command.clone(), cwd: entry.cwd.clone(), - } - }).collect(), + }) + .collect(), is_closing: false, - is_creating: false, + // Explicit mid-create marker, set by the daemon while the git + // checkout is in flight and mirrored over the wire. NOT derived from + // `layout.is_none()` — a fully-created worktree whose last terminal + // the user closed is a legitimate bookmark with layout None, and must + // not render the "Setting up worktree…" placeholder. + is_creating: project.is_creating, is_worktree: project.worktree_info.is_some(), pinned: project.pinned, } diff --git a/crates/okena-views-sidebar/src/sidebar/renames.rs b/crates/okena-views-sidebar/src/sidebar/renames.rs index 8f58bfa6f..bd4504a51 100644 --- a/crates/okena-views-sidebar/src/sidebar/renames.rs +++ b/crates/okena-views-sidebar/src/sidebar/renames.rs @@ -8,7 +8,14 @@ use okena_core::theme::FolderColor; use okena_ui::rename_state::{cancel_rename, finish_rename, start_rename_with_blur}; impl Sidebar { - pub fn start_rename(&mut self, project_id: String, terminal_id: String, current_name: String, window: &mut Window, cx: &mut Context<Self>) { + pub fn start_rename( + &mut self, + project_id: String, + terminal_id: String, + current_name: String, + window: &mut Window, + cx: &mut Context<Self>, + ) { self.terminal_rename = Some(start_rename_with_blur( (project_id, terminal_id), ¤t_name, @@ -26,12 +33,18 @@ impl Sidebar { } pub fn finish_rename(&mut self, cx: &mut Context<Self>) { - if let Some(((project_id, terminal_id), new_name)) = finish_rename(&mut self.terminal_rename, cx) { - self.dispatch_action_for_project(&project_id, ActionRequest::RenameTerminal { - project_id: project_id.clone(), - terminal_id, - name: new_name, - }, cx); + if let Some(((project_id, terminal_id), new_name)) = + finish_rename(&mut self.terminal_rename, cx) + { + self.dispatch_action_for_project( + &project_id, + ActionRequest::RenameTerminal { + project_id: project_id.clone(), + terminal_id, + name: new_name, + }, + cx, + ); } let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { @@ -51,7 +64,13 @@ impl Sidebar { cx.notify(); } - pub fn start_project_rename(&mut self, project_id: String, current_name: String, window: &mut Window, cx: &mut Context<Self>) { + pub fn start_project_rename( + &mut self, + project_id: String, + current_name: String, + window: &mut Window, + cx: &mut Context<Self>, + ) { self.project_rename = Some(start_rename_with_blur( project_id, ¤t_name, @@ -70,9 +89,16 @@ impl Sidebar { pub fn finish_project_rename(&mut self, cx: &mut Context<Self>) { 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| { @@ -93,40 +119,83 @@ impl Sidebar { } /// Request to show color picker for a project (routed via OverlayManager). - pub fn show_color_picker(&mut self, project_id: String, position: gpui::Point<gpui::Pixels>, cx: &mut Context<Self>) { + pub fn show_color_picker( + &mut self, + project_id: String, + position: gpui::Point<gpui::Pixels>, + cx: &mut Context<Self>, + ) { self.request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(okena_workspace::requests::OverlayRequest::Project(okena_workspace::requests::ProjectOverlay { - project_id, - kind: okena_workspace::requests::ProjectOverlayKind::ColorPicker { position }, - }), cx); + broker.push_overlay_request( + okena_workspace::requests::OverlayRequest::Project( + okena_workspace::requests::ProjectOverlay { + project_id, + kind: okena_workspace::requests::ProjectOverlayKind::ColorPicker { + position, + }, + }, + ), + cx, + ); }); } /// Request to show color picker for a folder (routed via OverlayManager). - pub fn show_folder_color_picker(&mut self, folder_id: String, position: gpui::Point<gpui::Pixels>, cx: &mut Context<Self>) { + pub fn show_folder_color_picker( + &mut self, + folder_id: String, + position: gpui::Point<gpui::Pixels>, + cx: &mut Context<Self>, + ) { self.request_broker.update(cx, |broker, cx| { - broker.push_overlay_request(okena_workspace::requests::OverlayRequest::Folder(okena_workspace::requests::FolderOverlay { - folder_id, - kind: okena_workspace::requests::FolderOverlayKind::ColorPicker { position }, - }), cx); + broker.push_overlay_request( + okena_workspace::requests::OverlayRequest::Folder( + okena_workspace::requests::FolderOverlay { + folder_id, + kind: okena_workspace::requests::FolderOverlayKind::ColorPicker { + position, + }, + }, + ), + cx, + ); }); } /// Sync a project color change to remote server (called when color picker emits event). - pub fn sync_remote_color(&mut self, project_id: &str, color: FolderColor, cx: &mut Context<Self>) { - if let Some(conn_id) = self.workspace.read(cx).project(project_id) + pub fn sync_remote_color( + &mut self, + project_id: &str, + color: FolderColor, + cx: &mut Context<Self>, + ) { + if let Some(conn_id) = self + .workspace + .read(cx) + .project(project_id) .filter(|p| p.is_remote) .and_then(|p| p.connection_id.clone()) - && let Some(ref send_action) = self.send_remote_action { - let server_id = okena_transport::client::strip_prefix(project_id, &conn_id); - (send_action)(&conn_id, ActionRequest::SetProjectColor { + && let Some(ref send_action) = self.send_remote_action + { + let server_id = okena_transport::client::strip_prefix(project_id, &conn_id); + (send_action)( + &conn_id, + ActionRequest::SetProjectColor { project_id: server_id, color, - }, cx); - } + }, + cx, + ); + } } - pub fn start_folder_rename(&mut self, folder_id: String, current_name: String, window: &mut Window, cx: &mut Context<Self>) { + pub fn start_folder_rename( + &mut self, + folder_id: String, + current_name: String, + window: &mut Window, + cx: &mut Context<Self>, + ) { self.folder_rename = Some(start_rename_with_blur( folder_id, ¤t_name, @@ -145,9 +214,15 @@ impl Sidebar { pub fn finish_folder_rename(&mut self, cx: &mut Context<Self>) { 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 +242,16 @@ impl Sidebar { cx.notify(); } - pub(super) fn create_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) { - 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<Self>) { + // 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..50fc27306 100644 --- a/crates/okena-views-sidebar/src/sidebar/render.rs +++ b/crates/okena-views-sidebar/src/sidebar/render.rs @@ -1,8 +1,10 @@ //! Top-level `Render` impl for the sidebar and the `render_expanded_children` //! helper that lays out per-project terminal / service / hook groups. -use super::{GroupKind, Sidebar, SidebarCursorItem, SidebarItem, SidebarProjectInfo, SidebarServiceInfo}; -use crate::activity_order::{order_by_activity, ActivityEntry, ActivityTier}; +use super::{ + GroupKind, Sidebar, SidebarCursorItem, SidebarItem, SidebarProjectInfo, SidebarServiceInfo, +}; +use crate::activity_order::{ActivityEntry, ActivityTier, order_by_activity}; use crate::drag::{FolderDrag, ProjectDrag}; use gpui::*; use okena_ui::color_dot::color_dot; @@ -25,10 +27,14 @@ impl Sidebar { let mut project_services: HashMap<String, Vec<SidebarServiceInfo>> = if let Some(ref sm) = self.service_manager { let sm = sm.read(cx); - workspace.data().projects.iter() + workspace + .data() + .projects + .iter() .filter(|p| sm.has_services(&p.id)) .map(|p| { - let services = sm.services_for_project(&p.id) + let services = sm + .services_for_project(&p.id) .into_iter() .filter(|inst| !inst.is_extra) .map(|inst| SidebarServiceInfo { @@ -36,7 +42,10 @@ impl Sidebar { status: inst.status.clone(), ports: inst.detected_ports.clone(), port_host: "localhost".to_string(), - is_docker: matches!(inst.kind, okena_services::manager::ServiceKind::DockerCompose { .. }), + is_docker: matches!( + inst.kind, + okena_services::manager::ServiceKind::DockerCompose { .. } + ), }) .collect(); (p.id.clone(), services) @@ -48,20 +57,29 @@ impl Sidebar { // Also populate services from remote project data (for projects not covered by local ServiceManager) for project in &workspace.data().projects { - let Some(snapshot) = workspace.remote_snapshot(&project.id) else { continue }; + let Some(snapshot) = workspace.remote_snapshot(&project.id) else { + continue; + }; if !snapshot.services.is_empty() && !project_services.contains_key(&project.id) { - let port_host = snapshot.host.clone().unwrap_or_else(|| "localhost".to_string()); - let services = snapshot.services.iter() + let port_host = snapshot + .host + .clone() + .unwrap_or_else(|| "localhost".to_string()); + let services = snapshot + .services + .iter() .filter(|api_svc| !api_svc.is_extra) - .map(|api_svc| { - SidebarServiceInfo { - name: api_svc.name.clone(), - status: okena_services::manager::ServiceStatus::from_api(&api_svc.status, api_svc.exit_code), - ports: api_svc.ports.clone(), - port_host: port_host.clone(), - is_docker: api_svc.kind == "docker_compose", - } - }).collect(); + .map(|api_svc| SidebarServiceInfo { + name: api_svc.name.clone(), + status: okena_services::manager::ServiceStatus::from_api( + &api_svc.status, + api_svc.exit_code, + ), + ports: api_svc.ports.clone(), + port_host: port_host.clone(), + is_docker: api_svc.kind == "docker_compose", + }) + .collect(); project_services.insert(project.id.clone(), services); } } @@ -116,7 +134,11 @@ impl Sidebar { /// A section header for one activity tier (PINNED / NEEDS ATTENTION / /// RUNNING / RECENT). Non-interactive — purely a visual divider. - fn render_activity_tier_header(&self, tier: ActivityTier, cx: &mut Context<Self>) -> impl IntoElement { + fn render_activity_tier_header( + &self, + tier: ActivityTier, + cx: &mut Context<Self>, + ) -> impl IntoElement { let t = theme(cx); // A small colored dot echoes the per-row indicators for the two // attention-worthy tiers; pinned/recent get none. @@ -197,10 +219,14 @@ impl Sidebar { let mut idx_map = HashMap::new(); for (idx, project) in workspace.data().projects.iter().enumerate() { let mut info = SidebarProjectInfo::from_project(project, workspace, self.window_id); - info.is_closing = workspace.is_project_closing(&project.id); - info.is_creating = workspace.is_creating_project(&project.id); + // Mirrored daemon flag OR the client-local optimistic flag: the + // mirror is authoritative (and heals on abort), the local flag + // gives instant feedback before the daemon acknowledges. + info.is_closing = project.is_closing || workspace.is_project_closing(&project.id); let has_attention = info.terminal_ids.iter().any(|tid| { - terminals.get(tid.as_str()).is_some_and(|t| t.has_bell() || t.has_notification()) + terminals + .get(tid.as_str()) + .is_some_and(|t| t.has_bell() || t.has_notification()) }); // "Running" uses cached atomics only (no live /proc read per // render): a terminal the user has driven that is not sitting at @@ -208,9 +234,9 @@ impl Sidebar { // `waiting_for_input` flag is the same background-computed signal // the manual view's idle dot uses. let is_running = info.terminal_ids.iter().any(|tid| { - terminals.get(tid.as_str()).is_some_and(|t| { - t.had_user_input() && !t.is_waiting_for_input() - }) + terminals + .get(tid.as_str()) + .is_some_and(|t| t.had_user_input() && !t.is_waiting_for_input()) }); e.push(ActivityEntry { id: project.id.clone(), @@ -274,7 +300,9 @@ impl Sidebar { } let Some(info) = infos.get(id) else { continue }; let first = cursor_items.len(); - cursor_items.push(SidebarCursorItem::Project { project_id: id.clone() }); + cursor_items.push(SidebarCursorItem::Project { + project_id: id.clone(), + }); if self.expanded_projects.contains(id) { self.push_activity_group_cursor_items(info, &mut cursor_items); } @@ -297,7 +325,10 @@ impl Sidebar { let mut last_tier: Option<ActivityTier> = None; for (tier, id) in ordering { if last_tier != Some(tier) { - flat_elements.push(self.render_activity_tier_header(tier, cx).into_any_element()); + flat_elements.push( + self.render_activity_tier_header(tier, cx) + .into_any_element(), + ); last_tier = Some(tier); } let Some(info) = infos.get(&id) else { continue }; @@ -310,7 +341,16 @@ impl Sidebar { ); flat_idx += 1; if self.expanded_projects.contains(&id) { - self.render_expanded_children(info, 20.0, 34.0, "", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + info, + 20.0, + 34.0, + "", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } } flat_elements @@ -332,28 +372,46 @@ impl Sidebar { ) { // Terminals group if !info.terminal_ids.is_empty() { - cursor_items.push(SidebarCursorItem::GroupHeader { project_id: info.id.clone(), group: GroupKind::Terminals }); + cursor_items.push(SidebarCursorItem::GroupHeader { + project_id: info.id.clone(), + group: GroupKind::Terminals, + }); if !self.is_group_collapsed(&info.id, &GroupKind::Terminals) { for tid in &info.terminal_ids { - cursor_items.push(SidebarCursorItem::Terminal { project_id: info.id.clone(), terminal_id: tid.clone() }); + cursor_items.push(SidebarCursorItem::Terminal { + project_id: info.id.clone(), + terminal_id: tid.clone(), + }); } } } // Services group if !info.services.is_empty() { - cursor_items.push(SidebarCursorItem::GroupHeader { project_id: info.id.clone(), group: GroupKind::Services }); + cursor_items.push(SidebarCursorItem::GroupHeader { + project_id: info.id.clone(), + group: GroupKind::Services, + }); if !self.is_group_collapsed(&info.id, &GroupKind::Services) { for service in &info.services { - cursor_items.push(SidebarCursorItem::Service { project_id: info.id.clone(), service_name: service.name.clone() }); + cursor_items.push(SidebarCursorItem::Service { + project_id: info.id.clone(), + service_name: service.name.clone(), + }); } } } // Hooks group if !info.hook_terminals.is_empty() { - cursor_items.push(SidebarCursorItem::GroupHeader { project_id: info.id.clone(), group: GroupKind::Hooks }); + cursor_items.push(SidebarCursorItem::GroupHeader { + project_id: info.id.clone(), + group: GroupKind::Hooks, + }); if !self.is_group_collapsed(&info.id, &GroupKind::Hooks) { for hook in &info.hook_terminals { - cursor_items.push(SidebarCursorItem::Hook { project_id: info.id.clone(), terminal_id: hook.terminal_id.clone() }); + cursor_items.push(SidebarCursorItem::Hook { + project_id: info.id.clone(), + terminal_id: hook.terminal_id.clone(), + }); } } } @@ -369,7 +427,11 @@ impl Sidebar { /// focused terminal of the active window is cleared on the very next /// terminal-pane render, so without this it would flicker through the menu /// for a single frame. - fn collect_attention_infos(&self, workspace: &Workspace, focused_terminal_id: Option<&str>) -> Vec<SidebarProjectInfo> { + fn collect_attention_infos( + &self, + workspace: &Workspace, + focused_terminal_id: Option<&str>, + ) -> Vec<SidebarProjectInfo> { let terminals = self.terminals.lock(); // Determine attention cheaply from raw layout + terminal state first; // only build the (potentially git-touching) `SidebarProjectInfo` for the @@ -398,7 +460,9 @@ impl Sidebar { // Most-recent activity first; `None` (never active) sorts last. hits.sort_by_key(|h| std::cmp::Reverse(h.0)); hits.into_iter() - .map(|(_, project)| SidebarProjectInfo::from_project(project, workspace, self.window_id)) + .map(|(_, project)| { + SidebarProjectInfo::from_project(project, workspace, self.window_id) + }) .collect() } @@ -407,7 +471,12 @@ impl Sidebar { /// id. `None` when the window is in the background or nothing is focused. /// Used to keep that terminal out of the needs-attention section (see /// `collect_attention_infos`). - fn active_focused_terminal_id(&self, workspace: &Workspace, window: &Window, cx: &App) -> Option<String> { + fn active_focused_terminal_id( + &self, + workspace: &Workspace, + window: &Window, + cx: &App, + ) -> Option<String> { if !window.is_window_active() { return None; } @@ -430,12 +499,18 @@ impl Sidebar { /// attention section. Clicking focuses the canonical project (the same /// action as its real row below); it is not a second entity and is not part /// of keyboard cursor navigation. - fn render_attention_mirror_row(&self, info: &SidebarProjectInfo, cx: &mut Context<Self>) -> impl IntoElement { + fn render_attention_mirror_row( + &self, + info: &SidebarProjectInfo, + cx: &mut Context<Self>, + ) -> impl IntoElement { let t = theme(cx); let project_id = info.id.clone(); let dot_color = t.get_folder_color(info.folder_color); div() - .id(ElementId::Name(format!("attention-mirror-{}", info.id).into())) + .id(ElementId::Name( + format!("attention-mirror-{}", info.id).into(), + )) .h(px(24.0)) .pl(px(4.0)) .pr(px(8.0)) @@ -530,16 +605,18 @@ impl Sidebar { this.toggle_group(&project_id, GroupKind::Terminals); cx.notify(); })) - .into_any_element() + .into_any_element(), ); *flat_idx += 1; if !is_collapsed { let minimized_states: Vec<(String, bool)> = { let ws = self.workspace.read(cx); - project.terminal_ids.iter().map(|id| { - (id.clone(), ws.is_terminal_minimized(&project.id, id)) - }).collect() + project + .terminal_ids + .iter() + .map(|id| (id.clone(), ws.is_terminal_minimized(&project.id, id))) + .collect() }; for (tid, is_minimized) in &minimized_states { let is_cursor = cursor_index == Some(*flat_idx); @@ -547,11 +624,18 @@ impl Sidebar { let is_in_tab_group = project.tab_group_terminals.contains(tid.as_str()); flat_elements.push( self.render_terminal_item( - &project.id, tid, &project.terminal_names, - *is_minimized, is_inactive_tab, is_in_tab_group, - group_items_padding, id_prefix, is_cursor, cx, + &project.id, + tid, + &project.terminal_names, + *is_minimized, + is_inactive_tab, + is_in_tab_group, + group_items_padding, + id_prefix, + is_cursor, + cx, ) - .into_any_element() + .into_any_element(), ); *flat_idx += 1; } @@ -563,8 +647,14 @@ impl Sidebar { let is_collapsed = self.is_group_collapsed(&project.id, &GroupKind::Services); let is_cursor = cursor_index == Some(*flat_idx); flat_elements.push( - self.render_services_group_header(project, is_collapsed, is_cursor, group_header_padding, cx) - .into_any_element() + self.render_services_group_header( + project, + is_collapsed, + is_cursor, + group_header_padding, + cx, + ) + .into_any_element(), ); *flat_idx += 1; @@ -572,8 +662,14 @@ impl Sidebar { for service in &project.services { let is_cursor = cursor_index == Some(*flat_idx); flat_elements.push( - self.render_service_item(project, service, group_items_padding, is_cursor, cx) - .into_any_element() + self.render_service_item( + project, + service, + group_items_padding, + is_cursor, + cx, + ) + .into_any_element(), ); *flat_idx += 1; } @@ -585,8 +681,14 @@ impl Sidebar { let is_collapsed = self.is_group_collapsed(&project.id, &GroupKind::Hooks); let is_cursor = cursor_index == Some(*flat_idx); flat_elements.push( - self.render_hooks_group_header(project, is_collapsed, is_cursor, group_header_padding, cx) - .into_any_element() + self.render_hooks_group_header( + project, + is_collapsed, + is_cursor, + group_header_padding, + cx, + ) + .into_any_element(), ); *flat_idx += 1; @@ -595,7 +697,7 @@ impl Sidebar { let is_cursor = cursor_index == Some(*flat_idx); flat_elements.push( self.render_hook_item(project, hook, group_items_padding, is_cursor, cx) - .into_any_element() + .into_any_element(), ); *flat_idx += 1; } @@ -612,10 +714,16 @@ impl Render for Sidebar { let pending = std::mem::take(&mut self.pending_sidebar_requests); for request in pending { match request { - SidebarRequest::RenameProject { project_id, project_name } => { + SidebarRequest::RenameProject { + project_id, + project_name, + } => { self.start_project_rename(project_id, project_name, window, cx); } - SidebarRequest::RenameFolder { folder_id, folder_name } => { + SidebarRequest::RenameFolder { + folder_id, + folder_name, + } => { self.start_folder_rename(folder_id, folder_name, window, cx); } SidebarRequest::QuickCreateWorktree { project_id } => { @@ -624,7 +732,6 @@ impl Render for Sidebar { } } - // Clear cursor when sidebar loses focus if self.cursor_index.is_some() && !self.focus_handle.is_focused(window) { self.cursor_index = None; @@ -667,22 +774,30 @@ impl Render for Sidebar { }; // Collect all projects for lookup - let all_projects: HashMap<&str, &ProjectData> = workspace.data().projects.iter() + let all_projects: HashMap<&str, &ProjectData> = workspace + .data() + .projects + .iter() .map(|p| (p.id.as_str(), p)) .collect(); // Build worktree children map using parent's worktree_ids for deterministic ordering // Build worktree children map using parent's worktree_ids for deterministic ordering let mut worktree_children_map: HashMap<String, Vec<SidebarProjectInfo>> = HashMap::new(); - let all_project_ids: HashSet<&str> = workspace.data().projects.iter().map(|p| p.id.as_str()).collect(); + let all_project_ids: HashSet<&str> = workspace + .data() + .projects + .iter() + .map(|p| p.id.as_str()) + .collect(); for parent in &workspace.data().projects { if !parent.worktree_ids.is_empty() { let mut children = Vec::new(); for wt_id in &parent.worktree_ids { if let Some(&p) = all_projects.get(wt_id.as_str()) { - let mut info = SidebarProjectInfo::from_project(p, workspace, self.window_id); - info.is_closing = workspace.is_project_closing(&p.id); - info.is_creating = workspace.is_creating_project(&p.id); + let mut info = + SidebarProjectInfo::from_project(p, workspace, self.window_id); + info.is_closing = p.is_closing || workspace.is_project_closing(&p.id); // Inherit parent project's color for visual association info.folder_color = parent.folder_color; children.push(info); @@ -702,22 +817,31 @@ impl Render for Sidebar { for (top_index, id) in workspace.data().project_order.iter().enumerate() { // Check if this is a folder if let Some(folder) = workspace.data().folders.iter().find(|f| &f.id == id) { - let mut folder_projects: Vec<SidebarProjectInfo> = folder.project_ids.iter() + let mut folder_projects: Vec<SidebarProjectInfo> = folder + .project_ids + .iter() .filter_map(|pid| all_projects.get(pid.as_str())) - .filter(|p| p.worktree_info.is_none() || !all_project_ids.contains( - p.worktree_info.as_ref().map(|w| w.parent_project_id.as_str()).unwrap_or("") - )) + .filter(|p| { + p.worktree_info.is_none() + || !all_project_ids.contains( + p.worktree_info + .as_ref() + .map(|w| w.parent_project_id.as_str()) + .unwrap_or(""), + ) + }) .map(|p| { - let mut info = SidebarProjectInfo::from_project(p, workspace, self.window_id); + let mut info = + SidebarProjectInfo::from_project(p, workspace, self.window_id); info.is_orphan = p.worktree_info.as_ref().is_some_and(|wt| { !all_project_ids.contains(wt.parent_project_id.as_str()) }); - info.is_closing = workspace.is_project_closing(&p.id); - info.is_creating = workspace.is_creating_project(&p.id); + info.is_closing = p.is_closing || workspace.is_project_closing(&p.id); info }) .collect(); - let mut folder_wt_children: HashMap<String, Vec<SidebarProjectInfo>> = HashMap::new(); + let mut folder_wt_children: HashMap<String, Vec<SidebarProjectInfo>> = + HashMap::new(); for fp in &mut folder_projects { if let Some(mut children) = worktree_children_map.remove(&fp.id) { fp.worktree_count = children.len(); @@ -744,17 +868,22 @@ impl Render for Sidebar { // Check if this is a top-level project (not a worktree child) if let Some(&project) = all_projects.get(id.as_str()) { if let Some(ref wt_info) = project.worktree_info - && all_project_ids.contains(wt_info.parent_project_id.as_str()) { - // This is a worktree child shown under its parent, skip - continue; - } - let mut wt_children = worktree_children_map.remove(&project.id).unwrap_or_default(); - let mut project_info = SidebarProjectInfo::from_project(project, workspace, self.window_id); - project_info.is_orphan = project.worktree_info.as_ref().is_some_and(|wt| { - !all_project_ids.contains(wt.parent_project_id.as_str()) - }); - project_info.is_closing = workspace.is_project_closing(&project.id); - project_info.is_creating = workspace.is_creating_project(&project.id); + && all_project_ids.contains(wt_info.parent_project_id.as_str()) + { + // This is a worktree child shown under its parent, skip + continue; + } + let mut wt_children = worktree_children_map + .remove(&project.id) + .unwrap_or_default(); + let mut project_info = + SidebarProjectInfo::from_project(project, workspace, self.window_id); + project_info.is_orphan = project + .worktree_info + .as_ref() + .is_some_and(|wt| !all_project_ids.contains(wt.parent_project_id.as_str())); + project_info.is_closing = + project.is_closing || workspace.is_project_closing(&project.id); project_info.worktree_count = wt_children.len(); if !wt_children.is_empty() { @@ -797,9 +926,15 @@ impl Render for Sidebar { // with cursor items, offset by the leading drop zone (1) plus the // attention section (header + one row per attention project + a trailing // divider, when shown). - let attention_section_len = if attention_infos.is_empty() { 0 } else { 1 + attention_infos.len() + 1 }; + let attention_section_len = if attention_infos.is_empty() { + 0 + } else { + 1 + attention_infos.len() + 1 + }; let cursor_scroll_base = 1 + attention_section_len; - self.cursor_scroll_indices = (0..cursor_items.len()).map(|i| cursor_scroll_base + i).collect(); + self.cursor_scroll_indices = (0..cursor_items.len()) + .map(|i| cursor_scroll_base + i) + .collect(); // Determine which project is focused — only highlight when explicitly focused via sidebar click let (focused_project_id, focus_individual) = { @@ -818,22 +953,38 @@ impl Render for Sidebar { .h(px(4.0)) .w_full() .drag_over::<ProjectDrag>(move |style, _, _, _| { - style.h(px(8.0)).border_b_2().border_color(rgb(t.border_active)) + 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::<FolderDrag>(move |style, _, _, _| { - style.h(px(8.0)).border_b_2().border_color(rgb(t.border_active)) + 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() + .into_any_element(), ); // Opt-in "needs attention" section: mirror the attention projects at @@ -843,9 +994,15 @@ impl Render for Sidebar { // but they occupy scroll children — already accounted for in // `cursor_scroll_indices` above via `attention_section_len`. if !attention_infos.is_empty() { - flat_elements.push(self.render_activity_tier_header(ActivityTier::Attention, cx).into_any_element()); + flat_elements.push( + self.render_activity_tier_header(ActivityTier::Attention, cx) + .into_any_element(), + ); for info in &attention_infos { - flat_elements.push(self.render_attention_mirror_row(info, cx).into_any_element()); + flat_elements.push( + self.render_attention_mirror_row(info, cx) + .into_any_element(), + ); } // A thin rule sets the mirrored section apart from the structured // folder/project layout that follows. @@ -861,17 +1018,35 @@ impl Render for Sidebar { for item in items { match item { - SidebarItem::Project { project, index, worktree_children } => { + SidebarItem::Project { + project, + index, + worktree_children, + } => { let has_worktrees = !worktree_children.is_empty(); if has_worktrees && !project.is_orphan { // Group header mode: project becomes a group, main project is first child let is_cursor = cursor_index == Some(flat_idx); // Group header highlights when focused non-individual (showing all) - let is_focused_group = focused_project_id.as_ref() == Some(&project.id) && !focus_individual; - let all_hidden = !project.show_in_overview && worktree_children.iter().all(|c| !c.show_in_overview); + let is_focused_group = + focused_project_id.as_ref() == Some(&project.id) && !focus_individual; + let all_hidden = !project.show_in_overview + && worktree_children.iter().all(|c| !c.show_in_overview); flat_elements.push( - self.render_project_group_header(&project, 4.0, "gh", "group-header-item", crate::project_list::GroupHeaderDragConfig::TopLevel { index }, all_hidden, is_cursor, is_focused_group, window, cx).into_any_element() + self.render_project_group_header( + &project, + 4.0, + "gh", + "group-header-item", + crate::project_list::GroupHeaderDragConfig::TopLevel { index }, + all_hidden, + is_cursor, + is_focused_group, + window, + cx, + ) + .into_any_element(), ); flat_idx += 1; @@ -879,27 +1054,67 @@ impl Render for Sidebar { if is_expanded { // Main project as first child — highlights when focused individual let is_cursor = cursor_index == Some(flat_idx); - let is_focused_project = focused_project_id.as_ref() == Some(&project.id) && focus_individual; + let is_focused_project = focused_project_id.as_ref() + == Some(&project.id) + && focus_individual; flat_elements.push( - self.render_project_group_child(&project, 20.0, "gc", "group-child-item", is_cursor, is_focused_project, window, cx).into_any_element() + self.render_project_group_child( + &project, + 20.0, + "gc", + "group-child-item", + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); flat_idx += 1; if self.expanded_projects.contains(&project.id) { - self.render_expanded_children(&project, 34.0, 48.0, "gm-", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + &project, + 34.0, + 48.0, + "gm-", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } // Worktree children as siblings for (wt_idx, child) in worktree_children.iter().enumerate() { let is_cursor = cursor_index == Some(flat_idx); - let is_focused_project = focused_project_id.as_ref() == Some(&child.id); + let is_focused_project = + focused_project_id.as_ref() == Some(&child.id); flat_elements.push( - self.render_worktree_item(child, 20.0, wt_idx, is_cursor, is_focused_project, window, cx).into_any_element() + self.render_worktree_item( + child, + 20.0, + wt_idx, + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); flat_idx += 1; if self.expanded_projects.contains(&child.id) { - self.render_expanded_children(child, 34.0, 48.0, "wt-", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + child, + 34.0, + 48.0, + "wt-", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } } } @@ -909,22 +1124,53 @@ impl Render for Sidebar { let is_focused_project = focused_project_id.as_ref() == Some(&project.id); if project.is_orphan { flat_elements.push( - self.render_worktree_item(&project, 8.0, 0, is_cursor, is_focused_project, window, cx).into_any_element() + self.render_worktree_item( + &project, + 8.0, + 0, + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); } else { flat_elements.push( - self.render_project_item(&project, index, is_cursor, is_focused_project, window, cx).into_any_element() + self.render_project_item( + &project, + index, + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); } flat_idx += 1; let show_children = self.expanded_projects.contains(&project.id); if show_children { - self.render_expanded_children(&project, 20.0, 34.0, "", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + &project, + 20.0, + 34.0, + "", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } } } - SidebarItem::Folder { folder, index, projects, worktree_children } => { + SidebarItem::Folder { + folder, + index, + projects, + worktree_children, + } => { let is_cursor = cursor_index == Some(flat_idx); let folder_collapsed = folder_collapsed_map .get(&folder.id) @@ -932,16 +1178,35 @@ impl Render for Sidebar { .unwrap_or(false); let idle_terminal_count = if folder_collapsed { let terminals = self.terminals.lock(); - projects.iter() + projects + .iter() .flat_map(|p| p.terminal_ids.iter()) - .filter(|id| terminals.get(id.as_str()).is_some_and(|t| t.is_waiting_for_input())) + .filter(|id| { + terminals + .get(id.as_str()) + .is_some_and(|t| t.is_waiting_for_input()) + }) .count() } else { 0 }; - let all_hidden = projects.iter().all(|p| !p.show_in_overview) && worktree_children.values().flat_map(|c| c.iter()).all(|c| !c.show_in_overview); + let all_hidden = projects.iter().all(|p| !p.show_in_overview) + && worktree_children + .values() + .flat_map(|c| c.iter()) + .all(|c| !c.show_in_overview); flat_elements.push( - self.render_folder_header(&folder, index, projects.len(), idle_terminal_count, all_hidden, is_cursor, window, cx).into_any_element() + self.render_folder_header( + &folder, + index, + projects.len(), + idle_terminal_count, + all_hidden, + is_cursor, + window, + cx, + ) + .into_any_element(), ); flat_idx += 1; @@ -954,41 +1219,96 @@ impl Render for Sidebar { if has_worktrees && !fp.is_orphan { // Group header mode within folder let is_cursor = cursor_index == Some(flat_idx); - let is_focused_group = focused_project_id.as_ref() == Some(&fp.id) && !focus_individual; - flat_elements.push( - { - let all_hidden = !fp.show_in_overview && fp_wt_children.is_none_or(|c| c.iter().all(|c| !c.show_in_overview)); - self.render_project_group_header(fp, 20.0, "fgh", "fgh-item", crate::project_list::GroupHeaderDragConfig::InFolder { folder_id: folder.id.clone() }, all_hidden, is_cursor, is_focused_group, window, cx).into_any_element() - } - ); + let is_focused_group = focused_project_id.as_ref() == Some(&fp.id) + && !focus_individual; + flat_elements.push({ + let all_hidden = !fp.show_in_overview + && fp_wt_children + .is_none_or(|c| c.iter().all(|c| !c.show_in_overview)); + self.render_project_group_header( + fp, + 20.0, + "fgh", + "fgh-item", + crate::project_list::GroupHeaderDragConfig::InFolder { + folder_id: folder.id.clone(), + }, + all_hidden, + is_cursor, + is_focused_group, + window, + cx, + ) + .into_any_element() + }); flat_idx += 1; let is_expanded = self.is_project_expanded(&fp.id, true); if is_expanded { // Main project as first child let is_cursor = cursor_index == Some(flat_idx); - let is_focused_project = focused_project_id.as_ref() == Some(&fp.id) && focus_individual; + let is_focused_project = focused_project_id.as_ref() + == Some(&fp.id) + && focus_individual; flat_elements.push( - self.render_project_group_child(fp, 36.0, "fgc", "fgc-item", is_cursor, is_focused_project, window, cx).into_any_element() + self.render_project_group_child( + fp, + 36.0, + "fgc", + "fgc-item", + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); flat_idx += 1; if self.expanded_projects.contains(&fp.id) { - self.render_expanded_children(fp, 50.0, 64.0, "gm-", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + fp, + 50.0, + 64.0, + "gm-", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } // Worktree children as siblings if let Some(wt_children) = fp_wt_children { for (wt_idx, child) in wt_children.iter().enumerate() { let is_cursor = cursor_index == Some(flat_idx); - let is_focused_project = focused_project_id.as_ref() == Some(&child.id); + let is_focused_project = + focused_project_id.as_ref() == Some(&child.id); flat_elements.push( - self.render_worktree_item(child, 36.0, wt_idx, is_cursor, is_focused_project, window, cx).into_any_element() + self.render_worktree_item( + child, + 36.0, + wt_idx, + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); flat_idx += 1; if self.expanded_projects.contains(&child.id) { - self.render_expanded_children(child, 50.0, 64.0, "wt-", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + child, + 50.0, + 64.0, + "wt-", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } } } @@ -996,21 +1316,48 @@ impl Render for Sidebar { } else { // No worktrees or orphan — standard folder project rendering let is_cursor = cursor_index == Some(flat_idx); - let is_focused_project = focused_project_id.as_ref() == Some(&fp.id); + let is_focused_project = + focused_project_id.as_ref() == Some(&fp.id); if fp.is_orphan { flat_elements.push( - self.render_worktree_item(fp, 20.0, 0, is_cursor, is_focused_project, window, cx).into_any_element() + self.render_worktree_item( + fp, + 20.0, + 0, + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); } else { flat_elements.push( - self.render_folder_project_item(fp, &folder.id, is_cursor, is_focused_project, window, cx).into_any_element() + self.render_folder_project_item( + fp, + &folder.id, + is_cursor, + is_focused_project, + window, + cx, + ) + .into_any_element(), ); } flat_idx += 1; let show_children = self.expanded_projects.contains(&fp.id); if show_children { - self.render_expanded_children(fp, 36.0, 50.0, "", cursor_index, &mut flat_idx, &mut flat_elements, cx); + self.render_expanded_children( + fp, + 36.0, + 50.0, + "", + cursor_index, + &mut flat_idx, + &mut flat_elements, + cx, + ); } } } @@ -1030,19 +1377,29 @@ 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::<FolderDrag>(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() + .into_any_element(), ); self.render_sidebar_container(flat_elements, cx) diff --git a/crates/okena-views-sidebar/src/sidebar/worktree.rs b/crates/okena-views-sidebar/src/sidebar/worktree.rs index 0c8c51f93..a47f29db2 100644 --- a/crates/okena-views-sidebar/src/sidebar/worktree.rs +++ b/crates/okena-views-sidebar/src/sidebar/worktree.rs @@ -1,78 +1,78 @@ -//! 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 action client for a project. 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<(okena_transport::remote_action::RemoteActionClient, 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.effective_auth_token()?; + let daemon_id = okena_transport::client::strip_prefix(project_id, &conn_id); + let client = okena_transport::remote_action::RemoteActionClient::new(config, token); + Some((client, 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<Self>) { // 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((client, 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>), 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<String, String> { + let action = ActionRequest::GenerateWorktreeBranchName { + project_id: daemon_id, + }; + match client.post_action(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,86 +84,23 @@ 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(); }); - }).detach(); + }) + .detach(); } } diff --git a/crates/okena-views-sidebar/src/worktree_list.rs b/crates/okena-views-sidebar/src/worktree_list.rs index 8ed028504..d4a6d2dc4 100644 --- a/crates/okena-views-sidebar/src/worktree_list.rs +++ b/crates/okena-views-sidebar/src/worktree_list.rs @@ -3,23 +3,39 @@ //! Shows all git worktrees for a project with checkboxes to toggle sidebar visibility. //! Rendered at WindowView level via OverlayManager, like context menus. +use gpui::prelude::*; +use gpui::*; 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 gpui::*; -use gpui::prelude::*; +use okena_ui::tokens::{ui_text_md, ui_text_ms}; +use okena_workspace::state::Workspace; use crate::Cancel; +use okena_core::api::ApiWorktreeEntry; /// 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 { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl EventEmitter<WorktreeListPopoverEvent> for WorktreeListPopover {} @@ -27,56 +43,90 @@ impl EventEmitter<WorktreeListPopoverEvent> for WorktreeListPopover {} /// Standalone worktree list popover entity. pub struct WorktreeListPopover { workspace: Entity<Workspace>, - focus_manager: Entity<okena_workspace::focus::FocusManager>, - /// 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)>, + entries: Vec<ApiWorktreeEntry>, + loading: bool, + error_message: Option<String>, position: Point<Pixels>, - hooks: HooksConfig, focus_handle: FocusHandle, - /// Normalized git root (for filtering out the main repo entry). - norm_git_root: std::path::PathBuf, - /// Subdirectory within the git repo (empty for non-monorepo projects). - subdir: std::path::PathBuf, } impl WorktreeListPopover { + #[allow(clippy::too_many_arguments)] pub fn new( + client: okena_transport::remote_action::RemoteActionClient, + daemon_project_id: String, workspace: Entity<Workspace>, - focus_manager: Entity<okena_workspace::focus::FocusManager>, project_id: String, position: Point<Pixels>, - hooks: HooksConfig, - window_id: WindowId, cx: &mut Context<Self>, ) -> 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 focus_handle = cx.focus_handle(); - Self { workspace, focus_manager, window_id, project_id, entries, position, hooks, focus_handle, norm_git_root, subdir } + let mut popover = Self { + workspace, + project_id, + entries: Vec::new(), + loading: true, + error_message: None, + position, + focus_handle, + }; + popover.load_worktrees(client, daemon_project_id, cx); + popover + } + + fn load_worktrees( + &mut self, + client: okena_transport::remote_action::RemoteActionClient, + project_id: String, + cx: &mut Context<Self>, + ) { + cx.spawn(async move |this, cx| { + let result = smol::unblock(move || Self::fetch_worktrees(&client, project_id)).await; + let _ = this.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(entries) => this.entries = entries, + Err(error) => this.error_message = Some(error), + } + cx.notify(); + }); + }) + .detach(); + } + + /// Fetch the worktree listing from the daemon. The git repo lives on the + /// daemon, so we post a `GitListWorktrees` action rather than scanning the + fn fetch_worktrees( + client: &okena_transport::remote_action::RemoteActionClient, + project_id: String, + ) -> Result<Vec<ApiWorktreeEntry>, String> { + let action = okena_core::api::ActionRequest::GitListWorktrees { project_id }; + let value = client + .post_action(action)? + .ok_or_else(|| "Missing worktree list response".to_string())?; + let entries = value + .get("entries") + .cloned() + .ok_or_else(|| "Invalid worktree list response".to_string())?; + serde_json::from_value(entries) + .map_err(|error| format!("Invalid worktree list response: {error}")) } /// Find a tracked worktree project by its worktree root path. /// Checks both the expected project path (with monorepo subdir) and the /// bare worktree root for backwards compatibility with older workspace files. - fn find_tracked_project_id(&self, wt_path: &str, cx: &App) -> Option<String> { - let expected_path = okena_git::repository::project_path_in_worktree(wt_path, &self.subdir); + fn find_tracked_project_id(&self, entry: &ApiWorktreeEntry, cx: &App) -> Option<String> { let ws = self.workspace.read(cx); - ws.data().projects.iter() - .find(|p| (p.path == expected_path || p.path == wt_path) - && p.worktree_info.as_ref() - .is_some_and(|wt| wt.parent_project_id == self.project_id)) + ws.data() + .projects + .iter() + .find(|p| { + (p.path == entry.project_path || p.path == entry.worktree_path) + && p.worktree_info + .as_ref() + .is_some_and(|wt| wt.parent_project_id == self.project_id) + }) .map(|p| p.id.clone()) } @@ -88,6 +138,8 @@ impl WorktreeListPopover { impl Render for WorktreeListPopover { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let t = theme(cx); + let loading = self.loading; + let error_message = self.error_message.clone(); if !self.focus_handle.is_focused(window) { window.focus(&self.focus_handle, cx); @@ -95,24 +147,27 @@ impl Render for WorktreeListPopover { let ws = self.workspace.read(cx); let project_id = &self.project_id; - let subdir = &self.subdir; - let tracked_project_paths: std::collections::HashSet<String> = ws.data().projects.iter() - .filter(|p| p.worktree_info.as_ref() - .is_some_and(|wt| wt.parent_project_id == *project_id)) + let tracked_project_paths: std::collections::HashSet<String> = ws + .data() + .projects + .iter() + .filter(|p| { + p.worktree_info + .as_ref() + .is_some_and(|wt| wt.parent_project_id == *project_id) + }) .map(|p| p.path.clone()) .collect(); - let worktrees: Vec<(String, String, bool)> = self.entries.iter() - .filter(|(wt_path, _)| { - let norm_wt = okena_git::repository::normalize_path(std::path::Path::new(wt_path)); - norm_wt != self.norm_git_root - }) - .map(|(wt_path, branch)| { - let expected_path = okena_git::repository::project_path_in_worktree(wt_path, subdir); - let is_tracked = tracked_project_paths.contains(&expected_path) - || tracked_project_paths.contains(wt_path); - (wt_path.clone(), branch.clone(), is_tracked) + let worktrees: Vec<(ApiWorktreeEntry, bool)> = self + .entries + .iter() + .filter(|entry| !entry.is_main) + .map(|entry| { + let is_tracked = tracked_project_paths.contains(&entry.project_path) + || tracked_project_paths.contains(&entry.worktree_path); + (entry.clone(), is_tracked) }) .collect(); @@ -130,100 +185,120 @@ impl Render for WorktreeListPopover { .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(t.text_secondary)) .pb(px(6.0)) - .child("WORKTREES") + .child("WORKTREES"), ) .child( div() .id("worktree-list-scroll") .max_h(scroll_max_h) .overflow_y_scroll() - .when(worktrees.is_empty(), |d| { + .when(loading, |d| { d.child( div() .text_size(ui_text_md(cx)) .text_color(rgb(t.text_muted)) .py(px(8.0)) - .child("No worktrees found") + .child("Loading\u{2026}"), + ) + }) + .when_some(error_message, |d, error| { + d.child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.error)) + .py(px(8.0)) + .child(error), ) }) - .children(worktrees.into_iter().map(|(wt_path, branch, is_tracked)| { - let project_id = self.project_id.clone(); - let wt_path_clone = wt_path.clone(); - let branch_clone = branch.clone(); - let hooks = self.hooks.clone(); + .when( + worktrees.is_empty() && !loading && self.error_message.is_none(), + |d| { + d.child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_muted)) + .py(px(8.0)) + .child("No worktrees found"), + ) + }, + ) + .children(worktrees.into_iter().map(|(entry, is_tracked)| { + let project_id = self.project_id.clone(); + let entry_for_click = entry.clone(); - div() - .id(ElementId::Name(format!("wt-list-{}", wt_path).into())) - .flex() - .items_center() - .gap(px(6.0)) - .px(px(4.0)) - .py(px(4.0)) - .rounded(px(4.0)) - .cursor_pointer() - .hover(|s| s.bg(rgb(t.bg_hover))) - .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(); - }); - } - } 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); - }); - } - cx.notify(); - })) - .child( div() - .flex_shrink_0() - .w(px(14.0)) - .h(px(14.0)) - .rounded(px(3.0)) - .border_1() - .border_color(rgb(if is_tracked { t.border_active } else { t.border })) - .when(is_tracked, |d| d.bg(rgb(t.border_active))) + .id(ElementId::Name( + format!("wt-list-{}", entry.worktree_path).into(), + )) .flex() .items_center() - .justify_center() - .when(is_tracked, |d| { - d.child( - svg() - .path("icons/check.svg") - .size(px(10.0)) - .text_color(rgb(t.bg_primary)) - ) - }) - ) - .child( - div() - .flex_1() - .min_w_0() + .gap(px(6.0)) + .px(px(4.0)) + .py(px(4.0)) + .rounded(px(4.0)) + .cursor_pointer() + .hover(|s| s.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + if is_tracked { + if let Some(id) = + this.find_tracked_project_id(&entry_for_click, cx) + { + // 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 { + // 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: entry_for_click.worktree_path.clone(), + branch: entry_for_click.branch.clone(), + }); + } + cx.notify(); + })) .child( div() - .text_size(ui_text_md(cx)) - .text_color(rgb(t.text_primary)) - .overflow_hidden() - .text_ellipsis() - .child(branch.clone()) + .flex_shrink_0() + .w(px(14.0)) + .h(px(14.0)) + .rounded(px(3.0)) + .border_1() + .border_color(rgb(if is_tracked { + t.border_active + } else { + t.border + })) + .when(is_tracked, |d| d.bg(rgb(t.border_active))) + .flex() + .items_center() + .justify_center() + .when(is_tracked, |d| { + d.child( + svg() + .path("icons/check.svg") + .size(px(10.0)) + .text_color(rgb(t.bg_primary)), + ) + }), ) - ) - })) + .child( + div().flex_1().min_w_0().child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_primary)) + .overflow_hidden() + .text_ellipsis() + .child(entry.branch.clone()), + ), + ) + })), ); let position = self.position; @@ -238,18 +313,23 @@ impl Render for WorktreeListPopover { .inset_0() .occlude() .id("worktree-list-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_scroll_wheel(|_, _, cx| { cx.stop_propagation(); }) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child(panel) + anchored().position(position).snap_to_window().child(panel), )) } } diff --git a/crates/okena-views-terminal/src/elements/resize_handle.rs b/crates/okena-views-terminal/src/elements/resize_handle.rs index b7d857734..cd600f29e 100644 --- a/crates/okena-views-terminal/src/elements/resize_handle.rs +++ b/crates/okena-views-terminal/src/elements/resize_handle.rs @@ -1,149 +1 @@ -use gpui::*; -use std::cell::RefCell; -use std::rc::Rc; - -const DIVIDER_SIZE: f32 = 1.0; -const HANDLE_HITBOX_SIZE: f32 = 9.0; - -/// One-shot drag-start callback, shared into the element's paint closure and -/// `take()`n on first invocation. -type DragStartCallback = Rc<RefCell<Option<Box<dyn FnOnce(Point<Pixels>, &mut App)>>>>; - -pub struct ResizeHandle { - is_horizontal: bool, - border_color: u32, - border_active_color: u32, - on_drag_start: DragStartCallback, -} - -impl ResizeHandle { - pub fn new( - is_horizontal: bool, - border_color: u32, - border_active_color: u32, - on_drag_start: impl FnOnce(Point<Pixels>, &mut App) + 'static, - ) -> Self { - Self { - is_horizontal, - border_color, - border_active_color, - on_drag_start: Rc::new(RefCell::new(Some(Box::new(on_drag_start)))), - } - } -} - -impl IntoElement for ResizeHandle { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for ResizeHandle { - type RequestLayoutState = (); - type PrepaintState = Hitbox; - - fn id(&self) -> Option<ElementId> { - None - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = if self.is_horizontal { - Style { - size: Size { - width: relative(1.0).into(), - height: px(DIVIDER_SIZE).into(), - }, - flex_shrink: 0.0, - ..Default::default() - } - } else { - Style { - size: Size { - width: px(DIVIDER_SIZE).into(), - height: relative(1.0).into(), - }, - flex_shrink: 0.0, - ..Default::default() - } - }; - - let layout_id = window.request_layout(style, [], cx); - (layout_id, ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds<Pixels>, - _state: &mut Self::RequestLayoutState, - window: &mut Window, - _cx: &mut App, - ) -> Self::PrepaintState { - let expand = px((HANDLE_HITBOX_SIZE - DIVIDER_SIZE) / 2.0); - let hitbox_bounds = if self.is_horizontal { - Bounds::new( - point(bounds.origin.x, bounds.origin.y - expand), - size(bounds.size.width, px(HANDLE_HITBOX_SIZE)), - ) - } else { - Bounds::new( - point(bounds.origin.x - expand, bounds.origin.y), - size(px(HANDLE_HITBOX_SIZE), bounds.size.height), - ) - }; - - window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll) - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds<Pixels>, - _state: &mut Self::RequestLayoutState, - hitbox: &mut Self::PrepaintState, - window: &mut Window, - _cx: &mut App, - ) { - let color = if hitbox.is_hovered(window) { - rgb(self.border_active_color) - } else { - rgb(self.border_color) - }; - window.paint_quad(fill(bounds, color)); - - let cursor = if self.is_horizontal { - CursorStyle::ResizeUpDown - } else { - CursorStyle::ResizeLeftRight - }; - window.set_cursor_style(cursor, hitbox); - - let on_drag_start = self.on_drag_start.clone(); - let hitbox_id = hitbox.id; - window.on_mouse_event(move |e: &MouseDownEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble - && e.button == MouseButton::Left - && hitbox_id.is_hovered(window) - { - if let Some(cb) = on_drag_start.borrow_mut().take() { - cb(e.position, cx); - } - cx.stop_propagation(); - } - }); - } -} +pub use okena_ui::resize_handle::ResizeHandle; diff --git a/crates/okena-views-terminal/src/elements/terminal_element.rs b/crates/okena-views-terminal/src/elements/terminal_element.rs index 2ad4fc189..1a1cb9a41 100644 --- a/crates/okena-views-terminal/src/elements/terminal_element.rs +++ b/crates/okena-views-terminal/src/elements/terminal_element.rs @@ -1,19 +1,19 @@ use crate::terminal_view_settings; -use okena_terminal::terminal::{Terminal, TerminalSize}; -use okena_files::theme::theme; -use okena_ui::theme::ansi_to_hsla; -use okena_ui::color_utils::tint_color; -use okena_workspace::settings::CursorShape; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::vte::ansi::{Color, NamedColor}; -use alacritty_terminal::index::{Column, Line}; -use alacritty_terminal::grid::Dimensions; use gpui::*; +use okena_files::theme::theme; +use okena_terminal::terminal::{Terminal, TerminalSize}; +use okena_ui::color_utils::tint_color; +use okena_ui::theme::ansi_to_hsla; +use okena_workspace::settings::CursorShape; use parking_lot::Mutex; use std::collections::HashMap; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; use super::terminal_input::TerminalInputHandler; use super::terminal_rendering::{BatchedTextRun, LayoutRect, is_default_bg}; @@ -111,8 +111,16 @@ fn shared_resize_target( viewers.insert(viewer_id, desired_size); let viewer_count = viewers.len(); - let min_cols = viewers.values().map(|size| size.cols).min().unwrap_or(desired_size.cols); - let min_rows = viewers.values().map(|size| size.rows).min().unwrap_or(desired_size.rows); + let min_cols = viewers + .values() + .map(|size| size.cols) + .min() + .unwrap_or(desired_size.cols); + let min_rows = viewers + .values() + .map(|size| size.rows) + .min() + .unwrap_or(desired_size.rows); ( viewer_count, @@ -139,10 +147,7 @@ pub enum LinkKind { /// A web URL (http/https) Url, /// A file path, optionally with line and column numbers - FilePath { - line: Option<u32>, - col: Option<u32>, - }, + FilePath { line: Option<u32>, col: Option<u32> }, } /// A detected URL or file path in the terminal grid @@ -326,7 +331,8 @@ impl Element for TerminalElement { let font_id = text_system.resolve_font(&font); // Use advance() for proper cell width (like Zed) - let cell_width = text_system.advance(font_id, font_size, 'm') + let cell_width = text_system + .advance(font_id, font_size, 'm') .map(|size| size.width) .unwrap_or(font_size * 0.6); @@ -384,6 +390,7 @@ impl Element for TerminalElement { // Register input handler let input_handler = TerminalInputHandler { terminal: self.terminal.clone(), + viewer_id: self.resize_viewer_id, }; window.handle_input(&self.focus_handle, input_handler, cx); @@ -413,7 +420,8 @@ impl Element for TerminalElement { ); let current_size = self.terminal.resize_state.lock().size; - let cols_rows_changed = resize_size.cols != current_size.cols || resize_size.rows != current_size.rows; + let cols_rows_changed = + resize_size.cols != current_size.cols || resize_size.rows != current_size.rows; let cell_size_changed = (cell_width_f - current_size.cell_width).abs() > 0.001 || (line_height_f - current_size.cell_height).abs() > 0.001; @@ -423,7 +431,11 @@ impl Element for TerminalElement { // desired by all live viewers. This avoids ping-pong between // differently shaped windows while still allowing growth once every // visible viewer can fit the larger dimension. - let target = if n_viewers <= 1 { desired_size } else { resize_size }; + let target = if n_viewers <= 1 { + desired_size + } else { + resize_size + }; self.terminal.resize(target); } else if cell_size_changed { let mut rs = self.terminal.resize_state.lock(); @@ -496,14 +508,24 @@ impl Element for TerminalElement { if cell.flags.contains(Flags::BOLD) { fg = match fg { - Color::Named(NamedColor::Black) => Color::Named(NamedColor::BrightBlack), + Color::Named(NamedColor::Black) => { + Color::Named(NamedColor::BrightBlack) + } Color::Named(NamedColor::Red) => Color::Named(NamedColor::BrightRed), - Color::Named(NamedColor::Green) => Color::Named(NamedColor::BrightGreen), - Color::Named(NamedColor::Yellow) => Color::Named(NamedColor::BrightYellow), + Color::Named(NamedColor::Green) => { + Color::Named(NamedColor::BrightGreen) + } + Color::Named(NamedColor::Yellow) => { + Color::Named(NamedColor::BrightYellow) + } Color::Named(NamedColor::Blue) => Color::Named(NamedColor::BrightBlue), - Color::Named(NamedColor::Magenta) => Color::Named(NamedColor::BrightMagenta), + Color::Named(NamedColor::Magenta) => { + Color::Named(NamedColor::BrightMagenta) + } Color::Named(NamedColor::Cyan) => Color::Named(NamedColor::BrightCyan), - Color::Named(NamedColor::White) => Color::Named(NamedColor::BrightWhite), + Color::Named(NamedColor::White) => { + Color::Named(NamedColor::BrightWhite) + } Color::Indexed(idx @ 0..=7) => Color::Indexed(idx + 8), other => other, }; @@ -513,28 +535,31 @@ impl Element for TerminalElement { std::mem::swap(&mut fg, &mut bg); } - let is_selected = if let Some(((start_col, start_row), (end_col, end_row))) = selection { - let (start_row, start_col, end_row, end_col) = if start_row < end_row || (start_row == end_row && start_col <= end_col) { - (start_row, start_col, end_row, end_col) - } else { - (end_row, end_col, start_row, start_col) - }; - if buffer_line >= start_row && buffer_line <= end_row { - if start_row == end_row { - col >= start_col && col <= end_col - } else if buffer_line == start_row { - col >= start_col - } else if buffer_line == end_row { - col <= end_col + let is_selected = + if let Some(((start_col, start_row), (end_col, end_row))) = selection { + let (start_row, start_col, end_row, end_col) = if start_row < end_row + || (start_row == end_row && start_col <= end_col) + { + (start_row, start_col, end_row, end_col) + } else { + (end_row, end_col, start_row, start_col) + }; + if buffer_line >= start_row && buffer_line <= end_row { + if start_row == end_row { + col >= start_col && col <= end_col + } else if buffer_line == start_row { + col >= start_col + } else if buffer_line == end_row { + col <= end_col + } else { + true + } } else { - true + false } } else { false - } - } else { - false - }; + }; let bg_color = if is_selected { Some(rgb(t.selection_bg).into()) @@ -567,7 +592,8 @@ impl Element for TerminalElement { if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { continue; } - if cell.c == ' ' && !cell.flags.intersects(Flags::UNDERLINE | Flags::STRIKEOUT) { + if cell.c == ' ' && !cell.flags.intersects(Flags::UNDERLINE | Flags::STRIKEOUT) + { continue; } @@ -629,7 +655,12 @@ impl Element for TerminalElement { if let Some(prev) = current_batch.take() { batched_runs.push(prev); } - current_batch = Some(BatchedTextRun::new(visual_line, col_i32, cell.c, text_style)); + current_batch = Some(BatchedTextRun::new( + visual_line, + col_i32, + cell.c, + text_style, + )); } } } @@ -657,10 +688,20 @@ impl Element for TerminalElement { let is_current = self.current_match_index == Some(idx); let highlight_color = if is_current { let c = rgb(t.search_current_bg); - Hsla::from(Rgba { r: c.r, g: c.g, b: c.b, a: 0.7 }) + Hsla::from(Rgba { + r: c.r, + g: c.g, + b: c.b, + a: 0.7, + }) } else { let c = rgb(t.search_match_bg); - Hsla::from(Rgba { r: c.r, g: c.g, b: c.b, a: 0.5 }) + Hsla::from(Rgba { + r: c.r, + g: c.g, + b: c.b, + a: 0.5, + }) }; let position = point( @@ -688,7 +729,12 @@ impl Element for TerminalElement { let url_width = px((cell_width_f * url_match.len as f32).ceil()); if is_hovered { - let hover_bg = Hsla::from(Rgba { r: 0.0, g: 0.48, b: 0.8, a: 0.2 }); + let hover_bg = Hsla::from(Rgba { + r: 0.0, + g: 0.48, + b: 0.8, + a: 0.2, + }); let hover_bounds = Bounds { origin: point(url_x, url_y), size: size(url_width, line_height), @@ -703,7 +749,12 @@ impl Element for TerminalElement { }; window.paint_quad(fill(underline_bounds, underline_color)); } else { - let underline_color = Hsla::from(Rgba { r: 0.5, g: 0.5, b: 0.5, a: 0.5 }); + let underline_color = Hsla::from(Rgba { + r: 0.5, + g: 0.5, + b: 0.5, + a: 0.5, + }); let underline_y = url_y + line_height - px(2.0); let underline_bounds = Bounds { origin: point(url_x, underline_y), @@ -724,12 +775,19 @@ impl Element for TerminalElement { let cursor_visual_line = cursor_point.line.0 + display_offset; if cursor_visual_line >= 0 && cursor_visual_line < screen_lines as i32 { - let cursor_x = px((f32::from(origin.x) + cursor_point.column.0 as f32 * cell_width_f).floor()); - let cursor_y = px((f32::from(origin.y) + cursor_visual_line as f32 * line_height_f).floor()); + let cursor_x = px((f32::from(origin.x) + + cursor_point.column.0 as f32 * cell_width_f) + .floor()); + let cursor_y = px((f32::from(origin.y) + + cursor_visual_line as f32 * line_height_f) + .floor()); let cursor_rgba = rgb(t.cursor); let cursor_color = Hsla::from(Rgba { - r: cursor_rgba.r, g: cursor_rgba.g, b: cursor_rgba.b, a: 0.8, + r: cursor_rgba.r, + g: cursor_rgba.g, + b: cursor_rgba.b, + a: 0.8, }); let cursor_bounds = match cursor_style { @@ -755,9 +813,28 @@ impl Element for TerminalElement { if !is_focused { let bg_rgba = rgb(bg_color); let fog = Hsla::from(Rgba { - r: bg_rgba.r, g: bg_rgba.g, b: bg_rgba.b, a: 0.2, + r: bg_rgba.r, + g: bg_rgba.g, + b: bg_rgba.b, + a: 0.2, }); window.paint_quad(fill(bounds, fog)); } + + let painted_samples = okena_core::latency_probe::client_painted( + &self.terminal.terminal_id, + self.resize_viewer_id, + ); + if !painted_samples.is_empty() { + let terminal_id = self.terminal.terminal_id.clone(); + let viewer_id = self.resize_viewer_id; + window.on_next_frame(move |_window, _cx| { + okena_core::latency_probe::client_frame_completed( + &terminal_id, + viewer_id, + &painted_samples, + ); + }); + } } } diff --git a/crates/okena-views-terminal/src/elements/terminal_input.rs b/crates/okena-views-terminal/src/elements/terminal_input.rs index 6489a13e2..aedb55243 100644 --- a/crates/okena-views-terminal/src/elements/terminal_input.rs +++ b/crates/okena-views-terminal/src/elements/terminal_input.rs @@ -1,5 +1,5 @@ -use okena_terminal::terminal::Terminal; use gpui::*; +use okena_terminal::terminal::Terminal; use std::ops::Range; use std::sync::Arc; @@ -14,6 +14,7 @@ const MACOS_FUNCTION_KEY_RANGE: std::ops::RangeInclusive<char> = '\u{F700}'..='\ /// Input handler for terminal text input pub(crate) struct TerminalInputHandler { pub terminal: Arc<Terminal>, + pub viewer_id: u64, } impl TerminalInputHandler { @@ -37,19 +38,20 @@ impl TerminalInputHandler { // Fast path: no control characters, send entire string at once if !filtered.chars().any(|c| matches!(c, '\n' | '\r' | '\u{8}')) { - self.terminal.send_input(&filtered); + self.terminal + .send_input_from_viewer(&filtered, self.viewer_id); return; } // Slow path: handle control characters individually for c in filtered.chars() { match c { - '\u{8}' => self.terminal.send_bytes(&[DEL]), - '\n' | '\r' => self.terminal.send_bytes(b"\r"), + '\u{8}' => self.terminal.send_bytes_from_viewer(&[DEL], self.viewer_id), + '\n' | '\r' => self.terminal.send_bytes_from_viewer(b"\r", self.viewer_id), _ => { let mut buf = [0u8; 4]; let s = c.encode_utf8(&mut buf); - self.terminal.send_input(s); + self.terminal.send_input_from_viewer(s, self.viewer_id); } } } diff --git a/crates/okena-views-terminal/src/elements/terminal_rendering.rs b/crates/okena-views-terminal/src/elements/terminal_rendering.rs index dca2371f4..e671f829c 100644 --- a/crates/okena-views-terminal/src/elements/terminal_rendering.rs +++ b/crates/okena-views-terminal/src/elements/terminal_rendering.rs @@ -1,6 +1,6 @@ -use okena_core::theme::ThemeColors; use alacritty_terminal::vte::ansi::{Color, NamedColor}; use gpui::*; +use okena_core::theme::ThemeColors; /// A batched text run that combines multiple adjacent cells with the same style (like Zed) #[derive(Debug)] @@ -74,14 +74,7 @@ impl BatchedTextRun { &[run_style], Some(cell_width), ) - .paint( - pos, - line_height, - TextAlign::Left, - None, - window, - cx, - ); + .paint(pos, line_height, TextAlign::Left, None, window, cx); } } @@ -108,7 +101,13 @@ impl LayoutRect { self.num_cells += 1; } - pub fn paint(&self, origin: Point<Pixels>, cell_width: Pixels, line_height: Pixels, window: &mut Window) { + pub fn paint( + &self, + origin: Point<Pixels>, + cell_width: Pixels, + line_height: Pixels, + window: &mut Window, + ) { let position = point( px((f32::from(origin.x) + self.start_col as f32 * f32::from(cell_width)).floor()), origin.y + line_height * self.line as f32, diff --git a/crates/okena-views-terminal/src/layout/layout_container.rs b/crates/okena-views-terminal/src/layout/layout_container.rs index 97c60553a..91ca118c8 100644 --- a/crates/okena-views-terminal/src/layout/layout_container.rs +++ b/crates/okena-views-terminal/src/layout/layout_container.rs @@ -1,20 +1,20 @@ //! Recursive layout container that renders terminal/split/tabs nodes use crate::ActionDispatch; -use okena_core::api::ActionRequest; -use okena_terminal::backend::TerminalBackend; -use okena_files::theme::theme; -use okena_ui::theme::with_alpha; -use okena_ui::click_detector::ClickDetector; -use crate::layout::pane_drag::{PaneDrag, DropZone}; +use crate::layout::pane_drag::{DropZone, PaneDrag}; use crate::layout::split_pane::{ActiveDrag, render_split_divider}; use crate::layout::terminal_pane::TerminalPane; +use gpui::prelude::*; +use gpui::*; +use okena_core::api::ActionRequest; +use okena_files::theme::theme; use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; +use okena_ui::click_detector::ClickDetector; +use okena_ui::theme::with_alpha; use okena_workspace::focus::FocusManager; use okena_workspace::request_broker::RequestBroker; use okena_workspace::state::{LayoutNode, SplitDirection, WindowId, Workspace}; -use gpui::*; -use gpui::prelude::*; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; @@ -77,7 +77,10 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { child_containers: HashMap::new(), container_bounds_ref: Rc::new(RefCell::new(Bounds { origin: Point::default(), - size: Size { width: px(800.0), height: px(600.0) }, + size: Size { + width: px(800.0), + height: px(600.0), + }, })), drop_animation: None, active_drag, @@ -207,9 +210,13 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { let parent_path = &self.layout_path[..self.layout_path.len() - 1]; let ws = self.workspace.read(cx); if let Some(project) = ws.project(&self.project_id) - && let Some(LayoutNode::Tabs { .. }) = project.layout.as_ref().and_then(|l| l.get_at_path(parent_path)) { - return true; - } + && let Some(LayoutNode::Tabs { .. }) = project + .layout + .as_ref() + .and_then(|l| l.get_at_path(parent_path)) + { + return true; + } false } @@ -240,16 +247,17 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { pub(super) fn finish_tab_rename(&mut self, cx: &mut Context<Self>) { if let Some((terminal_id, new_name)) = finish_rename(&mut self.tab_rename_state, cx) - && let Some(ref dispatcher) = self.action_dispatcher { - dispatcher.dispatch( - ActionRequest::RenameTerminal { - project_id: self.project_id.clone(), - terminal_id, - name: new_name, - }, - cx, - ); - } + && let Some(ref dispatcher) = self.action_dispatcher + { + dispatcher.dispatch( + ActionRequest::RenameTerminal { + project_id: self.project_id.clone(), + terminal_id, + name: new_name, + }, + cx, + ); + } let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| ws.restore_focused_terminal(fm, cx)); @@ -284,30 +292,22 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { fm.is_terminal_fullscreened(&self.project_id, tid) }); - let mut container = div() - .size_full() - .min_h_0() - .flex() - .flex_col() - .relative(); + let mut container = div().size_full().min_h_0().flex().flex_col().relative(); if !in_tab_group && !is_zoomed { container = container.child(self.render_standalone_tab_bar(window, cx)); } - container - .child( - div() - .flex_1() - .min_h_0() - .relative() - .when_some(self.terminal_pane.clone(), |d, pane| { - d.child(AnyView::from(pane).cached( - StyleRefinement::default().size_full(), - )) - }) - .child(self.render_drop_zones(terminal_id, cx, &self.active_drag.clone())), - ) + container.child( + div() + .flex_1() + .min_h_0() + .relative() + .when_some(self.terminal_pane.clone(), |d, pane| { + d.child(AnyView::from(pane).cached(StyleRefinement::default().size_full())) + }) + .child(self.render_drop_zones(terminal_id, cx, &self.active_drag.clone())), + ) } fn render_drop_zones( @@ -323,53 +323,58 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { let id_suffix = terminal_id.unwrap_or_else(|| format!("none-{:?}", self.layout_path)); let dispatcher = self.action_dispatcher.clone(); - let make_zone = |zone: DropZone, id_suffix: &str, active_drag: &ActiveDrag| -> Stateful<Div> { - let zone_id = format!("drop-zone-{}-{:?}", id_suffix, zone); - let pid = project_id.clone(); - let this_tid = tid.clone(); - let active_drag_for_hover = active_drag.clone(); - let active_drag_for_drop = active_drag.clone(); - let dispatcher = dispatcher.clone(); - - let zone_str = match zone { - DropZone::Top => "top", - DropZone::Bottom => "bottom", - DropZone::Left => "left", - DropZone::Right => "right", - DropZone::Center => "center", - }; + let make_zone = + |zone: DropZone, id_suffix: &str, active_drag: &ActiveDrag| -> Stateful<Div> { + let zone_id = format!("drop-zone-{}-{:?}", id_suffix, zone); + let pid = project_id.clone(); + let this_tid = tid.clone(); + let active_drag_for_hover = active_drag.clone(); + let active_drag_for_drop = active_drag.clone(); + let dispatcher = dispatcher.clone(); + + let zone_str = match zone { + DropZone::Top => "top", + DropZone::Bottom => "bottom", + DropZone::Left => "left", + DropZone::Right => "right", + DropZone::Center => "center", + }; - div() - .id(ElementId::Name(zone_id.into())) - .drag_over::<PaneDrag>(move |style, _, _, _| { - if active_drag_for_hover.borrow().is_some() { - return style; - } - style.bg(highlight) - }) - .on_drop(cx.listener({ - let pid = pid.clone(); - let this_tid = this_tid.clone(); - move |_this, drag: &PaneDrag, _window, cx| { - if active_drag_for_drop.borrow().is_some() { - return; - } - if Some(drag.terminal_id.as_str()) == this_tid.as_deref() { - return; + div() + .id(ElementId::Name(zone_id.into())) + .drag_over::<PaneDrag>(move |style, _, _, _| { + if active_drag_for_hover.borrow().is_some() { + return style; } - if let Some(ref target_id) = this_tid - && let Some(ref dispatcher) = dispatcher { - dispatcher.dispatch(ActionRequest::MovePaneTo { - project_id: drag.project_id.clone(), - terminal_id: drag.terminal_id.clone(), - target_project_id: pid.clone(), - target_terminal_id: target_id.clone(), - zone: zone_str.to_string(), - }, cx); + style.bg(highlight) + }) + .on_drop(cx.listener({ + let pid = pid.clone(); + let this_tid = this_tid.clone(); + move |_this, drag: &PaneDrag, _window, cx| { + if active_drag_for_drop.borrow().is_some() { + return; } - } - })) - }; + if Some(drag.terminal_id.as_str()) == this_tid.as_deref() { + return; + } + if let Some(ref target_id) = this_tid + && let Some(ref dispatcher) = dispatcher + { + dispatcher.dispatch( + ActionRequest::MovePaneTo { + project_id: drag.project_id.clone(), + terminal_id: drag.terminal_id.clone(), + target_project_id: pid.clone(), + target_terminal_id: target_id.clone(), + zone: zone_str.to_string(), + }, + cx, + ); + } + } + })) + }; div() .absolute() @@ -454,13 +459,13 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { .clone(); return div() - .id(ElementId::Name(format!("split-container-{}-{:?}", project_id, layout_path).into())) + .id(ElementId::Name( + format!("split-container-{}-{:?}", project_id, layout_path).into(), + )) .size_full() .min_h_0() .min_w_0() - .child(AnyView::from(container).cached( - StyleRefinement::default().size_full() - )); + .child(AnyView::from(container).cached(StyleRefinement::default().size_full())); } let is_horizontal = direction == SplitDirection::Horizontal; @@ -489,13 +494,17 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { }) .collect(); self.deregister_child_resize_viewers_except(&visible_paths, cx); - self.child_containers.retain(|path, _| valid_paths.contains(path)); + self.child_containers + .retain(|path, _| valid_paths.contains(path)); let container_bounds_ref = self.container_bounds_ref.clone(); let total_visible_size: f32 = visible_children_info.iter().map(|(_, s)| s).sum(); let normalized_sizes: Vec<f32> = if total_visible_size > 0.0 { - visible_children_info.iter().map(|(_, s)| s / total_visible_size * 100.0).collect() + visible_children_info + .iter() + .map(|(_, s)| s / total_visible_size * 100.0) + .collect() } else { vec![100.0 / visible_children_info.len().max(1) as f32; visible_children_info.len()] }; @@ -550,25 +559,29 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { .flex_basis(relative(size_percent / 100.0)) .min_w_0() .min_h_0() - .child(AnyView::from(container).cached( - StyleRefinement::default().size_full() - )) + .child(AnyView::from(container).cached(StyleRefinement::default().size_full())) .into_any_element(); elements.push(child_element); } div() - .id(ElementId::Name(format!("split-container-{}-{:?}", project_id, layout_path).into())) - .child(canvas( - { - let container_bounds_ref = container_bounds_ref.clone(); - move |bounds, _window, _cx| { - *container_bounds_ref.borrow_mut() = bounds; - } - }, - |_bounds, _prepaint, _window, _cx| {}, - ).absolute().size_full()) + .id(ElementId::Name( + format!("split-container-{}-{:?}", project_id, layout_path).into(), + )) + .child( + canvas( + { + let container_bounds_ref = container_bounds_ref.clone(); + move |bounds, _window, _cx| { + *container_bounds_ref.borrow_mut() = bounds; + } + }, + |_bounds, _prepaint, _window, _cx| {}, + ) + .absolute() + .size_full(), + ) .flex() .when(is_horizontal, |d| d.flex_col()) .flex_nowrap() @@ -583,7 +596,12 @@ impl<D: ActionDispatch + Send + Sync> Render for LayoutContainer<D> { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let t = theme(cx); let workspace = self.workspace.read(cx); - let layout = self.get_layout(workspace).cloned(); + let mut layout = self.get_layout(workspace).cloned(); + if let Some(LayoutNode::Split { direction, .. }) = &mut layout { + *direction = workspace + .project_layout_mode(self.window_id) + .presented_split_direction(*direction); + } match &layout { Some(LayoutNode::Terminal { .. }) => { diff --git a/crates/okena-views-terminal/src/layout/navigation.rs b/crates/okena-views-terminal/src/layout/navigation.rs index f1f15583e..9e6d23f15 100644 --- a/crates/okena-views-terminal/src/layout/navigation.rs +++ b/crates/okena-views-terminal/src/layout/navigation.rs @@ -52,7 +52,14 @@ impl PaneMap { /// Register (or update) a pane's bounds. /// Uses upsert semantics so cached views that skip prepaint keep their entry. - pub fn register(&mut self, window_id: WindowId, project_id: String, layout_path: Vec<usize>, bounds: Bounds<Pixels>, focus_handle: Option<FocusHandle>) { + pub fn register( + &mut self, + window_id: WindowId, + project_id: String, + layout_path: Vec<usize>, + bounds: Bounds<Pixels>, + focus_handle: Option<FocusHandle>, + ) { if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) { return; } @@ -78,15 +85,17 @@ impl PaneMap { /// Remove a pane from the map (e.g. when the terminal pane is dropped). pub fn deregister(&mut self, window_id: WindowId, project_id: &str, layout_path: &[usize]) { self.panes.retain(|p| { - !(p.window_id == window_id && p.project_id == project_id && p.layout_path == layout_path) + !(p.window_id == window_id + && p.project_id == project_id + && p.layout_path == layout_path) }); } /// Find the pane at the given project_id and layout_path pub fn find_pane(&self, project_id: &str, layout_path: &[usize]) -> Option<&PaneBounds> { - self.panes.iter().find(|p| { - p.project_id == project_id && p.layout_path == layout_path - }) + self.panes + .iter() + .find(|p| p.project_id == project_id && p.layout_path == layout_path) } /// Find the nearest pane in the given direction from the source pane @@ -97,9 +106,13 @@ impl PaneMap { ) -> Option<&PaneBounds> { let source_center = source.bounds.center(); - self.panes.iter() + self.panes + .iter() .filter(|p| { - if p.window_id == source.window_id && p.project_id == source.project_id && p.layout_path == source.layout_path { + if p.window_id == source.window_id + && p.project_id == source.project_id + && p.layout_path == source.layout_path + { return false; } @@ -115,7 +128,9 @@ impl PaneMap { .min_by(|a, b| { let dist_a = weighted_distance(&source_center, &a.bounds.center(), direction); let dist_b = weighted_distance(&source_center, &b.bounds.center(), direction); - dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal) + dist_a + .partial_cmp(&dist_b) + .unwrap_or(std::cmp::Ordering::Equal) }) } @@ -138,12 +153,18 @@ impl PaneMap { /// Called during render to evict stale entries from hidden projects /// (e.g. worktree columns that are retained but not currently visible). pub fn retain_projects(&mut self, visible_ids: &std::collections::HashSet<&str>) { - self.panes.retain(|p| visible_ids.contains(p.project_id.as_str())); + self.panes + .retain(|p| visible_ids.contains(p.project_id.as_str())); } /// Retain visible projects for one window while leaving other windows' panes untouched. - pub fn retain_window_projects(&mut self, window_id: WindowId, visible_ids: &std::collections::HashSet<&str>) { - self.panes.retain(|p| p.window_id != window_id || visible_ids.contains(p.project_id.as_str())); + pub fn retain_window_projects( + &mut self, + window_id: WindowId, + visible_ids: &std::collections::HashSet<&str>, + ) { + self.panes + .retain(|p| p.window_id != window_id || visible_ids.contains(p.project_id.as_str())); } /// Get all registered panes @@ -154,7 +175,12 @@ impl PaneMap { /// Return a copy containing only panes for one window. pub fn for_window(&self, window_id: WindowId) -> Self { Self { - panes: self.panes.iter().filter(|p| p.window_id == window_id).cloned().collect(), + panes: self + .panes + .iter() + .filter(|p| p.window_id == window_id) + .cloned() + .collect(), } } @@ -189,15 +215,25 @@ impl PaneMap { // Sort groups by minimum origin.x, then project_id as tiebreaker let mut group_entries: Vec<(&str, Vec<&PaneBounds>)> = groups.into_iter().collect(); group_entries.sort_by(|(id_a, panes_a), (id_b, panes_b)| { - let min_x_a = panes_a.iter().map(|p| f32::from(p.bounds.origin.x)).fold(f32::INFINITY, f32::min); - let min_x_b = panes_b.iter().map(|p| f32::from(p.bounds.origin.x)).fold(f32::INFINITY, f32::min); - min_x_a.partial_cmp(&min_x_b) + let min_x_a = panes_a + .iter() + .map(|p| f32::from(p.bounds.origin.x)) + .fold(f32::INFINITY, f32::min); + let min_x_b = panes_b + .iter() + .map(|p| f32::from(p.bounds.origin.x)) + .fold(f32::INFINITY, f32::min); + min_x_a + .partial_cmp(&min_x_b) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| id_a.cmp(id_b)) }); // Flatten - group_entries.into_iter().flat_map(|(_, panes)| panes).collect() + group_entries + .into_iter() + .flat_map(|(_, panes)| panes) + .collect() } /// Find the previous pane in reading order (top-to-bottom, left-to-right, cycles) @@ -260,40 +296,67 @@ pub fn register_pane_bounds( bounds: Bounds<Pixels>, focus_handle: Option<FocusHandle>, ) { - pane_map_lock().lock().register(window_id, project_id, layout_path, bounds, focus_handle); + pane_map_lock() + .lock() + .register(window_id, project_id, layout_path, bounds, focus_handle); } /// Remove a pane from the global map (call when a terminal pane is dropped) pub fn deregister_pane_bounds(window_id: WindowId, project_id: &str, layout_path: &[usize]) { - pane_map_lock().lock().deregister(window_id, project_id, layout_path); + pane_map_lock() + .lock() + .deregister(window_id, project_id, layout_path); } /// Remove pane entries for projects not in the visible set. /// Prevents stale entries from hidden columns (e.g. worktree projects with /// show_in_overview=false) from blocking spatial navigation. pub fn prune_pane_map(window_id: WindowId, visible_project_ids: &std::collections::HashSet<&str>) { - pane_map_lock().lock().retain_window_projects(window_id, visible_project_ids); + pane_map_lock() + .lock() + .retain_window_projects(window_id, visible_project_ids); } #[cfg(test)] mod tests { - use super::{PaneMap, NavigationDirection}; - use gpui::{px, Bounds, Point, Size}; + use super::{NavigationDirection, PaneMap}; + use gpui::{Bounds, Point, Size, px}; use okena_workspace::state::WindowId; fn make_bounds(x: f32, y: f32, w: f32, h: f32) -> Bounds<gpui::Pixels> { Bounds { origin: Point { x: px(x), y: px(y) }, - size: Size { width: px(w), height: px(h) }, + size: Size { + width: px(w), + height: px(h), + }, } } #[test] fn sorted_by_reading_order_horizontal_row() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "c".into(), vec![0], make_bounds(600.0, 0.0, 300.0, 400.0), None); - map.register(WindowId::Main, "a".into(), vec![0], make_bounds(0.0, 0.0, 300.0, 400.0), None); - map.register(WindowId::Main, "b".into(), vec![0], make_bounds(300.0, 0.0, 300.0, 400.0), None); + map.register( + WindowId::Main, + "c".into(), + vec![0], + make_bounds(600.0, 0.0, 300.0, 400.0), + None, + ); + map.register( + WindowId::Main, + "a".into(), + vec![0], + make_bounds(0.0, 0.0, 300.0, 400.0), + None, + ); + map.register( + WindowId::Main, + "b".into(), + vec![0], + make_bounds(300.0, 0.0, 300.0, 400.0), + None, + ); let sorted = map.sorted_by_reading_order(); assert_eq!(sorted[0].project_id, "a"); @@ -305,11 +368,35 @@ mod tests { fn sorted_by_reading_order_2x2_grid() { let mut map = PaneMap::new(); // Left column (project "left") — two stacked panes - map.register(WindowId::Main, "left".into(), vec![1], make_bounds(0.0, 300.0, 400.0, 300.0), None); - map.register(WindowId::Main, "left".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "left".into(), + vec![1], + make_bounds(0.0, 300.0, 400.0, 300.0), + None, + ); + map.register( + WindowId::Main, + "left".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 300.0), + None, + ); // Right column (project "right") — two stacked panes - map.register(WindowId::Main, "right".into(), vec![1], make_bounds(400.0, 300.0, 400.0, 300.0), None); - map.register(WindowId::Main, "right".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "right".into(), + vec![1], + make_bounds(400.0, 300.0, 400.0, 300.0), + None, + ); + map.register( + WindowId::Main, + "right".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 300.0), + None, + ); let sorted = map.sorted_by_reading_order(); // Left column first (top then bottom), then right column @@ -327,10 +414,28 @@ mod tests { fn sorted_by_reading_order_multi_column_different_heights() { let mut map = PaneMap::new(); // Column A (left): one full-height pane, center Y=300 - map.register(WindowId::Main, "col_a".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); + map.register( + WindowId::Main, + "col_a".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); // Column B (right): two stacked panes, center Y=150 and Y=450 - map.register(WindowId::Main, "col_b".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 300.0), None); - map.register(WindowId::Main, "col_b".into(), vec![1], make_bounds(400.0, 300.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "col_b".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 300.0), + None, + ); + map.register( + WindowId::Main, + "col_b".into(), + vec![1], + make_bounds(400.0, 300.0, 400.0, 300.0), + None, + ); let sorted = map.sorted_by_reading_order(); // Column A first (leftmost), then column B top-to-bottom @@ -345,7 +450,13 @@ mod tests { #[test] fn sorted_by_reading_order_single_pane() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "only".into(), vec![0], make_bounds(0.0, 0.0, 800.0, 600.0), None); + map.register( + WindowId::Main, + "only".into(), + vec![0], + make_bounds(0.0, 0.0, 800.0, 600.0), + None, + ); let sorted = map.sorted_by_reading_order(); assert_eq!(sorted.len(), 1); @@ -356,10 +467,22 @@ mod tests { fn register_upserts_existing_entry() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "p".into(), vec![0, 1], make_bounds(0.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "p".into(), + vec![0, 1], + make_bounds(0.0, 0.0, 400.0, 300.0), + None, + ); assert_eq!(map.panes().len(), 1); - map.register(WindowId::Main, "p".into(), vec![0, 1], make_bounds(100.0, 0.0, 500.0, 300.0), None); + map.register( + WindowId::Main, + "p".into(), + vec![0, 1], + make_bounds(100.0, 0.0, 500.0, 300.0), + None, + ); assert_eq!(map.panes().len(), 1); assert_eq!(f32::from(map.panes()[0].bounds.origin.x), 100.0); } @@ -369,8 +492,20 @@ mod tests { let mut map = PaneMap::new(); let extra = WindowId::Extra(okena_workspace::state::WindowState::default().id); - map.register(WindowId::Main, "p".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 300.0), None); - map.register(extra, "p".into(), vec![0], make_bounds(500.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "p".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 300.0), + None, + ); + map.register( + extra, + "p".into(), + vec![0], + make_bounds(500.0, 0.0, 400.0, 300.0), + None, + ); assert_eq!(map.panes().len(), 2); assert_eq!(map.for_window(WindowId::Main).panes().len(), 1); @@ -390,8 +525,20 @@ mod tests { #[test] fn deregister_removes_matching_entry() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "a".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 300.0), None); - map.register(WindowId::Main, "b".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "a".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 300.0), + None, + ); + map.register( + WindowId::Main, + "b".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 300.0), + None, + ); assert_eq!(map.panes().len(), 2); map.deregister(WindowId::Main, "a", &[0]); @@ -402,7 +549,13 @@ mod tests { #[test] fn deregister_noop_when_not_found() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "a".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "a".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 300.0), + None, + ); map.deregister(WindowId::Main, "nonexistent", &[0]); assert_eq!(map.panes().len(), 1); @@ -411,9 +564,27 @@ mod tests { #[test] fn retain_projects_removes_hidden() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "parent".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "worktree".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "other".into(), vec![0], make_bounds(800.0, 0.0, 400.0, 600.0), None); + map.register( + WindowId::Main, + "parent".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "worktree".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "other".into(), + vec![0], + make_bounds(800.0, 0.0, 400.0, 600.0), + None, + ); assert_eq!(map.panes().len(), 3); // Only parent and other are visible (worktree hidden in overview) @@ -428,9 +599,27 @@ mod tests { #[test] fn retain_projects_allows_navigation_past_hidden() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "a".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "hidden_wt".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "b".into(), vec![0], make_bounds(800.0, 0.0, 400.0, 600.0), None); + map.register( + WindowId::Main, + "a".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "hidden_wt".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "b".into(), + vec![0], + make_bounds(800.0, 0.0, 400.0, 600.0), + None, + ); // Prune hidden worktree let visible: std::collections::HashSet<&str> = ["a", "b"].into_iter().collect(); @@ -447,25 +636,65 @@ mod tests { fn retain_window_projects_does_not_prune_other_windows() { let mut map = PaneMap::new(); let extra = WindowId::Extra(okena_workspace::state::WindowState::default().id); - map.register(WindowId::Main, "visible".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "hidden".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 600.0), None); - map.register(extra, "hidden".into(), vec![0], make_bounds(800.0, 0.0, 400.0, 600.0), None); + map.register( + WindowId::Main, + "visible".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "hidden".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + extra, + "hidden".into(), + vec![0], + make_bounds(800.0, 0.0, 400.0, 600.0), + None, + ); let visible: std::collections::HashSet<&str> = ["visible"].into_iter().collect(); map.retain_window_projects(WindowId::Main, &visible); - assert!(map.for_window(WindowId::Main).find_pane("hidden", &[0]).is_none()); + assert!( + map.for_window(WindowId::Main) + .find_pane("hidden", &[0]) + .is_none() + ); assert!(map.for_window(extra).find_pane("hidden", &[0]).is_some()); } #[test] fn navigation_works_after_upsert() { let mut map = PaneMap::new(); - map.register(WindowId::Main, "a".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "b".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 600.0), None); + map.register( + WindowId::Main, + "a".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "b".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 600.0), + None, + ); // Upsert pane "a" with same bounds (simulates cached re-register) - map.register(WindowId::Main, "a".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); + map.register( + WindowId::Main, + "a".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); assert_eq!(map.panes().len(), 2); let source = map.find_pane("a", &[0]).unwrap(); @@ -479,9 +708,27 @@ mod tests { let mut map = PaneMap::new(); // Register in non-visual order to prove insertion order is ignored // Left column: one full-height pane; Right column: two stacked panes - map.register(WindowId::Main, "right".into(), vec![1], make_bounds(400.0, 300.0, 400.0, 300.0), None); - map.register(WindowId::Main, "left".into(), vec![0], make_bounds(0.0, 0.0, 400.0, 600.0), None); - map.register(WindowId::Main, "right".into(), vec![0], make_bounds(400.0, 0.0, 400.0, 300.0), None); + map.register( + WindowId::Main, + "right".into(), + vec![1], + make_bounds(400.0, 300.0, 400.0, 300.0), + None, + ); + map.register( + WindowId::Main, + "left".into(), + vec![0], + make_bounds(0.0, 0.0, 400.0, 600.0), + None, + ); + map.register( + WindowId::Main, + "right".into(), + vec![0], + make_bounds(400.0, 0.0, 400.0, 300.0), + None, + ); // From left (first in reading order), next should be right[0] (top of right column) let source_left = map.find_pane("left", &[0]).unwrap().clone(); diff --git a/crates/okena-views-terminal/src/layout/pane_drag.rs b/crates/okena-views-terminal/src/layout/pane_drag.rs index 1680e76a1..912d0cde9 100644 --- a/crates/okena-views-terminal/src/layout/pane_drag.rs +++ b/crates/okena-views-terminal/src/layout/pane_drag.rs @@ -1,10 +1,10 @@ //! Pane drag-and-drop types for terminal rearrangement. +use gpui::*; +use gpui_component::h_flex; use okena_files::theme::theme; use okena_ui::theme::with_alpha; use okena_ui::tokens::ui_text_md; -use gpui::*; -use gpui_component::h_flex; /// Drag payload emitted from a terminal header. #[derive(Clone)] diff --git a/crates/okena-views-terminal/src/layout/split_pane.rs b/crates/okena-views-terminal/src/layout/split_pane.rs index dab2804ac..6726988b8 100644 --- a/crates/okena-views-terminal/src/layout/split_pane.rs +++ b/crates/okena-views-terminal/src/layout/split_pane.rs @@ -1,8 +1,8 @@ use crate::ActionDispatch; use crate::elements::resize_handle::ResizeHandle; +use gpui::*; use okena_files::theme::theme; use okena_workspace::state::{SplitDirection, WindowId, Workspace}; -use gpui::*; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -30,6 +30,8 @@ pub enum DragState { /// Available space along the resize axis (width for columns, height /// for rows), minus divider thickness. available_size: f32, + /// Sum of the raw widths for the currently visible projects. + visible_widths_sum: f32, /// When true the projects are stacked as rows, so the drag tracks the /// vertical axis instead of the horizontal one. vertical: bool, @@ -81,6 +83,27 @@ pub fn new_active_drag() -> ActiveDrag { Rc::new(RefCell::new(None)) } +fn resize_project_pair( + left_initial: f32, + right_initial: f32, + visible_widths_sum: f32, + delta_px: f32, + container_size: f32, + min_col_width: f32, +) -> (f32, f32) { + let scale = if visible_widths_sum > 0.0 { + visible_widths_sum + } else { + 100.0 + }; + let delta_width = delta_px / container_size * scale; + let min_width = (min_col_width / container_size * scale).max(scale * 0.05); + let combined_width = (left_initial + right_initial).max(2.0 * min_width); + let left_width = (left_initial + delta_width).clamp(min_width, combined_width - min_width); + + (left_width, combined_width - left_width) +} + /// Helper to compute and apply resize based on mouse position. /// /// The `window_id` parameter selects which window's `project_widths` slot @@ -97,7 +120,18 @@ pub fn compute_resize( cx: &mut App, ) { match drag_state { - DragState::Split { project_id, layout_path, left_child, right_child, direction, container_bounds, initial_mouse_pos, initial_sizes, visible_sizes_sum, action_dispatcher } => { + DragState::Split { + project_id, + layout_path, + left_child, + right_child, + direction, + container_bounds, + initial_mouse_pos, + initial_sizes, + visible_sizes_sum, + action_dispatcher, + } => { let bounds = *container_bounds; let is_horizontal = *direction == SplitDirection::Horizontal; let left_child = *left_child; @@ -124,7 +158,11 @@ pub fn compute_resize( } else { f32::from(mouse_pos.x) - f32::from(initial_mouse_pos.x) }; - let scale = if *visible_sizes_sum > 0.0 { *visible_sizes_sum } else { 100.0 }; + let scale = if *visible_sizes_sum > 0.0 { + *visible_sizes_sum + } else { + 100.0 + }; let delta_percent = delta / container_size * scale; let min_size = scale * 0.05; @@ -141,11 +179,14 @@ pub fn compute_resize( let layout_path = layout_path.clone(); if let Some(dispatcher) = action_dispatcher { - dispatcher.dispatch_action(okena_core::api::ActionRequest::UpdateSplitSizes { - project_id, - path: layout_path, - sizes: new_sizes, - }, cx); + dispatcher.dispatch_action( + okena_core::api::ActionRequest::UpdateSplitSizes { + project_id, + path: layout_path, + sizes: new_sizes, + }, + cx, + ); } else { // Use UI-only notify during drag to avoid auto-save spam; // final sizes are persisted on mouse-up via notify_data. @@ -154,7 +195,16 @@ pub fn compute_resize( }); } } - DragState::ProjectColumn { divider_index, project_ids, available_size, vertical, initial_mouse_pos, initial_widths, min_col_width } => { + DragState::ProjectColumn { + divider_index, + project_ids, + available_size, + visible_widths_sum, + vertical, + initial_mouse_pos, + initial_widths, + min_col_width, + } => { let container_size = *available_size; if container_size <= 0.0 { return; @@ -166,20 +216,28 @@ pub fn compute_resize( let num_projects = project_ids.len(); let default_width = 100.0 / num_projects as f32; - let left_initial = initial_widths.get(left_id).copied().unwrap_or(default_width); - let right_initial = initial_widths.get(right_id).copied().unwrap_or(default_width); + let left_initial = initial_widths + .get(left_id) + .copied() + .unwrap_or(default_width); + let right_initial = initial_widths + .get(right_id) + .copied() + .unwrap_or(default_width); let delta_px = if *vertical { f32::from(mouse_pos.y) - f32::from(initial_mouse_pos.y) } else { f32::from(mouse_pos.x) - f32::from(initial_mouse_pos.x) }; - let delta_percent = delta_px / container_size * 100.0; - - let min_width = (*min_col_width / container_size * 100.0).max(5.0); - - let left_new = (left_initial + delta_percent).max(min_width); - let right_new = (right_initial - delta_percent).max(min_width); + let (left_new, right_new) = resize_project_pair( + left_initial, + right_initial, + *visible_widths_sum, + delta_px, + container_size, + *min_col_width, + ); let mut new_widths = initial_widths.clone(); new_widths.insert(left_id.clone(), left_new); @@ -220,23 +278,31 @@ pub fn render_split_divider<D: ActionDispatch + Send + Sync>( move |mouse_pos, cx| { let bounds = *container_bounds.borrow(); - let (initial_sizes, visible_sizes_sum) = workspace.read(cx).project(&project_id).and_then(|p| { - p.layout.as_ref()?.get_at_path(&layout_path) - }).and_then(|node| { - if let okena_workspace::state::LayoutNode::Split { sizes, children, .. } = node { - let visible_sum: f32 = children.iter().enumerate() - .filter(|(_, c)| !c.is_all_hidden()) - .map(|(i, _)| sizes.get(i).copied().unwrap_or(0.0)) - .sum(); - Some((sizes.clone(), visible_sum)) - } else { - None - } - }).unwrap_or((vec![], 100.0)); - - let boxed_dispatcher: Option<Box<dyn ActionDispatchClone>> = action_dispatcher.as_ref().map(|d| { - Box::new(d.clone()) as Box<dyn ActionDispatchClone> - }); + let (initial_sizes, visible_sizes_sum) = workspace + .read(cx) + .project(&project_id) + .and_then(|p| p.layout.as_ref()?.get_at_path(&layout_path)) + .and_then(|node| { + if let okena_workspace::state::LayoutNode::Split { + sizes, children, .. + } = node + { + let visible_sum: f32 = children + .iter() + .enumerate() + .filter(|(_, c)| !c.is_all_hidden()) + .map(|(i, _)| sizes.get(i).copied().unwrap_or(0.0)) + .sum(); + Some((sizes.clone(), visible_sum)) + } else { + None + } + }) + .unwrap_or((vec![], 100.0)); + + let boxed_dispatcher: Option<Box<dyn ActionDispatchClone>> = action_dispatcher + .as_ref() + .map(|d| Box::new(d.clone()) as Box<dyn ActionDispatchClone>); *active_drag.borrow_mut() = Some(DragState::Split { project_id: project_id.clone(), @@ -279,38 +345,41 @@ pub fn render_project_divider( // A rows grid needs a horizontal divider (full width, drag along Y); a // columns grid needs a vertical divider (full height, drag along X). - ResizeHandle::new( - is_rows, - t.border, - t.border_active, - move |mouse_pos, cx| { - let bounds = *container_bounds.borrow(); - let num_projects = project_ids.len(); - let num_dividers = num_projects.saturating_sub(1) as f32; - - let viewport_size = if is_rows { - f32::from(bounds.size.height) - } else { - f32::from(bounds.size.width) - }; - let available_size = (viewport_size - num_dividers * 1.0).max(0.0); - - let ws = workspace.read(cx); - let initial_widths: HashMap<String, f32> = project_ids.iter() - .map(|id| (id.clone(), ws.get_project_width(window_id, id, num_projects))) - .collect(); - - *active_drag.borrow_mut() = Some(DragState::ProjectColumn { - divider_index, - project_ids: project_ids.clone(), - available_size, - vertical: is_rows, - initial_mouse_pos: mouse_pos, - initial_widths, - min_col_width, - }); - }, - ) + ResizeHandle::new(is_rows, t.border, t.border_active, move |mouse_pos, cx| { + let bounds = *container_bounds.borrow(); + let num_projects = project_ids.len(); + let num_dividers = num_projects.saturating_sub(1) as f32; + + let viewport_size = if is_rows { + f32::from(bounds.size.height) + } else { + f32::from(bounds.size.width) + }; + let available_size = (viewport_size - num_dividers * 1.0).max(0.0); + + let ws = workspace.read(cx); + let initial_widths: HashMap<String, f32> = project_ids + .iter() + .map(|id| { + ( + id.clone(), + ws.get_project_width(window_id, id, num_projects), + ) + }) + .collect(); + let visible_widths_sum = initial_widths.values().sum(); + + *active_drag.borrow_mut() = Some(DragState::ProjectColumn { + divider_index, + project_ids: project_ids.clone(), + available_size, + visible_widths_sum, + vertical: is_rows, + initial_mouse_pos: mouse_pos, + initial_widths, + min_col_width, + }); + }) } /// Render the sidebar resize divider @@ -318,12 +387,67 @@ pub fn render_sidebar_divider(active_drag: &ActiveDrag, cx: &App) -> impl IntoEl let t = theme(cx); let active_drag = active_drag.clone(); - ResizeHandle::new( - false, - t.border, - t.border_active, - move |_, _| { - *active_drag.borrow_mut() = Some(DragState::Sidebar); - }, - ) + ResizeHandle::new(false, t.border, t.border_active, move |_, _| { + *active_drag.borrow_mut() = Some(DragState::Sidebar); + }) +} + +#[cfg(test)] +mod tests { + use super::resize_project_pair; + + fn rendered_width(weight: f32, total: f32, container_size: f32) -> f32 { + weight / total * container_size + } + + #[test] + fn project_resize_tracks_mouse_when_visible_widths_sum_below_100() { + let container_size = 1_000.0; + let visible_sum = 50.0; + let initial_left = 20.0; + let initial_right = 30.0; + + let (left, right) = resize_project_pair( + initial_left, + initial_right, + visible_sum, + 100.0, + container_size, + 50.0, + ); + + let initial_px = rendered_width(initial_left, visible_sum, container_size); + let resized_px = rendered_width(left, left + right, container_size); + assert!((resized_px - initial_px - 100.0).abs() < 0.001); + } + + #[test] + fn project_resize_tracks_mouse_when_visible_widths_sum_above_100() { + let container_size = 1_000.0; + let visible_sum = 200.0; + let initial_left = 80.0; + let initial_right = 120.0; + + let (left, right) = resize_project_pair( + initial_left, + initial_right, + visible_sum, + 100.0, + container_size, + 50.0, + ); + + let initial_px = rendered_width(initial_left, visible_sum, container_size); + let resized_px = rendered_width(left, left + right, container_size); + assert!((resized_px - initial_px - 100.0).abs() < 0.001); + } + + #[test] + fn project_resize_preserves_pair_total_at_minimum_width() { + let (left, right) = resize_project_pair(60.0, 40.0, 100.0, 500.0, 1_000.0, 200.0); + + assert_eq!(left, 80.0); + assert_eq!(right, 20.0); + assert_eq!(left + right, 100.0); + } } diff --git a/crates/okena-views-terminal/src/layout/tabs/mod.rs b/crates/okena-views-terminal/src/layout/tabs/mod.rs index 830862065..2fadd273b 100644 --- a/crates/okena-views-terminal/src/layout/tabs/mod.rs +++ b/crates/okena-views-terminal/src/layout/tabs/mod.rs @@ -2,21 +2,21 @@ mod shell_selector; -use crate::actions::Cancel; use crate::ActionDispatch; -use crate::terminal_view_settings; -use okena_files::theme::theme; -use okena_ui::theme::with_alpha; -use okena_ui::tokens::{ui_text_sm, ui_text_md}; -use okena_ui::header_buttons::{header_button_base, ButtonSize, HeaderAction}; +use crate::actions::Cancel; use crate::layout::layout_container::{LayoutContainer, is_renaming, rename_input}; use crate::layout::pane_drag::{PaneDrag, PaneDragView}; use crate::simple_input::SimpleInput; -use okena_terminal::terminal::TerminalProgressState; -use okena_workspace::state::{LayoutNode, SplitDirection}; +use crate::terminal_view_settings; +use gpui::prelude::*; use gpui::*; use gpui_component::{h_flex, v_flex}; -use gpui::prelude::*; +use okena_files::theme::theme; +use okena_terminal::terminal::TerminalProgressState; +use okena_ui::header_buttons::{ButtonSize, HeaderAction, header_button_base}; +use okena_ui::theme::with_alpha; +use okena_ui::tokens::{ui_text_md, ui_text_sm}; +use okena_workspace::state::{LayoutNode, SplitDirection}; use std::collections::HashSet; /// Context for tab action button closures. @@ -30,7 +30,6 @@ pub(super) struct TabActionContext<D: ActionDispatch> { pub action_dispatcher: Option<D>, } - impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { pub(super) fn start_drop_animation(&mut self, tab_index: usize, cx: &mut Context<Self>) { self.drop_animation = Some((tab_index, 1.0)); @@ -63,7 +62,8 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { this.drop_animation = None; cx.notify(); }); - }).detach(); + }) + .detach(); } pub(super) fn render_tab_action_buttons( @@ -76,7 +76,6 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { 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<D: ActionDispatch + Send + Sync> LayoutContainer<D> { 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<D: ActionDispatch + Send + Sync> LayoutContainer<D> { 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); + } } }), ) @@ -241,9 +245,7 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { return v_flex() .size_full() - .child(AnyView::from(container).cached( - StyleRefinement::default().size_full() - )); + .child(AnyView::from(container).cached(StyleRefinement::default().size_full())); } let num_children = children.len(); @@ -261,7 +263,8 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { }; let visible_paths = HashSet::from([active_path]); self.deregister_child_resize_viewers_except(&visible_paths, cx); - self.child_containers.retain(|path, _| valid_paths.contains(path)); + self.child_containers + .retain(|path, _| valid_paths.contains(path)); // Deregister pane map entries for inactive tabs so stale entries // don't interfere with spatial navigation @@ -271,7 +274,11 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { if i != active_tab { path.truncate(base_len); path.push(i); - crate::layout::navigation::deregister_pane_bounds(self.window_id, &self.project_id, &path); + crate::layout::navigation::deregister_pane_bounds( + self.window_id, + &self.project_id, + &path, + ); } } @@ -280,45 +287,48 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { v_flex() .size_full() .relative() - .child(canvas( - { - let container_bounds_ref = container_bounds_ref.clone(); - move |bounds, _window, _cx| { - *container_bounds_ref.borrow_mut() = bounds; - } - }, - |_bounds, _prepaint, _window, _cx| {}, - ).absolute().size_full()) - .child(self.render_tab_bar(children, active_tab, false, cx)) .child( - div().flex_1().child({ - let mut child_path = self.layout_path.clone(); - child_path.push(active_tab); - - let container = self.child_containers - .entry(child_path.clone()) - .or_insert_with(|| { - cx.new(|_cx| { - LayoutContainer::new( - self.workspace.clone(), - self.focus_manager.clone(), - self.request_broker.clone(), - self.window_id, - self.project_id.clone(), - self.project_path.clone(), - child_path.clone(), - self.backend.clone(), - self.terminals.clone(), - self.active_drag.clone(), - self.action_dispatcher.clone(), - ) - }) + canvas( + { + let container_bounds_ref = container_bounds_ref.clone(); + move |bounds, _window, _cx| { + *container_bounds_ref.borrow_mut() = bounds; + } + }, + |_bounds, _prepaint, _window, _cx| {}, + ) + .absolute() + .size_full(), + ) + .child(self.render_tab_bar(children, active_tab, false, cx)) + .child(div().flex_1().child({ + let mut child_path = self.layout_path.clone(); + child_path.push(active_tab); + + let container = self + .child_containers + .entry(child_path.clone()) + .or_insert_with(|| { + cx.new(|_cx| { + LayoutContainer::new( + self.workspace.clone(), + self.focus_manager.clone(), + self.request_broker.clone(), + self.window_id, + self.project_id.clone(), + self.project_path.clone(), + child_path.clone(), + self.backend.clone(), + self.terminals.clone(), + self.active_drag.clone(), + self.action_dispatcher.clone(), + ) }) - .clone(); + }) + .clone(); - AnyView::from(container).cached(StyleRefinement::default().size_full()) - }), - ) + AnyView::from(container).cached(StyleRefinement::default().size_full()) + })) } pub(super) fn render_standalone_tab_bar( @@ -359,11 +369,12 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { let project = workspace_reader.project(&self.project_id); let project_for_names = project.cloned(); - let is_pane_focused = self.focus_manager.read(cx) + let is_pane_focused = self + .focus_manager + .read(cx) .focused_terminal_state() .is_some_and(|f| { - f.project_id == self.project_id - && f.layout_path.starts_with(&self.layout_path) + f.project_id == self.project_id && f.layout_path.starts_with(&self.layout_path) }); let tab_elements: Vec<_> = children.iter().enumerate().map(|(i, child)| { @@ -713,21 +724,19 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { let dispatcher_for_new = self.action_dispatcher.clone(); let mut end_drop_zone = div() - .id(ElementId::Name(format!("tab-end-drop-{:?}", self.layout_path).into())) + .id(ElementId::Name( + format!("tab-end-drop-{:?}", self.layout_path).into(), + )) .flex_1() .flex_shrink_0() .h_full() .min_w(px(20.0)) .on_click(cx.listener(move |this, _, _window, cx| { if this.empty_area_click_detector.check(()) - && let Some(ref dispatcher) = dispatcher_for_new { - dispatcher.add_tab( - &project_id_for_new, - &layout_path_for_new, - !standalone, - cx, - ); - } + && let Some(ref dispatcher) = dispatcher_for_new + { + dispatcher.add_tab(&project_id_for_new, &layout_path_for_new, !standalone, cx); + } })); if !standalone { @@ -762,24 +771,30 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { let target_index = num_children; if from_index != target_index - 1 { if let Some(ref dispatcher) = dispatcher_for_end { - dispatcher.dispatch(okena_core::api::ActionRequest::MoveTab { - project_id: project_id_for_end.clone(), - path: layout_path_for_end.clone(), - from_index, - to_index: target_index, - }, cx); + dispatcher.dispatch( + okena_core::api::ActionRequest::MoveTab { + project_id: project_id_for_end.clone(), + path: layout_path_for_end.clone(), + from_index, + to_index: target_index, + }, + cx, + ); } this.start_drop_animation(num_children - 1, cx); } } } else if let Some(ref dispatcher) = dispatcher_for_end { - dispatcher.dispatch(okena_core::api::ActionRequest::MoveTerminalToTabGroup { - project_id: drag.project_id.clone(), - terminal_id: drag.terminal_id.clone(), - target_path: layout_path_for_end.clone(), - position: None, - target_project_id: Some(project_id_for_end.clone()), - }, cx); + dispatcher.dispatch( + okena_core::api::ActionRequest::MoveTerminalToTabGroup { + project_id: drag.project_id.clone(), + terminal_id: drag.terminal_id.clone(), + target_path: layout_path_for_end.clone(), + position: None, + target_project_id: Some(project_id_for_end.clone()), + }, + cx, + ); } })); } @@ -802,9 +817,13 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { self.get_active_terminal_id(active_tab, cx) }; - let action_buttons = self.render_tab_action_buttons(action_ctx, terminal_id_for_actions.clone(), cx); + 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); @@ -818,10 +837,16 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { .flex() .items_center() .gap(px(0.0)) - .bg(rgb(if is_pane_focused { t.term_background_unfocused } else { t.bg_header })) + .bg(rgb(if is_pane_focused { + t.term_background_unfocused + } else { + t.bg_header + })) .child( div() - .id(ElementId::Name(format!("tab-scroll-{:?}", self.layout_path).into())) + .id(ElementId::Name( + format!("tab-scroll-{:?}", self.layout_path).into(), + )) .flex_1() .min_w_0() .flex() diff --git a/crates/okena-views-terminal/src/layout/tabs/shell_selector.rs b/crates/okena-views-terminal/src/layout/tabs/shell_selector.rs index 21ec97617..a1965610e 100644 --- a/crates/okena-views-terminal/src/layout/tabs/shell_selector.rs +++ b/crates/okena-views-terminal/src/layout/tabs/shell_selector.rs @@ -1,17 +1,21 @@ //! Shell selector for tab groups use crate::ActionDispatch; -use okena_terminal::shell_config::ShellType; -use okena_files::theme::theme; -use okena_ui::chip::shell_indicator_chip; use crate::layout::layout_container::LayoutContainer; -use okena_workspace::state::LayoutNode; use gpui::prelude::*; use gpui::*; use gpui_component::tooltip::Tooltip; +use okena_files::theme::theme; +use okena_terminal::shell_config::ShellType; +use okena_ui::chip::shell_indicator_chip; +use okena_workspace::state::LayoutNode; impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { - pub(super) fn get_active_terminal_id(&self, active_tab: usize, cx: &Context<Self>) -> Option<String> { + pub(super) fn get_active_terminal_id( + &self, + active_tab: usize, + cx: &Context<Self>, + ) -> Option<String> { let ws = self.workspace.read(cx); match self.get_layout(ws) { Some(LayoutNode::Tabs { children, .. }) => { @@ -43,7 +47,11 @@ impl<D: ActionDispatch + Send + Sync> LayoutContainer<D> { ShellType::Default } - pub(super) fn render_shell_indicator(&self, active_tab: usize, cx: &Context<Self>) -> impl IntoElement { + pub(super) fn render_shell_indicator( + &self, + active_tab: usize, + cx: &Context<Self>, + ) -> impl IntoElement { let t = theme(cx); let shell_type = self.get_active_shell_type(active_tab, cx); let shell_name = shell_type.short_display_name().to_string(); diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs b/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs index 90311f844..6f145670b 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs @@ -1,11 +1,11 @@ //! Terminal pane action handlers. -use crate::ActionDispatch; +use crate::{ActionDispatch, RemotePasteFile}; +use gpui::*; use okena_core::api::ActionRequest; #[cfg(target_os = "windows")] use okena_terminal::shell_config::ShellType; use okena_workspace::state::SplitDirection; -use gpui::*; use super::TerminalPane; @@ -61,9 +61,10 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { pub(super) fn handle_copy(&mut self, cx: &mut Context<Self>) { if let Some(ref terminal) = self.terminal - && let Some(text) = terminal.get_selected_text() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } + && let Some(text) = terminal.get_selected_text() + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } } pub(super) fn handle_paste(&mut self, cx: &mut Context<Self>) { @@ -90,7 +91,7 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { // writes the file and bracketed-pastes its path; the client-local temp // paths below would hand the server a path that doesn't exist on it. if let Some(ref dispatcher) = self.action_dispatcher - && dispatcher.is_remote() + && !dispatcher.shares_local_filesystem() && let Some(ref terminal_id) = self.terminal_id { dispatcher.upload_remote_paste_image( @@ -132,7 +133,9 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { log::warn!("WSL UNC write to {} failed; falling back to /mnt/c", unc); } - let Some(path) = write_paste_image_to_temp(&image, &filename) else { return }; + let Some(path) = write_paste_image_to_temp(&image, &filename) else { + return; + }; let path_str = if matches!(shell, ShellType::Wsl { .. }) { okena_terminal::shell_config::windows_path_to_wsl(&path.to_string_lossy()) } else { @@ -143,44 +146,102 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { #[cfg(not(target_os = "windows"))] { - let Some(path) = write_paste_image_to_temp(&image, &filename) else { return }; + let Some(path) = write_paste_image_to_temp(&image, &filename) else { + return; + }; terminal.send_paste(&path.to_string_lossy()); } } pub(super) fn handle_jump_prev_prompt(&mut self, cx: &mut Context<Self>) { if let Some(ref terminal) = self.terminal - && terminal.jump_to_prompt_above() { - cx.notify(); - } + && terminal.jump_to_prompt_above() + { + cx.notify(); + } } pub(super) fn handle_jump_next_prompt(&mut self, cx: &mut Context<Self>) { if let Some(ref terminal) = self.terminal - && terminal.jump_to_prompt_below() { - cx.notify(); - } + && terminal.jump_to_prompt_below() + { + cx.notify(); + } } pub(super) fn handle_jump_prev_failed(&mut self, cx: &mut Context<Self>) { if let Some(ref terminal) = self.terminal - && terminal.jump_to_prev_failed_command() { - cx.notify(); - } + && terminal.jump_to_prev_failed_command() + { + cx.notify(); + } } pub(super) fn handle_jump_next_failed(&mut self, cx: &mut Context<Self>) { if let Some(ref terminal) = self.terminal - && terminal.jump_to_next_failed_command() { - cx.notify(); - } + && terminal.jump_to_next_failed_command() + { + cx.notify(); + } } - pub(super) fn handle_file_drop(&mut self, paths: &ExternalPaths, _cx: &mut Context<Self>) { + pub(super) fn handle_file_drop(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) { let Some(ref terminal) = self.terminal else { return; }; + let Some(ref dispatcher) = self.action_dispatcher else { + return; + }; + + if !dispatcher.shares_local_filesystem() { + let Some(terminal_id) = self.terminal_id.clone() else { + return; + }; + let dispatcher = dispatcher.clone(); + let paths = paths.paths().to_vec(); + cx.spawn(async move |_this, cx| { + let files = smol::unblock(move || { + let mut files = Vec::new(); + let mut total_bytes = 0usize; + for path in paths.into_iter().take(20) { + match read_remote_paste_file(&path) { + Ok(file) + if total_bytes + file.bytes.len() + <= REMOTE_FILE_UPLOAD_LIMIT as usize => + { + total_bytes += file.bytes.len(); + files.push(file); + } + Ok(_) => { + log::error!( + "Cannot upload dropped files: combined size exceeds {} MiB", + REMOTE_FILE_UPLOAD_LIMIT / 1024 / 1024 + ); + break; + } + Err(error) => { + log::error!( + "Cannot upload dropped file {}: {error}", + path.display() + ); + } + } + } + files + }) + .await; + if files.is_empty() { + return; + } + cx.update(|cx| { + dispatcher.upload_remote_paste_files(&terminal_id, files, cx); + }); + }) + .detach(); + return; + } + for path in paths.paths() { let escaped_path = Self::shell_escape_path(path); terminal.send_input(&format!("{} ", escaped_path)); @@ -206,6 +267,36 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { } } +const REMOTE_FILE_UPLOAD_LIMIT: u64 = 64 * 1024 * 1024; + +fn read_remote_paste_file(path: &std::path::Path) -> Result<RemotePasteFile, String> { + let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?; + if !metadata.is_file() { + return Err("only files can be dropped into a remote terminal".to_string()); + } + if metadata.len() > REMOTE_FILE_UPLOAD_LIMIT { + return Err(format!( + "file is larger than the {} MiB upload limit", + REMOTE_FILE_UPLOAD_LIMIT / 1024 / 1024 + )); + } + + let extension = path + .extension() + .and_then(std::ffi::OsStr::to_str) + .filter(|extension| { + !extension.is_empty() + && extension.len() <= 16 + && extension + .chars() + .all(|character| character.is_ascii_alphanumeric()) + }) + .map(str::to_ascii_lowercase) + .unwrap_or_else(|| "bin".to_string()); + let bytes = std::fs::read(path).map_err(|error| error.to_string())?; + Ok(RemotePasteFile { extension, bytes }) +} + fn paste_filename(image: &Image) -> String { let ext = match image.format { ImageFormat::Png => "png", @@ -237,13 +328,19 @@ fn write_paste_image_to_temp(image: &Image, filename: &str) -> Option<std::path: #[cfg(target_os = "windows")] fn wsl_distro(shell: &ShellType) -> Option<String> { use std::sync::OnceLock; - let ShellType::Wsl { distro } = shell else { return None }; + let ShellType::Wsl { distro } = shell else { + return None; + }; if let Some(d) = distro { return Some(d.clone()); } static DEFAULT: OnceLock<Option<String>> = OnceLock::new(); DEFAULT - .get_or_init(|| okena_terminal::shell_config::detect_wsl_distros().into_iter().next()) + .get_or_init(|| { + okena_terminal::shell_config::detect_wsl_distros() + .into_iter() + .next() + }) .clone() } 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 f883e5c8d..f0183d666 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/content.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/content.rs @@ -1,16 +1,17 @@ //! Terminal content component. use crate::elements::terminal_element::{ - deregister_resize_viewer as deregister_shared_resize_viewer, next_resize_viewer_id, LinkKind, - SearchMatch, TerminalElement, + LinkKind, SearchMatch, TerminalElement, + deregister_resize_viewer as deregister_shared_resize_viewer, next_resize_viewer_id, }; +use crate::layout::navigation::register_pane_bounds; use crate::terminal_view_settings; -use okena_terminal::terminal::Terminal; +use gpui::*; use okena_files::theme::theme; +use okena_terminal::terminal::Terminal; 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; use std::time::{Duration, Instant}; @@ -31,6 +32,7 @@ pub struct TerminalContent { terminal: Option<Arc<Terminal>>, resize_viewer_id: u64, focus_handle: FocusHandle, + window_activation_subscription: Option<Subscription>, url_detector: UrlDetector, scrollbar: Entity<Scrollbar>, is_selecting: bool, @@ -44,6 +46,7 @@ pub struct TerminalContent { layout_path: Vec<usize>, window_id: Option<WindowId>, workspace: Entity<Workspace>, + request_broker: Entity<RequestBroker>, scroll_accumulator: f32, /// True while we're in the inertial "momentum" tail after a trackpad scroll /// gesture was released. On macOS the OS keeps emitting scroll-wheel events @@ -68,6 +71,7 @@ impl TerminalContent { project_id: String, layout_path: Vec<usize>, workspace: Entity<Workspace>, + request_broker: Entity<RequestBroker>, cx: &mut Context<Self>, ) -> Self { let scrollbar = cx.new(Scrollbar::new); @@ -76,6 +80,7 @@ impl TerminalContent { terminal: None, resize_viewer_id: next_resize_viewer_id(), focus_handle, + window_activation_subscription: None, url_detector: UrlDetector::new(), scrollbar, is_selecting: false, @@ -89,6 +94,7 @@ impl TerminalContent { layout_path, window_id, workspace, + request_broker, scroll_accumulator: 0.0, in_scroll_inertia: false, mouse_down_cell: None, @@ -97,6 +103,41 @@ impl TerminalContent { } } + /// Present the latest parsed terminal state. Calls are already coalesced by + /// the app-wide activity frame, so visible non-key windows stay live without + /// each terminal stream driving an independent repaint clock. + pub fn request_activity_repaint(&mut self, cx: &mut Context<Self>) { + if let Some(terminal) = &self.terminal { + okena_core::latency_probe::client_notify_requested( + &terminal.terminal_id, + self.resize_viewer_id, + ); + } + cx.notify(); + } + + fn bind_window_activation(&mut self, window: &mut Window, cx: &mut Context<Self>) { + if self.window_activation_subscription.is_some() { + return; + } + + let subscription = cx.observe_window_activation(window, |this, window, cx| { + // A GPUI focus handle remains focused when its OS window deactivates. + // Keep the notification-suppression reporter aligned with the exact + // key window. + if let Some(ref terminal) = this.terminal { + terminal.update_focus_reporter( + this.resize_viewer_id, + window.is_window_active() && this.focus_handle.is_focused(window), + ); + } + + // Activation changes update focused styling immediately. + cx.notify(); + }); + self.window_activation_subscription = Some(subscription); + } + fn mouse_modifier_bits(m: &Modifiers) -> u8 { let mut bits = 0u8; if m.shift { @@ -156,10 +197,11 @@ impl TerminalContent { return false; } if let Some(terminal) = self.terminal.as_ref() - && let Some((col, row, _)) = self.pixel_to_cell(event_position) { - let mods = Self::mouse_modifier_bits(modifiers); - terminal.send_mouse_button(button_code, false, col, row as usize, mods); - } + && let Some((col, row, _)) = self.pixel_to_cell(event_position) + { + let mods = Self::mouse_modifier_bits(modifiers); + terminal.send_mouse_button(button_code, false, col, row as usize, mods); + } self.forwarded_button = None; self.mouse_down_cell = None; true @@ -167,7 +209,9 @@ impl TerminalContent { pub fn set_terminal(&mut self, terminal: Option<Arc<Terminal>>, cx: &mut Context<Self>) { if let Some(old_terminal) = self.terminal.as_ref() { - let next_id = terminal.as_ref().map(|terminal| terminal.terminal_id.as_str()); + let next_id = terminal + .as_ref() + .map(|terminal| terminal.terminal_id.as_str()); if next_id != Some(old_terminal.terminal_id.as_str()) { deregister_shared_resize_viewer(&old_terminal.terminal_id, self.resize_viewer_id); old_terminal.remove_focus_reporter(self.resize_viewer_id); @@ -263,7 +307,10 @@ impl TerminalContent { const TERMINAL_PADDING: f32 = 4.0; - fn pixel_to_cell(&self, pos: Point<Pixels>) -> Option<(usize, i32, alacritty_terminal::index::Side)> { + fn pixel_to_cell( + &self, + pos: Point<Pixels>, + ) -> Option<(usize, i32, alacritty_terminal::index::Side)> { let bounds = self.element_bounds?; let terminal = self.terminal.as_ref()?; let (cell_width, cell_height) = terminal.cell_dimensions(); @@ -288,7 +335,12 @@ impl TerminalContent { Some((col, row, side)) } - fn pixel_to_cell_raw(&self, pos: Point<Pixels>, cell_width: f32, cell_height: f32) -> (usize, usize) { + fn pixel_to_cell_raw( + &self, + pos: Point<Pixels>, + cell_width: f32, + cell_height: f32, + ) -> (usize, usize) { if let Some(bounds) = self.element_bounds { let x = (f32::from(pos.x) - f32::from(bounds.origin.x)).max(0.0); let y = (f32::from(pos.y) - f32::from(bounds.origin.y)).max(0.0); @@ -298,6 +350,26 @@ 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<String> { + let clean = super::url_detector::strip_line_col_suffix(raw); + let project_path = self + .workspace + .read(cx) + .project(&self.project_id)? + .path + .clone(); + let cwd = self + .terminal + .as_ref() + .map(|terminal| terminal.current_cwd()) + .unwrap_or_else(|| project_path.clone()); + remote_project_relative_path(clean, &project_path, &cwd) + } + fn handle_mouse_down( &mut self, event: &MouseDownEvent, @@ -312,7 +384,11 @@ impl TerminalContent { self.mouse_down_cell = Some((col, row)); if event.modifiers.platform || event.modifiers.control { - if let Some(uri) = self.terminal.as_ref().and_then(|t| t.hyperlink_at(col, row)) { + if let Some(uri) = self + .terminal + .as_ref() + .and_then(|t| t.hyperlink_at(col, row)) + { UrlDetector::open_url(&uri); self.mouse_down_cell = None; return; @@ -323,8 +399,28 @@ 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; @@ -344,7 +440,11 @@ impl TerminalContent { let same_position = (col as i32 - last_col as i32).abs() <= 1 && (row - last_row).abs() <= 0; if elapsed < 400 && same_position { - if self.click_count >= 3 { 1 } else { self.click_count + 1 } + if self.click_count >= 3 { + 1 + } else { + self.click_count + 1 + } } else { 1 } @@ -450,9 +550,10 @@ impl TerminalContent { if let Some((button, mods)) = self.forwarded_button { if let Some(ref terminal) = self.terminal && terminal.supports_mouse_drag() - && let Some((col, row, _side)) = self.pixel_to_cell(event.position) { - terminal.send_mouse_drag(button, col, row as usize, mods); - } + && let Some((col, row, _side)) = self.pixel_to_cell(event.position) + { + terminal.send_mouse_drag(button, col, row as usize, mods); + } return; } @@ -461,7 +562,10 @@ impl TerminalContent { if let Some(ref terminal) = self.terminal { terminal.end_selection(); if !terminal.has_selection() - || terminal.get_selected_text().map(|s| s.is_empty()).unwrap_or(true) + || terminal + .get_selected_text() + .map(|s| s.is_empty()) + .unwrap_or(true) { terminal.clear_selection(); } @@ -472,10 +576,11 @@ impl TerminalContent { } if let Some(ref terminal) = self.terminal - && let Some((col, row, side)) = self.pixel_to_cell(event.position) { - terminal.update_selection(col, row, side); - cx.notify(); - } + && let Some((col, row, side)) = self.pixel_to_cell(event.position) + { + terminal.update_selection(col, row, side); + cx.notify(); + } } } @@ -486,34 +591,42 @@ impl TerminalContent { } if self.is_selecting - && let Some(ref terminal) = self.terminal { - terminal.end_selection(); - self.is_selecting = false; - - let empty_selection = !terminal.has_selection() - || terminal.get_selected_text().map(|s| s.is_empty()).unwrap_or(true); - - if empty_selection { - terminal.clear_selection(); - - // Click-to-cursor: on a clean single click (no drag), move cursor - if self.click_count == 1 - && let Some((col, row)) = self.mouse_down_cell.take() - && !terminal.is_mouse_mode() && !terminal.is_alt_screen() && !terminal.has_running_child() { - terminal.move_cursor_to_click(col, row); - } + && let Some(ref terminal) = self.terminal + { + terminal.end_selection(); + self.is_selecting = false; + + let empty_selection = !terminal.has_selection() + || terminal + .get_selected_text() + .map(|s| s.is_empty()) + .unwrap_or(true); + + if empty_selection { + terminal.clear_selection(); + + // Click-to-cursor: on a clean single click (no drag), move cursor + if self.click_count == 1 + && let Some((col, row)) = self.mouse_down_cell.take() + && !terminal.is_mouse_mode() + && !terminal.is_alt_screen() + && !terminal.has_running_child() + { + terminal.move_cursor_to_click(col, row); } - cx.notify(); } + cx.notify(); + } // Sync any non-empty selection to PRIMARY so middle-click paste works // for drag, double-click (word), and triple-click (line) selections. #[cfg(target_os = "linux")] if let Some(ref terminal) = self.terminal && let Some(text) = terminal.get_selected_text() - && !text.is_empty() { - cx.write_to_primary(ClipboardItem::new_string(text)); - } + && !text.is_empty() + { + cx.write_to_primary(ClipboardItem::new_string(text)); + } self.mouse_down_cell = None; } @@ -521,8 +634,10 @@ impl TerminalContent { impl Render for TerminalContent { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + self.bind_window_activation(window, cx); + let t = theme(cx); - let is_focused = self.focus_handle.is_focused(window); + let is_focused = window.is_window_active() && self.focus_handle.is_focused(window); if let Some(ref terminal) = self.terminal { terminal.update_focus_reporter(self.resize_viewer_id, is_focused); @@ -560,23 +675,30 @@ impl Render for TerminalContent { None => base_bg, }; - self.url_detector.update_matches(&self.terminal); + let validate_paths_locally = self + .workspace + .read(cx) + .is_local_daemon_project(&self.project_id); + self.url_detector + .update_matches(&self.terminal, validate_paths_locally); let Some(ref terminal) = self.terminal else { return div() - .flex_1() - .min_h(px(200.0)) + .size_full() .flex() .items_center() .justify_center() .text_color(rgb(t.text_muted)) - .child("Creating terminal...") + .child("Starting terminal\u{2026}") .into_any_element(); }; let terminal_clone = terminal.clone(); let focus_handle = self.focus_handle.clone(); - let zoom_level = self.workspace.read(cx).get_terminal_zoom(&self.project_id, &self.layout_path); + let zoom_level = self + .workspace + .read(cx) + .get_terminal_zoom(&self.project_id, &self.layout_path); let element_bounds_setter = { let entity = cx.entity().downgrade(); @@ -586,7 +708,13 @@ impl Render for TerminalContent { let fh = self.focus_handle.clone(); move |bounds: Bounds<Pixels>, _window: &mut Window, cx: &mut App| { if let Some(window_id) = window_id { - register_pane_bounds(window_id, project_id.clone(), layout_path.clone(), bounds, Some(fh.clone())); + register_pane_bounds( + window_id, + project_id.clone(), + layout_path.clone(), + bounds, + Some(fh.clone()), + ); } if let Some(entity) = entity.upgrade() { @@ -660,7 +788,10 @@ impl Render for TerminalContent { this.in_scroll_inertia && matches!(event.delta, ScrollDelta::Pixels(_)); if event.modifiers.control && !is_inertial_momentum { - let current_zoom = this.workspace.read(cx).get_terminal_zoom(&this.project_id, &this.layout_path); + let current_zoom = this + .workspace + .read(cx) + .get_terminal_zoom(&this.project_id, &this.layout_path); let zoom_delta = if f32::from(delta.y) > 0.0 { 0.1 } else { -0.1 }; let new_zoom = (current_zoom + zoom_delta).clamp(0.5, 3.0); let project_id = this.project_id.clone(); @@ -669,7 +800,12 @@ impl Render for TerminalContent { workspace.set_terminal_zoom(&project_id, &layout_path, new_zoom, cx); }); } else { - this.handle_scroll(f32::from(delta.y), event.position, event.modifiers.shift, cx); + this.handle_scroll( + f32::from(delta.y), + event.position, + event.modifiers.shift, + cx, + ); } })) .on_mouse_down( @@ -679,12 +815,19 @@ impl Render for TerminalContent { cx.notify(); return; } - let has_selection = this.terminal.as_ref().map(|t| t.has_selection()).unwrap_or(false); - let link_url = this.pixel_to_cell(event.position).and_then(|(col, row, _side)| { - this.url_detector.find_at(col, row) - .filter(|m| m.kind == LinkKind::Url) - .map(|m| m.url) - }); + let has_selection = this + .terminal + .as_ref() + .map(|t| t.has_selection()) + .unwrap_or(false); + let link_url = + this.pixel_to_cell(event.position) + .and_then(|(col, row, _side)| { + this.url_detector + .find_at(col, row) + .filter(|m| m.kind == LinkKind::Url) + .map(|m| m.url) + }); cx.emit(TerminalContentEvent::RequestContextMenu { position: event.position, has_selection, @@ -725,24 +868,24 @@ impl Render for TerminalContent { } }), ) - .child(canvas(element_bounds_setter, |_, _, _, _| {}).absolute().size_full()) .child( - div() - .size_full() - .p(px(4.0)) - .bg(rgb(term_bg)) - .child( - TerminalElement::new(terminal_clone, focus_handle, self.resize_viewer_id) - .with_zoom(zoom_level) - .with_bg_tint(bg_tint) - .with_search(self.search_matches.clone(), self.search_current_index) - .with_urls( - self.url_detector.matches_arc(), - self.url_detector.hovered_group(), - ) - .with_cursor_visible(self.cursor_visible) - .with_cursor_style(render_settings.cursor_style), - ), + canvas(element_bounds_setter, |_, _, _, _| {}) + .absolute() + .size_full(), + ) + .child( + div().size_full().p(px(4.0)).bg(rgb(term_bg)).child( + TerminalElement::new(terminal_clone, focus_handle, self.resize_viewer_id) + .with_zoom(zoom_level) + .with_bg_tint(bg_tint) + .with_search(self.search_matches.clone(), self.search_current_index) + .with_urls( + self.url_detector.matches_arc(), + self.url_detector.hovered_group(), + ) + .with_cursor_visible(self.cursor_visible) + .with_cursor_style(render_settings.cursor_style), + ), ) .child(self.scrollbar.clone()) .into_any_element() @@ -758,6 +901,65 @@ impl Drop for TerminalContent { impl EventEmitter<TerminalContentEvent> for TerminalContent {} +fn remote_project_relative_path(raw: &str, project_path: &str, cwd: &str) -> Option<String> { + if raw.starts_with('~') { + return None; + } + + let raw = raw.replace('\\', "/"); + let project_path = project_path.replace('\\', "/"); + let cwd = cwd.replace('\\', "/"); + let raw_is_absolute = is_absolute_path(&raw); + let candidate = if raw_is_absolute { + raw + } else if cwd.is_empty() { + format!("{project_path}/{raw}") + } else { + format!("{cwd}/{raw}") + }; + + let project_parts = normalize_path_parts(&project_path)?; + let candidate_parts = normalize_path_parts(&candidate)?; + let windows_path = + project_path.as_bytes().get(1) == Some(&b':') || project_path.starts_with("//"); + if candidate_parts.len() <= project_parts.len() + || !candidate_parts + .iter() + .zip(&project_parts) + .all(|(candidate, project)| { + if windows_path { + candidate.eq_ignore_ascii_case(project) + } else { + candidate == project + } + }) + { + return None; + } + + Some(candidate_parts[project_parts.len()..].join("/")) +} + +fn is_absolute_path(path: &str) -> bool { + path.starts_with('/') + || path.starts_with("//") + || (path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/')) +} + +fn normalize_path_parts(path: &str) -> Option<Vec<&str>> { + let mut parts = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop()?; + } + _ => parts.push(part), + } + } + Some(parts) +} + /// Lines to scroll for drag-selection auto-scroll, given the pointer's `y` and /// the terminal content's `top`/`bottom` edges (all window-space pixels). /// @@ -781,7 +983,7 @@ fn autoscroll_lines(y: f32, top: f32, bottom: f32, cell_height: f32) -> i32 { #[cfg(test)] mod tests { - use super::autoscroll_lines; + use super::{autoscroll_lines, remote_project_relative_path}; const CELL: f32 = 16.0; const TOP: f32 = 100.0; @@ -820,4 +1022,48 @@ mod tests { fn zero_cell_height_is_safe() { assert_eq!(autoscroll_lines(TOP - 50.0, TOP, BOTTOM, 0.0), 0); } + + #[test] + fn remote_absolute_path_is_made_project_relative() { + assert_eq!( + remote_project_relative_path( + "/srv/project/src/main.rs", + "/srv/project", + "/srv/project" + ), + Some("src/main.rs".to_string()) + ); + } + + #[test] + fn remote_relative_path_uses_terminal_cwd() { + assert_eq!( + remote_project_relative_path("../shared.rs", "/srv/project", "/srv/project/src/bin"), + Some("src/shared.rs".to_string()) + ); + } + + #[test] + fn remote_windows_paths_are_normalized_without_client_path_rules() { + assert_eq!( + remote_project_relative_path( + r"C:\work\project\src\main.rs", + r"c:\work\project", + r"C:\work\project" + ), + Some("src/main.rs".to_string()) + ); + } + + #[test] + fn remote_path_outside_project_is_rejected() { + assert_eq!( + remote_project_relative_path("/etc/passwd", "/srv/project", "/srv/project"), + None + ); + assert_eq!( + remote_project_relative_path("../../../etc/passwd", "/srv/project", "/srv/project"), + 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..bc8cf9720 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs @@ -1,13 +1,13 @@ //! Terminal pane view - composition of child entity views. -pub mod url_detector; -mod scrollbar; -mod search_bar; -mod content; mod actions; -mod zoom; +mod content; mod navigation; mod render; +mod scrollbar; +mod search_bar; +pub mod url_detector; +mod zoom; use content::TerminalContentEvent; use search_bar::{SearchBar, SearchBarEvent}; @@ -16,15 +16,14 @@ pub use content::TerminalContent; use crate::ActionDispatch; use crate::terminal_view_settings; +use gpui::*; +use okena_terminal::TerminalsRegistry; use okena_terminal::backend::TerminalBackend; 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::*; use std::sync::Arc; use std::time::Duration; @@ -97,13 +96,15 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { project_id.clone(), layout_path.clone(), workspace.clone(), + request_broker.clone(), cx, ) }); let search_bar = cx.new(|cx| SearchBar::new(workspace.clone(), focus_manager.clone(), cx)); - cx.subscribe(&search_bar, Self::handle_search_bar_event).detach(); + cx.subscribe(&search_bar, Self::handle_search_bar_event) + .detach(); cx.subscribe(&content, Self::handle_content_event).detach(); let mut pane = Self { @@ -136,9 +137,6 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { pane.create_new_terminal(cx); } - if pane.terminal_id.as_deref().is_some_and(|id| id.starts_with("remote:")) { - pane.start_remote_dirty_check_loop(cx); - } pane.start_cursor_blink_loop(cx); pane.start_idle_check_loop(cx); @@ -175,7 +173,11 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { cx: &mut Context<Self>, ) { match event { - TerminalContentEvent::RequestContextMenu { position, has_selection, link_url } => { + TerminalContentEvent::RequestContextMenu { + position, + has_selection, + link_url, + } => { if let Some(ref terminal_id) = self.terminal_id { self.request_broker.update(cx, |broker, cx| { broker.push_overlay_request( @@ -197,33 +199,6 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { } } - fn start_remote_dirty_check_loop(&self, cx: &mut Context<Self>) { - cx.spawn(async move |this: WeakEntity<TerminalPane<D>>, cx| { - let interval = Duration::from_millis(8); - loop { - smol::Timer::after(interval).await; - let result = this.update(cx, |pane, cx| { - if let Some(terminal) = pane.terminal.as_ref() - && terminal.take_dirty() { - // Parse the freshly-arrived bytes up front so derived - // state (bell, waiting) is current before the frame is - // built. Otherwise the lazy parse inside the content - // child's `with_content` runs *after* the pane border - // and sidebar read `has_bell()`, leaving those server- - // driven indicators stale until local input forces a - // second repaint (issue #128). - terminal.process_pending_output(); - pane.content.update(cx, |_, cx| cx.notify()); - } - }); - if result.is_err() { - break; - } - } - }) - .detach(); - } - fn start_cursor_blink_loop(&self, cx: &mut Context<Self>) { cx.spawn(async move |this: WeakEntity<TerminalPane<D>>, cx| { let interval = Duration::from_millis(500); @@ -315,7 +290,9 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { if was_waiting { was_waiting = false; terminal.set_waiting_for_input(false); - let _ = this.update(cx, |_pane, cx| { cx.notify(); }); + let _ = this.update(cx, |_pane, cx| { + cx.notify(); + }); } continue; } @@ -354,7 +331,8 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { let settings = terminal_view_settings(cx); let ws = self.workspace.read(cx); let shell = self.shell_type.clone().resolve_default( - ws.project(&self.project_id).and_then(|p| p.default_shell.as_ref()), + ws.project(&self.project_id) + .and_then(|p| p.default_shell.as_ref()), &settings.default_shell, ); @@ -369,7 +347,12 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { } let size = TerminalSize::default(); - let terminal = Arc::new(Terminal::new(terminal_id.clone(), size, self.backend.transport(), self.project_path.clone())); + let terminal = Arc::new(Terminal::new( + terminal_id.clone(), + size, + self.backend.transport(), + self.project_path.clone(), + )); if let Some(pid) = self.backend.get_foreground_shell_pid(&terminal_id) { terminal.set_shell_pid(pid); } @@ -378,85 +361,16 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { self.update_child_terminals(terminal, cx); } - fn create_new_terminal(&mut self, cx: &mut Context<Self>) { - 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<Self>) { + 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<Terminal>, cx: &mut Context<Self>) { - crate::register_content_pane( - terminal.terminal_id.clone(), - self.content.downgrade(), - ); + crate::register_content_pane(terminal.terminal_id.clone(), self.content.downgrade()); self.content.update(cx, |content, cx| { content.set_terminal(Some(terminal.clone()), cx); @@ -509,14 +423,17 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { ) }) } - } impl<D: ActionDispatch> Drop for TerminalPane<D> { fn drop(&mut self) { // Remove this pane from the spatial navigation map so stale entries // don't linger after a terminal is closed. - crate::layout::navigation::deregister_pane_bounds(self.window_id, &self.project_id, &self.layout_path); + crate::layout::navigation::deregister_pane_bounds( + self.window_id, + &self.project_id, + &self.layout_path, + ); } } diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs b/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs index 976beb7fb..9cded6b0d 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs @@ -1,10 +1,10 @@ //! Terminal pane navigation, search, and key handling. use crate::ActionDispatch; +use crate::layout::navigation::{NavigationDirection, PaneBounds, get_pane_map}; +use gpui::*; use okena_terminal::input::{KeyEvent, KeyModifiers, key_to_bytes}; -use crate::layout::navigation::{get_pane_map, PaneBounds, NavigationDirection}; use okena_workspace::state::LayoutNode; -use gpui::*; use super::TerminalPane; @@ -46,7 +46,11 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { return false; } - let new_tab = if next { current_tab_index + 1 } else { current_tab_index - 1 }; + let new_tab = if next { + current_tab_index + 1 + } else { + current_tab_index - 1 + }; let project_id = self.project_id.clone(); let mut new_layout_path = parent_path.to_vec(); new_layout_path.push(new_tab); @@ -54,7 +58,12 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { let workspace = self.workspace.clone(); self.focus_manager.update(cx, |fm, cx| { workspace.update(cx, |ws, cx| { - ws.set_active_tab(&project_id, &new_layout_path[..new_layout_path.len() - 1], new_tab, cx); + ws.set_active_tab( + &project_id, + &new_layout_path[..new_layout_path.len() - 1], + new_tab, + cx, + ); ws.set_focused_terminal(fm, project_id, new_layout_path, cx); }); cx.notify(); @@ -69,7 +78,10 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { cx: &mut Context<Self>, ) { // Left/Right: try switching tabs first, fall through to spatial nav at edges - if matches!(direction, NavigationDirection::Left | NavigationDirection::Right) { + if matches!( + direction, + NavigationDirection::Left | NavigationDirection::Right + ) { let next = matches!(direction, NavigationDirection::Right); if self.try_switch_tab(next, cx) { return; @@ -188,9 +200,10 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { && !terminal.is_mouse_mode() && !terminal.is_alt_screen() && !terminal.has_running_child() - && terminal.delete_selection() { - return; - } + && terminal.delete_selection() + { + return; + } // Opt-in: Ctrl+C copies selection (and clears it) instead of sending SIGINT. // Without a (non-empty) selection, falls through to the normal Ctrl+C → SIGINT path. @@ -202,12 +215,13 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { && !event.keystroke.modifiers.platform && crate::terminal_view_settings(cx).ctrl_c_copies_selection && let Some(text) = terminal.get_selected_text() - && !text.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - terminal.clear_selection(); - cx.notify(); - return; - } + && !text.is_empty() + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + terminal.clear_selection(); + cx.notify(); + return; + } let app_cursor_mode = terminal.is_app_cursor_mode(); let kitty = terminal.kitty_keyboard_flags(); diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/render.rs b/crates/okena-views-terminal/src/layout/terminal_pane/render.rs index 355857c24..1f11c208a 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/render.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/render.rs @@ -1,21 +1,20 @@ //! Render implementation for TerminalPane. use crate::ActionDispatch; -use okena_core::api::ActionRequest; use crate::actions::{ AddTab, CloseSearch, CloseTerminal, Copy, FocusDown, FocusLeft, FocusNextTerminal, FocusPrevTerminal, FocusRight, FocusUp, FullscreenNextTerminal, FullscreenPrevTerminal, JumpToNextFailedCommand, JumpToNextPrompt, JumpToPreviousFailedCommand, JumpToPreviousPrompt, - MinimizeTerminal, Paste, ResetZoom, Search, - SearchNext, SearchPrev, SendBacktab, SendEscape, SendTab, SplitHorizontal, SplitVertical, - ToggleFullscreen, ZoomIn, ZoomOut, + MinimizeTerminal, Paste, ResetZoom, Search, SearchNext, SearchPrev, SendBacktab, SendEscape, + SendTab, SplitHorizontal, SplitVertical, ToggleFullscreen, ZoomIn, ZoomOut, }; -use crate::terminal_view_settings; -use okena_files::theme::theme; use crate::layout::navigation::NavigationDirection; -use okena_workspace::state::SplitDirection; +use crate::terminal_view_settings; use gpui::prelude::FluentBuilder; use gpui::*; +use okena_core::api::ActionRequest; +use okena_files::theme::theme; +use okena_workspace::state::SplitDirection; use super::TerminalPane; @@ -24,7 +23,8 @@ impl<D: ActionDispatch + Send + Sync> Render for TerminalPane<D> { let t = theme(cx); // Refresh search results if terminal content changed (scroll, new output) - self.search_bar.update(cx, |bar, cx| bar.refresh_if_needed(cx)); + self.search_bar + .update(cx, |bar, cx| bar.refresh_if_needed(cx)); if self.minimized || self.detached { self.deregister_resize_viewer(cx); } @@ -40,62 +40,69 @@ impl<D: ActionDispatch + Send + Sync> Render for TerminalPane<D> { if !search_active && !is_modal { if let Some(focused) = fm.focused_terminal_state() && focused.project_id == self.project_id - && focused.layout_path == self.layout_path - && !focus_handle.is_focused(window) - { - self.pending_focus = true; - } + && focused.layout_path == self.layout_path + && !focus_handle.is_focused(window) + { + self.pending_focus = true; + } if let Some(ref tid) = self.terminal_id && fm.is_terminal_fullscreened(&self.project_id, tid) - && !focus_handle.is_focused(window) - { - self.pending_focus = true; - } + && !focus_handle.is_focused(window) + { + self.pending_focus = true; + } } is_modal }; let search_active = self.search_bar.read(cx).is_active(); - if self.pending_focus - && self.terminal.is_some() - && !search_active - && !is_modal - { + if self.pending_focus && self.terminal.is_some() && !search_active && !is_modal { self.pending_focus = false; window.focus(&self.focus_handle, cx); } - let is_focused = focus_handle.is_focused(window); + let is_focused = window.is_window_active() && focus_handle.is_focused(window); let has_bell = self.terminal.as_ref().is_some_and(|t| t.has_bell()); - if is_focused && has_bell - && let Some(ref terminal) = self.terminal { - terminal.clear_bell(); - } + if is_focused + && has_bell + && let Some(ref terminal) = self.terminal + { + terminal.clear_bell(); + } let has_notification = self.terminal.as_ref().is_some_and(|t| t.has_notification()); - if is_focused && has_notification - && let Some(ref terminal) = self.terminal { - terminal.clear_notification(); - } + if is_focused + && has_notification + && let Some(ref terminal) = self.terminal + { + terminal.clear_notification(); + } if is_focused && let Some(ref terminal) = self.terminal - && terminal.is_waiting_for_input() { - terminal.clear_waiting(); - } + && terminal.is_waiting_for_input() + { + terminal.clear_waiting(); + } - if self.was_focused && !is_focused - && let Some(ref terminal) = self.terminal { - terminal.mark_as_viewed(); - } + if self.was_focused + && !is_focused + && let Some(ref terminal) = self.terminal + { + terminal.mark_as_viewed(); + } self.was_focused = is_focused; let show_focused_border = terminal_view_settings(cx).show_focused_border; - let is_waiting = !is_focused && self.terminal.as_ref() - .is_some_and(|t| t.is_waiting_for_input()); - let show_border = (is_focused && show_focused_border) || has_bell || has_notification || is_waiting; + let is_waiting = !is_focused + && self + .terminal + .as_ref() + .is_some_and(|t| t.is_waiting_for_input()); + let show_border = + (is_focused && show_focused_border) || has_bell || has_notification || is_waiting; // OSC 9/777 notifications share the bell's attention color. let border_color = if is_focused && show_focused_border { rgb(t.border_focused) @@ -131,61 +138,151 @@ impl<D: ActionDispatch + Send + Sync> Render for TerminalPane<D> { }); }), ) - .on_action(cx.listener(|this, _: &SplitVertical, _window, cx| { this.handle_split(SplitDirection::Vertical, cx); })) - .on_action(cx.listener(|this, _: &SplitHorizontal, _window, cx| { this.handle_split(SplitDirection::Horizontal, cx); })) - .on_action(cx.listener(|this, _: &AddTab, _window, cx| { this.handle_add_tab(cx); })) - .on_action(cx.listener(|this, _: &CloseTerminal, _window, cx| { this.handle_close(cx); })) - .on_action(cx.listener(|this, _: &MinimizeTerminal, _window, cx| { this.handle_minimize(cx); })) - .on_action(cx.listener(|this, _: &Copy, _window, cx| { this.handle_copy(cx); })) - .on_action(cx.listener(|this, _: &Paste, _window, cx| { this.handle_paste(cx); })) - .on_action(cx.listener(|this, _: &Search, window, cx| { if !this.search_bar.read(cx).is_active() { this.start_search(window, cx); } })) - .on_action(cx.listener(|this, _: &CloseSearch, _window, cx| { if this.search_bar.read(cx).is_active() { this.close_search(cx); } })) - .on_action(cx.listener(|this, _: &SearchNext, _window, cx| { this.next_match(cx); })) - .on_action(cx.listener(|this, _: &SearchPrev, _window, cx| { this.prev_match(cx); })) - .on_action(cx.listener(|this, _: &JumpToPreviousPrompt, _window, cx| { this.handle_jump_prev_prompt(cx); })) - .on_action(cx.listener(|this, _: &JumpToNextPrompt, _window, cx| { this.handle_jump_next_prompt(cx); })) - .on_action(cx.listener(|this, _: &JumpToPreviousFailedCommand, _window, cx| { this.handle_jump_prev_failed(cx); })) - .on_action(cx.listener(|this, _: &JumpToNextFailedCommand, _window, cx| { this.handle_jump_next_failed(cx); })) - .on_action(cx.listener(|this, _: &FocusLeft, window, cx| { this.handle_navigation(NavigationDirection::Left, window, cx); })) - .on_action(cx.listener(|this, _: &FocusRight, window, cx| { this.handle_navigation(NavigationDirection::Right, window, cx); })) - .on_action(cx.listener(|this, _: &FocusUp, window, cx| { this.handle_navigation(NavigationDirection::Up, window, cx); })) - .on_action(cx.listener(|this, _: &FocusDown, window, cx| { this.handle_navigation(NavigationDirection::Down, window, cx); })) - .on_action(cx.listener(|this, _: &FocusNextTerminal, window, cx| { this.handle_sequential_navigation(true, window, cx); })) - .on_action(cx.listener(|this, _: &FocusPrevTerminal, window, cx| { this.handle_sequential_navigation(false, window, cx); })) - .on_action(cx.listener(|this, _: &SendTab, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_tab(); } })) - .on_action(cx.listener(|this, _: &SendBacktab, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_backtab(); } })) - .on_action(cx.listener(|this, _: &SendEscape, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_escape(); } })) + .on_action(cx.listener(|this, _: &SplitVertical, _window, cx| { + this.handle_split(SplitDirection::Vertical, cx); + })) + .on_action(cx.listener(|this, _: &SplitHorizontal, _window, cx| { + this.handle_split(SplitDirection::Horizontal, cx); + })) + .on_action(cx.listener(|this, _: &AddTab, _window, cx| { + this.handle_add_tab(cx); + })) + .on_action(cx.listener(|this, _: &CloseTerminal, _window, cx| { + this.handle_close(cx); + })) + .on_action(cx.listener(|this, _: &MinimizeTerminal, _window, cx| { + this.handle_minimize(cx); + })) + .on_action(cx.listener(|this, _: &Copy, _window, cx| { + this.handle_copy(cx); + })) + .on_action(cx.listener(|this, _: &Paste, _window, cx| { + this.handle_paste(cx); + })) + .on_action(cx.listener(|this, _: &Search, window, cx| { + if !this.search_bar.read(cx).is_active() { + this.start_search(window, cx); + } + })) + .on_action(cx.listener(|this, _: &CloseSearch, _window, cx| { + if this.search_bar.read(cx).is_active() { + this.close_search(cx); + } + })) + .on_action(cx.listener(|this, _: &SearchNext, _window, cx| { + this.next_match(cx); + })) + .on_action(cx.listener(|this, _: &SearchPrev, _window, cx| { + this.prev_match(cx); + })) + .on_action(cx.listener(|this, _: &JumpToPreviousPrompt, _window, cx| { + this.handle_jump_prev_prompt(cx); + })) + .on_action(cx.listener(|this, _: &JumpToNextPrompt, _window, cx| { + this.handle_jump_next_prompt(cx); + })) + .on_action( + cx.listener(|this, _: &JumpToPreviousFailedCommand, _window, cx| { + this.handle_jump_prev_failed(cx); + }), + ) + .on_action( + cx.listener(|this, _: &JumpToNextFailedCommand, _window, cx| { + this.handle_jump_next_failed(cx); + }), + ) + .on_action(cx.listener(|this, _: &FocusLeft, window, cx| { + this.handle_navigation(NavigationDirection::Left, window, cx); + })) + .on_action(cx.listener(|this, _: &FocusRight, window, cx| { + this.handle_navigation(NavigationDirection::Right, window, cx); + })) + .on_action(cx.listener(|this, _: &FocusUp, window, cx| { + this.handle_navigation(NavigationDirection::Up, window, cx); + })) + .on_action(cx.listener(|this, _: &FocusDown, window, cx| { + this.handle_navigation(NavigationDirection::Down, window, cx); + })) + .on_action(cx.listener(|this, _: &FocusNextTerminal, window, cx| { + this.handle_sequential_navigation(true, window, cx); + })) + .on_action(cx.listener(|this, _: &FocusPrevTerminal, window, cx| { + this.handle_sequential_navigation(false, window, cx); + })) + .on_action(cx.listener(|this, _: &SendTab, _window, _cx| { + if let Some(ref terminal) = this.terminal { + terminal.send_tab(); + } + })) + .on_action(cx.listener(|this, _: &SendBacktab, _window, _cx| { + if let Some(ref terminal) = this.terminal { + terminal.send_backtab(); + } + })) + .on_action(cx.listener(|this, _: &SendEscape, _window, _cx| { + if let Some(ref terminal) = this.terminal { + terminal.send_escape(); + } + })) .on_action(cx.listener(|this, _: &ZoomIn, _window, cx| { - let current = this.workspace.read(cx).get_terminal_zoom(&this.project_id, &this.layout_path); + let current = this + .workspace + .read(cx) + .get_terminal_zoom(&this.project_id, &this.layout_path); let new_zoom = (current + 0.1).clamp(0.5, 3.0); let project_id = this.project_id.clone(); let layout_path = this.layout_path.clone(); - this.workspace.update(cx, |ws, cx| { ws.set_terminal_zoom(&project_id, &layout_path, new_zoom, cx); }); + this.workspace.update(cx, |ws, cx| { + ws.set_terminal_zoom(&project_id, &layout_path, new_zoom, cx); + }); })) .on_action(cx.listener(|this, _: &ZoomOut, _window, cx| { - let current = this.workspace.read(cx).get_terminal_zoom(&this.project_id, &this.layout_path); + let current = this + .workspace + .read(cx) + .get_terminal_zoom(&this.project_id, &this.layout_path); let new_zoom = (current - 0.1).clamp(0.5, 3.0); let project_id = this.project_id.clone(); let layout_path = this.layout_path.clone(); - this.workspace.update(cx, |ws, cx| { ws.set_terminal_zoom(&project_id, &layout_path, new_zoom, cx); }); + this.workspace.update(cx, |ws, cx| { + ws.set_terminal_zoom(&project_id, &layout_path, new_zoom, cx); + }); })) .on_action(cx.listener(|this, _: &ResetZoom, _window, cx| { let project_id = this.project_id.clone(); let layout_path = this.layout_path.clone(); - this.workspace.update(cx, |ws, cx| { ws.set_terminal_zoom(&project_id, &layout_path, 1.0, cx); }); + this.workspace.update(cx, |ws, cx| { + ws.set_terminal_zoom(&project_id, &layout_path, 1.0, cx); + }); })) .on_action(cx.listener(|this, _: &ToggleFullscreen, _window, cx| { let is_fullscreen = this.focus_manager.read(cx).has_fullscreen(); if is_fullscreen { - let action = ActionRequest::SetFullscreen { project_id: this.project_id.clone(), terminal_id: None, window: None }; - if let Some(ref dispatcher) = this.action_dispatcher { dispatcher.dispatch(action, cx); } + let action = ActionRequest::SetFullscreen { + project_id: this.project_id.clone(), + terminal_id: None, + window: None, + }; + if let Some(ref dispatcher) = this.action_dispatcher { + dispatcher.dispatch(action, cx); + } } else { this.handle_fullscreen(cx); } })) - .on_action(cx.listener(|this, _: &FullscreenNextTerminal, _window, cx| { this.handle_zoom_next_terminal(cx); })) - .on_action(cx.listener(|this, _: &FullscreenPrevTerminal, _window, cx| { this.handle_zoom_prev_terminal(cx); })) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { this.handle_key(event, cx); })) + .on_action( + cx.listener(|this, _: &FullscreenNextTerminal, _window, cx| { + this.handle_zoom_next_terminal(cx); + }), + ) + .on_action( + cx.listener(|this, _: &FullscreenPrevTerminal, _window, cx| { + this.handle_zoom_prev_terminal(cx); + }), + ) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + this.handle_key(event, cx); + })) .on_click(cx.listener(|this, _, window, cx| { window.focus(&this.focus_handle, cx); let project_id = this.project_id.clone(); @@ -198,7 +295,9 @@ impl<D: ActionDispatch + Send + Sync> Render for TerminalPane<D> { cx.notify(); }); })) - .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { this.handle_file_drop(paths, cx); })) + .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { + this.handle_file_drop(paths, cx); + })) .flex() .flex_col() .size_full() @@ -216,11 +315,18 @@ impl<D: ActionDispatch + Send + Sync> Render for TerminalPane<D> { .min_w_0() .overflow_hidden() .relative() - .child(AnyView::from(self.content.clone()).cached( - StyleRefinement::default().size_full() - )) + .child( + AnyView::from(self.content.clone()) + .cached(StyleRefinement::default().size_full()), + ) .when(show_border, |el| { - el.child(div().absolute().inset_0().border_1().border_color(border_color)) + el.child( + div() + .absolute() + .inset_0() + .border_1() + .border_color(border_color), + ) }), ) }) diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/scrollbar.rs b/crates/okena-views-terminal/src/layout/terminal_pane/scrollbar.rs index cdb6b0982..5deacb556 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/scrollbar.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/scrollbar.rs @@ -1,8 +1,8 @@ //! Scrollbar component for terminal pane. -use okena_terminal::terminal::Terminal; -use okena_files::theme::theme; use gpui::*; +use okena_files::theme::theme; +use okena_terminal::terminal::Terminal; use std::sync::Arc; use std::time::Instant; @@ -180,11 +180,12 @@ impl Scrollbar { cx.notify(); } } else if relative_y > content_height - zone - && let Some(ref terminal) = self.terminal { - terminal.scroll_to(0); - self.last_activity = Instant::now(); - cx.notify(); - } + && let Some(ref terminal) = self.terminal + { + terminal.scroll_to(0); + self.last_activity = Instant::now(); + cx.notify(); + } } } @@ -240,10 +241,11 @@ impl Render for Scrollbar { ) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { if this.dragging - && let Some(bounds) = this.element_bounds { - let content_height = f32::from(bounds.size.height); - this.update_drag(f32::from(event.position.y), content_height, cx); - } + && let Some(bounds) = this.element_bounds + { + let content_height = f32::from(bounds.size.height); + this.update_drag(f32::from(event.position.y), content_height, cx); + } })) .on_mouse_up( MouseButton::Left, @@ -265,16 +267,22 @@ impl Render for Scrollbar { }, { let entity = cx.entity().downgrade(); - move |bounds: Bounds<Pixels>, _state: (), window: &mut Window, _cx: &mut App| { + move |bounds: Bounds<Pixels>, + _state: (), + window: &mut Window, + _cx: &mut App| { if let Some(ref terminal) = terminal_clone { - let (total_lines, visible_lines, display_offset) = terminal.scroll_info(); + let (total_lines, visible_lines, display_offset) = + terminal.scroll_info(); if total_lines > visible_lines { let track_height = f32::from(bounds.size.height); let scrollable_lines = total_lines - visible_lines; - let thumb_height = - (visible_lines as f32 / total_lines as f32 * track_height).max(20.0); + let thumb_height = (visible_lines as f32 / total_lines as f32 + * track_height) + .max(20.0); let available_scroll_space = track_height - thumb_height; - let scroll_ratio = display_offset as f32 / scrollable_lines as f32; + let scroll_ratio = + display_offset as f32 / scrollable_lines as f32; let thumb_y = (1.0 - scroll_ratio) * available_scroll_space; let thumb_color = if dragging { @@ -284,10 +292,15 @@ impl Render for Scrollbar { }; let thumb_bounds = Bounds { - origin: point(bounds.origin.x + px(2.0), bounds.origin.y + px(thumb_y)), + origin: point( + bounds.origin.x + px(2.0), + bounds.origin.y + px(thumb_y), + ), size: size(px(6.0), px(thumb_height)), }; - window.paint_quad(fill(thumb_bounds, thumb_color).corner_radii(px(3.0))); + window.paint_quad( + fill(thumb_bounds, thumb_color).corner_radii(px(3.0)), + ); } } @@ -302,21 +315,27 @@ impl Render for Scrollbar { } if let Some(entity) = entity.upgrade() { entity.update(cx, |this, cx| { - this.update_drag(f32::from(event.position.y), content_height, cx); + this.update_drag( + f32::from(event.position.y), + content_height, + cx, + ); }); } } }); - window.on_mouse_event(move |_: &MouseUpEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if let Some(entity) = entity.upgrade() { - entity.update(cx, |this, cx| { - this.end_drag(cx); - }); - } - }); + window.on_mouse_event( + move |_: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, cx| { + this.end_drag(cx); + }); + } + }, + ); } } }, diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/search_bar.rs b/crates/okena-views-terminal/src/layout/terminal_pane/search_bar.rs index f30b5304a..989e6ad24 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/search_bar.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/search_bar.rs @@ -1,17 +1,17 @@ //! Search bar component for terminal pane. -use crate::elements::terminal_element::SearchMatch; use crate::actions::CloseSearch; -use okena_terminal::terminal::Terminal; -use okena_files::theme::theme; -use okena_ui::tokens::ui_text_md; +use crate::elements::terminal_element::SearchMatch; use crate::simple_input::{SimpleInput, SimpleInputState}; -use okena_ui::simple_input::InputChangedEvent; -use okena_workspace::focus::FocusManager; -use okena_workspace::state::Workspace; use gpui::prelude::FluentBuilder; use gpui::*; +use okena_files::theme::theme; +use okena_terminal::terminal::Terminal; use okena_ui::icon_button::icon_button_sized; +use okena_ui::simple_input::InputChangedEvent; +use okena_ui::tokens::ui_text_md; +use okena_workspace::focus::FocusManager; +use okena_workspace::state::Workspace; use std::sync::Arc; #[derive(Clone)] @@ -36,7 +36,11 @@ pub struct SearchBar { } impl SearchBar { - pub fn new(workspace: Entity<Workspace>, focus_manager: Entity<FocusManager>, _cx: &mut Context<Self>) -> Self { + pub fn new( + workspace: Entity<Workspace>, + focus_manager: Entity<FocusManager>, + _cx: &mut Context<Self>, + ) -> Self { Self { workspace, focus_manager, @@ -71,7 +75,8 @@ impl SearchBar { }); cx.subscribe(&input, |this: &mut Self, _, _: &InputChangedEvent, cx| { this.perform_search(cx); - }).detach(); + }) + .detach(); self.input = Some(input); self.matches = Arc::new(Vec::new()); self.current_match_index = None; @@ -101,7 +106,11 @@ impl SearchBar { } pub fn perform_search(&mut self, cx: &mut Context<Self>) { - let query = self.input.as_ref().map(|i| i.read(cx).value().to_string()).unwrap_or_default(); + let query = self + .input + .as_ref() + .map(|i| i.read(cx).value().to_string()) + .unwrap_or_default(); if let Some(ref terminal) = self.terminal { self.last_search_generation = terminal.content_generation(); @@ -111,7 +120,11 @@ impl SearchBar { .map(|(line, col, len)| SearchMatch { line, col, len }) .collect(); - self.current_match_index = if !search_matches.is_empty() { Some(0) } else { None }; + self.current_match_index = if !search_matches.is_empty() { + Some(0) + } else { + None + }; self.matches = Arc::new(search_matches); cx.emit(SearchBarEvent::MatchesChanged( @@ -124,7 +137,9 @@ impl SearchBar { /// Re-run search if terminal content has changed since last search. pub fn refresh_if_needed(&mut self, cx: &mut Context<Self>) { - if !self.is_active { return; } + if !self.is_active { + return; + } if let Some(ref terminal) = self.terminal { let current_gen = terminal.content_generation(); if current_gen != self.last_search_generation { @@ -144,49 +159,72 @@ impl SearchBar { } pub fn next_match(&mut self, cx: &mut Context<Self>) { - if self.matches.is_empty() { return; } + if self.matches.is_empty() { + return; + } let next_idx = match self.current_match_index { Some(idx) => (idx + 1) % self.matches.len(), None => 0, }; self.current_match_index = Some(next_idx); self.scroll_to_current_match(); - cx.emit(SearchBarEvent::MatchesChanged(self.matches.clone(), self.current_match_index)); + cx.emit(SearchBarEvent::MatchesChanged( + self.matches.clone(), + self.current_match_index, + )); cx.notify(); } pub fn prev_match(&mut self, cx: &mut Context<Self>) { - if self.matches.is_empty() { return; } + if self.matches.is_empty() { + return; + } let prev_idx = match self.current_match_index { - Some(idx) => { if idx == 0 { self.matches.len() - 1 } else { idx - 1 } } + Some(idx) => { + if idx == 0 { + self.matches.len() - 1 + } else { + idx - 1 + } + } None => self.matches.len() - 1, }; self.current_match_index = Some(prev_idx); self.scroll_to_current_match(); - cx.emit(SearchBarEvent::MatchesChanged(self.matches.clone(), self.current_match_index)); + cx.emit(SearchBarEvent::MatchesChanged( + self.matches.clone(), + self.current_match_index, + )); cx.notify(); } fn scroll_to_current_match(&self) { if let (Some(idx), Some(terminal)) = (self.current_match_index, &self.terminal) - && let Some(search_match) = self.matches.get(idx) { - let screen_lines = terminal.screen_lines() as i32; - let display_offset = terminal.display_offset() as i32; - // Convert absolute grid line to visual line - let visual_line = search_match.line + display_offset; - if visual_line < 0 || visual_line >= screen_lines { - let target_visible_line = screen_lines / 2; - let scroll_delta = target_visible_line - visual_line; - if scroll_delta > 0 { terminal.scroll_up(scroll_delta); } - else if scroll_delta < 0 { terminal.scroll_down(-scroll_delta); } + && let Some(search_match) = self.matches.get(idx) + { + let screen_lines = terminal.screen_lines() as i32; + let display_offset = terminal.display_offset() as i32; + // Convert absolute grid line to visual line + let visual_line = search_match.line + display_offset; + if visual_line < 0 || visual_line >= screen_lines { + let target_visible_line = screen_lines / 2; + let scroll_delta = target_visible_line - visual_line; + if scroll_delta > 0 { + terminal.scroll_up(scroll_delta); + } else if scroll_delta < 0 { + terminal.scroll_down(-scroll_delta); } } + } } fn handle_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) { if event.keystroke.key.as_str() == "enter" { - if event.keystroke.modifiers.shift { self.prev_match(cx); } - else { self.next_match(cx); } + if event.keystroke.modifiers.shift { + self.prev_match(cx); + } else { + self.next_match(cx); + } } } } @@ -196,7 +234,11 @@ impl Render for SearchBar { let t = theme(cx); let match_count = self.matches.len(); let current_idx = self.current_match_index.map(|i| i + 1).unwrap_or(0); - let match_text = if match_count > 0 { format!("{}/{}", current_idx, match_count) } else { "0/0".to_string() }; + let match_text = if match_count > 0 { + format!("{}/{}", current_idx, match_count) + } else { + "0/0".to_string() + }; let case_sensitive = self.case_sensitive; let is_regex = self.use_regex; @@ -208,49 +250,126 @@ impl Render for SearchBar { .items_center() .gap(px(8.0)) .bg(rgb(t.bg_header)) + .child(if let Some(ref input) = self.input { + div() + .id("search-input-wrapper") + .key_context("SearchBar") + .flex_1() + .min_w(px(100.0)) + .max_w(px(300.0)) + .bg(rgb(t.bg_secondary)) + .border_1() + .border_color(rgb(t.border_active)) + .rounded(px(4.0)) + .child(SimpleInput::new(input).text_size(ui_text_md(cx))) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_action(cx.listener(|this, _: &CloseSearch, _window, cx| { + this.close(cx); + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + cx.stop_propagation(); + this.handle_key_down(event, cx); + })) + .into_any_element() + } else { + div().flex_1().into_any_element() + }) .child( - if let Some(ref input) = self.input { - div() - .id("search-input-wrapper") - .key_context("SearchBar") - .flex_1() - .min_w(px(100.0)) - .max_w(px(300.0)) - .bg(rgb(t.bg_secondary)) - .border_1() - .border_color(rgb(t.border_active)) - .rounded(px(4.0)) - .child(SimpleInput::new(input).text_size(ui_text_md(cx))) - .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_action(cx.listener(|this, _: &CloseSearch, _window, cx| { this.close(cx); })) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - cx.stop_propagation(); - this.handle_key_down(event, cx); - })) - .into_any_element() - } else { - div().flex_1().into_any_element() - }, - ) - .child( - div().id("search-case-sensitive-btn").cursor_pointer().w(px(24.0)).h(px(24.0)).flex().items_center().justify_center().rounded(px(4.0)) + div() + .id("search-case-sensitive-btn") + .cursor_pointer() + .w(px(24.0)) + .h(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) .when(case_sensitive, |s| s.bg(rgb(t.bg_selection))) .hover(|s| s.bg(rgb(t.bg_hover))) - .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_click(cx.listener(|this, _, _window, cx| { this.toggle_case_sensitive(cx); })) - .child(div().text_size(ui_text_md(cx)).font_weight(FontWeight::BOLD).text_color(if case_sensitive { rgb(t.text_primary) } else { rgb(t.text_secondary) }).child("Aa")), + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.toggle_case_sensitive(cx); + })) + .child( + div() + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::BOLD) + .text_color(if case_sensitive { + rgb(t.text_primary) + } else { + rgb(t.text_secondary) + }) + .child("Aa"), + ), ) .child( - div().id("search-regex-btn").cursor_pointer().w(px(24.0)).h(px(24.0)).flex().items_center().justify_center().rounded(px(4.0)) + div() + .id("search-regex-btn") + .cursor_pointer() + .w(px(24.0)) + .h(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) .when(is_regex, |s| s.bg(rgb(t.bg_selection))) .hover(|s| s.bg(rgb(t.bg_hover))) - .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .on_click(cx.listener(|this, _, _window, cx| { this.toggle_regex(cx); })) - .child(div().text_size(ui_text_md(cx)).font_weight(FontWeight::BOLD).text_color(if is_regex { rgb(t.text_primary) } else { rgb(t.text_secondary) }).child(".*")), + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.toggle_regex(cx); + })) + .child( + div() + .text_size(ui_text_md(cx)) + .font_weight(FontWeight::BOLD) + .text_color(if is_regex { + rgb(t.text_primary) + } else { + rgb(t.text_secondary) + }) + .child(".*"), + ), + ) + .child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_secondary)) + .min_w(px(40.0)) + .child(match_text), + ) + .child( + icon_button_sized("search-prev-btn", "icons/chevron-up.svg", 24.0, 14.0, &t) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.prev_match(cx); + })), + ) + .child( + icon_button_sized("search-next-btn", "icons/chevron-down.svg", 24.0, 14.0, &t) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.next_match(cx); + })), + ) + .child( + icon_button_sized("search-close-btn", "icons/close.svg", 24.0, 14.0, &t) + .hover(|s| s.bg(rgba(0xf14c4c99))) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.close(cx); + })), ) - .child(div().text_size(ui_text_md(cx)).text_color(rgb(t.text_secondary)).min_w(px(40.0)).child(match_text)) - .child(icon_button_sized("search-prev-btn", "icons/chevron-up.svg", 24.0, 14.0, &t).on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }).on_click(cx.listener(|this, _, _window, cx| { this.prev_match(cx); }))) - .child(icon_button_sized("search-next-btn", "icons/chevron-down.svg", 24.0, 14.0, &t).on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }).on_click(cx.listener(|this, _, _window, cx| { this.next_match(cx); }))) - .child(icon_button_sized("search-close-btn", "icons/close.svg", 24.0, 14.0, &t).hover(|s| s.bg(rgba(0xf14c4c99))).on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }).on_click(cx.listener(|this, _, _window, cx| { this.close(cx); }))) } } 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..3be3269e2 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 @@ -20,6 +20,8 @@ pub struct UrlDetector { path_exists_cache: HashMap<String, bool>, /// Last terminal content generation we processed (skip if unchanged) last_generation: u64, + /// Whether the current project shares the client's filesystem. + last_validate_paths_locally: bool, /// Cwd used for the entries currently in `path_exists_cache`. When the /// shell reports a new cwd (OSC 7), relative-path cache hits become /// wrong, so the cache is cleared. @@ -40,6 +42,7 @@ impl UrlDetector { hovered_group: None, path_exists_cache: HashMap::new(), last_generation: u64::MAX, // force first update + last_validate_paths_locally: true, last_cwd: String::new(), } } @@ -83,13 +86,23 @@ impl UrlDetector { /// Update URL matches from terminal content. /// Skips detection if terminal content hasn't changed since last call. - pub fn update_matches(&mut self, terminal: &Option<Arc<Terminal>>) { + pub fn update_matches( + &mut self, + terminal: &Option<Arc<Terminal>>, + validate_paths_locally: bool, + ) { if let Some(terminal) = terminal { let content_gen = terminal.content_generation(); - if content_gen == self.last_generation { + if content_gen == self.last_generation + && validate_paths_locally == self.last_validate_paths_locally + { return; } self.last_generation = content_gen; + if validate_paths_locally != self.last_validate_paths_locally { + self.path_exists_cache.clear(); + self.last_validate_paths_locally = validate_paths_locally; + } let detected = terminal.detect_urls(); let cwd = terminal.current_cwd(); @@ -122,8 +135,8 @@ impl UrlDetector { link_group: current_group, }) } else { - // File path: verify existence before showing - if self.path_exists_cached(&link.text, &cwd) { + // A remote path can only be validated by the daemon. + if !validate_paths_locally || self.path_exists_cached(&link.text, &cwd) { Some(URLMatch { line: link.line, col: link.col, @@ -345,7 +358,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/layout/terminal_pane/zoom.rs b/crates/okena-views-terminal/src/layout/terminal_pane/zoom.rs index ad720d293..7016c8bed 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/zoom.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/zoom.rs @@ -1,22 +1,22 @@ //! Zoom (fullscreen) state and rendering for terminal panes. use crate::ActionDispatch; -use okena_files::theme::theme; -use okena_ui::header_buttons::{header_button_base, ButtonSize, HeaderAction}; -use okena_ui::tokens::{ui_text_ms, ui_text_md}; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::h_flex; use okena_core::api::ActionRequest; +use okena_files::theme::theme; +use okena_ui::header_buttons::{ButtonSize, HeaderAction, header_button_base}; +use okena_ui::tokens::{ui_text_md, ui_text_ms}; use super::TerminalPane; impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { pub(super) fn is_zoomed(&self, cx: &Context<Self>) -> bool { let fm = self.focus_manager.read(cx); - self.terminal_id.as_ref().is_some_and(|tid| { - fm.is_terminal_fullscreened(&self.project_id, tid) - }) + self.terminal_id + .as_ref() + .is_some_and(|tid| fm.is_terminal_fullscreened(&self.project_id, tid)) } fn get_project_terminals(&self, cx: &Context<Self>) -> Vec<String> { @@ -28,39 +28,59 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { } pub(super) fn handle_zoom_next_terminal(&mut self, cx: &mut Context<Self>) { - if !self.is_zoomed(cx) { return; } + if !self.is_zoomed(cx) { + return; + } let terminals = self.get_project_terminals(cx); - if terminals.len() <= 1 { return; } + if terminals.len() <= 1 { + return; + } if let Some(ref current_id) = self.terminal_id - && let Some(idx) = terminals.iter().position(|id| id == current_id) { - let next_idx = (idx + 1) % terminals.len(); - let next_id = terminals[next_idx].clone(); - if let Some(ref dispatcher) = self.action_dispatcher { - dispatcher.dispatch(ActionRequest::SetFullscreen { + && let Some(idx) = terminals.iter().position(|id| id == current_id) + { + let next_idx = (idx + 1) % terminals.len(); + let next_id = terminals[next_idx].clone(); + if let Some(ref dispatcher) = self.action_dispatcher { + dispatcher.dispatch( + ActionRequest::SetFullscreen { project_id: self.project_id.clone(), terminal_id: Some(next_id), window: None, - }, cx); - } + }, + cx, + ); } + } } pub(super) fn handle_zoom_prev_terminal(&mut self, cx: &mut Context<Self>) { - if !self.is_zoomed(cx) { return; } + if !self.is_zoomed(cx) { + return; + } let terminals = self.get_project_terminals(cx); - if terminals.len() <= 1 { return; } + if terminals.len() <= 1 { + return; + } if let Some(ref current_id) = self.terminal_id - && let Some(idx) = terminals.iter().position(|id| id == current_id) { - let prev_idx = if idx == 0 { terminals.len() - 1 } else { idx - 1 }; - let prev_id = terminals[prev_idx].clone(); - if let Some(ref dispatcher) = self.action_dispatcher { - dispatcher.dispatch(ActionRequest::SetFullscreen { + && let Some(idx) = terminals.iter().position(|id| id == current_id) + { + let prev_idx = if idx == 0 { + terminals.len() - 1 + } else { + idx - 1 + }; + let prev_id = terminals[prev_idx].clone(); + if let Some(ref dispatcher) = self.action_dispatcher { + dispatcher.dispatch( + ActionRequest::SetFullscreen { project_id: self.project_id.clone(), terminal_id: Some(prev_id), window: None, - }, cx); - } + }, + cx, + ); } + } } pub(super) fn render_zoom_header(&self, cx: &Context<Self>) -> impl IntoElement { @@ -80,7 +100,8 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { }; let is_hook = self.terminal_id.as_ref().is_some_and(|tid| { - ws.project(&self.project_id).is_some_and(|p| p.hook_terminals.contains_key(tid)) + ws.project(&self.project_id) + .is_some_and(|p| p.hook_terminals.contains_key(tid)) }); let all_terminals = ws .project(&self.project_id) @@ -109,10 +130,29 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { h_flex() .gap(px(6.0)) .items_center() - .child(svg().path("icons/terminal.svg").size(px(12.0)).text_color(if is_hook { rgb(t.term_yellow) } else { rgb(t.success) })) - .child(div().text_size(ui_text_md(cx)).text_color(rgb(t.text_primary)).child(terminal_name)) + .child( + svg() + .path("icons/terminal.svg") + .size(px(12.0)) + .text_color(if is_hook { + rgb(t.term_yellow) + } else { + rgb(t.success) + }), + ) + .child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_primary)) + .child(terminal_name), + ) .when(has_multiple, |d| { - d.child(div().text_size(ui_text_ms(cx)).text_color(rgb(t.text_muted)).child(format!("{}/{}", current_index + 1, terminal_count))) + d.child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(format!("{}/{}", current_index + 1, terminal_count)), + ) }), ) .child( @@ -121,48 +161,88 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { .items_center() .when(has_multiple, |d| { d.child( - header_button_base(HeaderAction::ZoomPrev, id_suffix, size, &t, None, None) - .on_click({ - let workspace = workspace.clone(); - let project_id = self.project_id.clone(); - let terminal_id = self.terminal_id.clone(); - let dispatcher = dispatcher.clone(); - move |_, _window, cx| { - let terminals = { - let ws = workspace.read(cx); - ws.project(&project_id).and_then(|p| p.layout.as_ref()).map(|l| l.collect_terminal_ids()).unwrap_or_default() + header_button_base( + HeaderAction::ZoomPrev, + id_suffix, + size, + &t, + None, + None, + ) + .on_click({ + let workspace = workspace.clone(); + let project_id = self.project_id.clone(); + let terminal_id = self.terminal_id.clone(); + let dispatcher = dispatcher.clone(); + move |_, _window, cx| { + let terminals = { + let ws = workspace.read(cx); + ws.project(&project_id) + .and_then(|p| p.layout.as_ref()) + .map(|l| l.collect_terminal_ids()) + .unwrap_or_default() + }; + if let Some(ref tid) = terminal_id + && let Some(idx) = terminals.iter().position(|id| id == tid) + { + let prev = if idx == 0 { + terminals.len() - 1 + } else { + idx - 1 }; - if let Some(ref tid) = terminal_id - && let Some(idx) = terminals.iter().position(|id| id == tid) { - let prev = if idx == 0 { terminals.len() - 1 } else { idx - 1 }; - if let Some(ref dispatcher) = dispatcher { - dispatcher.dispatch(ActionRequest::SetFullscreen { project_id: project_id.clone(), terminal_id: Some(terminals[prev].clone()), window: None }, cx); - } - } + if let Some(ref dispatcher) = dispatcher { + dispatcher.dispatch( + ActionRequest::SetFullscreen { + project_id: project_id.clone(), + terminal_id: Some(terminals[prev].clone()), + window: None, + }, + cx, + ); + } } - }), + } + }), ) .child( - header_button_base(HeaderAction::ZoomNext, id_suffix, size, &t, None, None) - .on_click({ - let workspace = workspace.clone(); - let project_id = self.project_id.clone(); - let terminal_id = self.terminal_id.clone(); - let dispatcher = dispatcher.clone(); - move |_, _window, cx| { - let terminals = { - let ws = workspace.read(cx); - ws.project(&project_id).and_then(|p| p.layout.as_ref()).map(|l| l.collect_terminal_ids()).unwrap_or_default() - }; - if let Some(ref tid) = terminal_id - && let Some(idx) = terminals.iter().position(|id| id == tid) { - let next = (idx + 1) % terminals.len(); - if let Some(ref dispatcher) = dispatcher { - dispatcher.dispatch(ActionRequest::SetFullscreen { project_id: project_id.clone(), terminal_id: Some(terminals[next].clone()), window: None }, cx); - } - } + header_button_base( + HeaderAction::ZoomNext, + id_suffix, + size, + &t, + None, + None, + ) + .on_click({ + let workspace = workspace.clone(); + let project_id = self.project_id.clone(); + let terminal_id = self.terminal_id.clone(); + let dispatcher = dispatcher.clone(); + move |_, _window, cx| { + let terminals = { + let ws = workspace.read(cx); + ws.project(&project_id) + .and_then(|p| p.layout.as_ref()) + .map(|l| l.collect_terminal_ids()) + .unwrap_or_default() + }; + if let Some(ref tid) = terminal_id + && let Some(idx) = terminals.iter().position(|id| id == tid) + { + let next = (idx + 1) % terminals.len(); + if let Some(ref dispatcher) = dispatcher { + dispatcher.dispatch( + ActionRequest::SetFullscreen { + project_id: project_id.clone(), + terminal_id: Some(terminals[next].clone()), + window: None, + }, + cx, + ); + } } - }), + } + }), ) }) .child( @@ -173,7 +253,14 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> { move |_, _window, cx| { cx.stop_propagation(); if let Some(ref dispatcher) = dispatcher { - dispatcher.dispatch(ActionRequest::SetFullscreen { project_id: project_id.clone(), terminal_id: None, window: None }, cx); + dispatcher.dispatch( + ActionRequest::SetFullscreen { + project_id: project_id.clone(), + terminal_id: None, + window: None, + }, + cx, + ); } } }), diff --git a/crates/okena-views-terminal/src/lib.rs b/crates/okena-views-terminal/src/lib.rs index 8d6aafc58..72598e46d 100644 --- a/crates/okena-views-terminal/src/lib.rs +++ b/crates/okena-views-terminal/src/lib.rs @@ -16,6 +16,12 @@ mod simple_input; use okena_core::api::ActionRequest; use okena_workspace::state::SplitDirection; +/// Client-local file bytes to materialize beside a truly remote daemon. +pub struct RemotePasteFile { + pub extension: String, + pub bytes: Vec<u8>, +} + /// Trait for dispatching terminal actions (local or remote). /// /// This abstracts the `ActionDispatcher` enum from the main application, @@ -25,8 +31,8 @@ pub trait ActionDispatch: Clone + 'static { /// Dispatch a standard action. fn dispatch(&self, action: ActionRequest, cx: &mut gpui::App); - /// Whether this dispatcher targets a remote project. - fn is_remote(&self) -> bool; + /// Whether the client and daemon see the same local filesystem. + fn shares_local_filesystem(&self) -> bool; /// Split a terminal. fn split_terminal( @@ -38,13 +44,7 @@ pub trait ActionDispatch: Clone + 'static { ); /// Add a tab. - fn add_tab( - &self, - project_id: &str, - layout_path: &[usize], - in_group: bool, - cx: &mut gpui::App, - ); + fn add_tab(&self, project_id: &str, layout_path: &[usize], in_group: bool, cx: &mut gpui::App); /// Upload a clipboard image pasted into a remote terminal. /// @@ -62,6 +62,26 @@ pub trait ActionDispatch: Clone + 'static { ) { let _ = (terminal_id, mime, bytes, cx); } + + /// Materialize dropped client files on a truly remote daemon and paste + /// their server-local paths into the terminal. + fn upload_remote_paste_files( + &self, + terminal_id: &str, + files: Vec<RemotePasteFile>, + cx: &mut gpui::App, + ) { + let _ = (terminal_id, files, 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<std::path::PathBuf> { + let _ = (terminal_id, cx); + None + } } /// Settings namespace used in ExtensionSettingsStore. @@ -121,10 +141,12 @@ pub fn set_terminal_view_settings(settings: &TerminalViewSettings, cx: &mut gpui } /// Callback type for registering content panes for dirty notification. -pub type RegisterContentPaneFn = Box<dyn Fn(String, gpui::WeakEntity<layout::terminal_pane::TerminalContent>) + Send + Sync>; +pub type RegisterContentPaneFn = + Box<dyn Fn(String, gpui::WeakEntity<layout::terminal_pane::TerminalContent>) + Send + Sync>; /// Global content pane registration function. -static REGISTER_CONTENT_PANE_FN: std::sync::OnceLock<RegisterContentPaneFn> = std::sync::OnceLock::new(); +static REGISTER_CONTENT_PANE_FN: std::sync::OnceLock<RegisterContentPaneFn> = + std::sync::OnceLock::new(); /// Set the global content pane registration function. /// Called once by the main app at startup. diff --git a/crates/okena-views-terminal/src/overlays/detached_terminal.rs b/crates/okena-views-terminal/src/overlays/detached_terminal.rs index f74c71a1d..7b75bb4a4 100644 --- a/crates/okena-views-terminal/src/overlays/detached_terminal.rs +++ b/crates/okena-views-terminal/src/overlays/detached_terminal.rs @@ -1,15 +1,16 @@ -use okena_terminal::terminal::{Terminal, TerminalTransport}; -use okena_terminal::TerminalsRegistry; -use okena_ui::theme::theme; -use okena_ui::tokens::{ui_text, ui_text_ms, ui_text_md}; use crate::layout::terminal_pane::TerminalContent; -use okena_workspace::state::Workspace; use crate::overlays::terminal_overlay_utils::{ - create_terminal_content, get_or_create_terminal, handle_pending_focus, handle_terminal_key_input, + create_terminal_content, get_or_create_terminal, handle_pending_focus, + handle_terminal_key_input, }; +use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::h_flex; -use gpui::prelude::FluentBuilder; +use okena_terminal::TerminalsRegistry; +use okena_terminal::terminal::{Terminal, TerminalTransport}; +use okena_ui::theme::theme; +use okena_ui::tokens::{ui_text, ui_text_md, ui_text_ms}; +use okena_workspace::state::Workspace; use std::sync::Arc; /// Detached terminal window view @@ -44,12 +45,13 @@ impl DetachedTerminalView { for project in ws.projects() { if let Some(layout) = &project.layout - && let Some(path) = layout.find_terminal_path(&terminal_id) { - found_project_id = project.id.clone(); - found_layout_path = path; - found_project_path = project.path.clone(); - break; - } + && let Some(path) = layout.find_terminal_path(&terminal_id) + { + found_project_id = project.id.clone(); + found_layout_path = path; + found_project_path = project.path.clone(); + break; + } } (found_project_id, found_layout_path, found_project_path) }; @@ -57,6 +59,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,8 +72,10 @@ impl DetachedTerminalView { project_id, layout_path, workspace.clone(), + request_broker, terminal.clone(), ); + crate::register_content_pane(terminal_id.clone(), content.downgrade()); // Observe workspace for changes (to detect when re-attached) let terminal_id_for_observer = terminal_id.clone(); @@ -81,37 +91,6 @@ impl DetachedTerminalView { }) .detach(); - // Refresh timer - checks terminal dirty flag and notifies only when content changed - let terminal_for_refresh = terminal.clone(); - cx.spawn(async move |this: WeakEntity<DetachedTerminalView>, cx| { - loop { - smol::Timer::after(std::time::Duration::from_millis(8)).await; // ~120fps check rate - - // Only notify if terminal has new content - if terminal_for_refresh.take_dirty() { - let should_continue = this.update(cx, |this, cx| { - if this.should_close { - return false; - } - cx.notify(); - true - }); - match should_continue { - Ok(true) => continue, - _ => break, - } - } else { - // Check if view still exists - let should_continue = this.update(cx, |this, _| !this.should_close); - match should_continue { - Ok(true) => continue, - _ => break, - } - } - } - }) - .detach(); - Self { workspace, terminal, @@ -154,8 +133,14 @@ impl Render for DetachedTerminalView { let terminal_name = { let ws = self.workspace.read(cx); let osc_title = self.terminal.title(); - ws.projects().iter() - .find(|p| p.layout.as_ref().and_then(|l| l.find_terminal_path(&self.terminal_id)).is_some()) + ws.projects() + .iter() + .find(|p| { + p.layout + .as_ref() + .and_then(|l| l.find_terminal_path(&self.terminal_id)) + .is_some() + }) .map(|p| p.terminal_display_name(&self.terminal_id, osc_title.clone())) .unwrap_or_else(|| osc_title.unwrap_or_else(|| "Terminal".to_string())) }; @@ -173,9 +158,12 @@ impl Render for DetachedTerminalView { div() .track_focus(&focus_handle) .key_context("DetachedTerminal") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, window, cx| { - window.focus(&this.focus_handle, cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + }), + ) .on_key_down(cx.listener(|this, event, _window, cx| { this.handle_key(event, cx); })) @@ -234,7 +222,9 @@ impl Render for DetachedTerminalView { .text_size(ui_text_md(cx)) .text_color(rgb(t.text_primary)) .child("Re-attach") - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) .on_click(cx.listener(|this, _, _window, cx| { this.handle_reattach(cx); })), @@ -261,14 +251,18 @@ impl Render for DetachedTerminalView { .hover(|s| s.bg(rgb(t.bg_hover))) .child("─") .when(use_native, |d| { - d.occlude().window_control_area(WindowControlArea::Min) + d.occlude() + .window_control_area(WindowControlArea::Min) }) .when(!use_native, |d| { - d.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(|_, window, cx| { - cx.stop_propagation(); - window.minimize_window(); - }) + d.on_mouse_down( + MouseButton::Left, + |_, _, cx| cx.stop_propagation(), + ) + .on_click(|_, window, cx| { + cx.stop_propagation(); + window.minimize_window(); + }) }), ) // Maximize/Restore @@ -287,14 +281,18 @@ impl Render for DetachedTerminalView { .hover(|s| s.bg(rgb(t.bg_hover))) .child(if is_maximized { "❐" } else { "□" }) .when(use_native, |d| { - d.occlude().window_control_area(WindowControlArea::Max) + d.occlude() + .window_control_area(WindowControlArea::Max) }) .when(!use_native, |d| { - d.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(|_, window, cx| { - cx.stop_propagation(); - window.zoom_window(); - }) + d.on_mouse_down( + MouseButton::Left, + |_, _, cx| cx.stop_propagation(), + ) + .on_click(|_, window, cx| { + cx.stop_propagation(); + window.zoom_window(); + }) }), ) // Close — re-attach instead of closing the OS window, @@ -312,9 +310,13 @@ impl Render for DetachedTerminalView { .rounded(px(4.0)) .text_size(ui_text_md(cx)) .text_color(rgb(t.text_secondary)) - .hover(|s| s.bg(rgb(0xE81123)).text_color(rgb(0xffffff))) + .hover(|s| { + s.bg(rgb(0xE81123)).text_color(rgb(0xffffff)) + }) .child("✕") - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) .on_click(cx.listener(|this, _, _window, cx| { // Close = re-attach this.handle_reattach(cx); @@ -326,12 +328,10 @@ impl Render for DetachedTerminalView { ) .child( // Terminal content (reuses TerminalContent for selection, context menu, etc.) - div() - .flex_1() - .min_h_0() - .child(AnyView::from(self.content.clone()).cached( - StyleRefinement::default().size_full() - )), + div().flex_1().min_h_0().child( + AnyView::from(self.content.clone()) + .cached(StyleRefinement::default().size_full()), + ), ) .id("detached-terminal-main") .on_click(cx.listener(|this, _, window, cx| { diff --git a/crates/okena-views-terminal/src/overlays/tab_context_menu.rs b/crates/okena-views-terminal/src/overlays/tab_context_menu.rs index 01db76bf1..ac6f978b8 100644 --- a/crates/okena-views-terminal/src/overlays/tab_context_menu.rs +++ b/crates/okena-views-terminal/src/overlays/tab_context_menu.rs @@ -1,17 +1,29 @@ //! Context menu for tab bar (right-click on a tab). use crate::actions::Cancel; -use okena_ui::theme::theme; -use okena_ui::menu::{context_menu_panel, menu_item, menu_item_disabled, menu_separator}; use gpui::prelude::*; use gpui::*; +use okena_ui::menu::{context_menu_panel, menu_item, menu_item_disabled, menu_separator}; +use okena_ui::theme::theme; /// Event emitted by TabContextMenu pub enum TabContextMenuEvent { Close, - CloseTab { project_id: String, layout_path: Vec<usize>, tab_index: usize }, - CloseOtherTabs { project_id: String, layout_path: Vec<usize>, tab_index: usize }, - CloseTabsToRight { project_id: String, layout_path: Vec<usize>, tab_index: usize }, + CloseTab { + project_id: String, + layout_path: Vec<usize>, + tab_index: usize, + }, + CloseOtherTabs { + project_id: String, + layout_path: Vec<usize>, + tab_index: usize, + }, + CloseTabsToRight { + project_id: String, + layout_path: Vec<usize>, + tab_index: usize, + }, } /// Context menu for tab bar @@ -73,57 +85,85 @@ impl Render for TabContextMenu { .absolute() .inset_0() .id("tab-context-menu-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child( - context_menu_panel("tab-context-menu", &t) - // Close tab - .child( - menu_item("tab-ctx-close", "icons/close.svg", "Close", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TabContextMenuEvent::CloseTab { - project_id: this.project_id.clone(), - layout_path: this.layout_path.clone(), - tab_index: this.tab_index, - }); - })), + anchored().position(position).snap_to_window().child( + context_menu_panel("tab-context-menu", &t) + // Close tab + .child( + menu_item("tab-ctx-close", "icons/close.svg", "Close", &t).on_click( + cx.listener(|this, _, _window, cx| { + cx.emit(TabContextMenuEvent::CloseTab { + project_id: this.project_id.clone(), + layout_path: this.layout_path.clone(), + tab_index: this.tab_index, + }); + }), + ), + ) + .child(menu_separator(&t)) + // Close Others + .child(if has_other_tabs { + menu_item( + "tab-ctx-close-others", + "icons/close.svg", + "Close Others", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + cx.emit(TabContextMenuEvent::CloseOtherTabs { + project_id: this.project_id.clone(), + layout_path: this.layout_path.clone(), + tab_index: this.tab_index, + }); + }, + )) + } else { + menu_item_disabled( + "tab-ctx-close-others", + "icons/close.svg", + "Close Others", + &t, + ) + }) + // Close to Right + .child(if has_tabs_to_right { + menu_item( + "tab-ctx-close-to-right", + "icons/chevron-right.svg", + "Close to Right", + &t, + ) + .on_click(cx.listener( + |this, _, _window, cx| { + cx.emit(TabContextMenuEvent::CloseTabsToRight { + project_id: this.project_id.clone(), + layout_path: this.layout_path.clone(), + tab_index: this.tab_index, + }); + }, + )) + } else { + menu_item_disabled( + "tab-ctx-close-to-right", + "icons/chevron-right.svg", + "Close to Right", + &t, ) - .child(menu_separator(&t)) - // Close Others - .child(if has_other_tabs { - menu_item("tab-ctx-close-others", "icons/close.svg", "Close Others", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TabContextMenuEvent::CloseOtherTabs { - project_id: this.project_id.clone(), - layout_path: this.layout_path.clone(), - tab_index: this.tab_index, - }); - })) - } else { - menu_item_disabled("tab-ctx-close-others", "icons/close.svg", "Close Others", &t) - }) - // Close to Right - .child(if has_tabs_to_right { - menu_item("tab-ctx-close-to-right", "icons/chevron-right.svg", "Close to Right", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TabContextMenuEvent::CloseTabsToRight { - project_id: this.project_id.clone(), - layout_path: this.layout_path.clone(), - tab_index: this.tab_index, - }); - })) - } else { - menu_item_disabled("tab-ctx-close-to-right", "icons/chevron-right.svg", "Close to Right", &t) - }), - ), + }), + ), )) } } diff --git a/crates/okena-views-terminal/src/overlays/terminal_context_menu.rs b/crates/okena-views-terminal/src/overlays/terminal_context_menu.rs index 331c100df..ea385ff98 100644 --- a/crates/okena-views-terminal/src/overlays/terminal_context_menu.rs +++ b/crates/okena-views-terminal/src/overlays/terminal_context_menu.rs @@ -1,23 +1,44 @@ //! Context menu for terminal content (right-click on terminal area). use crate::actions::Cancel; -use okena_ui::theme::theme; -use okena_ui::menu::{context_menu_panel, menu_item, menu_item_conditional, menu_item_with_color, menu_separator}; -use okena_workspace::state::SplitDirection; use gpui::prelude::*; use gpui::*; +use okena_ui::menu::{ + context_menu_panel, menu_item, menu_item_conditional, menu_item_with_color, menu_separator, +}; +use okena_ui::theme::theme; +use okena_workspace::state::SplitDirection; /// Event emitted by TerminalContextMenu pub enum TerminalContextMenuEvent { Close, - Copy { terminal_id: String }, - Paste { terminal_id: String }, - Clear { terminal_id: String }, - SelectAll { terminal_id: String }, - Split { project_id: String, layout_path: Vec<usize>, direction: SplitDirection }, - CloseTerminal { project_id: String, terminal_id: String }, - OpenLink { url: String }, - CopyLink { url: String }, + Copy { + terminal_id: String, + }, + Paste { + terminal_id: String, + }, + Clear { + terminal_id: String, + }, + SelectAll { + terminal_id: String, + }, + Split { + project_id: String, + layout_path: Vec<usize>, + direction: SplitDirection, + }, + CloseTerminal { + project_id: String, + terminal_id: String, + }, + OpenLink { + url: String, + }, + CopyLink { + url: String, + }, } /// Context menu for terminal content @@ -83,114 +104,154 @@ impl Render for TerminalContextMenu { .absolute() .inset_0() .id("terminal-context-menu-backdrop") - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child(deferred( - anchored() - .position(position) - .snap_to_window() - .child( - context_menu_panel("terminal-context-menu", &t) - // Open in Browser (conditional - requires URL at click position) - .when_some(link_url.clone(), |el, url| { - let url2 = url.clone(); - el.child( - menu_item("ctx-open-link", "icons/external-link.svg", "Open in Browser", &t) - .on_click(cx.listener(move |_this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::OpenLink { - url: url.clone(), - }); - })), - ) - // Copy Link - .child( - menu_item("ctx-copy-link", "icons/link.svg", "Copy Link", &t) - .on_click(cx.listener(move |_this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::CopyLink { - url: url2.clone(), - }); - })), + anchored().position(position).snap_to_window().child( + context_menu_panel("terminal-context-menu", &t) + // Open in Browser (conditional - requires URL at click position) + .when_some(link_url.clone(), |el, url| { + let url2 = url.clone(); + el.child( + menu_item( + "ctx-open-link", + "icons/external-link.svg", + "Open in Browser", + &t, ) - .child(menu_separator(&t)) - }) - // Copy (conditional - requires selection) - .child( - menu_item_conditional("ctx-copy", "icons/copy.svg", "Copy", has_selection, &t) - .when(has_selection, |el| { - el.on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::Copy { - terminal_id: this.terminal_id.clone(), - }); - })) - }), + .on_click(cx.listener( + move |_this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::OpenLink { + url: url.clone(), + }); + }, + )), ) - // Paste + // Copy Link .child( - menu_item("ctx-paste", "icons/clipboard-paste.svg", "Paste", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::Paste { - terminal_id: this.terminal_id.clone(), + menu_item("ctx-copy-link", "icons/link.svg", "Copy Link", &t) + .on_click(cx.listener(move |_this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::CopyLink { + url: url2.clone(), }); })), ) .child(menu_separator(&t)) - // Clear - .child( - menu_item("ctx-clear", "icons/eraser.svg", "Clear", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::Clear { - terminal_id: this.terminal_id.clone(), - }); - })), + }) + // Copy (conditional - requires selection) + .child( + menu_item_conditional( + "ctx-copy", + "icons/copy.svg", + "Copy", + has_selection, + &t, ) - // Select All - .child( - menu_item("ctx-select-all", "icons/select-all.svg", "Select All", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::SelectAll { - terminal_id: this.terminal_id.clone(), - }); - })), + .when(has_selection, |el| { + el.on_click(cx.listener(|this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::Copy { + terminal_id: this.terminal_id.clone(), + }); + })) + }), + ) + // Paste + .child( + menu_item("ctx-paste", "icons/clipboard-paste.svg", "Paste", &t) + .on_click(cx.listener(|this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::Paste { + terminal_id: this.terminal_id.clone(), + }); + })), + ) + .child(menu_separator(&t)) + // Clear + .child( + menu_item("ctx-clear", "icons/eraser.svg", "Clear", &t).on_click( + cx.listener(|this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::Clear { + terminal_id: this.terminal_id.clone(), + }); + }), + ), + ) + // Select All + .child( + menu_item("ctx-select-all", "icons/select-all.svg", "Select All", &t) + .on_click(cx.listener(|this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::SelectAll { + terminal_id: this.terminal_id.clone(), + }); + })), + ) + .child(menu_separator(&t)) + // Split Horizontal + .child( + menu_item( + "ctx-split-h", + "icons/split-horizontal.svg", + "Split Horizontal", + &t, ) - .child(menu_separator(&t)) - // Split Horizontal - .child( - menu_item("ctx-split-h", "icons/split-horizontal.svg", "Split Horizontal", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::Split { - project_id: this.project_id.clone(), - layout_path: this.layout_path.clone(), - direction: SplitDirection::Horizontal, - }); - })), + .on_click(cx.listener( + |this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::Split { + project_id: this.project_id.clone(), + layout_path: this.layout_path.clone(), + direction: SplitDirection::Horizontal, + }); + }, + )), + ) + // Split Vertical + .child( + menu_item( + "ctx-split-v", + "icons/split-vertical.svg", + "Split Vertical", + &t, ) - // Split Vertical - .child( - menu_item("ctx-split-v", "icons/split-vertical.svg", "Split Vertical", &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::Split { - project_id: this.project_id.clone(), - layout_path: this.layout_path.clone(), - direction: SplitDirection::Vertical, - }); - })), + .on_click(cx.listener( + |this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::Split { + project_id: this.project_id.clone(), + layout_path: this.layout_path.clone(), + direction: SplitDirection::Vertical, + }); + }, + )), + ) + .child(menu_separator(&t)) + // Close + .child( + menu_item_with_color( + "ctx-close", + "icons/close.svg", + "Close", + t.error, + t.error, + &t, ) - .child(menu_separator(&t)) - // Close - .child( - menu_item_with_color("ctx-close", "icons/close.svg", "Close", t.error, t.error, &t) - .on_click(cx.listener(|this, _, _window, cx| { - cx.emit(TerminalContextMenuEvent::CloseTerminal { - project_id: this.project_id.clone(), - terminal_id: this.terminal_id.clone(), - }); - })), - ), - ), + .on_click(cx.listener( + |this, _, _window, cx| { + cx.emit(TerminalContextMenuEvent::CloseTerminal { + project_id: this.project_id.clone(), + terminal_id: this.terminal_id.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..3f77cc141 100644 --- a/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs +++ b/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs @@ -6,12 +6,13 @@ //! - Key input handling //! - Focus management +use crate::layout::terminal_pane::TerminalContent; +use gpui::*; +use okena_terminal::TerminalsRegistry; use okena_terminal::input::{KeyEvent, KeyModifiers, KittyKeyboardFlags, key_to_bytes}; 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; /// Convert a GPUI key event to terminal input bytes. @@ -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<V: 'static>( cx: &mut Context<V>, focus_handle: FocusHandle, project_id: String, layout_path: Vec<usize>, workspace: Entity<Workspace>, + request_broker: Entity<RequestBroker>, terminal: Arc<Terminal>, ) -> Entity<TerminalContent> { cx.new(|cx| { @@ -86,6 +89,7 @@ pub fn create_terminal_content<V: 'static>( project_id, layout_path, workspace, + request_broker, cx, ); content.set_terminal(Some(terminal), cx); diff --git a/crates/okena-views-terminal/src/shell_selector_overlay.rs b/crates/okena-views-terminal/src/shell_selector_overlay.rs index 1ef3ad85a..da7eee188 100644 --- a/crates/okena-views-terminal/src/shell_selector_overlay.rs +++ b/crates/okena-views-terminal/src/shell_selector_overlay.rs @@ -1,16 +1,16 @@ //! Shell selector overlay for switching terminal shells. use crate::actions::Cancel; -use okena_terminal::shell_config::{available_shells, AvailableShell, ShellType}; -use okena_ui::theme::theme; -use okena_ui::tokens::{ui_text, ui_text_md}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; use okena_files::list_overlay::{ - handle_list_overlay_key, ListOverlayAction, ListOverlayConfig, ListOverlayState, + ListOverlayAction, ListOverlayConfig, ListOverlayState, handle_list_overlay_key, }; +use okena_terminal::shell_config::{AvailableShell, ShellType, available_shells}; use okena_ui::modal::{modal_backdrop, modal_content, modal_header}; -use gpui::*; -use gpui_component::h_flex; -use gpui::prelude::*; +use okena_ui::theme::theme; +use okena_ui::tokens::{ui_text, ui_text_md}; /// Shell selector overlay for choosing a shell. pub struct ShellSelectorOverlay { @@ -22,8 +22,15 @@ pub struct ShellSelectorOverlay { } impl ShellSelectorOverlay { - pub fn new(current_shell: ShellType, context: Option<(String, String)>, cx: &mut Context<Self>) -> Self { - let shells: Vec<_> = available_shells().into_iter().filter(|s| s.available).collect(); + pub fn new( + current_shell: ShellType, + context: Option<(String, String)>, + cx: &mut Context<Self>, + ) -> Self { + let shells: Vec<_> = available_shells() + .into_iter() + .filter(|s| s.available) + .collect(); let selected_index = shells .iter() .position(|s| s.shell_type == current_shell) @@ -74,7 +81,9 @@ pub enum ShellSelectorOverlayEvent { } impl okena_ui::overlay::CloseEvent for ShellSelectorOverlayEvent { - fn is_close(&self) -> bool { matches!(self, Self::Close) } + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } } impl EventEmitter<ShellSelectorOverlayEvent> for ShellSelectorOverlay {} @@ -108,9 +117,12 @@ impl Render for ShellSelectorOverlay { _ => {} } })) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _window, cx| { - this.close(cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.close(cx); + }), + ) .child( modal_content("shell-selector-overlay-modal", &t) .w(px(config_width)) @@ -127,47 +139,49 @@ impl Render for ShellSelectorOverlay { .py(px(4.0)) .max_h(px(self.state.config.max_height)) .overflow_y_scroll() - .children(self.state.filtered.iter().enumerate().map(|(i, filter_result)| { - let shell = &self.state.items[filter_result.index]; - let is_current = shell.shell_type == current_shell; - let is_selected = i == selected_index; - let shell_type = shell.shell_type.clone(); - let name = shell.name.clone(); - - div() - .id(ElementId::Name(format!("shell-opt-{}", i).into())) - .w_full() - .px(px(12.0)) - .py(px(8.0)) - .cursor_pointer() - .when(is_selected, |d| d.bg(rgb(t.bg_hover))) - .hover(|s| s.bg(rgb(t.bg_hover))) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, _window, cx| { - cx.stop_propagation(); - this.select_shell(shell_type.clone(), cx); - }), - ) - .child( - h_flex() - .justify_between() - .child( - div() - .text_size(ui_text(13.0, cx)) - .text_color(rgb(t.text_primary)) - .child(name), - ) - .when(is_current, |d| { - d.child( + .children(self.state.filtered.iter().enumerate().map( + |(i, filter_result)| { + let shell = &self.state.items[filter_result.index]; + let is_current = shell.shell_type == current_shell; + let is_selected = i == selected_index; + let shell_type = shell.shell_type.clone(); + let name = shell.name.clone(); + + div() + .id(ElementId::Name(format!("shell-opt-{}", i).into())) + .w_full() + .px(px(12.0)) + .py(px(8.0)) + .cursor_pointer() + .when(is_selected, |d| d.bg(rgb(t.bg_hover))) + .hover(|s| s.bg(rgb(t.bg_hover))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.select_shell(shell_type.clone(), cx); + }), + ) + .child( + h_flex() + .justify_between() + .child( div() - .text_size(ui_text_md(cx)) - .text_color(rgb(t.success)) - .child("✓"), + .text_size(ui_text(13.0, cx)) + .text_color(rgb(t.text_primary)) + .child(name), ) - }), - ) - })), + .when(is_current, |d| { + d.child( + div() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.success)) + .child("✓"), + ) + }), + ) + }, + )), ), ) } diff --git a/crates/okena-workspace/Cargo.toml b/crates/okena-workspace/Cargo.toml index 6b5d2466c..5b339930c 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" @@ -25,6 +29,16 @@ dirs = "5.0" anyhow = "1.0" smol = "2.0" libc = "0.2" +fs2 = "0.4" +# `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/access_history.rs b/crates/okena-workspace/src/access_history.rs index 4256d66a9..5dae124bc 100644 --- a/crates/okena-workspace/src/access_history.rs +++ b/crates/okena-workspace/src/access_history.rs @@ -15,7 +15,8 @@ impl ProjectAccessHistory { /// Record that a project was just accessed. pub fn touch(&mut self, project_id: &str) { - self.access_times.insert(project_id.to_string(), Instant::now()); + self.access_times + .insert(project_id.to_string(), Instant::now()); } pub fn accessed_at(&self, project_id: &str) -> Option<Instant> { diff --git a/crates/okena-workspace/src/actions/focus.rs b/crates/okena-workspace/src/actions/focus.rs index 8c8f09491..8f2ab56b8 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::*; +use crate::state::{WindowId, Workspace}; impl Workspace { /// Set focused project (focus mode) @@ -22,7 +22,7 @@ impl Workspace { &mut self, focus_manager: &mut FocusManager, project_id: Option<String>, - cx: &mut Context<Self>, + 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<String>, - cx: &mut Context<Self>, + 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<Self>, + cx: &mut impl WorkspaceCx, ) { let selecting = self.active_folder_filter(window_id).map(|s| s.as_str()) != Some(folder_id); if selecting { @@ -80,7 +80,11 @@ impl Workspace { // Clear project focus so all visible folder projects show focus_manager.set_focused_project_id(None); // Focus the first project's terminal - if let Some(first_pid) = self.folder(folder_id).and_then(|f| f.project_ids.first()).cloned() { + if let Some(first_pid) = self + .folder(folder_id) + .and_then(|f| f.project_ids.first()) + .cloned() + { self.focus_first_terminal_in(focus_manager, &first_pid); } } else { @@ -100,15 +104,16 @@ impl Workspace { /// out of `Modal`/`Fullscreen`. pub(crate) fn first_terminal_target_in(&self, project_id: &str) -> Option<FocusTarget> { // Try the project itself first, then its worktree children - let candidates = std::iter::once(project_id.to_string()) - .chain(self.worktree_child_ids(project_id)); + let candidates = + std::iter::once(project_id.to_string()).chain(self.worktree_child_ids(project_id)); for id in candidates { if let Some(project) = self.project(&id) - && let Some(layout) = project.layout.as_ref() { - // Follow active tabs to the currently visible terminal - let path = layout.find_visible_terminal_path(); - return Some(FocusTarget::new(id, path)); - } + && let Some(layout) = project.layout.as_ref() + { + // Follow active tabs to the currently visible terminal + let path = layout.find_visible_terminal_path(); + return Some(FocusTarget::new(id, path)); + } } None } @@ -133,12 +138,17 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: String, terminal_id: String, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { - log::info!("set_fullscreen_terminal called with project_id={}, terminal_id={}", project_id, terminal_id); + log::info!( + "set_fullscreen_terminal called with project_id={}, terminal_id={}", + project_id, + terminal_id + ); // Find the layout path for this terminal - let layout_path = self.project(&project_id) + let layout_path = self + .project(&project_id) .and_then(|p| p.layout.as_ref()) .and_then(|l| l.find_terminal_path(&terminal_id)) .unwrap_or_default(); @@ -148,7 +158,10 @@ impl Workspace { // Use FocusManager for fullscreen entry (saves current state + sets focused_project_id) focus_manager.enter_fullscreen(project_id, layout_path, terminal_id.clone()); - log::info!("fullscreen_terminal set via FocusManager with terminal_id={}", terminal_id); + log::info!( + "fullscreen_terminal set via FocusManager with terminal_id={}", + terminal_id + ); cx.notify(); } @@ -156,7 +169,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<Self>) { + pub fn exit_fullscreen(&mut self, focus_manager: &mut FocusManager, cx: &mut impl WorkspaceCx) { focus_manager.exit_fullscreen(); cx.notify(); } @@ -169,7 +182,7 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: String, layout_path: Vec<usize>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { // Update FocusManager focus_manager.focus_terminal(project_id.clone(), layout_path.clone()); @@ -187,7 +200,11 @@ 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<Self>) { + pub fn clear_focused_terminal( + &mut self, + focus_manager: &mut FocusManager, + cx: &mut impl WorkspaceCx, + ) { focus_manager.enter_modal(); cx.notify(); } @@ -195,7 +212,11 @@ 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<Self>) { + pub fn restore_focused_terminal( + &mut self, + focus_manager: &mut FocusManager, + cx: &mut impl WorkspaceCx, + ) { focus_manager.exit_modal(); cx.notify(); } @@ -208,28 +229,30 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: &str, terminal_id: &str, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project(project_id) && let Some(ref layout) = project.layout - && let Some(path) = layout.find_terminal_path(terminal_id) { - // Activate any tabs along the path so the terminal becomes visible - if let Some(project_mut) = self.project_mut(project_id) - && let Some(ref mut layout) = project_mut.layout { - layout.activate_tabs_along_path(&path); - } - self.notify_data(cx); - // Focus the terminal without changing which projects are shown - self.set_focused_terminal(focus_manager, project_id.to_string(), path, cx); - } + && let Some(path) = layout.find_terminal_path(terminal_id) + { + // Activate any tabs along the path so the terminal becomes visible + if let Some(project_mut) = self.project_mut(project_id) + && let Some(ref mut layout) = project_mut.layout + { + layout.activate_tabs_along_path(&path); + } + self.notify_data(cx); + // Focus the terminal without changing which projects are shown + self.set_focused_terminal(focus_manager, project_id.to_string(), path, cx); + } } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod gpui_tests { - use gpui::AppContext as _; use crate::focus::FocusManager; use crate::state::{FolderData, WindowId, WindowState, Workspace, WorkspaceData}; + use gpui::AppContext as _; use okena_core::theme::FolderColor; use std::collections::HashMap; @@ -303,11 +326,17 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { // Targeted extra got the filter. - assert_eq!(ws.data().extra_windows[0].folder_filter.as_deref(), Some("f1")); + assert_eq!( + ws.data().extra_windows[0].folder_filter.as_deref(), + Some("f1") + ); // Main stays untouched. assert!(ws.data().main_window.folder_filter.is_none()); // Sibling extra's pre-existing filter is preserved. - assert_eq!(ws.data().extra_windows[1].folder_filter.as_deref(), Some("f1")); + assert_eq!( + ws.data().extra_windows[1].folder_filter.as_deref(), + Some("f1") + ); assert_eq!(extra_b_id, ws.data().extra_windows[1].id); }); } @@ -333,7 +362,10 @@ mod gpui_tests { ws.toggle_folder_focus(&mut fm, WindowId::Extra(extra_a_id), "f1", cx); }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.data().extra_windows[0].folder_filter.as_deref(), Some("f1")); + assert_eq!( + ws.data().extra_windows[0].folder_filter.as_deref(), + Some("f1") + ); }); workspace.update(cx, |ws: &mut Workspace, cx| { diff --git a/crates/okena-workspace/src/actions/folder.rs b/crates/okena-workspace/src/actions/folder.rs index 08d399680..bcec0c369 100644 --- a/crates/okena-workspace/src/actions/folder.rs +++ b/crates/okena-workspace/src/actions/folder.rs @@ -2,13 +2,13 @@ //! //! 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::*; +use okena_core::theme::FolderColor; impl Workspace { /// Create a new folder, appending it to project_order - pub fn create_folder(&mut self, name: String, cx: &mut Context<Self>) -> 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,14 +22,22 @@ 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<Self>) { - let project_ids = self.data.folders.iter() + 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()) .unwrap_or_default(); // Find folder position in project_order - if let Some(pos) = self.data.project_order.iter().position(|id| id == folder_id) { + if let Some(pos) = self + .data + .project_order + .iter() + .position(|id| id == folder_id) + { self.data.project_order.remove(pos); // Insert contained projects at the folder's old position for (i, pid) in project_ids.into_iter().enumerate() { @@ -43,7 +51,7 @@ impl Workspace { } /// Rename a folder - pub fn rename_folder(&mut self, folder_id: &str, new_name: String, cx: &mut Context<Self>) { + 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 +59,12 @@ impl Workspace { } /// Set the color for a folder - pub fn set_folder_item_color(&mut self, folder_id: &str, color: FolderColor, cx: &mut Context<Self>) { + 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 +104,12 @@ 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<Self>) { + 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 +118,13 @@ 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<usize>, cx: &mut Context<Self>) { + pub fn move_project_to_folder( + &mut self, + project_id: &str, + folder_id: &str, + position: Option<usize>, + 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 +143,12 @@ 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<Self>) { + 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); @@ -128,29 +157,43 @@ impl Workspace { self.data.project_order.retain(|id| id != project_id); let target = top_level_index.min(self.data.project_order.len()); - self.data.project_order.insert(target, project_id.to_string()); + self.data + .project_order + .insert(target, project_id.to_string()); self.notify_data(cx); } /// 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<Self>) { + 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); - let target = if new_index > current { - new_index.saturating_sub(1) - } else { - new_index - }; - let target = target.min(folder.project_ids.len()); - folder.project_ids.insert(target, id); - self.notify_data(cx); - } + && let Some(current) = folder.project_ids.iter().position(|id| id == project_id) + { + let id = folder.project_ids.remove(current); + let target = if new_index > current { + new_index.saturating_sub(1) + } else { + new_index + }; + let target = target.min(folder.project_ids.len()); + folder.project_ids.insert(target, id); + self.notify_data(cx); + } } /// 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<Self>) { + 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 { @@ -167,8 +210,8 @@ impl Workspace { #[cfg(test)] mod tests { - use crate::state::*; use crate::settings::HooksConfig; + use crate::state::*; use okena_core::theme::FolderColor; use std::collections::HashMap; @@ -191,6 +234,8 @@ mod tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -209,7 +254,9 @@ mod tests { /// Simulate delete_folder: splice projects back into project_order fn simulate_delete_folder(data: &mut WorkspaceData, folder_id: &str) { - let project_ids = data.folders.iter() + let project_ids = data + .folders + .iter() .find(|f| f.id == folder_id) .map(|f| f.project_ids.clone()) .unwrap_or_default(); @@ -224,7 +271,12 @@ mod tests { } /// Simulate move_project_to_folder - fn simulate_move_to_folder(data: &mut WorkspaceData, project_id: &str, folder_id: &str, position: Option<usize>) { + fn simulate_move_to_folder( + data: &mut WorkspaceData, + project_id: &str, + folder_id: &str, + position: Option<usize>, + ) { for folder in &mut data.folders { folder.project_ids.retain(|id| id != project_id); } @@ -276,11 +328,13 @@ 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}; use crate::settings::HooksConfig; + use crate::state::{ + FolderData, LayoutNode, ProjectData, WindowId, WindowState, Workspace, WorkspaceData, + }; + use gpui::AppContext as _; use okena_core::theme::FolderColor; use std::collections::HashMap; @@ -303,6 +357,8 @@ mod gpui_tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -338,10 +394,8 @@ mod gpui_tests { #[gpui::test] fn test_delete_folder_gpui(cx: &mut gpui::TestAppContext) { - let mut data = make_workspace_data( - vec![make_project("p1"), make_project("p2")], - vec!["f1"], - ); + let mut data = + make_workspace_data(vec![make_project("p1"), make_project("p2")], vec!["f1"]); data.folders = vec![FolderData { id: "f1".to_string(), name: "Folder".to_string(), @@ -395,13 +449,13 @@ mod gpui_tests { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; let workspace = cx.new(|_cx| Workspace::new(data)); @@ -412,7 +466,10 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.active_folder_filter(WindowId::Main), Some(&"f1".to_string())); + assert_eq!( + ws.active_folder_filter(WindowId::Main), + Some(&"f1".to_string()) + ); }); // Delete f1 — filter should auto-clear @@ -439,7 +496,9 @@ mod gpui_tests { project_ids: vec![], folder_color: FolderColor::default(), }]; - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -494,7 +553,9 @@ mod gpui_tests { project_ids: vec![], folder_color: FolderColor::default(), }]; - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); let workspace = cx.new(|_cx| Workspace::new(data)); let unknown = uuid::Uuid::new_v4(); @@ -524,7 +585,10 @@ mod gpui_tests { ws.toggle_folder_collapsed(WindowId::Main, "f1", cx); }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.data().main_window.folder_collapsed.get("f1"), Some(&true)); + assert_eq!( + ws.data().main_window.folder_collapsed.get("f1"), + Some(&true) + ); }); // Second toggle: true -> false. main_window removes the entry @@ -604,17 +668,16 @@ mod gpui_tests { // (the new source of truth). Without the scrub, a re-added folder with // the same id would inherit the deleted folder's collapsed state on // the next render. - let mut data = make_workspace_data( - vec![make_project("p1")], - vec!["f1"], - ); + let mut data = make_workspace_data(vec![make_project("p1")], vec!["f1"]); data.folders = vec![FolderData { id: "f1".to_string(), name: "Folder".to_string(), project_ids: vec!["p1".to_string()], folder_color: FolderColor::default(), }]; - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { @@ -647,7 +710,9 @@ mod gpui_tests { }, ]; data.main_window.folder_filter = Some("f1".to_string()); - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); let mut extra = WindowState { folder_filter: Some("f1".to_string()), @@ -684,13 +749,13 @@ mod gpui_tests { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; let workspace = cx.new(|_cx| Workspace::new(data)); @@ -706,7 +771,10 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.active_folder_filter(WindowId::Main), Some(&"f1".to_string())); + assert_eq!( + ws.active_folder_filter(WindowId::Main), + Some(&"f1".to_string()) + ); }); } } diff --git a/crates/okena-workspace/src/actions/layout/close.rs b/crates/okena-workspace/src/actions/layout/close.rs index 6e45a0fd0..49615be1d 100644 --- a/crates/okena-workspace/src/actions/layout/close.rs +++ b/crates/okena-workspace/src/actions/layout/close.rs @@ -1,61 +1,75 @@ //! 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<Self>) -> Vec<String> { + pub fn close_terminal( + &mut self, + project_id: &str, + path: &[usize], + cx: &mut impl WorkspaceCx, + ) -> Vec<String> { if let Some(project) = self.project_mut(project_id) - && let Some(ref mut layout) = project.layout { - if path.is_empty() { - // Closing root - remove layout entirely (project becomes bookmark) - project.layout = None; - self.notify_data(cx); - return self.cleanup_orphaned_metadata(project_id); + && let Some(ref mut layout) = project.layout + { + if path.is_empty() { + // Closing root - remove layout entirely (project becomes bookmark) + project.layout = None; + self.notify_data(cx); + return self.cleanup_orphaned_metadata(project_id); + } + + // Focus-preservation: if the parent is a Tabs container and we're + // closing a tab *before* the active one, the active tab shifts left + // by one (its content moves to index active_tab - 1). Capture the + // original active_tab here so we can set the new index absolutely + // after removal — we must NOT decrement the post-removal value, + // because remove_at_path already clamps active_tab when it was the + // last tab, and an additional decrement would overshoot by one. + let (parent_path, child_index) = path.split_at(path.len() - 1); + let child_index = child_index[0]; + let prev_active_tab = match layout.get_at_path(parent_path) { + Some(LayoutNode::Tabs { active_tab, .. }) if child_index < *active_tab => { + Some(*active_tab) } + _ => None, + }; - // Focus-preservation: if the parent is a Tabs container and we're - // closing a tab *before* the active one, the active tab shifts left - // by one (its content moves to index active_tab - 1). Capture the - // original active_tab here so we can set the new index absolutely - // after removal — we must NOT decrement the post-removal value, - // because remove_at_path already clamps active_tab when it was the - // last tab, and an additional decrement would overshoot by one. - let (parent_path, child_index) = path.split_at(path.len() - 1); - let child_index = child_index[0]; - let prev_active_tab = match layout.get_at_path(parent_path) { - Some(LayoutNode::Tabs { active_tab, .. }) if child_index < *active_tab => { - Some(*active_tab) - } - _ => None, - }; - - // Delegate the tree mutation (remove child, collapse a parent left - // with a single child, clamp active_tab) to the shared, unit-tested - // LayoutNode::remove_at_path. It returns the removed node, or None - // for an invalid path (out-of-range index / Terminal parent). - if layout.remove_at_path(path).is_some() { - // After removal the parent may have collapsed (single child left) - // or no longer be a Tabs — skip those; only adjust a surviving Tabs. - if let Some(prev_active_tab) = prev_active_tab - && let Some(LayoutNode::Tabs { children, active_tab }) = - layout.get_at_path_mut(parent_path) - { - *active_tab = (prev_active_tab - 1).min(children.len().saturating_sub(1)); - } - self.notify_data(cx); - return self.cleanup_orphaned_metadata(project_id); + // Delegate the tree mutation (remove child, collapse a parent left + // with a single child, clamp active_tab) to the shared, unit-tested + // LayoutNode::remove_at_path. It returns the removed node, or None + // for an invalid path (out-of-range index / Terminal parent). + if layout.remove_at_path(path).is_some() { + // After removal the parent may have collapsed (single child left) + // or no longer be a Tabs — skip those; only adjust a surviving Tabs. + if let Some(prev_active_tab) = prev_active_tab + && let Some(LayoutNode::Tabs { + children, + active_tab, + }) = layout.get_at_path_mut(parent_path) + { + *active_tab = (prev_active_tab - 1).min(children.len().saturating_sub(1)); } + self.notify_data(cx); + return self.cleanup_orphaned_metadata(project_id); } + } vec![] } /// 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<Self>) -> Vec<String> { + pub fn close_terminal_and_focus_sibling( + &mut self, + focus_manager: &mut FocusManager, + project_id: &str, + path: &[usize], + cx: &mut impl WorkspaceCx, + ) -> Vec<String> { if path.is_empty() { // Closing root - remove layout (project becomes bookmark) let removed = self.close_terminal(project_id, path, cx); @@ -88,7 +102,8 @@ impl Workspace { } else { // Parent keeps multiple children // Focus previous sibling, or next if closing first - let sibling_index = if child_index > 0 { child_index - 1 } else { 1 }; + let sibling_index = + if child_index > 0 { child_index - 1 } else { 1 }; if let Some(sibling) = children.get(sibling_index) { let relative_path = sibling.find_first_terminal_path(); let mut full_path = parent_path.to_vec(); @@ -135,10 +150,14 @@ impl Workspace { project_id: &str, path: &[usize], tab_index: usize, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Vec<String> { let applied = self.with_layout_node(project_id, path, cx, |node| { - if let LayoutNode::Tabs { children, active_tab } = node { + if let LayoutNode::Tabs { + children, + active_tab, + } = node + { if tab_index >= children.len() || children.len() <= 1 { return false; } @@ -164,7 +183,11 @@ impl Workspace { } }); - if applied { self.cleanup_orphaned_metadata(project_id) } else { vec![] } + if applied { + self.cleanup_orphaned_metadata(project_id) + } else { + vec![] + } } /// Close all tabs except the one at the specified index. @@ -175,7 +198,7 @@ impl Workspace { project_id: &str, path: &[usize], keep_index: usize, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Vec<String> { let applied = self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { children, .. } = node { @@ -191,7 +214,11 @@ impl Workspace { } }); - if applied { self.cleanup_orphaned_metadata(project_id) } else { vec![] } + if applied { + self.cleanup_orphaned_metadata(project_id) + } else { + vec![] + } } /// Close all tabs to the right of the specified index. @@ -202,10 +229,14 @@ impl Workspace { project_id: &str, path: &[usize], from_index: usize, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Vec<String> { let applied = self.with_layout_node(project_id, path, cx, |node| { - if let LayoutNode::Tabs { children, active_tab } = node { + if let LayoutNode::Tabs { + children, + active_tab, + } = node + { if from_index >= children.len() { return false; } @@ -227,6 +258,10 @@ impl Workspace { } }); - if applied { self.cleanup_orphaned_metadata(project_id) } else { vec![] } + if applied { + self.cleanup_orphaned_metadata(project_id) + } else { + vec![] + } } } diff --git a/crates/okena-workspace/src/actions/layout/mod.rs b/crates/okena-workspace/src/actions/layout/mod.rs index b130adfc7..ea0f10cf5 100644 --- a/crates/okena-workspace/src/actions/layout/mod.rs +++ b/crates/okena-workspace/src/actions/layout/mod.rs @@ -22,11 +22,15 @@ impl Workspace { return vec![]; }; - let layout_ids: std::collections::HashSet<String> = project.layout.as_ref() + let layout_ids: std::collections::HashSet<String> = project + .layout + .as_ref() .map(|l| l.collect_terminal_ids().into_iter().collect()) .unwrap_or_default(); - let orphaned: Vec<String> = project.terminal_names.keys() + let orphaned: Vec<String> = project + .terminal_names + .keys() .filter(|id| !layout_ids.contains(id.as_str())) .cloned() .collect(); @@ -43,5 +47,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..bd63ce505 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<Self>, + cx: &mut impl WorkspaceCx, ) { // Self-drop check if source_terminal_id == target_terminal_id { @@ -31,9 +31,24 @@ impl Workspace { } if source_project_id == target_project_id { - self.move_pane_same_project(focus_manager, source_project_id, source_terminal_id, target_terminal_id, zone, cx); + self.move_pane_same_project( + focus_manager, + source_project_id, + source_terminal_id, + target_terminal_id, + zone, + cx, + ); } else { - self.move_pane_cross_project(focus_manager, source_project_id, source_terminal_id, target_project_id, target_terminal_id, zone, cx); + self.move_pane_cross_project( + focus_manager, + source_project_id, + source_terminal_id, + target_project_id, + target_terminal_id, + zone, + cx, + ); } } @@ -45,7 +60,7 @@ impl Workspace { source_terminal_id: &str, target_terminal_id: &str, zone: DropZone, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let project = match self.project(project_id) { Some(p) => p, @@ -136,14 +151,24 @@ impl Workspace { target_project_id: &str, target_terminal_id: &str, zone: DropZone, - cx: &mut Context<Self>, + 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) { + let src_idx = match self + .data + .projects + .iter() + .position(|p| p.id == source_project_id) + { Some(i) => i, None => return, }; - let tgt_idx = match self.data.projects.iter().position(|p| p.id == target_project_id) { + let tgt_idx = match self + .data + .projects + .iter() + .position(|p| p.id == target_project_id) + { Some(i) => i, None => return, }; @@ -163,7 +188,11 @@ impl Workspace { }; // Block if terminal is a service terminal - if self.data.projects[src_idx].service_terminals.values().any(|id| id == source_terminal_id) { + if self.data.projects[src_idx] + .service_terminals + .values() + .any(|id| id == source_terminal_id) + { return; } @@ -196,20 +225,30 @@ impl Workspace { let hidden_state = src_project.hidden_terminals.remove(source_terminal_id); // Cleanup orphaned source metadata - let src_layout_ids: std::collections::HashSet<String> = src_project.layout.as_ref() + let src_layout_ids: std::collections::HashSet<String> = src_project + .layout + .as_ref() .map(|l| l.collect_terminal_ids().into_iter().collect()) .unwrap_or_default(); - src_project.terminal_names.retain(|id, _| src_layout_ids.contains(id)); - src_project.hidden_terminals.retain(|id, _| src_layout_ids.contains(id)); + src_project + .terminal_names + .retain(|id, _| src_layout_ids.contains(id)); + src_project + .hidden_terminals + .retain(|id, _| src_layout_ids.contains(id)); // --- Insert into target --- let tgt_project = &mut self.data.projects[tgt_idx]; if let Some(name) = terminal_name { - tgt_project.terminal_names.insert(source_terminal_id.to_string(), name); + tgt_project + .terminal_names + .insert(source_terminal_id.to_string(), name); } if let Some(hidden) = hidden_state { - tgt_project.hidden_terminals.insert(source_terminal_id.to_string(), hidden); + tgt_project + .hidden_terminals + .insert(source_terminal_id.to_string(), hidden); } let new_focus_path = if let Some(ref mut tgt_layout) = tgt_project.layout { @@ -247,7 +286,11 @@ impl Workspace { } /// Build wrapper node for drop zone placement. - fn build_drop_zone_wrapper(source_node: LayoutNode, target_node: LayoutNode, zone: DropZone) -> LayoutNode { + fn build_drop_zone_wrapper( + source_node: LayoutNode, + target_node: LayoutNode, + zone: DropZone, + ) -> LayoutNode { match zone { DropZone::Top => LayoutNode::Split { direction: SplitDirection::Horizontal, @@ -298,12 +341,27 @@ impl Workspace { target_project_id: &str, tabs_path: &[usize], insert_index: Option<usize>, - cx: &mut Context<Self>, + 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); + self.move_terminal_to_tab_group_same_project( + focus_manager, + source_project_id, + terminal_id, + tabs_path, + insert_index, + cx, + ); } else { - self.move_terminal_to_tab_group_cross_project(focus_manager, source_project_id, terminal_id, target_project_id, tabs_path, insert_index, cx); + self.move_terminal_to_tab_group_cross_project( + focus_manager, + source_project_id, + terminal_id, + target_project_id, + tabs_path, + insert_index, + cx, + ); } } @@ -315,7 +373,7 @@ impl Workspace { terminal_id: &str, tabs_path: &[usize], insert_index: Option<usize>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let project = match self.project(project_id) { Some(p) => p, @@ -405,7 +463,11 @@ impl Workspace { None => return, }; - if let LayoutNode::Tabs { children, active_tab } = tabs_node { + if let LayoutNode::Tabs { + children, + active_tab, + } = tabs_node + { let idx = insert_index.unwrap_or(children.len()); let clamped = idx.min(children.len()); children.insert(clamped, source_node); @@ -435,13 +497,23 @@ impl Workspace { target_project_id: &str, tabs_path: &[usize], insert_index: Option<usize>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { - let src_idx = match self.data.projects.iter().position(|p| p.id == source_project_id) { + let src_idx = match self + .data + .projects + .iter() + .position(|p| p.id == source_project_id) + { Some(i) => i, None => return, }; - let tgt_idx = match self.data.projects.iter().position(|p| p.id == target_project_id) { + let tgt_idx = match self + .data + .projects + .iter() + .position(|p| p.id == target_project_id) + { Some(i) => i, None => return, }; @@ -461,7 +533,11 @@ impl Workspace { }; // Block service terminals - if self.data.projects[src_idx].service_terminals.values().any(|id| id == terminal_id) { + if self.data.projects[src_idx] + .service_terminals + .values() + .any(|id| id == terminal_id) + { return; } @@ -503,20 +579,30 @@ impl Workspace { let hidden_state = src_project.hidden_terminals.remove(terminal_id); // Cleanup orphaned source metadata - let src_layout_ids: std::collections::HashSet<String> = src_project.layout.as_ref() + let src_layout_ids: std::collections::HashSet<String> = src_project + .layout + .as_ref() .map(|l| l.collect_terminal_ids().into_iter().collect()) .unwrap_or_default(); - src_project.terminal_names.retain(|id, _| src_layout_ids.contains(id)); - src_project.hidden_terminals.retain(|id, _| src_layout_ids.contains(id)); + src_project + .terminal_names + .retain(|id, _| src_layout_ids.contains(id)); + src_project + .hidden_terminals + .retain(|id, _| src_layout_ids.contains(id)); // --- Insert into target --- let tgt_project = &mut self.data.projects[tgt_idx]; if let Some(name) = terminal_name { - tgt_project.terminal_names.insert(terminal_id.to_string(), name); + tgt_project + .terminal_names + .insert(terminal_id.to_string(), name); } if let Some(hidden) = hidden_state { - tgt_project.hidden_terminals.insert(terminal_id.to_string(), hidden); + tgt_project + .hidden_terminals + .insert(terminal_id.to_string(), hidden); } let new_focus_path = if let Some(ref mut tgt_layout) = tgt_project.layout { @@ -536,7 +622,11 @@ impl Workspace { None => return, }; - if let LayoutNode::Tabs { children, active_tab } = tabs_node { + if let LayoutNode::Tabs { + children, + active_tab, + } = tabs_node + { let idx = insert_index.unwrap_or(children.len()); let clamped = idx.min(children.len()); children.insert(clamped, source_node); diff --git a/crates/okena-workspace/src/actions/layout/split.rs b/crates/okena-workspace/src/actions/layout/split.rs index f52575066..38b3d1ed8 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,9 +12,13 @@ impl Workspace { project_id: &str, path: &[usize], direction: SplitDirection, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { - log::info!("Workspace::split_terminal called for project {} at path {:?}", project_id, path); + log::info!( + "Workspace::split_terminal called for project {} at path {:?}", + project_id, + path + ); // If the target node is inside a Tabs container, split the Tabs container // instead of splitting inside the tab. This avoids nested splits within tabs @@ -76,7 +80,7 @@ impl Workspace { project_id: &str, path: &[usize], new_sizes: Vec<f32>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Split { sizes, .. } = node { @@ -96,33 +100,42 @@ impl Workspace { project_id: &str, path: &[usize], new_sizes: Vec<f32>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) && let Some(ref mut layout) = project.layout - && let Some(node) = layout.get_at_path_mut(path) - && let LayoutNode::Split { sizes, .. } = node { - *sizes = new_sizes; - self.notify_ui_only(cx); - } + && let Some(node) = layout.get_at_path_mut(path) + && let LayoutNode::Split { sizes, .. } = node + { + *sizes = new_sizes; + self.notify_ui_only(cx); + } } /// Equalize pane sizes in the focused terminal's parent split. - pub fn equalize_focused_split(&mut self, focus_manager: &FocusManager, cx: &mut Context<Self>) { + 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 { - let parent_path = if target.layout_path.is_empty() { - &target.layout_path[..] - } else { - &target.layout_path[..target.layout_path.len() - 1] - }; - if let Some(node) = layout.get_at_path_mut(parent_path) - && let LayoutNode::Split { sizes, children, .. } = node { - let n = children.len(); - *sizes = vec![100.0 / n as f32; n]; - } - } + && let Some(ref mut layout) = project.layout + { + let parent_path = if target.layout_path.is_empty() { + &target.layout_path[..] + } else { + &target.layout_path[..target.layout_path.len() - 1] + }; + if let Some(node) = layout.get_at_path_mut(parent_path) + && let LayoutNode::Split { + sizes, children, .. + } = node + { + let n = children.len(); + *sizes = vec![100.0 / n as f32; n]; + } + } self.notify_data(cx); } } diff --git a/crates/okena-workspace/src/actions/layout/tabs.rs b/crates/okena-workspace/src/actions/layout/tabs.rs index b011dd5d2..47ee118fd 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,20 +11,25 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: &str, path: &[usize], - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { - log::info!("Workspace::add_tab called for project {} at path {:?}", project_id, path); + log::info!( + "Workspace::add_tab called for project {} at path {:?}", + project_id, + path + ); // Check if parent is a Tabs container if !path.is_empty() { let parent_path = &path[..path.len() - 1]; if let Some(project) = self.project(project_id) && let Some(ref layout) = project.layout - && let Some(LayoutNode::Tabs { .. }) = layout.get_at_path(parent_path) { - // Parent is Tabs - add new tab to the group - self.add_tab_to_group(focus_manager, project_id, parent_path, cx); - return; - } + && let Some(LayoutNode::Tabs { .. }) = layout.get_at_path(parent_path) + { + // Parent is Tabs - add new tab to the group + self.add_tab_to_group(focus_manager, project_id, parent_path, cx); + return; + } } // Parent is not Tabs - create new tab group @@ -50,15 +55,22 @@ impl Workspace { focus_manager: &mut FocusManager, project_id: &str, tabs_path: &[usize], - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let mut new_tab_index = 0; self.with_layout_node(project_id, tabs_path, cx, |node| { - if let LayoutNode::Tabs { children, active_tab } = node { + if let LayoutNode::Tabs { + children, + active_tab, + } = node + { children.push(LayoutNode::new_terminal()); *active_tab = children.len() - 1; new_tab_index = *active_tab; - log::info!("Added new tab to existing group, now {} tabs", children.len()); + log::info!( + "Added new tab to existing group, now {} tabs", + children.len() + ); true } else { false @@ -77,7 +89,7 @@ impl Workspace { project_id: &str, path: &[usize], tab_index: usize, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Tabs { active_tab, .. } = node { @@ -96,10 +108,14 @@ impl Workspace { path: &[usize], from_index: usize, to_index: usize, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { - if let LayoutNode::Tabs { children, active_tab } = node { + if let LayoutNode::Tabs { + children, + active_tab, + } = node + { if from_index >= children.len() || to_index >= children.len() { return false; } diff --git a/crates/okena-workspace/src/actions/layout/tests_gpui.rs b/crates/okena-workspace/src/actions/layout/tests_gpui.rs index 30afcdaa9..03dff4e20 100644 --- a/crates/okena-workspace/src/actions/layout/tests_gpui.rs +++ b/crates/okena-workspace/src/actions/layout/tests_gpui.rs @@ -1,12 +1,12 @@ //! GPUI-level integration tests for layout actions: exercise the full Workspace //! entity via TestAppContext. -use gpui::AppContext as _; use crate::focus::FocusManager; -use crate::state::{DropZone, LayoutNode, ProjectData, SplitDirection, Workspace, WorkspaceData}; use crate::settings::HooksConfig; -use okena_terminal::shell_config::ShellType; +use crate::state::{DropZone, LayoutNode, ProjectData, SplitDirection, Workspace, WorkspaceData}; +use gpui::AppContext as _; use okena_core::theme::FolderColor; +use okena_terminal::shell_config::ShellType; use std::collections::HashMap; fn make_project(id: &str) -> ProjectData { @@ -34,6 +34,18 @@ fn make_project(id: &str) -> ProjectData { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, + } +} + +fn terminal(id: &str) -> LayoutNode { + LayoutNode::Terminal { + terminal_id: Some(id.to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, } } @@ -58,7 +70,13 @@ fn test_split_terminal_gpui(cx: &mut gpui::TestAppContext) { let v0 = workspace.read_with(cx, |ws: &Workspace, _cx| ws.data_version()); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.split_terminal(&mut FocusManager::new(), "p1", &[], SplitDirection::Vertical, cx); + ws.split_terminal( + &mut FocusManager::new(), + "p1", + &[], + SplitDirection::Vertical, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -90,7 +108,10 @@ fn test_add_tab_gpui(cx: &mut gpui::TestAppContext) { workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); assert_eq!(*active_tab, 1); } @@ -137,6 +158,39 @@ fn test_close_terminal_gpui(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +fn split_close_cycle_preserves_pane_weights(cx: &mut gpui::TestAppContext) { + let mut project = make_project("p1"); + project.layout = Some(LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![66.0, 34.0], + children: vec![terminal("t1"), terminal("t2")], + }); + let data = make_workspace_data(vec![project], vec!["p1"]); + let workspace = cx.new(|_cx| Workspace::new(data)); + + for _ in 0..4 { + workspace.update(cx, |ws: &mut Workspace, cx| { + ws.split_terminal( + &mut FocusManager::new(), + "p1", + &[0], + SplitDirection::Horizontal, + cx, + ); + ws.close_terminal("p1", &[1], cx); + }); + } + + workspace.read_with(cx, |ws: &Workspace, _cx| { + let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); + let LayoutNode::Split { sizes, .. } = layout else { + panic!("Expected split after split/close cycles"); + }; + assert_eq!(sizes, &[66.0, 34.0]); + }); +} + #[gpui::test] fn test_close_tab_gpui(cx: &mut gpui::TestAppContext) { let mut project = make_project("p1"); @@ -177,15 +231,24 @@ fn test_close_tab_gpui(cx: &mut gpui::TestAppContext) { workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); // active_tab was 2, after removing index 0 it should be 1 assert_eq!(*active_tab, 1); // Remaining are t2 and t3 - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t2", "t3"]); } _ => panic!("Expected tabs"), @@ -231,8 +294,14 @@ fn test_close_terminal_before_active_tab_preserves_focus_gpui(cx: &mut gpui::Tes // Capture which terminal the active tab points at before the close. let active_id_before = workspace.read_with(cx, |ws: &Workspace, _cx| { match ws.project("p1").unwrap().layout.as_ref().unwrap() { - LayoutNode::Tabs { children, active_tab } => match &children[*active_tab] { - LayoutNode::Terminal { terminal_id: Some(id), .. } => id.clone(), + LayoutNode::Tabs { + children, + active_tab, + } => match &children[*active_tab] { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => id.clone(), _ => panic!("Expected terminal"), }, _ => panic!("Expected tabs"), @@ -248,20 +317,32 @@ fn test_close_terminal_before_active_tab_preserves_focus_gpui(cx: &mut gpui::Tes workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); // active_tab decremented from 1 -> 0 so it still points at t2. assert_eq!(*active_tab, 0); match &children[*active_tab] { - LayoutNode::Terminal { terminal_id: Some(id), .. } => { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => { assert_eq!(id, &active_id_before); } _ => panic!("Expected terminal"), } - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t2", "t3"]); } _ => panic!("Expected tabs"), @@ -298,18 +379,30 @@ fn test_close_before_last_active_tab_preserves_focus_gpui(cx: &mut gpui::TestApp workspace.read_with(cx, |ws: &Workspace, _cx| { match ws.project("p1").unwrap().layout.as_ref().unwrap() { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); // active_tab shifts 2 -> 1 (single shift), still pointing at t3. assert_eq!(*active_tab, 1); match &children[*active_tab] { - LayoutNode::Terminal { terminal_id: Some(id), .. } => assert_eq!(id, "t3"), + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => assert_eq!(id, "t3"), _ => panic!("Expected terminal"), } - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t2", "t3"]); } _ => panic!("Expected tabs"), @@ -357,11 +450,20 @@ fn test_move_tab_gpui(cx: &mut gpui::TestAppContext) { workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + LayoutNode::Tabs { + children, + active_tab, + } => { + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t2", "t3", "t1"]); assert_eq!(*active_tab, 2); // active_tab was 0 (the moved tab), should follow } @@ -401,6 +503,8 @@ fn make_project_with_layout(id: &str, layout: LayoutNode) -> ProjectData { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -416,7 +520,15 @@ fn test_move_pane_left_creates_vertical_split(cx: &mut gpui::TestAppContext) { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p1", "t2", DropZone::Left, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p1", + "t2", + DropZone::Left, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -446,14 +558,26 @@ fn test_move_pane_top_creates_horizontal_split(cx: &mut gpui::TestAppContext) { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p1", "t2", DropZone::Top, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p1", + "t2", + DropZone::Top, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); // t1 removed -> t2 becomes root. t1 dropped on top of t2 -> H[t1, t2] match layout { - LayoutNode::Split { direction, children, .. } => { + LayoutNode::Split { + direction, + children, + .. + } => { assert_eq!(*direction, SplitDirection::Horizontal); assert_eq!(children.len(), 2); let ids = layout.collect_terminal_ids(); @@ -476,13 +600,25 @@ fn test_move_pane_bottom_creates_horizontal_split(cx: &mut gpui::TestAppContext) let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p1", "t2", DropZone::Bottom, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p1", + "t2", + DropZone::Bottom, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Split { direction, children, .. } => { + LayoutNode::Split { + direction, + children, + .. + } => { assert_eq!(*direction, SplitDirection::Horizontal); assert_eq!(children.len(), 2); let ids = layout.collect_terminal_ids(); @@ -506,13 +642,24 @@ fn test_move_pane_center_creates_tab_group(cx: &mut gpui::TestAppContext) { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p1", "t2", DropZone::Center, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p1", + "t2", + DropZone::Center, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); assert_eq!(*active_tab, 1); let ids = layout.collect_terminal_ids(); @@ -537,7 +684,15 @@ fn test_move_pane_self_drop_is_noop(cx: &mut gpui::TestAppContext) { let v0 = workspace.read_with(cx, |ws: &Workspace, _cx| ws.data_version()); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p1", "t1", DropZone::Top, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p1", + "t1", + DropZone::Top, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -556,7 +711,15 @@ fn test_move_pane_only_terminal_is_noop(cx: &mut gpui::TestAppContext) { let v0 = workspace.read_with(cx, |ws: &Workspace, _cx| ws.data_version()); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "term_p1", "p1", "term_p1", DropZone::Left, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "term_p1", + "p1", + "term_p1", + DropZone::Left, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -585,19 +748,36 @@ fn test_move_terminal_to_tab_group_inserts_at_position(cx: &mut gpui::TestAppCon let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_terminal_to_tab_group(&mut FocusManager::new(), "p1", "t3", "p1", &[0], Some(1), cx); + ws.move_terminal_to_tab_group( + &mut FocusManager::new(), + "p1", + "t3", + "p1", + &[0], + Some(1), + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 3); assert_eq!(*active_tab, 1); - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t1", "t3", "t2"]); } _ => panic!("Expected tabs, got {:?}", layout), @@ -630,13 +810,22 @@ fn test_move_terminal_to_tab_group_appends(cx: &mut gpui::TestAppContext) { workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 3); assert_eq!(*active_tab, 2); - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t1", "t2", "t3"]); } _ => panic!("Expected tabs, got {:?}", layout), @@ -648,7 +837,11 @@ fn test_move_terminal_to_tab_group_appends(cx: &mut gpui::TestAppContext) { fn test_move_terminal_to_tab_group_same_group_reorders(cx: &mut gpui::TestAppContext) { // Tabs[t1, t2, t3] → move t1 (already in group) to index 2 → reorder let layout = LayoutNode::Tabs { - children: vec![terminal_node_t("t1"), terminal_node_t("t2"), terminal_node_t("t3")], + children: vec![ + terminal_node_t("t1"), + terminal_node_t("t2"), + terminal_node_t("t3"), + ], active_tab: 0, }; let project = make_project_with_layout("p1", layout); @@ -663,10 +856,16 @@ fn test_move_terminal_to_tab_group_same_group_reorders(cx: &mut gpui::TestAppCon let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); match layout { LayoutNode::Tabs { children, .. } => { - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t2", "t3", "t1"]); } _ => panic!("Expected tabs, got {:?}", layout), @@ -680,14 +879,26 @@ fn test_move_pane_3_children_with_flatten(cx: &mut gpui::TestAppContext) { let layout = LayoutNode::Split { direction: SplitDirection::Vertical, sizes: vec![33.0, 33.0, 34.0], - children: vec![terminal_node_t("t1"), terminal_node_t("t2"), terminal_node_t("t3")], + children: vec![ + terminal_node_t("t1"), + terminal_node_t("t2"), + terminal_node_t("t3"), + ], }; let project = make_project_with_layout("p1", layout); let data = make_workspace_data(vec![project], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p1", "t3", DropZone::Top, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p1", + "t3", + DropZone::Top, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -753,18 +964,22 @@ fn test_close_terminal_cleans_metadata(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_close_tab_cleans_metadata(cx: &mut gpui::TestAppContext) { let layout = LayoutNode::Tabs { - children: vec![terminal_node_t("t1"), terminal_node_t("t2"), terminal_node_t("t3")], + children: vec![ + terminal_node_t("t1"), + terminal_node_t("t2"), + terminal_node_t("t3"), + ], active_tab: 0, }; - let project = make_project_with_names("p1", layout, vec![ - ("t1", "Term 1"), ("t2", "Term 2"), ("t3", "Term 3"), - ]); + let project = make_project_with_names( + "p1", + layout, + vec![("t1", "Term 1"), ("t2", "Term 2"), ("t3", "Term 3")], + ); let data = make_workspace_data(vec![project], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); - let removed = workspace.update(cx, |ws: &mut Workspace, cx| { - ws.close_tab("p1", &[], 1, cx) - }); + let removed = workspace.update(cx, |ws: &mut Workspace, cx| ws.close_tab("p1", &[], 1, cx)); assert_eq!(removed, vec!["t2"]); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -778,12 +993,18 @@ fn test_close_tab_cleans_metadata(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_close_other_tabs_cleans_metadata(cx: &mut gpui::TestAppContext) { let layout = LayoutNode::Tabs { - children: vec![terminal_node_t("t1"), terminal_node_t("t2"), terminal_node_t("t3")], + children: vec![ + terminal_node_t("t1"), + terminal_node_t("t2"), + terminal_node_t("t3"), + ], active_tab: 0, }; - let project = make_project_with_names("p1", layout, vec![ - ("t1", "Term 1"), ("t2", "Term 2"), ("t3", "Term 3"), - ]); + let project = make_project_with_names( + "p1", + layout, + vec![("t1", "Term 1"), ("t2", "Term 2"), ("t3", "Term 3")], + ); let data = make_workspace_data(vec![project], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); @@ -805,12 +1026,18 @@ fn test_close_other_tabs_cleans_metadata(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_close_tabs_to_right_cleans_metadata(cx: &mut gpui::TestAppContext) { let layout = LayoutNode::Tabs { - children: vec![terminal_node_t("t1"), terminal_node_t("t2"), terminal_node_t("t3")], + children: vec![ + terminal_node_t("t1"), + terminal_node_t("t2"), + terminal_node_t("t3"), + ], active_tab: 0, }; - let project = make_project_with_names("p1", layout, vec![ - ("t1", "Term 1"), ("t2", "Term 2"), ("t3", "Term 3"), - ]); + let project = make_project_with_names( + "p1", + layout, + vec![("t1", "Term 1"), ("t2", "Term 2"), ("t3", "Term 3")], + ); let data = make_workspace_data(vec![project], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); @@ -834,28 +1061,45 @@ fn test_close_tabs_to_right_cleans_metadata(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_move_pane_cross_project(cx: &mut gpui::TestAppContext) { // p1: V[t1, t2], p2: t3 → move t1 to left of t3 → p1: t2, p2: V[t1, t3] - let p1 = make_project_with_layout("p1", LayoutNode::Split { - direction: SplitDirection::Vertical, - sizes: vec![50.0, 50.0], - children: vec![terminal_node_t("t1"), terminal_node_t("t2")], - }); + let p1 = make_project_with_layout( + "p1", + LayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![terminal_node_t("t1"), terminal_node_t("t2")], + }, + ); let p2 = make_project_with_layout("p2", terminal_node_t("t3")); let data = make_workspace_data(vec![p1, p2], vec!["p1", "p2"]); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p2", "t3", DropZone::Left, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p2", + "t3", + DropZone::Left, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { // p1 should have just t2 let p1_layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - assert!(matches!(p1_layout, LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "t2")); + assert!( + matches!(p1_layout, LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "t2") + ); // p2 should have V[t1, t3] let p2_layout = ws.project("p2").unwrap().layout.as_ref().unwrap(); match p2_layout { - LayoutNode::Split { direction, children, .. } => { + LayoutNode::Split { + direction, + children, + .. + } => { assert_eq!(*direction, SplitDirection::Vertical); assert_eq!(children.len(), 2); let ids = p2_layout.collect_terminal_ids(); @@ -869,23 +1113,37 @@ fn test_move_pane_cross_project(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_move_pane_cross_project_center(cx: &mut gpui::TestAppContext) { // p1: V[t1, t2], p2: t3 → move t1 center onto t3 → p2: Tabs[t3, t1] - let p1 = make_project_with_layout("p1", LayoutNode::Split { - direction: SplitDirection::Vertical, - sizes: vec![50.0, 50.0], - children: vec![terminal_node_t("t1"), terminal_node_t("t2")], - }); + let p1 = make_project_with_layout( + "p1", + LayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![terminal_node_t("t1"), terminal_node_t("t2")], + }, + ); let p2 = make_project_with_layout("p2", terminal_node_t("t3")); let data = make_workspace_data(vec![p1, p2], vec!["p1", "p2"]); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p2", "t3", DropZone::Center, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p2", + "t3", + DropZone::Center, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { let p2_layout = ws.project("p2").unwrap().layout.as_ref().unwrap(); match p2_layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); assert_eq!(*active_tab, 1); let ids = p2_layout.collect_terminal_ids(); @@ -905,7 +1163,15 @@ fn test_move_pane_cross_project_last_terminal(cx: &mut gpui::TestAppContext) { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p2", "t2", DropZone::Left, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p2", + "t2", + DropZone::Left, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -922,13 +1188,18 @@ fn test_move_pane_cross_project_last_terminal(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_move_pane_cross_project_metadata_migration(cx: &mut gpui::TestAppContext) { // Move t1 from p1 to p2, verify terminal_names and hidden_terminals migrate - let mut p1 = make_project_with_layout("p1", LayoutNode::Split { - direction: SplitDirection::Vertical, - sizes: vec![50.0, 50.0], - children: vec![terminal_node_t("t1"), terminal_node_t("t2")], - }); - p1.terminal_names.insert("t1".to_string(), "My Terminal".to_string()); - p1.terminal_names.insert("t2".to_string(), "Other Terminal".to_string()); + let mut p1 = make_project_with_layout( + "p1", + LayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![terminal_node_t("t1"), terminal_node_t("t2")], + }, + ); + p1.terminal_names + .insert("t1".to_string(), "My Terminal".to_string()); + p1.terminal_names + .insert("t2".to_string(), "Other Terminal".to_string()); p1.hidden_terminals.insert("t1".to_string(), true); let p2 = make_project_with_layout("p2", terminal_node_t("t3")); @@ -936,7 +1207,15 @@ fn test_move_pane_cross_project_metadata_migration(cx: &mut gpui::TestAppContext let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.move_pane(&mut FocusManager::new(), "p1", "t1", "p2", "t3", DropZone::Right, cx); + ws.move_pane( + &mut FocusManager::new(), + "p1", + "t1", + "p2", + "t3", + DropZone::Right, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -954,11 +1233,14 @@ fn test_move_pane_cross_project_metadata_migration(cx: &mut gpui::TestAppContext #[gpui::test] fn test_move_pane_cross_project_to_bookmark(cx: &mut gpui::TestAppContext) { // p2 has no layout (bookmark) → t1 becomes root of p2 - let p1 = make_project_with_layout("p1", LayoutNode::Split { - direction: SplitDirection::Vertical, - sizes: vec![50.0, 50.0], - children: vec![terminal_node_t("t1"), terminal_node_t("t2")], - }); + let p1 = make_project_with_layout( + "p1", + LayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![terminal_node_t("t1"), terminal_node_t("t2")], + }, + ); let mut p2 = make_project("p2"); p2.layout = None; let data = make_workspace_data(vec![p1, p2], vec!["p1", "p2"]); @@ -978,15 +1260,21 @@ fn test_move_pane_cross_project_to_bookmark(cx: &mut gpui::TestAppContext) { #[gpui::test] fn test_move_to_tab_group_cross_project(cx: &mut gpui::TestAppContext) { // p1: V[t1, t2], p2: Tabs[t3, t4] → move t1 into p2's tabs at index 1 - let p1 = make_project_with_layout("p1", LayoutNode::Split { - direction: SplitDirection::Vertical, - sizes: vec![50.0, 50.0], - children: vec![terminal_node_t("t1"), terminal_node_t("t2")], - }); - let p2 = make_project_with_layout("p2", LayoutNode::Tabs { - children: vec![terminal_node_t("t3"), terminal_node_t("t4")], - active_tab: 0, - }); + let p1 = make_project_with_layout( + "p1", + LayoutNode::Split { + direction: SplitDirection::Vertical, + sizes: vec![50.0, 50.0], + children: vec![terminal_node_t("t1"), terminal_node_t("t2")], + }, + ); + let p2 = make_project_with_layout( + "p2", + LayoutNode::Tabs { + children: vec![terminal_node_t("t3"), terminal_node_t("t4")], + active_tab: 0, + }, + ); let data = make_workspace_data(vec![p1, p2], vec!["p1", "p2"]); let workspace = cx.new(|_cx| Workspace::new(data)); @@ -997,18 +1285,29 @@ fn test_move_to_tab_group_cross_project(cx: &mut gpui::TestAppContext) { workspace.read_with(cx, |ws: &Workspace, _cx| { // p1 should have just t2 let p1_layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - assert!(matches!(p1_layout, LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "t2")); + assert!( + matches!(p1_layout, LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "t2") + ); // p2 should have Tabs[t3, t1, t4] let p2_layout = ws.project("p2").unwrap().layout.as_ref().unwrap(); match p2_layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 3); assert_eq!(*active_tab, 1); - let ids: Vec<_> = children.iter().filter_map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => Some(id.as_str()), - _ => None, - }).collect(); + let ids: Vec<_> = children + .iter() + .filter_map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => Some(id.as_str()), + _ => None, + }) + .collect(); assert_eq!(ids, vec!["t3", "t1", "t4"]); } _ => panic!("Expected tabs in p2, got {:?}", p2_layout), diff --git a/crates/okena-workspace/src/actions/layout/tests_simulate.rs b/crates/okena-workspace/src/actions/layout/tests_simulate.rs index a467f7e12..6726b2376 100644 --- a/crates/okena-workspace/src/actions/layout/tests_simulate.rs +++ b/crates/okena-workspace/src/actions/layout/tests_simulate.rs @@ -47,12 +47,24 @@ fn test_split_terminal_creates_split() { simulate_split(&mut layout, SplitDirection::Vertical); match &layout { - LayoutNode::Split { direction, children, sizes } => { + LayoutNode::Split { + direction, + children, + sizes, + } => { assert_eq!(*direction, SplitDirection::Vertical); assert_eq!(children.len(), 2); assert_eq!(sizes.len(), 2); - assert!(matches!(&children[0], LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "t1")); - assert!(matches!(&children[1], LayoutNode::Terminal { terminal_id: None, .. })); + assert!( + matches!(&children[0], LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "t1") + ); + assert!(matches!( + &children[1], + LayoutNode::Terminal { + terminal_id: None, + .. + } + )); } _ => panic!("Expected split"), } @@ -73,7 +85,11 @@ fn test_nested_split_normalizes() { layout.normalize(); match &layout { - LayoutNode::Split { direction, children, .. } => { + LayoutNode::Split { + direction, + children, + .. + } => { assert_eq!(*direction, SplitDirection::Horizontal); // Should be flattened to 3 children assert_eq!(children.len(), 3); @@ -88,7 +104,10 @@ fn test_add_tab_creates_tab_group() { simulate_add_tab(&mut layout); match &layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 2); assert_eq!(*active_tab, 1); } @@ -102,12 +121,19 @@ fn test_add_tab_to_existing_tabs() { children: vec![terminal_node("t1"), terminal_node("t2")], active_tab: 0, }; - if let LayoutNode::Tabs { children, active_tab } = &mut layout { + if let LayoutNode::Tabs { + children, + active_tab, + } = &mut layout + { children.push(LayoutNode::new_terminal()); *active_tab = children.len() - 1; } match &layout { - LayoutNode::Tabs { children, active_tab } => { + LayoutNode::Tabs { + children, + active_tab, + } => { assert_eq!(children.len(), 3); assert_eq!(*active_tab, 2); } @@ -131,18 +157,30 @@ fn test_close_terminal_from_3_child_split() { let mut layout = LayoutNode::Split { direction: SplitDirection::Horizontal, sizes: vec![33.0, 33.0, 34.0], - children: vec![terminal_node("t1"), terminal_node("t2"), terminal_node("t3")], + children: vec![ + terminal_node("t1"), + terminal_node("t2"), + terminal_node("t3"), + ], }; simulate_close(&mut layout, &[1]); match &layout { - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { assert_eq!(children.len(), 2); assert_eq!(sizes.len(), 2); // t1 and t3 remain - let ids: Vec<_> = children.iter().map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => id.as_str(), - _ => "", - }).collect(); + let ids: Vec<_> = children + .iter() + .map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => id.as_str(), + _ => "", + }) + .collect(); assert_eq!(ids, vec!["t1", "t3"]); } _ => panic!("Expected split with 2 children"), @@ -152,37 +190,48 @@ fn test_close_terminal_from_3_child_split() { #[test] fn test_close_terminal_from_3_child_sizes_consistent() { // Verify that closing a child from a 3-child split keeps sizes in sync - // and that the remaining sizes sum correctly + // and transfers its space to an adjacent pane. let mut layout = LayoutNode::Split { direction: SplitDirection::Horizontal, sizes: vec![25.0, 50.0, 25.0], - children: vec![terminal_node("t1"), terminal_node("t2"), terminal_node("t3")], + children: vec![ + terminal_node("t1"), + terminal_node("t2"), + terminal_node("t3"), + ], }; // Close the middle terminal (index 1, size 50.0) simulate_close(&mut layout, &[1]); match &layout { - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { assert_eq!(children.len(), 2); assert_eq!(sizes.len(), 2); - // Sizes should be [25.0, 25.0] — the middle entry was removed - assert_eq!(sizes, &vec![25.0, 25.0]); + assert_eq!(sizes, &vec![75.0, 25.0]); } _ => panic!("Expected split with 2 children"), } - // Close the first terminal (index 0) — should collapse to single terminal + // Closing the first terminal transfers its weight to the next pane. let mut layout = LayoutNode::Split { direction: SplitDirection::Vertical, sizes: vec![30.0, 40.0, 30.0], - children: vec![terminal_node("t1"), terminal_node("t2"), terminal_node("t3")], + children: vec![ + terminal_node("t1"), + terminal_node("t2"), + terminal_node("t3"), + ], }; simulate_close(&mut layout, &[0]); match &layout { - LayoutNode::Split { children, sizes, .. } => { + LayoutNode::Split { + children, sizes, .. + } => { assert_eq!(children.len(), 2); assert_eq!(sizes.len(), 2); - assert_eq!(sizes, &vec![40.0, 30.0]); + assert_eq!(sizes, &vec![70.0, 30.0]); } _ => panic!("Expected split with 2 children"), } @@ -191,22 +240,39 @@ fn test_close_terminal_from_3_child_sizes_consistent() { #[test] fn test_move_tab() { let mut layout = LayoutNode::Tabs { - children: vec![terminal_node("t1"), terminal_node("t2"), terminal_node("t3")], + children: vec![ + terminal_node("t1"), + terminal_node("t2"), + terminal_node("t3"), + ], active_tab: 0, }; // Move tab at index 0 to index 2 - if let LayoutNode::Tabs { children, active_tab } = &mut layout { + if let LayoutNode::Tabs { + children, + active_tab, + } = &mut layout + { let tab = children.remove(0); children.insert(2.min(children.len()), tab); // active_tab was 0, which was the moved tab, so update *active_tab = 2.min(children.len() - 1); } match &layout { - LayoutNode::Tabs { children, active_tab } => { - let ids: Vec<_> = children.iter().map(|c| match c { - LayoutNode::Terminal { terminal_id: Some(id), .. } => id.as_str(), - _ => "", - }).collect(); + LayoutNode::Tabs { + children, + active_tab, + } => { + let ids: Vec<_> = children + .iter() + .map(|c| match c { + LayoutNode::Terminal { + terminal_id: Some(id), + .. + } => id.as_str(), + _ => "", + }) + .collect(); assert_eq!(ids, vec!["t2", "t3", "t1"]); assert_eq!(*active_tab, 2); } @@ -226,10 +292,13 @@ fn test_equalize_parent_split_via_path() { // Focused terminal at path [1] → parent split at path [] let parent_path: &[usize] = &[]; if let Some(node) = layout.get_at_path_mut(parent_path) - && let LayoutNode::Split { sizes, children, .. } = node { - let n = children.len(); - *sizes = vec![100.0 / n as f32; n]; - } + && let LayoutNode::Split { + sizes, children, .. + } = node + { + let n = children.len(); + *sizes = vec![100.0 / n as f32; n]; + } if let LayoutNode::Split { sizes, .. } = &layout { assert_eq!(sizes, &vec![50.0, 50.0]); } else { @@ -254,12 +323,18 @@ fn test_equalize_nested_parent_split_via_path() { }; let parent_path: &[usize] = &[0]; if let Some(node) = layout.get_at_path_mut(parent_path) - && let LayoutNode::Split { sizes, children, .. } = node { - let n = children.len(); - *sizes = vec![100.0 / n as f32; n]; - } + && let LayoutNode::Split { + sizes, children, .. + } = node + { + let n = children.len(); + *sizes = vec![100.0 / n as f32; n]; + } // Outer split unchanged - if let LayoutNode::Split { sizes, children, .. } = &layout { + if let LayoutNode::Split { + sizes, children, .. + } = &layout + { assert_eq!(sizes, &vec![60.0, 40.0]); // Inner split equalized if let LayoutNode::Split { sizes: inner, .. } = &children[0] { diff --git a/crates/okena-workspace/src/actions/project.rs b/crates/okena-workspace/src/actions/project.rs index ab5ab835b..db7afb0cd 100644 --- a/crates/okena-workspace/src/actions/project.rs +++ b/crates/okena-workspace/src/actions/project.rs @@ -2,13 +2,106 @@ //! //! 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; +use crate::state::{LayoutNode, ProjectData, WindowId, Workspace}; +use okena_core::theme::FolderColor; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone)] +pub struct ProjectDirectoryRenamePlan { + project_id: String, + old_path: std::path::PathBuf, + new_name: String, + translated_paths: Vec<ProjectPathTranslation>, + move_kind: ProjectDirectoryMove, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectPathTranslation { + project_id: String, + old_path: String, + new_path: String, + translated_hook_terminal_ids: Vec<String>, +} + +impl ProjectPathTranslation { + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn old_path(&self) -> &str { + &self.old_path + } + + pub fn new_path(&self) -> &str { + &self.new_path + } +} + +#[derive(Clone)] +enum ProjectDirectoryMove { + Directory { + old_path: std::path::PathBuf, + new_path: std::path::PathBuf, + }, + Worktree { + verified: okena_git::VerifiedWorktree, + new_path: std::path::PathBuf, + }, +} + +pub struct ProjectDirectoryRenameResult { + moved_worktree_root: Option<String>, +} + +impl ProjectDirectoryRenamePlan { + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn old_path(&self) -> &std::path::Path { + &self.old_path + } + + pub fn new_path(&self) -> &std::path::Path { + match &self.move_kind { + ProjectDirectoryMove::Directory { new_path, .. } + | ProjectDirectoryMove::Worktree { new_path, .. } => new_path, + } + } + + pub fn affected_translations(&self) -> &[ProjectPathTranslation] { + &self.translated_paths + } + + pub fn affected_project_ids(&self) -> impl Iterator<Item = &str> { + self.translated_paths + .iter() + .map(|translation| translation.project_id.as_str()) + } + + pub fn execute(&self) -> Result<ProjectDirectoryRenameResult, String> { + match &self.move_kind { + ProjectDirectoryMove::Directory { old_path, new_path } => { + std::fs::rename(old_path, new_path) + .map_err(|error| format!("Failed to rename: {error}"))?; + Ok(ProjectDirectoryRenameResult { + moved_worktree_root: None, + }) + } + ProjectDirectoryMove::Worktree { verified, new_path } => { + let moved = okena_git::move_worktree(verified, new_path) + .map_err(|error| error.to_string())?; + Ok(ProjectDirectoryRenameResult { + moved_worktree_root: Some(moved.checkout_path().to_string_lossy().into_owned()), + }) + } + } + } +} /// Pick a replacement focus target after hiding `hidden_id`. /// @@ -41,10 +134,11 @@ fn pick_focus_replacement( /// Does not expand `~user/...` syntax (other user's home directories). fn expand_tilde(path: &str) -> String { if (path == "~" || path.starts_with("~/")) - && let Some(home) = dirs::home_dir() { - let rest = &path[1..]; // "" or "/..." - return format!("{}{}", home.display(), rest); - } + && let Some(home) = dirs::home_dir() + { + let rest = &path[1..]; // "" or "/..." + return format!("{}{}", home.display(), rest); + } path.to_string() } @@ -83,7 +177,7 @@ impl Workspace { focus_manager: &mut FocusManager, window_id: WindowId, project_id: &str, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { if self.project(project_id).is_none() { return; @@ -167,14 +261,25 @@ 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<Self>) -> String { + pub fn add_project( + &mut self, + name: String, + path: String, + with_terminal: bool, + global_hooks: &HooksConfig, + window_id: WindowId, + cx: &mut impl WorkspaceCx, + ) -> Result<String, String> { let path = expand_tilde(&path); + self.ensure_project_path_claim_allowed(std::path::Path::new(&path))?; // Auto-detect WSL UNC paths and set default shell accordingly #[cfg(windows)] - let default_shell = okena_terminal::shell_config::parse_wsl_unc_path(&path) - .map(|(distro, _)| okena_terminal::shell_config::ShellType::Wsl { - distro: Some(distro), + let default_shell = + okena_terminal::shell_config::parse_wsl_unc_path(&path).map(|(distro, _)| { + okena_terminal::shell_config::ShellType::Wsl { + distro: Some(distro), + } }); #[cfg(not(windows))] let default_shell: Option<okena_terminal::shell_config::ShellType> = None; @@ -184,7 +289,11 @@ impl Workspace { id: id.clone(), name: name.clone(), path: path.clone(), - layout: if with_terminal { Some(LayoutNode::new_terminal()) } else { None }, + layout: if with_terminal { + Some(LayoutNode::new_terminal()) + } else { + None + }, terminal_names: HashMap::new(), hidden_terminals: HashMap::new(), worktree_info: None, @@ -198,6 +307,8 @@ impl Workspace { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, }; let project_hooks = project.hooks.clone(); self.data.projects.push(project); @@ -208,13 +319,105 @@ 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); + Ok(id) + } + + /// Remove hook terminal state restored without a matching live PTY. + /// + /// Returns the stale terminal ids so the caller can also tear down a + /// persistent session backend before the ids become unreachable. + pub fn clear_stale_hook_terminals( + &mut self, + project_id: &str, + cx: &mut impl WorkspaceCx, + ) -> Vec<String> { + let Some(project) = self.project_mut(project_id) else { + return Vec::new(); + }; + let stale: Vec<String> = project.hook_terminals.keys().cloned().collect(); + if stale.is_empty() { + return stale; + } + + let stale_set: HashSet<&str> = stale.iter().map(String::as_str).collect(); + LayoutNode::remove_terminal_ids(&mut project.layout, &stale_set); + project.hook_terminals.clear(); + project + .terminal_names + .retain(|terminal_id, _| !stale_set.contains(terminal_id.as_str())); + self.notify_data(cx); + stale + } + + /// Re-open an ALREADY-EXISTING project (e.g. one restored from + /// `workspace.json` at daemon boot): drop its stale hook terminals and fire + /// its `on_project_open` hook, reading the project's stored hooks/name/path. + /// + /// `add_project` runs the fire+register step for NEW projects, but restored + /// projects enter the workspace via `Workspace::new` (never `add_project`), + /// so without this their `project.on_open` hook — global or per-project — + /// would never run on restart. The stale-clear matters because + /// `hook_terminals` is persisted: the entries reloaded from disk point at + /// PTYs that died with the previous process, so they must be dropped both to + /// avoid phantom rows (whose rerun/dismiss fail with "hook terminal not + /// found") and to stop entries accumulating on every restart. No-ops the + /// fire when no `on_open` hook resolves. + pub fn fire_project_open_hooks( + &mut self, + project_id: &str, + global_hooks: &HooksConfig, + cx: &mut impl WorkspaceCx, + ) { + let Some(project) = self.project(project_id) else { + return; + }; + let project_hooks = project.hooks.clone(); + let name = project.name.clone(); + let path = project.path.clone(); + self.clear_stale_hook_terminals(project_id, cx); + // Immutable `project` borrow ends here (values cloned); the folder + // borrow below ends at the fire call, freeing `&mut self` for the + // mutations that follow. + 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_project_open( + &project_hooks, + project_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<Self>) { + 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(); @@ -231,7 +434,8 @@ impl Workspace { } // Focus the newly created terminal (terminal_id: None) - let new_path = self.project(project_id) + let new_path = self + .project(project_id) .and_then(|p| p.layout.as_ref()) .and_then(|l| l.find_uninitialized_terminal_path()); if let Some(path) = new_path { @@ -245,7 +449,7 @@ impl Workspace { project_id: &str, command: &str, env_vars: &HashMap<String, String>, - cx: &mut Context<Self>, + 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 +468,12 @@ impl Workspace { } /// Rename a project - pub fn rename_project(&mut self, project_id: &str, new_name: String, cx: &mut Context<Self>) { + 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,17 +481,300 @@ 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<Self>) { - self.with_project(project_id, cx, |project| { - project.path = new_path; - project.name = new_name; - true + pub fn rename_project_directory( + &mut self, + project_id: &str, + new_path: String, + new_name: String, + cx: &mut impl WorkspaceCx, + ) -> Result<(), String> { + let plan = self.prepare_project_directory_rename(project_id, new_path, new_name)?; + let result = plan.execute()?; + self.finish_project_directory_rename(&plan, result, cx) + } + + /// Validate and snapshot a directory rename without changing the filesystem. + pub fn prepare_project_directory_rename( + &self, + project_id: &str, + new_path: String, + new_name: String, + ) -> Result<ProjectDirectoryRenamePlan, String> { + if new_name.is_empty() { + return Err("name must not be empty".to_string()); + } + if new_name.contains('/') || new_name.contains('\\') || new_name == "." || new_name == ".." + { + return Err("name must not contain path separators".to_string()); + } + let new_path_buf = std::path::PathBuf::from(&new_path); + self.ensure_project_path_mutation_allowed(project_id, &new_path_buf)?; + if new_path_buf.exists() { + return Err(format!("'{}' already exists", new_name)); + } + let project = self + .project(project_id) + .ok_or_else(|| "Project not found".to_string())?; + let old_path = std::path::PathBuf::from(&project.path); + let worktree = project.worktree_info.as_ref().map(|metadata| { + ( + metadata.parent_project_id.clone(), + metadata.worktree_path.clone(), + ) }); + + let Some((parent_project_id, recorded_root)) = worktree else { + self.ensure_directory_tree_git_topology_safe(&old_path, None)?; + let translated_paths = self.translate_local_project_paths(&old_path, &new_path_buf)?; + return Ok(ProjectDirectoryRenamePlan { + project_id: project_id.to_string(), + old_path: old_path.clone(), + new_name, + translated_paths, + move_kind: ProjectDirectoryMove::Directory { + old_path, + new_path: new_path_buf, + }, + }); + }; + + let parent_path = self + .project(&parent_project_id) + .filter(|parent| !parent.is_remote) + .map(|parent| parent.path.clone()) + .ok_or_else(|| "Worktree parent project is not local".to_string())?; + let checkout_query = if recorded_root.is_empty() { + old_path.clone() + } else { + std::path::PathBuf::from(&recorded_root) + }; + let verified = okena_git::verify_linked_worktree_fresh( + std::path::Path::new(&parent_path), + &checkout_query, + ) + .map_err(|error| error.to_string())?; + let root_identity = Self::physical_path_identity(verified.checkout_path()); + let old_identity = Self::physical_path_identity(&old_path); + if !old_identity.starts_with(&root_identity) { + return Err("project path is outside its linked worktree root".to_string()); + } + + if old_identity != root_identity { + if !Self::physical_path_identity(&new_path_buf).starts_with(&root_identity) { + return Err("renamed project path must stay inside its linked worktree".to_string()); + } + self.ensure_directory_tree_git_topology_safe(&old_path, None)?; + let translated_paths = self.translate_local_project_paths(&old_path, &new_path_buf)?; + return Ok(ProjectDirectoryRenamePlan { + project_id: project_id.to_string(), + old_path: old_path.clone(), + new_name, + translated_paths, + move_kind: ProjectDirectoryMove::Directory { + old_path, + new_path: new_path_buf, + }, + }); + } + + self.ensure_directory_tree_git_topology_safe( + verified.checkout_path(), + Some(verified.checkout_path()), + )?; + let translated_paths = + self.translate_local_project_paths(verified.checkout_path(), &new_path_buf)?; + Ok(ProjectDirectoryRenamePlan { + project_id: project_id.to_string(), + old_path, + new_name, + translated_paths, + move_kind: ProjectDirectoryMove::Worktree { + verified, + new_path: new_path_buf, + }, + }) + } + + /// Publish the state translation after the off-reactor move succeeds. + pub fn finish_project_directory_rename( + &mut self, + plan: &ProjectDirectoryRenamePlan, + result: ProjectDirectoryRenameResult, + cx: &mut impl WorkspaceCx, + ) -> Result<(), String> { + if self + .project(&plan.project_id) + .is_none_or(|project| std::path::Path::new(&project.path) != plan.old_path) + { + return Err(format!( + "project changed while its directory was being renamed: {}", + plan.project_id + )); + } + self.apply_translated_project_paths(plan.translated_paths.clone()); + if let Some(project) = self + .data + .projects + .iter_mut() + .find(|project| project.id == plan.project_id) + { + project.name = plan.new_name.clone(); + if let Some(moved_root) = result.moved_worktree_root { + project.path = moved_root.clone(); + if let Some(metadata) = &mut project.worktree_info { + metadata.worktree_path = moved_root; + } + } + } + self.notify_data(cx); + Ok(()) + } + + fn translate_local_project_paths( + &self, + old_root: &std::path::Path, + new_root: &std::path::Path, + ) -> Result<Vec<ProjectPathTranslation>, String> { + let old_identity = Self::physical_path_identity(old_root); + let canonical_root = std::fs::canonicalize(old_root) + .map_err(|error| format!("Failed to resolve project directory: {error}"))?; + let mut translated_paths = Vec::new(); + for descendant in self.projects().iter().filter(|project| !project.is_remote) { + let descendant_path = std::path::Path::new(&descendant.path); + if !Self::physical_path_identity(descendant_path).starts_with(&old_identity) { + continue; + } + let canonical_descendant = std::fs::canonicalize(descendant_path).map_err(|error| { + format!( + "Failed to resolve descendant project '{}': {error}", + descendant.name + ) + })?; + let suffix = canonical_descendant + .strip_prefix(&canonical_root) + .map_err(|_| { + format!( + "Failed to translate descendant project '{}' into moved directory", + descendant.name + ) + })?; + let translated_path = if suffix.as_os_str().is_empty() { + new_root.to_path_buf() + } else { + new_root.join(suffix) + }; + let translated_hook_terminal_ids = descendant + .hook_terminals + .iter() + .filter(|(_, entry)| { + Self::physical_path_identity(std::path::Path::new(&entry.cwd)) + == Self::physical_path_identity(descendant_path) + }) + .map(|(terminal_id, _)| terminal_id.clone()) + .collect(); + translated_paths.push(ProjectPathTranslation { + project_id: descendant.id.clone(), + old_path: descendant.path.clone(), + new_path: translated_path.to_string_lossy().into_owned(), + translated_hook_terminal_ids, + }); + } + Ok(translated_paths) + } + + fn apply_translated_project_paths(&mut self, translated_paths: Vec<ProjectPathTranslation>) { + for translation in translated_paths { + if let Some(descendant) = self + .data + .projects + .iter_mut() + .find(|project| project.id == translation.project_id) + { + descendant.path = translation.new_path.clone(); + for terminal_id in &translation.translated_hook_terminal_ids { + if let Some(entry) = descendant.hook_terminals.get_mut(terminal_id) { + entry.cwd = translation.new_path.clone(); + } + } + } + } + } + + /// Reject directory moves that would invalidate Git's absolute worktree links. + fn ensure_directory_tree_git_topology_safe( + &self, + old_root: &std::path::Path, + allowed_worktree_root: Option<&std::path::Path>, + ) -> Result<(), String> { + let old_identity = Self::physical_path_identity(old_root); + let allowed_identity = allowed_worktree_root.map(Self::physical_path_identity); + let mut repo_roots: Vec<std::path::PathBuf> = Vec::new(); + + for project in self.projects().iter().filter(|project| !project.is_remote) { + let project_path = std::path::Path::new(&project.path); + let project_identity = Self::physical_path_identity(project_path); + if project_identity.starts_with(&old_identity) + && let Some(repo_root) = okena_git::get_repo_root(project_path) + { + let repo_identity = Self::physical_path_identity(&repo_root); + if repo_identity.starts_with(&old_identity) { + if !repo_roots + .iter() + .any(|known| Self::physical_path_identity(known) == repo_identity) + { + repo_roots.push(repo_root); + } + if project.worktree_info.is_some() + && allowed_identity.as_ref() != Some(&repo_identity) + { + return Err(format!( + "Cannot rename a directory containing linked worktree project '{}'", + project.name + )); + } + } + } + + if let Some(metadata) = &project.worktree_info + && !metadata.worktree_path.is_empty() + { + let recorded_identity = + Self::physical_path_identity(std::path::Path::new(&metadata.worktree_path)); + if recorded_identity.starts_with(&old_identity) + && allowed_identity.as_ref() != Some(&recorded_identity) + { + return Err(format!( + "Cannot rename a directory containing recorded worktree root for '{}'", + project.name + )); + } + } + } + + for repo_root in repo_roots { + let repo_identity = Self::physical_path_identity(&repo_root); + if allowed_identity.as_ref() == Some(&repo_identity) { + continue; + } + if !okena_git::list_linked_worktree_paths(&repo_root).is_empty() { + return Err( + "Cannot rename a Git repository while it has linked worktrees; remove them first" + .to_string(), + ); + } + } + Ok(()) } /// 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<Self>) { - let is_worktree = self.project(project_id) + 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(); @@ -290,7 +782,8 @@ impl Workspace { self.set_worktree_color_override(project_id, Some(color), cx); } else { // Collect child IDs from the parent's worktree_ids to avoid a full scan - let child_ids: Vec<String> = self.project(project_id) + let child_ids: Vec<String> = self + .project(project_id) .map(|p| p.worktree_ids.clone()) .unwrap_or_default(); @@ -302,7 +795,9 @@ impl Workspace { } for child_id in &child_ids { if let Some(child) = self.project_mut(child_id) { - let has_override = child.worktree_info.as_ref() + let has_override = child + .worktree_info + .as_ref() .and_then(|wt| wt.color_override) .is_some(); if !has_override { @@ -317,15 +812,48 @@ impl Workspace { } /// Delete a project - pub fn delete_project(&mut self, focus_manager: &mut FocusManager, project_id: &str, global_hooks: &HooksConfig, cx: &mut Context<Self>) { + pub fn delete_project( + &mut self, + focus_manager: &mut FocusManager, + project_id: &str, + global_hooks: &HooksConfig, + cx: &mut impl WorkspaceCx, + ) { + self.delete_project_inner(focus_manager, project_id, Some(global_hooks), cx); + } + + /// Delete a worktree whose project-close hook already completed before its + /// checkout was removed. + pub(crate) fn delete_project_without_project_close( + &mut self, + focus_manager: &mut FocusManager, + project_id: &str, + cx: &mut impl WorkspaceCx, + ) { + self.delete_project_inner(focus_manager, project_id, None, cx); + } + + fn delete_project_inner( + &mut self, + focus_manager: &mut FocusManager, + project_id: &str, + global_hooks: Option<&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) { + let hook_terminal_ids: Vec<String> = project.hook_terminals.keys().cloned().collect(); + if let Some(monitor) = cx.hook_monitor() { + for terminal_id in &hook_terminal_ids { + monitor.cancel_by_terminal_id(terminal_id); + } + } let mut kill_ids: Vec<String> = Vec::new(); if let Some(layout) = &project.layout { kill_ids.extend(layout.collect_terminal_ids()); } - kill_ids.extend(project.hook_terminals.keys().cloned()); + kill_ids.extend(hook_terminal_ids); kill_ids.extend(project.service_terminals.values().cloned()); self.queue_terminal_kills(kill_ids); } @@ -343,11 +871,17 @@ impl Workspace { let hook_folder_id = folder.map(|f| f.id.clone()); let hook_folder_name = folder.map(|f| f.name.clone()); let hook_info = self.project(project_id).map(|p| { - (p.hooks.clone(), p.id.clone(), p.name.clone(), p.path.clone()) + ( + p.hooks.clone(), + p.id.clone(), + p.name.clone(), + p.path.clone(), + ) }); // Collect orphaned worktree children (if deleting a parent) - let orphaned_worktrees: Vec<String> = self.project(project_id) + let orphaned_worktrees: Vec<String> = self + .project(project_id) .map(|p| p.worktree_ids.clone()) .unwrap_or_default(); @@ -367,7 +901,9 @@ impl Workspace { // Re-home orphaned worktrees to project_order for wt_id in orphaned_worktrees { - if self.data.projects.iter().any(|p| p.id == wt_id) && !self.data.project_order.contains(&wt_id) { + if self.data.projects.iter().any(|p| p.id == wt_id) + && !self.data.project_order.contains(&wt_id) + { self.data.project_order.push(wt_id); } } @@ -391,15 +927,27 @@ 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); + if let (Some((project_hooks, id, name, path)), Some(global_hooks)) = + (hook_info, global_hooks) + { + 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<Self>) { + 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); @@ -446,29 +994,22 @@ impl Workspace { /// Update project column widths on the targeted window. /// - /// Wholesale-replaces the targeted window's `project_widths` map with the - /// supplied map. The leading clear is routed through the `window_mut` - /// lookup pair so an unknown extra id (e.g. caller raced a close) is a - /// silent no-op for the clear; the per-entry `set_project_width` calls - /// then also no-op via the same lookup contract. `notify_data` still - /// bumps `data_version` so the auto-save observer's cadence is unchanged - /// in the close-race path -- consistent with the silent-no-op contract - /// the data-layer setters absorb. + /// Merges the supplied widths into the targeted window's `project_widths` + /// map. Omitted entries may belong to hidden projects and are preserved. /// /// Each entry is written via `data.set_project_width(window_id, ...)` so - /// a future migration off the wholesale shape inherits the per-entry - /// pair-shaped contract automatically. The runtime shape of a column-resize - /// is per-column; the wholesale shape on this entrypoint is a relic of the - /// prior data layout where `project_widths` was a top-level field. + /// future changes inherit the per-entry pair-shaped contract automatically. /// /// Bumps `data_version` exactly once per call (not per entry) -- the data /// 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<String, f32>, cx: &mut Context<Self>) { - if let Some(w) = self.data.window_mut(window_id) { - w.project_widths.clear(); - } + pub fn update_project_widths( + &mut self, + window_id: WindowId, + widths: HashMap<String, f32>, + cx: &mut impl WorkspaceCx, + ) { for (id, w) in widths { self.data.set_project_width(window_id, &id, w); } @@ -476,14 +1017,28 @@ 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<Self>) { - self.data.service_panel_heights.insert(project_id.to_string(), height); + 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<Self>) { - self.data.hook_panel_heights.insert(project_id.to_string(), height); + 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); } @@ -496,23 +1051,42 @@ impl Workspace { /// default, matching the "missing entry == default" contract on the lookup /// side. Default is `100.0 / visible_count` so a render path that asks for /// every visible column gets a balanced grid when no widths are set yet. - pub fn get_project_width(&self, window_id: WindowId, project_id: &str, visible_count: usize) -> f32 { + pub fn get_project_width( + &self, + window_id: WindowId, + project_id: &str, + visible_count: usize, + ) -> f32 { self.data .window(window_id) .and_then(|w| w.project_widths.get(project_id).copied()) .unwrap_or_else(|| 100.0 / visible_count as f32) } - } #[cfg(test)] mod tests { use super::{expand_tilde, pick_focus_replacement}; - use crate::state::*; + use crate::context::WorkspaceCx; use crate::settings::HooksConfig; + use crate::state::*; use okena_core::theme::FolderColor; + use okena_hooks::{HookMonitor, HookRunner}; use std::collections::HashMap; + struct TestCx; + + impl WorkspaceCx for TestCx { + fn notify(&mut self) {} + fn refresh_views(&mut self) {} + fn hook_runner(&self) -> Option<HookRunner> { + None + } + fn hook_monitor(&self) -> Option<HookMonitor> { + None + } + } + fn make_project(id: &str) -> ProjectData { ProjectData { id: id.to_string(), @@ -532,6 +1106,8 @@ mod tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -584,7 +1160,9 @@ mod tests { #[test] fn test_get_project_width_custom() { let mut data = make_workspace_data(); - data.main_window.project_widths.insert("p1".to_string(), 60.0); + data.main_window + .project_widths + .insert("p1".to_string(), 60.0); let ws = Workspace::new(data); assert_eq!(ws.get_project_width(WindowId::Main, "p1", 2), 60.0); } @@ -594,7 +1172,9 @@ mod tests { // Per-window viewport model: WindowId::Main routes through // data.window(...) and reads main_window.project_widths. let mut data = make_workspace_data(); - data.main_window.project_widths.insert("p1".to_string(), 75.0); + data.main_window + .project_widths + .insert("p1".to_string(), 75.0); let ws = Workspace::new(data); assert_eq!(ws.get_project_width(WindowId::Main, "p1", 2), 75.0); } @@ -614,7 +1194,10 @@ mod tests { data.extra_windows.push(extra); let ws = Workspace::new(data); - assert_eq!(ws.get_project_width(WindowId::Extra(extra_id), "p1", 2), 80.0); + assert_eq!( + ws.get_project_width(WindowId::Extra(extra_id), "p1", 2), + 80.0 + ); // Main has no entry for p1 -> equal-distribution default of 50.0 (2 visible). assert_eq!(ws.get_project_width(WindowId::Main, "p1", 2), 50.0); } @@ -629,12 +1212,17 @@ mod tests { let mut data = make_workspace_data(); // Pre-populate main with a value to ensure the unknown-extra path // does NOT silently read from main as a fallback. - data.main_window.project_widths.insert("p1".to_string(), 90.0); + data.main_window + .project_widths + .insert("p1".to_string(), 90.0); let ws = Workspace::new(data); let unknown = uuid::Uuid::new_v4(); // Default for visible_count = 4 -> 25.0, NOT 90.0 (main's value). - assert_eq!(ws.get_project_width(WindowId::Extra(unknown), "p1", 4), 25.0); + assert_eq!( + ws.get_project_width(WindowId::Extra(unknown), "p1", 4), + 25.0 + ); } #[test] @@ -677,14 +1265,20 @@ mod tests { fn pick_focus_replacement_prefers_next() { let before = s(&["a", "b", "c", "d"]); let after = s(&["a", "b", "d"]); - assert_eq!(pick_focus_replacement(&before, &after, "c").as_deref(), Some("d")); + assert_eq!( + pick_focus_replacement(&before, &after, "c").as_deref(), + Some("d") + ); } #[test] fn pick_focus_replacement_falls_back_to_previous() { let before = s(&["a", "b", "c"]); let after = s(&["a", "b"]); - assert_eq!(pick_focus_replacement(&before, &after, "c").as_deref(), Some("b")); + assert_eq!( + pick_focus_replacement(&before, &after, "c").as_deref(), + Some("b") + ); } #[test] @@ -692,7 +1286,10 @@ mod tests { // Hiding "b" while "c" is also no longer visible should jump to "d". let before = s(&["a", "b", "c", "d"]); let after = s(&["a", "d"]); - assert_eq!(pick_focus_replacement(&before, &after, "b").as_deref(), Some("d")); + assert_eq!( + pick_focus_replacement(&before, &after, "b").as_deref(), + Some("d") + ); } #[test] @@ -708,16 +1305,103 @@ mod tests { let after = s(&["a", "b"]); assert_eq!(pick_focus_replacement(&before, &after, "missing"), None); } + + #[test] + fn clear_stale_hook_terminals_clears_metadata_and_legacy_layout_id() { + let mut project = make_project("p1"); + project.layout = Some(LayoutNode::Split { + direction: SplitDirection::Horizontal, + sizes: vec![50.0, 50.0], + children: vec![ + LayoutNode::Terminal { + terminal_id: Some("layout-terminal".to_string()), + minimized: false, + detached: false, + shell_type: Default::default(), + zoom_level: 1.0, + }, + LayoutNode::Terminal { + terminal_id: Some("stale-hook".to_string()), + minimized: true, + detached: true, + shell_type: Default::default(), + zoom_level: 1.0, + }, + ], + }); + project.hook_terminals.insert( + "stale-hook".to_string(), + HookTerminalEntry { + label: "on_project_open".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo old".to_string(), + cwd: "/tmp".to_string(), + }, + ); + project + .terminal_names + .insert("stale-hook".to_string(), "Old hook".to_string()); + + let mut data = make_workspace_data(); + data.projects.push(project); + data.project_order.push("p1".to_string()); + let mut workspace = Workspace::new(data); + let stale = workspace.clear_stale_hook_terminals("p1", &mut TestCx); + + assert_eq!(stale, vec!["stale-hook".to_string()]); + let project = workspace.project("p1").unwrap(); + assert!(project.hook_terminals.is_empty()); + assert!(!project.terminal_names.contains_key("stale-hook")); + let layout = project.layout.as_ref().unwrap(); + assert!(layout.find_terminal_path("stale-hook").is_none()); + assert!(layout.find_terminal_path("layout-terminal").is_some()); + assert_eq!(layout.collect_terminal_ids(), vec!["layout-terminal"]); + assert!(matches!(layout, LayoutNode::Terminal { .. })); + } + + #[test] + fn clear_stale_hook_terminals_removes_a_legacy_root_leaf() { + let mut project = make_project("p1"); + project.layout = Some(LayoutNode::Terminal { + terminal_id: Some("stale-hook".to_string()), + minimized: false, + detached: false, + shell_type: Default::default(), + zoom_level: 1.0, + }); + project.hook_terminals.insert( + "stale-hook".to_string(), + HookTerminalEntry { + label: "on_project_open".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo old".to_string(), + cwd: "/tmp".to_string(), + }, + ); + + let mut data = make_workspace_data(); + data.projects.push(project); + data.project_order.push("p1".to_string()); + let mut workspace = Workspace::new(data); + + workspace.clear_stale_hook_terminals("p1", &mut TestCx); + + assert!(workspace.project("p1").unwrap().layout.is_none()); + } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod gpui_tests { - use gpui::AppContext as _; use crate::focus::FocusManager; - use crate::state::{LayoutNode, ProjectData, WindowId, WindowState, Workspace, WorkspaceData}; use crate::settings::HooksConfig; + use crate::state::{LayoutNode, ProjectData, WindowId, WindowState, Workspace, WorkspaceData}; + use gpui::AppContext as _; use okena_core::theme::FolderColor; use std::collections::HashMap; + use std::path::Path; + use std::process::Command; fn make_workspace_data() -> WorkspaceData { WorkspaceData { @@ -751,6 +1435,8 @@ mod gpui_tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -771,7 +1457,15 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(data)); let new_id = workspace.update(cx, |ws: &mut Workspace, cx| { - ws.add_project("p1".to_string(), "/tmp/p1".to_string(), false, &HooksConfig::default(), WindowId::Main, cx) + ws.add_project( + "p1".to_string(), + "/tmp/p1".to_string(), + false, + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .expect("add project") }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -799,7 +1493,15 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(data)); let new_id = workspace.update(cx, |ws: &mut Workspace, cx| { - ws.add_project("p1".to_string(), "/tmp/p1".to_string(), false, &HooksConfig::default(), WindowId::Extra(extra_a_id), cx) + ws.add_project( + "p1".to_string(), + "/tmp/p1".to_string(), + false, + &HooksConfig::default(), + WindowId::Extra(extra_a_id), + cx, + ) + .expect("add project") }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -816,7 +1518,15 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(make_workspace_data())); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.add_project("Test".to_string(), "/tmp/test".to_string(), true, &HooksConfig::default(), WindowId::Main, cx); + ws.add_project( + "Test".to_string(), + "/tmp/test".to_string(), + true, + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .expect("add project"); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -834,7 +1544,15 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(make_workspace_data())); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.add_project("Bookmark".to_string(), "/tmp/bm".to_string(), false, &HooksConfig::default(), WindowId::Main, cx); + ws.add_project( + "Bookmark".to_string(), + "/tmp/bm".to_string(), + false, + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .expect("add project"); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -890,7 +1608,12 @@ mod gpui_tests { // First toggle: visible -> hidden. main_window inserts the id. workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut FocusManager::new(), WindowId::Main, "p1", cx); + ws.toggle_project_overview_visibility( + &mut FocusManager::new(), + WindowId::Main, + "p1", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { assert!(ws.data().main_window.hidden_project_ids.contains("p1")); @@ -898,7 +1621,12 @@ mod gpui_tests { // Second toggle: hidden -> visible. main_window removes the entry. workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut FocusManager::new(), WindowId::Main, "p1", cx); + ws.toggle_project_overview_visibility( + &mut FocusManager::new(), + WindowId::Main, + "p1", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { assert!(!ws.data().main_window.hidden_project_ids.contains("p1")); @@ -947,13 +1675,20 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert!(ws.data().main_window.hidden_project_ids.contains("unknown_id")); + assert!( + ws.data() + .main_window + .hidden_project_ids + .contains("unknown_id") + ); assert_eq!(ws.data_version(), 1); }); } #[gpui::test] - fn toggle_worktree_visibility_extra_writes_only_to_targeted_window(cx: &mut gpui::TestAppContext) { + fn toggle_worktree_visibility_extra_writes_only_to_targeted_window( + cx: &mut gpui::TestAppContext, + ) { // Per-window viewport model: toggling on WindowId::Extra(uuid) flips // only that extra's hidden_project_ids -- main and any sibling extras // stay untouched. Defends against a regression that ignores window_id @@ -1046,17 +1781,29 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(make_workspace_data())); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut FocusManager::new(), WindowId::Main, "unknown_id", cx); + ws.toggle_project_overview_visibility( + &mut FocusManager::new(), + WindowId::Main, + "unknown_id", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert!(!ws.data().main_window.hidden_project_ids.contains("unknown_id")); + assert!( + !ws.data() + .main_window + .hidden_project_ids + .contains("unknown_id") + ); assert_eq!(ws.data_version(), 0); }); } #[gpui::test] - fn toggle_project_overview_visibility_extra_writes_only_to_targeted_window(cx: &mut gpui::TestAppContext) { + fn toggle_project_overview_visibility_extra_writes_only_to_targeted_window( + cx: &mut gpui::TestAppContext, + ) { // Per-window viewport model: toggling on WindowId::Extra(uuid) flips // only that extra's hidden_project_ids -- main and any sibling extras // stay untouched. Defends against a regression that ignores window_id @@ -1077,7 +1824,12 @@ mod gpui_tests { // First toggle: visible -> hidden in extra_a. workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut FocusManager::new(), WindowId::Extra(extra_a_id), "p1", cx); + ws.toggle_project_overview_visibility( + &mut FocusManager::new(), + WindowId::Extra(extra_a_id), + "p1", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -1097,7 +1849,12 @@ mod gpui_tests { // semantic so a regression that hard-codes insert-only or remove-only // would surface here. workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut FocusManager::new(), WindowId::Extra(extra_a_id), "p1", cx); + ws.toggle_project_overview_visibility( + &mut FocusManager::new(), + WindowId::Extra(extra_a_id), + "p1", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -1106,7 +1863,9 @@ mod gpui_tests { } #[gpui::test] - fn toggle_project_overview_visibility_unknown_extra_is_silent_noop(cx: &mut gpui::TestAppContext) { + fn toggle_project_overview_visibility_unknown_extra_is_silent_noop( + cx: &mut gpui::TestAppContext, + ) { // Close-race contract: a fresh uuid that does not match any extra // produces no panic; main_window stays untouched. Pre-populate main // with hidden state for p1 to ensure the unknown-extra path does NOT @@ -1124,7 +1883,12 @@ mod gpui_tests { let unknown = uuid::Uuid::new_v4(); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut FocusManager::new(), WindowId::Extra(unknown), "p1", cx); + ws.toggle_project_overview_visibility( + &mut FocusManager::new(), + WindowId::Extra(unknown), + "p1", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -1155,14 +1919,13 @@ mod gpui_tests { } #[gpui::test] - fn update_project_widths_wholesale_replaces_existing_entries(cx: &mut gpui::TestAppContext) { - // Wholesale-replace contract: keys absent from the supplied map are - // removed from main_window.project_widths. Pins the semantic so a - // future refactor that drops the leading clear() (e.g. switching to a - // merge body) silently breaks here. Pre-populate p1, then call with a - // map containing only p2 -- p1 must be gone. + fn update_project_widths_preserves_unmentioned_entries(cx: &mut gpui::TestAppContext) { + // Hidden projects are absent from a resize update but must retain their + // width for when they become visible again. let mut data = make_workspace_data(); - data.main_window.project_widths.insert("p1".to_string(), 0.50); + data.main_window + .project_widths + .insert("p1".to_string(), 0.50); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { @@ -1172,8 +1935,14 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert!(!ws.data().main_window.project_widths.contains_key("p1")); - assert_eq!(ws.data().main_window.project_widths.get("p2").copied(), Some(0.40)); + assert_eq!( + ws.data().main_window.project_widths.get("p1").copied(), + Some(0.50) + ); + assert_eq!( + ws.data().main_window.project_widths.get("p2").copied(), + Some(0.40) + ); }); } @@ -1212,7 +1981,9 @@ mod gpui_tests { let mut extra_b = WindowState::default(); let extra_b_id = extra_b.id; // Pre-populate sibling state on main + extra_b to verify isolation. - data.main_window.project_widths.insert("p1".to_string(), 100.0); + data.main_window + .project_widths + .insert("p1".to_string(), 100.0); extra_b.project_widths.insert("p1".to_string(), 200.0); // extra_a starts empty. let _ = extra_a_id; @@ -1234,7 +2005,10 @@ mod gpui_tests { // Main's p1 width is untouched. assert_eq!(ws.data().main_window.project_widths.get("p1"), Some(&100.0)); // Sibling extra's p1 width is untouched. - assert_eq!(ws.data().extra_windows[1].project_widths.get("p1"), Some(&200.0)); + assert_eq!( + ws.data().extra_windows[1].project_widths.get("p1"), + Some(&200.0) + ); // Sibling extra has no p2 from the targeted write. assert!(!ws.data().extra_windows[1].project_widths.contains_key("p2")); // Main has no p2 from the targeted write. @@ -1251,7 +2025,9 @@ mod gpui_tests { // main as a default. data_version still bumps via notify_data, // matching the silent-no-op contract on the data-layer setters. let mut data = make_workspace_data(); - data.main_window.project_widths.insert("p1".to_string(), 50.0); + data.main_window + .project_widths + .insert("p1".to_string(), 50.0); let workspace = cx.new(|_cx| Workspace::new(data)); let unknown = uuid::Uuid::new_v4(); @@ -1275,8 +2051,12 @@ mod gpui_tests { let mut data = make_workspace_data(); data.projects = vec![make_project("p1"), make_project("p2")]; data.project_order = vec!["p1".to_string(), "p2".to_string()]; - data.main_window.project_widths.insert("p1".to_string(), 60.0); - data.main_window.project_widths.insert("p2".to_string(), 40.0); + data.main_window + .project_widths + .insert("p1".to_string(), 60.0); + data.main_window + .project_widths + .insert("p2".to_string(), 40.0); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { @@ -1306,7 +2086,9 @@ mod gpui_tests { let mut data = make_workspace_data(); data.projects = vec![make_project("p1"), make_project("p2")]; data.project_order = vec!["p1".to_string(), "p2".to_string()]; - data.main_window.project_widths.insert("p1".to_string(), 60.0); + data.main_window + .project_widths + .insert("p1".to_string(), 60.0); data.main_window.hidden_project_ids.insert("p1".to_string()); let mut extra1 = WindowState::default(); extra1.project_widths.insert("p1".to_string(), 30.0); @@ -1351,8 +2133,12 @@ mod gpui_tests { let wt = make_worktree_project("wt1", "parent"); data.projects = vec![parent, wt]; data.project_order = vec!["parent".to_string()]; - data.main_window.project_widths.insert("wt1".to_string(), 35.0); - data.main_window.hidden_project_ids.insert("wt1".to_string()); + data.main_window + .project_widths + .insert("wt1".to_string(), 35.0); + data.main_window + .hidden_project_ids + .insert("wt1".to_string()); let mut extra = WindowState::default(); extra.project_widths.insert("wt1".to_string(), 20.0); extra.hidden_project_ids.insert("wt1".to_string()); @@ -1366,8 +2152,16 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { assert!(!ws.data().main_window.project_widths.contains_key("wt1")); assert!(!ws.data().main_window.hidden_project_ids.contains("wt1")); - assert!(!ws.data().extra_windows[0].project_widths.contains_key("wt1")); - assert!(!ws.data().extra_windows[0].hidden_project_ids.contains("wt1")); + assert!( + !ws.data().extra_windows[0] + .project_widths + .contains_key("wt1") + ); + assert!( + !ws.data().extra_windows[0] + .hidden_project_ids + .contains("wt1") + ); }); } @@ -1399,12 +2193,383 @@ mod gpui_tests { p } + fn git(args: &[&str]) { + let output = Command::new("git").args(args).output().expect("run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + + fn path_str(path: &Path) -> &str { + path.to_str().expect("test path is utf-8") + } + + #[gpui::test] + fn removal_rejects_sibling_project_inside_physical_worktree_root( + cx: &mut gpui::TestAppContext, + ) { + let fixture = + std::env::temp_dir().join(format!("okena-root-owner-test-{}", uuid::Uuid::new_v4())); + let main_repo = fixture.join("main"); + let worktree = fixture.join("worktree"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::create_dir_all(main_repo.join("packages/a")).unwrap(); + std::fs::create_dir_all(main_repo.join("packages/b")).unwrap(); + std::fs::write(main_repo.join("packages/a/tracked.txt"), "a\n").unwrap(); + std::fs::write(main_repo.join("packages/b/tracked.txt"), "b\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "packages"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(&worktree), + ]); + let sentinel = worktree.join("packages/b/uncommitted.txt"); + std::fs::write(&sentinel, "must survive\n").unwrap(); + + let mut parent = make_project("parent"); + parent.path = main_repo.to_string_lossy().into_owned(); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut wt = make_worktree_project("wt1", "parent"); + wt.path = worktree.join("packages/a").to_string_lossy().into_owned(); + let metadata = wt.worktree_info.as_mut().unwrap(); + metadata.main_repo_path = main_repo.to_string_lossy().into_owned(); + metadata.worktree_path = worktree.to_string_lossy().into_owned(); + metadata.branch_name = "feature".to_string(); + let mut claimant = make_project("claimant"); + claimant.name = "Sibling".to_string(); + claimant.path = worktree.join("packages/b").to_string_lossy().into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![parent, wt, claimant]; + data.project_order = vec!["parent".to_string(), "claimant".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + let result = workspace.update(cx, |ws, cx| { + ws.remove_worktree_project( + &mut FocusManager::new(), + "wt1", + true, + &HooksConfig::default(), + cx, + ) + }); + + assert!( + result.is_err_and(|error| error.contains("Sibling")), + "removal must identify the sibling claimant" + ); + assert!(sentinel.exists(), "uncommitted sibling data must survive"); + assert!(worktree.exists(), "the shared checkout must survive"); + + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + "--force", + path_str(&worktree), + ]); + let _ = std::fs::remove_dir_all(&fixture); + } + + #[gpui::test] + fn close_rejects_stale_root_pointing_into_independent_repository( + cx: &mut gpui::TestAppContext, + ) { + let fixture = + std::env::temp_dir().join(format!("okena-stale-root-test-{}", uuid::Uuid::new_v4())); + let main_repo = fixture.join("main"); + let registered_worktree = fixture.join("registered-worktree"); + let independent_repo = fixture.join("independent"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::write(main_repo.join("base.txt"), "base\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "base.txt"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(®istered_worktree), + ]); + + git(&["init", "-b", "main", path_str(&independent_repo)]); + std::fs::create_dir_all(independent_repo.join("packages/app")).unwrap(); + let sentinel = independent_repo.join("must-survive.txt"); + std::fs::write(&sentinel, "independent data\n").unwrap(); + + let mut parent = make_project("parent"); + parent.path = main_repo.to_string_lossy().into_owned(); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut stale = make_worktree_project("wt1", "parent"); + stale.path = independent_repo + .join("packages/app") + .to_string_lossy() + .into_owned(); + let metadata = stale.worktree_info.as_mut().unwrap(); + metadata.main_repo_path = main_repo.to_string_lossy().into_owned(); + metadata.worktree_path = registered_worktree.to_string_lossy().into_owned(); + metadata.branch_name = "feature".to_string(); + let mut data = make_workspace_data(); + data.projects = vec![parent, stale]; + data.project_order = vec!["parent".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + let error = workspace.update(cx, |ws, cx| { + ws.close_worktree( + &mut FocusManager::new(), + "wt1", + false, + false, + false, + false, + false, + &HooksConfig::default(), + cx, + ) + .unwrap_err() + }); + + assert!(error.contains("does not match its recorded checkout root")); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "independent data\n" + ); + assert!(independent_repo.exists()); + assert!(workspace.read_with(cx, |ws, _| ws.project("wt1").is_some())); + + let legacy_error = workspace.update(cx, |ws, cx| { + ws.with_project("wt1", cx, |project| { + project + .worktree_info + .as_mut() + .unwrap() + .worktree_path + .clear(); + true + }); + match ws.begin_worktree_removal("wt1", &HooksConfig::default(), cx) { + Ok(_) => panic!("unregistered legacy worktree must be rejected"), + Err(error) => error, + } + }); + assert!(legacy_error.contains("does not belong to the parent repository")); + assert!( + sentinel.exists(), + "legacy metadata cannot bypass registration" + ); + + let legacy_plan = workspace.update(cx, |ws, cx| { + ws.with_project("wt1", cx, |project| { + project.path = registered_worktree.to_string_lossy().into_owned(); + true + }); + ws.begin_worktree_removal("wt1", &HooksConfig::default(), cx) + .expect("registered legacy worktree is accepted") + }); + assert_eq!( + Workspace::physical_path_identity(&legacy_plan.worktree_path), + Workspace::physical_path_identity(®istered_worktree) + ); + + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + "--force", + path_str(®istered_worktree), + ]); + let _ = std::fs::remove_dir_all(&fixture); + } + + #[gpui::test] + fn active_root_lease_rejects_project_and_worktree_registration(cx: &mut gpui::TestAppContext) { + let fixture = + std::env::temp_dir().join(format!("okena-root-lease-test-{}", uuid::Uuid::new_v4())); + let root = fixture.join("checkout"); + std::fs::create_dir_all(&root).unwrap(); + let mut parent = make_project("parent"); + parent.path = fixture.join("main").to_string_lossy().into_owned(); + let mut container = make_project("container"); + container.path = fixture.to_string_lossy().into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![parent, container]; + data.project_order = vec!["parent".to_string(), "container".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + let (add_error, register_error, discovered_error, rename_error, released_claim) = workspace + .update(cx, |ws, cx| { + let active = ws + .register_worktree_project_deferred_hooks( + "parent", + "active", + &fixture.join("main"), + path_str(&root), + path_str(&root.join("packages/a")), + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .expect("register active worktree"); + let metadata = ws.project(&active).unwrap().worktree_info.as_ref().unwrap(); + assert_eq!(metadata.worktree_path, path_str(&root)); + ws.mark_creating_project(&active); + let add_error = ws + .add_project( + "claim".to_string(), + root.join("packages/b").to_string_lossy().into_owned(), + false, + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .unwrap_err(); + let register_error = ws + .register_worktree_project_deferred_hooks( + "parent", + "other", + &fixture.join("main"), + path_str(&root.join("nested")), + path_str(&root.join("nested/project")), + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .unwrap_err(); + let discovered_error = ws + .add_discovered_worktree( + path_str(&root.join("discovered")), + "discovered", + "parent", + WindowId::Main, + ) + .unwrap_err(); + let rename_error = ws + .ensure_project_path_mutation_allowed( + "container", + &fixture.with_extension("moved"), + ) + .unwrap_err(); + ws.finish_creating_project(&active); + let released_claim = ws + .add_project( + "released claim".to_string(), + root.join("packages/b").to_string_lossy().into_owned(), + false, + &HooksConfig::default(), + WindowId::Main, + cx, + ) + .expect("claim succeeds after lease release"); + ( + add_error, + register_error, + discovered_error, + rename_error, + released_claim, + ) + }); + + assert!(add_error.contains("active worktree operation")); + assert!(register_error.contains("overlaps active operation")); + assert!(discovered_error.contains("overlaps active operation")); + assert!(rename_error.contains("overlaps active worktree operation")); + workspace.read_with(cx, |ws, _| { + assert!(ws.project(&released_claim).is_some()); + }); + let _ = std::fs::remove_dir_all(&fixture); + } + + #[test] + fn physical_path_identity_normalizes_relative_paths() { + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + Workspace::physical_path_identity(Path::new("identity-a/../identity-b")), + Workspace::physical_path_identity(&cwd.join("identity-b")) + ); + } + + #[cfg(windows)] + #[test] + fn physical_path_identity_folds_windows_case_aliases() { + let path = std::env::temp_dir().join(format!( + "okena-case-identity-test-{}/Missing", + uuid::Uuid::new_v4() + )); + let upper = std::path::PathBuf::from(path.to_string_lossy().to_uppercase()); + assert_eq!( + Workspace::physical_path_identity(&path), + Workspace::physical_path_identity(&upper) + ); + } + + #[cfg(unix)] + #[test] + fn physical_path_identity_follows_dangling_symlink_before_parent_components() { + use std::os::unix::fs::symlink; + + let fixture = + std::env::temp_dir().join(format!("okena-path-identity-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&fixture).unwrap(); + let target = fixture.join("not-created/checkout"); + let alias = fixture.join("alias"); + symlink(&target, &alias).unwrap(); + + assert_eq!( + Workspace::physical_path_identity(&alias.join("child/../project")), + Workspace::physical_path_identity(&target.join("project")) + ); + let _ = std::fs::remove_dir_all(&fixture); + } + #[gpui::test] fn test_delete_worktree_removes_from_parent_worktree_ids(cx: &mut gpui::TestAppContext) { let mut parent = make_project("parent"); parent.worktree_ids = vec!["wt1".to_string(), "wt2".to_string()]; let mut data = make_workspace_data(); - data.projects = vec![parent, make_worktree_project("wt1", "parent"), make_worktree_project("wt2", "parent")]; + data.projects = vec![ + parent, + make_worktree_project("wt1", "parent"), + make_worktree_project("wt2", "parent"), + ]; data.project_order = vec!["parent".to_string()]; let workspace = cx.new(|_cx| Workspace::new(data)); @@ -1424,12 +2589,21 @@ mod gpui_tests { let mut parent = make_project("parent"); parent.worktree_ids = vec!["wt1".to_string(), "wt2".to_string()]; let mut data = make_workspace_data(); - data.projects = vec![parent, make_worktree_project("wt1", "parent"), make_worktree_project("wt2", "parent")]; + data.projects = vec![ + parent, + make_worktree_project("wt1", "parent"), + make_worktree_project("wt2", "parent"), + ]; data.project_order = vec!["parent".to_string()]; let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.delete_project(&mut FocusManager::new(), "parent", &HooksConfig::default(), cx); + ws.delete_project( + &mut FocusManager::new(), + "parent", + &HooksConfig::default(), + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -1445,7 +2619,12 @@ mod gpui_tests { let mut parent = make_project("parent"); parent.worktree_ids = vec!["wt1".to_string(), "wt2".to_string(), "wt3".to_string()]; let mut data = make_workspace_data(); - data.projects = vec![parent, make_worktree_project("wt1", "parent"), make_worktree_project("wt2", "parent"), make_worktree_project("wt3", "parent")]; + data.projects = vec![ + parent, + make_worktree_project("wt1", "parent"), + make_worktree_project("wt2", "parent"), + make_worktree_project("wt3", "parent"), + ]; data.project_order = vec!["parent".to_string()]; let workspace = cx.new(|_cx| Workspace::new(data)); @@ -1516,7 +2695,10 @@ mod gpui_tests { }); // Modal context survives the hide — the switcher keeps keyboard focus. - assert!(fm.is_modal(), "switcher must keep keyboard focus after hiding"); + assert!( + fm.is_modal(), + "switcher must keep keyboard focus after hiding" + ); // Closing the switcher restores focus to the neighbor (p3), not the // now-hidden p2, and leaves the modal context. @@ -1580,7 +2762,10 @@ mod gpui_tests { ws.remove_stale_worktree("wt1"); - assert!(ws.project("wt1").is_some(), "closing project should not be removed"); + assert!( + ws.project("wt1").is_some(), + "closing project should not be removed" + ); } #[test] @@ -1594,7 +2779,10 @@ mod gpui_tests { ws.remove_stale_worktree("wt1"); - assert!(ws.project("wt1").is_some(), "creating project should not be removed"); + assert!( + ws.project("wt1").is_some(), + "creating project should not be removed" + ); } #[test] @@ -1607,6 +2795,649 @@ mod gpui_tests { ws.remove_stale_worktree("wt1"); - assert!(ws.project("wt1").is_none(), "unmanaged stale worktree should be removed"); + assert!( + ws.project("wt1").is_none(), + "unmanaged stale worktree should be removed" + ); + } + + #[gpui::test] + fn begin_worktree_removal_rejected_while_creating(cx: &mut gpui::TestAppContext) { + // Optimistic worktree create registers the row and returns before its + // background `git worktree add` finishes; a removal landing in that + // window must be rejected so it can't race the in-flight checkout and + // strand an orphaned, git-registered worktree with no workspace row. + // The row and its creating flag must survive the rejected call intact. + let mut parent = make_project("parent"); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut data = make_workspace_data(); + data.projects = vec![parent, make_worktree_project("wt1", "parent")]; + data.project_order = vec!["parent".to_string()]; + let workspace = cx.new(|_cx| Workspace::new(data)); + + let err = workspace.update(cx, |ws: &mut Workspace, cx| { + ws.mark_creating_project("wt1"); + ws.begin_worktree_removal("wt1", &HooksConfig::default(), cx) + .err() + }); + + assert_eq!(err.as_deref(), Some("worktree is still being created")); + workspace.read_with(cx, |ws: &Workspace, _cx| { + assert!( + ws.project("wt1").is_some(), + "row survives the rejected removal" + ); + assert!(ws.is_creating_project("wt1"), "creating flag untouched"); + }); + } + + #[gpui::test] + fn close_worktree_rejected_while_creating(cx: &mut gpui::TestAppContext) { + // The close-entry guard must reject BEFORE close_worktree fires a + // before_remove hook or registers a pending close. Without the entry + // guard the flow would still be rejected — by the begin_worktree_removal + // backstop, with the identical error — but only AFTER the headless + // before_remove hook ran. So the sharp assertion is the marker file: the + // project carries a before_remove hook that writes one, and it must not + // exist after the rejected call. (The project path must be a real dir — + // the headless hook spawns with cwd = OKENA_PROJECT_PATH.) + let marker = + std::env::temp_dir().join(format!("okena_close_guard_marker_{}", std::process::id())); + let _ = std::fs::remove_file(&marker); + let mut parent = make_project("parent"); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut wt = make_worktree_project("wt1", "parent"); + wt.path = std::env::temp_dir().to_string_lossy().into_owned(); + wt.hooks.worktree.before_remove = Some(format!("echo x > \"{}\"", marker.display())); + let mut data = make_workspace_data(); + data.projects = vec![parent, wt]; + data.project_order = vec!["parent".to_string()]; + let workspace = cx.new(|_cx| Workspace::new(data)); + + let err = workspace.update(cx, |ws: &mut Workspace, cx| { + ws.mark_creating_project("wt1"); + ws.close_worktree( + &mut FocusManager::new(), + "wt1", + false, // merge + false, // stash + false, // fetch + false, // push + false, // delete_branch + &HooksConfig::default(), + cx, + ) + .err() + }); + + assert_eq!(err.as_deref(), Some("worktree is still being created")); + assert!( + !marker.exists(), + "before_remove hook must not fire on a rejected mid-create close", + ); + workspace.read_with(cx, |ws: &Workspace, _cx| { + assert!( + ws.project("wt1").is_some(), + "row survives the rejected close" + ); + assert!(ws.is_creating_project("wt1"), "creating flag untouched"); + assert!( + !ws.is_project_closing("wt1"), + "no pending close registered (tracker)" + ); + assert!( + !ws.project("wt1").unwrap().is_closing, + "no pending close registered (wire-facing closing flag stays clear)", + ); + }); + let _ = std::fs::remove_file(&marker); + } + + #[gpui::test] + fn close_worktree_rejected_while_already_closing(cx: &mut gpui::TestAppContext) { + let mut parent = make_project("parent"); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut data = make_workspace_data(); + data.projects = vec![parent, make_worktree_project("wt1", "parent")]; + data.project_order = vec!["parent".to_string()]; + let workspace = cx.new(|_cx| Workspace::new(data)); + + let err = workspace.update(cx, |ws: &mut Workspace, cx| { + ws.mark_closing_project_authoritative("wt1"); + ws.close_worktree( + &mut FocusManager::new(), + "wt1", + false, + false, + false, + false, + false, + &HooksConfig::default(), + cx, + ) + .err() + }); + + assert_eq!(err.as_deref(), Some("worktree is already closing")); + workspace.read_with(cx, |ws: &Workspace, _cx| { + assert!(ws.project("wt1").is_some()); + assert!(ws.is_project_closing("wt1")); + assert!(ws.project("wt1").unwrap().is_closing); + }); + } + + #[gpui::test] + fn rename_worktree_root_moves_git_registration_and_descendant_projects( + cx: &mut gpui::TestAppContext, + ) { + let fixture = + std::env::temp_dir().join(format!("okena-root-rename-{}", uuid::Uuid::new_v4())); + let main_repo = fixture.join("main"); + let worktree = fixture.join("worktree"); + let renamed = fixture.join("renamed-worktree"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::create_dir_all(main_repo.join("packages/child")).unwrap(); + std::fs::write(main_repo.join("packages/child/file.txt"), "tracked\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "packages"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(&worktree), + ]); + + let mut parent = make_project("parent"); + parent.path = main_repo.to_string_lossy().into_owned(); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut child = make_worktree_project("wt1", "parent"); + child.path = worktree.to_string_lossy().into_owned(); + let metadata = child.worktree_info.as_mut().unwrap(); + metadata.main_repo_path = main_repo.to_string_lossy().into_owned(); + metadata.worktree_path = worktree.to_string_lossy().into_owned(); + let mut descendant = make_project("descendant"); + descendant.path = worktree + .join("packages/child") + .to_string_lossy() + .into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![parent, child, descendant]; + data.project_order = vec!["parent".to_string(), "descendant".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + workspace.update(cx, |ws, cx| { + ws.rename_project_directory( + "wt1", + renamed.to_string_lossy().into_owned(), + "renamed-worktree".to_string(), + cx, + ) + .unwrap(); + }); + + workspace.read_with(cx, |ws, _| { + let moved = ws.project("wt1").unwrap(); + assert_eq!(Path::new(&moved.path), renamed); + assert_eq!( + Path::new(&moved.worktree_info.as_ref().unwrap().worktree_path), + renamed + ); + assert_eq!( + Path::new(&ws.project("descendant").unwrap().path), + renamed.join("packages/child") + ); + }); + assert!(!worktree.exists()); + assert!(okena_git::verify_linked_worktree_fresh(&main_repo, &renamed).is_ok()); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + "--force", + path_str(&renamed), + ]); + let _ = std::fs::remove_dir_all(fixture); + } + + #[gpui::test] + fn rename_monorepo_subdirectory_preserves_worktree_root(cx: &mut gpui::TestAppContext) { + let fixture = + std::env::temp_dir().join(format!("okena-subdir-rename-{}", uuid::Uuid::new_v4())); + let main_repo = fixture.join("main"); + let worktree = fixture.join("worktree"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::create_dir_all(main_repo.join("packages/app/nested")).unwrap(); + std::fs::write(main_repo.join("packages/app/file.txt"), "tracked\n").unwrap(); + std::fs::write(main_repo.join("packages/app/nested/file.txt"), "nested\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "packages"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(&worktree), + ]); + let old_project_path = worktree.join("packages/app"); + let new_project_path = worktree.join("packages/renamed-app"); + + let mut parent = make_project("parent"); + parent.path = main_repo.to_string_lossy().into_owned(); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut child = make_worktree_project("wt1", "parent"); + child.path = old_project_path.to_string_lossy().into_owned(); + let metadata = child.worktree_info.as_mut().unwrap(); + metadata.main_repo_path = main_repo.to_string_lossy().into_owned(); + metadata.worktree_path = worktree.to_string_lossy().into_owned(); + let mut descendant = make_project("descendant"); + descendant.path = old_project_path + .join("nested") + .to_string_lossy() + .into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![parent, child, descendant]; + data.project_order = vec!["parent".to_string(), "descendant".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + workspace.update(cx, |ws, cx| { + ws.rename_project_directory( + "wt1", + new_project_path.to_string_lossy().into_owned(), + "renamed-app".to_string(), + cx, + ) + .unwrap(); + }); + + workspace.read_with(cx, |ws, _| { + let moved = ws.project("wt1").unwrap(); + assert_eq!(Path::new(&moved.path), new_project_path); + assert_eq!( + Path::new(&moved.worktree_info.as_ref().unwrap().worktree_path), + worktree + ); + assert_eq!( + Path::new(&ws.project("descendant").unwrap().path), + new_project_path.join("nested") + ); + }); + assert!(worktree.exists()); + assert!(!old_project_path.exists()); + assert!(new_project_path.exists()); + assert!(okena_git::verify_linked_worktree_fresh(&main_repo, &worktree).is_ok()); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + "--force", + path_str(&worktree), + ]); + let _ = std::fs::remove_dir_all(fixture); + } + + #[gpui::test] + fn rename_project_directory_translates_descendant_projects(cx: &mut gpui::TestAppContext) { + let fixture = + std::env::temp_dir().join(format!("okena-project-rename-{}", uuid::Uuid::new_v4())); + let project_path = fixture.join("project"); + let renamed_path = fixture.join("renamed-project"); + let descendant_path = project_path.join("packages/nested"); + std::fs::create_dir_all(&descendant_path).unwrap(); + + let mut project = make_project("project"); + project.path = project_path.to_string_lossy().into_owned(); + let mut descendant = make_project("descendant"); + descendant.path = descendant_path.to_string_lossy().into_owned(); + descendant.hook_terminals.insert( + "completed-descendant-hook".to_string(), + okena_state::HookTerminalEntry { + label: "completed".to_string(), + status: okena_state::HookTerminalStatus::Succeeded, + hook_type: "project.on_open".to_string(), + command: "echo done".to_string(), + cwd: descendant_path.to_string_lossy().into_owned(), + }, + ); + let mut data = make_workspace_data(); + data.projects = vec![project, descendant]; + data.project_order = vec!["project".to_string(), "descendant".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + workspace.update(cx, |ws, cx| { + let plan = ws + .prepare_project_directory_rename( + "project", + renamed_path.to_string_lossy().into_owned(), + "renamed-project".to_string(), + ) + .unwrap(); + assert_eq!( + plan.affected_project_ids().collect::<Vec<_>>(), + vec!["project", "descendant"] + ); + assert_eq!(plan.old_path(), project_path); + assert_eq!(plan.new_path(), renamed_path); + ws.rename_project_directory( + "project", + renamed_path.to_string_lossy().into_owned(), + "renamed-project".to_string(), + cx, + ) + .unwrap(); + }); + + workspace.read_with(cx, |ws, _| { + assert_eq!( + Path::new(&ws.project("project").unwrap().path), + renamed_path + ); + assert_eq!( + Path::new(&ws.project("descendant").unwrap().path), + renamed_path.join("packages/nested") + ); + assert_eq!( + Path::new( + &ws.project("descendant").unwrap().hook_terminals["completed-descendant-hook"] + .cwd + ), + renamed_path.join("packages/nested") + ); + }); + assert!(!project_path.exists()); + assert!(renamed_path.join("packages/nested").exists()); + let _ = std::fs::remove_dir_all(fixture); + } + + #[gpui::test] + fn rename_main_repository_requires_linked_worktrees_to_be_removed( + cx: &mut gpui::TestAppContext, + ) { + let fixture = + std::env::temp_dir().join(format!("okena-main-repo-rename-{}", uuid::Uuid::new_v4())); + let main_repo = fixture.join("main"); + let linked_worktree = fixture.join("external-worktree"); + let renamed_repo = fixture.join("renamed-main"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::write(main_repo.join("file.txt"), "tracked\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "file.txt"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "external", + path_str(&linked_worktree), + ]); + + let mut project = make_project("project"); + project.path = main_repo.to_string_lossy().into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![project]; + data.project_order = vec!["project".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + let error = workspace.update(cx, |ws, cx| { + ws.rename_project_directory( + "project", + renamed_repo.to_string_lossy().into_owned(), + "renamed-main".to_string(), + cx, + ) + .expect_err("linked worktree must block repository rename") + }); + + assert!(error.contains("has linked worktrees")); + assert!(main_repo.exists()); + assert!(!renamed_repo.exists()); + assert!(okena_git::verify_linked_worktree_fresh(&main_repo, &linked_worktree).is_ok()); + + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + path_str(&linked_worktree), + ]); + workspace.update(cx, |ws, cx| { + ws.rename_project_directory( + "project", + renamed_repo.to_string_lossy().into_owned(), + "renamed-main".to_string(), + cx, + ) + .expect("repository without linked worktrees can be renamed"); + }); + + assert!(!main_repo.exists()); + assert!(renamed_repo.join(".git").exists()); + workspace.read_with(cx, |ws, _| { + assert_eq!( + Path::new(&ws.project("project").unwrap().path), + renamed_repo + ); + }); + let _ = std::fs::remove_dir_all(fixture); + } + + #[gpui::test] + fn rename_ancestor_rejects_descendant_repository_with_external_worktree( + cx: &mut gpui::TestAppContext, + ) { + let fixture = std::env::temp_dir().join(format!( + "okena-ancestor-repo-rename-{}", + uuid::Uuid::new_v4() + )); + let ancestor = fixture.join("workspace"); + let main_repo = ancestor.join("packages/repo"); + let linked_worktree = fixture.join("external-worktree"); + let renamed = fixture.join("renamed-workspace"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::write(main_repo.join("file.txt"), "tracked\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "file.txt"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "external", + path_str(&linked_worktree), + ]); + + let mut parent = make_project("ancestor"); + parent.path = ancestor.to_string_lossy().into_owned(); + let mut descendant = make_project("repo"); + descendant.path = main_repo.to_string_lossy().into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![parent, descendant]; + data.project_order = vec!["ancestor".to_string(), "repo".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + let error = workspace.update(cx, |ws, _| { + ws.prepare_project_directory_rename( + "ancestor", + renamed.to_string_lossy().into_owned(), + "renamed-workspace".to_string(), + ) + .err() + .expect("descendant repository registration must block ancestor move") + }); + assert!(error.contains("has linked worktrees")); + + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + "--force", + path_str(&linked_worktree), + ]); + let _ = std::fs::remove_dir_all(fixture); + } + + #[gpui::test] + fn rename_ancestor_rejects_descendant_worktree_metadata_root(cx: &mut gpui::TestAppContext) { + let fixture = std::env::temp_dir().join(format!( + "okena-ancestor-worktree-rename-{}", + uuid::Uuid::new_v4() + )); + let main_repo = fixture.join("main"); + let ancestor = fixture.join("workspace"); + let worktree = ancestor.join("linked-checkout"); + let renamed = fixture.join("renamed-workspace"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::write(main_repo.join("file.txt"), "tracked\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "file.txt"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(&worktree), + ]); + + let mut main = make_project("main"); + main.path = main_repo.to_string_lossy().into_owned(); + let mut outer = make_project("ancestor"); + outer.path = ancestor.to_string_lossy().into_owned(); + let mut child = make_worktree_project("worktree", "main"); + child.path = worktree.to_string_lossy().into_owned(); + child.worktree_info.as_mut().unwrap().worktree_path = + worktree.to_string_lossy().into_owned(); + let mut data = make_workspace_data(); + data.projects = vec![main, outer, child]; + data.project_order = vec!["main".to_string(), "ancestor".to_string()]; + let workspace = cx.new(|_| Workspace::new(data)); + + let error = workspace.update(cx, |ws, _| { + ws.prepare_project_directory_rename( + "ancestor", + renamed.to_string_lossy().into_owned(), + "renamed-workspace".to_string(), + ) + .err() + .expect("registered descendant checkout must block plain directory move") + }); + assert!(error.contains("linked worktree project")); + + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "remove", + "--force", + path_str(&worktree), + ]); + let _ = std::fs::remove_dir_all(fixture); + } + + #[gpui::test] + fn root_claim_succeeds_after_create_finishes(cx: &mut gpui::TestAppContext) { + // Once finalize clears the create lease, paths below the checkout can + // be claimed again rather than remaining wedged forever. + let mut parent = make_project("parent"); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut data = make_workspace_data(); + data.projects = vec![parent, make_worktree_project("wt1", "parent")]; + data.project_order = vec!["parent".to_string()]; + let workspace = cx.new(|_cx| Workspace::new(data)); + + let result = workspace.update(cx, |ws: &mut Workspace, _cx| { + ws.mark_creating_project("wt1"); + ws.finish_creating_project("wt1"); + ws.ensure_project_path_claim_allowed(Path::new("/tmp/worktrees/wt1/packages/app")) + }); + + assert!( + result.is_ok(), + "guard should release once create finishes, got {:?}", + result.err(), + ); + workspace.read_with(cx, |ws: &Workspace, _cx| { + assert!(!ws.is_creating_project("wt1"), "creating flag cleared"); + }); } } diff --git a/crates/okena-workspace/src/actions/soft_close.rs b/crates/okena-workspace/src/actions/soft_close.rs index 56d82ef75..18f34630e 100644 --- a/crates/okena-workspace/src/actions/soft_close.rs +++ b/crates/okena-workspace/src/actions/soft_close.rs @@ -5,9 +5,263 @@ //! 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::{SOFT_CLOSE_KILL_PREFIX, SOFT_CLOSE_UNDO_PREFIX, encode_action}; +use okena_state::{Toast, ToastAction, ToastActionStyle}; +use okena_terminal::TerminalsRegistry; +use okena_terminal::backend::TerminalBackend; + +use crate::context::WorkspaceCx; use crate::focus::FocusManager; -use crate::state::{LayoutNode, PendingClose, RestoredClose, Workspace}; -use gpui::*; +use crate::state::{ClosingTerminalOwner, LayoutNode, PendingClose, RestoredClose, Workspace}; + +/// 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<Mutex<HashMap<String, Instant>>>; + +/// 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<String>) { + 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<String>, + 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::<Vec<_>>() + .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<String>, + cx: &mut impl WorkspaceCx, +) -> Option<Toast> { + 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 return the PTYs the caller must tear down after +/// releasing its workspace lock. +pub fn close_now_flow( + deadlines: &SoftCloseDeadlines, + ws: &mut Workspace, + terminal_id: &str, + cx: &mut impl WorkspaceCx, +) -> Vec<String> { + deadlines.lock().remove(terminal_id); + ws.finalize_soft_close(terminal_id, cx); + ws.drain_pending_terminal_kills() +} + +/// 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 and returns the kill queue. +/// Callers tear down those PTYs after releasing their workspace lock. The client +/// toast TTLs out on its own. Callers drive this on a timer (~200ms). +pub fn finalize_expired( + deadlines: &SoftCloseDeadlines, + ws: &mut Workspace, + cx: &mut impl WorkspaceCx, +) -> Vec<String> { + // Collect + remove expired ids under the deadline lock only. + let expired: Vec<String> = { + let now = Instant::now(); + let mut d = deadlines.lock(); + let exp: Vec<String> = d + .iter() + .filter(|(_, dl)| **dl <= now) + .map(|(t, _)| t.clone()) + .collect(); + for t in &exp { + d.remove(t); + } + exp + }; + if expired.is_empty() { + return Vec::new(); + } + + // Finalize on the workspace (queues kills), then drain the kill queue. + for tid in &expired { + ws.finalize_soft_close(tid, cx); + } + ws.drain_pending_terminal_kills() +} /// 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 @@ -26,6 +280,34 @@ pub enum PendingDecision { } impl Workspace { + /// Retain the owner while a terminal is absent from the layout but its PTY + /// exit event is still in flight. + pub fn remember_closing_terminal_owner(&mut self, project_id: &str, terminal_id: &str) { + let Some(project) = self.project(project_id) else { + return; + }; + let owner = ClosingTerminalOwner { + project_id: project_id.to_string(), + terminal_name: project.terminal_names.get(terminal_id).cloned(), + }; + self.closing_terminal_owners + .insert(terminal_id.to_string(), owner); + } + + /// Consume retained ownership exactly once when the PTY exit is handled. + pub fn take_closing_terminal_owner( + &mut self, + terminal_id: &str, + ) -> Option<(String, Option<String>)> { + self.closing_terminal_owners + .remove(terminal_id) + .map(|owner| (owner.project_id, owner.terminal_name)) + } + + fn forget_closing_terminal_owner(&mut self, terminal_id: &str) { + self.closing_terminal_owners.remove(terminal_id); + } + /// Begin a soft close: snapshot the project's layout, remove the terminal /// from the tree (focusing a sibling, exactly like a normal close), and /// record the pending close so it can be undone or finalized later. @@ -39,10 +321,12 @@ impl Workspace { path: &[usize], terminal_id: &str, toast_id: &str, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let pre_close_layout = self.project(project_id).and_then(|p| p.layout.clone()); + self.remember_closing_terminal_owner(project_id, terminal_id); + // Remove from the layout + focus a sibling (same as a hard close). self.close_terminal_and_focus_sibling(focus_manager, project_id, path, cx); @@ -58,7 +342,8 @@ impl Workspace { // A fresh close supersedes any earlier restore-race breadcrumb for this // terminal (e.g. close → undo → close again). - self.restored_closes.retain(|r| r.terminal_id != terminal_id); + self.restored_closes + .retain(|r| r.terminal_id != terminal_id); } /// True if the terminal is currently waiting out its grace period. @@ -80,7 +365,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<Self>) -> 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 +389,7 @@ impl Workspace { &mut self, terminal_id: &str, busy: bool, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> PendingDecision { if !self.has_pending_close(terminal_id) { return PendingDecision::Raced; @@ -128,12 +413,13 @@ impl Workspace { focus_manager: &mut FocusManager, terminal_id: &str, alive: bool, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> bool { let pending = match self.take_pending_close(terminal_id) { Some(p) => p, None => return false, }; + let retained_owner = self.take_closing_terminal_owner(terminal_id); if !alive { // The shell exited during the grace window — nothing to bring back. @@ -158,7 +444,12 @@ impl Workspace { .and_then(|l| l.find_terminal_node(terminal_id)) .cloned(); if let Some(mut node) = node { - if let LayoutNode::Terminal { minimized, detached, .. } = &mut node { + if let LayoutNode::Terminal { + minimized, + detached, + .. + } = &mut node + { *minimized = false; *detached = false; } @@ -171,6 +462,14 @@ impl Workspace { } } + if let Some((_, Some(terminal_name))) = retained_owner + && let Some(project) = self.project_mut(&project_id) + { + project + .terminal_names + .insert(terminal_id.to_string(), terminal_name); + } + // Focus the restored terminal. if let Some(path) = self .project(&project_id) @@ -185,7 +484,8 @@ impl Workspace { // (its exit event still queued). If that exit lands, the exit handler // calls `reap_restored_close` to tear this pane back out — see // [`RestoredClose`]. - self.restored_closes.retain(|r| r.terminal_id != terminal_id); + self.restored_closes + .retain(|r| r.terminal_id != terminal_id); self.restored_closes.push(RestoredClose { terminal_id: terminal_id.to_string(), project_id, @@ -200,7 +500,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<Self>) -> bool { + pub fn reap_restored_close(&mut self, terminal_id: &str, cx: &mut impl WorkspaceCx) -> bool { let Some(idx) = self .restored_closes .iter() @@ -224,10 +524,14 @@ impl Workspace { /// quit to make sure soft-closed PTYs are actually torn down (and don't /// leak as orphaned session-backend sessions). pub fn drain_pending_closes(&mut self) -> Vec<String> { - std::mem::take(&mut self.pending_closes) + let ids: Vec<String> = std::mem::take(&mut self.pending_closes) .into_iter() .map(|p| p.terminal_id) - .collect() + .collect(); + for terminal_id in &ids { + self.forget_closing_terminal_owner(terminal_id); + } + ids } /// Drain pending soft-closes belonging to `project_id`, returning their @@ -244,6 +548,9 @@ impl Workspace { true } }); + for terminal_id in &ids { + self.forget_closing_terminal_owner(terminal_id); + } ids } @@ -252,21 +559,31 @@ impl Workspace { /// and return its toast id so the caller can dismiss the now-useless undo /// toast. Returns `None` if the terminal wasn't mid soft-close. pub fn cancel_pending_close(&mut self, terminal_id: &str) -> Option<String> { - self.take_pending_close(terminal_id).map(|p| p.toast_id) + let pending = self.take_pending_close(terminal_id); + self.forget_closing_terminal_owner(terminal_id); + pending.map(|p| p.toast_id) } } -#[cfg(test)] +#[cfg(all(test, feature = "gpui"))] mod tests { use gpui::AppContext as _; - use super::PendingDecision; + use super::{ + PendingDecision, SoftCloseDeadlines, begin_soft_close_flow, build_soft_close_toast, + close_now_flow, finalize_expired, undo_soft_close_flow, + }; use crate::focus::FocusManager; use crate::settings::HooksConfig; use crate::state::{LayoutNode, ProjectData, SplitDirection, Workspace, WorkspaceData}; use okena_core::theme::FolderColor; + use okena_terminal::TerminalsRegistry; use okena_terminal::shell_config::ShellType; + use okena_terminal::terminal::{Terminal, TerminalSize, TerminalTransport}; + use parking_lot::Mutex; use std::collections::HashMap; + use std::sync::Arc; + use std::time::{Duration, Instant}; fn term(id: &str) -> LayoutNode { LayoutNode::Terminal { @@ -297,6 +614,8 @@ mod tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -324,7 +643,10 @@ mod tests { #[gpui::test] fn undo_restores_exact_tree_when_unchanged(cx: &mut gpui::TestAppContext) { - let data = workspace_data(hsplit(vec![term("a"), term("b")])); + let mut data = workspace_data(hsplit(vec![term("a"), term("b")])); + data.projects[0] + .terminal_names + .insert("a".to_string(), "Named terminal".to_string()); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { @@ -344,6 +666,15 @@ mod tests { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); assert_eq!(layout.find_terminal_path("a"), Some(vec![0])); assert_eq!(layout.find_terminal_path("b"), Some(vec![1])); + assert_eq!( + ws.project("p1") + .unwrap() + .terminal_names + .get("a") + .map(String::as_str), + Some("Named terminal") + ); + assert!(ws.take_closing_terminal_owner("a").is_none()); }); } @@ -379,6 +710,11 @@ mod tests { assert!(ws.finalize_soft_close("a", cx)); assert!(!ws.has_pending_close("a")); assert_eq!(ws.drain_pending_terminal_kills(), vec!["a".to_string()]); + assert_eq!( + ws.take_closing_terminal_owner("a"), + Some(("p1".to_string(), None)), + "ownership remains until the PTY exit" + ); // Idempotent — second finalize is a no-op. assert!(!ws.finalize_soft_close("a", cx)); @@ -396,7 +732,10 @@ mod tests { // Busy → keep the pending record (no kill queued) so the caller can // offer an undo. - assert_eq!(ws.decide_pending_close("a", true, cx), PendingDecision::KeepForUndo); + assert_eq!( + ws.decide_pending_close("a", true, cx), + PendingDecision::KeepForUndo + ); assert!(ws.has_pending_close("a")); assert!(ws.drain_pending_terminal_kills().is_empty()); }); @@ -412,7 +751,10 @@ mod tests { ws.begin_soft_close(&mut fm, "p1", &[0], "a", "toast-a", cx); // Idle → kill immediately, pending record dropped. - assert_eq!(ws.decide_pending_close("a", false, cx), PendingDecision::Finalized); + assert_eq!( + ws.decide_pending_close("a", false, cx), + PendingDecision::Finalized + ); assert!(!ws.has_pending_close("a")); assert_eq!(ws.drain_pending_terminal_kills(), vec!["a".to_string()]); }); @@ -430,7 +772,10 @@ mod tests { ws.cancel_pending_close("a"); // Whatever the busy result, there's nothing left to decide. - assert_eq!(ws.decide_pending_close("a", true, cx), PendingDecision::Raced); + assert_eq!( + ws.decide_pending_close("a", true, cx), + PendingDecision::Raced + ); assert!(ws.drain_pending_terminal_kills().is_empty()); }); } @@ -449,7 +794,10 @@ mod tests { // Undo can't restore the exact spot — it appends "a" to the root group. assert!(ws.undo_soft_close(&mut fm, "a", true, cx)); let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - assert!(layout.find_terminal_path("a").is_some(), "a is back in the tree"); + assert!( + layout.find_terminal_path("a").is_some(), + "a is back in the tree" + ); assert!(layout.find_terminal_path("b").is_some(), "b retained"); }); } @@ -479,7 +827,10 @@ mod tests { // dead pane back out instead of leaving it to linger / respawn. assert!(ws.reap_restored_close("a", cx)); let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - assert!(layout.find_terminal_path("a").is_none(), "dead pane removed"); + assert!( + layout.find_terminal_path("a").is_none(), + "dead pane removed" + ); assert!(layout.find_terminal_path("b").is_some(), "sibling retained"); // Idempotent — nothing left to reap. @@ -500,7 +851,10 @@ mod tests { // Soft-closing "a" again supersedes the earlier restore breadcrumb, // so a later stray exit can't reap the freshly-closed pane. ws.begin_soft_close(&mut fm, "p1", &[0], "a", "toast-a2", cx); - assert!(!ws.reap_restored_close("a", cx), "breadcrumb cleared by re-close"); + assert!( + !ws.reap_restored_close("a", cx), + "breadcrumb cleared by re-close" + ); }); } @@ -515,6 +869,7 @@ mod tests { let drained = ws.drain_pending_closes(); assert_eq!(drained, vec!["a".to_string()]); assert!(!ws.has_pending_close("a")); + assert!(ws.take_closing_terminal_owner("a").is_none()); }); } @@ -530,6 +885,7 @@ mod tests { // Shell exited on its own → cancel returns the toast id to dismiss. assert_eq!(ws.cancel_pending_close("a"), Some("toast-a".to_string())); assert!(!ws.has_pending_close("a")); + assert!(ws.take_closing_terminal_owner("a").is_none()); // Idempotent — nothing left to cancel. assert_eq!(ws.cancel_pending_close("a"), None); }); @@ -555,4 +911,192 @@ 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 + } + } + + 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 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)); + + let kill_ids = finalize_expired(&deadlines, ws, cx); + + assert_eq!( + kill_ids, + vec!["a".to_string()], + "only past-deadline returned for teardown" + ); + 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 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)); + + let kill_ids = close_now_flow(&deadlines, ws, "a", cx); + + assert!(!deadlines.lock().contains_key("a"), "deadline cleared"); + assert!(!ws.has_pending_close("a"), "pending finalized"); + assert_eq!(kill_ids, vec!["a".to_string()], "PTY queued for teardown"); + }); + } + + #[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..5c8730a6e 100644 --- a/crates/okena-workspace/src/actions/terminal.rs +++ b/crates/okena-workspace/src/actions/terminal.rs @@ -2,9 +2,9 @@ //! //! Actions for managing individual terminals within projects. -use okena_terminal::shell_config::ShellType; +use crate::context::WorkspaceCx; use crate::state::{LayoutNode, Workspace}; -use gpui::*; +use okena_terminal::shell_config::ShellType; impl Workspace { /// Set terminal ID at a layout path @@ -13,15 +13,42 @@ impl Workspace { project_id: &str, path: &[usize], terminal_id: String, - cx: &mut Context<Self>, + 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 = Some(terminal_id); + return true; + } + false + }); + } + + /// 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 = Some(terminal_id); - return true; - } + && let LayoutNode::Terminal { + terminal_id: id, .. + } = node + { + *id = None; + return true; + } false }); } @@ -32,7 +59,7 @@ impl Workspace { project_id: &str, path: &[usize], shell_type: ShellType, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { self.with_layout_node(project_id, path, cx, |node| { if let LayoutNode::Terminal { shell_type: st, .. } = node { @@ -46,7 +73,9 @@ impl Workspace { /// Get shell type for a terminal at a layout path pub fn get_terminal_shell(&self, project_id: &str, path: &[usize]) -> Option<ShellType> { let project = self.project(project_id)?; - if let Some(LayoutNode::Terminal { shell_type, .. }) = project.layout.as_ref().and_then(|l| l.get_at_path(path)) { + if let Some(LayoutNode::Terminal { shell_type, .. }) = + project.layout.as_ref().and_then(|l| l.get_at_path(path)) + { Some(shell_type.clone()) } else { None @@ -59,7 +88,7 @@ impl Workspace { project_id: &str, terminal_id: &str, new_name: String, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let terminal_id = terminal_id.to_string(); self.with_project(project_id, cx, |project| { @@ -75,7 +104,7 @@ impl Workspace { project_id: &str, terminal_id: &str, hidden: bool, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let terminal_id = terminal_id.to_string(); self.with_project(project_id, cx, |project| { @@ -85,7 +114,12 @@ impl Workspace { } /// Restore (un-minimize) a terminal at a path - pub fn restore_terminal(&mut self, project_id: &str, path: &[usize], cx: &mut Context<Self>) { + 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,26 +135,28 @@ impl Workspace { &mut self, project_id: &str, terminal_id: &str, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) && let Some(ref mut layout) = project.layout - && let Some(path) = layout.find_terminal_path(terminal_id) - && let Some(node) = layout.get_at_path_mut(&path) - && let LayoutNode::Terminal { minimized, .. } = node { - *minimized = !*minimized; - self.notify_data(cx); - } + && let Some(path) = layout.find_terminal_path(terminal_id) + && let Some(node) = layout.get_at_path_mut(&path) + && let LayoutNode::Terminal { minimized, .. } = node + { + *minimized = !*minimized; + self.notify_data(cx); + } } /// Check if a terminal is minimized by ID pub fn is_terminal_minimized(&self, project_id: &str, terminal_id: &str) -> bool { if let Some(project) = self.project(project_id) && let Some(ref layout) = project.layout - && let Some(path) = layout.find_terminal_path(terminal_id) - && let Some(LayoutNode::Terminal { minimized, .. }) = layout.get_at_path(&path) { - return *minimized; - } + && let Some(path) = layout.find_terminal_path(terminal_id) + && let Some(LayoutNode::Terminal { minimized, .. }) = layout.get_at_path(&path) + { + return *minimized; + } false } @@ -131,31 +167,38 @@ impl Workspace { &mut self, project_id: &str, path: &[usize], - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> bool { self.with_layout_node(project_id, path, cx, |node| { - if let LayoutNode::Terminal { terminal_id: Some(_), detached, .. } = node - && !*detached { - *detached = true; - return true; - } + if let LayoutNode::Terminal { + terminal_id: Some(_), + detached, + .. + } = node + && !*detached + { + *detached = true; + return true; + } false }) } /// 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<Self>) { + 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) { - if let Some(node) = layout.get_at_path_mut(&path) - && let LayoutNode::Terminal { detached, .. } = node { - *detached = false; - } - self.notify_data(cx); - return; + && let Some(path) = layout.find_terminal_path(terminal_id) + { + if let Some(node) = layout.get_at_path_mut(&path) + && let LayoutNode::Terminal { detached, .. } = node + { + *detached = false; } + self.notify_data(cx); + return; + } } } @@ -164,9 +207,10 @@ impl Workspace { for project in &self.data.projects { if let Some(ref layout) = project.layout && let Some(path) = layout.find_terminal_path(terminal_id) - && let Some(LayoutNode::Terminal { detached, .. }) = layout.get_at_path(&path) { - return *detached; - } + && let Some(LayoutNode::Terminal { detached, .. }) = layout.get_at_path(&path) + { + return *detached; + } } false } @@ -192,7 +236,7 @@ impl Workspace { project_id: &str, path: &[usize], zoom: f32, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let clamped = zoom.clamp(0.5, 3.0); self.with_layout_node(project_id, path, cx, |node| { @@ -204,5 +248,4 @@ impl Workspace { } }); } - } diff --git a/crates/okena-workspace/src/actions/worktree.rs b/crates/okena-workspace/src/actions/worktree.rs index b04836664..aa1a6f841 100644 --- a/crates/okena-workspace/src/actions/worktree.rs +++ b/crates/okena-workspace/src/actions/worktree.rs @@ -3,13 +3,266 @@ //! Actions for creating, registering, discovering, and removing git //! 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 std::collections::HashMap; +use crate::state::{LayoutNode, PendingWorktreeClose, ProjectData, WindowId, Workspace}; +use okena_core::theme::FolderColor; +use std::collections::{HashMap, HashSet}; + +/// Captured inputs for a two-phase worktree removal. [`Workspace::begin_worktree_removal`] +/// snapshots everything the finalize step needs (branch, paths, hooks) BEFORE the +/// git worktree checkout is deleted, so the daemon can run the slow, blocking +/// `git worktree remove` off the command-loop thread and then +/// [`Workspace::finish_worktree_removal`] applies the state change from this +/// snapshot — the checkout is gone by then, so branch/paths can't be re-read. +#[derive(Clone)] +pub struct WorktreeRemovalPlan { + pub project_id: String, + /// The git worktree root to remove (may differ from project.path for monorepos). + pub worktree_path: std::path::PathBuf, + /// The main repo path — used for `git worktree prune` in the fast removal. + pub main_repo_path: String, + verified_worktree: okena_git::VerifiedWorktree, + branch: String, + project_hooks: HooksConfig, + project_name: String, + /// The project's own path (the on_dirty-close hook's CWD), which for a + /// monorepo worktree is a subdir inside `worktree_path`. + project_path: String, + folder_id: Option<String>, + folder_name: Option<String>, +} + +impl WorktreeRemovalPlan { + pub fn worktree_path(&self) -> &std::path::Path { + &self.worktree_path + } + + /// Reject standard-remove failures that can be known before runtimes are + /// disturbed. Git refuses a dirty checkout unless the caller passes force. + pub fn preflight_remove(&self, force: bool) -> Result<(), String> { + if !force && okena_git::has_uncommitted_changes(&self.worktree_path) { + return Err( + "worktree has uncommitted changes; pass force=true to remove it".to_string(), + ); + } + Ok(()) + } + + pub fn remove(&self, force: bool) -> Result<(), String> { + okena_git::remove_worktree(&self.verified_worktree, force) + .map_err(|error| error.to_string()) + } + + pub fn remove_fast(&self) -> okena_git::GitResult<()> { + okena_git::remove_worktree_fast(&self.verified_worktree) + } + + /// Run the dirty-close safety hook before the checkout disappears. + pub fn fire_on_dirty_close_headless( + &self, + global_hooks: &HooksConfig, + monitor: Option<&okena_hooks::HookMonitor>, + ) -> Result<(), String> { + hooks::fire_on_dirty_worktree_close_headless( + &self.project_hooks, + global_hooks, + &self.project_id, + &self.project_name, + &self.project_path, + &self.branch, + self.folder_id.as_deref(), + self.folder_name.as_deref(), + monitor, + ) + } + + /// Complete close hooks while their checkout-backed working directory is valid. + pub fn fire_close_hooks_headless( + &self, + global_hooks: &HooksConfig, + monitor: Option<&okena_hooks::HookMonitor>, + ) { + if let Err(error) = hooks::fire_on_worktree_close_headless_sync( + &self.project_hooks, + &self.project_id, + &self.project_name, + &self.project_path, + &self.branch, + self.folder_id.as_deref(), + self.folder_name.as_deref(), + global_hooks, + monitor, + ) { + log::warn!("on_worktree_close hook failed: {error}"); + } + if let Err(error) = hooks::fire_on_project_close_headless_sync( + &self.project_hooks, + &self.project_id, + &self.project_name, + &self.project_path, + self.folder_id.as_deref(), + self.folder_name.as_deref(), + global_hooks, + monitor, + ) { + log::warn!("on_project_close hook failed: {error}"); + } + } +} + +/// Result of the worktree-close merge pipeline ([`close_worktree_merge_git`]). +/// The pipeline is pure git + headless hooks (no workspace access), so it can run +/// off the daemon reactor; the caller applies the workspace-side effects. +pub enum CloseWorktreeGitOutcome { + /// Merge (or a no-op merge) succeeded. `did_stash` gates `force_remove`. + Ok { did_stash: bool }, + /// Rebase hit a conflict. The caller executes the deferred hook plan and + /// registers its results before aborting the close with `error`. + RebaseConflict { + error: String, + hook_plan: Option<hooks::HookActionPlan>, + }, + /// A git step (or the `pre_merge` hook) failed; stash-pop recovery already ran. + Err(String), +} + +/// Restore a stash after a failed merge step (best-effort; a failed pop only warns). +fn stash_pop_recover(did_stash: bool, project_path: &str, branch: &str, step: &str) { + 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 {} failure: {}. Your changes remain in the git stash — run `git stash pop` in that worktree to recover them.", + branch, + project_path, + step, + pop_err + ); + } +} + +/// The worktree-close merge pipeline: stash → fetch → pre_merge hook → rebase → +/// merge → post_merge hook → push → delete-branch, with stash-pop recovery on any +/// failing step. PURE: only git subprocesses + headless hooks (monitor, no PTY +/// runner), no `&mut Workspace` — so the daemon runs it on a blocking thread with +/// no lock held. `on_rebase_conflict` is resolved into a deferred plan for the +/// caller to execute and register on its reactor. Call only when merge is enabled. +#[allow(clippy::too_many_arguments)] // cohesive close-pipeline inputs +pub fn close_worktree_merge_git( + stash_enabled: bool, + fetch_enabled: bool, + push_enabled: bool, + delete_branch_enabled: bool, + project_id: &str, + project_name: &str, + project_path: &str, + branch: &str, + default_branch: &str, + main_repo_path: &str, + project_hooks: &HooksConfig, + global_hooks: &HooksConfig, + folder_id: Option<&str>, + folder_name: Option<&str>, + monitor: Option<&okena_hooks::HookMonitor>, +) -> CloseWorktreeGitOutcome { + use std::path::Path; + let mut did_stash = false; + + if stash_enabled { + if let Err(e) = okena_git::stash_changes(Path::new(project_path)) { + return CloseWorktreeGitOutcome::Err(format!("Stash failed: {}", e)); + } + did_stash = true; + } + + if fetch_enabled && let Err(e) = okena_git::fetch_all(Path::new(project_path)) { + stash_pop_recover(did_stash, project_path, branch, "fetch"); + return CloseWorktreeGitOutcome::Err(format!("Fetch failed: {}", e)); + } + + // pre_merge hook (sync, headless — no PTY runner). + if let Err(e) = hooks::fire_pre_merge( + project_hooks, + global_hooks, + project_id, + project_name, + project_path, + branch, + default_branch, + main_repo_path, + folder_id, + folder_name, + monitor, + None, + ) { + stash_pop_recover(did_stash, project_path, branch, "pre_merge hook"); + return CloseWorktreeGitOutcome::Err(format!("pre_merge hook failed: {}", e)); + } + + // Rebase; on conflict, defer the hook until the caller can register any PTY + // results before yielding back to its event loop. + if let Err(e) = okena_git::rebase_onto(Path::new(project_path), default_branch) { + let error_msg = e.to_string(); + let hook_plan = hooks::plan_on_rebase_conflict( + project_hooks, + global_hooks, + project_id, + project_name, + project_path, + branch, + default_branch, + main_repo_path, + &error_msg, + folder_id, + folder_name, + ); + stash_pop_recover(did_stash, project_path, branch, "rebase"); + return CloseWorktreeGitOutcome::RebaseConflict { + error: format!("Rebase failed: {}", e), + hook_plan, + }; + } + + // Merge (ff-only) in the main repo. + if let Err(e) = okena_git::merge_branch(Path::new(main_repo_path), branch, true) { + stash_pop_recover(did_stash, project_path, branch, "merge"); + return CloseWorktreeGitOutcome::Err(format!("Merge failed: {}", e)); + } + + // Finish before teardown: a PTY would have no durable owner, while a + // detached headless process could lose its worktree CWD during removal. + let _ = hooks::fire_post_merge_headless_sync( + project_hooks, + global_hooks, + project_id, + project_name, + project_path, + branch, + default_branch, + main_repo_path, + folder_id, + folder_name, + monitor, + ); + + if push_enabled + && let Err(e) = okena_git::push_branch(Path::new(main_repo_path), default_branch) + { + log::warn!("Push failed (continuing): {}", e); + } + + if delete_branch_enabled { + if let Err(e) = okena_git::delete_local_branch(Path::new(main_repo_path), branch) { + log::warn!("Delete local branch failed (continuing): {}", e); + } + if let Err(e) = okena_git::delete_remote_branch(Path::new(main_repo_path), branch) { + log::warn!("Delete remote branch failed (continuing): {}", e); + } + } + + CloseWorktreeGitOutcome::Ok { did_stash } +} impl Workspace { /// Toggle visibility for a single worktree (no propagation to children). @@ -20,12 +273,22 @@ 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<Self>) { + 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<FolderColor>, cx: &mut Context<Self>) { + pub fn set_worktree_color_override( + &mut self, + project_id: &str, + color: Option<FolderColor>, + 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,19 +300,26 @@ 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<Self>) { + 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); - let target = if new_index > current_index { - new_index.saturating_sub(1) - } else { - new_index - }; - let target = target.min(parent.worktree_ids.len()); - parent.worktree_ids.insert(target, id); - self.notify_data(cx); - } + && let Some(current_index) = parent.worktree_ids.iter().position(|id| id == worktree_id) + { + let id = parent.worktree_ids.remove(current_index); + let target = if new_index > current_index { + new_index.saturating_sub(1) + } else { + new_index + }; + let target = target.min(parent.worktree_ids.len()); + parent.worktree_ids.insert(target, id); + self.notify_data(cx); + } } /// Create a worktree project from an existing project. @@ -79,20 +349,35 @@ impl Workspace { create_branch: bool, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Result<String, String> { // Create the git worktree at the repo-level target path let target = std::path::PathBuf::from(worktree_path); - okena_git::create_worktree(repo_path, branch, &target, create_branch) - .map_err(|e| match &e { + self.ensure_worktree_target_claim_allowed(&target)?; + self.ensure_project_path_claim_allowed(std::path::Path::new(project_path))?; + okena_git::create_worktree(repo_path, branch, &target, create_branch).map_err( + |e| match &e { okena_git::GitError::WorktreeExists { path } => { - format!("Directory '{}' is already an active worktree", path.display()) + format!( + "Directory '{}' is already an active worktree", + path.display() + ) } other => other.to_string(), - })?; + }, + )?; // Register in workspace state - self.register_worktree_project(parent_project_id, branch, repo_path, worktree_path, project_path, global_hooks, window_id, cx) + self.register_worktree_project( + parent_project_id, + branch, + repo_path, + worktree_path, + project_path, + global_hooks, + window_id, + cx, + ) } /// Register a worktree project in workspace state. @@ -114,9 +399,19 @@ impl Workspace { project_path: &str, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Result<String, String> { - self.register_worktree_project_inner(parent_project_id, branch, repo_path, worktree_path, project_path, true, global_hooks, window_id, cx) + self.register_worktree_project_inner( + parent_project_id, + branch, + repo_path, + worktree_path, + project_path, + true, + global_hooks, + window_id, + cx, + ) } /// Same as `register_worktree_project` but defers on_worktree_create hooks. @@ -135,9 +430,19 @@ impl Workspace { project_path: &str, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Result<String, String> { - self.register_worktree_project_inner(parent_project_id, branch, repo_path, worktree_path, project_path, false, global_hooks, window_id, cx) + self.register_worktree_project_inner( + parent_project_id, + branch, + repo_path, + worktree_path, + project_path, + false, + global_hooks, + window_id, + cx, + ) } #[allow(clippy::too_many_arguments)] // cohesive worktree path/branch params @@ -145,16 +450,42 @@ impl Workspace { &mut self, parent_project_id: &str, branch: &str, - _repo_path: &std::path::Path, - _worktree_path: &str, + repo_path: &std::path::Path, + worktree_path: &str, project_path: &str, fire_hooks: bool, global_hooks: &HooksConfig, window_id: WindowId, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> Result<String, String> { + self.ensure_worktree_target_claim_allowed(std::path::Path::new(worktree_path))?; + self.ensure_project_path_claim_allowed(std::path::Path::new(project_path))?; + + // Dedupe: refuse to register a second worktree row at a path some + // project already occupies. Two CreateWorktree requests for the same + // branch (a double-click or an agent retry) compute the SAME + // deterministic target path; without this both register a row and both + // run `git worktree add` against that one path concurrently, and the + // loser's failure cleanup can delete the winner's live checkout. Mirrors + // add_discovered_worktree's path dedupe. + let project_identity = Self::physical_path_identity(std::path::Path::new(project_path)); + let worktree_identity = Self::physical_path_identity(std::path::Path::new(worktree_path)); + if self + .data + .projects + .iter() + .filter(|project| !project.is_remote) + .any(|project| { + let existing = Self::physical_path_identity(std::path::Path::new(&project.path)); + existing == project_identity || existing == worktree_identity + }) + { + return Err(format!("A worktree for '{branch}' already exists")); + } + // Get parent project info - let parent = self.project(parent_project_id) + let parent = self + .project(parent_project_id) .ok_or_else(|| "Parent project not found".to_string())?; let parent_layout = parent.layout.clone(); @@ -165,25 +496,31 @@ impl Workspace { let id = uuid::Uuid::new_v4().to_string(); let project_name = branch.to_string(); - let new_layout = parent_layout - .as_ref() - .map(|l| l.clone_structure()); + let new_layout = parent_layout.as_ref().map(|l| l.clone_structure()); let project = ProjectData { id: id.clone(), name: project_name, path: project_path.to_string(), - // When hooks are deferred the worktree directory doesn't exist yet. - // Use None so no terminals are spawned until creation finishes. - layout: if fire_hooks { new_layout } else { None }, + // When hooks are deferred the worktree directory doesn't exist yet, + // so use None (no terminals spawned until creation finishes). Otherwise + // clone the parent's structure; if the parent has NO layout, still seed + // a single terminal so the new worktree opens with an initial shell + // instead of an empty project (matches the deferred `fire_worktree_hooks` + // path). `spawn_uninitialized_terminals` materializes the seeded slot. + layout: if fire_hooks { + new_layout.or_else(|| Some(crate::state::LayoutNode::new_terminal())) + } else { + None + }, terminal_names: HashMap::new(), hidden_terminals: HashMap::new(), worktree_info: Some(crate::state::WorktreeMetadata { parent_project_id: parent_project_id.to_string(), color_override: None, - main_repo_path: String::new(), - worktree_path: String::new(), - branch_name: String::new(), + main_repo_path: repo_path.to_string_lossy().into_owned(), + worktree_path: worktree_path.to_string(), + branch_name: branch.to_string(), }), worktree_ids: Vec::new(), folder_color: parent_color, @@ -195,6 +532,10 @@ impl Workspace { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + // Set by the caller via mark_creating_project when this is an + // optimistic (deferred-hooks) create still awaiting its checkout. + is_creating: false, + is_closing: false, }; let new_project_hooks = project.hooks.clone(); @@ -202,7 +543,12 @@ impl Workspace { self.data.projects.push(project); // Add to parent's worktree_ids (not project_order) - if let Some(parent) = self.data.projects.iter_mut().find(|p| p.id == parent_project_id) { + if let Some(parent) = self + .data + .projects + .iter_mut() + .find(|p| p.id == parent_project_id) + { parent.worktree_ids.push(id.clone()); } @@ -219,6 +565,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 +576,8 @@ impl Workspace { folder_id, folder_name, global_hooks, - cx, + runner.as_ref(), + monitor.as_ref(), ); self.register_hook_results(hook_results, cx); } @@ -238,8 +587,15 @@ 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<Self>) { - let Some(project) = self.project(project_id) else { return }; + 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(); let path = project.path.clone(); @@ -249,7 +605,9 @@ impl Workspace { // If layout is still None (deferred creation), clone it from the parent if project.layout.is_none() { - let parent_layout = project.worktree_info.as_ref() + let parent_layout = project + .worktree_info + .as_ref() .and_then(|wt| self.project(&wt.parent_project_id)) .and_then(|p| p.layout.as_ref()) .map(|l| l.clone_structure()); @@ -262,6 +620,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,14 +631,15 @@ impl Workspace { folder_id, folder_name, global_hooks, - cx, + runner.as_ref(), + monitor.as_ref(), ); self.register_hook_results(hook_results, cx); } /// Add a worktree project discovered by the periodic sync watcher. /// Does NOT fire hooks (the worktree was created outside Okena). - /// Returns the new project ID, or None if already tracked. + /// Returns the new project ID, or an error if already tracked or reserved. /// /// `window_id` identifies the spawning window for the multi-window /// new-project visibility rule (PRD user story 14): the discovered @@ -294,19 +655,33 @@ impl Workspace { branch: &str, parent_id: &str, window_id: WindowId, - ) -> Option<String> { + ) -> Result<String, String> { // For monorepo projects, resolve the subdirectory offset so the // project path points to the right place inside the worktree. - let parent_path = self.project(parent_id) + let parent_path = self + .project(parent_id) .map(|p| p.path.clone()) - .unwrap_or_default(); - let (_git_root, subdir) = okena_git::resolve_git_root_and_subdir( - std::path::Path::new(&parent_path), - ); + .ok_or_else(|| "Parent project not found".to_string())?; + let (git_root, subdir) = + okena_git::resolve_git_root_and_subdir(std::path::Path::new(&parent_path)); let project_path = okena_git::repository::project_path_in_worktree(wt_path, &subdir); - if self.data.projects.iter().any(|p| p.path == project_path || p.path == wt_path) { - return None; + self.ensure_worktree_target_claim_allowed(std::path::Path::new(wt_path))?; + self.ensure_project_path_claim_allowed(std::path::Path::new(&project_path))?; + + let project_identity = Self::physical_path_identity(std::path::Path::new(&project_path)); + let worktree_identity = Self::physical_path_identity(std::path::Path::new(wt_path)); + if self + .data + .projects + .iter() + .filter(|project| !project.is_remote) + .any(|project| { + let existing = Self::physical_path_identity(std::path::Path::new(&project.path)); + existing == project_identity || existing == worktree_identity + }) + { + return Err(format!("A worktree for '{branch}' already exists")); } let dir_name = std::path::Path::new(wt_path) @@ -326,9 +701,9 @@ impl Workspace { worktree_info: Some(crate::state::WorktreeMetadata { parent_project_id: parent_id.to_string(), color_override: None, - main_repo_path: String::new(), - worktree_path: String::new(), - branch_name: String::new(), + main_repo_path: git_root.to_string_lossy().into_owned(), + worktree_path: wt_path.to_string(), + branch_name: branch.to_string(), }), worktree_ids: Vec::new(), default_shell: None, @@ -340,6 +715,8 @@ impl Workspace { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, }; // Multi-window new-project visibility rule (PRD user story 14): @@ -353,22 +730,28 @@ impl Workspace { // Insert after parent in project_order self.data.projects.push(project); - if let Some(parent_index) = self.data.project_order.iter().position(|pid| pid == parent_id) { + if let Some(parent_index) = self + .data + .project_order + .iter() + .position(|pid| pid == parent_id) + { self.data.project_order.insert(parent_index + 1, id.clone()); } else { self.data.project_order.push(id.clone()); } // Note: caller is responsible for calling notify_data - Some(id) + Ok(id) } /// Add a worktree project ID to its parent's worktree_ids list (deduped). /// Also removes the worktree from project_order since it lives under its parent now. pub fn add_to_worktree_ids(&mut self, parent_id: &str, worktree_id: &str) { if let Some(parent) = self.data.projects.iter_mut().find(|p| p.id == parent_id) - && !parent.worktree_ids.iter().any(|id| id == worktree_id) { - parent.worktree_ids.push(worktree_id.to_string()); - } + && !parent.worktree_ids.iter().any(|id| id == worktree_id) + { + parent.worktree_ids.push(worktree_id.to_string()); + } // Worktrees in worktree_ids don't belong in project_order self.data.project_order.retain(|id| id != worktree_id); // Also remove from any folder's project_ids @@ -386,7 +769,10 @@ impl Workspace { } // Only remove if it's actually a worktree project - let is_worktree = self.data.projects.iter() + let is_worktree = self + .data + .projects + .iter() .any(|p| p.id == project_id && p.worktree_info.is_some()); if !is_worktree { return; @@ -397,6 +783,13 @@ impl Workspace { for folder in &mut self.data.folders { folder.project_ids.retain(|id| id != project_id); } + // Scrub the child id from its parent's worktree_ids, or the sidebar keeps + // a dangling phantom child (both for externally-deleted worktrees and the + // optimistic-create rollback path). `delete_project` already does this; a + // stale removal must too. + for parent in &mut self.data.projects { + parent.worktree_ids.retain(|id| id != project_id); + } // Scrub the worktree id from every window's per-project storage // (hidden set + widths map on main + every extra). Same fan-out as // the primary `delete_project` path. @@ -412,48 +805,610 @@ impl Workspace { ) -> Option<(String, Option<String>)> { let parent = self.project(parent_project_id)?; let main_repo = self.worktree_parent_path(parent_project_id); - Some(( - parent.path.clone(), - main_repo, - )) + Some((parent.path.clone(), main_repo)) } - /// 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<Self>) -> Result<(), String> { - let project = self.project(project_id) - .ok_or_else(|| "Project not found".to_string())?; + /// Remove a worktree project and its git worktree (synchronous). Fires the + /// `on_worktree_close` hook, runs `git worktree remove`, then finalizes state. + /// Single entry point for in-process / GUI / test callers; the daemon splits + /// it via [`begin_worktree_removal`](Self::begin_worktree_removal) + an + /// off-reactor `git worktree remove` + [`finish_worktree_removal`](Self::finish_worktree_removal) + /// so the (slow, blocking) git call doesn't stall the command loop. + 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 plan = self.begin_worktree_removal(project_id, global_hooks, cx)?; + let monitor = cx.hook_monitor(); + plan.fire_close_hooks_headless(global_hooks, monitor.as_ref()); + plan.remove(force)?; + self.finish_worktree_removal(focus_manager, &plan, global_hooks, cx); + Ok(()) + } - // Ensure it's a worktree project + /// Phase 1 of worktree removal: validate and snapshot the inputs the + /// off-reactor close hooks and finalize step need. Returns the plan; the + /// caller completes its close hooks, runs `git worktree remove`, and calls + /// [`finish_worktree_removal`](Self::finish_worktree_removal). + pub fn begin_worktree_removal( + &mut self, + project_id: &str, + _global_hooks: &HooksConfig, + _cx: &mut impl WorkspaceCx, + ) -> Result<WorktreeRemovalPlan, String> { + // Reject removal while the worktree is still being created: the optimistic + // create registers the row (worktree_info set) and returns before its + // background `git worktree add` finishes, so removing now would race the + // in-flight checkout and strand an orphaned, git-registered worktree with + // no workspace row. Single choke point for every removal route; mirrors the + // `is_creating` guard in `remove_stale_worktree`. + if self.lifecycle.is_creating(project_id) { + return Err("worktree is still being created".to_string()); + } + let project = self + .project(project_id) + .ok_or_else(|| "Project not found".to_string())?; if project.worktree_info.is_none() { return Err("Not a worktree project".to_string()); } - // Capture info before removal for the hook + self.ensure_worktree_removal_claim_allowed(project_id)?; + + // Snapshot everything BEFORE removal, while the project is still in state + // and its checkout exists on disk (git worktree remove deletes it). let folder = self.folder_for_project_or_parent(project_id); - let hook_folder_id = folder.map(|f| f.id.clone()); - let hook_folder_name = folder.map(|f| f.name.clone()); + let folder_id = folder.map(|f| f.id.clone()); + let folder_name = folder.map(|f| f.name.clone()); let project_hooks = project.hooks.clone(); let project_name = project.name.clone(); let project_path = project.path.clone(); - // For monorepos the project path is a subdirectory inside the worktree checkout. - // Resolve the actual worktree root via git so `git worktree remove` gets the right path. - let project_pathbuf = std::path::PathBuf::from(&project_path); - let worktree_path = okena_git::get_repo_root(&project_pathbuf) - .unwrap_or(project_pathbuf); - - // Resolve branch BEFORE removal (git worktree remove deletes the checkout) + let main_repo_path = self.worktree_parent_path(project_id).unwrap_or_default(); + // For monorepos the project path is a subdirectory inside the checkout; + // resolve the actual worktree root so `git worktree remove` gets it right. + let verified_worktree = self.verified_worktree(project_id)?; + let worktree_path = verified_worktree.checkout_path().to_path_buf(); 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); + Ok(WorktreeRemovalPlan { + project_id: project_id.to_string(), + worktree_path, + main_repo_path, + verified_worktree, + branch, + project_hooks, + project_name, + project_path, + folder_id, + folder_name, + }) + } - // Remove the git worktree - okena_git::remove_worktree(&worktree_path, force) - .map_err(|e| e.to_string())?; + fn verified_worktree(&self, project_id: &str) -> Result<okena_git::VerifiedWorktree, String> { + let project = self + .project(project_id) + .ok_or_else(|| "Project not found".to_string())?; + let metadata = project + .worktree_info + .as_ref() + .ok_or_else(|| "Not a worktree project".to_string())?; + let project_path = std::path::PathBuf::from(&project.path); + let parent = self + .project(&metadata.parent_project_id) + .ok_or_else(|| "Worktree parent project not found".to_string())?; + if parent.is_remote { + return Err("Worktree parent project is not local".to_string()); + } + let checkout_query = if metadata.worktree_path.is_empty() { + project_path.clone() + } else { + std::path::PathBuf::from(&metadata.worktree_path) + }; + let verified = okena_git::verify_linked_worktree_fresh( + std::path::Path::new(&parent.path), + &checkout_query, + ) + .map_err(|error| error.to_string())?; + if !metadata.worktree_path.is_empty() + && Self::physical_path_identity(verified.checkout_path()) + != Self::physical_path_identity(std::path::Path::new(&metadata.worktree_path)) + { + return Err("worktree path does not match its recorded checkout root".to_string()); + } + if !Self::physical_path_identity(&project_path) + .starts_with(&Self::physical_path_identity(verified.checkout_path())) + { + return Err(if metadata.worktree_path.is_empty() { + "project path is outside its linked worktree root".to_string() + } else { + "worktree path does not match its recorded checkout root".to_string() + }); + } + Ok(verified) + } - // Delete the project from workspace (this also fires on_project_close) - self.delete_project(focus_manager, project_id, global_hooks, cx); + fn worktree_root_path(&self, project_id: &str) -> Result<std::path::PathBuf, String> { + self.verified_worktree(project_id) + .map(|verified| verified.checkout_path().to_path_buf()) + } - Ok(()) + /// Validate that deleting this checkout cannot remove another project's + /// working directory. Used before hooks/merge as well as at deletion time. + pub fn ensure_worktree_removal_claim_allowed(&self, project_id: &str) -> Result<(), String> { + let root = self.worktree_root_path(project_id)?; + self.ensure_worktree_root_exclusively_owned(project_id, &root) + } + + /// Keep the authoritative project row while a daemon removal runs, but stop + /// its terminals so their CWD cannot keep the checkout busy on Windows. + pub fn prepare_background_worktree_removal( + &mut self, + project_id: &str, + cx: &mut impl WorkspaceCx, + ) -> Result<Vec<String>, String> { + if self.lifecycle.is_creating(project_id) { + return Err("worktree is still being created".to_string()); + } + let project = self + .project_mut(project_id) + .ok_or_else(|| "Project not found".to_string())?; + if project.worktree_info.is_none() { + return Err("Not a worktree project".to_string()); + } + + let mut terminal_ids = project + .layout + .as_ref() + .map_or_else(Vec::new, LayoutNode::collect_terminal_ids); + terminal_ids.extend(project.hook_terminals.keys().cloned()); + terminal_ids.extend(project.service_terminals.values().cloned()); + if let Some(layout) = &mut project.layout { + layout.clear_terminal_ids_except(&HashSet::new()); + } + + terminal_ids.extend(self.drain_pending_closes_for_project(project_id)); + terminal_ids.sort(); + terminal_ids.dedup(); + self.mark_closing_project_authoritative(project_id); + self.notify_data(cx); + Ok(terminal_ids) + } + + /// Phase 2 of worktree removal (after its close hooks and `git worktree + /// remove` have run): delete the project from workspace state and fire + /// the `worktree_removed` hook from the `plan` snapshot. This is the single + /// convergence point for every removal route, so the hook fires exactly once; + /// the checkout is gone, so it runs from `main_repo_path` (OKENA_BRANCH still + /// carries the removed branch). The hook fires headless (no PTY runner) since + /// the project is already deleted — see [`fire_worktree_removed_hook`](Self::fire_worktree_removed_hook). + pub fn finish_worktree_removal( + &mut self, + focus_manager: &mut FocusManager, + plan: &WorktreeRemovalPlan, + global_hooks: &HooksConfig, + cx: &mut impl WorkspaceCx, + ) { + self.delete_project_without_project_close(focus_manager, &plan.project_id, cx); + self.fire_worktree_removed_hook(plan, global_hooks, cx); + } + + /// Fire the `on_worktree_removed` hook. Split out of + /// [`finish_worktree_removal`](Self::finish_worktree_removal) so the + /// optimistic deferred-close path can `delete_project` immediately (the + /// client's row vanishes at once) and fire this only after the physical + /// directory delete finishes — preserving the hook's "actually removed" + /// semantics without making the row hang around for the whole `remove_dir_all`. + /// + /// Fires headless (no PTY runner). The project is already deleted, so a + /// keep_alive PTY hook would have no project to register its terminal in: + /// the shell would leak in the terminals registry unowned/undismissable and + /// its monitor entry would stay Running forever (only registered hook + /// terminals are ever finished, by `handle_hook_terminal_exits`). The + /// headless path runs the command on a background thread and records the + /// monitor entry's completion — and any failure — itself. + pub fn fire_worktree_removed_hook( + &self, + plan: &WorktreeRemovalPlan, + global_hooks: &HooksConfig, + cx: &mut impl WorkspaceCx, + ) { + let monitor = cx.hook_monitor(); + let _ = hooks::fire_worktree_removed( + &plan.project_hooks, + global_hooks, + &plan.project_id, + &plan.project_name, + &plan.main_repo_path, + &plan.branch, + &plan.main_repo_path, + plan.folder_id.as_deref(), + plan.folder_name.as_deref(), + monitor.as_ref(), + None, + ); + } + + /// 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> { + // Reject up front while the worktree is still being created — before a + // before_remove hook is spawned and a pending close (with its mirrored + // `is_closing` marker) is registered. `begin_worktree_removal` has the + // same guard as the backstop for every removal route, but by then the + // hook has already run and the closing state would need unwinding. + if self.lifecycle.is_creating(project_id) { + return Err("worktree is still being created".to_string()); + } + if self.lifecycle.is_closing(project_id) { + return Err("worktree is already closing".to_string()); + } + self.ensure_worktree_removal_claim_allowed(project_id)?; + // 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(); + + // Step 1: If merge enabled, run the merge pipeline (pure git + headless + // hooks — see `close_worktree_merge_git`; the daemon runs it off-reactor). + let did_stash = if merge_enabled { + match close_worktree_merge_git( + stash_enabled, + fetch_enabled, + push_enabled, + delete_branch_enabled, + project_id, + &project_name, + &project_path, + &branch, + &default_branch, + &main_repo_path, + &project_hooks, + global_hooks, + folder_id.as_deref(), + folder_name.as_deref(), + monitor.as_ref(), + ) { + CloseWorktreeGitOutcome::Ok { did_stash } => did_stash, + CloseWorktreeGitOutcome::RebaseConflict { error, hook_plan } => { + let (terminal_actions, hook_results) = hook_plan.map_or_else( + || (Vec::new(), Vec::new()), + |plan| { + hooks::execute_hook_action_plan(plan, 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); + return Err(error); + } + CloseWorktreeGitOutcome::Err(e) => return Err(e), + } + } else { + false + }; + + 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 completes close hooks, removes the git + // worktree, and deletes the project. + self.remove_worktree_project(focus_manager, project_id, force_remove, global_hooks, cx) + } + } +} + +#[cfg(test)] +mod merge_pipeline_tests { + use super::{CloseWorktreeGitOutcome, WorktreeRemovalPlan, close_worktree_merge_git}; + use crate::hook_monitor::{HookMonitor, HookStatus}; + use crate::settings::{HooksConfig, ProjectHooks, WorktreeHooks}; + use std::path::Path; + use std::process::Command; + + struct TestRepo { + root: std::path::PathBuf, + } + + impl TestRepo { + fn new() -> Self { + let root = std::env::temp_dir() + .join(format!("okena-post-merge-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + Self { root } + } + } + + impl Drop for TestRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn git(args: &[&str]) { + let output = Command::new("git").args(args).output().unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + + fn path_str(path: &Path) -> &str { + path.to_str().expect("test path is utf-8") + } + + #[test] + fn post_merge_is_finished_before_merge_pipeline_returns() { + let fixture = TestRepo::new(); + let main_repo = fixture.root.join("main"); + let worktree = fixture.root.join("worktree"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::write(main_repo.join("base.txt"), "base\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "base.txt"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(&worktree), + ]); + std::fs::write(worktree.join("feature.txt"), "feature\n").unwrap(); + git(&["-C", path_str(&worktree), "add", "feature.txt"]); + git(&["-C", path_str(&worktree), "commit", "-m", "feature"]); + + let hooks = HooksConfig { + worktree: WorktreeHooks { + post_merge: Some("git --version".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let monitor = HookMonitor::new(); + let outcome = close_worktree_merge_git( + false, + false, + false, + false, + "p1", + "Project", + path_str(&worktree), + "feature", + "main", + path_str(&main_repo), + &hooks, + &HooksConfig::default(), + None, + None, + Some(&monitor), + ); + + assert!(matches!( + outcome, + CloseWorktreeGitOutcome::Ok { did_stash: false } + )); + let history = monitor.history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].hook_type, "post_merge"); + assert!(matches!(history[0].status, HookStatus::Succeeded { .. })); + assert!(history[0].terminal_id.is_none()); + } + + #[test] + fn removal_plan_finishes_close_hooks_while_checkout_exists() { + let fixture = TestRepo::new(); + let main_repo = fixture.root.join("main"); + let worktree = fixture.root.join("worktree"); + git(&["init", "-b", "main", path_str(&main_repo)]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.email", + "okena@example.invalid", + ]); + git(&[ + "-C", + path_str(&main_repo), + "config", + "user.name", + "Okena Test", + ]); + std::fs::write(main_repo.join("base.txt"), "base\n").unwrap(); + git(&["-C", path_str(&main_repo), "add", "base.txt"]); + git(&["-C", path_str(&main_repo), "commit", "-m", "base"]); + git(&[ + "-C", + path_str(&main_repo), + "worktree", + "add", + "-b", + "feature", + path_str(&worktree), + ]); + let verified_worktree = + okena_git::verify_linked_worktree_fresh(&main_repo, &worktree).unwrap(); + let hooks = HooksConfig { + project: ProjectHooks { + on_close: Some("echo project > project-close.txt".to_string()), + ..Default::default() + }, + worktree: WorktreeHooks { + on_close: Some("echo worktree > worktree-close.txt".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let plan = WorktreeRemovalPlan { + project_id: "p1".to_string(), + worktree_path: worktree.clone(), + main_repo_path: main_repo.to_string_lossy().into_owned(), + verified_worktree, + branch: "feature".to_string(), + project_hooks: hooks, + project_name: "Project".to_string(), + project_path: worktree.to_string_lossy().into_owned(), + folder_id: None, + folder_name: None, + }; + let monitor = HookMonitor::new(); + + plan.fire_close_hooks_headless(&HooksConfig::default(), Some(&monitor)); + + assert!(worktree.join("worktree-close.txt").exists()); + assert!(worktree.join("project-close.txt").exists()); + let history = monitor.history(); + assert_eq!(history.len(), 2); + assert!( + history + .iter() + .all(|entry| matches!(entry.status, HookStatus::Succeeded { .. })) + ); } } diff --git a/crates/okena-workspace/src/claude_env.rs b/crates/okena-workspace/src/claude_env.rs new file mode 100644 index 000000000..90093ce7d --- /dev/null +++ b/crates/okena-workspace/src/claude_env.rs @@ -0,0 +1,179 @@ +//! 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<PathBuf> { + 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<String>)> { + // 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<String>)> { + 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<Self>`. This is a non-breaking change for existing callers: +//! they pass `&mut Context<Workspace>`, 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<okena_hooks::HookRunner>; + + /// 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<okena_hooks::HookMonitor>; +} + +#[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<App>`. + self.refresh_windows(); + } + + fn hook_runner(&self) -> Option<okena_hooks::HookRunner> { + // Reaches `App::try_global` through `Context`'s `Deref<App>`. + self.try_global::<okena_hooks::HookRunner>().cloned() + } + + fn hook_monitor(&self) -> Option<okena_hooks::HookMonitor> { + self.try_global::<okena_hooks::HookMonitor>().cloned() + } +} diff --git a/crates/okena-workspace/src/focus.rs b/crates/okena-workspace/src/focus.rs index 9fa0d2f4d..7d73272b0 100644 --- a/crates/okena-workspace/src/focus.rs +++ b/crates/okena-workspace/src/focus.rs @@ -105,12 +105,12 @@ impl FocusManager { /// This is the primary method for checking which terminal is focused. /// Returns None if no terminal is focused. pub fn focused_terminal_state(&self) -> Option<crate::state::FocusedTerminalState> { - self.current_focus.as_ref().map(|target| { - crate::state::FocusedTerminalState { + self.current_focus + .as_ref() + .map(|target| crate::state::FocusedTerminalState { project_id: target.project_id.clone(), layout_path: target.layout_path.clone(), - } - }) + }) } /// Get the current focus context @@ -121,9 +121,9 @@ impl FocusManager { /// Check if a specific terminal is currently focused pub fn is_focused(&self, project_id: &str, layout_path: &[usize]) -> bool { - self.current_focus.as_ref().is_some_and(|f| { - f.project_id == project_id && f.layout_path == layout_path - }) + self.current_focus + .as_ref() + .is_some_and(|f| f.project_id == project_id && f.layout_path == layout_path) } // --- Focused project ID (project zoom) --- @@ -187,7 +187,9 @@ impl FocusManager { return None; } self.current_focus.as_ref().and_then(|f| { - f.terminal_id.as_deref().map(|tid| (f.project_id.as_str(), tid)) + f.terminal_id + .as_deref() + .map(|tid| (f.project_id.as_str(), tid)) }) } @@ -200,7 +202,10 @@ impl FocusManager { /// Check if any terminal is in fullscreen mode pub fn has_fullscreen(&self) -> bool { self.context == FocusContext::Fullscreen - && self.current_focus.as_ref().is_some_and(|f| f.terminal_id.is_some()) + && self + .current_focus + .as_ref() + .is_some_and(|f| f.terminal_id.is_some()) } /// Get the project ID of the fullscreened terminal (if any) @@ -234,18 +239,35 @@ impl FocusManager { /// If already in fullscreen, the target is swapped in place — switching /// terminals via the zoom header arrows must not grow the stack, otherwise /// each switch would require another exit click to undo. - pub fn enter_fullscreen(&mut self, project_id: String, layout_path: Vec<usize>, terminal_id: String) { + pub fn enter_fullscreen( + &mut self, + project_id: String, + layout_path: Vec<usize>, + terminal_id: String, + ) { if self.context == FocusContext::Fullscreen { - self.current_focus = Some(FocusTarget::with_terminal(project_id.clone(), layout_path, terminal_id)); + self.current_focus = Some(FocusTarget::with_terminal( + project_id.clone(), + layout_path, + terminal_id, + )); self.focused_project_id = Some(project_id); return; } // Save current state to stack (target may be None if nothing was focused) - self.push_focus(self.current_focus.clone(), self.context.clone(), self.focused_project_id.clone()); + self.push_focus( + self.current_focus.clone(), + self.context.clone(), + self.focused_project_id.clone(), + ); // Set fullscreen as current focus - self.current_focus = Some(FocusTarget::with_terminal(project_id.clone(), layout_path, terminal_id)); + self.current_focus = Some(FocusTarget::with_terminal( + project_id.clone(), + layout_path, + terminal_id, + )); self.context = FocusContext::Fullscreen; // Also zoom to the project @@ -421,13 +443,22 @@ impl FocusManager { } /// Push a focus entry onto the stack. - fn push_focus(&mut self, target: Option<FocusTarget>, context: FocusContext, focused_project_id: Option<String>) { + fn push_focus( + &mut self, + target: Option<FocusTarget>, + context: FocusContext, + focused_project_id: Option<String>, + ) { // Enforce max stack depth while self.focus_stack.len() >= self.max_stack_depth { self.focus_stack.remove(0); } - self.focus_stack.push(FocusStackEntry { target, context, focused_project_id }); + self.focus_stack.push(FocusStackEntry { + target, + context, + focused_project_id, + }); } /// Pop the most recent focus entry from the stack. diff --git a/crates/okena-workspace/src/lib.rs b/crates/okena-workspace/src/lib.rs index 596d06e69..823166b5b 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,12 +11,15 @@ 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; pub mod settings; +pub mod sidebar_controller; pub mod state; pub mod toast; pub mod visibility; +#[cfg(feature = "gpui")] pub mod worktree_sync; diff --git a/crates/okena-workspace/src/lifecycle.rs b/crates/okena-workspace/src/lifecycle.rs index 45b71c771..ed47e9605 100644 --- a/crates/okena-workspace/src/lifecycle.rs +++ b/crates/okena-workspace/src/lifecycle.rs @@ -16,6 +16,9 @@ pub struct ProjectLifecycleTracker { creating: HashSet<String>, /// Project IDs currently being closed (hook running or removal in progress). closing: HashSet<String>, + /// Exact owners for headless runtime quiesce operations. + runtime_quiesce_owners: HashMap<String, u64>, + next_runtime_quiesce_generation: u64, /// Worktree paths currently being removed in the background. /// The sync watcher skips these to avoid re-adding a worktree /// whose directory hasn't been fully deleted yet. @@ -30,6 +33,14 @@ impl ProjectLifecycleTracker { Self::default() } + pub fn has_active_operations(&self) -> bool { + !self.creating.is_empty() + || !self.closing.is_empty() + || !self.runtime_quiesce_owners.is_empty() + || !self.removing_worktree_paths.is_empty() + || !self.pending_worktree_closes.is_empty() + } + // === creating === pub fn mark_creating(&mut self, project_id: &str) { @@ -58,6 +69,46 @@ impl ProjectLifecycleTracker { self.closing.contains(project_id) } + /// Atomically claim runtime ownership for every project in one operation. + pub fn claim_runtime_quiesce(&mut self, project_ids: &[String]) -> Result<u64, String> { + if let Some(project_id) = project_ids.iter().find(|project_id| { + self.runtime_quiesce_owners + .contains_key(project_id.as_str()) + }) { + return Err(format!("project runtime is already quiesced: {project_id}")); + } + let generation = self.next_runtime_quiesce_generation.max(1); + self.next_runtime_quiesce_generation = generation + .checked_add(1) + .ok_or_else(|| "project runtime quiesce generation exhausted".to_string())?; + for project_id in project_ids { + self.runtime_quiesce_owners + .insert(project_id.clone(), generation); + } + Ok(generation) + } + + pub fn owns_runtime_quiesce(&self, project_id: &str, generation: u64) -> bool { + self.runtime_quiesce_owners.get(project_id) == Some(&generation) + } + + /// Release only the operation that still owns this project. + pub fn finish_runtime_quiesce(&mut self, project_id: &str, generation: u64) -> bool { + if !self.owns_runtime_quiesce(project_id, generation) { + return false; + } + self.runtime_quiesce_owners.remove(project_id); + true + } + + /// Prune the closing set to only the given project ids. Used by the client + /// to reconcile its optimistic closing flags against the daemon's mirror: + /// projects the mirror no longer reports as closing (or that vanished) drop + /// their local flag so an aborted close doesn't strand the row "Closing…". + pub fn retain_closing(&mut self, keep: &HashSet<String>) { + self.closing.retain(|id| keep.contains(id)); + } + // === worktree removal === pub fn mark_worktree_removing(&mut self, path: &str) { @@ -81,16 +132,25 @@ impl ProjectLifecycleTracker { .insert(pending.hook_terminal_id.clone(), pending); } + /// Snapshot hook terminal IDs with a worktree close awaiting authoritative + /// completion. Callers must still claim a particular ID through + /// [`Self::cancel_pending_close`] because the snapshot can become stale. + pub fn pending_close_terminal_ids(&self) -> Vec<String> { + self.pending_worktree_closes.keys().cloned().collect() + } + /// Take a pending worktree close for the given hook terminal ID (removes it). pub fn take_pending_close(&mut self, hook_terminal_id: &str) -> Option<PendingWorktreeClose> { self.pending_worktree_closes.remove(hook_terminal_id) } - /// Cancel a pending worktree close: remove it and unmark the project as closing. - pub fn cancel_pending_close(&mut self, hook_terminal_id: &str) { - if let Some(pending) = self.take_pending_close(hook_terminal_id) { - self.closing.remove(&pending.project_id); - } + /// Cancel a pending worktree close: remove it and unmark the project as + /// closing. Returns the affected project id (if any) so the caller can clear + /// the wire-facing `is_closing` marker too. + pub fn cancel_pending_close(&mut self, hook_terminal_id: &str) -> Option<String> { + let pending = self.take_pending_close(hook_terminal_id)?; + self.closing.remove(&pending.project_id); + Some(pending.project_id) } } @@ -160,4 +220,41 @@ mod tests { tracker.cancel_pending_close("hook1"); assert!(!tracker.is_closing("p1")); } + + #[test] + fn pending_close_terminal_ids_is_a_snapshot() { + let mut tracker = ProjectLifecycleTracker::new(); + tracker.register_pending_close(pending("p1", "hook1")); + tracker.register_pending_close(pending("p2", "hook2")); + let mut ids = tracker.pending_close_terminal_ids(); + ids.sort(); + assert_eq!(ids, ["hook1", "hook2"]); + tracker.cancel_pending_close("hook1"); + assert_eq!(ids, ["hook1", "hook2"], "snapshot remains independent"); + } + + #[test] + fn runtime_quiesce_claims_are_atomic_and_generation_fenced() { + let mut tracker = ProjectLifecycleTracker::new(); + let first = tracker + .claim_runtime_quiesce(&["p1".to_string(), "p2".to_string()]) + .expect("claim batch"); + + assert!(tracker.owns_runtime_quiesce("p1", first)); + assert!(tracker.owns_runtime_quiesce("p2", first)); + assert!( + tracker + .claim_runtime_quiesce(&["p2".to_string(), "p3".to_string()]) + .is_err() + ); + assert!(!tracker.owns_runtime_quiesce("p3", first)); + + assert!(tracker.finish_runtime_quiesce("p1", first)); + let second = tracker + .claim_runtime_quiesce(&["p1".to_string()]) + .expect("reclaim project"); + assert_ne!(first, second); + assert!(!tracker.finish_runtime_quiesce("p1", first)); + assert!(tracker.owns_runtime_quiesce("p1", second)); + } } diff --git a/crates/okena-workspace/src/persistence.rs b/crates/okena-workspace/src/persistence.rs index 13f903c94..c9f3bee11 100644 --- a/crates/okena-workspace/src/persistence.rs +++ b/crates/okena-workspace/src/persistence.rs @@ -1,11 +1,15 @@ -use okena_terminal::session_backend::SessionBackend; -use okena_core::theme::FolderColor; -use crate::state::{HookTerminalStatus, LayoutNode, ProjectData, WindowState, WorkspaceData}; #[cfg(test)] use crate::state::WorktreeMetadata; +use crate::state::{HookTerminalStatus, LayoutNode, ProjectData, WindowState, WorkspaceData}; +use okena_core::theme::FolderColor; +use okena_terminal::backend::{TerminalSessionTeardown, TerminalTeardownRoute}; +use okena_terminal::session_backend::SessionBackend; +use okena_terminal::shell_config::ShellType; use anyhow::Result; use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read as _, Seek as _, Write as _}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; @@ -26,23 +30,29 @@ static WORKSPACE_LOCK: Mutex<()> = Mutex::new(()); // Re-export from settings module for backward compatibility #[allow(unused_imports)] pub use super::settings::{ - load_settings, save_settings, get_settings_path, - AppSettings, CursorShape, DiffViewMode, HooksConfig, ProjectHooks, TerminalHooks, WorktreeHooks, SidebarSettings, - DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, - SETTINGS_VERSION, + AppSettings, CursorShape, DEFAULT_SIDEBAR_WIDTH, DiffViewMode, HooksConfig, MAX_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, ProjectHooks, SETTINGS_VERSION, SidebarSettings, TerminalHooks, + WorktreeHooks, get_settings_path, load_settings, save_settings, }; // Re-export from sessions module for backward compatibility #[allow(unused_imports)] pub use super::sessions::{ - list_sessions, save_session, load_session, delete_session, rename_session, session_exists, - export_workspace, import_workspace, - SessionInfo, ExportedWorkspace, + ExportedWorkspace, SessionInfo, delete_session, export_workspace, import_workspace, + list_sessions, load_session, load_session_with_cleanup, load_session_with_cleanup_for_shell, + rename_session, save_session, session_exists, }; /// Current workspace schema version - increment when making breaking changes pub const WORKSPACE_VERSION: u32 = 2; +/// Workspace data plus persistent terminal sessions orphaned by load-time +/// cleanup. Startup kills these ids after constructing its terminal backend. +pub struct LoadedWorkspace { + pub data: WorkspaceData, + pub stale_terminal_ids: Vec<TerminalSessionTeardown>, +} + /// Get the config directory for the active profile. /// /// Falls back to the legacy flat layout path if profiles are not yet initialized @@ -69,67 +79,79 @@ 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 lock acquisition and lifecycle diagnostics so +/// every process resolves the same ownership boundary. +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<LockGuard> { 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")); + acquire_instance_lock_at(instance_lock_path()) +} +fn acquire_instance_lock_at(lock_path: PathBuf) -> Result<LockGuard> { if let Some(parent) = lock_path.parent() { std::fs::create_dir_all(parent)?; } - // Check if a lock file already exists with a live process - if lock_path.exists() - && let Ok(content) = std::fs::read_to_string(&lock_path) - && let Ok(pid) = content.trim().parse::<u32>() { - if is_process_alive(pid) { - anyhow::bail!( - "Another Okena instance is already running (PID {pid}). \ - If this is incorrect, delete {lock_path:?} and try again." - ); - } - // Stale lock file from a crashed process — safe to take over - log::info!("Removing stale lock file from PID {pid}"); - } + let mut file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path)?; + + if let Err(error) = fs2::FileExt::try_lock_exclusive(&file) { + let mut content = String::new(); + let _ = file.read_to_string(&mut content); + let owner = instance_lock_pid(&content) + .map(|pid| format!("PID {pid}")) + .unwrap_or_else(|| "another process".to_string()); + anyhow::bail!( + "Another Okena instance is already running ({owner}): {error}. \ + If this is incorrect, delete {lock_path:?} and try again." + ); + } - let my_pid = std::process::id(); - std::fs::write(&lock_path, my_pid.to_string())?; + let identity = format!("{}:{}", std::process::id(), uuid::Uuid::new_v4()); + file.set_len(0)?; + file.rewind()?; + file.write_all(identity.as_bytes())?; + file.sync_data()?; - Ok(LockGuard { path: lock_path }) + Ok(LockGuard { + path: lock_path, + identity, + file: Some(file), + }) } -/// Guard that removes the lock file on drop +pub fn instance_lock_pid(content: &str) -> Option<u32> { + content.trim().split(':').next()?.parse().ok() +} + +/// Guard that keeps the OS lock held and removes only its own lock file. pub struct LockGuard { path: PathBuf, + identity: String, + file: Option<File>, } impl Drop for LockGuard { fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } -} - -/// Check whether a process with the given PID is still alive -fn is_process_alive(pid: u32) -> bool { - let _slow = okena_core::timing::SlowGuard::new("is_process_alive"); - #[cfg(unix)] - { - // kill(pid, 0) checks existence without sending a signal - unsafe { libc::kill(pid as i32, 0) == 0 } - } - #[cfg(windows)] - { - // On Windows, try tasklist to check if PID exists - okena_core::process::safe_output( - okena_core::process::command("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH"]), - ) - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) - .unwrap_or(false) + if std::fs::read_to_string(&self.path).is_ok_and(|content| content == self.identity) { + let _ = std::fs::remove_file(&self.path); + } + if let Some(file) = self.file.take() { + let _ = fs2::FileExt::unlock(&file); + } } } @@ -138,15 +160,16 @@ fn is_process_alive(pid: u32) -> bool { pub(crate) fn validate_workspace_data( data: &mut WorkspaceData, clear_terminal_ids: bool, - #[cfg_attr(not(windows), allow(unused))] - backend_preference: SessionBackend, + #[cfg_attr(not(windows), allow(unused))] backend_preference: SessionBackend, ) { // Auto-detect WSL default shell for projects with WSL UNC paths that don't have it set. // This must run BEFORE clearing terminal IDs so we can check WSL backend availability. #[cfg(windows)] for project in &mut data.projects { if project.default_shell.is_none() { - if let Some((distro, _)) = okena_terminal::shell_config::parse_wsl_unc_path(&project.path) { + if let Some((distro, _)) = + okena_terminal::shell_config::parse_wsl_unc_path(&project.path) + { project.default_shell = Some(okena_terminal::shell_config::ShellType::Wsl { distro: Some(distro), }); @@ -175,8 +198,8 @@ pub(crate) fn validate_workspace_data( } } // Preserve hook terminal IDs so they're recognized after restart - let hook_ids: std::collections::HashSet<&str> = project.hook_terminals - .keys().map(|s| s.as_str()).collect(); + let hook_ids: std::collections::HashSet<&str> = + project.hook_terminals.keys().map(|s| s.as_str()).collect(); if let Some(ref mut layout) = project.layout { layout.clear_terminal_ids_except(&hook_ids); } @@ -201,11 +224,17 @@ pub(crate) fn validate_workspace_data( // Clean up orphaned terminal metadata (terminal_names/hidden_terminals entries // for terminals no longer in the layout tree) for project in &mut data.projects { - let layout_ids: std::collections::HashSet<String> = project.layout.as_ref() + let layout_ids: std::collections::HashSet<String> = project + .layout + .as_ref() .map(|l| l.collect_terminal_ids().into_iter().collect()) .unwrap_or_default(); - project.terminal_names.retain(|id, _| layout_ids.contains(id)); - project.hidden_terminals.retain(|id, _| layout_ids.contains(id)); + project + .terminal_names + .retain(|id, _| layout_ids.contains(id)); + project + .hidden_terminals + .retain(|id, _| layout_ids.contains(id)); } // Populate worktree_ids from worktree_info back-references (migration for old data) @@ -224,30 +253,40 @@ pub(crate) fn validate_workspace_data( for project in &mut data.projects { if project.worktree_ids.is_empty() - && let Some(mut children) = parent_to_children.remove(&project.id) { - // Sort by position in project_order for deterministic migration - children.sort_by_key(|(_, pos)| pos.unwrap_or(usize::MAX)); - project.worktree_ids = children.into_iter().map(|(id, _)| id).collect(); - } + && let Some(mut children) = parent_to_children.remove(&project.id) + { + // Sort by position in project_order for deterministic migration + children.sort_by_key(|(_, pos)| pos.unwrap_or(usize::MAX)); + project.worktree_ids = children.into_iter().map(|(id, _)| id).collect(); + } } // Remove non-orphan worktrees from project_order (they live in parent's worktree_ids now) - let worktree_ids_in_parents: std::collections::HashSet<String> = data.projects.iter() + let worktree_ids_in_parents: std::collections::HashSet<String> = data + .projects + .iter() .flat_map(|p| p.worktree_ids.iter().cloned()) .collect(); - data.project_order.retain(|id| !worktree_ids_in_parents.contains(id)); + data.project_order + .retain(|id| !worktree_ids_in_parents.contains(id)); // Also remove from folder project_ids for folder in &mut data.folders { - folder.project_ids.retain(|id| !worktree_ids_in_parents.contains(id)); + folder + .project_ids + .retain(|id| !worktree_ids_in_parents.contains(id)); } } // Ensure project_order contains all project IDs (that aren't in a folder or worktree_ids) - let folder_project_ids: std::collections::HashSet<String> = data.folders.iter() + let folder_project_ids: std::collections::HashSet<String> = data + .folders + .iter() .flat_map(|f| f.project_ids.iter().cloned()) .collect(); - let worktree_child_ids: std::collections::HashSet<String> = data.projects.iter() + let worktree_child_ids: std::collections::HashSet<String> = data + .projects + .iter() .flat_map(|p| p.worktree_ids.iter().cloned()) .collect(); for project in &data.projects { @@ -261,15 +300,19 @@ pub(crate) fn validate_workspace_data( // Folder consistency checks { - let valid_project_ids: std::collections::HashSet<&str> = data.projects.iter().map(|p| p.id.as_str()).collect(); + let valid_project_ids: std::collections::HashSet<&str> = + data.projects.iter().map(|p| p.id.as_str()).collect(); // Remove stale project refs from folders for folder in &mut data.folders { - folder.project_ids.retain(|pid| valid_project_ids.contains(pid.as_str())); + folder + .project_ids + .retain(|pid| valid_project_ids.contains(pid.as_str())); } // Ensure folder IDs in project_order match actual folders - let valid_folder_ids: std::collections::HashSet<&str> = data.folders.iter().map(|f| f.id.as_str()).collect(); + let valid_folder_ids: std::collections::HashSet<&str> = + data.folders.iter().map(|f| f.id.as_str()).collect(); data.project_order.retain(|id| { valid_project_ids.contains(id.as_str()) || valid_folder_ids.contains(id.as_str()) }); @@ -286,6 +329,19 @@ pub(crate) fn validate_workspace_data( /// On error, the caller should fall back to `default_workspace()` — auto-save is /// automatically blocked to prevent overwriting valid data on disk. pub fn load_workspace(backend: SessionBackend) -> Result<WorkspaceData> { + load_workspace_with_cleanup(backend).map(|loaded| loaded.data) +} + +/// Load workspace data while retaining ids owned by stale worktree rows. +pub fn load_workspace_with_cleanup(backend: SessionBackend) -> Result<LoadedWorkspace> { + load_workspace_with_cleanup_for_shell(backend, &ShellType::Default) +} + +/// Load workspace data with the transient global shell needed for stale cleanup routing. +pub fn load_workspace_with_cleanup_for_shell( + backend: SessionBackend, + global_default_shell: &ShellType, +) -> Result<LoadedWorkspace> { let path = get_workspace_path(); // If workspace.json is missing, try to auto-recover from backup @@ -340,9 +396,16 @@ pub fn load_workspace(backend: SessionBackend) -> Result<WorkspaceData> { bak_path }; if let Err(backup_err) = std::fs::copy(&path, &backup_path) { - log::error!("Failed to back up corrupted workspace to {:?}: {}", backup_path, backup_err); + log::error!( + "Failed to back up corrupted workspace to {:?}: {}", + backup_path, + backup_err + ); } else { - log::error!("Workspace file is corrupted, backed up to {:?}", backup_path); + log::error!( + "Workspace file is corrupted, backed up to {:?}", + backup_path + ); } // Block auto-save so the default workspace doesn't overwrite the real file LOADED_FROM_DEFAULT.store(true, Ordering::Relaxed); @@ -355,11 +418,15 @@ pub fn load_workspace(backend: SessionBackend) -> Result<WorkspaceData> { let session_backend = backend.resolve(); let clear_ids = !session_backend.supports_persistence(); validate_workspace_data(&mut data, clear_ids, backend); - sync_worktrees(&mut data); + let stale_terminal_ids = + sync_worktrees_with_backend_and_shell(&mut data, backend, global_default_shell); // Successful load — allow saving LOADED_FROM_DEFAULT.store(false, Ordering::Relaxed); - Ok(data) + Ok(LoadedWorkspace { + data, + stale_terminal_ids, + }) } else { let bak_path = path.with_extension("json.bak"); if bak_path.exists() { @@ -375,7 +442,10 @@ pub fn load_workspace(backend: SessionBackend) -> Result<WorkspaceData> { // Fresh install — no workspace.json and no backup. Allow saving. log::info!("No workspace file found — starting with default workspace."); } - Ok(default_workspace()) + Ok(LoadedWorkspace { + data: default_workspace(), + stale_terminal_ids: Vec::new(), + }) } } @@ -393,7 +463,9 @@ pub fn save_workspace(data: &WorkspaceData) -> Result<()> { let _guard = WORKSPACE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); // Layer 1: block save if we loaded from fallback default if LOADED_FROM_DEFAULT.load(Ordering::Relaxed) { - log::warn!("Skipping workspace save — loaded from fallback default, protecting file on disk."); + log::warn!( + "Skipping workspace save — loaded from fallback default, protecting file on disk." + ); return Ok(()); } @@ -445,6 +517,295 @@ pub fn save_workspace(data: &WorkspaceData) -> Result<()> { Ok(()) } +/// Version that introduced client-owned project presentation fields. +const WINDOW_LAYOUT_PROJECT_PRESENTATION_VERSION: u32 = 3; + +/// 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 +/// v3 adds project layout presentation and panel heights so those client-owned +/// values survive a desktop restart without entering daemon persistence. +pub const WINDOW_LAYOUT_VERSION: u32 = WINDOW_LAYOUT_PROJECT_PRESENTATION_VERSION; + +/// 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 presentation: windows, OS bounds, per-window viewport, panel +/// heights, and visual project-layout state. The desktop GUI persists it locally +/// separate from the daemon-owned `workspace.json`. +/// +/// 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<WindowState>, + /// Client-owned visual state keyed by prefixed project ID. The daemon's + /// layout remains structurally authoritative when this is restored. + #[serde(default)] + pub project_layouts: HashMap<String, LayoutNode>, + #[serde(default)] + pub service_panel_heights: HashMap<String, f32>, + #[serde(default)] + pub hook_panel_heights: HashMap<String, f32>, +} + +/// 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. A missing file falls back to the pre-daemon +/// workspace presentation once; an unreadable or corrupt client file returns +/// `None`. Migrates older schema versions in place (see +/// [`WINDOW_LAYOUT_VERSION`]). +pub fn load_window_layout() -> Option<ClientWindowLayout> { + let path = get_window_layout_path(); + load_window_layout_at(&path, load_workspace_window_layout) +} + +fn load_window_layout_at( + path: &Path, + load_legacy_layout: impl FnOnce() -> Option<ClientWindowLayout>, +) -> Option<ClientWindowLayout> { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return load_legacy_layout(); + } + Err(error) => { + log::warn!("Failed to read window-layout.json: {error}; ignoring."); + return None; + } + }; + match serde_json::from_str::<ClientWindowLayout>(&content) { + Ok(mut layout) => { + if layout.version < WINDOW_LAYOUT_PROJECT_PRESENTATION_VERSION + && let Some(legacy_layout) = load_legacy_layout() + { + migrate_window_layout_v3_presentation(&mut layout, legacy_layout); + } + migrate_window_layout(&mut layout); + Some(layout) + } + Err(e) => { + log::warn!("Failed to parse window-layout.json: {e}; ignoring."); + None + } + } +} + +/// 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`. Version-specific +/// backfills run before this common normalization and version stamp. (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<ClientWindowLayout> { + 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::<WorkspaceData>(&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, + project_layouts: data + .projects + .into_iter() + .filter_map(|project| project.layout.map(|layout| (project.id, layout))) + .collect(), + service_panel_heights: data.service_panel_heights, + hook_panel_heights: data.hook_panel_heights, + }; + prefix_local_daemon_window_refs(&mut layout); + Some(layout) +} + +fn migrate_window_layout_v3_presentation( + target: &mut ClientWindowLayout, + source: ClientWindowLayout, +) { + for (project_id, project_layout) in source.project_layouts { + target + .project_layouts + .entry(project_id) + .or_insert(project_layout); + } + if target.service_panel_heights.is_empty() { + target.service_panel_heights = source.service_panel_heights; + } + if target.hook_panel_heights.is_empty() { + target.hook_panel_heights = source.hook_panel_heights; + } +} + +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); + } + prefix_local_daemon_project_layouts(&mut layout.project_layouts); + prefix_local_daemon_keyed_values(&mut layout.service_panel_heights); + prefix_local_daemon_keyed_values(&mut layout.hook_panel_heights); +} + +fn prefix_local_daemon_keyed_values<T>(values: &mut HashMap<String, T>) { + *values = std::mem::take(values) + .into_iter() + .map(|(id, value)| (prefix_local_daemon_id(&id), value)) + .collect(); +} + +fn prefix_local_daemon_project_layouts(layouts: &mut HashMap<String, LayoutNode>) { + *layouts = std::mem::take(layouts) + .into_iter() + .map(|(project_id, mut layout)| { + prefix_local_daemon_terminal_ids(&mut layout); + (prefix_local_daemon_id(&project_id), layout) + }) + .collect(); +} + +fn prefix_local_daemon_terminal_ids(layout: &mut LayoutNode) { + match layout { + LayoutNode::Terminal { terminal_id, .. } => { + if let Some(id) = terminal_id.take() { + *terminal_id = Some(prefix_local_daemon_id(&id)); + } + } + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + for child in children { + prefix_local_daemon_terminal_ids(child); + } + } + } +} + +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 presentation 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, + project_layouts: HashMap<String, LayoutNode>, +) -> 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(), + project_layouts, + service_panel_heights: data.service_panel_heights.clone(), + hook_panel_heights: data.hook_panel_heights.clone(), + }; + let path = get_window_layout_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + // Multiple desktop clients can share one profile. Serialize their + // profile-wide snapshots across processes; the last completed save wins. + let lock_path = path.with_extension("json.lock"); + let lock_file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(lock_path)?; + fs2::FileExt::lock_exclusive(&lock_file)?; + 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. /// @@ -508,16 +869,10 @@ fn migrate_legacy_workspace_value(value: &mut serde_json::Value) { if let Some(Value::Array(projects)) = map.get_mut("projects") { for p in projects.iter_mut() { if let Value::Object(po) = p { - let show_in_overview = po - .remove("show_in_overview") - .and_then(|v| v.as_bool()); - let is_visible = po - .remove("is_visible") - .and_then(|v| v.as_bool()); + let show_in_overview = po.remove("show_in_overview").and_then(|v| v.as_bool()); + let is_visible = po.remove("is_visible").and_then(|v| v.as_bool()); let visible = show_in_overview.or(is_visible).unwrap_or(true); - if !visible - && let Some(id) = po.get("id").and_then(|v| v.as_str()) - { + if !visible && let Some(id) = po.get("id").and_then(|v| v.as_str()) { hidden_ids.push(id.to_string()); } } @@ -545,9 +900,7 @@ fn migrate_legacy_workspace_value(value: &mut serde_json::Value) { .remove("collapsed") .and_then(|v| v.as_bool()) .unwrap_or(false); - if collapsed - && let Some(id) = fo.get("id").and_then(|v| v.as_str()) - { + if collapsed && let Some(id) = fo.get("id").and_then(|v| v.as_str()) { collapsed_ids.push(id.to_string()); } } @@ -593,7 +946,11 @@ pub(crate) fn migrate_workspace(mut data: WorkspaceData) -> WorkspaceData { } if original_version != data.version { - log::info!("Workspace migrated from v{} to v{}", original_version, data.version); + log::info!( + "Workspace migrated from v{} to v{}", + original_version, + data.version + ); } data @@ -604,20 +961,182 @@ pub(crate) fn migrate_workspace(mut data: WorkspaceData) -> WorkspaceData { /// Worktrees are only added as projects explicitly by the user (via the worktree /// list popover or the create worktree dialog). This function only cleans up /// worktree projects that have become stale. -pub(crate) fn sync_worktrees(data: &mut WorkspaceData) { - let stale_ids: Vec<String> = data.projects.iter() +#[cfg(test)] +pub(crate) fn sync_worktrees(data: &mut WorkspaceData) -> Vec<TerminalSessionTeardown> { + sync_worktrees_with_backend_and_shell(data, SessionBackend::None, &ShellType::Default) +} + +pub(crate) fn sync_worktrees_with_backend_and_shell( + data: &mut WorkspaceData, + backend_preference: SessionBackend, + global_default_shell: &ShellType, +) -> Vec<TerminalSessionTeardown> { + let stale_ids: Vec<String> = data + .projects + .iter() .filter(|p| p.worktree_info.is_some()) - .filter(|p| !Path::new(&p.path).exists()) + .filter(|p| !worktree_checkout_path(p).exists()) .map(|p| p.id.clone()) .collect(); + let mut stale_terminal_ids: Vec<TerminalSessionTeardown> = data + .projects + .iter() + .filter(|project| stale_ids.contains(&project.id)) + .flat_map(|project| { + let mut sessions = Vec::new(); + if let Some(layout) = &project.layout { + collect_layout_teardowns( + layout, + project.default_shell.as_ref(), + global_default_shell, + backend_preference, + &mut sessions, + ); + } + sessions.extend( + project + .service_terminals + .values() + .chain(project.hook_terminals.keys()) + .cloned() + .map(TerminalSessionTeardown::host), + ); + sessions + }) + .collect(); + stale_terminal_ids.sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + stale_terminal_ids.dedup_by(|a, b| a.terminal_id == b.terminal_id); + for id in &stale_ids { data.projects.retain(|p| p.id != *id); data.project_order.retain(|pid| pid != id); for folder in &mut data.folders { folder.project_ids.retain(|pid| pid != id); } + // A removed worktree stays referenced by its parent otherwise. + for parent in &mut data.projects { + parent.worktree_ids.retain(|pid| pid != id); + } } + + let retained_terminal_ids: std::collections::HashSet<String> = data + .projects + .iter() + .flat_map(|project| { + let mut ids = project + .layout + .as_ref() + .map_or_else(Vec::new, LayoutNode::collect_terminal_ids); + ids.extend(project.service_terminals.values().cloned()); + ids.extend(project.hook_terminals.keys().cloned()); + ids + }) + .collect(); + stale_terminal_ids.retain(|session| !retained_terminal_ids.contains(&session.terminal_id)); + + // Self-heal a worktree left mid-create by a daemon kill: optimistic create + // registers the row with layout:None before the git checkout, and the + // finalize (which seeds the layout + spawns the PTY) may not have persisted. + // If the checkout dir now EXISTS (so it isn't stale above), seed a terminal + // so it opens a shell instead of hanging on the "Setting up worktree…" + // placeholder forever. + // + // Gated on the persisted `is_creating` marker so we only touch worktrees + // genuinely interrupted mid-create. A worktree the user deliberately emptied + // (closed its last terminal -> layout:None bookmark) has is_creating == false + // and is left untouched — seeding a shell there would silently un-bookmark it + // and resurrect a shell on every restart. + for p in data.projects.iter_mut() { + if p.is_creating + && p.worktree_info.is_some() + && p.layout.is_none() + && Path::new(&p.path).exists() + { + p.layout = Some(LayoutNode::new_terminal()); + // Checkout exists and the layout is now seeded — the create is + // effectively finalized, so clear the marker (materialize spawns the + // PTY and the row renders as a normal worktree, not "creating"). + p.is_creating = false; + } + } + + stale_terminal_ids +} + +pub(crate) fn worktree_checkout_path(project: &ProjectData) -> &Path { + let path = project + .worktree_info + .as_ref() + .map(|metadata| metadata.worktree_path.as_str()) + .filter(|path| !path.is_empty()) + .unwrap_or(project.path.as_str()); + Path::new(path) +} + +fn collect_layout_teardowns( + layout: &LayoutNode, + project_default_shell: Option<&ShellType>, + global_default_shell: &ShellType, + backend_preference: SessionBackend, + sessions: &mut Vec<TerminalSessionTeardown>, +) { + match layout { + LayoutNode::Terminal { + terminal_id: Some(terminal_id), + shell_type, + .. + } => sessions.push(TerminalSessionTeardown { + terminal_id: terminal_id.clone(), + route: teardown_route( + shell_type, + project_default_shell, + global_default_shell, + backend_preference, + ), + }), + LayoutNode::Terminal { .. } => {} + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + for child in children { + collect_layout_teardowns( + child, + project_default_shell, + global_default_shell, + backend_preference, + sessions, + ); + } + } + } +} + +pub(crate) fn teardown_route( + shell: &ShellType, + project_default_shell: Option<&ShellType>, + global_default_shell: &ShellType, + backend_preference: SessionBackend, +) -> TerminalTeardownRoute { + #[cfg(windows)] + if let ShellType::Wsl { distro } = shell + .clone() + .resolve_default(project_default_shell, global_default_shell) + { + return TerminalTeardownRoute::Wsl { + backend: okena_terminal::session_backend::resolve_for_wsl( + distro.as_deref(), + backend_preference, + ), + distro, + }; + } + #[cfg(not(windows))] + let _ = ( + shell, + project_default_shell, + global_default_shell, + backend_preference, + ); + TerminalTeardownRoute::Host } /// Create a default workspace with one project @@ -647,6 +1166,8 @@ pub fn default_workspace() -> WorkspaceData { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, }], project_order: vec![project_id], service_panel_heights: HashMap::new(), @@ -662,6 +1183,394 @@ mod tests { use super::*; use crate::state::{FolderData, SplitDirection}; + fn test_lock_path() -> PathBuf { + std::env::temp_dir().join(format!("okena-lock-test-{}", uuid::Uuid::new_v4())) + } + + #[test] + fn instance_lock_is_exclusive_and_reacquirable() { + let path = test_lock_path(); + let first = acquire_instance_lock_at(path.clone()).expect("first lock"); + let error = match acquire_instance_lock_at(path.clone()) { + Ok(_) => panic!("second lock must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("Another Okena instance")); + + drop(first); + assert!(!path.exists(), "owner removes its lock file"); + let second = acquire_instance_lock_at(path.clone()).expect("lock after release"); + drop(second); + assert!(!path.exists()); + } + + #[test] + fn instance_lock_pid_accepts_legacy_and_owned_formats() { + assert_eq!(instance_lock_pid("1234"), Some(1234)); + assert_eq!(instance_lock_pid("1234:owner-token"), Some(1234)); + assert_eq!(instance_lock_pid("invalid"), None); + } + + #[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 project_layout = LayoutNode::Terminal { + terminal_id: Some("remote:local-daemon:t1".to_string()), + minimized: true, + detached: false, + shell_type: Default::default(), + zoom_level: 1.25, + }; + let layout = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + main_window: WindowState::default(), + extra_windows: vec![extra], + project_layouts: HashMap::from([( + "remote:local-daemon:p1".to_string(), + project_layout.clone(), + )]), + service_panel_heights: HashMap::from([("remote:local-daemon:p1".to_string(), 240.0)]), + hook_panel_heights: HashMap::from([("remote:local-daemon:p1".to_string(), 180.0)]), + }; + 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") + ); + assert_eq!( + parsed.project_layouts.get("remote:local-daemon:p1"), + Some(&project_layout) + ); + assert_eq!( + parsed.service_panel_heights.get("remote:local-daemon:p1"), + Some(&240.0) + ); + assert_eq!( + parsed.hook_panel_heights.get("remote:local-daemon:p1"), + Some(&180.0) + ); + + // 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], + ..Default::default() + }; + + 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![], + ..Default::default() + }; + 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(), + project_layouts: HashMap::from([( + "p1".to_string(), + LayoutNode::Terminal { + terminal_id: Some("t1".to_string()), + minimized: true, + detached: false, + shell_type: Default::default(), + zoom_level: 1.5, + }, + )]), + service_panel_heights: HashMap::from([("p1".to_string(), 200.0)]), + hook_panel_heights: HashMap::from([("p1".to_string(), 160.0)]), + }; + + 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), + ); + let restored = layout + .project_layouts + .get("remote:local-daemon:p1") + .expect("prefixed project layout"); + assert_eq!( + restored.collect_terminal_ids(), + vec!["remote:local-daemon:t1"] + ); + assert_eq!( + layout.service_panel_heights.get("remote:local-daemon:p1"), + Some(&200.0) + ); + assert_eq!( + layout.hook_panel_heights.get("remote:local-daemon:p1"), + Some(&160.0) + ); + } + + fn test_window_layout_path() -> PathBuf { + std::env::temp_dir().join(format!( + "okena-window-layout-test-{}.json", + uuid::Uuid::new_v4() + )) + } + + #[test] + fn valid_window_layout_with_no_extras_is_authoritative() { + let path = test_window_layout_path(); + let layout = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + ..Default::default() + }; + std::fs::write(&path, serde_json::to_string(&layout).unwrap()).unwrap(); + + let loaded = load_window_layout_at(&path, || { + panic!("legacy workspace must not be loaded when the client file exists") + }) + .unwrap(); + + let _ = std::fs::remove_file(path); + assert!(loaded.extra_windows.is_empty()); + } + + #[test] + fn valid_empty_window_visibility_is_authoritative() { + let path = test_window_layout_path(); + let extra = WindowState::default(); + let extra_id = extra.id; + let layout = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + extra_windows: vec![extra], + ..Default::default() + }; + std::fs::write(&path, serde_json::to_string(&layout).unwrap()).unwrap(); + + let loaded = load_window_layout_at(&path, || { + panic!("legacy workspace must not fill an explicit empty visibility set") + }) + .unwrap(); + + let _ = std::fs::remove_file(path); + assert_eq!(loaded.extra_windows.len(), 1); + assert_eq!(loaded.extra_windows[0].id, extra_id); + assert!(loaded.extra_windows[0].hidden_project_ids.is_empty()); + } + + #[test] + fn v2_migration_restores_project_presentation_without_overwriting_client_state() { + let path = test_window_layout_path(); + let mut client_extra = WindowState::default(); + client_extra + .hidden_project_ids + .insert("remote:local-daemon:client-hidden".to_string()); + let extra_id = client_extra.id; + let client_project_layout = LayoutNode::Tabs { + children: vec![LayoutNode::new_terminal()], + active_tab: 0, + }; + let client = ClientWindowLayout { + version: 2, + extra_windows: vec![client_extra], + project_layouts: HashMap::from([( + "remote:local-daemon:p1".to_string(), + client_project_layout.clone(), + )]), + ..Default::default() + }; + std::fs::write(&path, serde_json::to_string(&client).unwrap()).unwrap(); + + let mut legacy_extra = WindowState { + id: extra_id, + ..Default::default() + }; + legacy_extra + .hidden_project_ids + .insert("remote:local-daemon:workspace-hidden".to_string()); + let legacy_project_layout = LayoutNode::new_terminal(); + let legacy = ClientWindowLayout { + version: WINDOW_LAYOUT_VERSION, + extra_windows: vec![legacy_extra], + project_layouts: HashMap::from([ + ( + "remote:local-daemon:p1".to_string(), + legacy_project_layout.clone(), + ), + ( + "remote:local-daemon:p2".to_string(), + legacy_project_layout.clone(), + ), + ]), + service_panel_heights: HashMap::from([("remote:local-daemon:p1".to_string(), 240.0)]), + hook_panel_heights: HashMap::from([("remote:local-daemon:p1".to_string(), 180.0)]), + ..Default::default() + }; + + let loaded = load_window_layout_at(&path, || Some(legacy)).unwrap(); + + let _ = std::fs::remove_file(path); + assert_eq!(loaded.extra_windows.len(), 1); + assert!( + loaded.extra_windows[0] + .hidden_project_ids + .contains("remote:local-daemon:client-hidden") + ); + assert!( + !loaded.extra_windows[0] + .hidden_project_ids + .contains("remote:local-daemon:workspace-hidden") + ); + assert_eq!( + loaded.project_layouts.get("remote:local-daemon:p1"), + Some(&client_project_layout) + ); + assert_eq!( + loaded.project_layouts.get("remote:local-daemon:p2"), + Some(&legacy_project_layout) + ); + assert_eq!( + loaded.service_panel_heights.get("remote:local-daemon:p1"), + Some(&240.0) + ); + assert_eq!( + loaded.hook_panel_heights.get("remote:local-daemon:p1"), + Some(&180.0) + ); + } + + #[test] + fn missing_window_layout_uses_workspace_migration() { + let path = test_window_layout_path(); + let mut legacy = ClientWindowLayout::default(); + legacy + .main_window + .hidden_project_ids + .insert("remote:local-daemon:old".to_string()); + + let loaded = load_window_layout_at(&path, || Some(legacy)).unwrap(); + + assert!( + loaded + .main_window + .hidden_project_ids + .contains("remote:local-daemon:old") + ); + } + + #[test] + fn corrupt_window_layout_does_not_restore_stale_workspace_state() { + let path = test_window_layout_path(); + std::fs::write(&path, "{").unwrap(); + + let loaded = load_window_layout_at(&path, || { + panic!("legacy workspace must not replace a corrupt client file") + }); + + let _ = std::fs::remove_file(path); + assert!(loaded.is_none()); + } + fn make_project(id: &str) -> ProjectData { ProjectData { id: id.to_string(), @@ -681,10 +1590,16 @@ mod tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } - fn make_workspace(projects: Vec<ProjectData>, order: Vec<&str>, folders: Vec<FolderData>) -> WorkspaceData { + fn make_workspace( + projects: Vec<ProjectData>, + order: Vec<&str>, + folders: Vec<FolderData>, + ) -> WorkspaceData { WorkspaceData { version: WORKSPACE_VERSION, projects, @@ -734,7 +1649,11 @@ mod tests { vec![], ); validate_workspace_data(&mut data, false, SessionBackend::None); - assert!(!data.project_order.contains(&"nonexistent_folder".to_string())); + assert!( + !data + .project_order + .contains(&"nonexistent_folder".to_string()) + ); assert!(data.project_order.contains(&"p1".to_string())); } @@ -748,13 +1667,20 @@ mod tests { shell_type: okena_terminal::shell_config::ShellType::Default, zoom_level: 1.0, }); - project.service_terminals.insert("web".to_string(), "svc-term-1".to_string()); + project + .service_terminals + .insert("web".to_string(), "svc-term-1".to_string()); let mut data = make_workspace(vec![project], vec!["p1"], vec![]); validate_workspace_data(&mut data, true, SessionBackend::None); let layout = data.projects[0].layout.as_ref().unwrap(); match layout { - LayoutNode::Terminal { terminal_id, minimized, detached, .. } => { + LayoutNode::Terminal { + terminal_id, + minimized, + detached, + .. + } => { assert!(terminal_id.is_none()); assert!(!minimized); assert!(!detached); @@ -789,13 +1715,16 @@ mod tests { }, ], }); - project.hook_terminals.insert("hook-term".to_string(), HookTerminalEntry { - label: "on_project_open".to_string(), - status: HookTerminalStatus::Running, - hook_type: "on_project_open".to_string(), - command: "echo hello".to_string(), - cwd: "/tmp".to_string(), - }); + project.hook_terminals.insert( + "hook-term".to_string(), + HookTerminalEntry { + label: "on_project_open".to_string(), + status: HookTerminalStatus::Running, + hook_type: "on_project_open".to_string(), + command: "echo hello".to_string(), + cwd: "/tmp".to_string(), + }, + ); let mut data = make_workspace(vec![project], vec!["p1"], vec![]); validate_workspace_data(&mut data, true, SessionBackend::None); @@ -805,11 +1734,18 @@ mod tests { LayoutNode::Split { children, .. } => { // Regular terminal should have its ID cleared if let LayoutNode::Terminal { terminal_id, .. } = &children[0] { - assert!(terminal_id.is_none(), "regular terminal ID should be cleared"); + assert!( + terminal_id.is_none(), + "regular terminal ID should be cleared" + ); } // Hook terminal should keep its ID if let LayoutNode::Terminal { terminal_id, .. } = &children[1] { - assert_eq!(terminal_id.as_deref(), Some("hook-term"), "hook terminal ID should be preserved"); + assert_eq!( + terminal_id.as_deref(), + Some("hook-term"), + "hook terminal ID should be preserved" + ); } } _ => panic!("Expected split"), @@ -833,7 +1769,10 @@ mod tests { let mut data = make_workspace(vec![project], vec!["p1"], vec![]); validate_workspace_data(&mut data, false, SessionBackend::None); - assert!(matches!(data.projects[0].layout, Some(LayoutNode::Terminal { .. }))); + assert!(matches!( + data.projects[0].layout, + Some(LayoutNode::Terminal { .. }) + )); } #[test] @@ -984,8 +1923,14 @@ mod tests { let data: WorkspaceData = serde_json::from_str(&migrated_json).unwrap(); let migrated = migrate_workspace(data); assert_eq!(migrated.version, WORKSPACE_VERSION); - assert_eq!(migrated.main_window.project_widths.get("p1").copied(), Some(60.0)); - assert_eq!(migrated.main_window.project_widths.get("p2").copied(), Some(40.0)); + assert_eq!( + migrated.main_window.project_widths.get("p1").copied(), + Some(60.0) + ); + assert_eq!( + migrated.main_window.project_widths.get("p2").copied(), + Some(40.0) + ); } #[test] @@ -1022,8 +1967,14 @@ mod tests { assert_eq!(migrated.version, WORKSPACE_VERSION); assert!(migrated.main_window.hidden_project_ids.contains("p1")); - assert_eq!(migrated.main_window.folder_collapsed.get("f1").copied(), Some(true)); - assert_eq!(migrated.main_window.project_widths.get("p1").copied(), Some(60.0)); + assert_eq!( + migrated.main_window.folder_collapsed.get("f1").copied(), + Some(true) + ); + assert_eq!( + migrated.main_window.project_widths.get("p1").copied(), + Some(60.0) + ); } #[test] @@ -1080,7 +2031,8 @@ mod tests { }"#; let migrated_json = migrate_legacy_json(legacy_json).expect("legacy migration succeeds"); - let raw: WorkspaceData = serde_json::from_str(&migrated_json).expect("typed parse succeeds"); + let raw: WorkspaceData = + serde_json::from_str(&migrated_json).expect("typed parse succeeds"); let mut data = migrate_workspace(raw); validate_workspace_data(&mut data, false, SessionBackend::None); @@ -1088,19 +2040,36 @@ mod tests { assert_eq!(data.version, WORKSPACE_VERSION); // Both legacy hide flags fold into main_window.hidden_project_ids. - assert!(data.main_window.hidden_project_ids.contains("hidden_show_in_overview")); - assert!(data.main_window.hidden_project_ids.contains("hidden_is_visible")); + assert!( + data.main_window + .hidden_project_ids + .contains("hidden_show_in_overview") + ); + assert!( + data.main_window + .hidden_project_ids + .contains("hidden_is_visible") + ); assert!(!data.main_window.hidden_project_ids.contains("visible")); // FolderData.collapsed folds into main_window.folder_collapsed; only // the explicitly-collapsed folder appears (absence == expanded). - assert_eq!(data.main_window.folder_collapsed.get("folder1").copied(), Some(true)); + assert_eq!( + data.main_window.folder_collapsed.get("folder1").copied(), + Some(true) + ); assert!(!data.main_window.folder_collapsed.contains_key("folder2")); // Top-level project_widths folds into main_window.project_widths. - assert_eq!(data.main_window.project_widths.get("visible").copied(), Some(60.0)); assert_eq!( - data.main_window.project_widths.get("hidden_show_in_overview").copied(), + data.main_window.project_widths.get("visible").copied(), + Some(60.0) + ); + assert_eq!( + data.main_window + .project_widths + .get("hidden_show_in_overview") + .copied(), Some(40.0), ); @@ -1113,17 +2082,29 @@ mod tests { let saved = serde_json::to_string(&data).unwrap(); let value: serde_json::Value = serde_json::from_str(&saved).unwrap(); let obj = value.as_object().unwrap(); - assert!(!obj.contains_key("project_widths"), "no top-level project_widths after save"); + assert!( + !obj.contains_key("project_widths"), + "no top-level project_widths after save" + ); let projects = obj.get("projects").and_then(|v| v.as_array()).unwrap(); for p in projects { let po = p.as_object().unwrap(); - assert!(!po.contains_key("show_in_overview"), "no per-project show_in_overview after save"); - assert!(!po.contains_key("is_visible"), "no per-project is_visible after save"); + assert!( + !po.contains_key("show_in_overview"), + "no per-project show_in_overview after save" + ); + assert!( + !po.contains_key("is_visible"), + "no per-project is_visible after save" + ); } let folders = obj.get("folders").and_then(|v| v.as_array()).unwrap(); for f in folders { let fo = f.as_object().unwrap(); - assert!(!fo.contains_key("collapsed"), "no per-folder collapsed after save"); + assert!( + !fo.contains_key("collapsed"), + "no per-folder collapsed after save" + ); } } @@ -1178,7 +2159,10 @@ mod tests { first.main_window.folder_collapsed, second.main_window.folder_collapsed ); - assert_eq!(first.main_window.folder_filter, second.main_window.folder_filter); + assert_eq!( + first.main_window.folder_filter, + second.main_window.folder_filter + ); // The underlying workspace shape (projects, folders, order) is preserved. let ids1: std::collections::HashSet<&str> = @@ -1233,15 +2217,25 @@ mod tests { folder_color: FolderColor::default(), }], ); - data.main_window.folder_collapsed.insert("f1".to_string(), true); - data.main_window.project_widths.insert("p1".to_string(), 60.0); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); + data.main_window + .project_widths + .insert("p1".to_string(), 60.0); let json = serde_json::to_string(&data).unwrap(); let deserialized: WorkspaceData = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.folders.len(), 1); assert_eq!(deserialized.folders[0].name, "My Folder"); - assert_eq!(deserialized.main_window.folder_collapsed.get("f1"), Some(&true)); - assert_eq!(deserialized.main_window.project_widths.get("p1"), Some(&60.0)); + assert_eq!( + deserialized.main_window.folder_collapsed.get("f1"), + Some(&true) + ); + assert_eq!( + deserialized.main_window.project_widths.get("p1"), + Some(&60.0) + ); } #[test] @@ -1255,9 +2249,15 @@ mod tests { zoom_level: 1.0, }); // t1 is in layout, t2 and t3 are orphaned - project.terminal_names.insert("t1".to_string(), "Term 1".to_string()); - project.terminal_names.insert("t2".to_string(), "Term 2".to_string()); - project.terminal_names.insert("t3".to_string(), "Term 3".to_string()); + project + .terminal_names + .insert("t1".to_string(), "Term 1".to_string()); + project + .terminal_names + .insert("t2".to_string(), "Term 2".to_string()); + project + .terminal_names + .insert("t3".to_string(), "Term 3".to_string()); project.hidden_terminals.insert("t2".to_string(), true); let mut data = make_workspace(vec![project], vec!["p1"], vec![]); @@ -1273,8 +2273,12 @@ mod tests { fn validate_cleans_all_metadata_when_no_layout() { let mut project = make_project("p1"); project.layout = None; - project.terminal_names.insert("t1".to_string(), "Term 1".to_string()); - project.terminal_names.insert("t2".to_string(), "Term 2".to_string()); + project + .terminal_names + .insert("t1".to_string(), "Term 1".to_string()); + project + .terminal_names + .insert("t2".to_string(), "Term 2".to_string()); let mut data = make_workspace(vec![project], vec!["p1"], vec![]); validate_workspace_data(&mut data, false, SessionBackend::None); @@ -1320,7 +2324,7 @@ mod tests { let mut p = make_project(id); p.worktree_info = Some(crate::state::WorktreeMetadata { parent_project_id: parent_id.to_string(), - color_override: None, + color_override: None, main_repo_path: "/tmp/repo".to_string(), worktree_path: format!("/tmp/worktrees/{}", id), branch_name: String::new(), @@ -1336,7 +2340,7 @@ mod tests { wt_project.path = "/nonexistent/path/that/does/not/exist".to_string(); wt_project.worktree_info = Some(WorktreeMetadata { parent_project_id: "p1".to_string(), - color_override: None, + color_override: None, main_repo_path: "/tmp/test".to_string(), worktree_path: String::new(), branch_name: "some-branch".to_string(), @@ -1362,7 +2366,7 @@ mod tests { wt_project.path = "/nonexistent/path".to_string(); wt_project.worktree_info = Some(WorktreeMetadata { parent_project_id: "p1".to_string(), - color_override: None, + color_override: None, main_repo_path: "/tmp/test".to_string(), worktree_path: String::new(), branch_name: "some-branch".to_string(), @@ -1392,7 +2396,7 @@ mod tests { wt_project.path = tmp.to_string_lossy().to_string(); wt_project.worktree_info = Some(WorktreeMetadata { parent_project_id: "p1".to_string(), - color_override: None, + color_override: None, main_repo_path: "/tmp/test".to_string(), worktree_path: String::new(), branch_name: "some-branch".to_string(), @@ -1411,13 +2415,294 @@ mod tests { assert!(data.project_order.contains(&"wt1".to_string())); } + #[test] + fn sync_worktrees_preserves_monorepo_worktree_when_project_subdir_is_missing() { + let checkout = + std::env::temp_dir().join(format!("okena-monorepo-sync-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&checkout).expect("create checkout root"); + let mut wt_project = make_project("wt1"); + wt_project.path = checkout + .join("packages/missing") + .to_string_lossy() + .into_owned(); + wt_project.worktree_info = Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "/tmp/test".to_string(), + worktree_path: checkout.to_string_lossy().into_owned(), + branch_name: "some-branch".to_string(), + }); + let mut data = make_workspace( + vec![make_project("p1"), wt_project], + vec!["p1", "wt1"], + vec![], + ); + + sync_worktrees(&mut data); + + assert!(data.projects.iter().any(|project| project.id == "wt1")); + assert!(data.project_order.contains(&"wt1".to_string())); + std::fs::remove_dir(checkout).expect("remove checkout root"); + } + + #[test] + fn sync_worktrees_seeds_layout_for_mid_create_worktree() { + // Optimistic create registers a worktree with layout:None and the + // is_creating marker before the git checkout; a daemon kill could persist + // that. On reload, if the checkout dir now exists, seed a layout so it + // opens a shell instead of hanging on the "Setting up worktree…" + // placeholder — and clear the now-stale marker. + let mut wt = make_project("wt1"); + wt.path = std::env::temp_dir().to_string_lossy().to_string(); + wt.layout = None; + wt.is_creating = true; + wt.worktree_info = Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "/tmp/test".to_string(), + worktree_path: String::new(), + branch_name: "some-branch".to_string(), + }); + + let mut data = make_workspace(vec![make_project("p1"), wt], vec!["p1", "wt1"], vec![]); + sync_worktrees(&mut data); + + let wt = data + .projects + .iter() + .find(|p| p.id == "wt1") + .expect("worktree kept"); + assert!( + wt.layout.is_some(), + "mid-create worktree with an existing dir gets a seeded layout" + ); + assert!( + !wt.is_creating, + "the mid-create marker is cleared once the layout is seeded" + ); + } + + #[test] + fn sync_worktrees_leaves_deliberate_bookmark_untouched() { + // A worktree the user deliberately emptied (closed its last terminal -> + // layout:None bookmark) has is_creating == false. The self-heal must NOT + // seed a terminal for it, or every restart would silently un-bookmark it + // and resurrect a shell. Only genuinely mid-create worktrees self-heal. + let mut wt = make_project("wt1"); + wt.path = std::env::temp_dir().to_string_lossy().to_string(); + wt.layout = None; + wt.is_creating = false; + wt.worktree_info = Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "/tmp/test".to_string(), + worktree_path: String::new(), + branch_name: "some-branch".to_string(), + }); + + let mut data = make_workspace(vec![make_project("p1"), wt], vec!["p1", "wt1"], vec![]); + sync_worktrees(&mut data); + + let wt = data + .projects + .iter() + .find(|p| p.id == "wt1") + .expect("bookmark kept"); + assert!( + wt.layout.is_none(), + "a deliberate bookmark (is_creating false) keeps layout None" + ); + } + + #[test] + fn sync_worktrees_scrubs_parent_worktree_ids_on_stale_removal() { + let mut parent = make_project("p1"); + parent.worktree_ids = vec!["wt1".to_string()]; + let mut wt = make_project("wt1"); + wt.path = "/nonexistent/okena-stale/xyz".to_string(); // stale → removed + wt.worktree_info = Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "/tmp/test".to_string(), + worktree_path: String::new(), + branch_name: "b".to_string(), + }); + wt.layout = Some(LayoutNode::Terminal { + terminal_id: Some("stale-layout".to_string()), + minimized: false, + detached: false, + shell_type: Default::default(), + zoom_level: 1.0, + }); + wt.service_terminals + .insert("service".to_string(), "stale-service".to_string()); + wt.hook_terminals.insert( + "stale-hook".to_string(), + crate::state::HookTerminalEntry { + label: "hook".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo hook".to_string(), + cwd: "/tmp".to_string(), + }, + ); + + let mut data = make_workspace(vec![parent, wt], vec!["p1", "wt1"], vec![]); + let stale_terminal_ids = sync_worktrees(&mut data); + + let p1 = data.projects.iter().find(|p| p.id == "p1").unwrap(); + assert!( + p1.worktree_ids.is_empty(), + "stale worktree id scrubbed from parent.worktree_ids" + ); + assert!( + !data.projects.iter().any(|p| p.id == "wt1"), + "stale worktree removed" + ); + assert_eq!( + stale_terminal_ids, + vec![ + "stale-hook".to_string(), + "stale-layout".to_string(), + "stale-service".to_string(), + ], + "discarded ownership survives long enough for startup cleanup" + ); + } + + #[cfg(windows)] + #[test] + fn stale_session_descriptor_keeps_wsl_distro_and_resolved_backend() { + let route = teardown_route( + &ShellType::Wsl { + distro: Some("Ubuntu".to_string()), + }, + None, + &ShellType::Default, + SessionBackend::None, + ); + + assert_eq!( + route, + TerminalTeardownRoute::Wsl { + distro: Some("Ubuntu".to_string()), + backend: okena_terminal::session_backend::ResolvedBackend::None, + } + ); + } + + #[cfg(windows)] + #[test] + fn stale_cleanup_routes_only_layout_terminals_through_wsl() { + let mut worktree = make_project("wt1"); + worktree.path = format!("Z:\\missing-okena-worktree-{}", uuid::Uuid::new_v4()); + worktree.worktree_info = Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "C:\\repo".to_string(), + worktree_path: worktree.path.clone(), + branch_name: "feature".to_string(), + }); + worktree.default_shell = Some(ShellType::Wsl { + distro: Some("Ubuntu".to_string()), + }); + worktree.layout = Some(LayoutNode::Terminal { + terminal_id: Some("layout".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }); + worktree + .service_terminals + .insert("web".to_string(), "service".to_string()); + worktree.hook_terminals.insert( + "hook".to_string(), + crate::state::HookTerminalEntry { + label: "hook".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "on_project_open".to_string(), + command: "echo hook".to_string(), + cwd: "C:\\repo".to_string(), + }, + ); + let mut data = make_workspace( + vec![make_project("p1"), worktree], + vec!["p1", "wt1"], + vec![], + ); + + let stale = + sync_worktrees_with_backend_and_shell(&mut data, SessionBackend::None, &ShellType::Cmd); + let route = |id: &str| { + stale + .iter() + .find(|session| session.terminal_id == id) + .map(|session| session.route.clone()) + .expect("stale descriptor") + }; + + assert!(matches!(route("layout"), TerminalTeardownRoute::Wsl { .. })); + assert_eq!(route("service"), TerminalTeardownRoute::Host); + assert_eq!(route("hook"), TerminalTeardownRoute::Host); + } + + #[cfg(windows)] + #[test] + fn stale_default_layout_uses_transient_global_wsl_route() { + let mut worktree = make_project("wt1"); + worktree.path = format!("Z:\\missing-okena-worktree-{}", uuid::Uuid::new_v4()); + worktree.worktree_info = Some(WorktreeMetadata { + parent_project_id: "p1".to_string(), + color_override: None, + main_repo_path: "C:\\repo".to_string(), + worktree_path: worktree.path.clone(), + branch_name: "feature".to_string(), + }); + worktree.layout = Some(LayoutNode::Terminal { + terminal_id: Some("layout".to_string()), + minimized: false, + detached: false, + shell_type: ShellType::Default, + zoom_level: 1.0, + }); + let mut data = make_workspace( + vec![make_project("p1"), worktree], + vec!["p1", "wt1"], + vec![], + ); + + let stale = sync_worktrees_with_backend_and_shell( + &mut data, + SessionBackend::None, + &ShellType::Wsl { + distro: Some("Debian".to_string()), + }, + ); + + assert!(matches!( + stale.as_slice(), + [TerminalSessionTeardown { + terminal_id, + route: TerminalTeardownRoute::Wsl { + distro: Some(distro), + .. + }, + }] if terminal_id == "layout" && distro == "Debian" + )); + } + // === validate_workspace_data worktree migration === #[test] fn validate_populates_worktree_ids_from_worktree_info() { // Simulate old data: worktrees in project_order, parent has empty worktree_ids let mut data = make_workspace( - vec![make_project("parent"), make_worktree_project("wt1", "parent"), make_worktree_project("wt2", "parent")], + vec![ + make_project("parent"), + make_worktree_project("wt1", "parent"), + make_worktree_project("wt2", "parent"), + ], vec!["parent", "wt1", "wt2"], vec![], ); @@ -1425,13 +2710,19 @@ mod tests { // Parent should now have worktree_ids populated let parent = data.projects.iter().find(|p| p.id == "parent").unwrap(); - assert_eq!(parent.worktree_ids, vec!["wt1".to_string(), "wt2".to_string()]); + assert_eq!( + parent.worktree_ids, + vec!["wt1".to_string(), "wt2".to_string()] + ); } #[test] fn validate_removes_worktrees_from_project_order() { let mut data = make_workspace( - vec![make_project("parent"), make_worktree_project("wt1", "parent")], + vec![ + make_project("parent"), + make_worktree_project("wt1", "parent"), + ], vec!["parent", "wt1"], vec![], ); @@ -1445,7 +2736,10 @@ mod tests { #[test] fn validate_removes_worktrees_from_folder_project_ids() { let mut data = make_workspace( - vec![make_project("parent"), make_worktree_project("wt1", "parent")], + vec![ + make_project("parent"), + make_worktree_project("wt1", "parent"), + ], vec!["f1"], vec![FolderData { id: "f1".to_string(), @@ -1466,7 +2760,11 @@ mod tests { let mut parent = make_project("parent"); parent.worktree_ids = vec!["wt2".to_string(), "wt1".to_string()]; // custom order let mut data = make_workspace( - vec![parent, make_worktree_project("wt1", "parent"), make_worktree_project("wt2", "parent")], + vec![ + parent, + make_worktree_project("wt1", "parent"), + make_worktree_project("wt2", "parent"), + ], vec!["parent"], vec![], ); @@ -1474,6 +2772,9 @@ mod tests { let parent = data.projects.iter().find(|p| p.id == "parent").unwrap(); // Should preserve existing order, not overwrite - assert_eq!(parent.worktree_ids, vec!["wt2".to_string(), "wt1".to_string()]); + assert_eq!( + parent.worktree_ids, + vec!["wt2".to_string(), "wt1".to_string()] + ); } } diff --git a/crates/okena-workspace/src/remote_apply.rs b/crates/okena-workspace/src/remote_apply.rs index b617895d1..7844f70ea 100644 --- a/crates/okena-workspace/src/remote_apply.rs +++ b/crates/okena-workspace/src/remote_apply.rs @@ -13,9 +13,12 @@ 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 okena_transport::client::RemoteConnectionConfig; use crate::remote_sync::RemoteSyncState; @@ -59,14 +62,14 @@ pub fn apply_remote_snapshot( window_id: WindowId, ) -> RemoteSyncOutcome { let mut expected_remote_ids: HashSet<String> = HashSet::new(); - let active_conn_ids: HashSet<String> = snapshots.iter() - .map(|s| s.config.id.clone()) - .collect(); + let mut synced_conn_ids: HashSet<String> = HashSet::new(); + let active_conn_ids: HashSet<String> = snapshots.iter().map(|s| s.config.id.clone()).collect(); for snap in snapshots { let conn_id = &snap.config.id; if let Some(ref state) = snap.state { + synced_conn_ids.insert(conn_id.clone()); // Build the server folder lookup let server_folder_map: HashMap<&str, &okena_core::api::ApiFolder> = state.folders.iter().map(|f| (f.id.as_str(), f)).collect(); @@ -80,7 +83,9 @@ pub fn apply_remote_snapshot( if let Some(sf) = server_folder_map.get(order_id.as_str()) { // This is a folder — create a prefixed FolderData let prefixed_folder_id = format!("remote:{}:{}", conn_id, sf.id); - let prefixed_project_ids: Vec<String> = sf.project_ids.iter() + let prefixed_project_ids: Vec<String> = sf + .project_ids + .iter() .map(|pid| format!("remote:{}:{}", conn_id, pid)) .collect(); remote_folders.push(FolderData { @@ -106,11 +111,14 @@ pub fn apply_remote_snapshot( let prefixed_id = format!("remote:{}:{}", conn_id, api_project.id); expected_remote_ids.insert(prefixed_id.clone()); - let layout = api_project.layout.as_ref().map(|l| { - LayoutNode::from_api_prefixed(l, &format!("remote:{}", conn_id)) - }); + let mut layout = api_project + .layout + .as_ref() + .map(|l| LayoutNode::from_api_prefixed(l, &format!("remote:{}", conn_id))); - let terminal_names: HashMap<String, String> = api_project.terminal_names.iter() + let terminal_names: HashMap<String, String> = api_project + .terminal_names + .iter() .map(|(k, v)| (format!("remote:{}:{}", conn_id, k), v.clone())) .collect(); @@ -118,15 +126,34 @@ pub fn apply_remote_snapshot( let conn_id_owned = conn_id.clone(); // Build remote services with prefixed terminal IDs - let remote_services: Vec<okena_core::api::ApiServiceInfo> = api_project.services.iter().map(|s| { - let mut svc = s.clone(); - svc.terminal_id = s.terminal_id.as_ref() - .map(|tid| format!("remote:{}:{}", conn_id, tid)); - svc - }).collect(); + let remote_services: Vec<okena_core::api::ApiServiceInfo> = api_project + .services + .iter() + .map(|s| { + let mut svc = s.clone(); + svc.terminal_id = s + .terminal_id + .as_ref() + .map(|tid| format!("remote:{}:{}", conn_id, tid)); + svc + }) + .collect(); 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<String, HookTerminalEntry> = 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(); @@ -140,31 +167,69 @@ pub fn apply_remote_snapshot( }; existing.terminal_names = terminal_names; existing.folder_color = project_color; - existing.worktree_info = api_project.worktree_info.as_ref().map(|wt| { - WorktreeMetadata { - parent_project_id: format!("remote:{}:{}", conn_id, wt.parent_project_id), - color_override: wt.color_override, - main_repo_path: String::new(), - worktree_path: String::new(), - branch_name: String::new(), - } - }); - existing.worktree_ids = api_project.worktree_ids.iter() + existing.worktree_info = + api_project + .worktree_info + .as_ref() + .map(|wt| WorktreeMetadata { + parent_project_id: format!( + "remote:{}:{}", + conn_id, wt.parent_project_id + ), + color_override: wt.color_override, + main_repo_path: String::new(), + worktree_path: String::new(), + branch_name: String::new(), + }); + 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; + // Mid-create marker: drives the "Setting up worktree…" + // placeholder. Mirrored (not derived from layout) so a + // deliberately-emptied worktree bookmark (layout None) isn't + // mistaken for a checkout in flight. + existing.is_creating = api_project.is_creating; + // Mirrored close-in-progress marker: drives the dimmed + // "Closing…" row. The Workspace wrapper reconciles the local + // optimistic closing flag against this after the snapshot + // applies (see `Workspace::apply_remote_snapshot`). + existing.is_closing = api_project.is_closing; + // 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 { - let worktree_info = api_project.worktree_info.as_ref().map(|wt| { - WorktreeMetadata { - parent_project_id: format!("remote:{}:{}", conn_id, wt.parent_project_id), - color_override: wt.color_override, - main_repo_path: String::new(), - worktree_path: String::new(), - branch_name: String::new(), - } - }); - let worktree_ids: Vec<String> = api_project.worktree_ids.iter() + if let Some(client_layout) = remote_sync.take_project_layout(&prefixed_id) { + layout = layout + .as_ref() + .map(|server| LayoutNode::merge_visual_state(server, &client_layout)); + } + let worktree_info = + api_project + .worktree_info + .as_ref() + .map(|wt| WorktreeMetadata { + parent_project_id: format!( + "remote:{}:{}", + conn_id, wt.parent_project_id + ), + color_override: wt.color_override, + main_repo_path: String::new(), + worktree_path: String::new(), + branch_name: String::new(), + }); + let worktree_ids: Vec<String> = api_project + .worktree_ids + .iter() .map(|id| format!("remote:{}:{}", conn_id, id)) .collect(); apply_initial_remote_project_visibility( @@ -174,7 +239,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 +250,16 @@ 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, + is_creating: api_project.is_creating, + is_closing: api_project.is_closing, }); } // Update the transient remote snapshot regardless of create/update path. @@ -208,8 +274,12 @@ pub fn apply_remote_snapshot( // Scrub per-window state for remote folders that disappeared this sync. let next_remote_folder_ids: HashSet<String> = remote_folders.iter().map(|f| f.id.clone()).collect(); - let removed_folder_ids: Vec<String> = data.folders.iter() - .filter(|f| f.id.starts_with(&remote_prefix) && !next_remote_folder_ids.contains(&f.id)) + let removed_folder_ids: Vec<String> = data + .folders + .iter() + .filter(|f| { + f.id.starts_with(&remote_prefix) && !next_remote_folder_ids.contains(&f.id) + }) .map(|f| f.id.clone()) .collect(); for folder_id in removed_folder_ids { @@ -218,7 +288,8 @@ pub fn apply_remote_snapshot( // Remove old remote folders for this connection data.folders.retain(|f| !f.id.starts_with(&remote_prefix)); // Remove old remote entries from project_order for this connection - data.project_order.retain(|id| !id.starts_with(&remote_prefix)); + data.project_order + .retain(|id| !id.starts_with(&remote_prefix)); // Add new remote folders for rf in remote_folders { @@ -228,21 +299,18 @@ 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<String> = data.projects.iter() - .filter(|p| p.id.starts_with(&prefix)) - .map(|p| p.id.clone()) - .collect(); - let removed_folder_ids: Vec<String> = 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); + for project in data.projects.iter().filter(|p| p.id.starts_with(&prefix)) { + if let Some(layout) = &project.layout { + remote_sync.preserve_project_layout(project.id.clone(), layout.clone()); + } } data.projects.retain(|p| !p.id.starts_with(&prefix)); data.folders.retain(|f| !f.id.starts_with(&prefix)); @@ -250,12 +318,22 @@ pub fn apply_remote_snapshot( } } + for project_id in + remote_sync.prune_project_state(&active_conn_ids, &synced_conn_ids, &expected_remote_ids) + { + data.delete_project_scrub_all_windows(&project_id); + } + // Remove stale remote projects/folders from connections that no longer exist - let removed_project_ids: Vec<String> = data.projects.iter() + let removed_project_ids: Vec<String> = data + .projects + .iter() .filter(|p| p.is_remote && !expected_remote_ids.contains(&p.id)) .map(|p| p.id.clone()) .collect(); - let removed_folder_ids: Vec<String> = data.folders.iter() + let removed_folder_ids: Vec<String> = data + .folders + .iter() .filter(|f| { if f.id.starts_with("remote:") { // Remote folder IDs are "remote:{conn_id}:{folder_id}" @@ -271,6 +349,7 @@ pub fn apply_remote_snapshot( .collect(); for project_id in removed_project_ids { data.delete_project_scrub_all_windows(&project_id); + remote_sync.remove_project(&project_id); } for folder_id in removed_folder_ids { data.delete_folder_scrub_all_windows(&folder_id); @@ -293,24 +372,37 @@ pub fn apply_remote_snapshot( true } }); - let valid_ids: HashSet<&str> = data.projects.iter().map(|p| p.id.as_str()) + let valid_ids: HashSet<&str> = data + .projects + .iter() + .map(|p| p.id.as_str()) .chain(data.folders.iter().map(|f| f.id.as_str())) .collect(); - data.project_order.retain(|id| valid_ids.contains(id.as_str())); + data.project_order + .retain(|id| valid_ids.contains(id.as_str())); // Compute focus targets for projects that had a window-scoped pending // CreateTerminal and whose layout grew a new terminal during this sync. let mut outcome = RemoteSyncOutcome::default(); for pending_focus in remote_sync.drain_pending_focus(window_id) { let pid = pending_focus.project_id; - let layout = match data.projects.iter().find(|p| p.id == pid).and_then(|p| p.layout.as_ref()) { + let layout = match data + .projects + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.layout.as_ref()) + { Some(layout) => layout, None => continue, }; let new_ids = layout.collect_terminal_ids(); // Find the first terminal ID that wasn't present when the // CreateTerminal action originated in this window. - let old_set: HashSet<&str> = pending_focus.old_terminal_ids.iter().map(|s| s.as_str()).collect(); + let old_set: HashSet<&str> = pending_focus + .old_terminal_ids + .iter() + .map(|s| s.as_str()) + .collect(); if let Some(new_tid) = new_ids.iter().find(|id| !old_set.contains(id.as_str())) && let Some(path) = layout.find_terminal_path(new_tid) { @@ -326,9 +418,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 +435,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 +473,7 @@ mod tests { token_obtained_at: None, tls: false, pinned_cert_sha256: None, + local_endpoint: None, } } @@ -392,6 +490,13 @@ 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(), + is_creating: false, + is_closing: false, } } @@ -400,12 +505,17 @@ mod tests { terminal_id: Some(id.to_string()), minimized: false, detached: false, + shell_type: Default::default(), cols: None, rows: None, } } - fn state_with(projects: Vec<ApiProject>, order: Vec<String>, folders: Vec<ApiFolder>) -> StateResponse { + fn state_with( + projects: Vec<ApiProject>, + order: Vec<String>, + folders: Vec<ApiFolder>, + ) -> StateResponse { StateResponse { state_version: 1, projects, @@ -414,6 +524,7 @@ mod tests { project_order: order, folders, windows: vec![], + hooks: Vec::new(), } } @@ -442,11 +553,60 @@ mod tests { assert_eq!(data.projects[0].connection_id.as_deref(), Some("c1")); // Terminal IDs in the layout are prefixed. assert_eq!( - data.projects[0].layout.as_ref().unwrap().collect_terminal_ids(), + data.projects[0] + .layout + .as_ref() + .unwrap() + .collect_terminal_ids(), vec!["remote:c1:ta"] ); // Transient snapshot host recorded. - assert_eq!(rs.snapshot("remote:c1:a").unwrap().host.as_deref(), Some("c1.example.com")); + 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] @@ -476,7 +636,7 @@ mod tests { } #[test] - fn merge_visual_state_preserves_local_state_on_resync() { + fn merge_visual_state_preserves_local_presentation_on_resync() { let mut data = empty_data(); let mut rs = RemoteSyncState::new(); @@ -487,32 +647,179 @@ mod tests { }; let first = RemoteSnapshot { config: config("c1"), - state: Some(state_with(vec![api_project("a", Some(layout.clone()))], vec!["a".into()], vec![])), + state: Some(state_with( + vec![api_project("a", Some(layout.clone()))], + vec!["a".into()], + vec![], + )), }; apply_remote_snapshot(&mut data, &mut rs, &[first], WindowId::Main); - // Locally the user switched to tab 1 — mutate the materialized layout. - if let Some(LayoutNode::Tabs { active_tab, .. }) = data.projects[0].layout.as_mut() { + // Locally the user switched tabs and zoomed the first terminal. + if let Some(LayoutNode::Tabs { + active_tab, + children, + }) = data.projects[0].layout.as_mut() + { *active_tab = 1; + let LayoutNode::Terminal { zoom_level, .. } = &mut children[0] else { + panic!("expected terminal"); + }; + *zoom_level = 1.5; } else { panic!("expected tabs layout"); } - // Re-sync with the same server layout (active_tab 0). Local active_tab must win. + // Re-sync with the daemon defaults. Client-owned presentation must win. let second = RemoteSnapshot { config: config("c1"), - state: Some(state_with(vec![api_project("a", Some(layout))], vec!["a".into()], vec![])), + state: Some(state_with( + vec![api_project("a", Some(layout))], + vec!["a".into()], + vec![], + )), }; apply_remote_snapshot(&mut data, &mut rs, &[second], WindowId::Main); match data.projects[0].layout.as_ref().unwrap() { - LayoutNode::Tabs { active_tab, .. } => assert_eq!(*active_tab, 1, "local active_tab preserved"), + LayoutNode::Tabs { + active_tab, + children, + } => { + assert_eq!(*active_tab, 1, "local active_tab preserved"); + let LayoutNode::Terminal { zoom_level, .. } = &children[0] else { + panic!("expected terminal"); + }; + assert_eq!(*zoom_level, 1.5, "local zoom preserved"); + } _ => panic!("expected tabs layout"), } // Still only one materialized project (update, not duplicate). assert_eq!(data.projects.len(), 1); } + #[test] + fn reordered_tabs_preserve_selected_terminal_and_terminal_presentation() { + let mut data = empty_data(); + let mut rs = RemoteSyncState::new(); + let initial_layout = ApiLayoutNode::Tabs { + active_tab: 0, + children: vec![terminal("t1"), terminal("t2")], + }; + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(initial_layout))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); + + let Some(LayoutNode::Tabs { + children, + active_tab, + }) = data.projects[0].layout.as_mut() + else { + panic!("expected tabs"); + }; + *active_tab = 0; + let LayoutNode::Terminal { zoom_level, .. } = &mut children[0] else { + panic!("expected terminal"); + }; + *zoom_level = 1.5; + let LayoutNode::Terminal { minimized, .. } = &mut children[1] else { + panic!("expected terminal"); + }; + *minimized = true; + + let reordered = ApiLayoutNode::Tabs { + active_tab: 1, + children: vec![terminal("t2"), terminal("t1")], + }; + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(reordered))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); + + let Some(LayoutNode::Tabs { + children, + active_tab, + }) = data.projects[0].layout.as_ref() + else { + panic!("expected tabs"); + }; + assert_eq!(*active_tab, 1); + assert!(matches!( + &children[0], + LayoutNode::Terminal { + terminal_id: Some(id), + minimized: true, + .. + } if id == "remote:c1:t2" + )); + assert!(matches!( + &children[1], + LayoutNode::Terminal { + terminal_id: Some(id), + zoom_level, + .. + } if id == "remote:c1:t1" && (*zoom_level - 1.5).abs() < f32::EPSILON + )); + } + + #[test] + fn restores_client_layout_on_first_snapshot() { + let mut data = empty_data(); + let mut rs = RemoteSyncState::new(); + let server_layout = ApiLayoutNode::Tabs { + active_tab: 0, + children: vec![terminal("t1"), terminal("t2")], + }; + rs.seed_project_layouts(HashMap::from([( + "remote:c1:a".to_string(), + LayoutNode::Tabs { + active_tab: 1, + children: vec![ + LayoutNode::from_api_prefixed(&terminal("t1"), "remote:c1"), + LayoutNode::from_api_prefixed(&terminal("t2"), "remote:c1"), + ], + }, + )])); + + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(server_layout))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); + + assert!(matches!( + data.projects[0].layout, + Some(LayoutNode::Tabs { active_tab: 1, .. }) + )); + } + #[test] fn prunes_stale_remote_projects_when_connection_gone() { let mut data = empty_data(); @@ -521,11 +828,19 @@ mod tests { // Sync two connections. let c1 = RemoteSnapshot { config: config("c1"), - state: Some(state_with(vec![api_project("a", None)], vec!["a".into()], vec![])), + state: Some(state_with( + vec![api_project("a", None)], + vec!["a".into()], + vec![], + )), }; let c2 = RemoteSnapshot { config: config("c2"), - state: Some(state_with(vec![api_project("x", None)], vec!["x".into()], vec![])), + state: Some(state_with( + vec![api_project("x", None)], + vec!["x".into()], + vec![], + )), }; apply_remote_snapshot(&mut data, &mut rs, &[c1, c2], WindowId::Main); assert_eq!(data.projects.len(), 2); @@ -533,7 +848,11 @@ mod tests { // Re-sync with only c1 present — c2's projects must be pruned. let c1_only = RemoteSnapshot { config: config("c1"), - state: Some(state_with(vec![api_project("a", None)], vec!["a".into()], vec![])), + state: Some(state_with( + vec![api_project("a", None)], + vec!["a".into()], + vec![], + )), }; apply_remote_snapshot(&mut data, &mut rs, &[c1_only], WindowId::Main); @@ -547,34 +866,184 @@ mod tests { let mut data = empty_data(); 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); + 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); // Same connection now reports no state (disconnected). - apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { - config: config("c1"), - state: None, - }], WindowId::Main); + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: None, + }], + WindowId::Main, + ); assert!(data.projects.is_empty()); 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(); + let layout = ApiLayoutNode::Tabs { + active_tab: 0, + children: vec![terminal("t1"), terminal("t2")], + }; + + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(layout.clone()))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); + if let Some(LayoutNode::Tabs { active_tab, .. }) = data.projects[0].layout.as_mut() { + *active_tab = 1; + } + 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!(rs.preserved_project_layouts().contains_key("remote:c1:a")); + 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", Some(layout))], + 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") + ); + assert!(matches!( + data.projects[0].layout.as_ref(), + Some(LayoutNode::Tabs { active_tab: 1, .. }) + )); + assert!(rs.preserved_project_layouts().is_empty()); + } + + #[test] + fn permanent_disconnect_prunes_presentation_and_panel_state() { + let mut data = empty_data(); + let mut rs = RemoteSyncState::new(); + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(terminal("t1")))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); + data.service_panel_heights + .insert("remote:c1:a".to_string(), 180.0); + data.hook_panel_heights + .insert("remote:c1:a".to_string(), 220.0); + + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: None, + }], + WindowId::Main, + ); + apply_remote_snapshot(&mut data, &mut rs, &[], WindowId::Main); + + assert!(rs.preserved_project_layouts().is_empty()); + assert!(!data.service_panel_heights.contains_key("remote:c1:a")); + assert!(!data.hook_panel_heights.contains_key("remote:c1:a")); + } + #[test] fn pending_focus_detects_new_terminal() { let mut data = empty_data(); let mut rs = RemoteSyncState::new(); // Initial sync: project with one terminal. - apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { - config: config("c1"), - state: Some(state_with(vec![api_project("a", Some(terminal("t1")))], vec!["a".into()], vec![])), - }], WindowId::Main); + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(terminal("t1")))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); // Queue pending focus for the project (as a CreateTerminal dispatch would). - rs.queue_focus(WindowId::Main, "remote:c1:a", vec!["remote:c1:t1".to_string()]); + rs.queue_focus( + WindowId::Main, + "remote:c1:a", + vec!["remote:c1:t1".to_string()], + ); // Next sync grows the layout with a second terminal. let grown = ApiLayoutNode::Split { @@ -582,10 +1051,19 @@ mod tests { sizes: vec![50.0, 50.0], children: vec![terminal("t1"), terminal("t2")], }; - let outcome = apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { - config: config("c1"), - state: Some(state_with(vec![api_project("a", Some(grown))], vec!["a".into()], vec![])), - }], WindowId::Main); + let outcome = apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(grown))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); assert_eq!(outcome.focus_targets.len(), 1); let target = &outcome.focus_targets[0]; @@ -601,17 +1079,39 @@ mod tests { let mut data = empty_data(); let mut rs = RemoteSyncState::new(); - apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { - config: config("c1"), - state: Some(state_with(vec![api_project("a", Some(terminal("t1")))], vec!["a".into()], vec![])), - }], WindowId::Main); - rs.queue_focus(WindowId::Main, "remote:c1:a", vec!["remote:c1:t1".to_string()]); + apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(terminal("t1")))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); + rs.queue_focus( + WindowId::Main, + "remote:c1:a", + vec!["remote:c1:t1".to_string()], + ); // Re-sync with the identical layout — nothing new appeared. - let outcome = apply_remote_snapshot(&mut data, &mut rs, &[RemoteSnapshot { - config: config("c1"), - state: Some(state_with(vec![api_project("a", Some(terminal("t1")))], vec!["a".into()], vec![])), - }], WindowId::Main); + let outcome = apply_remote_snapshot( + &mut data, + &mut rs, + &[RemoteSnapshot { + config: config("c1"), + state: Some(state_with( + vec![api_project("a", Some(terminal("t1")))], + vec!["a".into()], + vec![], + )), + }], + WindowId::Main, + ); assert!(outcome.focus_targets.is_empty()); assert!(rs.drain_pending_focus(WindowId::Main).is_empty()); @@ -640,24 +1140,39 @@ mod tests { "remote:conn:p1", "Project", "/repo/project", - true, ); - assert!(data.main_window.hidden_project_ids.contains("remote:conn:p1")); assert!( - !data.window(WindowId::Extra(extra_a_id)).unwrap() - .hidden_project_ids.contains("remote:conn:p1") + data.main_window + .hidden_project_ids + .contains("remote:conn:p1") + ); + assert!( + !data + .window(WindowId::Extra(extra_a_id)) + .unwrap() + .hidden_project_ids + .contains("remote:conn:p1") ); assert!( - data.window(WindowId::Extra(extra_b_id)).unwrap() - .hidden_project_ids.contains("remote:conn:p1") + data.window(WindowId::Extra(extra_b_id)) + .unwrap() + .hidden_project_ids + .contains("remote:conn:p1") ); // The pending create-visibility request was consumed. - assert_eq!(rs.take_project_visibility("conn", "Project", "/repo/project"), None); + assert_eq!( + rs.take_project_visibility("conn", "Project", "/repo/project"), + None + ); } #[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,13 +1186,20 @@ mod tests { "remote:conn:p1", "Project", "/repo/project", - false, ); - assert!(data.main_window.hidden_project_ids.contains("remote:conn:p1")); assert!( - data.window(WindowId::Extra(extra_id)).unwrap() - .hidden_project_ids.contains("remote:conn:p1") + !data + .main_window + .hidden_project_ids + .contains("remote:conn:p1") + ); + assert!( + !data + .window(WindowId::Extra(extra_id)) + .unwrap() + .hidden_project_ids + .contains("remote:conn:p1") ); } } diff --git a/crates/okena-workspace/src/remote_sync.rs b/crates/okena-workspace/src/remote_sync.rs index a044e6f5c..e32c75061 100644 --- a/crates/okena-workspace/src/remote_sync.rs +++ b/crates/okena-workspace/src/remote_sync.rs @@ -1,8 +1,9 @@ //! Transient remote-sync coordination state. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use okena_core::api::{ApiGitStatus, ApiServiceInfo}; +use okena_layout::LayoutNode; use okena_state::WindowId; /// Per-project transient remote state populated during state sync. @@ -35,6 +36,9 @@ pub struct RemoteSyncState { /// state sync. The server assigns the project ID, so the client matches /// the first newly materialized project by connection/name/path. pending_project_visibility: Vec<PendingRemoteProjectVisibility>, + /// Client-owned layouts waiting for their daemon projects to materialize, + /// or retained across a temporary disconnect. + preserved_project_layouts: HashMap<String, LayoutNode>, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -96,12 +100,13 @@ impl RemoteSyncState { name: &str, path: Option<&str>, ) { - self.pending_project_visibility.push(PendingRemoteProjectVisibility { - connection_id: connection_id.to_string(), - name: name.to_string(), - path: path.map(|path| path.to_string()), - window_id, - }); + self.pending_project_visibility + .push(PendingRemoteProjectVisibility { + connection_id: connection_id.to_string(), + name: name.to_string(), + path: path.map(|path| path.to_string()), + window_id, + }); } /// Take the spawning window for a newly materialized remote project. @@ -135,15 +140,15 @@ impl RemoteSyncState { connection_id: &str, name: &str, ) -> Option<usize> { - let mut matches = self - .pending_project_visibility - .iter() - .enumerate() - .filter(|(_, pending)| { - pending.connection_id == connection_id - && pending.name == name - && pending.allows_name_only_match() - }); + let mut matches = + self.pending_project_visibility + .iter() + .enumerate() + .filter(|(_, pending)| { + pending.connection_id == connection_id + && pending.name == name + && pending.allows_name_only_match() + }); let (index, _) = matches.next()?; if matches.next().is_none() { Some(index) @@ -170,10 +175,100 @@ impl RemoteSyncState { self.snapshots.remove(project_id); } - /// Remove all snapshots whose project ID starts with the given prefix. - pub fn retain_not_starting_with(&mut self, prefix: &str) { - self.snapshots.retain(|id, _| !id.starts_with(prefix)); + // === client presentation === + + pub fn seed_project_layouts(&mut self, layouts: HashMap<String, LayoutNode>) { + self.preserved_project_layouts = layouts; + } + + pub fn preserve_project_layout(&mut self, project_id: String, layout: LayoutNode) { + self.preserved_project_layouts.insert(project_id, layout); + } + + pub fn take_project_layout(&mut self, project_id: &str) -> Option<LayoutNode> { + self.preserved_project_layouts.remove(project_id) + } + + pub fn preserved_project_layouts(&self) -> &HashMap<String, LayoutNode> { + &self.preserved_project_layouts + } + + pub fn remove_project(&mut self, project_id: &str) { + self.snapshots.remove(project_id); + self.preserved_project_layouts.remove(project_id); + for projects in self.pending_focus.values_mut() { + projects.remove(project_id); + } + self.pending_focus + .retain(|_, projects| !projects.is_empty()); + } + + /// Drop cached project presentation after a connection disappears or a + /// successful snapshot confirms that the project was deleted. + pub fn prune_project_state( + &mut self, + active_connection_ids: &HashSet<String>, + synced_connection_ids: &HashSet<String>, + expected_project_ids: &HashSet<String>, + ) -> Vec<String> { + let mut known_ids: HashSet<String> = self.snapshots.keys().cloned().collect(); + known_ids.extend(self.preserved_project_layouts.keys().cloned()); + for projects in self.pending_focus.values() { + known_ids.extend(projects.keys().cloned()); + } + + let mut removed: Vec<String> = known_ids + .into_iter() + .filter(|project_id| { + let Some(connection_id) = remote_connection_id(project_id) else { + return false; + }; + !active_connection_ids.contains(connection_id) + || (synced_connection_ids.contains(connection_id) + && !expected_project_ids.contains(project_id)) + }) + .collect(); + for project_id in &removed { + self.remove_project(project_id); + } + self.pending_project_visibility + .retain(|pending| active_connection_ids.contains(&pending.connection_id)); + removed.sort(); + removed } + + /// Remove all cached remote state whose project ID starts with the prefix. + pub fn retain_not_starting_with(&mut self, prefix: &str) -> Vec<String> { + let mut removed: HashSet<String> = self + .snapshots + .keys() + .chain(self.preserved_project_layouts.keys()) + .filter(|id| id.starts_with(prefix)) + .cloned() + .collect(); + for projects in self.pending_focus.values() { + removed.extend(projects.keys().filter(|id| id.starts_with(prefix)).cloned()); + } + for project_id in &removed { + self.remove_project(project_id); + } + + if let Some(connection_id) = prefix + .strip_prefix("remote:") + .and_then(|rest| rest.strip_suffix(':')) + { + self.pending_project_visibility + .retain(|pending| pending.connection_id != connection_id); + } + + let mut removed: Vec<String> = removed.into_iter().collect(); + removed.sort(); + removed + } +} + +fn remote_connection_id(project_id: &str) -> Option<&str> { + project_id.strip_prefix("remote:")?.split(':').next() } impl PendingRemoteProjectVisibility { diff --git a/crates/okena-workspace/src/requests.rs b/crates/okena-workspace/src/requests.rs index 0017ec9f7..f04e5a66e 100644 --- a/crates/okena-workspace/src/requests.rs +++ b/crates/okena-workspace/src/requests.rs @@ -29,8 +29,13 @@ pub struct ProjectOverlay { /// The specific overlay to show for a project. #[derive(Clone, Debug)] pub enum ProjectOverlayKind { - ContextMenu { position: gpui::Point<gpui::Pixels> }, - ShellSelector { terminal_id: String, current_shell: okena_terminal::shell_config::ShellType }, + ContextMenu { + position: gpui::Point<gpui::Pixels>, + }, + ShellSelector { + terminal_id: String, + current_shell: okena_terminal::shell_config::ShellType, + }, DiffViewer { file: Option<String>, mode: Option<okena_core::types::DiffMode>, @@ -53,13 +58,25 @@ pub enum ProjectOverlayKind { layout_path: Vec<usize>, position: gpui::Point<gpui::Pixels>, }, - ShowServiceLog { service_name: String }, - ShowHookTerminal { terminal_id: String }, + ShowServiceLog { + service_name: String, + }, + ShowHookTerminal { + terminal_id: String, + }, FileSearch, ContentSearch, FileBrowser, - ColorPicker { position: gpui::Point<gpui::Pixels> }, - WorktreeList { position: gpui::Point<gpui::Pixels> }, + /// Open a specific file (project-relative path) in the file viewer. + FileViewer { + relative_path: String, + }, + ColorPicker { + position: gpui::Point<gpui::Pixels>, + }, + WorktreeList { + position: gpui::Point<gpui::Pixels>, + }, } /// Folder-scoped overlay request. Carries a `folder_id` once; @@ -73,8 +90,13 @@ pub struct FolderOverlay { /// The specific overlay to show for a folder. #[derive(Clone, Debug)] pub enum FolderOverlayKind { - ContextMenu { folder_name: String, position: gpui::Point<gpui::Pixels> }, - ColorPicker { position: gpui::Point<gpui::Pixels> }, + ContextMenu { + folder_name: String, + position: gpui::Point<gpui::Pixels>, + }, + ColorPicker { + position: gpui::Point<gpui::Pixels>, + }, } /// Requests consumed by WindowView::process_pending_requests(). @@ -102,7 +124,15 @@ pub enum OverlayRequest { /// Requests consumed by Sidebar::render() #[derive(Clone, Debug)] pub enum SidebarRequest { - RenameProject { project_id: String, project_name: String }, - RenameFolder { folder_id: String, folder_name: String }, - QuickCreateWorktree { project_id: String }, + RenameProject { + project_id: String, + project_name: String, + }, + RenameFolder { + folder_id: String, + folder_name: String, + }, + QuickCreateWorktree { + project_id: String, + }, } diff --git a/crates/okena-workspace/src/sessions.rs b/crates/okena-workspace/src/sessions.rs index b54713a26..916b9cebc 100644 --- a/crates/okena-workspace/src/sessions.rs +++ b/crates/okena-workspace/src/sessions.rs @@ -1,5 +1,5 @@ -use okena_terminal::session_backend::SessionBackend; use crate::state::WorkspaceData; +use okena_terminal::session_backend::SessionBackend; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -19,8 +19,8 @@ fn atomic_write_json(path: &Path, content: &str) -> std::io::Result<()> { } use super::persistence::{ - get_config_dir, migrate_legacy_json, migrate_workspace, validate_workspace_data, - WORKSPACE_VERSION, + LoadedWorkspace, WORKSPACE_VERSION, get_config_dir, migrate_legacy_json, migrate_workspace, + validate_workspace_data, }; /// Metadata about a saved session @@ -57,7 +57,13 @@ fn get_session_path(name: &str) -> PathBuf { /// Sanitize session name for use as filename fn sanitize_session_name(name: &str) -> String { name.chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) .collect() } @@ -76,42 +82,43 @@ pub fn list_sessions() -> Result<Vec<SessionInfo>> { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "json") - && let Some(name) = path.file_stem().and_then(|s| s.to_str()) { - // Read file metadata for timestamps - let metadata = std::fs::metadata(&path)?; - let modified = metadata.modified().ok(); - let created = metadata.created().ok(); - - // Try to read workspace to get project count - let project_count = if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(content) = migrate_legacy_json(&content) { - if let Ok(data) = serde_json::from_str::<WorkspaceData>(&content) { - data.projects.len() - } else { - 0 - } - } else if let Ok(data) = serde_json::from_str::<WorkspaceData>(&content) { + && let Some(name) = path.file_stem().and_then(|s| s.to_str()) + { + // Read file metadata for timestamps + let metadata = std::fs::metadata(&path)?; + let modified = metadata.modified().ok(); + let created = metadata.created().ok(); + + // Try to read workspace to get project count + let project_count = if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(content) = migrate_legacy_json(&content) { + if let Ok(data) = serde_json::from_str::<WorkspaceData>(&content) { data.projects.len() } else { 0 } + } else if let Ok(data) = serde_json::from_str::<WorkspaceData>(&content) { + data.projects.len() } else { 0 - }; - - sessions.push(SessionInfo { - name: name.to_string(), - created_at: created - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| format_timestamp(d.as_secs())) - .unwrap_or_else(|| "Unknown".to_string()), - modified_at: modified - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| format_timestamp(d.as_secs())) - .unwrap_or_else(|| "Unknown".to_string()), - project_count, - }); - } + } + } else { + 0 + }; + + sessions.push(SessionInfo { + name: name.to_string(), + created_at: created + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| format_timestamp(d.as_secs())) + .unwrap_or_else(|| "Unknown".to_string()), + modified_at: modified + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| format_timestamp(d.as_secs())) + .unwrap_or_else(|| "Unknown".to_string()), + project_count, + }); + } } // Sort by modification time (most recent first) @@ -132,8 +139,48 @@ pub fn save_session(name: &str, data: &WorkspaceData) -> Result<()> { Ok(()) } +fn prepare_loaded_session( + mut data: WorkspaceData, + backend: SessionBackend, + global_default_shell: &okena_terminal::shell_config::ShellType, +) -> LoadedWorkspace { + data = migrate_workspace(data); + + let session_backend = backend.resolve(); + let clear_ids = !session_backend.supports_persistence(); + validate_workspace_data(&mut data, clear_ids, backend); + let stale_terminal_ids = super::persistence::sync_worktrees_with_backend_and_shell( + &mut data, + backend, + global_default_shell, + ); + + LoadedWorkspace { + data, + stale_terminal_ids, + } +} + /// Load a named session pub fn load_session(name: &str, backend: SessionBackend) -> Result<WorkspaceData> { + load_session_with_cleanup(name, backend).map(|loaded| loaded.data) +} + +/// Load a named session while retaining ids removed with stale worktree rows. +pub fn load_session_with_cleanup(name: &str, backend: SessionBackend) -> Result<LoadedWorkspace> { + load_session_with_cleanup_for_shell( + name, + backend, + &okena_terminal::shell_config::ShellType::Default, + ) +} + +/// Load a named session with the transient global shell used for stale cleanup routes. +pub fn load_session_with_cleanup_for_shell( + name: &str, + backend: SessionBackend, + global_default_shell: &okena_terminal::shell_config::ShellType, +) -> Result<LoadedWorkspace> { let path = get_session_path(name); if !path.exists() { @@ -144,16 +191,10 @@ pub fn load_session(name: &str, backend: SessionBackend) -> Result<WorkspaceData .with_context(|| format!("Failed to read session file: {}", path.display()))?; let content = migrate_legacy_json(&content) .with_context(|| format!("Failed to migrate legacy session file: {}", path.display()))?; - let mut data: WorkspaceData = serde_json::from_str(&content) + let data: WorkspaceData = serde_json::from_str(&content) .with_context(|| format!("Failed to parse session file: {}", path.display()))?; - data = migrate_workspace(data); - - let session_backend = backend.resolve(); - let clear_ids = !session_backend.supports_persistence(); - validate_workspace_data(&mut data, clear_ids, backend); - - Ok(data) + Ok(prepare_loaded_session(data, backend, global_default_shell)) } /// Delete a named session @@ -212,16 +253,19 @@ pub fn export_workspace(data: &WorkspaceData, path: &std::path::Path) -> Result< pub fn import_workspace(path: &std::path::Path) -> Result<WorkspaceData> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read file: {}", path.display()))?; - let content = migrate_legacy_json(&content) - .with_context(|| format!("Failed to migrate legacy workspace file: {}", path.display()))?; + let content = migrate_legacy_json(&content).with_context(|| { + format!( + "Failed to migrate legacy workspace file: {}", + path.display() + ) + })?; // Try to parse as ExportedWorkspace first (has version/metadata) let mut data = if let Ok(exported) = serde_json::from_str::<ExportedWorkspace>(&content) { exported.workspace } else { // Fall back to parsing as raw WorkspaceData (for backwards compatibility) - serde_json::from_str(&content) - .with_context(|| "Failed to parse workspace file")? + serde_json::from_str(&content).with_context(|| "Failed to parse workspace file")? }; data = migrate_workspace(data); @@ -328,7 +372,8 @@ mod tests { #[test] fn import_raw_legacy_workspace_runs_json_migration() { - let path = write_import_file(r#"{ + let path = write_import_file( + r#"{ "version": 1, "projects": [ { @@ -349,20 +394,28 @@ mod tests { } ], "project_widths": {"p1": 60.0} - }"#); + }"#, + ); let data = import_workspace(&path).expect("legacy raw import should load"); let _ = fs::remove_file(path); assert_eq!(data.version, WORKSPACE_VERSION); assert!(data.main_window.hidden_project_ids.contains("p1")); - assert_eq!(data.main_window.folder_collapsed.get("f1").copied(), Some(true)); - assert_eq!(data.main_window.project_widths.get("p1").copied(), Some(60.0)); + assert_eq!( + data.main_window.folder_collapsed.get("f1").copied(), + Some(true) + ); + assert_eq!( + data.main_window.project_widths.get("p1").copied(), + Some(60.0) + ); } #[test] fn import_exported_legacy_workspace_runs_nested_json_migration() { - let path = write_import_file(r#"{ + let path = write_import_file( + r#"{ "version": 1, "exported_at": "2026-05-12T00:00:00Z", "workspace": { @@ -387,14 +440,75 @@ mod tests { ], "project_widths": {"p1": 60.0} } - }"#); + }"#, + ); let data = import_workspace(&path).expect("legacy exported import should load"); let _ = fs::remove_file(path); assert_eq!(data.version, WORKSPACE_VERSION); assert!(data.main_window.hidden_project_ids.contains("p1")); - assert_eq!(data.main_window.folder_collapsed.get("f1").copied(), Some(true)); - assert_eq!(data.main_window.project_widths.get("p1").copied(), Some(60.0)); + assert_eq!( + data.main_window.folder_collapsed.get("f1").copied(), + Some(true) + ); + assert_eq!( + data.main_window.project_widths.get("p1").copied(), + Some(60.0) + ); + } + + #[test] + fn named_session_self_heals_completed_optimistic_worktree() { + let id = NEXT_TEST_FILE_ID.fetch_add(1, Ordering::Relaxed); + let checkout = std::env::temp_dir().join(format!( + "okena-session-worktree-{}-{id}", + std::process::id() + )); + fs::create_dir(&checkout).expect("create completed checkout"); + let checkout_path = checkout.to_string_lossy().into_owned(); + let data: WorkspaceData = serde_json::from_value(serde_json::json!({ + "version": WORKSPACE_VERSION, + "projects": [ + { + "id": "parent", + "name": "Parent", + "path": checkout_path, + "layout": null, + "worktree_ids": ["worktree"] + }, + { + "id": "worktree", + "name": "feature", + "path": checkout_path, + "layout": null, + "worktree_info": { + "parent_project_id": "parent", + "main_repo_path": checkout_path, + "worktree_path": checkout_path, + "branch_name": "feature" + }, + "is_creating": true + } + ], + "project_order": ["parent"] + })) + .expect("build session fixture"); + + let loaded = prepare_loaded_session( + data, + SessionBackend::None, + &okena_terminal::shell_config::ShellType::Default, + ); + let worktree = loaded + .data + .projects + .iter() + .find(|project| project.id == "worktree") + .expect("completed worktree retained"); + + assert!(worktree.layout.is_some()); + assert!(!worktree.is_creating); + fs::remove_dir(checkout).expect("remove completed checkout fixture"); } } diff --git a/crates/okena-workspace/src/settings.rs b/crates/okena-workspace/src/settings.rs index a943ea09a..5b466ae5b 100644 --- a/crates/okena-workspace/src/settings.rs +++ b/crates/okena-workspace/src/settings.rs @@ -1,10 +1,11 @@ -use okena_transport::client::RemoteConnectionConfig; -use okena_terminal::session_backend::SessionBackend; -use okena_terminal::shell_config::ShellType; use okena_core::theme::ThemeMode; pub use okena_core::types::DiffViewMode; +use okena_terminal::session_backend::SessionBackend; +use okena_terminal::shell_config::ShellType; +use okena_transport::client::RemoteConnectionConfig; use anyhow::Result; +use fs2::FileExt; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::sync::Mutex; @@ -131,7 +132,11 @@ pub struct NotificationSettings { impl Default for NotificationSettings { fn default() -> Self { - Self { enabled: false, osc: true, bell: true } + Self { + enabled: false, + osc: true, + bell: true, + } } } @@ -306,9 +311,9 @@ pub struct AppSettings { /// Serve the remote control server over TLS (https/wss) using a persisted /// self-signed certificate. Clients pin the cert fingerprint on pairing. - /// Default ON (incl. for existing configs missing the key): the server is - /// dual-stack, so enabling TLS does not break already-paired plain-http - /// clients — they keep working while new/auto clients negotiate TLS. + /// Default ON (including existing configs missing the key). TLS is only + /// activated when the daemon exposes a non-loopback listener; loopback-only + /// daemons continue serving plain HTTP. #[serde(default = "default_true")] pub remote_tls_enabled: bool, @@ -522,7 +527,10 @@ pub fn load_settings() -> AppSettings { log::info!("[settings] loading from {}", path.display()); if !path.exists() { - log::warn!("[settings] file not found at {}, using defaults", path.display()); + log::warn!( + "[settings] file not found at {}, using defaults", + path.display() + ); return AppSettings::default(); } @@ -540,7 +548,11 @@ pub fn load_settings() -> AppSettings { let old_version = settings.version; settings = migrate_settings(settings); if settings.version != old_version { - log::info!("Settings migrated from v{} to v{}", old_version, settings.version); + log::info!( + "Settings migrated from v{} to v{}", + old_version, + settings.version + ); if let Err(e) = save_settings(&settings) { log::warn!("Failed to save migrated settings: {}", e); } @@ -548,7 +560,10 @@ pub fn load_settings() -> AppSettings { return settings; } Err(e) => { - log::warn!("Failed to parse settings directly: {}, attempting partial recovery", e); + log::warn!( + "Failed to parse settings directly: {}, attempting partial recovery", + e + ); } } @@ -660,7 +675,9 @@ fn migrate_settings(mut settings: AppSettings) -> AppSettings { if settings.version == 2 { log::info!("Migrating settings from v2 to v3 (extension system)"); if settings.claude_code_integration { - settings.enabled_extensions.insert("claude-code".to_string()); + settings + .enabled_extensions + .insert("claude-code".to_string()); } if settings.codex_integration { settings.enabled_extensions.insert("codex".to_string()); @@ -698,10 +715,26 @@ static SETTINGS_LOCK: Mutex<()> = Mutex::new(()); pub fn save_settings(settings: &AppSettings) -> Result<()> { let _slow = okena_core::timing::SlowGuard::new("save_settings"); let _guard = SETTINGS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _file_guard = acquire_settings_file_lock()?; save_settings_locked(settings) } -/// Inner save — caller MUST already hold `SETTINGS_LOCK`. +fn acquire_settings_file_lock() -> Result<std::fs::File> { + let lock_path = get_settings_path().with_extension("lock"); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path)?; + FileExt::lock_exclusive(&file)?; + Ok(file) +} + +/// Inner save — caller holds both process and file locks. fn save_settings_locked(settings: &AppSettings) -> Result<()> { let path = get_settings_path(); if let Some(parent) = path.parent() { @@ -712,11 +745,18 @@ fn save_settings_locked(settings: &AppSettings) -> Result<()> { // by update_remote_connections and not kept in SettingsState's in-memory copy). let mut to_save = settings.clone(); if let Ok(content) = std::fs::read_to_string(&path) - && let Ok(on_disk) = serde_json::from_str::<AppSettings>(&content) { - to_save.remote_connections = on_disk.remote_connections; - } + && let Ok(on_disk) = serde_json::from_str::<AppSettings>(&content) + { + to_save.remote_connections = on_disk.remote_connections; + } - let content = serde_json::to_string_pretty(&to_save)?; + write_settings_locked(&to_save) +} + +/// Write a complete settings value. Caller holds both settings locks. +fn write_settings_locked(settings: &AppSettings) -> Result<()> { + let path = get_settings_path(); + let content = serde_json::to_string_pretty(settings)?; // Atomic write: tmp + fsync + rename ensures the file is never partial. let tmp_path = path.with_extension("json.tmp"); @@ -737,87 +777,26 @@ fn save_settings_locked(settings: &AppSettings) -> Result<()> { /// Atomically load, update, and save the `remote_connections` field in settings. /// -/// Uses a process-level mutex to prevent concurrent read-modify-write races. -/// On Unix, also uses file locking (flock) for cross-process safety. +/// Uses the same process and cross-process locks as [`save_settings`]. pub fn update_remote_connections<F>(updater: F) -> Result<()> where F: FnOnce(&mut Vec<RemoteConnectionConfig>), { let _slow = okena_core::timing::SlowGuard::new("update_remote_connections"); let _guard = SETTINGS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _file_guard = acquire_settings_file_lock()?; let path = get_settings_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - #[cfg(unix)] - { - use std::io::{Read, Write, Seek}; - let mut file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path)?; - - // Acquire exclusive file lock for cross-process safety. Retry on EINTR; - // if locking genuinely fails (e.g. a filesystem without advisory locking, - // such as some network mounts) log it and proceed — the process-level - // SETTINGS_LOCK still serializes writers within this process. - // SAFETY: the fd is owned by the live `file` binding for the whole call. - loop { - let rc = unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) }; - if rc == 0 { - break; - } - let err = std::io::Error::last_os_error(); - if err.kind() == std::io::ErrorKind::Interrupted { - continue; - } - log::warn!("flock on settings.json failed, proceeding without cross-process lock: {err}"); - break; - } - - let mut content = String::new(); - file.read_to_string(&mut content)?; - - let mut settings: AppSettings = if content.is_empty() { - AppSettings::default() - } else { - serde_json::from_str(&content).unwrap_or_default() - }; - - updater(&mut settings.remote_connections); - - let new_content = serde_json::to_string_pretty(&settings)?; - file.seek(std::io::SeekFrom::Start(0))?; - file.set_len(0)?; - file.write_all(new_content.as_bytes())?; - - // Set restrictive permissions - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - - // Lock is released automatically when `file` is dropped - Ok(()) - } - - #[cfg(not(unix))] - { - let path = get_settings_path(); - let mut settings: AppSettings = std::fs::read_to_string(&path) - .ok() - .and_then(|c| serde_json::from_str(&c).ok()) - .unwrap_or_default(); - updater(&mut settings.remote_connections); - - let content = serde_json::to_string_pretty(&settings)?; - let tmp_path = path.with_extension("json.tmp"); - std::fs::write(&tmp_path, &content)?; - std::fs::rename(&tmp_path, &path)?; - Ok(()) - } + let mut settings: AppSettings = std::fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default(); + updater(&mut settings.remote_connections); + write_settings_locked(&settings) } #[cfg(test)] @@ -850,7 +829,10 @@ mod tests { let json = serde_json::to_string(&config).unwrap(); let deserialized: HooksConfig = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.project.on_open, Some("echo open".into())); - assert_eq!(deserialized.terminal.shell_wrapper, Some("devcontainer exec -- {shell}".into())); + assert_eq!( + deserialized.terminal.shell_wrapper, + Some("devcontainer exec -- {shell}".into()) + ); assert_eq!(deserialized.worktree.pre_merge, Some("lint".into())); assert_eq!(deserialized.worktree.after_remove, Some("log".into())); } @@ -908,13 +890,12 @@ mod tests { AppSettings::default().remote_tls_enabled, "fresh installs should default to TLS on" ); - // Existing settings.json predates the key → defaults to ON too. Safe - // because the server is dual-stack (still accepts plain http), so - // already-paired clients don't break. + // Existing settings.json predates the key and defaults to ON too. TLS + // only takes effect when a non-loopback listener is configured. let existing: AppSettings = serde_json::from_str(r#"{"version": 2}"#).unwrap(); assert!( existing.remote_tls_enabled, - "existing configs without the key default to TLS on (dual-stack server)" + "existing configs without the key default to TLS on" ); // An explicit opt-out is still respected. let explicit: AppSettings = @@ -1045,7 +1026,9 @@ mod tests { #[test] fn enabled_extensions_not_serialized_with_legacy_fields() { let mut settings = AppSettings::default(); - settings.enabled_extensions.insert("claude-code".to_string()); + settings + .enabled_extensions + .insert("claude-code".to_string()); let json = serde_json::to_string_pretty(&settings).unwrap(); // Legacy bool fields should not appear in serialized output assert!(!json.contains("claude_code_integration")); diff --git a/crates/okena-workspace/src/sidebar_controller.rs b/crates/okena-workspace/src/sidebar_controller.rs index 879bd31ad..b0fca2f8a 100644 --- a/crates/okena-workspace/src/sidebar_controller.rs +++ b/crates/okena-workspace/src/sidebar_controller.rs @@ -3,7 +3,7 @@ //! Manages sidebar visibility, auto-hide behavior, and animation state. //! The actual animation spawning is handled by the parent view. -use crate::settings::{AppSettings, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH}; +use crate::settings::{AppSettings, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH}; /// Animation duration in milliseconds. pub const ANIMATION_DURATION_MS: u64 = 150; @@ -62,7 +62,10 @@ impl SidebarController { /// Create a new sidebar controller from app settings. pub fn new(settings: &AppSettings) -> Self { let open = settings.sidebar.is_open; - let width = settings.sidebar.width.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); + let width = settings + .sidebar + .width + .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); Self { open, animation: if open { 1.0 } else { 0.0 }, @@ -140,8 +143,6 @@ impl SidebarController { pub fn toggle_auto_hide(&mut self) -> AnimationTarget { self.auto_hide = !self.auto_hide; - - if self.auto_hide && self.open { // Close sidebar when enabling auto-hide self.open = false; diff --git a/crates/okena-workspace/src/state.rs b/crates/okena-workspace/src/state.rs index d21528795..0ffbed6a0 100644 --- a/crates/okena-workspace/src/state.rs +++ b/crates/okena-workspace/src/state.rs @@ -5,14 +5,21 @@ //! `okena-state` / `okena-layout` and are re-exported here so existing //! `crate::state::*` imports keep working. -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; +use okena_core::theme::FolderColor; +use okena_terminal::backend::TerminalSessionTeardown; +use okena_terminal::session_backend::SessionBackend; +use okena_terminal::shell_config::ShellType; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; pub use okena_layout::{LayoutNode, SplitDirection}; pub use okena_state::{ @@ -21,10 +28,190 @@ pub use okena_state::{ WorkspaceData, WorktreeMetadata, }; +/// Diagnostics returned after atomically aborting a vanished before-remove hook. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AbortedWorktreeClose { + pub project_id: String, + pub project_name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum FilesystemObjectIdentity { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(windows)] + Windows { volume: u32, file: u64 }, + #[cfg(not(unix))] + CanonicalPath(PathBuf), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PathComponentIdentity { + Exact(OsString), + CaseFolded(String), +} + +/// Filesystem-aware path identity used for destructive ownership checks. +/// +/// Existing ancestors are identified by their filesystem object IDs, so bind +/// mounts, drive mappings, and UNC aliases converge. Components below the +/// deepest existing ancestor remain an ordered suffix. +#[derive(Clone, Debug)] +pub struct PhysicalPathIdentity { + ancestors: Vec<FilesystemObjectIdentity>, + unresolved: Vec<PathComponentIdentity>, + fallback: PathBuf, +} + +impl PhysicalPathIdentity { + pub fn starts_with(&self, root: &Self) -> bool { + let Some(root_anchor) = root.ancestors.first() else { + return self.fallback.starts_with(&root.fallback); + }; + + if root.unresolved.is_empty() { + return self.ancestors.contains(root_anchor); + } + + self.ancestors.first() == Some(root_anchor) && self.unresolved.starts_with(&root.unresolved) + } +} + +impl PartialEq for PhysicalPathIdentity { + fn eq(&self, other: &Self) -> bool { + self.starts_with(other) && other.starts_with(self) + } +} + +impl Eq for PhysicalPathIdentity {} + +#[cfg(unix)] +fn filesystem_object_identity(path: &Path) -> Option<FilesystemObjectIdentity> { + use std::os::unix::fs::MetadataExt as _; + + let metadata = std::fs::metadata(path).ok()?; + Some(FilesystemObjectIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(windows)] +fn filesystem_object_identity(path: &Path) -> Option<FilesystemObjectIdentity> { + use std::os::windows::fs::MetadataExt as _; + + let metadata = std::fs::metadata(path).ok()?; + match (metadata.volume_serial_number(), metadata.file_index()) { + (Some(volume), Some(file)) => Some(FilesystemObjectIdentity::Windows { volume, file }), + _ => std::fs::canonicalize(path) + .ok() + .map(normalize_fallback_path) + .map(FilesystemObjectIdentity::CanonicalPath), + } +} + +#[cfg(not(any(unix, windows)))] +fn filesystem_object_identity(path: &Path) -> Option<FilesystemObjectIdentity> { + std::fs::canonicalize(path) + .ok() + .map(normalize_fallback_path) + .map(FilesystemObjectIdentity::CanonicalPath) +} + +fn normalize_fallback_path(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + PathBuf::from(path.to_string_lossy().to_lowercase()) + } + #[cfg(not(windows))] + { + path + } +} + +fn normalize_unresolved_component( + component: &OsString, + case_sensitive: bool, +) -> PathComponentIdentity { + if case_sensitive { + PathComponentIdentity::Exact(component.clone()) + } else { + PathComponentIdentity::CaseFolded(component.to_string_lossy().to_lowercase()) + } +} + +#[cfg(windows)] +fn filesystem_is_case_sensitive(_path: &Path) -> bool { + false +} + +#[cfg(target_os = "macos")] +fn filesystem_is_case_sensitive(path: &Path) -> bool { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt as _; + + let Ok(c_path) = CString::new(path.as_os_str().as_bytes()) else { + return true; + }; + // `pathconf` reads the volume's case-sensitivity flag for this directory. + let result = unsafe { libc::pathconf(c_path.as_ptr(), libc::_PC_CASE_SENSITIVE) }; + match result { + 0 => false, + 1 => true, + _ => probe_case_sensitivity(path).unwrap_or(true), + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn filesystem_is_case_sensitive(path: &Path) -> bool { + probe_case_sensitivity(path).unwrap_or(true) +} + +#[cfg(not(any(unix, windows)))] +fn filesystem_is_case_sensitive(_path: &Path) -> bool { + true +} + +#[cfg(unix)] +fn probe_case_sensitivity(path: &Path) -> Option<bool> { + if let Some(result) = probe_case_alias(path) { + return Some(result); + } + let entries = std::fs::read_dir(path).ok()?; + entries + .filter_map(Result::ok) + .take(64) + .find_map(|entry| probe_case_alias(&entry.path())) +} + +#[cfg(unix)] +fn probe_case_alias(path: &Path) -> Option<bool> { + use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; + + let name = path.file_name()?; + let mut alternate = name.as_bytes().to_vec(); + let byte = alternate + .iter_mut() + .find(|byte| byte.is_ascii_alphabetic())?; + if byte.is_ascii_lowercase() { + byte.make_ascii_uppercase(); + } else { + byte.make_ascii_lowercase(); + } + let alias = path.parent()?.join(OsString::from_vec(alternate)); + let original = filesystem_object_identity(path)?; + match filesystem_object_identity(&alias) { + Some(alias) => Some(alias != original), + None => Some(true), + } +} + /// Global workspace wrapper for app-wide access (used by quit handler) +#[cfg(feature = "gpui")] #[derive(Clone)] pub struct GlobalWorkspace(pub Entity<Workspace>); +#[cfg(feature = "gpui")] impl Global for GlobalWorkspace {} /// GPUI Entity for workspace state. @@ -49,6 +236,8 @@ pub struct Workspace { data_version: u64, /// Monotonic counter incremented when all workspace data is replaced. data_replacement_epoch: u64, + /// Active live session-backend migration, fenced by its replacement epoch. + terminal_backend_migration_epoch: Option<u64>, /// Terminal IDs queued for killing by the app layer (drained by Okena observer). pending_terminal_kills: Vec<String>, /// Terminals closed with the grace-period "soft close": removed from the @@ -58,6 +247,9 @@ pub struct Workspace { /// Terminals just brought back by an undo whose PTY might still be racing an /// in-flight exit event — see [`RestoredClose`]. pub(crate) restored_closes: Vec<RestoredClose>, + /// Ownership retained after a terminal leaves the layout but before its PTY + /// exit is processed, so daemon lifecycle hooks still have project context. + pub(crate) closing_terminal_owners: HashMap<String, ClosingTerminalOwner>, } /// A terminal that was soft-closed and is waiting out its grace period. @@ -90,6 +282,163 @@ pub struct RestoredClose { pub project_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalMigrationSlot { + pub project_id: String, + pub path: Vec<usize>, + pub terminal_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalBackendMigration { + pub epoch: u64, + pub project_ids: Vec<String>, + pub ordinary_slots: Vec<TerminalMigrationSlot>, + pub teardown_sessions: Vec<TerminalSessionTeardown>, + pub hook_terminal_ids: Vec<String>, +} + +/// Terminal ownership detached from one project while its directory is moved +/// or removed by the headless daemon. +#[derive(Clone, Debug)] +pub struct ProjectRuntimeQuiesce { + pub project_id: String, + pub data_replacement_epoch: u64, + pub runtime_quiesce_generation: u64, + pub project_path: String, + pub teardown_sessions: Vec<TerminalSessionTeardown>, + /// Running hook owners that must be cancelled and removed from the registry. + pub hook_terminal_ids: Vec<String>, + /// Completed hooks whose scrollback remains registered after session teardown. + pub preserved_registry_terminal_ids: Vec<String>, + pub pending_close_terminal_ids: Vec<String>, + layout_slots: Vec<ProjectRuntimeSlot>, +} + +#[derive(Clone, Debug)] +struct ProjectRuntimeSlot { + path: Vec<usize>, + terminal_name: Option<String>, + hidden: Option<bool>, +} + +/// Transient ownership metadata for a terminal awaiting its PTY exit event. +#[derive(Clone, Debug)] +pub(crate) struct ClosingTerminalOwner { + pub project_id: String, + pub terminal_name: Option<String>, +} + +#[allow(clippy::too_many_arguments)] +fn take_layout_terminal_ownership( + layout: &mut LayoutNode, + project_id: &str, + project_default_shell: Option<&ShellType>, + global_default_shell: &ShellType, + backend_preference: SessionBackend, + path: &mut Vec<usize>, + ordinary_slots: &mut Vec<TerminalMigrationSlot>, + teardown_sessions: &mut Vec<TerminalSessionTeardown>, +) { + match layout { + LayoutNode::Terminal { + terminal_id, + shell_type, + .. + } => { + let Some(terminal_id) = terminal_id.take() else { + return; + }; + teardown_sessions.push(TerminalSessionTeardown { + terminal_id: terminal_id.clone(), + route: crate::persistence::teardown_route( + shell_type, + project_default_shell, + global_default_shell, + backend_preference, + ), + }); + ordinary_slots.push(TerminalMigrationSlot { + project_id: project_id.to_string(), + path: path.clone(), + terminal_id, + }); + } + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + for (index, child) in children.iter_mut().enumerate() { + path.push(index); + take_layout_terminal_ownership( + child, + project_id, + project_default_shell, + global_default_shell, + backend_preference, + path, + ordinary_slots, + teardown_sessions, + ); + path.pop(); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn take_project_layout_runtime( + layout: &mut LayoutNode, + project_default_shell: Option<&ShellType>, + global_default_shell: &ShellType, + backend_preference: SessionBackend, + terminal_names: &mut HashMap<String, String>, + hidden_terminals: &mut HashMap<String, bool>, + path: &mut Vec<usize>, + slots: &mut Vec<ProjectRuntimeSlot>, + teardown_sessions: &mut Vec<TerminalSessionTeardown>, +) { + match layout { + LayoutNode::Terminal { + terminal_id, + shell_type, + .. + } => { + let Some(terminal_id) = terminal_id.take() else { + return; + }; + teardown_sessions.push(TerminalSessionTeardown { + terminal_id: terminal_id.clone(), + route: crate::persistence::teardown_route( + shell_type, + project_default_shell, + global_default_shell, + backend_preference, + ), + }); + slots.push(ProjectRuntimeSlot { + path: path.clone(), + terminal_name: terminal_names.remove(&terminal_id), + hidden: hidden_terminals.remove(&terminal_id), + }); + } + LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => { + for (index, child) in children.iter_mut().enumerate() { + path.push(index); + take_project_layout_runtime( + child, + project_default_shell, + global_default_shell, + backend_preference, + terminal_names, + hidden_terminals, + path, + slots, + teardown_sessions, + ); + path.pop(); + } + } + } +} + impl Workspace { pub fn new(data: WorkspaceData) -> Self { Self { @@ -99,12 +448,32 @@ impl Workspace { access_history: ProjectAccessHistory::new(), data_version: 0, data_replacement_epoch: 0, + terminal_backend_migration_epoch: None, pending_terminal_kills: Vec::new(), pending_closes: Vec::new(), restored_closes: Vec::new(), + closing_terminal_owners: HashMap::new(), } } + /// Seed desktop-owned project presentation before the first daemon snapshot. + pub fn seed_client_project_layouts(&mut self, layouts: HashMap<String, LayoutNode>) { + self.remote_sync.seed_project_layouts(layouts); + } + + /// Snapshot all desktop-owned layouts, including projects temporarily + /// absent while their daemon connection is reconnecting. + pub fn client_project_layouts(&self) -> HashMap<String, LayoutNode> { + let mut layouts = self.remote_sync.preserved_project_layouts().clone(); + layouts.extend(self.data.projects.iter().filter_map(|project| { + project + .layout + .clone() + .map(|layout| (project.id.clone(), layout)) + })); + layouts + } + /// Current data version (incremented on persistent data mutations) pub fn data_version(&self) -> u64 { self.data_version @@ -115,6 +484,662 @@ impl Workspace { self.data_replacement_epoch } + /// Detach every runtime that can retain a project's working directory. + /// The layout remains authoritative with empty terminal slots so it can be + /// materialized again after a failed removal or a directory move. + pub fn begin_project_runtime_quiesce( + &mut self, + project_id: &str, + global_default_shell: &ShellType, + backend_preference: SessionBackend, + reject_running_hooks: bool, + cx: &mut impl WorkspaceCx, + ) -> Result<ProjectRuntimeQuiesce, String> { + let mut snapshots = self.begin_project_runtimes_quiesce( + &[project_id.to_string()], + global_default_shell, + backend_preference, + reject_running_hooks, + cx, + )?; + snapshots + .pop() + .ok_or_else(|| "project runtime quiesce produced no owner".to_string()) + } + + /// Atomically detach every runtime for a set of projects. + pub fn begin_project_runtimes_quiesce( + &mut self, + project_ids: &[String], + global_default_shell: &ShellType, + backend_preference: SessionBackend, + reject_running_hooks: bool, + cx: &mut impl WorkspaceCx, + ) -> Result<Vec<ProjectRuntimeQuiesce>, String> { + let mut unique_ids = Vec::new(); + let mut seen = HashSet::new(); + for project_id in project_ids { + if seen.insert(project_id.clone()) { + unique_ids.push(project_id.clone()); + } + } + if unique_ids.is_empty() { + return Ok(Vec::new()); + } + + // Validate the full batch before claiming or mutating any project. + for project_id in &unique_ids { + if self.lifecycle.is_creating(project_id) { + return Err(format!("project is still being created: {project_id}")); + } + if self.lifecycle.is_closing(project_id) { + return Err(format!( + "project operation is already in progress: {project_id}" + )); + } + let project = self + .project(project_id) + .ok_or_else(|| format!("Project not found: {project_id}"))?; + if project.is_remote { + return Err(format!( + "remote project directories cannot be changed locally: {project_id}" + )); + } + if reject_running_hooks + && project + .hook_terminals + .values() + .any(|entry| entry.status == HookTerminalStatus::Running) + { + return Err(format!( + "cannot move a project while a lifecycle hook is running: {project_id}" + )); + } + } + + let generation = self.lifecycle.claim_runtime_quiesce(&unique_ids)?; + let data_replacement_epoch = self.data_replacement_epoch; + let mut snapshots = Vec::with_capacity(unique_ids.len()); + for project_id in &unique_ids { + let project_path = self + .project(project_id) + .map(|project| project.path.clone()) + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let mut teardown_sessions = Vec::new(); + let mut hook_terminal_ids = Vec::new(); + let mut preserved_registry_terminal_ids = Vec::new(); + let mut layout_slots = Vec::new(); + { + let project = self + .project_mut(project_id) + .ok_or_else(|| format!("Project not found: {project_id}"))?; + if let Some(layout) = &mut project.layout { + take_project_layout_runtime( + layout, + project.default_shell.as_ref(), + global_default_shell, + backend_preference, + &mut project.terminal_names, + &mut project.hidden_terminals, + &mut Vec::new(), + &mut layout_slots, + &mut teardown_sessions, + ); + } + teardown_sessions.extend( + project + .service_terminals + .drain() + .map(|(_, terminal_id)| TerminalSessionTeardown::host(terminal_id)), + ); + let all_hook_ids: Vec<String> = project.hook_terminals.keys().cloned().collect(); + for terminal_id in &all_hook_ids { + let is_running = project + .hook_terminals + .get(terminal_id) + .is_some_and(|entry| entry.status == HookTerminalStatus::Running); + if is_running { + hook_terminal_ids.push(terminal_id.clone()); + project.hook_terminals.remove(terminal_id); + project.terminal_names.remove(terminal_id); + project.hidden_terminals.remove(terminal_id); + } else { + preserved_registry_terminal_ids.push(terminal_id.clone()); + } + } + teardown_sessions + .extend(all_hook_ids.into_iter().map(TerminalSessionTeardown::host)); + } + let pending_close_terminal_ids = self.drain_pending_closes_for_project(project_id); + teardown_sessions.extend( + pending_close_terminal_ids + .iter() + .cloned() + .map(TerminalSessionTeardown::host), + ); + teardown_sessions.sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + teardown_sessions.dedup_by(|a, b| a.terminal_id == b.terminal_id); + hook_terminal_ids.sort(); + preserved_registry_terminal_ids.sort(); + self.mark_closing_project_authoritative(project_id); + snapshots.push(ProjectRuntimeQuiesce { + project_id: project_id.clone(), + data_replacement_epoch, + runtime_quiesce_generation: generation, + project_path, + teardown_sessions, + hook_terminal_ids, + preserved_registry_terminal_ids, + pending_close_terminal_ids, + layout_slots, + }); + } + self.notify_data(cx); + Ok(snapshots) + } + + pub fn project_runtime_quiesce_is_current(&self, snapshot: &ProjectRuntimeQuiesce) -> bool { + self.project_runtime_quiesce_is_current_at(snapshot, &snapshot.project_path) + } + + pub fn project_runtime_quiesce_is_current_at( + &self, + snapshot: &ProjectRuntimeQuiesce, + project_path: &str, + ) -> bool { + self.data_replacement_epoch == snapshot.data_replacement_epoch + && self + .lifecycle + .owns_runtime_quiesce(&snapshot.project_id, snapshot.runtime_quiesce_generation) + && self.lifecycle.is_closing(&snapshot.project_id) + && self + .project(&snapshot.project_id) + .is_some_and(|project| project.path == project_path) + } + + /// Restore terminal metadata after empty layout slots have been materialized. + pub fn finish_project_runtime_recovery( + &mut self, + snapshot: &ProjectRuntimeQuiesce, + cx: &mut impl WorkspaceCx, + ) { + if self.data_replacement_epoch != snapshot.data_replacement_epoch + || !self + .lifecycle + .owns_runtime_quiesce(&snapshot.project_id, snapshot.runtime_quiesce_generation) + { + return; + } + let Some(project) = self.project_mut(&snapshot.project_id) else { + return; + }; + for slot in &snapshot.layout_slots { + let Some(terminal_id) = project + .layout + .as_ref() + .and_then(|layout| layout.get_at_path(&slot.path)) + .and_then(|node| match node { + LayoutNode::Terminal { terminal_id, .. } => terminal_id.clone(), + _ => None, + }) + else { + continue; + }; + if let Some(name) = &slot.terminal_name { + project + .terminal_names + .insert(terminal_id.clone(), name.clone()); + } + if let Some(hidden) = slot.hidden { + project.hidden_terminals.insert(terminal_id, hidden); + } + } + if !self + .lifecycle + .finish_runtime_quiesce(&snapshot.project_id, snapshot.runtime_quiesce_generation) + { + return; + } + self.finish_closing_project(&snapshot.project_id); + self.notify_data(cx); + } + + /// Clear PTYs created by a failed rematerialization attempt so recovery can + /// fail without publishing a half-restored layout or leaking registry ids. + pub fn drain_partial_project_runtime_recovery( + &mut self, + snapshot: &ProjectRuntimeQuiesce, + global_default_shell: &ShellType, + backend_preference: SessionBackend, + cx: &mut impl WorkspaceCx, + ) -> Vec<TerminalSessionTeardown> { + let Some(project) = self.project_mut(&snapshot.project_id) else { + return Vec::new(); + }; + let mut teardown_sessions = Vec::new(); + let mut discarded_slots = Vec::new(); + if let Some(layout) = &mut project.layout { + take_project_layout_runtime( + layout, + project.default_shell.as_ref(), + global_default_shell, + backend_preference, + &mut project.terminal_names, + &mut project.hidden_terminals, + &mut Vec::new(), + &mut discarded_slots, + &mut teardown_sessions, + ); + } + if !teardown_sessions.is_empty() { + self.notify_data(cx); + } + teardown_sessions + } + + /// Snapshot and atomically clear all local terminal ownership for migration. + pub fn begin_terminal_backend_migration( + &mut self, + backend_preference: SessionBackend, + global_default_shell: &ShellType, + ) -> Result<TerminalBackendMigration, String> { + if self.terminal_backend_migration_epoch.is_some() { + return Err("terminal backend migration already in progress".to_string()); + } + if self.lifecycle.has_active_operations() + || !self.pending_closes.is_empty() + || !self.restored_closes.is_empty() + || !self.closing_terminal_owners.is_empty() + || !self.pending_terminal_kills.is_empty() + { + return Err( + "cannot switch terminal backend while a workspace operation is active".to_string(), + ); + } + self.data_replacement_epoch = self + .data_replacement_epoch + .checked_add(1) + .ok_or_else(|| "workspace replacement epoch exhausted".to_string())?; + let epoch = self.data_replacement_epoch; + self.terminal_backend_migration_epoch = Some(epoch); + + let mut project_ids = Vec::new(); + let mut ordinary_slots = Vec::new(); + let mut teardown_sessions = Vec::new(); + let mut hook_terminal_ids = Vec::new(); + for project in self + .data + .projects + .iter_mut() + .filter(|project| !project.is_remote) + { + project_ids.push(project.id.clone()); + if let Some(layout) = &mut project.layout { + take_layout_terminal_ownership( + layout, + &project.id, + project.default_shell.as_ref(), + global_default_shell, + backend_preference, + &mut Vec::new(), + &mut ordinary_slots, + &mut teardown_sessions, + ); + } + teardown_sessions.extend( + project + .service_terminals + .drain() + .map(|(_, terminal_id)| TerminalSessionTeardown::host(terminal_id)), + ); + let project_hook_ids: Vec<String> = + project.hook_terminals.drain().map(|(id, _)| id).collect(); + for terminal_id in &project_hook_ids { + project.terminal_names.remove(terminal_id); + project.hidden_terminals.remove(terminal_id); + } + teardown_sessions.extend( + project_hook_ids + .iter() + .cloned() + .map(TerminalSessionTeardown::host), + ); + hook_terminal_ids.extend(project_hook_ids); + } + teardown_sessions.sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + teardown_sessions.dedup_by(|a, b| a.terminal_id == b.terminal_id); + project_ids.sort(); + ordinary_slots.sort_by(|a, b| a.terminal_id.cmp(&b.terminal_id)); + hook_terminal_ids.sort(); + + Ok(TerminalBackendMigration { + epoch, + project_ids, + ordinary_slots, + teardown_sessions, + hook_terminal_ids, + }) + } + + /// Return the active migration epoch, if terminal ownership is provisional. + pub fn terminal_backend_migration_epoch(&self) -> Option<u64> { + self.terminal_backend_migration_epoch + } + + /// Atomically publish replacement data behind the transient terminal gate. + /// + /// Callers finish terminal teardown/materialization before releasing the + /// gate, so observers never persist partially materialized ownership. + pub fn begin_workspace_replacement_transition( + &mut self, + focus_manager: &mut FocusManager, + data: WorkspaceData, + ) -> Result<u64, String> { + if self.terminal_backend_migration_epoch.is_some() { + return Err("a terminal ownership transition is already in progress".to_string()); + } + self.data_replacement_epoch = self + .data_replacement_epoch + .checked_add(1) + .ok_or_else(|| "workspace replacement epoch exhausted".to_string())?; + let epoch = self.data_replacement_epoch; + self.terminal_backend_migration_epoch = Some(epoch); + self.data = data; + for project in &mut self.data.projects { + project.is_closing = false; + } + self.lifecycle = ProjectLifecycleTracker::new(); + self.pending_closes.clear(); + self.restored_closes.clear(); + self.closing_terminal_owners.clear(); + focus_manager.clear_all(); + self.data_version = self.data_version.wrapping_add(1); + Ok(epoch) + } + + /// Publish one consolidated change after replacement materialization. + pub fn finish_workspace_replacement_transition( + &mut self, + epoch: u64, + cx: &mut impl WorkspaceCx, + ) -> bool { + self.finish_terminal_backend_migration(epoch, cx) + } + + /// Clear only the migration that owns `epoch` and wake skipped observers. + pub fn finish_terminal_backend_migration( + &mut self, + epoch: u64, + cx: &mut impl WorkspaceCx, + ) -> bool { + if self.terminal_backend_migration_epoch != Some(epoch) { + return false; + } + self.terminal_backend_migration_epoch = None; + self.notify_data(cx); + true + } + + /// Restore ordinary logical IDs before reconnecting them on the selected backend. + pub fn restore_terminal_backend_migration_slots( + &mut self, + migration: &TerminalBackendMigration, + ) -> Result<(), String> { + if self.terminal_backend_migration_epoch != Some(migration.epoch) { + return Err("stale terminal backend migration completion".to_string()); + } + for slot in &migration.ordinary_slots { + let project = self.project_mut(&slot.project_id).ok_or_else(|| { + format!("project disappeared during migration: {}", slot.project_id) + })?; + let node = project + .layout + .as_mut() + .and_then(|layout| layout.get_at_path_mut(&slot.path)) + .ok_or_else(|| { + format!( + "terminal slot disappeared during migration: {}", + slot.terminal_id + ) + })?; + match node { + LayoutNode::Terminal { terminal_id, .. } + if terminal_id + .as_ref() + .is_none_or(|terminal_id| terminal_id == &slot.terminal_id) => + { + *terminal_id = Some(slot.terminal_id.clone()); + } + LayoutNode::Terminal { .. } => { + return Err(format!( + "terminal slot was claimed during migration: {}", + slot.terminal_id + )); + } + LayoutNode::Split { .. } | LayoutNode::Tabs { .. } => { + return Err(format!( + "terminal slot changed during migration: {}", + slot.terminal_id + )); + } + } + } + Ok(()) + } + + /// Resolve a path for physical ownership checks, including mount/drive + /// aliases, symlinked ancestors, and relative/nonexistent descendants. + pub fn physical_path_identity(path: &Path) -> PhysicalPathIdentity { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + + fn resolve_symlink_components(path: &Path, depth: usize) -> PathBuf { + let mut resolved = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + resolved.pop(); + } + _ => { + resolved.push(component.as_os_str()); + if depth >= 40 + || !std::fs::symlink_metadata(&resolved) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + continue; + } + let Ok(target) = std::fs::read_link(&resolved) else { + continue; + }; + resolved.pop(); + let target = if target.is_absolute() { + target + } else { + resolved.join(target) + }; + resolved = resolve_symlink_components(&target, depth + 1); + } + } + } + okena_git::repository::normalize_path(&resolved) + } + + // Walk components in filesystem order so `link/..` backs out of the + // symlink target, not the lexical directory containing the link. + let normalized = resolve_symlink_components(&absolute, 0); + let mut cursor = normalized.as_path(); + let mut unresolved = Vec::new(); + + let (existing_path, resolved) = loop { + if let Ok(mut existing) = std::fs::canonicalize(cursor) { + for component in unresolved.iter().rev() { + existing.push(component); + } + break ( + Some(cursor.to_path_buf()), + okena_git::repository::normalize_path(&existing), + ); + } + let Some(name) = cursor.file_name() else { + break (None, normalized); + }; + unresolved.push(name.to_os_string()); + let Some(parent) = cursor.parent() else { + break (None, normalized); + }; + cursor = parent; + }; + + let fallback = normalize_fallback_path(resolved); + let Some(existing_path) = existing_path else { + return PhysicalPathIdentity { + ancestors: Vec::new(), + unresolved: Vec::new(), + fallback, + }; + }; + + let case_sensitive = filesystem_is_case_sensitive(&existing_path); + let unresolved = unresolved + .iter() + .rev() + .map(|component| normalize_unresolved_component(component, case_sensitive)) + .collect(); + let mut ancestors = Vec::new(); + let mut ancestor = Some(existing_path.as_path()); + while let Some(path) = ancestor { + if let Some(identity) = filesystem_object_identity(path) + && ancestors.last() != Some(&identity) + { + ancestors.push(identity); + } + ancestor = path.parent(); + } + + PhysicalPathIdentity { + ancestors, + unresolved, + fallback, + } + } + + fn worktree_root_identity(&self, project: &ProjectData) -> Option<PhysicalPathIdentity> { + let metadata = project.worktree_info.as_ref()?; + let project_path = Path::new(&project.path); + let root = okena_git::get_repo_root(project_path).unwrap_or_else(|| { + if metadata.worktree_path.is_empty() { + project_path.to_path_buf() + } else { + PathBuf::from(&metadata.worktree_path) + } + }); + Some(Self::physical_path_identity(&root)) + } + + /// Reject a project path that would enter a worktree root currently being + /// created, closed, merged, or removed. + pub fn ensure_project_path_claim_allowed(&self, path: &Path) -> Result<(), String> { + let candidate = Self::physical_path_identity(path); + for project in self.projects().iter().filter(|project| !project.is_remote) { + if !(self.is_creating_project(&project.id) || self.is_project_closing(&project.id)) { + continue; + } + let Some(root) = self.worktree_root_identity(project) else { + continue; + }; + if candidate.starts_with(&root) { + return Err(format!( + "path is reserved by active worktree operation for '{}'", + project.name + )); + } + } + Ok(()) + } + + /// Reject a worktree target whose physical root overlaps an active root in + /// either direction. + pub fn ensure_worktree_target_claim_allowed(&self, root: &Path) -> Result<(), String> { + let candidate = Self::physical_path_identity(root); + for project in self.projects().iter().filter(|project| !project.is_remote) { + if !(self.is_creating_project(&project.id) || self.is_project_closing(&project.id)) { + continue; + } + let Some(active_root) = self.worktree_root_identity(project) else { + continue; + }; + if candidate.starts_with(&active_root) || active_root.starts_with(&candidate) { + return Err(format!( + "worktree target overlaps active operation for '{}'", + project.name + )); + } + } + Ok(()) + } + + /// A worktree checkout may only be removed when no other local project owns + /// its root or a descendant path. + pub fn ensure_worktree_root_exclusively_owned( + &self, + owner_project_id: &str, + root: &Path, + ) -> Result<(), String> { + let root = Self::physical_path_identity(root); + if let Some(claimant) = self.projects().iter().find(|project| { + project.id != owner_project_id + && !project.is_remote + && Self::physical_path_identity(Path::new(&project.path)).starts_with(&root) + }) { + return Err(format!( + "worktree checkout is also used by project '{}'", + claimant.name + )); + } + Ok(()) + } + + pub fn ensure_project_path_mutation_allowed( + &self, + project_id: &str, + new_path: &Path, + ) -> Result<(), String> { + if self.is_creating_project(project_id) { + return Err("worktree is still being created".to_string()); + } + if self.is_project_closing(project_id) { + return Err("worktree is already closing".to_string()); + } + let existing = self + .project(project_id) + .map(|project| Self::physical_path_identity(Path::new(&project.path))) + .ok_or_else(|| "Project not found".to_string())?; + let candidate = Self::physical_path_identity(new_path); + for project in self.projects().iter().filter(|project| !project.is_remote) { + if !(self.is_creating_project(&project.id) || self.is_project_closing(&project.id)) { + continue; + } + let Some(root) = self.worktree_root_identity(project) else { + continue; + }; + let overlaps = + |path: &PhysicalPathIdentity| path.starts_with(&root) || root.starts_with(path); + if overlaps(&candidate) || overlaps(&existing) { + return Err(format!( + "path overlaps active worktree operation for '{}'", + project.name + )); + } + } + Ok(()) + } + /// Read-only access to persistent workspace data. pub fn data(&self) -> &WorkspaceData { &self.data @@ -130,25 +1155,41 @@ 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<Self>) { + 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<Self>) { + /// Replace workspace data wholesale (e.g. by loading a named session). + /// + /// A replacement becomes the daemon's active restart state, so it is a + /// persistent mutation even though its source was another file. + pub fn replace_data( + &mut self, + focus_manager: &mut FocusManager, + data: WorkspaceData, + cx: &mut impl WorkspaceCx, + ) { self.data = data; + for project in &mut self.data.projects { + project.is_closing = false; + } self.data_replacement_epoch += 1; + self.lifecycle = ProjectLifecycleTracker::new(); // Snapshots in pending_closes refer to the old data — drop them so an // undo can't restore into a wholesale-replaced workspace. The // restore-race breadcrumbs refer to the old layout too. self.pending_closes.clear(); self.restored_closes.clear(); + self.closing_terminal_owners.clear(); focus_manager.clear_all(); - cx.notify(); - cx.refresh_windows(); + self.notify_data(cx); } /// Record that a project was accessed (for sorting by recency) @@ -163,7 +1204,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<Self>) { + 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 +1265,9 @@ impl Workspace { &mut self, window_id: WindowId, folder_id: Option<String>, - cx: &mut Context<Self>, + 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 +1283,9 @@ impl Workspace { &mut self, window_id: WindowId, project_id: &str, - cx: &mut Context<Self>, + 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 +1302,11 @@ impl Workspace { window_id: WindowId, project_id: &str, width: f32, - cx: &mut Context<Self>, + 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 +1325,11 @@ impl Workspace { window_id: WindowId, folder_id: &str, collapsed: bool, - cx: &mut Context<Self>, + 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,22 +1349,15 @@ impl Workspace { &mut self, window_id: WindowId, bounds: Option<WindowBounds>, - cx: &mut Context<Self>, + 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 /// so each window remembers its own chrome layout across launches. - pub fn set_sidebar_open( - &mut self, - window_id: WindowId, - open: bool, - cx: &mut Context<Self>, - ) { - self.data.set_sidebar_open(window_id, open); - self.notify_data(cx); + pub fn set_sidebar_open(&mut self, window_id: WindowId, open: bool, cx: &mut impl WorkspaceCx) { + self.mutate_data(cx, |data| data.set_sidebar_open(window_id, open)); } /// Read the project-grid orientation for the targeted window. Falls back @@ -335,38 +1369,18 @@ impl Workspace { .unwrap_or_default() } - /// Flip the targeted window's project grid between columns and rows, and - /// transpose every terminal split inside the projects shown in that window. + /// Flip the targeted window's project grid between columns and rows. /// - /// Flipping the grid axis without transposing the panes inside would leave - /// each project's internal splits running the "wrong" way relative to the - /// new grid orientation; transposing keeps the whole window's layout - /// visually consistent through the switch. Only projects visible in *this* - /// window are touched (using the window's persistent visibility, ignoring - /// transient focus narrowing), so a project shown in another window keeps - /// its own pane orientation. + /// Terminal splits are transposed at render time for this window. Their + /// canonical daemon-owned directions stay untouched, so another window can + /// present the same project with a different orientation and state syncs + /// cannot undo the local choice. /// /// 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<Self>) { - let Some(window_state) = self.data.window(window_id) else { + pub fn toggle_project_layout_mode(&mut self, window_id: WindowId, cx: &mut impl WorkspaceCx) { + if self.data.window(window_id).is_none() { return; - }; - - // Collect the IDs of projects shown in this window before mutating, so - // the borrow of `window_state` is released before we mutate layouts. - let visible_ids: Vec<String> = - compute_visible_projects(&self.data, None, false, window_state) - .into_iter() - .map(|p| p.id.clone()) - .collect(); - - for project in &mut self.data.projects { - if visible_ids.iter().any(|id| id == &project.id) - && let Some(layout) = project.layout.as_mut() - { - layout.transpose(); - } } if let Some(w) = self.data.window_mut(window_id) { @@ -377,7 +1391,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<Self>) { + 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 +1399,11 @@ 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<Self>) { + 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 +1411,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<Self>) { + 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 +1444,7 @@ impl Workspace { pub fn spawn_extra_window( &mut self, spawning_bounds: Option<WindowBounds>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) -> WindowId { let id = self.data.spawn_extra_window(spawning_bounds); self.notify_data(cx); @@ -450,7 +1468,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<Self>) { + pub fn close_extra_window(&mut self, id: WindowId, cx: &mut impl WorkspaceCx) { self.data.close_extra_window(id); self.notify_data(cx); } @@ -463,10 +1481,18 @@ impl Workspace { pub fn mark_creating_project(&mut self, project_id: &str) { self.lifecycle.mark_creating(project_id); + // Mirror onto the persisted/wire-facing marker so the flag survives a + // daemon restart and reaches clients (the in-memory tracker does neither). + if let Some(p) = self.data.projects.iter_mut().find(|p| p.id == project_id) { + p.is_creating = true; + } } pub fn finish_creating_project(&mut self, project_id: &str) { self.lifecycle.finish_creating(project_id); + if let Some(p) = self.data.projects.iter_mut().find(|p| p.id == project_id) { + p.is_creating = false; + } } pub fn mark_worktree_removing(&mut self, path: &str) { @@ -477,8 +1503,36 @@ impl Workspace { self.lifecycle.finish_worktree_removing(path); } + /// Client-side optimistic mark: sets only the in-memory tracker so the + /// initiating client dims the row instantly. The authoritative daemon flag + /// arrives via the mirror (`ProjectData::is_closing`); do NOT set the + /// wire-facing marker here or a stale local set could out-live the mirror. + pub fn mark_closing_project(&mut self, project_id: &str) { + self.lifecycle.mark_closing(project_id); + } + + /// Daemon-owned closing mark mirrored to thin clients. + pub fn mark_closing_project_authoritative(&mut self, project_id: &str) { + self.lifecycle.mark_closing(project_id); + self.set_project_closing_flag(project_id, true); + } + pub fn finish_closing_project(&mut self, project_id: &str) { self.lifecycle.finish_closing(project_id); + // Clear the wire-facing marker too: on the daemon this is the abort path + // (before-remove hook failed), and mirroring the cleared flag is what + // heals the client's "Closing…" row. + self.set_project_closing_flag(project_id, false); + } + + /// Mirror the closing lifecycle state onto the wire-facing `is_closing` + /// marker on `ProjectData` so the daemon's authoritative closing state + /// reaches thin clients (the in-memory lifecycle tracker is neither + /// persisted nor mirrored). + fn set_project_closing_flag(&mut self, project_id: &str, closing: bool) { + if let Some(p) = self.data.projects.iter_mut().find(|p| p.id == project_id) { + p.is_closing = closing; + } } // === Terminal kill queue === @@ -534,12 +1588,18 @@ 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<String, String>, cx: &mut Context<Self>) { + pub fn sync_service_terminals( + &mut self, + project_id: &str, + terminals: HashMap<String, String>, + cx: &mut impl WorkspaceCx, + ) { if let Some(project) = self.project_mut(project_id) - && project.service_terminals != terminals { - project.service_terminals = terminals; - self.notify_data(cx); - } + && project.service_terminals != terminals + { + project.service_terminals = terminals; + self.notify_data(cx); + } } pub fn register_hook_terminal( @@ -547,15 +1607,19 @@ impl Workspace { project_id: &str, terminal_id: &str, entry: HookTerminalEntry, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { if let Some(project) = self.project_mut(project_id) { let label = entry.label.clone(); - project.hook_terminals.insert(terminal_id.to_string(), entry); + project + .hook_terminals + .insert(terminal_id.to_string(), entry); // Hook terminals are displayed in the dedicated HookPanel (not in the layout tree). // Set the terminal name so the panel can display it. - project.terminal_names.insert(terminal_id.to_string(), label); + project + .terminal_names + .insert(terminal_id.to_string(), label); self.notify_data(cx); } @@ -566,16 +1630,21 @@ impl Workspace { pub fn register_hook_results( &mut self, results: Vec<crate::hooks::HookTerminalResult>, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { for result in results { - self.register_hook_terminal(&result.project_id, &result.terminal_id, HookTerminalEntry { - label: result.label, - status: HookTerminalStatus::Running, - hook_type: result.hook_type.to_string(), - command: result.command, - cwd: result.cwd, - }, cx); + self.register_hook_terminal( + &result.project_id, + &result.terminal_id, + HookTerminalEntry { + label: result.label, + status: HookTerminalStatus::Running, + hook_type: result.hook_type.to_string(), + command: result.command, + cwd: result.cwd, + }, + cx, + ); } } @@ -583,7 +1652,7 @@ impl Workspace { &mut self, terminal_id: &str, status: HookTerminalStatus, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { for project in &mut self.data.projects { if let Some(entry) = project.hook_terminals.get_mut(terminal_id) { @@ -596,21 +1665,18 @@ impl Workspace { } } - pub fn remove_hook_terminal( - &mut self, - terminal_id: &str, - cx: &mut Context<Self>, - ) { + pub fn remove_hook_terminal(&mut self, terminal_id: &str, cx: &mut impl WorkspaceCx) { for project in &mut self.data.projects { if project.hook_terminals.remove(terminal_id).is_some() { if let Some(ref layout) = project.layout - && let Some(path) = layout.find_terminal_path(terminal_id) { - if path.is_empty() { - project.layout = None; - } else if let Some(ref mut layout) = project.layout { - layout.remove_at_path(&path); - } + && let Some(path) = layout.find_terminal_path(terminal_id) + { + if path.is_empty() { + project.layout = None; + } else if let Some(ref mut layout) = project.layout { + layout.remove_at_path(&path); } + } project.terminal_names.remove(terminal_id); self.notify_data(cx); return; @@ -631,7 +1697,9 @@ impl Workspace { /// Returns a reference to the `ProjectData` if found. pub fn find_project_for_terminal(&self, terminal_id: &str) -> Option<&ProjectData> { self.data.projects.iter().find(|p| { - p.layout.as_ref().is_some_and(|l| l.find_terminal_path(terminal_id).is_some()) + p.layout + .as_ref() + .is_some_and(|l| l.find_terminal_path(terminal_id).is_some()) }) } @@ -649,7 +1717,7 @@ impl Workspace { project_id: &str, old_id: &str, new_id: &str, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let Some(project) = self.project_mut(project_id) else { return; @@ -673,17 +1741,68 @@ impl Workspace { /// Register a pending worktree close that will execute when the hook terminal exits. pub fn register_pending_worktree_close(&mut self, pending: PendingWorktreeClose) { + // Set the wire-facing marker so clients render "Closing…" authoritatively + // for the whole before-remove hook window (not just off their optimistic + // flag), and so an abort that clears it heals the row. + self.set_project_closing_flag(&pending.project_id, true); self.lifecycle.register_pending_close(pending); } /// Take a pending worktree close for the given terminal ID (removes it). - pub fn take_pending_worktree_close(&mut self, terminal_id: &str) -> Option<PendingWorktreeClose> { + pub fn take_pending_worktree_close( + &mut self, + terminal_id: &str, + ) -> Option<PendingWorktreeClose> { self.lifecycle.take_pending_close(terminal_id) } /// Cancel a pending worktree close: remove it and unmark the project as closing. pub fn cancel_pending_worktree_close(&mut self, terminal_id: &str) { - self.lifecycle.cancel_pending_close(terminal_id); + if let Some(project_id) = self.lifecycle.cancel_pending_close(terminal_id) { + self.set_project_closing_flag(&project_id, false); + } + } + + /// Snapshot before-remove hook terminal IDs awaiting authoritative completion. + /// + /// The returned IDs are only candidates; callers must use + /// [`Self::abort_orphaned_worktree_close`] to atomically claim an orphan. + pub fn pending_worktree_close_terminal_ids(&self) -> Vec<String> { + self.lifecycle.pending_close_terminal_ids() + } + + /// Abort a pending close whose before-remove hook PTY vanished without an + /// authoritative exit result. This is intentionally state-only: it retains + /// the project and worktree, does not run removal hooks, and is idempotent. + /// + /// The lifecycle record, in-memory closing marker, wire-facing closing flag, + /// and still-running hook entry are healed in one workspace mutation. A + /// caller that sees `None` lost the race to a normal exit, rerun, data + /// replacement, or another watchdog pass. + pub fn abort_orphaned_worktree_close( + &mut self, + terminal_id: &str, + cx: &mut impl WorkspaceCx, + ) -> Option<AbortedWorktreeClose> { + let project_id = self.lifecycle.cancel_pending_close(terminal_id)?; + let project = self.data.projects.iter_mut().find(|p| p.id == project_id); + let project_name = project + .as_ref() + .map(|project| project.name.clone()) + .unwrap_or_else(|| project_id.clone()); + if let Some(project) = project { + project.is_closing = false; + if let Some(entry) = project.hook_terminals.get_mut(terminal_id) + && entry.status == HookTerminalStatus::Running + { + entry.status = HookTerminalStatus::Failed { exit_code: -1 }; + } + } + cx.notify(); + Some(AbortedWorktreeClose { + project_id, + project_name, + }) } /// Check if a project is currently being closed (hook running or removal in progress). @@ -712,7 +1831,10 @@ impl Workspace { // calling window's persisted WindowState. Fall back to main_window // if the targeted extra has been dropped between caller-resolve and // read (drop-race safety). - let window_state = self.data.window(window_id).unwrap_or(&self.data.main_window); + let window_state = self + .data + .window(window_id) + .unwrap_or(&self.data.main_window); compute_visible_projects( &self.data, focused_project_id, @@ -730,8 +1852,7 @@ impl Workspace { /// projects the user can actually see somewhere. pub fn all_visible_project_ids(&self) -> std::collections::HashSet<String> { let mut ids = std::collections::HashSet::new(); - for window in - std::iter::once(&self.data.main_window).chain(self.data.extra_windows.iter()) + for window in std::iter::once(&self.data.main_window).chain(self.data.extra_windows.iter()) { for p in compute_visible_projects(&self.data, None, false, window) { ids.insert(p.id.clone()); @@ -742,8 +1863,14 @@ impl Workspace { /// Get IDs of worktree children for a given parent project. pub fn worktree_child_ids(&self, parent_id: &str) -> Vec<String> { - self.data.projects.iter() - .filter(|p| p.worktree_info.as_ref().is_some_and(|w| w.parent_project_id == parent_id)) + self.data + .projects + .iter() + .filter(|p| { + p.worktree_info + .as_ref() + .is_some_and(|w| w.parent_project_id == parent_id) + }) .map(|p| p.id.clone()) .collect() } @@ -753,6 +1880,20 @@ 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<String> { self.project(project_id) @@ -800,17 +1941,19 @@ impl Workspace { /// Find which folder (if any) contains a given project pub fn folder_for_project(&self, project_id: &str) -> Option<&FolderData> { - self.data.folders.iter().find(|f| f.project_ids.contains(&project_id.to_string())) + self.data + .folders + .iter() + .find(|f| f.project_ids.contains(&project_id.to_string())) } /// Find folder for a project, falling back to the parent project's folder for worktrees. pub fn folder_for_project_or_parent(&self, project_id: &str) -> Option<&FolderData> { - self.folder_for_project(project_id) - .or_else(|| { - self.project(project_id) - .and_then(|p| p.worktree_info.as_ref()) - .and_then(|wt| self.folder_for_project(&wt.parent_project_id)) - }) + self.folder_for_project(project_id).or_else(|| { + self.project(project_id) + .and_then(|p| p.worktree_info.as_ref()) + .and_then(|wt| self.folder_for_project(&wt.parent_project_id)) + }) } /// Collect all detached terminals across all projects by traversing layout trees. @@ -835,7 +1978,12 @@ 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<Self>) { + 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<String> = self @@ -855,7 +2003,9 @@ impl Workspace { self.data.projects.retain(|p| !p.id.starts_with(&prefix)); self.data.folders.retain(|f| !f.id.starts_with(&prefix)); - self.data.project_order.retain(|id| !id.starts_with(&prefix)); + self.data + .project_order + .retain(|id| !id.starts_with(&prefix)); for project_id in &removed_project_ids { self.data.delete_project_scrub_all_windows(project_id); @@ -864,18 +2014,21 @@ impl Workspace { self.data.delete_folder_scrub_all_windows(folder_id); } - self.remote_sync.retain_not_starting_with(&prefix); + for project_id in self.remote_sync.retain_not_starting_with(&prefix) { + self.data.delete_project_scrub_all_windows(&project_id); + } if let Some(focused) = focus_manager.focused_project_id() - && focused.starts_with(&prefix) { - focus_manager.set_focused_project_id(None); - } + && focused.starts_with(&prefix) + { + focus_manager.set_focused_project_id(None); + } cx.notify(); } /// 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<Self>) { + pub fn notify_ui_only(&mut self, cx: &mut impl WorkspaceCx) { cx.notify(); } @@ -890,7 +2043,7 @@ impl Workspace { snapshots: &[crate::remote_apply::RemoteSnapshot], window_id: WindowId, focus_manager: &mut FocusManager, - cx: &mut Context<Self>, + cx: &mut impl WorkspaceCx, ) { let outcome = crate::remote_apply::apply_remote_snapshot( &mut self.data, @@ -899,6 +2052,23 @@ impl Workspace { window_id, ); + // Heal the optimistic client-side "closing" flag against the daemon's + // authoritative mirror. The dialog marks a project closing locally before + // dispatch for instant feedback; the daemon then sets `is_closing` on the + // mirrored project while the before-remove hook runs and clears it on + // abort. Keep the local flag only while the mirror still reports closing — + // any project the mirror reports as not-closing (hook aborted) or that + // vanished when the close completed drops its local flag, so an aborted + // close no longer strands the row dimmed "Closing…" forever. + let still_closing: std::collections::HashSet<String> = self + .data + .projects + .iter() + .filter(|p| p.is_closing) + .map(|p| p.id.clone()) + .collect(); + self.lifecycle.retain_closing(&still_closing); + for target in outcome.focus_targets { self.set_focused_terminal(focus_manager, target.project_id, target.layout_path, cx); } @@ -909,47 +2079,301 @@ 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<F>(&mut self, project_id: &str, path: &[usize], cx: &mut Context<Self>, f: F) -> bool + pub fn with_layout_node<F>( + &mut self, + project_id: &str, + path: &[usize], + cx: &mut impl WorkspaceCx, + f: F, + ) -> bool where F: FnOnce(&mut LayoutNode) -> bool, { if let Some(project) = self.project_mut(project_id) && let Some(ref mut layout) = project.layout - && let Some(node) = layout.get_at_path_mut(path) - && f(node) { - self.notify_data(cx); - return true; - } + && let Some(node) = layout.get_at_path_mut(path) + && f(node) + { + self.notify_data(cx); + return true; + } false } /// Helper to mutate a project, with automatic notify. /// Returns true if the mutation was applied. - pub fn with_project<F>(&mut self, project_id: &str, cx: &mut Context<Self>, f: F) -> bool + pub fn with_project<F>(&mut self, project_id: &str, cx: &mut impl WorkspaceCx, f: F) -> bool where F: FnOnce(&mut ProjectData) -> bool, { if let Some(project) = self.project_mut(project_id) - && f(project) { - self.notify_data(cx); - return true; - } + && f(project) + { + self.notify_data(cx); + return true; + } false } } - #[cfg(test)] mod workspace_tests { + use super::normalize_unresolved_component; + use crate::context::WorkspaceCx; + use crate::settings::HooksConfig; use crate::state::{ - FolderData, LayoutNode, ProjectData, SplitDirection, WindowId, WindowState, Workspace, - WorkspaceData, WorktreeMetadata, + FolderData, HookTerminalEntry, HookTerminalStatus, LayoutNode, ProjectData, SplitDirection, + WindowId, WindowState, Workspace, WorkspaceData, WorktreeMetadata, }; - use okena_terminal::shell_config::ShellType; use okena_core::theme::FolderColor; - use crate::settings::HooksConfig; + use okena_terminal::session_backend::SessionBackend; + use okena_terminal::shell_config::ShellType; use std::collections::HashMap; + #[derive(Default)] + struct RecordingCx { + notifications: usize, + } + + impl WorkspaceCx for RecordingCx { + fn notify(&mut self) { + self.notifications += 1; + } + + fn refresh_views(&mut self) {} + + fn hook_runner(&self) -> Option<okena_hooks::HookRunner> { + None + } + + fn hook_monitor(&self) -> Option<okena_hooks::HookMonitor> { + None + } + } + + #[test] + fn orphaned_worktree_close_aborts_atomically_and_idempotently() { + let mut project = make_project("wt1"); + project.name = "Feature".into(); + project.hook_terminals.insert( + "hook-1".into(), + HookTerminalEntry { + label: "Before remove".into(), + status: HookTerminalStatus::Running, + hook_type: "before_worktree_remove".into(), + command: "true".into(), + cwd: "/tmp".into(), + }, + ); + let mut workspace = Workspace::new(make_workspace_data(vec![project], vec!["wt1"])); + workspace.register_pending_worktree_close(crate::state::PendingWorktreeClose { + project_id: "wt1".into(), + hook_terminal_id: "hook-1".into(), + branch: "feature".into(), + main_repo_path: "/tmp".into(), + }); + assert!(workspace.is_project_closing("wt1")); + assert!(workspace.project("wt1").unwrap().is_closing); + + let mut cx = RecordingCx::default(); + assert_eq!( + workspace.abort_orphaned_worktree_close("hook-1", &mut cx), + Some(crate::state::AbortedWorktreeClose { + project_id: "wt1".into(), + project_name: "Feature".into(), + }) + ); + assert!(!workspace.is_project_closing("wt1")); + let project = workspace.project("wt1").expect("project retained"); + assert!(!project.is_closing); + assert!(matches!( + project.hook_terminals["hook-1"].status, + HookTerminalStatus::Failed { exit_code: -1 } + )); + assert_eq!(cx.notifications, 1); + assert!(workspace.pending_worktree_close_terminal_ids().is_empty()); + assert!( + workspace + .abort_orphaned_worktree_close("hook-1", &mut cx) + .is_none() + ); + assert_eq!(cx.notifications, 1, "second claim is a no-op"); + } + + #[test] + fn terminal_backend_migration_gate_is_exclusive_and_epoch_fenced() { + let mut workspace = Workspace::new(WorkspaceData::empty()); + let initial_epoch = workspace.data_replacement_epoch(); + let migration = workspace + .begin_terminal_backend_migration(SessionBackend::None, &ShellType::Default) + .expect("begin migration"); + let migration_epoch = migration.epoch; + + assert_eq!(migration_epoch, initial_epoch + 1); + assert_eq!( + workspace.terminal_backend_migration_epoch(), + Some(migration_epoch) + ); + assert!( + workspace + .begin_terminal_backend_migration(SessionBackend::None, &ShellType::Default) + .is_err() + ); + + let mut cx = RecordingCx::default(); + assert!(!workspace.finish_terminal_backend_migration(migration_epoch + 1, &mut cx)); + assert_eq!(cx.notifications, 0); + assert!(workspace.finish_terminal_backend_migration(migration_epoch, &mut cx)); + assert_eq!(workspace.terminal_backend_migration_epoch(), None); + assert_eq!(cx.notifications, 1); + } + + #[test] + fn backend_migration_clears_and_restores_only_ordinary_terminal_ownership() { + let mut project = make_project("p1"); + project + .service_terminals + .insert("web".to_string(), "service-1".to_string()); + project.hook_terminals.insert( + "hook-1".to_string(), + HookTerminalEntry { + label: "hook".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "project.on_open".to_string(), + command: "echo hook".to_string(), + cwd: "/tmp/test".to_string(), + }, + ); + project + .terminal_names + .insert("term_p1".to_string(), "ordinary".to_string()); + project + .terminal_names + .insert("hook-1".to_string(), "hook".to_string()); + if let Some(LayoutNode::Terminal { + minimized, + detached, + .. + }) = &mut project.layout + { + *minimized = true; + *detached = true; + } + let mut workspace = Workspace::new(make_workspace_data(vec![project], vec!["p1"])); + + let migration = workspace + .begin_terminal_backend_migration(SessionBackend::None, &ShellType::Default) + .expect("begin migration"); + + let project = workspace.project("p1").expect("project"); + assert!(matches!( + project.layout.as_ref(), + Some(LayoutNode::Terminal { + terminal_id: None, + minimized: true, + detached: true, + .. + }) + )); + assert!(project.service_terminals.is_empty()); + assert!(project.hook_terminals.is_empty()); + assert_eq!( + project.terminal_names.get("term_p1").map(String::as_str), + Some("ordinary") + ); + assert!(!project.terminal_names.contains_key("hook-1")); + assert_eq!(migration.hook_terminal_ids, vec!["hook-1"]); + assert_eq!( + migration + .teardown_sessions + .iter() + .map(|session| session.terminal_id.as_str()) + .collect::<Vec<_>>(), + vec!["hook-1", "service-1", "term_p1"] + ); + + workspace + .restore_terminal_backend_migration_slots(&migration) + .expect("restore slots"); + assert!(matches!( + workspace.project("p1").and_then(|project| project.layout.as_ref()), + Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id), + minimized: true, + detached: true, + .. + }) if terminal_id == "term_p1" + )); + } + + #[test] + fn case_insensitive_unresolved_components_share_identity() { + assert_eq!( + normalize_unresolved_component(&"NewWorktree".into(), false), + normalize_unresolved_component(&"newworktree".into(), false) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn nonexistent_suffix_follows_volume_case_semantics() { + let fixture = + std::env::temp_dir().join(format!("okena-case-volume-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&fixture).unwrap(); + if !super::filesystem_is_case_sensitive(&fixture) { + assert_eq!( + Workspace::physical_path_identity(&fixture.join("NewWorktree/project")), + Workspace::physical_path_identity(&fixture.join("newworktree/PROJECT")) + ); + } + let _ = std::fs::remove_dir_all(fixture); + } + + #[cfg(windows)] + #[test] + fn subst_aliases_share_filesystem_identity() { + struct SubstGuard(String); + + impl Drop for SubstGuard { + fn drop(&mut self) { + let _ = std::process::Command::new("subst") + .args([&self.0, "/D"]) + .status(); + } + } + + let fixture = std::env::temp_dir().join(format!( + "okena-subst-identity-test-{}", + uuid::Uuid::new_v4() + )); + let root = fixture.join("worktree"); + std::fs::create_dir_all(&root).unwrap(); + let fixture_text = fixture.to_string_lossy().into_owned(); + let Some(drive) = (b'D'..=b'Z').rev().find_map(|letter| { + let drive = format!("{}:", char::from(letter)); + std::process::Command::new("subst") + .args([&drive, &fixture_text]) + .status() + .ok() + .filter(|status| status.success()) + .map(|_| drive) + }) else { + let _ = std::fs::remove_dir_all(fixture); + return; + }; + let guard = SubstGuard(drive.clone()); + let mapped_root = std::path::PathBuf::from(format!("{drive}\\worktree")); + + let real = Workspace::physical_path_identity(&root); + let mapped = Workspace::physical_path_identity(&mapped_root); + let mapped_child = Workspace::physical_path_identity(&mapped_root.join("packages/app")); + assert_eq!(real, mapped); + assert!(mapped_child.starts_with(&real)); + + drop(guard); + let _ = std::fs::remove_dir_all(fixture); + } + fn make_project(id: &str) -> ProjectData { ProjectData { id: id.to_string(), @@ -975,6 +2399,8 @@ mod workspace_tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -995,6 +2421,131 @@ mod workspace_tests { } } + #[test] + fn project_runtime_quiesce_batch_is_all_or_nothing() { + let mut blocked = make_project("blocked"); + blocked.is_remote = true; + let mut workspace = Workspace::new(make_workspace_data( + vec![make_project("ready"), blocked], + vec!["ready", "blocked"], + )); + let mut cx = RecordingCx::default(); + + let error = workspace + .begin_project_runtimes_quiesce( + &["ready".to_string(), "blocked".to_string()], + &ShellType::Default, + SessionBackend::None, + true, + &mut cx, + ) + .expect_err("remote descendant rejects the full batch"); + + assert!(error.contains("remote project")); + assert!(matches!( + workspace.project("ready").and_then(|project| project.layout.as_ref()), + Some(LayoutNode::Terminal { + terminal_id: Some(terminal_id), + .. + }) if terminal_id == "term_ready" + )); + assert!(!workspace.is_project_closing("ready")); + assert_eq!(cx.notifications, 0); + } + + #[test] + fn project_runtime_quiesce_preserves_completed_hooks_and_fences_aba() { + let mut project = make_project("p1"); + project.hook_terminals.insert( + "completed-hook".to_string(), + HookTerminalEntry { + label: "completed".to_string(), + status: HookTerminalStatus::Succeeded, + hook_type: "project.on_open".to_string(), + command: "echo done".to_string(), + cwd: "/tmp/test".to_string(), + }, + ); + project.hook_terminals.insert( + "running-hook".to_string(), + HookTerminalEntry { + label: "running".to_string(), + status: HookTerminalStatus::Running, + hook_type: "project.on_open".to_string(), + command: "sleep 10".to_string(), + cwd: "/tmp/test".to_string(), + }, + ); + project + .terminal_names + .insert("completed-hook".to_string(), "completed".to_string()); + project + .terminal_names + .insert("running-hook".to_string(), "running".to_string()); + let mut workspace = Workspace::new(make_workspace_data(vec![project], vec!["p1"])); + let mut cx = RecordingCx::default(); + + let first = workspace + .begin_project_runtime_quiesce( + "p1", + &ShellType::Default, + SessionBackend::None, + false, + &mut cx, + ) + .expect("quiesce project"); + assert_eq!(first.hook_terminal_ids, vec!["running-hook"]); + assert_eq!( + first.preserved_registry_terminal_ids, + vec!["completed-hook"] + ); + let project = workspace.project("p1").expect("project"); + assert!(project.hook_terminals.contains_key("completed-hook")); + assert!(!project.hook_terminals.contains_key("running-hook")); + assert!(project.terminal_names.contains_key("completed-hook")); + assert!(!project.terminal_names.contains_key("running-hook")); + + workspace.finish_project_runtime_recovery(&first, &mut cx); + let second = workspace + .begin_project_runtime_quiesce( + "p1", + &ShellType::Default, + SessionBackend::None, + false, + &mut cx, + ) + .expect("quiesce project again"); + assert_ne!( + first.runtime_quiesce_generation, + second.runtime_quiesce_generation + ); + workspace.finish_project_runtime_recovery(&first, &mut cx); + assert!(workspace.project_runtime_quiesce_is_current(&second)); + assert!(workspace.is_project_closing("p1")); + + let mut focus = crate::focus::FocusManager::new(); + workspace.replace_data( + &mut focus, + make_workspace_data(vec![make_project("p1")], vec!["p1"]), + &mut cx, + ); + let replacement = workspace + .begin_project_runtime_quiesce( + "p1", + &ShellType::Default, + SessionBackend::None, + false, + &mut cx, + ) + .expect("quiesce replacement project"); + assert_eq!( + first.runtime_quiesce_generation, replacement.runtime_quiesce_generation, + "replacement tracker may reuse generations" + ); + workspace.finish_project_runtime_recovery(&first, &mut cx); + assert!(workspace.project_runtime_quiesce_is_current(&replacement)); + } + #[test] fn test_visible_projects_filters_hidden() { let mut data = make_workspace_data( @@ -1022,17 +2573,19 @@ mod workspace_tests { let mut fm = crate::focus::FocusManager::new(); fm.set_focused_project_id(Some("p3".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 1); assert_eq!(visible[0].id, "p3"); } #[test] fn test_visible_projects_with_folder() { - let mut data = make_workspace_data( - vec![make_project("p1"), make_project("p2")], - vec!["f1"], - ); + let mut data = + make_workspace_data(vec![make_project("p1"), make_project("p2")], vec!["f1"]); data.folders = vec![FolderData { id: "f1".to_string(), name: "Folder".to_string(), @@ -1120,8 +2673,10 @@ mod workspace_tests { fn test_visible_projects_with_folder_filter() { let mut data = make_workspace_data( vec![ - make_project("p1"), make_project("p2"), - make_project("p3"), make_project("p4"), + make_project("p1"), + make_project("p2"), + make_project("p3"), + make_project("p4"), make_project("p5"), ], vec!["f1", "f2", "p5"], @@ -1131,13 +2686,13 @@ mod workspace_tests { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string(), "p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p3".to_string(), "p4".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; @@ -1161,10 +2716,7 @@ mod workspace_tests { #[test] fn test_folder_filter_hides_top_level_projects() { let mut data = make_workspace_data( - vec![ - make_project("p1"), make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["f1", "p3"], ); data.folders = vec![FolderData { @@ -1203,27 +2755,36 @@ mod workspace_tests { branch_name: "branch-w2".to_string(), }); - let data = make_workspace_data( - vec![p1, w1, w2, make_project("p2")], - vec!["p1", "p2"], - ); + let data = make_workspace_data(vec![p1, w1, w2, make_project("p2")], vec!["p1", "p2"]); let ws = Workspace::new(data); let mut fm = crate::focus::FocusManager::new(); fm.set_focused_project_id(Some("p1".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 3); assert_eq!(visible[0].id, "p1"); assert_eq!(visible[1].id, "w1"); assert_eq!(visible[2].id, "w2"); fm.set_focused_project_id(Some("w1".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 1); assert_eq!(visible[0].id, "w1"); fm.set_focused_project_id(None); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 4); } @@ -1248,10 +2809,7 @@ mod workspace_tests { branch_name: "branch-w2".to_string(), }); - let mut data = make_workspace_data( - vec![p1, w1, w2, make_project("p2")], - vec!["f1", "p2"], - ); + let mut data = make_workspace_data(vec![p1, w1, w2, make_project("p2")], vec!["f1", "p2"]); data.folders = vec![FolderData { id: "f1".to_string(), name: "Folder".to_string(), @@ -1285,10 +2843,8 @@ mod workspace_tests { let mut p1 = make_project("p1"); p1.worktree_ids = vec!["w1".to_string()]; - let mut data = make_workspace_data( - vec![p1, w1, make_project("p2")], - vec!["f1", "w1", "p2"], - ); + let mut data = + make_workspace_data(vec![p1, w1, make_project("p2")], vec!["f1", "w1", "p2"]); data.folders = vec![FolderData { id: "f1".to_string(), name: "Folder".to_string(), @@ -1327,13 +2883,13 @@ mod workspace_tests { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; @@ -1361,23 +2917,21 @@ mod workspace_tests { let mut p2 = make_project("p2"); p2.worktree_ids = vec!["w1".to_string()]; - let mut data = make_workspace_data( - vec![make_project("p1"), p2, w1], - vec!["w1", "f1", "f2"], - ); + let mut data = + make_workspace_data(vec![make_project("p1"), p2, w1], vec!["w1", "f1", "f2"]); data.main_window.hidden_project_ids.insert("p2".to_string()); data.folders = vec![ FolderData { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; @@ -1404,23 +2958,21 @@ mod workspace_tests { let mut p1 = make_project("p1"); p1.worktree_ids = vec!["w1".to_string()]; - let mut data = make_workspace_data( - vec![p1, make_project("p2"), w1], - vec!["f1", "w1", "f2"], - ); + let mut data = + make_workspace_data(vec![p1, make_project("p2"), w1], vec!["f1", "w1", "f2"]); data.main_window.hidden_project_ids.insert("p1".to_string()); data.folders = vec![ FolderData { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; @@ -1452,13 +3004,13 @@ mod workspace_tests { id: "f1".to_string(), name: "Folder 1".to_string(), project_ids: vec!["p1".to_string(), "w1".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, FolderData { id: "f2".to_string(), name: "Folder 2".to_string(), project_ids: vec!["p2".to_string()], - folder_color: FolderColor::default(), + folder_color: FolderColor::default(), }, ]; @@ -1483,10 +3035,7 @@ mod workspace_tests { branch_name: "branch-w1".to_string(), }); - let mut data = make_workspace_data( - vec![make_project("p1"), w1], - vec!["p1", "w1"], - ); + let mut data = make_workspace_data(vec![make_project("p1"), w1], vec!["p1", "w1"]); data.main_window.hidden_project_ids.insert("p1".to_string()); let ws = Workspace::new(data); @@ -1498,10 +3047,7 @@ mod workspace_tests { #[test] fn test_folder_filter_with_focus_override() { let mut data = make_workspace_data( - vec![ - make_project("p1"), make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["f1", "p3"], ); data.folders = vec![FolderData { @@ -1517,7 +3063,11 @@ mod workspace_tests { let mut fm = crate::focus::FocusManager::new(); fm.set_focused_project_id(Some("p3".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 1); assert_eq!(visible[0].id, "p3"); } @@ -1606,7 +3156,11 @@ mod workspace_tests { let mut fm = crate::focus::FocusManager::new(); fm.set_focused_project_id(Some("parent".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 3); assert_eq!(visible[0].id, "parent"); assert_eq!(visible[1].id, "wt1"); @@ -1638,7 +3192,11 @@ mod workspace_tests { let mut fm = crate::focus::FocusManager::new(); fm.set_focused_project_id(Some("wt1".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 1); assert_eq!(visible[0].id, "wt1"); } @@ -1668,12 +3226,20 @@ mod workspace_tests { let mut fm = crate::focus::FocusManager::new(); fm.set_focused_project_id_individual(Some("parent".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 1); assert_eq!(visible[0].id, "parent"); fm.set_focused_project_id(Some("parent".to_string())); - let visible = ws.visible_projects(WindowId::Main, fm.focused_project_id(), fm.is_focus_individual()); + let visible = ws.visible_projects( + WindowId::Main, + fm.focused_project_id(), + fm.is_focus_individual(), + ); assert_eq!(visible.len(), 3); } @@ -1704,13 +3270,16 @@ 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}; use crate::settings::HooksConfig; - use okena_terminal::shell_config::ShellType; + use crate::state::{ + HookTerminalEntry, HookTerminalStatus, LayoutNode, ProjectData, ProjectLayoutMode, + WindowBounds, WindowId, WindowState, Workspace, WorkspaceData, + }; + use gpui::AppContext as _; use okena_core::theme::FolderColor; + use okena_terminal::shell_config::ShellType; use std::collections::HashMap; fn make_project(id: &str) -> ProjectData { @@ -1738,6 +3307,8 @@ mod gpui_tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -1814,7 +3385,6 @@ mod gpui_tests { }); } - #[gpui::test] fn test_replace_data_resets_focus(cx: &mut gpui::TestAppContext) { use crate::focus::FocusManager; @@ -1826,8 +3396,12 @@ mod gpui_tests { fm.set_focused_project_id(Some("p1".to_string())); assert!(fm.focused_project_id().is_some()); - let new_data = make_workspace_data(vec![make_project("p2")], vec!["p2"]); + let mut new_data = make_workspace_data(vec![make_project("p2")], vec!["p2"]); + new_data.projects[0].is_closing = true; workspace.update(cx, |ws: &mut Workspace, cx| { + ws.mark_creating_project("p2"); + ws.mark_closing_project("p2"); + ws.mark_worktree_removing("/tmp/p2"); ws.replace_data(&mut fm, new_data, cx); }); @@ -1835,6 +3409,12 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { assert_eq!(ws.data().projects.len(), 1); assert_eq!(ws.data().projects[0].id, "p2"); + assert_eq!(ws.data_version(), 1); + assert_eq!(ws.data_replacement_epoch(), 1); + assert!(!ws.is_creating_project("p2")); + assert!(!ws.is_project_closing("p2")); + assert!(!ws.data().projects[0].is_closing); + assert!(!ws.lifecycle.is_worktree_removing("/tmp/p2")); }); } @@ -1855,7 +3435,12 @@ mod gpui_tests { }); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_overview_visibility(&mut crate::focus::FocusManager::new(), WindowId::Main, "p1", cx); + ws.toggle_project_overview_visibility( + &mut crate::focus::FocusManager::new(), + WindowId::Main, + "p1", + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { @@ -1914,8 +3499,16 @@ mod gpui_tests { assert_eq!(ws.data.folders.len(), 1); assert_eq!(ws.data.folders[0].id, "remote:conn2:folder2"); - assert!(!ws.data.project_order.contains(&"remote:conn1:folder1".to_string())); - assert!(ws.data.project_order.contains(&"remote:conn2:folder2".to_string())); + assert!( + !ws.data + .project_order + .contains(&"remote:conn1:folder1".to_string()) + ); + assert!( + ws.data + .project_order + .contains(&"remote:conn2:folder2".to_string()) + ); }); } @@ -1931,7 +3524,9 @@ mod gpui_tests { vec![local, remote1, remote2], vec!["local1", "remote:conn1:folder1"], ); - data.main_window.hidden_project_ids.insert("remote:conn1:p2".to_string()); + data.main_window + .hidden_project_ids + .insert("remote:conn1:p2".to_string()); data.folders.push(FolderData { id: "remote:conn1:folder1".to_string(), name: "Server 1".to_string(), @@ -2012,7 +3607,10 @@ mod gpui_tests { assert!(p.hook_terminals.contains_key("hook-1")); assert!(p.hook_terminals.contains_key("hook-2")); assert!(p.hook_terminals.contains_key("hook-3")); - assert!(matches!(p.layout.as_ref().unwrap(), LayoutNode::Terminal { .. })); + assert!(matches!( + p.layout.as_ref().unwrap(), + LayoutNode::Terminal { .. } + )); }); } @@ -2026,7 +3624,12 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert!(ws.project("p1").unwrap().hook_terminals.contains_key("hook-1")); + assert!( + ws.project("p1") + .unwrap() + .hook_terminals + .contains_key("hook-1") + ); }); workspace.update(cx, |ws: &mut Workspace, cx| { @@ -2046,17 +3649,27 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { - ws.register_hook_terminal("p1", "hook-1", HookTerminalEntry { - label: "on_project_open (feature/foo)".to_string(), - status: HookTerminalStatus::Running, - hook_type: "on_project_open".to_string(), - command: "echo test".to_string(), - cwd: ".".to_string(), - }, cx); + ws.register_hook_terminal( + "p1", + "hook-1", + HookTerminalEntry { + label: "on_project_open (feature/foo)".to_string(), + status: HookTerminalStatus::Running, + hook_type: "on_project_open".to_string(), + command: "echo test".to_string(), + cwd: ".".to_string(), + }, + cx, + ); }); workspace.read_with(cx, |ws: &Workspace, _cx| { - let name = ws.project("p1").unwrap().terminal_names.get("hook-1").unwrap(); + let name = ws + .project("p1") + .unwrap() + .terminal_names + .get("hook-1") + .unwrap(); assert_eq!(name, "on_project_open (feature/foo)"); }); } @@ -2121,7 +3734,10 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { assert_eq!(ws.data().main_window.folder_filter.as_deref(), Some("f1")); - assert_eq!(ws.active_folder_filter(WindowId::Main).map(|s| s.as_str()), Some("f1")); + assert_eq!( + ws.active_folder_filter(WindowId::Main).map(|s| s.as_str()), + Some("f1") + ); assert_eq!(ws.data_version(), 1); }); } @@ -2296,7 +3912,10 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.data().main_window.project_widths.get("p1").copied(), Some(0.42)); + assert_eq!( + ws.data().main_window.project_widths.get("p1").copied(), + Some(0.42) + ); assert_eq!(ws.data_version(), 1); }); } @@ -2311,14 +3930,20 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.project_layout_mode(WindowId::Main), ProjectLayoutMode::Columns); + assert_eq!( + ws.project_layout_mode(WindowId::Main), + ProjectLayoutMode::Columns + ); }); workspace.update(cx, |ws: &mut Workspace, cx| { ws.toggle_project_layout_mode(WindowId::Main, cx); }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.project_layout_mode(WindowId::Main), ProjectLayoutMode::Rows); + assert_eq!( + ws.project_layout_mode(WindowId::Main), + ProjectLayoutMode::Rows + ); assert_eq!(ws.data_version(), 1); }); @@ -2326,16 +3951,42 @@ mod gpui_tests { ws.toggle_project_layout_mode(WindowId::Main, cx); }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.project_layout_mode(WindowId::Main), ProjectLayoutMode::Columns); + assert_eq!( + ws.project_layout_mode(WindowId::Main), + ProjectLayoutMode::Columns + ); assert_eq!(ws.data_version(), 2); }); } #[gpui::test] - fn toggle_project_layout_mode_transposes_visible_project_splits(cx: &mut gpui::TestAppContext) { - // Flipping the grid orientation must also deeply transpose every split - // inside the window's visible projects: horizontal <-> vertical, nested - // and inside tab groups, while leaving sizes and tab structure intact. + fn toggle_project_layout_mode_is_scoped_to_its_window(cx: &mut gpui::TestAppContext) { + let extra = WindowState::default(); + let extra_id = extra.id; + let mut data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); + data.extra_windows.push(extra); + let workspace = cx.new(|_cx| Workspace::new(data)); + + workspace.update(cx, |ws: &mut Workspace, cx| { + ws.toggle_project_layout_mode(WindowId::Main, cx); + }); + + workspace.read_with(cx, |ws: &Workspace, _cx| { + assert_eq!( + ws.project_layout_mode(WindowId::Main), + ProjectLayoutMode::Rows + ); + assert_eq!( + ws.project_layout_mode(WindowId::Extra(extra_id)), + ProjectLayoutMode::Columns + ); + }); + } + + #[gpui::test] + fn toggle_project_layout_mode_keeps_canonical_splits_unchanged(cx: &mut gpui::TestAppContext) { + // Orientation is per-window presentation; the shared daemon mirror must + // remain canonical so a state snapshot and another window cannot fight it. use crate::state::SplitDirection; let nested = LayoutNode::Split { @@ -2362,45 +4013,27 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - let LayoutNode::Split { direction, sizes, children } = layout else { + let LayoutNode::Split { + direction, + sizes, + children, + } = layout + else { panic!("expected outer split"); }; - assert_eq!(*direction, SplitDirection::Vertical, "outer flipped"); + assert_eq!( + *direction, + SplitDirection::Horizontal, + "outer stays canonical" + ); assert_eq!(sizes, &vec![0.6, 0.4], "sizes preserved"); - let LayoutNode::Split { direction: inner, .. } = &children[1] else { + let LayoutNode::Split { + direction: inner, .. + } = &children[1] + else { panic!("expected nested split"); }; - assert_eq!(*inner, SplitDirection::Horizontal, "nested flipped"); - }); - } - - #[gpui::test] - fn toggle_project_layout_mode_leaves_hidden_projects_untouched(cx: &mut gpui::TestAppContext) { - // A project hidden in this window is not part of its grid, so toggling - // the window orientation must not transpose that project's panes (it - // may be shown — at its own orientation — in another window). - use crate::state::SplitDirection; - - let mut hidden = make_project("hidden"); - hidden.layout = Some(LayoutNode::Split { - direction: SplitDirection::Horizontal, - sizes: vec![0.5, 0.5], - children: vec![LayoutNode::new_terminal(), LayoutNode::new_terminal()], - }); - let mut data = make_workspace_data(vec![make_project("p1"), hidden], vec!["p1", "hidden"]); - data.main_window.hidden_project_ids.insert("hidden".to_string()); - let workspace = cx.new(|_cx| Workspace::new(data)); - - workspace.update(cx, |ws: &mut Workspace, cx| { - ws.toggle_project_layout_mode(WindowId::Main, cx); - }); - - workspace.read_with(cx, |ws: &Workspace, _cx| { - let layout = ws.project("hidden").unwrap().layout.as_ref().unwrap(); - let LayoutNode::Split { direction, .. } = layout else { - panic!("expected split"); - }; - assert_eq!(*direction, SplitDirection::Horizontal, "hidden project untouched"); + assert_eq!(*inner, SplitDirection::Vertical, "nested stays canonical"); }); } @@ -2420,7 +4053,10 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.data().main_window.project_widths.get("p1").copied(), Some(0.75)); + assert_eq!( + ws.data().main_window.project_widths.get("p1").copied(), + Some(0.75) + ); assert_eq!(ws.data_version(), 2); }); } @@ -2492,7 +4128,10 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.data().main_window.folder_collapsed.get("f1"), Some(&true)); + assert_eq!( + ws.data().main_window.folder_collapsed.get("f1"), + Some(&true) + ); assert_eq!(ws.data_version(), 1); }); } @@ -2505,7 +4144,9 @@ mod gpui_tests { // insert (which would leave Some(false) tombstones bloating the on- // disk shape over time). let mut data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); - data.main_window.folder_collapsed.insert("f1".to_string(), true); + data.main_window + .folder_collapsed + .insert("f1".to_string(), true); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { @@ -2697,9 +4338,8 @@ mod gpui_tests { let data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); - let returned = workspace.update(cx, |ws: &mut Workspace, cx| { - ws.spawn_extra_window(None, cx) - }); + let returned = + workspace.update(cx, |ws: &mut Workspace, cx| ws.spawn_extra_window(None, cx)); workspace.read_with(cx, |ws: &Workspace, _cx| { assert_eq!(ws.data().extra_windows.len(), 1); @@ -2724,9 +4364,7 @@ mod gpui_tests { ); let workspace = cx.new(|_cx| Workspace::new(data)); - let id = workspace.update(cx, |ws: &mut Workspace, cx| { - ws.spawn_extra_window(None, cx) - }); + let id = workspace.update(cx, |ws: &mut Workspace, cx| ws.spawn_extra_window(None, cx)); workspace.read_with(cx, |ws: &Workspace, _cx| { let spawned = ws.data().window(id).unwrap(); @@ -2868,12 +4506,17 @@ mod gpui_tests { let workspace = cx.new(|_cx| Workspace::new(data)); workspace.read_with(cx, |ws: &Workspace, _cx| { - assert_eq!(ws.active_folder_filter(WindowId::Main).map(|s| s.as_str()), Some("f1")); + assert_eq!( + ws.active_folder_filter(WindowId::Main).map(|s| s.as_str()), + Some("f1") + ); }); } #[gpui::test] - fn active_folder_filter_extra_reads_targeted_extras_folder_filter(cx: &mut gpui::TestAppContext) { + fn active_folder_filter_extra_reads_targeted_extras_folder_filter( + cx: &mut gpui::TestAppContext, + ) { // Per-window viewport model: targeting `WindowId::Extra(uuid)` reads // from that extra's `WindowState::folder_filter` (NOT main's). The // fixture pre-populates main + a sibling extra with their own @@ -2897,11 +4540,13 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { assert_eq!( - ws.active_folder_filter(WindowId::Extra(extra_a_id)).map(|s| s.as_str()), + ws.active_folder_filter(WindowId::Extra(extra_a_id)) + .map(|s| s.as_str()), Some("extra_a_folder"), ); assert_eq!( - ws.active_folder_filter(WindowId::Extra(extra_b_id)).map(|s| s.as_str()), + ws.active_folder_filter(WindowId::Extra(extra_b_id)) + .map(|s| s.as_str()), Some("extra_b_folder"), ); // Main is unchanged by the extras' reads. diff --git a/crates/okena-workspace/src/toast.rs b/crates/okena-workspace/src/toast.rs index 0349824b7..e030cfca5 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,27 +21,29 @@ 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<Toast>) { while queue.len() > MAX_VISIBLE_TOASTS { - let idx = queue - .iter() - .position(|t| t.actions.is_empty()) - .unwrap_or(0); + let idx = queue.iter().position(|t| t.actions.is_empty()).unwrap_or(0); queue.remove(idx); } } +#[cfg(feature = "gpui")] #[derive(Clone)] pub struct ToastManager(pub Arc<Mutex<Vec<Toast>>>); +#[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()))) @@ -112,11 +118,14 @@ impl ToastManager { #[cfg(test)] mod tests { - use super::{trim_to_cap, Toast, ToastAction, ToastActionStyle, MAX_VISIBLE_TOASTS}; + use super::{MAX_VISIBLE_TOASTS, Toast, ToastAction, ToastActionStyle, trim_to_cap}; fn action_toast(msg: &str) -> Toast { - Toast::info(msg) - .with_actions(vec![ToastAction::new("undo", "Undo", ToastActionStyle::Primary)]) + Toast::info(msg).with_actions(vec![ToastAction::new( + "undo", + "Undo", + ToastActionStyle::Primary, + )]) } #[test] @@ -140,6 +149,9 @@ mod tests { .collect(); trim_to_cap(&mut q); assert_eq!(q.len(), MAX_VISIBLE_TOASTS); - assert_eq!(q[0].message, "a1", "oldest action toast dropped as fallback"); + assert_eq!( + q[0].message, "a1", + "oldest action toast dropped as fallback" + ); } } diff --git a/crates/okena-workspace/src/visibility.rs b/crates/okena-workspace/src/visibility.rs index 4c0caaee4..5d89392ca 100644 --- a/crates/okena-workspace/src/visibility.rs +++ b/crates/okena-workspace/src/visibility.rs @@ -40,9 +40,7 @@ pub fn compute_visible_projects<'a>( .filter(|p| { p.worktree_info .as_ref() - .is_some_and(|wi| { - folder_project_ids.contains(wi.parent_project_id.as_str()) - }) + .is_some_and(|wi| folder_project_ids.contains(wi.parent_project_id.as_str())) }) .map(|p| p.id.as_str()) .collect() @@ -55,24 +53,25 @@ pub fn compute_visible_projects<'a>( if let Some(folder) = data.folders.iter().find(|f| f.id == *id) { // When folder filter is active, skip folders that don't match if let Some(filter_id) = folder_filter - && &folder.id != filter_id { - // Still allow the focused project (or its worktree) through - if focused.is_some() { - for pid in &folder.project_ids { - if let Some(p) = data.projects.iter().find(|p| &p.id == pid) { - push_project_with_worktrees( - data, - p, - focused, - focus_individual, - window, - &mut result, - ); - } + && &folder.id != filter_id + { + // Still allow the focused project (or its worktree) through + if focused.is_some() { + for pid in &folder.project_ids { + if let Some(p) = data.projects.iter().find(|p| &p.id == pid) { + push_project_with_worktrees( + data, + p, + focused, + focus_individual, + window, + &mut result, + ); } } - continue; } + continue; + } // Folder: include its projects and their worktree children. // Worktree children live in project_order (not folder.project_ids), // so we expand them here to keep them positioned within their folder's section. @@ -132,9 +131,12 @@ pub fn compute_visible_projects<'a>( let mut map: HashMap<&str, Vec<&ProjectData>> = HashMap::new(); for p in &result { if let Some(ref wi) = p.worktree_info - && result_ids.contains(wi.parent_project_id.as_str()) { - map.entry(wi.parent_project_id.as_str()).or_default().push(p); - } + && result_ids.contains(wi.parent_project_id.as_str()) + { + map.entry(wi.parent_project_id.as_str()) + .or_default() + .push(p); + } } map }; @@ -237,6 +239,8 @@ mod tests { hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, + is_creating: false, + is_closing: false, } } @@ -275,11 +279,7 @@ mod tests { #[test] fn filters_hidden_projects() { let data = make_data( - vec![ - make_project("p1"), - make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["p1", "p2", "p3"], &["p2"], ); @@ -292,17 +292,12 @@ mod tests { #[test] fn focused_project_shown_even_when_hidden() { let data = make_data( - vec![ - make_project("p1"), - make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["p1", "p2", "p3"], &["p3"], ); let focused = "p3".to_string(); - let visible = - compute_visible_projects(&data, Some(&focused), false, &data.main_window); + let visible = compute_visible_projects(&data, Some(&focused), false, &data.main_window); assert_eq!(visible.len(), 1); assert_eq!(visible[0].id, "p3"); } @@ -327,11 +322,7 @@ mod tests { #[test] fn folder_filter_hides_top_level() { let mut data = make_data( - vec![ - make_project("p1"), - make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["f1", "p3"], &[], ); @@ -406,11 +397,7 @@ mod tests { // window hidden set is the sole visibility mechanism after the // legacy ProjectData.show_in_overview field was removed. let data = make_data( - vec![ - make_project("p1"), - make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["p1", "p2", "p3"], &[], ); @@ -443,11 +430,7 @@ mod tests { // WindowState.folder_filter instead of the prior loose argument — // verifies the new signature reads filter from the window. let mut data = make_data( - vec![ - make_project("p1"), - make_project("p2"), - make_project("p3"), - ], + vec![make_project("p1"), make_project("p2"), make_project("p3")], vec!["f1", "p3"], &[], ); diff --git a/crates/okena-workspace/src/worktree_sync.rs b/crates/okena-workspace/src/worktree_sync.rs index 0b19f7730..4dacb3112 100644 --- a/crates/okena-workspace/src/worktree_sync.rs +++ b/crates/okena-workspace/src/worktree_sync.rs @@ -1,7 +1,6 @@ use crate::state::Workspace; use gpui::prelude::*; use gpui::*; -use std::path::Path; use std::time::Duration; /// Periodically removes stale worktree projects whose directories no longer exist. @@ -28,27 +27,36 @@ impl WorktreeSyncWatcher { smol::Timer::after(Duration::from_secs(30)).await; // Collect current worktree projects, skipping those being actively managed - let current_worktrees: Vec<(String, String)> = cx.update(|cx| { + let current_worktrees: Vec<(String, std::path::PathBuf)> = cx.update(|cx| { let ws = workspace.read(cx); - ws.data().projects.iter() + ws.data() + .projects + .iter() .filter(|p| p.worktree_info.is_some()) .filter(|p| !p.is_remote) .filter(|p| !ws.is_project_closing(&p.id)) .filter(|p| !ws.is_creating_project(&p.id)) .filter(|p| !ws.lifecycle.is_worktree_removing(&p.path)) - .map(|p| (p.id.clone(), p.path.clone())) + .map(|p| { + ( + p.id.clone(), + crate::persistence::worktree_checkout_path(p).to_path_buf(), + ) + }) .collect() }); // Check for stale worktrees on blocking thread let stale_ids = smol::unblock({ move || { - current_worktrees.iter() - .filter(|(_, path)| !Path::new(path).exists()) + current_worktrees + .iter() + .filter(|(_, path)| !path.exists()) .map(|(id, _)| id.clone()) .collect::<Vec<_>>() } - }).await; + }) + .await; // Remove stale worktrees if !stale_ids.is_empty() { @@ -68,6 +76,7 @@ impl WorktreeSyncWatcher { break; } } - }).detach(); + }) + .detach(); } } 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<ConnectionHandler>` + (`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<Workspace>` 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<EnsuredDaemon>`, 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:<code>` 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/mobile/rn/README.md b/mobile/rn/README.md index 325f0136a..edd181459 100644 --- a/mobile/rn/README.md +++ b/mobile/rn/README.md @@ -32,7 +32,7 @@ cd mobile/rn npm ci npm run typecheck # tsc --noEmit, strict npm run lint # eslint -npm test # jest (packed-cell decoder smoke test) +npm test # jest (packed-cell decoder + keyboard modifier tests) ``` The ubrn cross-compile, the Skia native binaries, and an on-device run need the mobile @@ -46,9 +46,15 @@ gotchas that the generic steps don't mention. ## Verified Android run — what it actually took (2026-06) -The full chain has been run end-to-end on an Android **emulator**: build → TLS connect → pair → -workspace → live terminal (Skia paint of colored shell output). The generic steps below are -correct in spirit but several things needed fixing/wiring that aren't obvious — captured here. +The native chain has been run on both an Android emulator and a physical Android device: build → +connect → pair → workspace → live terminal → input/output. Both ADB-reversed plaintext +loopback and a real LAN TLS connection have been verified. The TypeScript adapter passes all five +arguments expected by Rust's `connect(host, port, token, tls, fingerprint)` API, so saved +certificate pins are enforced. First-use fingerprints are captured by the Rust connection but are +not yet exposed back through the FFI for persistence in `SavedServer`. + +The generic steps below are correct in spirit but several things needed fixing/wiring that aren't +obvious — captured here. **The turbo module is a local package.** ubrn treats a project as a *library* and would clobber the app's root `android/build.gradle`, so the generated module is kept as its own package at @@ -96,6 +102,60 @@ not happen on physical devices; the `-gpu host|swiftshader_indirect|angle_indire help (the guest EGL is fixed by the system image). **Use a real Android device** for stable terminal rendering. +### Terminal input controls + +The terminal uses a hidden native `TextInput` for the system keyboard and keeps a terminal-specific +key rail visible below the canvas: + +- Tap the terminal to focus the system keyboard. Autocorrect, capitalization, suggestions, and + autofill are disabled; Android uses its code-friendly `visible-password` keyboard mode. +- `ctrl`, `alt`, and `meta`: tap for a one-shot modifier; hold for 350 ms to lock it. A short accent + marker means one-shot, while a full accent fill and long marker mean locked. Locked modifiers + survive subsequent input until tapped or held again. +- `^C` sends an immediate interrupt. `esc`, `tab`, common path/shell characters, clipboard paste, + Compose, and keyboard hide are directly available in the scrollable rail. +- The fixed arrow pad sends on touch and repeats after holding for 360 ms. Modifiers are honored; + Meta + arrows map to Home/End/Page Up/Page Down. +- `more` hides the system keyboard and opens the extended key deck: Ctrl-C/D/Z, Escape, + Home/End/Page Up/Page Down/Delete, explicit arrows, and common raw characters. +- `paste` sends a single-line clipboard value directly. Multiline clipboard content opens in + Compose first so pasted newlines cannot run several commands without review. +- Compose offers **Insert** (send exactly the edited text) and **Run** (send text followed by Enter). +- Long-press and drag selects terminal text; release copies it to the OS clipboard and briefly + shows a `Copied` confirmation. Double-tap copies the selected word. + +### Input UX validation status (2026-07-17) + +Verified statically: + +- strict TypeScript check, ESLint, and 10 Jest tests, +- Android arm64 release bundle and native build, including clipboard autolinking, +- modifier state/encoding tests: one-shot reset, long-press lock persistence, Ctrl and Meta bytes. + +Verified remotely on a physical OnePlus DN2103 running Android 13: + +- install and restart with saved token, ADB-reversed loopback connection, stable Skia rendering, + command input, and returned output, +- LAN TLS pairing and saved-token reconnect to `10.145.103.64:19100` with no ADB reverse rule, + including a terminal command/output round-trip, +- code-friendly soft keyboard behavior: lowercase input is preserved and the canvas, rail, and IME + remain usable together, +- Ctrl tap for one-shot, automatic reset after input, long-press lock, persistence across input, and + explicit unlock; the dedicated Ctrl-C key interrupts the shell, +- arrow tap and hold-to-repeat through shell history, including returning to the empty prompt, +- the complete extended key deck in portrait, with all signal, navigation, character, paste, and + Compose controls visible, +- Compose **Insert** without Enter and **Run** with Enter, +- double-tap word copy, `Copied` feedback, and a single-line clipboard paste round-trip, +- the terminal rail with the keyboard both shown and hidden, plus the full rail in landscape. + +Still worth checking with a person holding the device: + +1. Alt, Meta, and modifier-plus-arrow combinations in real applications; +2. arrow repeat, Home/End/Page navigation, and Ctrl-D/Z in a TUI such as `vim`; +3. multiline clipboard review and long-press/drag selection across several cells; +4. one-handed reach, long-press timing, landscape keyboard layout, and iOS behavior. + --- ## Device-side setup (run on a machine with the RN toolchain) @@ -181,7 +241,7 @@ mobile/rn/ ├── metro.config.js · babel.config.js # bundler + transpiler ├── react-native.config.js # font asset linking ├── ubrn.config.yaml # ubrn: crate path, targets, output dirs -├── jest.config.js · __tests__/ # jest (cells decoder smoke test) +├── jest.config.js · __tests__/ # jest (cells decoder + keyboard logic) ├── .eslintrc.js · .prettierrc # lint + format ├── tsconfig.json · package.json ├── assets/JetBrainsMono-*.ttf # bundled monospace fonts diff --git a/mobile/rn/__tests__/connectionStore.test.ts b/mobile/rn/__tests__/connectionStore.test.ts new file mode 100644 index 000000000..cd79f0d29 --- /dev/null +++ b/mobile/rn/__tests__/connectionStore.test.ts @@ -0,0 +1,45 @@ +import { createSavedServer } from '../src/models/savedServer'; +import type { OkenaNative } from '../src/native/okena'; +import { connectSavedServer } from '../src/state/connectionStore'; + +describe('connectSavedServer', () => { + it('passes the TLS setting and certificate pin to the native API', () => { + const connect = jest.fn(() => 'conn-1'); + const native: Pick<OkenaNative, 'connect'> = { connect }; + const server = createSavedServer({ + host: 'okena.lan', + port: 19100, + token: 'saved-token', + tls: true, + fingerprint: 'sha256:certificate-pin', + }); + + expect(connectSavedServer(native, server)).toBe('conn-1'); + expect(connect).toHaveBeenCalledWith( + 'okena.lan', + 19100, + 'saved-token', + true, + 'sha256:certificate-pin', + ); + }); + + it('preserves an explicit plaintext connection without a pin', () => { + const connect = jest.fn(() => 'conn-2'); + const native: Pick<OkenaNative, 'connect'> = { connect }; + const server = createSavedServer({ + host: '127.0.0.1', + port: 19100, + tls: false, + }); + + expect(connectSavedServer(native, server)).toBe('conn-2'); + expect(connect).toHaveBeenCalledWith( + '127.0.0.1', + 19100, + undefined, + false, + undefined, + ); + }); +}); diff --git a/mobile/rn/__tests__/keyModifiers.test.ts b/mobile/rn/__tests__/keyModifiers.test.ts new file mode 100644 index 000000000..e24ef6153 --- /dev/null +++ b/mobile/rn/__tests__/keyModifiers.test.ts @@ -0,0 +1,41 @@ +jest.mock('@react-native-clipboard/clipboard', () => + require('@react-native-clipboard/clipboard/jest/clipboard-mock'), +); + +import { + KeyModifiers, + applyModifiersToText, +} from '../src/components/KeyToolbar'; + +describe('KeyModifiers', () => { + it('uses tap as a one-shot modifier', () => { + const modifiers = new KeyModifiers(); + + modifiers.toggleCtrl(); + expect(modifiers.getSnapshot().ctrl).toBe('active'); + + modifiers.reset(); + expect(modifiers.getSnapshot().ctrl).toBe('inactive'); + }); + + it('keeps a long-pressed modifier locked across reset', () => { + const modifiers = new KeyModifiers(); + + modifiers.lockOption(); + modifiers.reset(); + expect(modifiers.getSnapshot().option).toBe('locked'); + + modifiers.lockOption(); + expect(modifiers.getSnapshot().option).toBe('inactive'); + }); + + it('encodes control and meta input', () => { + const control = new KeyModifiers(); + control.toggleCtrl(); + expect(applyModifiersToText(control, 'c')).toBe('\u0003'); + + const meta = new KeyModifiers(); + meta.toggleCmd(); + expect(applyModifiersToText(meta, 'x')).toBe('\u001bx'); + }); +}); diff --git a/mobile/rn/package-lock.json b/mobile/rn/package-lock.json index 1c293c055..21f478214 100644 --- a/mobile/rn/package-lock.json +++ b/mobile/rn/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@react-native-async-storage/async-storage": "^2.1.0", + "@react-native-clipboard/clipboard": "^1.16.3", "@shopify/react-native-skia": "^1.5.0", "okena-mobile-ffi": "file:./modules/okena-mobile-ffi", "react": "18.3.1", @@ -2773,6 +2774,29 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-clipboard/clipboard": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.16.3.tgz", + "integrity": "sha512-cMIcvoZKIrShzJHEaHbTAp458R9WOv0fB6UyC7Ek4Qk561Ow/DrzmmJmH/rAZg21Z6ixJ4YSdFDC14crqIBmCQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": ">= 16.9.0", + "react-native": ">= 0.61.5", + "react-native-macos": ">= 0.61.0", + "react-native-windows": ">= 0.61.0" + }, + "peerDependenciesMeta": { + "react-native-macos": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native-community/cli": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-15.0.1.tgz", diff --git a/mobile/rn/package.json b/mobile/rn/package.json index 9e355a34a..9fe350c1c 100644 --- a/mobile/rn/package.json +++ b/mobile/rn/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "^2.1.0", + "@react-native-clipboard/clipboard": "^1.16.3", "@shopify/react-native-skia": "^1.5.0", "okena-mobile-ffi": "file:./modules/okena-mobile-ffi", "react": "18.3.1", diff --git a/mobile/rn/src/components/KeyToolbar.tsx b/mobile/rn/src/components/KeyToolbar.tsx index 308c84351..710e51ef3 100644 --- a/mobile/rn/src/components/KeyToolbar.tsx +++ b/mobile/rn/src/components/KeyToolbar.tsx @@ -3,11 +3,11 @@ * * Port of `mobile/lib/src/widgets/key_toolbar.dart`: * - ESC / TAB one-shot keys, - * - CTRL / ALT (option) / CMD sticky three-state toggles - * (inactive → active one-shot → locked → inactive), + * - CTRL / ALT / META modifiers (tap for one-shot, hold to lock), * - a handful of punctuation keys (`~ | / -`), - * - an arrow joystick (pan to fire arrows; tap fires from offset-from-center), - * - compose-sheet + paste + hide-keyboard icon buttons. + * - an arrow pad with hold-to-repeat, + * - clipboard paste, a reviewed compose flow, and an extended key deck for + * signals, navigation, and raw characters. * * Modifier semantics (the heart of the port): * - CTRL + a-z/A-Z → the control character (a→0x01 … z→0x1A). Other chars @@ -41,8 +41,10 @@ import { Modal, TextInput, StyleSheet, + Platform, type GestureResponderEvent, } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; import type { OkenaNative, SpecialKey } from '../native/okena'; import { getOkenaNative } from '../native/okena'; @@ -50,7 +52,7 @@ import { OkenaColors } from '../theme'; // ── Shared modifier state ────────────────────────────────────────────────── -/** Three-state modifier cycle: inactive → active (one-shot) → locked (sticky). */ +/** Modifier state: inactive, active for one key, or locked until released. */ export type ModifierState = 'inactive' | 'active' | 'locked'; interface ModifierSnapshot { @@ -65,17 +67,6 @@ const INITIAL_SNAPSHOT: ModifierSnapshot = { cmd: 'inactive', }; -function nextState(s: ModifierState): ModifierState { - switch (s) { - case 'inactive': - return 'active'; - case 'active': - return 'locked'; - case 'locked': - return 'inactive'; - } -} - /** * Shared modifier store between {@link KeyToolbar} and {@link TerminalPane}. * @@ -116,14 +107,42 @@ export class KeyModifiers { return this.ctrl || this.option || this.cmd; } + /** Tap toggles a one-shot modifier; long-press toggles its locked state. */ toggleCtrl(): void { - this.emit({ ...this.snapshot, ctrl: nextState(this.snapshot.ctrl) }); + this.emit({ + ...this.snapshot, + ctrl: this.snapshot.ctrl === 'inactive' ? 'active' : 'inactive', + }); + } + lockCtrl(): void { + this.emit({ + ...this.snapshot, + ctrl: this.snapshot.ctrl === 'locked' ? 'inactive' : 'locked', + }); } toggleOption(): void { - this.emit({ ...this.snapshot, option: nextState(this.snapshot.option) }); + this.emit({ + ...this.snapshot, + option: this.snapshot.option === 'inactive' ? 'active' : 'inactive', + }); + } + lockOption(): void { + this.emit({ + ...this.snapshot, + option: this.snapshot.option === 'locked' ? 'inactive' : 'locked', + }); } toggleCmd(): void { - this.emit({ ...this.snapshot, cmd: nextState(this.snapshot.cmd) }); + this.emit({ + ...this.snapshot, + cmd: this.snapshot.cmd === 'inactive' ? 'active' : 'inactive', + }); + } + lockCmd(): void { + this.emit({ + ...this.snapshot, + cmd: this.snapshot.cmd === 'locked' ? 'inactive' : 'locked', + }); } /** Reset only one-shot (active) modifiers; locked ones persist. */ @@ -209,23 +228,47 @@ export const KeyToolbar: React.FC<KeyToolbarProps> = ({ }) => { const mod = useKeyModifiers(modifiers); const [composeOpen, setComposeOpen] = useState(false); + const [composeInitialText, setComposeInitialText] = useState(''); + const [keyDeckOpen, setKeyDeckOpen] = useState(false); const sendSpecialKey = useCallback( (key: SpecialKey) => { if (!terminalId) return; void native.sendSpecialKey(connId, terminalId, key); + modifiers.reset(); }, - [native, connId, terminalId], + [native, connId, terminalId, modifiers], ); const sendText = useCallback( (text: string) => { if (!terminalId || text.length === 0) return; + try { + const offset = native.getScrollInfo(connId, terminalId).displayOffset; + if (offset > 0) native.scroll(connId, terminalId, -offset); + } catch { + // Input still works while the local terminal state is catching up. + } void native.sendText(connId, terminalId, text); }, [native, connId, terminalId], ); + const pasteClipboard = useCallback(() => { + void Clipboard.getString() + .then((text) => { + if (/\r|\n/.test(text)) { + setComposeInitialText(text); + setComposeOpen(true); + } else { + sendText(text); + } + }) + .catch(() => { + // Clipboard access can be denied by the OS. + }); + }, [sendText]); + /** Send a character key, applying any active modifiers (Dart `_sendCharKey`). */ const sendCharKey = useCallback( (char: string) => { @@ -295,19 +338,67 @@ export const KeyToolbar: React.FC<KeyToolbarProps> = ({ contentContainerStyle={styles.scrollContent} style={styles.scroll} > - <KeyButton label="esc" onPress={() => sendSpecialKey('Escape')} /> - <ToggleKey label={'⌃'} state={mod.ctrl} onPress={() => modifiers.toggleCtrl()} /> - <ToggleKey label={'⌥'} state={mod.option} onPress={() => modifiers.toggleOption()} /> - <ToggleKey label={'⌘'} state={mod.cmd} onPress={() => modifiers.toggleCmd()} /> + <KeyButton + label="esc" + accessibilityLabel="Escape" + onPress={() => sendSpecialKey('Escape')} + /> + <ToggleKey + label="ctrl" + state={mod.ctrl} + onPress={() => modifiers.toggleCtrl()} + onLongPress={() => modifiers.lockCtrl()} + /> + <ToggleKey + label="alt" + state={mod.option} + onPress={() => modifiers.toggleOption()} + onLongPress={() => modifiers.lockOption()} + /> + <ToggleKey + label="meta" + state={mod.cmd} + onPress={() => modifiers.toggleCmd()} + onLongPress={() => modifiers.lockCmd()} + /> <KeyButton label="tab" onPress={() => sendSpecialKey('Tab')} /> + <KeyButton + label="^C" + accessibilityLabel="Control C" + onPress={() => sendSpecialKey('CtrlC')} + /> <View style={styles.gap} /> <KeyButton label="~" onPress={() => sendCharKey('~')} /> <KeyButton label="|" onPress={() => sendCharKey('|')} /> <KeyButton label="/" onPress={() => sendCharKey('/')} /> <KeyButton label="-" onPress={() => sendCharKey('-')} /> <View style={styles.gap} /> - <KeyButton label={'✎'} onPress={() => setComposeOpen(true)} /> - <KeyButton label={'⌄'} onPress={() => onHideKeyboard?.()} /> + <KeyButton + label="paste" + accessibilityLabel="Paste clipboard" + onPress={pasteClipboard} + /> + <KeyButton + label="edit" + accessibilityLabel="Compose input" + onPress={() => { + setComposeInitialText(''); + setComposeOpen(true); + }} + /> + <KeyButton + label="more" + accessibilityLabel="More terminal keys" + onPress={() => { + onHideKeyboard?.(); + setKeyDeckOpen(true); + }} + /> + <KeyButton + label={'⌄'} + accessibilityLabel="Hide keyboard" + onPress={() => onHideKeyboard?.()} + /> </ScrollView> <View style={styles.arrowSlot}> <ArrowJoystick onArrow={handleArrow} /> @@ -315,23 +406,48 @@ export const KeyToolbar: React.FC<KeyToolbarProps> = ({ <ComposeSheet visible={composeOpen} - onClose={() => setComposeOpen(false)} + initialText={composeInitialText} + onClose={() => { + setComposeOpen(false); + setComposeInitialText(''); + }} onSubmit={(text, sendEnter) => { sendText(text); if (sendEnter) sendSpecialKey('Enter'); }} /> + <KeyDeck + visible={keyDeckOpen} + onClose={() => setKeyDeckOpen(false)} + onSpecialKey={sendSpecialKey} + onCharacter={sendCharKey} + onPaste={() => { + setKeyDeckOpen(false); + pasteClipboard(); + }} + onCompose={() => { + setKeyDeckOpen(false); + setComposeInitialText(''); + setComposeOpen(true); + }} + /> </View> ); }; // ── Key widgets ───────────────────────────────────────────────────────────── -const KeyButton: React.FC<{ label: string; onPress: () => void }> = ({ - label, - onPress, -}) => ( - <Pressable style={styles.key} onPress={onPress}> +const KeyButton: React.FC<{ + label: string; + onPress: () => void; + accessibilityLabel?: string; +}> = ({ label, onPress, accessibilityLabel }) => ( + <Pressable + style={({ pressed }) => [styles.key, pressed && styles.keyPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel ?? label} + > <Text style={styles.keyText}>{label}</Text> </Pressable> ); @@ -340,16 +456,53 @@ const ToggleKey: React.FC<{ label: string; state: ModifierState; onPress: () => void; -}> = ({ label, state, onPress }) => { + onLongPress: () => void; +}> = ({ label, state, onPress, onLongPress }) => { + const handledLongPress = useRef(false); const active = state !== 'inactive'; const locked = state === 'locked'; return ( <Pressable - style={[styles.key, styles.toggleKey, active && styles.toggleKeyActive]} - onPress={onPress} + style={({ pressed }) => [ + styles.key, + styles.toggleKey, + active && styles.toggleKeyActive, + locked && styles.toggleKeyLocked, + pressed && styles.keyPressed, + ]} + onPressIn={() => { + handledLongPress.current = false; + }} + onLongPress={() => { + handledLongPress.current = true; + onLongPress(); + }} + onPress={() => { + if (!handledLongPress.current) onPress(); + }} + delayLongPress={350} + accessibilityRole="button" + accessibilityLabel={`${label} modifier`} + accessibilityHint="Tap for the next key, hold to lock" + accessibilityState={{ selected: active }} > - <Text style={[styles.keyText, active && styles.toggleKeyTextActive]}>{label}</Text> - <View style={[styles.lockBar, locked && styles.lockBarActive]} /> + <Text + style={[ + styles.keyText, + styles.toggleKeyText, + active && styles.toggleKeyTextActive, + locked && styles.toggleKeyTextLocked, + ]} + > + {label} + </Text> + <View + style={[ + styles.modifierState, + active && styles.modifierStateActive, + locked && styles.modifierStateLocked, + ]} + /> </Pressable> ); }; @@ -357,22 +510,25 @@ const ToggleKey: React.FC<{ // ── Arrow joystick ───────────────────────────────────────────────────────── const JOYSTICK_SIZE = 52; -const DRAG_THRESHOLD = 14; const ArrowJoystick: React.FC<{ onArrow: (key: ArrowKey) => void }> = ({ onArrow, }) => { - const originRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const movedRef = useRef(false); const [active, setActive] = useState<ArrowKey | null>(null); - const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + const activeRef = useRef<ArrowKey | null>(null); + const repeatDelay = useRef<ReturnType<typeof setTimeout> | null>(null); + const repeatTimer = useRef<ReturnType<typeof setInterval> | null>(null); - useEffect( - () => () => { - if (clearTimer.current) clearTimeout(clearTimer.current); - }, - [], - ); + const stopRepeat = useCallback(() => { + if (repeatDelay.current) clearTimeout(repeatDelay.current); + if (repeatTimer.current) clearInterval(repeatTimer.current); + repeatDelay.current = null; + repeatTimer.current = null; + activeRef.current = null; + setActive(null); + }, []); + + useEffect(() => stopRepeat, [stopRepeat]); const dirFromDelta = (dx: number, dy: number): ArrowKey => Math.abs(dx) > Math.abs(dy) @@ -380,52 +536,39 @@ const ArrowJoystick: React.FC<{ onArrow: (key: ArrowKey) => void }> = ({ ? 'ArrowRight' : 'ArrowLeft' : dy > 0 - ? 'ArrowDown' - : 'ArrowUp'; + ? 'ArrowDown' + : 'ArrowUp'; - const fire = useCallback( + const startRepeat = useCallback( (dir: ArrowKey) => { - onArrow(dir); + stopRepeat(); + activeRef.current = dir; setActive(dir); + onArrow(dir); + repeatDelay.current = setTimeout(() => { + repeatTimer.current = setInterval(() => { + if (activeRef.current) onArrow(activeRef.current); + }, 70); + }, 360); }, - [onArrow], + [onArrow, stopRepeat], ); - const onResponderGrant = (e: GestureResponderEvent) => { - originRef.current = { - x: e.nativeEvent.locationX, - y: e.nativeEvent.locationY, - }; - movedRef.current = false; + const directionAt = (e: GestureResponderEvent): ArrowKey | null => { + const dx = e.nativeEvent.locationX - JOYSTICK_SIZE / 2; + const dy = e.nativeEvent.locationY - JOYSTICK_SIZE / 2; + if (Math.hypot(dx, dy) < 5) return null; + return dirFromDelta(dx, dy); }; - const onResponderMove = (e: GestureResponderEvent) => { - const dx = e.nativeEvent.locationX - originRef.current.x; - const dy = e.nativeEvent.locationY - originRef.current.y; - if (Math.hypot(dx, dy) >= DRAG_THRESHOLD) { - movedRef.current = true; - fire(dirFromDelta(dx, dy)); - originRef.current = { - x: e.nativeEvent.locationX, - y: e.nativeEvent.locationY, - }; - } + const onResponderGrant = (e: GestureResponderEvent) => { + const dir = directionAt(e); + if (dir) startRepeat(dir); }; - const onResponderRelease = () => { - if (!movedRef.current) { - const cx = JOYSTICK_SIZE / 2; - const cy = JOYSTICK_SIZE / 2; - const dx = originRef.current.x - cx; - const dy = originRef.current.y - cy; - if (Math.hypot(dx, dy) >= 4) { - fire(dirFromDelta(dx, dy)); - if (clearTimer.current) clearTimeout(clearTimer.current); - clearTimer.current = setTimeout(() => setActive(null), 120); - return; - } - } - setActive(null); + const onResponderMove = (e: GestureResponderEvent) => { + const dir = directionAt(e); + if (dir && dir !== activeRef.current) startRepeat(dir); }; return ( @@ -435,22 +578,45 @@ const ArrowJoystick: React.FC<{ onArrow: (key: ArrowKey) => void }> = ({ onMoveShouldSetResponder={() => true} onResponderGrant={onResponderGrant} onResponderMove={onResponderMove} - onResponderRelease={onResponderRelease} - onResponderTerminate={onResponderRelease} + onResponderRelease={stopRepeat} + onResponderTerminate={stopRepeat} + accessibilityRole="adjustable" + accessibilityLabel="Arrow keys" + accessibilityHint="Touch a direction and hold to repeat" > <View style={styles.joystickGrid}> - <Text style={[styles.arrowGlyph, active === 'ArrowUp' && styles.arrowGlyphActive]}> + <Text + style={[ + styles.arrowGlyph, + active === 'ArrowUp' && styles.arrowGlyphActive, + ]} + > {'▲'} </Text> <View style={styles.arrowRow}> - <Text style={[styles.arrowGlyph, active === 'ArrowLeft' && styles.arrowGlyphActive]}> + <Text + style={[ + styles.arrowGlyph, + active === 'ArrowLeft' && styles.arrowGlyphActive, + ]} + > {'◀'} </Text> - <Text style={[styles.arrowGlyph, active === 'ArrowRight' && styles.arrowGlyphActive]}> + <Text + style={[ + styles.arrowGlyph, + active === 'ArrowRight' && styles.arrowGlyphActive, + ]} + > {'▶'} </Text> </View> - <Text style={[styles.arrowGlyph, active === 'ArrowDown' && styles.arrowGlyphActive]}> + <Text + style={[ + styles.arrowGlyph, + active === 'ArrowDown' && styles.arrowGlyphActive, + ]} + > {'▼'} </Text> </View> @@ -458,21 +624,194 @@ const ArrowJoystick: React.FC<{ onArrow: (key: ArrowKey) => void }> = ({ ); }; +// ── Extended key deck ───────────────────────────────────────────────────── + +const KeyDeck: React.FC<{ + visible: boolean; + onClose: () => void; + onSpecialKey: (key: SpecialKey) => void; + onCharacter: (character: string) => void; + onPaste: () => void; + onCompose: () => void; +}> = ({ visible, onClose, onSpecialKey, onCharacter, onPaste, onCompose }) => ( + <Modal + visible={visible} + transparent + animationType="slide" + onRequestClose={onClose} + > + <Pressable style={styles.deckBackdrop} onPress={onClose} /> + <View style={styles.deckSheet}> + <View style={styles.sheetHandle} /> + <View style={styles.deckHeader}> + <View> + <Text style={styles.deckTitle}>Terminal keys</Text> + <Text style={styles.deckSubtitle}> + Signals, navigation and raw input + </Text> + </View> + <Pressable + style={({ pressed }) => [ + styles.deckClose, + pressed && styles.keyPressed, + ]} + onPress={onClose} + accessibilityRole="button" + accessibilityLabel="Close terminal keys" + > + <Text style={styles.deckCloseText}>Done</Text> + </Pressable> + </View> + + <DeckSection label="Signals"> + <DeckKey + label="ctrl c" + code="^C" + onPress={() => onSpecialKey('CtrlC')} + /> + <DeckKey + label="ctrl d" + code="^D" + onPress={() => onSpecialKey('CtrlD')} + /> + <DeckKey + label="ctrl z" + code="^Z" + onPress={() => onSpecialKey('CtrlZ')} + /> + <DeckKey + label="escape" + code="esc" + onPress={() => onSpecialKey('Escape')} + /> + </DeckSection> + + <DeckSection label="Navigation"> + <DeckKey + label="home" + code="home" + onPress={() => onSpecialKey('Home')} + /> + <DeckKey label="end" code="end" onPress={() => onSpecialKey('End')} /> + <DeckKey + label="page up" + code="pgup" + onPress={() => onSpecialKey('PageUp')} + /> + <DeckKey + label="page down" + code="pgdn" + onPress={() => onSpecialKey('PageDown')} + /> + <DeckKey + label="delete" + code="del" + onPress={() => onSpecialKey('Delete')} + /> + <DeckKey + label="arrow left" + code="←" + onPress={() => onSpecialKey('ArrowLeft')} + /> + <DeckKey + label="arrow up" + code="↑" + onPress={() => onSpecialKey('ArrowUp')} + /> + <DeckKey + label="arrow down" + code="↓" + onPress={() => onSpecialKey('ArrowDown')} + /> + <DeckKey + label="arrow right" + code="→" + onPress={() => onSpecialKey('ArrowRight')} + /> + </DeckSection> + + <DeckSection label="Characters"> + {['~', '|', '\\', '/', '-', '_', '=', ':', ';'].map((character) => ( + <DeckKey + key={character} + label={character} + code={character} + onPress={() => onCharacter(character)} + compact + /> + ))} + </DeckSection> + + <View style={styles.deckActions}> + <Pressable + style={({ pressed }) => [ + styles.deckAction, + pressed && styles.keyPressed, + ]} + onPress={onPaste} + > + <Text style={styles.deckActionText}>Paste clipboard</Text> + </Pressable> + <Pressable + style={({ pressed }) => [ + styles.deckAction, + styles.deckActionPrimary, + pressed && styles.keyPressed, + ]} + onPress={onCompose} + > + <Text style={styles.deckActionPrimaryText}>Compose input</Text> + </Pressable> + </View> + </View> + </Modal> +); + +const DeckSection: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( + <View style={styles.deckSection}> + <Text style={styles.deckSectionLabel}>{label}</Text> + <View style={styles.deckKeys}>{children}</View> + </View> +); + +const DeckKey: React.FC<{ + label: string; + code: string; + onPress: () => void; + compact?: boolean; +}> = ({ label, code, onPress, compact = false }) => ( + <Pressable + style={({ pressed }) => [ + styles.deckKey, + compact && styles.deckKeyCompact, + pressed && styles.deckKeyPressed, + ]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={label} + > + <Text style={styles.deckKeyText}>{code}</Text> + </Pressable> +); + // ── Compose sheet ───────────────────────────────────────────────────────── const ComposeSheet: React.FC<{ visible: boolean; + initialText: string; onClose: () => void; onSubmit: (text: string, sendEnter: boolean) => void; -}> = ({ visible, onClose, onSubmit }) => { +}> = ({ visible, initialText, onClose, onSubmit }) => { const [text, setText] = useState(''); - const [sendEnter, setSendEnter] = useState(true); useEffect(() => { - if (visible) setText(''); - }, [visible]); + if (visible) setText(initialText); + }, [visible, initialText]); - const submit = () => { + const submit = (sendEnter: boolean) => { if (text.length === 0) { onClose(); return; @@ -490,19 +829,30 @@ const ComposeSheet: React.FC<{ > <Pressable style={styles.composeBackdrop} onPress={onClose} /> <View style={styles.composeSheet}> + <View style={styles.sheetHandle} /> <View style={styles.composeHeader}> + <View style={styles.composeHeading}> + <Text style={styles.composeTitle}>Compose input</Text> + <Text style={styles.composeSubtitle}> + Review text before it reaches the terminal + </Text> + </View> <Pressable - style={[styles.enterToggle, sendEnter && styles.enterToggleActive]} - onPress={() => setSendEnter((v) => !v)} + style={({ pressed }) => [ + styles.composePaste, + pressed && styles.keyPressed, + ]} + onPress={() => { + void Clipboard.getString() + .then((clipboardText) => + setText((current) => current + clipboardText), + ) + .catch(() => { + // Clipboard access can be denied by the OS. + }); + }} > - <Text - style={[ - styles.enterToggleText, - sendEnter && styles.enterToggleTextActive, - ]} - > - {'⏎'} Enter - </Text> + <Text style={styles.composePasteText}>Paste</Text> </Pressable> </View> <TextInput @@ -511,15 +861,32 @@ const ComposeSheet: React.FC<{ onChangeText={setText} autoFocus multiline - placeholder="Enter command..." + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + autoComplete="off" + importantForAutofill="no" + keyboardType={ + Platform.OS === 'android' ? 'visible-password' : 'ascii-capable' + } + placeholder="Command, path, or multiline input" placeholderTextColor={OkenaColors.textTertiary} /> <View style={styles.composeActions}> <Pressable style={styles.composeBtn} onPress={onClose}> <Text style={styles.composeBtnText}>Cancel</Text> </Pressable> - <Pressable style={[styles.composeBtn, styles.composeSend]} onPress={submit}> - <Text style={styles.composeSendText}>Send</Text> + <Pressable + style={[styles.composeBtn, styles.composeInsert]} + onPress={() => submit(false)} + > + <Text style={styles.composeInsertText}>Insert</Text> + </Pressable> + <Pressable + style={[styles.composeBtn, styles.composeSend]} + onPress={() => submit(true)} + > + <Text style={styles.composeSendText}>Run ↵</Text> </Pressable> </View> </View> @@ -534,50 +901,57 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 6, - paddingVertical: 5, + paddingVertical: 6, backgroundColor: OkenaColors.glassBg, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: OkenaColors.glassStroke, }, scroll: { flex: 1 }, scrollContent: { alignItems: 'center' }, - gap: { width: 12 }, + gap: { width: 8 }, key: { - minWidth: 40, - paddingHorizontal: 8, - paddingVertical: 9, + minWidth: 42, + minHeight: 42, + paddingHorizontal: 10, + paddingVertical: 8, marginHorizontal: 2, - borderRadius: 10, + borderRadius: 8, backgroundColor: OkenaColors.keyBg, borderWidth: StyleSheet.hairlineWidth, borderColor: OkenaColors.keyBorder, alignItems: 'center', justifyContent: 'center', }, + keyPressed: { opacity: 0.62 }, keyText: { color: OkenaColors.keyText, - fontSize: 13, - fontWeight: '500', + fontFamily: 'JetBrainsMono', + fontSize: 12, + fontWeight: '600', }, - toggleKey: { paddingVertical: 7 }, + toggleKey: { minWidth: 50, paddingVertical: 6 }, toggleKeyActive: { - backgroundColor: OkenaColors.accent, + backgroundColor: OkenaColors.accentSoft, borderColor: OkenaColors.accent, }, - toggleKeyTextActive: { color: '#ffffff', fontWeight: '700', fontSize: 16 }, - lockBar: { - width: 12, + toggleKeyLocked: { backgroundColor: OkenaColors.accent }, + toggleKeyText: { fontSize: 11 }, + toggleKeyTextActive: { color: OkenaColors.accent, fontWeight: '700' }, + toggleKeyTextLocked: { color: '#ffffff' }, + modifierState: { + width: 4, height: 2, - marginTop: 1, + marginTop: 3, borderRadius: 1, backgroundColor: 'transparent', }, - lockBarActive: { backgroundColor: '#ffffff' }, + modifierStateActive: { backgroundColor: OkenaColors.accent }, + modifierStateLocked: { width: 18, backgroundColor: '#ffffff' }, arrowSlot: { marginLeft: 6 }, joystick: { width: JOYSTICK_SIZE, height: JOYSTICK_SIZE, - borderRadius: 16, + borderRadius: 12, backgroundColor: OkenaColors.keyBg, borderWidth: StyleSheet.hairlineWidth, borderColor: OkenaColors.keyBorder, @@ -593,39 +967,154 @@ const styles = StyleSheet.create({ marginVertical: 1, }, arrowGlyphActive: { color: OkenaColors.accent }, + sheetHandle: { + alignSelf: 'center', + width: 36, + height: 4, + marginBottom: 14, + borderRadius: 2, + backgroundColor: OkenaColors.borderLight, + }, + // Extended key deck + deckBackdrop: { flex: 1, backgroundColor: OkenaColors.backdrop }, + deckSheet: { + backgroundColor: OkenaColors.surface, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + borderTopWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: 24, + }, + deckHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 14, + }, + deckTitle: { + color: OkenaColors.textPrimary, + fontSize: 18, + fontWeight: '700', + }, + deckSubtitle: { color: OkenaColors.textTertiary, fontSize: 12, marginTop: 2 }, + deckClose: { + minHeight: 40, + paddingHorizontal: 12, + alignItems: 'center', + justifyContent: 'center', + }, + deckCloseText: { color: OkenaColors.accent, fontSize: 14, fontWeight: '600' }, + deckSection: { marginBottom: 14 }, + deckSectionLabel: { + color: OkenaColors.textTertiary, + fontSize: 11, + fontWeight: '700', + letterSpacing: 0.8, + textTransform: 'uppercase', + marginBottom: 7, + }, + deckKeys: { flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -3 }, + deckKey: { + minWidth: 64, + minHeight: 44, + paddingHorizontal: 10, + margin: 3, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: OkenaColors.surfaceElevated, + borderWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.keyBorder, + }, + deckKeyCompact: { minWidth: 44, paddingHorizontal: 8 }, + deckKeyPressed: { + backgroundColor: OkenaColors.accentSoft, + borderColor: OkenaColors.accent, + }, + deckKeyText: { + color: OkenaColors.textPrimary, + fontFamily: 'JetBrainsMono', + fontSize: 12, + fontWeight: '600', + }, + deckActions: { flexDirection: 'row', marginHorizontal: -4, marginTop: 2 }, + deckAction: { + flex: 1, + minHeight: 46, + marginHorizontal: 4, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: OkenaColors.surfaceElevated, + borderWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, + }, + deckActionPrimary: { + backgroundColor: OkenaColors.accent, + borderColor: OkenaColors.accent, + }, + deckActionText: { + color: OkenaColors.textSecondary, + fontSize: 13, + fontWeight: '600', + }, + deckActionPrimaryText: { color: '#ffffff', fontSize: 13, fontWeight: '700' }, // Compose sheet - composeBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' }, + composeBackdrop: { flex: 1, backgroundColor: OkenaColors.backdrop }, composeSheet: { backgroundColor: OkenaColors.surface, borderTopLeftRadius: 16, borderTopRightRadius: 16, - padding: 16, + borderTopWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: 20, }, composeHeader: { flexDirection: 'row', - justifyContent: 'flex-end', - marginBottom: 8, + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, }, - enterToggle: { - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - backgroundColor: OkenaColors.surfaceElevated, + composeHeading: { flex: 1, paddingRight: 12 }, + composeTitle: { + color: OkenaColors.textPrimary, + fontSize: 18, + fontWeight: '700', }, - enterToggleActive: { backgroundColor: OkenaColors.accent }, - enterToggleText: { + composeSubtitle: { color: OkenaColors.textTertiary, fontSize: 12, - fontFamily: 'JetBrainsMono', + marginTop: 2, + }, + composePaste: { + minHeight: 40, + paddingHorizontal: 12, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: OkenaColors.surfaceElevated, + borderWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, + }, + composePasteText: { + color: OkenaColors.accent, + fontSize: 13, + fontWeight: '600', }, - enterToggleTextActive: { color: '#ffffff' }, composeInput: { - minHeight: 96, + minHeight: 120, + maxHeight: 240, color: OkenaColors.textPrimary, fontFamily: 'JetBrainsMono', fontSize: 14, backgroundColor: OkenaColors.surfaceElevated, borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, padding: 12, textAlignVertical: 'top', }, @@ -635,12 +1124,24 @@ const styles = StyleSheet.create({ marginTop: 12, }, composeBtn: { + minHeight: 42, paddingHorizontal: 16, - paddingVertical: 8, borderRadius: 8, marginLeft: 8, + alignItems: 'center', + justifyContent: 'center', }, composeBtnText: { color: OkenaColors.textSecondary, fontSize: 14 }, + composeInsert: { + backgroundColor: OkenaColors.surfaceElevated, + borderWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, + }, + composeInsertText: { + color: OkenaColors.textPrimary, + fontSize: 14, + fontWeight: '600', + }, composeSend: { backgroundColor: OkenaColors.accent }, composeSendText: { color: '#ffffff', fontSize: 14, fontWeight: '600' }, }); diff --git a/mobile/rn/src/components/TerminalPane.tsx b/mobile/rn/src/components/TerminalPane.tsx index ce134c5c1..9920fc2c6 100644 --- a/mobile/rn/src/components/TerminalPane.tsx +++ b/mobile/rn/src/components/TerminalPane.tsx @@ -31,28 +31,29 @@ import React, { forwardRef, useCallback, + useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { View, + Text, TextInput, StyleSheet, + Platform, type LayoutChangeEvent, type NativeSyntheticEvent, type TextInputChangeEventData, type GestureResponderEvent, } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; import type { OkenaNative } from '../native/okena'; import { getOkenaNative } from '../native/okena'; -import { TerminalTheme } from '../theme'; +import { OkenaColors, TerminalTheme } from '../theme'; import { TerminalView, type TerminalFonts } from './TerminalView'; -import { - KeyModifiers, - applyModifiersToText, -} from './KeyToolbar'; +import { KeyModifiers, applyModifiersToText } from './KeyToolbar'; // Sentinel buffer: keeps spaces in the TextInput so backspace always has // something to delete. Without this, Android's soft keyboard backspace is a @@ -85,9 +86,14 @@ interface Grid { } export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( - ({ connId, terminalId, fonts, modifiers, native = getOkenaNative() }, ref) => { + ( + { connId, terminalId, fonts, modifiers, native = getOkenaNative() }, + ref, + ) => { const inputRef = useRef<TextInput>(null); const [selecting, setSelecting] = useState(false); + const [copied, setCopied] = useState(false); + const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null); // Mirror of what's currently in the hidden TextInput (sentinel-padded). const lastInputText = useRef<string>(SENTINEL); @@ -96,7 +102,12 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( // so touch coordinates can be converted to cells. Cell size is derived from // the laid-out box / grid (TerminalView floors width/cellWidth, so this is // an approximation good enough for hit-testing). - const grid = useRef<Grid>({ cols: 80, rows: 24, cellWidth: 0, cellHeight: 0 }); + const grid = useRef<Grid>({ + cols: 80, + rows: 24, + cellWidth: 0, + cellHeight: 0, + }); const boxSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); // Vertical-scroll accumulator (px) → whole-line deltas. @@ -141,6 +152,13 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( inputRef.current?.setNativeProps?.({ text: SENTINEL }); }, []); + useEffect(() => { + resetSentinel(); + return () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current); + }; + }, [terminalId, resetSentinel]); + const scrollToBottom = useCallback(() => { try { const offset = native.getScrollInfo(connId, terminalId).displayOffset; @@ -187,24 +205,33 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( // ── touch → cell ────────────────────────────────────────────────────────── - const touchToCell = useCallback((x: number, y: number): { col: number; row: number } => { - const { cellWidth, cellHeight, cols, rows } = grid.current; - const col = - cellWidth > 0 ? Math.min(Math.max(Math.floor(x / cellWidth), 0), cols - 1) : 0; - const row = - cellHeight > 0 ? Math.min(Math.max(Math.floor(y / cellHeight), 0), rows - 1) : 0; - return { col, row }; - }, []); + const touchToCell = useCallback( + (x: number, y: number): { col: number; row: number } => { + const { cellWidth, cellHeight, cols, rows } = grid.current; + const col = + cellWidth > 0 + ? Math.min(Math.max(Math.floor(x / cellWidth), 0), cols - 1) + : 0; + const row = + cellHeight > 0 + ? Math.min(Math.max(Math.floor(y / cellHeight), 0), rows - 1) + : 0; + return { col, row }; + }, + [], + ); // ── selection ────────────────────────────────────────────────────────────── const copySelectionAndClear = useCallback(() => { try { - // getSelectedText is available; clipboard write goes through the host - // (we send the text to the terminal? no — just clear). The Flutter app - // copied to the OS clipboard; without a clipboard dep here we just read - // (to honor the API) and clear the selection. - native.getSelectedText(connId, terminalId); + const selectedText = native.getSelectedText(connId, terminalId); + if (selectedText !== undefined && selectedText.length > 0) { + Clipboard.setString(selectedText); + setCopied(true); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 1200); + } } catch { // ignore } @@ -242,7 +269,10 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( clearLongPress(); longPressTimer.current = setTimeout(() => { // Begin a character selection at the grant cell. - const { col, row } = touchToCell(grantPos.current.x, grantPos.current.y); + const { col, row } = touchToCell( + grantPos.current.x, + grantPos.current.y, + ); try { native.startSelection(connId, terminalId, col, row); } catch { @@ -342,7 +372,15 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( } dragLastY.current = null; }, - [native, connId, terminalId, touchToCell, selecting, copySelectionAndClear, clearLongPress], + [ + native, + connId, + terminalId, + touchToCell, + selecting, + copySelectionAndClear, + clearLongPress, + ], ); return ( @@ -382,13 +420,25 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>( autoCapitalize="none" autoCorrect={false} spellCheck={false} + autoComplete="off" + importantForAutofill="no" + textContentType="none" multiline caretHidden contextMenuHidden - keyboardType="default" + keyboardType={ + Platform.OS === 'android' ? 'visible-password' : 'ascii-capable' + } + disableFullscreenUI + onFocus={resetSentinel} // Keep it from being read out / styled visibly. underlineColorAndroid="transparent" /> + {copied ? ( + <View style={styles.copyNotice} pointerEvents="none"> + <Text style={styles.copyNoticeText}>Copied</Text> + </View> + ) : null} </View> ); }, @@ -412,6 +462,22 @@ const styles = StyleSheet.create({ backgroundColor: 'transparent', padding: 0, }, + copyNotice: { + position: 'absolute', + alignSelf: 'center', + bottom: 16, + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + backgroundColor: OkenaColors.surfaceOverlay, + borderWidth: StyleSheet.hairlineWidth, + borderColor: OkenaColors.borderLight, + }, + copyNoticeText: { + color: OkenaColors.textPrimary, + fontSize: 12, + fontWeight: '600', + }, }); export default TerminalPane; diff --git a/mobile/rn/src/native/okena.ts b/mobile/rn/src/native/okena.ts index 72c19768c..ec6617e10 100644 --- a/mobile/rn/src/native/okena.ts +++ b/mobile/rn/src/native/okena.ts @@ -215,7 +215,13 @@ export interface OkenaNative { * token is supplied, pairing is skipped. (sync — just registers + kicks off * the connect; status is then polled via `connectionStatus`.) */ - connect(host: string, port: number, savedToken: string | undefined): ConnId; + connect( + host: string, + port: number, + savedToken: string | undefined, + tls: boolean, + pinnedCertFingerprint: string | undefined, + ): ConnId; /** Current auth token for a connection, if paired. */ getToken(connId: ConnId): string | undefined; diff --git a/mobile/rn/src/state/connectionStore.ts b/mobile/rn/src/state/connectionStore.ts index 2e8952cc1..b3ce6486c 100644 --- a/mobile/rn/src/state/connectionStore.ts +++ b/mobile/rn/src/state/connectionStore.ts @@ -9,8 +9,7 @@ * - `secondsSinceActivity` (staleness indicator), * - status polling: ~500ms while connecting/pairing, ~2s once connected * (matching the Dart `_startPolling(fast:)` cadence), - * - persisting the auth token (and pinned cert fingerprint) back onto the - * saved server once paired. + * - persisting the auth token back onto the saved server once paired. * * Dependencies — the native module and persistence — are INJECTED via * {@link configureConnectionStore}, mirroring `TerminalView`'s `native` prop. @@ -166,10 +165,23 @@ function persistServers(servers: readonly SavedServer[]): void { void deps().persistence.setItem(SAVED_SERVERS_KEY, listToJson(servers)); } +/** Connect using every transport-security field persisted with the server. */ +export function connectSavedServer( + native: Pick<OkenaNative, 'connect'>, + server: SavedServer, +): ConnId { + return native.connect( + server.host, + server.port, + server.token, + server.tls, + server.fingerprint, + ); +} + /** - * After connecting, copy the freshly-negotiated auth token (and pinned cert - * fingerprint, which the Dart model lacked) back onto the active server and - * persist. Mirrors `_persistToken`. + * After connecting, copy the freshly-negotiated auth token back onto the + * active server and persist. Mirrors `_persistToken`. */ function persistToken( get: StoreApi<ConnectionState>['getState'], @@ -289,7 +301,7 @@ export const useConnectionStore: UseBoundStore<StoreApi<ConnectionState>> = native.disconnect(connId); stopPolling(); } - const newConnId = native.connect(server.host, server.port, server.token); + const newConnId = connectSavedServer(native, server); set({ activeServer: server, connId: newConnId, diff --git a/mobile/rn/src/theme.ts b/mobile/rn/src/theme.ts index 190d83aed..84d4294d3 100644 --- a/mobile/rn/src/theme.ts +++ b/mobile/rn/src/theme.ts @@ -33,6 +33,7 @@ const OkenaColorsArgb = { borderLight: 0xff2a2a2a, // Accent accent: 0xff7c7fff, + accentSoft: 0x247c7fff, // Text textPrimary: 0xffe8e8ec, textSecondary: 0xff98989f, @@ -44,6 +45,7 @@ const OkenaColorsArgb = { // Glass glassBg: 0xcc0a0a0a, glassStroke: 0x18ffffff, + backdrop: 0x8f000000, // Key toolbar keyBg: 0xff161616, keyBorder: 0xff2a2a2a, diff --git a/scripts/audit-daemon-client-gaps.sh b/scripts/audit-daemon-client-gaps.sh new file mode 100755 index 000000000..d4d0be491 --- /dev/null +++ b/scripts/audit-daemon-client-gaps.sh @@ -0,0 +1,163 @@ +#!/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, and the action dispatcher. + matches=$(grep -rnE "$pat" "${GUI_GLOBS[@]}" 2>/dev/null \ + | grep -vE '(/tests?/|_test\.rs|#\[cfg\(test\)\]|remote_apply\.rs|action_dispatch\.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 <field>:` 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. +# WindowState presentation — each desktop owns its windows, visibility, +# ordering, sizing, and layout mode locally in +# window-layout.json; daemon ApiWindow must not +# overwrite those choices. +CAT2_IGNORE='^(version|service_panel_heights|hook_panel_heights|connection_id|is_remote|service_terminals|hidden_terminals|folder_collapsed|hidden_project_ids|os_bounds|project_layout|project_sort_mode|project_widths|show_attention_section)$' + +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/e2e-multiwindow.sh b/scripts/e2e-multiwindow.sh new file mode 100755 index 000000000..1841fce48 --- /dev/null +++ b/scripts/e2e-multiwindow.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# +# e2e-multiwindow.sh — multi-window quit/restore E2E under headless GNOME. +# +# Guards the recurring "only one window after relaunch" regression: quit flows +# that deliver an OS close to every window (GNOME dock Quit, logout, Alt+F4 +# per window) must NOT wipe extra windows from window-layout.json before the +# final quit-time save (see commit "stop quit-time window closes from wiping +# the multi-window layout"). Deliberate single closes must still be forgotten +# (multi-window PRD user story 22). +# +# How: boots a nested `gnome-shell --headless --unsafe-mode` compositor, +# seeds an isolated XDG_CONFIG_HOME with a layout of main + 2 extra windows, +# launches okena against it, and closes windows via org.gnome.Shell.Eval +# `meta_window.delete()` — real compositor close requests, the same path as +# Alt+F4 / dock Quit. The spawned okena daemon is isolated too (own config +# hash -> own socket; TCP falls back within 19100-19200). +# +# Scenarios: +# A restore 2 extras, close ALL windows (main last) -> layout keeps 2 +# B restore again, close ALL windows (main first) -> layout keeps 2 +# C close ONE extra, app keeps running -> forgotten after the 5s grace, +# no zombie reopen; final quit keeps the surviving extra +# +# Requirements: GNOME Shell >= 45 (headless mode), dbus-run-session, python3. +# Wayland/X11 session state is NOT touched — everything runs nested. +# +# Usage: ./scripts/e2e-multiwindow.sh +# OKENA_BIN=/path/to/okena binary under test (default: target/debug/okena) +# OKENA_E2E_KEEP=1 keep the scratch dir (logs, config) on exit +# +# Exit code = number of failed assertions. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BIN="${OKENA_BIN:-$PROJECT_ROOT/target/debug/okena}" + +if [ ! -x "$BIN" ]; then + echo "ABORT: okena binary not found at $BIN (run 'cargo build' or set OKENA_BIN)" + exit 2 +fi + +# Re-exec inside a private D-Bus session so the nested shell and gdbus calls +# can't touch the real desktop session. +if [ -z "${OKENA_E2E_INNER:-}" ]; then + OKENA_E2E_INNER=1 exec dbus-run-session -- bash "$0" "$@" +fi + +WORK="$(mktemp -d -t okena-e2e-XXXXXX)" +CFG="$WORK/xdg-config" +LAYOUT="$CFG/okena/profiles/default/window-layout.json" +GUILOG="$WORK/gui.log" +WL_DISPLAY="okena-e2e-$$" +FAILURES=0 +SHELL_PID="" +OKENA_PID="" + +cleanup() { + [ -n "$OKENA_PID" ] && kill -9 "$OKENA_PID" 2>/dev/null + [ -n "$SHELL_PID" ] && kill "$SHELL_PID" 2>/dev/null + if [ -n "${OKENA_E2E_KEEP:-}" ] || [ "$FAILURES" -gt 0 ]; then + echo "scratch kept at: $WORK" + else + rm -rf "$WORK" + fi +} +trap cleanup EXIT + +fail() { echo "FAIL: $1"; FAILURES=$((FAILURES+1)); } +pass() { echo "PASS: $1"; } + +seed_config() { + rm -rf "$CFG" + mkdir -p "$CFG" + python3 - "$CFG" <<'PYEOF' +import json, os, sys, uuid + +cfg_root = sys.argv[1] +okena = os.path.join(cfg_root, "okena") +profile = os.path.join(okena, "profiles", "default") +os.makedirs(profile, exist_ok=True) + +json.dump({ + "version": 1, + "profiles": [{"id": "default", "display_name": "Default", + "created_at": "2026-01-01T00:00:00Z"}], + "last_used": "default", + "default_profile": "default", +}, open(os.path.join(okena, "profiles.json"), "w"), indent=2) + +def window(x): + # Field values must parse as okena-state WindowState (serde rejects the + # whole layout otherwise and the app silently starts fresh). + return { + "id": str(uuid.uuid4()), + "hidden_project_ids": [], + "folder_filter": None, + "project_widths": {}, + "project_layout": "rows", + "project_sort_mode": "manual", + "show_attention_section": True, + "folder_collapsed": {}, + "os_bounds": {"origin_x": float(x), "origin_y": 0.0, + "width": 700.0, "height": 500.0}, + "sidebar_open": False, + } + +json.dump({ + "version": 3, + "main_window": window(0), + "extra_windows": [window(720), window(1440)], + "project_layouts": {}, + "service_panel_heights": {}, + "hook_panel_heights": {}, +}, open(os.path.join(profile, "window-layout.json"), "w"), indent=2) +PYEOF +} + +extras_count() { + python3 -c "import json;print(len(json.load(open('$LAYOUT'))['extra_windows']))" +} + +shell_eval() { + gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell \ + --method org.gnome.Shell.Eval "$1" 2>&1 +} + +okena_window_count() { + shell_eval "global.get_window_actors().filter(a => (a.meta_window.get_wm_class()||'').toLowerCase().includes('okena')).length" \ + | sed -E "s/.*'([0-9]+)'.*/\1/" +} + +# Close every okena window via real compositor close requests (== Alt+F4 / +# dock Quit). $1 is extra JS run on the `ws` array first ("" = map order, +# main first; "ws.reverse();" = extras first, main last). +close_all_okena() { + shell_eval " + let ws = global.get_window_actors() + .map(a => a.meta_window) + .filter(w => (w.get_wm_class()||'').toLowerCase().includes('okena')); + $1 + ws.forEach(w => w.delete(global.get_current_time())); + ws.length;" >/dev/null +} + +close_one_extra() { + # Topmost okena window in stacking order = the last-mapped extra. + shell_eval " + let ws = global.get_window_actors().map(a => a.meta_window) + .filter(w => (w.get_wm_class()||'').toLowerCase().includes('okena')); + ws[ws.length-1].delete(global.get_current_time());" >/dev/null +} + +start_shell() { + gnome-shell --headless --unsafe-mode --wayland-display="$WL_DISPLAY" \ + --virtual-monitor 2200x900 >"$WORK/shell.log" 2>&1 & + SHELL_PID=$! + for _ in $(seq 1 20); do + [ -S "$XDG_RUNTIME_DIR/$WL_DISPLAY" ] && break + sleep 1 + done + [ -S "$XDG_RUNTIME_DIR/$WL_DISPLAY" ] || { echo "ABORT: nested shell did not start (see $WORK/shell.log)"; exit 2; } + sleep 2 +} + +start_okena() { + WAYLAND_DISPLAY="$WL_DISPLAY" XDG_CONFIG_HOME="$CFG" "$BIN" >"$GUILOG" 2>&1 & + OKENA_PID=$! +} + +wait_okena_windows() { + local want=$1 + for _ in $(seq 1 60); do + [ "$(okena_window_count)" = "$want" ] && return 0 + sleep 1 + done + echo " (window count now: $(okena_window_count), wanted $want)" + return 1 +} + +wait_okena_exit() { + for _ in $(seq 1 30); do + if ! kill -0 "$OKENA_PID" 2>/dev/null; then OKENA_PID=""; return 0; fi + sleep 1 + done + return 1 +} + +echo "== setup (binary: $BIN) ==" +seed_config +start_shell +echo "nested shell up (pid $SHELL_PID, display $WL_DISPLAY)" + +echo "== scenario A: restore 2 extras, quit-all (main closed LAST) ==" +start_okena +if wait_okena_windows 3; then pass "A1 restore opened 3 windows (main + 2 extras)"; else fail "A1 restore did not open 3 windows"; fi +grep -q "Opening 2 extra window(s)" "$GUILOG" && pass "A2 log shows 'Opening 2 extra window(s)'" || fail "A2 missing restore log line" +sleep 2 +close_all_okena "ws.reverse();" # extras first, main last +if wait_okena_exit; then pass "A3 app exited after closing all windows"; else fail "A3 app did not exit"; kill -9 "$OKENA_PID"; OKENA_PID=""; fi +sleep 1 +c=$(extras_count) +if [ "$c" = "2" ]; then pass "A4 layout kept 2 extras after quit-all (was the bug)"; else fail "A4 layout has $c extras, expected 2"; fi + +echo "== scenario B: restore again, quit-all (main closed FIRST) ==" +start_okena +if wait_okena_windows 3; then pass "B1 restore opened 3 windows again"; else fail "B1 restore did not open 3 windows"; fi +sleep 2 +close_all_okena "" # map order: main first +if wait_okena_exit; then pass "B2 app exited"; else fail "B2 app did not exit"; kill -9 "$OKENA_PID"; OKENA_PID=""; fi +sleep 1 +c=$(extras_count) +if [ "$c" = "2" ]; then pass "B3 layout kept 2 extras (main-first order)"; else fail "B3 layout has $c extras, expected 2"; fi + +echo "== scenario C: deliberate single close still forgets (story 22) ==" +start_okena +if wait_okena_windows 3; then pass "C1 restore opened 3 windows"; else fail "C1 restore did not open 3 windows"; fi +sleep 2 +close_one_extra +sleep 8 # forget grace 5s + autosave debounce 0.5s + margin +c=$(extras_count) +if [ "$c" = "1" ]; then pass "C2 single OS close forgot the extra after grace"; else fail "C2 layout has $c extras, expected 1"; fi +wc=$(okena_window_count) +if [ "$wc" = "2" ]; then pass "C3 no zombie reopen (2 windows remain)"; else fail "C3 window count $wc, expected 2"; fi +close_all_okena "ws.reverse();" +if wait_okena_exit; then pass "C4 app exited"; else fail "C4 app did not exit"; kill -9 "$OKENA_PID"; OKENA_PID=""; fi +sleep 1 +c=$(extras_count) +if [ "$c" = "1" ]; then pass "C5 final layout kept the 1 surviving extra"; else fail "C5 layout has $c extras, expected 1"; fi + +echo "== done: $FAILURES failure(s) ==" +exit "$FAILURES" diff --git a/scripts/terminal-latency-report.py b/scripts/terminal-latency-report.py new file mode 100755 index 000000000..a36ecec41 --- /dev/null +++ b/scripts/terminal-latency-report.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Merge Okena desktop and daemon terminal-latency probe logs.""" + +from __future__ import annotations + +import argparse +import csv +import math +import re +import statistics +import sys +from pathlib import Path + +FIELD_RE = re.compile(r"([a-z_]+)=([^\s]+)") + +STAGES = [ + ("client_to_daemon_ms", "Client send → daemon WebSocket receive"), + ("daemon_bridge_ms", "Daemon WebSocket → command loop"), + ("daemon_to_pty_queue_ms", "Command loop → PTY writer queue"), + ("pty_writer_queue_ms", "PTY writer queue wait"), + ("pty_write_ms", "PTY write"), + ("pty_echo_ms", "dtach/PTY/application echo"), + ("daemon_fanout_ms", "PTY read → daemon stream queue"), + ("return_transport_ms", "Daemon stream queue → client receive"), + ("client_parse_ms", "Client receive → terminal parse"), + ("activity_emit_ms", "Parse → TerminalActivity emit"), + ("activity_delivery_ms", "TerminalActivity emit → Okena"), + ("throttle_ms", "Okena activity → repaint dispatch"), + ("notify_fanout_ms", "Repaint dispatch → pane notify"), + ("notify_to_paint_ms", "Pane notify → paint"), + ("input_to_paint_ms", "Client send → terminal paint"), + ("paint_to_frame_ms", "Paint → next GPUI frame callback"), + ("total_ms", "Client send → next GPUI frame callback"), +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("client_log", type=Path) + parser.add_argument("daemon_log", type=Path) + parser.add_argument("--terminal", help="only include one normalized terminal id") + parser.add_argument("--last", type=int, help="only include the last N matched samples") + parser.add_argument("--csv", action="store_true", help="print matched samples as CSV") + return parser.parse_args() + + +def parse_records(path: Path, marker: str) -> list[dict[str, str]]: + records = [] + for line in path.read_text(errors="replace").splitlines(): + if marker not in line: + continue + fields = dict(FIELD_RE.findall(line)) + if fields: + records.append(fields) + return records + + +def normalized_terminal(terminal_id: str) -> str: + if terminal_id.startswith("remote:"): + parts = terminal_id.split(":", 2) + if len(parts) == 3: + return parts[2] + return terminal_id + + +def record_signature(record: dict[str, str]) -> tuple[str, str, str]: + return ( + normalized_terminal(record["terminal"]), + record["input_len"], + record["input_hash"], + ) + + +def timestamp(record: dict[str, str], name: str) -> int | None: + value = int(record.get(name, "0")) + return value if value > 0 else None + + +def elapsed_ms(start: int | None, end: int | None) -> float | None: + if start is None or end is None or end < start: + return None + return (end - start) / 1_000 + + +def merge_samples( + clients: list[dict[str, str]], daemons: list[dict[str, str]] +) -> list[dict[str, object]]: + daemon_by_signature: dict[tuple[str, str, str], list[dict[str, str]]] = {} + for daemon in daemons: + daemon_by_signature.setdefault(record_signature(daemon), []).append(daemon) + + rows = [] + for client in clients: + signature = record_signature(client) + input_us = timestamp(client, "input_us") + candidates = daemon_by_signature.get(signature, []) + daemon = min( + ( + candidate + for candidate in candidates + if input_us is not None + and timestamp(candidate, "ws_receive_us") is not None + and timestamp(candidate, "ws_receive_us") >= input_us + ), + key=lambda candidate: timestamp(candidate, "ws_receive_us") - input_us, + default=None, + ) + if daemon is None: + continue + candidates.remove(daemon) + + output_receive_us = timestamp(client, "output_receive_us") + parsed_us = timestamp(client, "parsed_us") + activity_emit_us = timestamp(client, "activity_emit_us") + activity_receive_us = timestamp(client, "activity_receive_us") + throttle_fire_us = timestamp(client, "throttle_fire_us") + notify_us = timestamp(client, "notify_us") + paint_us = timestamp(client, "paint_us") + frame_us = timestamp(client, "frame_us") + + ws_receive_us = timestamp(daemon, "ws_receive_us") + bridge_us = timestamp(daemon, "bridge_us") + pty_queue_us = timestamp(daemon, "pty_queue_us") + pty_write_start_us = timestamp(daemon, "pty_write_start_us") + pty_write_end_us = timestamp(daemon, "pty_write_end_us") + pty_output_us = timestamp(daemon, "pty_output_us") + stream_us = timestamp(daemon, "stream_us") + + rows.append( + { + "terminal": signature[0], + "sample": int(client["sample"]), + "daemon_sample": int(daemon["sample"]), + "client_to_daemon_ms": elapsed_ms(input_us, ws_receive_us), + "daemon_bridge_ms": elapsed_ms(ws_receive_us, bridge_us), + "daemon_to_pty_queue_ms": elapsed_ms(bridge_us, pty_queue_us), + "pty_writer_queue_ms": elapsed_ms(pty_queue_us, pty_write_start_us), + "pty_write_ms": elapsed_ms(pty_write_start_us, pty_write_end_us), + "pty_echo_ms": elapsed_ms(pty_write_end_us, pty_output_us), + "daemon_fanout_ms": elapsed_ms(pty_output_us, stream_us), + "return_transport_ms": elapsed_ms(stream_us, output_receive_us), + "client_parse_ms": elapsed_ms(output_receive_us, parsed_us), + "activity_emit_ms": elapsed_ms(parsed_us, activity_emit_us), + "activity_delivery_ms": elapsed_ms(activity_emit_us, activity_receive_us), + "throttle_ms": elapsed_ms(activity_receive_us, throttle_fire_us), + "notify_fanout_ms": elapsed_ms(throttle_fire_us, notify_us), + "notify_to_paint_ms": elapsed_ms(notify_us, paint_us), + "input_to_paint_ms": elapsed_ms(input_us, paint_us), + "paint_to_frame_ms": elapsed_ms(paint_us, frame_us), + "total_ms": elapsed_ms(input_us, frame_us), + } + ) + return rows + + +def percentile(values: list[float], percentile_value: float) -> float: + ordered = sorted(values) + index = max(0, math.ceil(len(ordered) * percentile_value) - 1) + return ordered[index] + + +def print_summary(rows: list[dict[str, object]]) -> None: + print(f"Matched samples: {len(rows)}") + print() + print(f"{'Stage':43} {'median':>9} {'p95':>9} {'max':>9}") + for field, label in STAGES: + values = [row[field] for row in rows if isinstance(row[field], float)] + if not values: + continue + print( + f"{label:43} " + f"{statistics.median(values):8.3f} " + f"{percentile(values, 0.95):8.3f} " + f"{max(values):8.3f}" + ) + + +def print_csv(rows: list[dict[str, object]]) -> None: + fields = [ + "terminal", + "sample", + "daemon_sample", + *(field for field, _label in STAGES), + ] + writer = csv.DictWriter(sys.stdout, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def main() -> int: + args = parse_args() + clients = parse_records(args.client_log, "terminal_latency_client ") + daemons = parse_records(args.daemon_log, "terminal_latency_daemon ") + rows = merge_samples(clients, daemons) + if args.terminal: + rows = [row for row in rows if row["terminal"] == args.terminal] + if args.last is not None: + rows = rows[-args.last :] + if not rows: + print("No matching latency samples found.", file=sys.stderr) + return 1 + if args.csv: + print_csv(rows) + else: + print_summary(rows) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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 <<EOF +Okena notification propagation test +TERM=${TERM:-unknown} interval=${interval}s repeat=${repeat} + +Tip: for native notification bubbles, leave this pane unfocused or run it in a +background tab/pane. Focused-pane suppression is expected behavior. +EOF +fi + +run=1 +while [ "$run" -le "$repeat" ]; do + suffix="run ${run}/${repeat}, pid $$" + kitty_id="okena-test-${run}-$$" + + label "BEL: terminal bell (${suffix})" + emit_bell + pause + + label "OSC 9: plain notification, BEL terminated (${suffix})" + osc_bel "9;Okena OSC 9 notification (${suffix})" + pause + + label "OSC 9: plain notification, ST terminated (${suffix})" + osc_st "9;Okena OSC 9 ST notification (${suffix})" + pause + + label "OSC 777: rich notification with title/body (${suffix})" + osc_bel "777;notify;Okena OSC 777;Rich body from ${suffix}" + pause + + label "OSC 777: rich notification preserving semicolons (${suffix})" + osc_st "777;notify;Okena OSC 777 ST;body contains; semicolons; ${suffix}" + pause + + label "OSC 99: kitty title-only notification (${suffix})" + osc_bel "99;;Okena OSC 99 title-only notification (${suffix})" + pause + + label "OSC 99: kitty chunked title/body notification (${suffix})" + osc_bel "99;i=${kitty_id}:d=0;Okena OSC 99 chunked" + sleep 0.1 + osc_bel "99;i=${kitty_id}:p=body;Chunked body from ${suffix}" + pause + + label "OSC 99: kitty base64 notification (${suffix})" + # "Encoded from OSC99" in standard base64. + osc_bel "99;e=1;RW5jb2RlZCBmcm9tIE9TQzk5" + pause + + label "OSC 9;4: progress update, should NOT create a notification (${suffix})" + osc_bel "9;4;1;42" + sleep 0.25 + osc_bel "9;4;3;0" + sleep 0.25 + osc_bel "9;4;0;0" + pause + + label "OSC 133;D: command-finished activity edge, not a notification (${suffix})" + osc_st "133;D;0" + pause + + run=$((run + 1)) +done + +if [ "$quiet" -eq 0 ]; then + printf '\nDone. Re-run with --repeat N or --fast for stress testing.\n' +fi diff --git a/src/assets.rs b/src/assets.rs index dad905c0e..61e336d33 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use gpui::{AssetSource, SharedString}; use rust_embed::RustEmbed; diff --git a/src/main.rs b/src/main.rs index b355dd0d5..8dabf2871 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,12 +18,11 @@ mod smoke_tests; use okena_app::{settings, workspace}; use gpui::*; -use gpui_component::theme::{Theme as GpuiComponentTheme, ThemeMode as GpuiThemeMode}; #[cfg(not(target_os = "linux"))] use gpui_component::Root; +use gpui_component::theme::{Theme as GpuiComponentTheme, ThemeMode as GpuiThemeMode}; #[cfg(target_os = "linux")] use okena_app::simple_root::SimpleRoot as Root; -use std::sync::Arc; use std::net::IpAddr; @@ -90,32 +89,42 @@ impl std::io::Write for TeeWriter { } } -use okena_app::app::Okena; -use okena_app::app::headless::HeadlessApp; +/// Rotate one log without relying on platform-specific replacement semantics. +/// Windows does not let `rename` overwrite an existing destination, so remove +/// the old rotation target first and fail rather than truncating the active log. +fn rotate_log_file(active: &std::path::Path, previous: &std::path::Path) -> std::io::Result<()> { + if !active.exists() { + return Ok(()); + } + match std::fs::remove_file(previous) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::rename(active, previous) +} + use crate::assets::{Assets, embedded_fonts}; +use okena_app::app::Okena; use okena_app::keybindings; -use okena_app::keybindings::{About, NewWindow, Quit, ShowSettings, ShowCommandPalette, ShowThemeSelector, ShowKeybindings, ShowProfileManager}; +use okena_app::keybindings::{ + About, NewWindow, Quit, ShowCommandPalette, ShowKeybindings, ShowProfileManager, ShowSettings, + ShowThemeSelector, +}; use okena_app::logging; -use okena_app::settings::GlobalSettings; -use okena_app::terminal::pty_manager::PtyManager; use okena_app::theme::{AppTheme, GlobalTheme, ThemeMode}; -use okena_app::views::panels::toast::{Toast, ToastManager}; +use okena_app::views::panels::toast::ToastManager; use okena_app::workspace::persistence; -use okena_app::workspace::state::GlobalWorkspace; use okena_core::profiles; -/// Quit action handler - flushes pending saves before exiting +/// Quit action handler. fn quit(_: &Quit, cx: &mut App) { - // Flush pending settings save - if let Some(gs) = cx.try_global::<GlobalSettings>() { - gs.0.read(cx).flush_pending_save(); - } - - // Flush pending workspace save - if let Some(gw) = cx.try_global::<GlobalWorkspace>() - && 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(); } @@ -143,10 +152,20 @@ fn about(_: &About, _cx: &mut App) { fn msg_id(obj: *mut c_void, sel: *mut c_void, a: *mut c_void) -> *mut c_void; #[link_name = "objc_msgSend"] - fn msg_id2(obj: *mut c_void, sel: *mut c_void, a: *mut c_void, b: *mut c_void) -> *mut c_void; + fn msg_id2( + obj: *mut c_void, + sel: *mut c_void, + a: *mut c_void, + b: *mut c_void, + ) -> *mut c_void; #[link_name = "objc_msgSend"] - fn msg_bytes_len(obj: *mut c_void, sel: *mut c_void, bytes: *const u8, len: usize) -> *mut c_void; + fn msg_bytes_len( + obj: *mut c_void, + sel: *mut c_void, + bytes: *const u8, + len: usize, + ) -> *mut c_void; } unsafe { @@ -155,9 +174,8 @@ fn about(_: &About, _cx: &mut App) { let ns_string = objc_getClass(b"NSString\0".as_ptr()); // Helper: create NSString from null-terminated bytes - let nsstring = |s: &[u8]| -> *mut c_void { - msg_str(msg(ns_string, alloc), init_utf8, s.as_ptr()) - }; + let nsstring = + |s: &[u8]| -> *mut c_void { msg_str(msg(ns_string, alloc), init_utf8, s.as_ptr()) }; // Build options dictionary with version let dict = msg( @@ -262,7 +280,11 @@ fn set_app_menus(cx: &mut App) { MenuItem::os_action("Cut", okena_app::keybindings::Copy, OsAction::Cut), MenuItem::os_action("Copy", okena_app::keybindings::Copy, OsAction::Copy), MenuItem::os_action("Paste", okena_app::keybindings::Paste, OsAction::Paste), - MenuItem::os_action("Select All", okena_app::keybindings::Copy, OsAction::SelectAll), + MenuItem::os_action( + "Select All", + okena_app::keybindings::Copy, + OsAction::SelectAll, + ), ], }, Menu { @@ -278,57 +300,52 @@ fn set_app_menus(cx: &mut App) { Menu { name: "Window".into(), disabled: false, - items: vec![ - MenuItem::action("New Window", NewWindow), - ], + items: vec![MenuItem::action("New Window", NewWindow)], }, ]); } -/// `okena pair` — generate a pairing code and write it to a file for the running server to validate. -/// Global handle keeping the headless app entity alive for the process lifetime. -struct GlobalHeadless(#[allow(dead_code)] Entity<HeadlessApp>); -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<IpAddr>) -> anyhow::Result<()> { println!("Starting Okena in headless mode..."); - - Application::with_platform(gpui_platform::current_platform(true)).run(move |cx: &mut App| { - cx.set_quit_mode(QuitMode::Explicit); - - // Initialize global settings (must be before workspace load) - let settings_entity = settings::init_settings(cx); - 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")); - persistence::default_workspace() - }); - - // Create PTY manager - let (pty_manager, pty_events) = PtyManager::new(app_settings.session_backend); - let pty_manager = Arc::new(pty_manager); - - // 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. - let headless = cx.new(|cx| { - HeadlessApp::new( - workspace_data, - pty_manager, - pty_events, - listen_addr, - app_settings.remote_tls_enabled, - cx, - ) - }); - cx.set_global(GlobalHeadless(headless)); + let app_settings = okena_workspace::settings::load_settings(); + let session_backend = app_settings.session_backend; + let loaded_workspace = persistence::load_workspace_with_cleanup_for_shell( + session_backend, + &app_settings.default_shell, + ) + .unwrap_or_else(|error| { + log::error!( + "Failed to load workspace: {}. A backup may have been saved to {:?}. Using default workspace.", + error, + persistence::get_workspace_path().with_extension("json.bak") + ); + persistence::LoadedWorkspace { + data: persistence::default_workspace(), + stale_terminal_ids: Vec::new(), + } }); + let listen_addrs = + okena_remote_server::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; + let params = okena_daemon_core::DaemonParams { + workspace_data: loaded_workspace.data, + stale_terminal_ids: loaded_workspace.stale_terminal_ids, + settings: app_settings, + session_backend, + listen_addrs, + tls_enabled, + ui_owned: std::env::args().any(|arg| arg == "--ui-owned"), + }; + okena_daemon_core::DaemonCore::new(params)?.run() } fn main() { + if let Err(error) = okena_remote_server::local::remember_current_executable() { + eprintln!("Warning: failed to remember executable path: {error}"); + } + // Handle --version before initializing anything (used by updater validation) if std::env::args().any(|a| a == "--version") { println!("okena {}", env!("CARGO_PKG_VERSION")); @@ -404,8 +421,34 @@ fn main() { }; // SAFETY: called before any threads are spawned; no concurrent reads of the environment. unsafe { std::env::set_var("OKENA_PROFILE", &profile_paths.id) }; - let profile_log = profile_paths.log_path(); - let profile_log_prev = profile_paths.root.join("okena.log.1"); + // Pick the log filename BEFORE rotating/creating it. A single-binary daemon + // (`okena --headless [--ui-owned]`) reuses this same `src/main.rs` logging + // init as the GUI, so if both wrote `okena.log` they would rotate+clobber + // each other's history (and the standalone `okena-daemon.log` tee — which + // only exists in the separate `okena-daemon` binary — is never produced in + // ui-owned mode). Give the headless process its own `okena-headless.log` + // (with its own `.1` rotation) so the GUI's `okena.log` stays legible. This + // mirrors the headless detection performed in full further down (explicit + // `--headless`, or Linux `--listen`/`--remote` with no display); it is + // recomputed here only because logging is initialized before that block. + let log_is_headless = { + let explicit_headless = args.iter().any(|a| a == "--headless"); + let wants_listen = args.iter().any(|a| a == "--listen" || a == "--remote"); + let has_display = + std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok(); + explicit_headless || (cfg!(target_os = "linux") && wants_listen && !has_display) + }; + let (profile_log, profile_log_prev) = if log_is_headless { + ( + profile_paths.root.join("okena-headless.log"), + profile_paths.root.join("okena-headless.log.1"), + ) + } else { + ( + profile_paths.log_path(), + profile_paths.root.join("okena.log.1"), + ) + }; profiles::init_profile(profile_paths); // Migrate legacy flat-layout state into profiles/default/ if needed. @@ -414,6 +457,37 @@ 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() { @@ -423,11 +497,31 @@ fn main() { // Set up file logging: rotate previous log, write to both stderr and file let log_target = (|| -> Option<env_logger::fmt::Target> { let root = &profiles::current().root; - std::fs::create_dir_all(root).ok()?; - if profile_log.exists() { - let _ = std::fs::rename(&profile_log, &profile_log_prev); + if let Err(error) = std::fs::create_dir_all(root) { + eprintln!( + "Warning: could not create log directory '{}': {error}", + root.display() + ); + return None; + } + if let Err(error) = rotate_log_file(&profile_log, &profile_log_prev) { + eprintln!( + "Warning: could not rotate log '{}' to '{}'; leaving the active log intact: {error}", + profile_log.display(), + profile_log_prev.display() + ); + return None; } - let file = std::fs::File::create(&profile_log).ok()?; + let file = match std::fs::File::create(&profile_log) { + Ok(file) => file, + Err(error) => { + eprintln!( + "Warning: could not create log '{}': {error}", + profile_log.display() + ); + return None; + } + }; Some(env_logger::fmt::Target::Pipe(Box::new(TeeWriter { stderr: std::io::stderr(), file, @@ -490,30 +584,34 @@ fn main() { // 2. Auto-detect on Linux: --listen provided but no DISPLAY/WAYLAND_DISPLAY let explicit_headless = args.iter().any(|a| a == "--headless"); 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); + 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 <old>` + // (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 <addr>, e.g. --headless --listen 0.0.0.0"); + if let Err(error) = run_headless(listen_addr) { + eprintln!("Failed to start headless daemon: {error:#}"); std::process::exit(1); - }; - run_headless(addr); + } return; } if !has_display && cfg!(target_os = "linux") { eprintln!("No display server found (DISPLAY/WAYLAND_DISPLAY not set)."); - eprintln!("Use --headless --listen <addr> to run without a GUI."); + eprintln!("Use --headless [--listen <addr>] to run without a GUI."); std::process::exit(1); } @@ -646,22 +744,26 @@ 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). + let mut client_project_layouts = std::collections::HashMap::new(); + if let Some(layout) = persistence::load_window_layout() { + workspace_data.main_window = layout.main_window; + workspace_data.extra_windows = layout.extra_windows; + workspace_data.service_panel_heights = layout.service_panel_heights; + workspace_data.hook_panel_heights = layout.hook_panel_heights; + client_project_layouts = layout.project_layouts; + } // Create theme entity from settings, restoring custom theme if applicable let theme_entity = cx.new(|_cx| { @@ -698,9 +800,26 @@ 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); + } + }; + if let Some(local_build) = cx + .try_global::<okena_ext_updater::GlobalLocalBuild>() + .map(|global| global.0.clone()) + { + local_build.update(cx, |state, cx| { + state.set_daemon_ui_owned(local_daemon.daemon.ui_owned, cx); + }); + } // Create the main window #[allow( @@ -785,22 +904,14 @@ fn main() { }) .detach(); - // Main owns the Okena coordinator. If it closes while extras - // are still open, LastWindowClosed would otherwise leave - // orphaned WindowViews running without the app root. - window.on_window_should_close(cx, |_window, cx| { - cx.quit(); - true - }); - - // Wire up content pane registration so PTY events can notify terminal views + // Wire up content pane registration so remote activity events can notify terminal views okena_views_terminal::set_register_content_pane_fn(Box::new(|terminal_id, weak_content| { let mut registry = okena_app::views::window::content_pane_registry().lock(); let panes = registry.entry(terminal_id).or_default(); // Re-layouts (e.g. workspace switch) re-register the same // terminal, minting fresh panes. Drop dead weaks and skip an // entity already present so the vec stays bounded by live - // viewers and a live pane isn't notified twice per PTY event. + // viewers and a live pane isn't notified twice per activity event. let new_id = weak_content.entity_id(); panes.retain(|w| w.upgrade().is_some()); if !panes.iter().any(|w| w.entity_id() == new_id) { @@ -810,8 +921,28 @@ 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, + client_project_layouts, + local_daemon, + window, + cx, + ) }); + + // Main owns the Okena coordinator. If it closes while extras + // are still open, LastWindowClosed would otherwise leave + // orphaned WindowViews running without the app root. Flag the + // quit BEFORE cx.quit(): compositor quit-alls deliver a close + // to every window, and pending extra-window forgets must not + // commit during teardown or the final layout save loses them. + let okena_for_close = okena.clone(); + window.on_window_should_close(cx, move |_window, cx| { + okena_for_close.read(cx).note_quitting(); + cx.quit(); + true + }); + cx.new(|cx| Root::new(okena, window, cx)) }, ) @@ -821,21 +952,36 @@ fn main() { cx.activate(true); } - // Flush pending saves on ALL quit paths (including window X button). - // The Quit action handler only runs for Ctrl+Q / menu quit, not for - // QuitMode::LastWindowClosed. on_app_quit fires for every exit path. - let _quit_sub = cx.on_app_quit(|cx| { - // Flush pending settings save - if let Some(gs) = cx.try_global::<GlobalSettings>() { - gs.0.read(cx).flush_pending_save(); - } - - // Flush pending workspace save - if let Some(gw) = cx.try_global::<GlobalWorkspace>() - && let Err(e) = persistence::save_workspace(gw.0.read(cx).data()) { - log::error!("Failed to flush workspace on quit: {}", e); - } - async {} - }); }); } + +#[cfg(test)] +mod log_rotation_tests { + use super::rotate_log_file; + + #[test] + fn rotation_replaces_existing_previous_file_without_truncating_active() { + let directory = std::env::temp_dir().join(format!( + "okena-log-rotation-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after epoch") + .as_nanos() + )); + std::fs::create_dir(&directory).expect("create log directory"); + let active = directory.join("okena-headless.log"); + let previous = directory.join("okena-headless.log.1"); + std::fs::write(&active, "active log").expect("write active log"); + std::fs::write(&previous, "old rotation").expect("write old rotation"); + + rotate_log_file(&active, &previous).expect("rotate log"); + + assert!(!active.exists()); + assert_eq!( + std::fs::read_to_string(&previous).expect("read rotation"), + "active log" + ); + std::fs::remove_dir_all(directory).expect("remove log directory"); + } +} diff --git a/src/smoke_tests.rs b/src/smoke_tests.rs index 47c03b3a7..4384075b2 100644 --- a/src/smoke_tests.rs +++ b/src/smoke_tests.rs @@ -11,16 +11,14 @@ mod tests { fn init_globals(cx: &mut gpui::TestAppContext) { cx.update(|cx| { // Settings entity - let settings_entity = cx.new(|_cx| { - okena_app::settings::SettingsState::new(Default::default()) - }); + let settings_entity = + cx.new(|_cx| okena_app::settings::SettingsState::new(Default::default())); cx.set_global(okena_app::settings::GlobalSettings(settings_entity)); // Theme — AppTheme is a GPUI Entity, not a Global - let theme_entity = cx.new(|_cx| okena_app::theme::AppTheme::new( - okena_core::theme::ThemeMode::Dark, - false, - )); + let theme_entity = cx.new(|_cx| { + okena_app::theme::AppTheme::new(okena_core::theme::ThemeMode::Dark, false) + }); cx.set_global(okena_app::theme::GlobalTheme(theme_entity)); // Theme provider for view crates @@ -30,7 +28,10 @@ mod tests { // UI font size provider for view crates cx.set_global(okena_ui::tokens::GlobalUiFontSize(|cx| { - okena_app::settings::settings_entity(cx).read(cx).settings.ui_font_size + okena_app::settings::settings_entity(cx) + .read(cx) + .settings + .ui_font_size })); // Extension settings store (used by terminal and git view crates) @@ -38,27 +39,35 @@ mod tests { |namespace, cx| { let s = okena_app::settings::settings_entity(cx).read(cx); match namespace { - "terminal" => serde_json::to_value(&okena_views_terminal::TerminalViewSettings { - font_size: s.settings.font_size, - line_height: s.settings.line_height, - font_family: s.settings.font_family.clone(), - cursor_style: s.settings.cursor_style, - cursor_blink: s.settings.cursor_blink, - show_focused_border: s.settings.show_focused_border, - show_shell_selector: s.settings.show_shell_selector, - idle_timeout_secs: s.settings.idle_timeout_secs, - color_tinted_background: s.settings.color_tinted_background, - file_opener: s.settings.file_opener.clone(), - default_shell: s.settings.default_shell.clone(), - hooks: s.settings.hooks.clone(), - ctrl_c_copies_selection: s.settings.terminal_ctrl_c_copies_selection, - }).ok(), - "git" => serde_json::to_value(&okena_views_git::settings::GitViewSettings { - diff_view_mode: s.settings.diff_view_mode, - diff_ignore_whitespace: s.settings.diff_ignore_whitespace, - file_font_size: s.settings.file_font_size, - is_dark: true, - }).ok(), + "terminal" => { + serde_json::to_value(&okena_views_terminal::TerminalViewSettings { + font_size: s.settings.font_size, + line_height: s.settings.line_height, + font_family: s.settings.font_family.clone(), + cursor_style: s.settings.cursor_style, + cursor_blink: s.settings.cursor_blink, + show_focused_border: s.settings.show_focused_border, + show_shell_selector: s.settings.show_shell_selector, + idle_timeout_secs: s.settings.idle_timeout_secs, + color_tinted_background: s.settings.color_tinted_background, + file_opener: s.settings.file_opener.clone(), + default_shell: s.settings.default_shell.clone(), + hooks: s.settings.hooks.clone(), + ctrl_c_copies_selection: s + .settings + .terminal_ctrl_c_copies_selection, + }) + .ok() + } + "git" => { + serde_json::to_value(&okena_views_git::settings::GitViewSettings { + diff_view_mode: s.settings.diff_view_mode, + diff_ignore_whitespace: s.settings.diff_ignore_whitespace, + file_font_size: s.settings.file_font_size, + is_dark: true, + }) + .ok() + } _ => s.settings.extension_settings.get(namespace).cloned(), } }, 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<StateResponse> { return res.json(); } -export async function postAction(action: ActionRequest): Promise<Record<string, unknown>> { +export async function postAction(action: ActionRequest): Promise<unknown> { 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..b8028ae3d 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<string, string>; 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,133 @@ 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: "record_project_activity"; project_id: string } | { 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_list_pull_requests"; project_id: string; limit?: number } | { 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: "dismiss_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; + show_ignored?: boolean; + } + | { 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: "list_sessions" } + | { action: "load_session"; name: string } + | { action: "save_session"; name: string } + | { action: "rename_session"; old_name: string; new_name: string } + | { action: "delete_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 +411,21 @@ export type WsInbound = export type WsOutbound = | { type: "auth_ok" } | { type: "auth_failed"; error: string } - | { type: "subscribed"; mappings: Record<string, number> } + | { type: "subscribed"; mappings: Record<string, number>; sizes?: Record<string, [number, number]> } | { type: "state_changed"; state_version: number } | { type: "dropped"; count: number } | { type: "pong" } | { type: "error"; error: string } - | { type: "git_status_changed"; projects: Record<string, ApiGitStatus> }; + | { type: "git_status_changed"; projects: Record<string, ApiGitStatus> } + | { 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 +437,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<DirectoryState>({ status: "idle", entries: [] }); + const [fileState, setFileState] = useState<FileState>({ 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 ( + <div + className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4 py-5" + onMouseDown={onClose} + > + <section + className="flex h-[min(760px,92vh)] w-[min(1120px,96vw)] flex-col border border-[var(--ok-border)] bg-[var(--ok-panel)]" + onMouseDown={(event) => event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={`Files for ${project.name}`} + > + <header className="project-header flex min-h-[44px] items-center gap-3 border-b px-3"> + <div className="min-w-0 flex-1"> + <div className="truncate text-[13px] font-bold text-[var(--ok-text)]">{project.name}</div> + <div className="mt-0.5 truncate text-[10px] text-[var(--ok-text-muted)]"> + {directory || "."} + </div> + </div> + <label className="flex items-center gap-2 text-[11px] text-[var(--ok-text-secondary)]"> + <input + type="checkbox" + checked={showIgnored} + onChange={(event) => setShowIgnored(event.currentTarget.checked)} + className="h-3 w-3" + /> + ignored + </label> + <button + className="icon-button" + onClick={goUp} + disabled={!directory} + title="Parent directory" + aria-label="Parent directory" + > + .. + </button> + <button + className="icon-button" + onClick={loadDirectory} + disabled={directoryState.status === "loading"} + title="Refresh directory" + aria-label="Refresh directory" + > + R + </button> + <button className="icon-button icon-button-danger" onClick={onClose} aria-label="Close file viewer"> + x + </button> + </header> + + <div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]"> + <aside className="min-h-0 border-r border-[var(--ok-border)] bg-[var(--ok-terminal-muted)]"> + <div className="soft-rule border-b px-3 py-2 text-[10px] text-[var(--ok-text-muted)]"> + directory + </div> + <div className="h-full overflow-auto px-2 py-2"> + <DirectoryListing + state={directoryState} + entries={sortedEntries} + onOpenEntry={openEntry} + /> + </div> + </aside> + + <main className="min-w-0 overflow-hidden bg-[var(--ok-terminal)]"> + <FilePreview state={fileState} /> + </main> + </div> + </section> + </div> + ); +} + +function DirectoryListing({ + state, + entries, + onOpenEntry, +}: { + state: DirectoryState; + entries: DirectoryEntry[]; + onOpenEntry: (entry: DirectoryEntry) => void; +}) { + if (state.status === "error") { + return <div className="px-1 py-1 text-[11px] text-[var(--ok-red)]">{state.message}</div>; + } + if (entries.length === 0) { + return ( + <div className="px-1 py-1 text-[11px] text-[var(--ok-text-muted)]"> + {state.status === "loading" ? "Loading..." : "Empty directory"} + </div> + ); + } + + return ( + <div className="space-y-0.5 pb-8"> + {entries.map((entry) => ( + <button + key={`${entry.is_dir ? "d" : "f"}:${entry.name}`} + onClick={() => onOpenEntry(entry)} + className="grid w-full grid-cols-[1rem_minmax(0,1fr)] gap-1 rounded-[3px] px-1 py-1 text-left text-[11px] text-[var(--ok-text-secondary)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text)]" + > + <span className="text-[var(--ok-text-muted)]">{entry.is_dir ? "/" : "-"}</span> + <span className="truncate">{entry.name}</span> + </button> + ))} + </div> + ); +} + +function FilePreview({ state }: { state: FileState }) { + if (state.status === "empty") { + return ( + <div className="flex h-full items-center justify-center text-[12px] text-[var(--ok-text-muted)]"> + Select a file + </div> + ); + } + if (state.status === "loading") { + return <div className="px-4 py-3 text-[12px] text-[var(--ok-text-muted)]">Loading {state.path}...</div>; + } + if (state.status === "error") { + return ( + <div className="px-4 py-3 text-[12px]"> + <div className="truncate text-[var(--ok-text-secondary)]">{state.path}</div> + <div className="mt-2 text-[var(--ok-red)]">{state.message}</div> + </div> + ); + } + + return ( + <div className="flex h-full min-h-0 flex-col text-[12px]"> + <div className="soft-rule flex min-h-[34px] items-center border-b bg-[var(--ok-terminal-muted)] px-3 text-[var(--ok-text-secondary)]"> + <span className="truncate">{state.path}</span> + <span className="ml-auto text-[10px] text-[var(--ok-text-muted)]"> + {state.content.split("\n").length} lines + </span> + </div> + <pre className="min-h-0 flex-1 overflow-auto whitespace-pre px-4 py-3 font-mono leading-5 text-[var(--ok-text)]"> + {state.content} + </pre> + </div> + ); +} + +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<string, unknown> { + 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<DirectoryState>({ status: "idle", entries: [] }); + const [fileState, setFileState] = useState<FileState>({ 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 ( + <aside className="flex h-full w-80 flex-shrink-0 flex-col border-l border-zinc-800 bg-zinc-950"> + <div className="flex items-center gap-2 border-b border-zinc-800 px-3 py-2 text-xs"> + <span className="text-zinc-600">files</span> + <span className="min-w-0 flex-1 truncate text-zinc-300">{directory || "."}</span> + <button + onClick={goUp} + disabled={!directory} + className="text-zinc-600 hover:text-zinc-300 disabled:opacity-30" + > + up + </button> + <button + onClick={loadDirectory} + className="text-zinc-600 hover:text-zinc-300" + disabled={directoryState.status === "loading"} + > + {directoryState.status === "loading" ? "loading" : "refresh"} + </button> + </div> + + <label className="flex items-center gap-2 border-b border-zinc-900 px-3 py-1.5 text-xs text-zinc-500"> + <input + type="checkbox" + checked={showIgnored} + onChange={(event) => setShowIgnored(event.currentTarget.checked)} + className="h-3 w-3" + /> + show ignored + </label> + + <div className="h-44 flex-shrink-0 overflow-auto border-b border-zinc-800 px-2 py-1"> + {directoryState.status === "error" ? ( + <div className="px-1 py-1 text-xs text-red-400">{directoryState.message}</div> + ) : sortedEntries.length === 0 ? ( + <div className="px-1 py-1 text-xs text-zinc-600"> + {directoryState.status === "loading" ? "Loading..." : "Empty directory"} + </div> + ) : ( + <div className="space-y-0.5"> + {sortedEntries.map((entry) => ( + <button + key={`${entry.is_dir ? "d" : "f"}:${entry.name}`} + onClick={() => openEntry(entry)} + className="grid w-full grid-cols-[1rem_minmax(0,1fr)] gap-1 px-1 py-0.5 text-left text-xs text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200" + > + <span className="text-zinc-600">{entry.is_dir ? "/" : "-"}</span> + <span className="truncate">{entry.name}</span> + </button> + ))} + </div> + )} + </div> + + <div className="min-h-0 flex-1 overflow-auto"> + <FilePreview state={fileState} /> + </div> + </aside> + ); +} + +function FilePreview({ state }: { state: FileState }) { + if (state.status === "empty") { + return <div className="px-3 py-2 text-xs text-zinc-600">Select a file</div>; + } + if (state.status === "loading") { + return <div className="px-3 py-2 text-xs text-zinc-600">Loading {state.path}...</div>; + } + if (state.status === "error") { + return ( + <div className="px-3 py-2 text-xs"> + <div className="truncate text-zinc-500">{state.path}</div> + <div className="mt-2 text-red-400">{state.message}</div> + </div> + ); + } + + return ( + <div className="text-xs"> + <div className="sticky top-0 border-b border-zinc-900 bg-zinc-950 px-3 py-2 text-zinc-500"> + <span className="block truncate">{state.path}</span> + </div> + <pre className="whitespace-pre-wrap break-words px-3 py-2 font-mono leading-5 text-zinc-300"> + {state.content} + </pre> + </div> + ); +} + +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<string, unknown> { + 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<LoadState>({ 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 ( + <div className="soft-rule border-b bg-[var(--ok-panel)] px-3 py-2 text-[11px] text-[var(--ok-text-muted)]"> + git unavailable + </div> + ); + } + + return ( + <section className="panel-rule relative border-b bg-[var(--ok-panel)]"> + <div className="flex min-h-[32px] items-center gap-1 overflow-x-auto px-3 py-1.5"> + <button className={CHIP_CLASS} onClick={() => setDetailsOpen((open) => !open)} title="Git status"> + <span className="text-[var(--ok-text-muted)]">branch</span> + <span className="max-w-32 truncate text-[var(--ok-text)]">{git.branch}</span> + </button> + + {git.pr_info && ( + <a + className={CHIP_CLASS} + href={git.pr_info.url} + target="_blank" + rel="noreferrer" + title={`PR #${git.pr_info.number} ${prStateLabel(git.pr_info.state)}`} + > + <span className={prStateClass(git.pr_info.state)}>pr</span> + <span>#{git.pr_info.number}</span> + </a> + )} + + {git.ci_checks && ( + <button className={CHIP_CLASS} onClick={() => setDetailsOpen((open) => !open)} title={ciTooltip(git.ci_checks)}> + <span className={ciClassName(git.ci_checks.status)}>{ciStatusLabel(git.ci_checks.status)}</span> + <span>{git.ci_checks.passed}/{git.ci_checks.total}</span> + </button> + )} + + {hasChanges && ( + <button className={CHIP_CLASS} onClick={() => setDetailsOpen((open) => !open)} title="Working tree changes"> + {git.lines_added > 0 && <span className="text-[var(--ok-green)]">+{git.lines_added}</span>} + {git.lines_removed > 0 && <span className="text-[var(--ok-red)]">-{git.lines_removed}</span>} + </button> + )} + + {aheadBehindParts(git).map((part) => ( + <button + key={part.key} + className={CHIP_CLASS} + onClick={() => setDetailsOpen((open) => !open)} + title={part.title} + > + <span className={part.className}>{part.label}</span> + </button> + ))} + + <button + className={`${CHIP_CLASS} ml-auto`} + onClick={() => { + if (!detailsOpen && hasChanges && loadState.files.length === 0) { + loadSummary(); + } + setDetailsOpen((open) => !open); + }} + > + details + </button> + </div> + + {detailsOpen && ( + <GitStatusPopover + git={git} + loadState={loadState} + onRefresh={loadSummary} + onClose={() => setDetailsOpen(false)} + /> + )} + </section> + ); +} + +function GitStatusPopover({ + git, + loadState, + onRefresh, + onClose, +}: { + git: ApiGitStatus; + loadState: LoadState; + onRefresh: () => void; + onClose: () => void; +}) { + const checks = git.ci_checks?.checks ?? []; + + return ( + <div className="absolute left-3 right-3 top-full z-40 mt-1 max-h-[520px] overflow-hidden border border-[var(--ok-border)] bg-[var(--ok-panel)]"> + <div className="project-header flex min-h-[34px] items-center gap-2 border-b px-3"> + <span className="text-[11px] text-[var(--ok-text-muted)]">git status</span> + <span className="min-w-0 truncate text-[12px] text-[var(--ok-text)]">{git.branch ?? "detached"}</span> + <button className="icon-button ml-auto" onClick={onClose} aria-label="Close git status"> + x + </button> + </div> + + <div className="max-h-[486px] overflow-auto px-3 py-3"> + <div className="grid grid-cols-2 gap-2 text-[11px]"> + <Metric label="added" value={`+${git.lines_added}`} valueClassName="text-[var(--ok-green)]" /> + <Metric label="removed" value={`-${git.lines_removed}`} valueClassName="text-[var(--ok-red)]" /> + <Metric label="ahead" value={`${git.ahead ?? 0}`} valueClassName="text-[var(--ok-green)]" /> + <Metric label="behind" value={`${git.behind ?? 0}`} valueClassName="text-[var(--ok-yellow)]" /> + <Metric label="unpushed" value={`${git.unpushed ?? 0}`} valueClassName="text-[var(--ok-blue)]" /> + <Metric label="base" value={git.review_base ?? "-"} valueClassName="text-[var(--ok-text)]" /> + </div> + + {git.pr_info && ( + <section className="soft-rule mt-3 border-t pt-3"> + <div className="mb-2 text-[10px] text-[var(--ok-text-muted)]">pull request</div> + <a + href={git.pr_info.url} + target="_blank" + rel="noreferrer" + className="flex items-center gap-2 rounded-[3px] px-1 py-1 text-[11px] hover:bg-[var(--ok-hover)]" + > + <span className={prStateClass(git.pr_info.state)}>{prStateLabel(git.pr_info.state)}</span> + <span className="text-[var(--ok-text)]">#{git.pr_info.number}</span> + <span className="ml-auto text-[var(--ok-text-muted)]">open</span> + </a> + </section> + )} + + {git.ci_checks && ( + <section className="soft-rule mt-3 border-t pt-3"> + <div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--ok-text-muted)]"> + <span>checks</span> + <span className={ciClassName(git.ci_checks.status)}>{ciTooltip(git.ci_checks)}</span> + </div> + {checks.length === 0 ? ( + <div className="text-[11px] text-[var(--ok-text-muted)]">No checks reported</div> + ) : ( + <div className="space-y-0.5"> + {checks.map((check) => ( + <CiRow key={`${check.workflow ?? "check"}:${check.name}`} check={check} /> + ))} + </div> + )} + </section> + )} + + <section className="soft-rule mt-3 border-t pt-3"> + <div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--ok-text-muted)]"> + <span>diff summary</span> + <button + className="ml-auto rounded-[3px] px-1.5 py-0.5 hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text)]" + onClick={onRefresh} + disabled={loadState.status === "loading"} + > + {loadState.status === "loading" ? "loading" : "refresh"} + </button> + </div> + <DiffSummary state={loadState} /> + </section> + </div> + </div> + ); +} + +function Metric({ + label, + value, + valueClassName, +}: { + label: string; + value: string; + valueClassName: string; +}) { + return ( + <div className="border border-[var(--ok-border-soft)] bg-[var(--ok-terminal-muted)] px-2 py-1.5"> + <div className="text-[10px] text-[var(--ok-text-muted)]">{label}</div> + <div className={`mt-0.5 truncate text-[12px] font-bold ${valueClassName}`}>{value}</div> + </div> + ); +} + +function CiRow({ check }: { check: CiCheck }) { + const content = ( + <> + <span className={check.is_skipped ? "text-[var(--ok-text-muted)]" : ciClassName(check.status)}> + {check.is_skipped ? "skip" : ciStatusLabel(check.status)} + </span> + <span className="min-w-0 flex-1 truncate text-[var(--ok-text)]">{check.name}</span> + {check.workflow && <span className="hidden max-w-28 truncate text-[var(--ok-text-muted)] md:inline">{check.workflow}</span>} + <span className="text-[var(--ok-text-muted)]">{formatElapsed(check.elapsed_ms)}</span> + </> + ); + + if (check.link) { + return ( + <a + href={check.link} + target="_blank" + rel="noreferrer" + title={check.description} + className="flex items-center gap-2 rounded-[3px] px-1 py-1 text-[11px] hover:bg-[var(--ok-hover)]" + > + {content} + </a> + ); + } + + return ( + <div title={check.description} className="flex items-center gap-2 px-1 py-1 text-[11px]"> + {content} + </div> + ); +} + +function DiffSummary({ state }: { state: LoadState }) { + if (state.status === "error") { + return <div className="text-[11px] text-[var(--ok-red)]">{state.message}</div>; + } + if (state.files.length === 0) { + return ( + <div className="text-[11px] text-[var(--ok-text-muted)]"> + {state.status === "loading" ? "Loading..." : "No file changes"} + </div> + ); + } + + return ( + <div className="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-x-3 gap-y-1 text-[11px]"> + {state.files.slice(0, 20).map((file) => ( + <FileRow key={file.path} file={file} /> + ))} + {state.files.length > 20 && ( + <div className="col-span-4 text-[var(--ok-text-muted)]">+{state.files.length - 20} more files</div> + )} + </div> + ); +} + +function FileRow({ file }: { file: FileDiffSummary }) { + return ( + <> + <div className="truncate text-[var(--ok-text-secondary)]">{file.path}</div> + <div className="text-right text-[var(--ok-green)]">+{file.added}</div> + <div className="text-right text-[var(--ok-red)]">-{file.removed}</div> + <div className="text-right text-[var(--ok-text-muted)]">{file.is_new ? "new" : ""}</div> + </> + ); +} + +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/<branch>`, + }); + } + + 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<string, unknown> { + 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 ( - <div className="flex items-center justify-center h-screen"> - <div className="w-80 space-y-6"> - <div className="text-center"> - <h1 className="text-2xl font-bold text-zinc-100">Okena</h1> - <p className="mt-2 text-sm text-zinc-400"> + <div className="app-shell flex h-screen items-center justify-center"> + <div className="w-[360px] border border-[var(--ok-border)] bg-[var(--ok-panel)]"> + <div className="project-header border-b border-[var(--ok-border)] px-4 py-3"> + <h1 className="text-[15px] font-bold text-[var(--ok-text)]">Okena</h1> + <p className="mt-1 text-[11px] text-[var(--ok-text-secondary)]"> Enter the pairing code from the desktop app status bar </p> </div> - <form onSubmit={handleSubmit} className="space-y-4"> + <form onSubmit={handleSubmit} className="space-y-4 px-4 py-4"> <input ref={inputRef} type="text" @@ -45,23 +45,21 @@ export function PairingScreen({ onPaired }: { onPaired: () => 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 && ( - <p className="text-sm text-red-400 text-center">{error}</p> + <p className="text-center text-[12px] text-[var(--ok-red)]">{error}</p> )} <button type="submit" disabled={loading || code.trim().length === 0} - className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-700 - disabled:text-zinc-500 text-white font-medium rounded-lg - transition-colors" + className="w-full bg-[var(--ok-blue)] py-3 font-bold text-white transition-colors + hover:bg-[#005a9e] disabled:bg-[var(--ok-header)] disabled:text-[var(--ok-text-muted)]" > {loading ? "Pairing..." : "Connect"} </button> 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 ( - <div className="bg-zinc-900 overflow-y-auto h-full"> - <div className="px-3 py-3"> - <h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2"> - Projects + <div className="h-full overflow-y-auto bg-[var(--ok-panel)]"> + <div className="border-b border-[var(--ok-border)] px-3 py-3"> + <div className="text-[13px] font-bold leading-4 text-[var(--ok-text)]">Okena</div> + <div className="mt-1 text-[10px] text-[var(--ok-text-muted)]">remote workspace</div> + </div> + <div className="px-2 py-2"> + <h2 className="mb-2 px-1 text-[10px] font-bold text-[var(--ok-text-muted)]"> + projects </h2> <div className="space-y-0.5"> - {projects.map((p) => ( - <SidebarProject - key={p.id} - project={p} - selected={p.id === state.selectedProjectId} - isMobile={isMobile} - /> - ))} + {items.map((item) => { + if (item.type === "folder") { + return ( + <div key={item.folder.id} className="space-y-0.5"> + <div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-[var(--ok-text-secondary)]"> + <span className="truncate">{item.folder.name}</span> + <span className="ml-auto text-[var(--ok-text-muted)]">{item.projects.length}</span> + </div> + {item.projects.map((node) => ( + <SidebarProject + key={node.project.id} + project={node.project} + worktrees={node.worktrees} + selected={node.project.id === state.selectedProjectId} + isMobile={isMobile} + depth={1} + /> + ))} + </div> + ); + } + + return ( + <SidebarProject + key={item.project.id} + project={item.project} + worktrees={item.worktrees} + selected={item.project.id === state.selectedProjectId} + isMobile={isMobile} + /> + ); + })} </div> </div> </div> diff --git a/web/src/components/SidebarProject.tsx b/web/src/components/SidebarProject.tsx index 25fe9951e..94e7cedea 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: "record_project_activity", project_id: projectId }).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: "record_project_activity", project_id: worktree.id }).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 ( - <div> - <button - onClick={handleProjectClick} - className={`w-full text-left px-2 py-1.5 rounded text-sm truncate transition-colors flex items-center gap-1 - ${selected ? "bg-zinc-700 text-zinc-100" : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"}`} - > - <span - className="text-[10px] transition-transform duration-150 flex-shrink-0" - style={{ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)" }} + <div className={muted ? "opacity-70" : undefined}> + <div className="flex items-center gap-0.5" style={{ paddingLeft: depth * 10 }}> + <button + onClick={selectProject} + className={`flex min-w-0 flex-1 items-center gap-1 rounded-[3px] px-2 py-1.5 text-left text-[12px] transition-colors + ${selected ? "bg-[var(--ok-selection)] text-white" : "text-[var(--ok-text-secondary)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text)]"}`} > - ▶ - </span> - <span className="truncate">{project.name}</span> + <span + className="w-3 flex-shrink-0 text-[10px] text-[var(--ok-text-muted)] transition-transform duration-150" + style={{ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)" }} + > + {hasChildren ? "▶" : ""} + </span> + <span className="truncate">{project.name}</span> + {project.pinned && <span className="text-[10px] text-[var(--ok-text-muted)]">pin</span>} + {worktrees.length > 0 && <span className="text-[10px] text-[var(--ok-text-muted)]">wt {worktrees.length}</span>} + </button> <button - onClick={handleCreateTerminal} - className="ml-auto flex-shrink-0 p-0.5 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-600 rounded text-xs leading-none" + onClick={createTerminal} + className="icon-button h-[22px] w-[22px] flex-shrink-0" title="New terminal" + aria-label="New terminal" > + </button> - </button> + <button + onClick={togglePinned} + className={`icon-button h-[22px] w-[22px] flex-shrink-0 ${ + project.pinned ? "text-[var(--ok-text)]" : "" + }`} + title={project.pinned ? "Unpin project" : "Pin project"} + aria-label={project.pinned ? "Unpin project" : "Pin project"} + > + P + </button> + <button + onClick={toggleVisible} + className="icon-button h-[22px] w-[22px] flex-shrink-0" + title={project.show_in_overview ? "Hide from overview" : "Show in overview"} + aria-label={project.show_in_overview ? "Hide from overview" : "Show in overview"} + > + {project.show_in_overview ? "H" : "S"} + </button> + </div> - {isExpanded && terminalIds.length > 0 && ( + {isExpanded && ( <div className="ml-4 mt-0.5 space-y-0.5"> - {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 ( <button - key={tid} - onClick={() => handleTerminalClick(tid)} - className={`w-full text-left px-2 py-1 rounded text-xs truncate transition-colors - ${isSelected ? "bg-zinc-600 text-zinc-100" : "text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300"}`} + key={terminalId} + onClick={() => selectTerminal(project.id, terminalId)} + className={`w-full truncate rounded-[3px] px-2 py-1 text-left text-[11px] transition-colors + ${isSelected ? "bg-[var(--ok-header)] text-[var(--ok-text)]" : "text-[var(--ok-text-muted)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text-secondary)]"}`} > {name} </button> ); })} + + {services.length > 0 && ( + <div className="pt-1"> + <div className="flex items-center px-2 pb-0.5 text-[10px] text-[var(--ok-text-muted)]"> + <span>services</span> + <button + onClick={reloadServices} + className="ml-auto hover:text-[var(--ok-text-secondary)]" + title="Reload services" + > + reload + </button> + </div> + <div className="space-y-0.5"> + {services.map((service) => ( + <ServiceRow + key={`${service.kind ?? "service"}:${service.name}`} + service={service} + onOpenTerminal={(terminalId) => selectTerminal(project.id, terminalId)} + onStart={() => runServiceAction(service, "start_service")} + onStop={() => runServiceAction(service, "stop_service")} + onRestart={() => runServiceAction(service, "restart_service")} + /> + ))} + </div> + </div> + )} + + {worktrees.length > 0 && ( + <div className="pt-1"> + <div className="px-2 pb-0.5 text-[10px] text-[var(--ok-text-muted)]">worktrees</div> + <div className="space-y-0.5"> + {worktrees.map((worktree) => ( + <button + key={worktree.id} + onClick={() => selectWorktree(worktree)} + className={`w-full truncate rounded-[3px] px-2 py-1 text-left text-[11px] transition-colors + ${worktree.id === state.selectedProjectId ? "bg-[var(--ok-header)] text-[var(--ok-text)]" : "text-[var(--ok-text-muted)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text-secondary)]"}`} + > + <span className="truncate">{worktree.name}</span> + {worktree.git_status?.branch && ( + <span className="ml-1 text-[var(--ok-text-muted)]">{worktree.git_status.branch}</span> + )} + </button> + ))} + </div> + </div> + )} </div> )} </div> ); } + +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 ( + <div className="flex items-center gap-1 rounded-[3px] px-2 py-1 text-[11px] text-[var(--ok-text-muted)] hover:bg-[var(--ok-hover)]"> + <button + className="min-w-0 flex-1 truncate text-left hover:text-[var(--ok-text-secondary)]" + onClick={() => service.terminal_id && onOpenTerminal(service.terminal_id)} + disabled={!service.terminal_id} + title={service.terminal_id ? "Open service terminal" : undefined} + > + <span className="text-[var(--ok-text-secondary)]">{service.name}</span> + <span className="ml-1 text-[var(--ok-text-muted)]">{status}{ports}{crash}</span> + </button> + {canStart ? ( + <button className="hover:text-[var(--ok-green)]" onClick={onStart} title="Start service"> + start + </button> + ) : ( + <button className="hover:text-[var(--ok-red)] disabled:opacity-30" onClick={onStop} disabled={!canStop} title="Stop service"> + stop + </button> + )} + <button className="hover:text-[var(--ok-text-secondary)]" onClick={onRestart} title="Restart service"> + restart + </button> + </div> + ); +} 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<HTMLDivElement>(null); const [localSizes, setLocalSizes] = useState<number[] | null>(null); + const [activeDivider, setActiveDivider] = useState<number | null>(null); const draggingRef = useRef(false); + const liveSizesRef = useRef<number[]>(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<HTMLDivElement>, 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 ( <div ref={containerRef} - className={`flex h-full ${isHorizontal ? "flex-row" : "flex-col"}`} + className={`flex h-full ${isHorizontalSplit ? "flex-col" : "flex-row"}`} > {children.map((child, i) => ( <div key={i} className="contents"> {i > 0 && ( <div - className={`flex-none ${ - isHorizontal - ? "w-1 cursor-col-resize hover:bg-blue-500" - : "h-1 cursor-row-resize hover:bg-blue-500" - } bg-zinc-700 transition-colors`} - onMouseDown={(e) => 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)} /> )} <div className="min-w-0 min-h-0 overflow-hidden" - style={{ flex: `${(sizes[i] ?? 1) / total}` }} + style={{ + flexBasis: 0, + flexGrow: (sizes[i] ?? 1) / total, + flexShrink: 1, + }} > <LayoutRenderer node={child} project={project} path={[...path, i]} /> </div> 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<string, string> = { - 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 ( - <div className="flex items-center gap-2 px-3 py-1 bg-zinc-900 border-t border-zinc-800 text-xs text-zinc-500"> + <div className="panel-rule flex items-center gap-2 border-t bg-[var(--ok-header)] px-3 py-1 text-[11px] text-[var(--ok-text-secondary)]"> <span className={`inline-block w-2 h-2 rounded-full ${STATUS_COLORS[state.wsStatus]}`} /> diff --git a/web/src/components/TabLayout.tsx b/web/src/components/TabLayout.tsx index 3dd551fc4..83b1ee142 100644 --- a/web/src/components/TabLayout.tsx +++ b/web/src/components/TabLayout.tsx @@ -1,7 +1,17 @@ -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"; +function firstTerminalId(node: ApiLayoutNode): string | null { + if (node.type === "terminal") return node.terminal_id; + for (const child of node.children) { + const terminalId = firstTerminalId(child); + if (terminalId) return terminalId; + } + return null; +} + export function TabLayout({ activeTab: initialActive, children, @@ -13,34 +23,68 @@ export function TabLayout({ project: ApiProject; path: number[]; }) { - const [activeIdx, setActiveIdx] = useState(initialActive); - const clamped = Math.min(activeIdx, children.length - 1); + const initialIdx = Math.min(initialActive, Math.max(children.length - 1, 0)); + const [activeTerminalId, setActiveTerminalId] = useState(() => + children[initialIdx] ? firstTerminalId(children[initialIdx]) : null, + ); + const identityIdx = activeTerminalId + ? children.findIndex((child) => firstTerminalId(child) === activeTerminalId) + : -1; + const clamped = identityIdx >= 0 ? identityIdx : initialIdx; + + useEffect(() => { + if (identityIdx < 0) { + setActiveTerminalId(children[initialIdx] ? firstTerminalId(children[initialIdx]) : null); + } + }, [children, identityIdx, initialIdx]); + + const selectTab = useCallback( + (index: number) => { + setActiveTerminalId(children[index] ? firstTerminalId(children[index]) : null); + }, + [children], + ); + + const addTab = useCallback(() => { + postAction({ + action: "add_tab", + project_id: project.id, + path, + in_group: true, + }).catch(() => {}); + }, [project.id, path]); return ( <div className="flex flex-col h-full"> - {/* Tab bar */} - <div className="flex bg-zinc-900 border-b border-zinc-800 flex-shrink-0"> + <div className="terminal-header flex flex-shrink-0 border-b"> {children.map((child, i) => { const label = child.type === "terminal" && child.terminal_id ? (project.terminal_names[child.terminal_id] ?? `Terminal ${i + 1}`) : `Tab ${i + 1}`; return ( <button - key={i} - onClick={() => setActiveIdx(i)} - className={`px-3 py-1.5 text-xs truncate max-w-32 transition-colors + key={firstTerminalId(child) ?? `tab-${i}`} + onClick={() => selectTab(i)} + className={`max-w-32 truncate border-r border-[var(--ok-border)] px-3 py-1.5 text-[11px] transition-colors ${i === clamped - ? "bg-zinc-800 text-zinc-100 border-b-2 border-blue-500" - : "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50" + ? "bg-[var(--ok-selection)] text-white" + : "text-[var(--ok-text-muted)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text)]" }`} > {label} </button> ); })} + <button + onClick={addTab} + className="icon-button ml-auto h-[30px] w-[30px]" + title="New tab" + aria-label="New tab" + > + + + </button> </div> - {/* Active tab content */} <div className="flex-1 min-h-0"> {children[clamped] && ( <LayoutRenderer node={children[clamped]} project={project} path={[...path, clamped]} /> diff --git a/web/src/components/TerminalPane.tsx b/web/src/components/TerminalPane.tsx index 93bc828a5..81abc74d2 100644 --- a/web/src/components/TerminalPane.tsx +++ b/web/src/components/TerminalPane.tsx @@ -76,7 +76,7 @@ export function TerminalPane({ // Actions const handleFocus = useCallback(() => { if (!terminalId) return; - postAction({ action: "focus_terminal", project_id: projectId, terminal_id: terminalId }).catch(() => {}); + postAction({ action: "record_project_activity", project_id: projectId }).catch(() => {}); }, [terminalId, projectId]); const handleSplit = useCallback((direction: "horizontal" | "vertical") => { @@ -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 ( - <div className="flex flex-col h-full" onMouseDown={handleFocus}> - {/* Header with name and action buttons */} - <div className="flex items-center flex-shrink-0 px-2 py-1 bg-zinc-900 border-b border-zinc-800"> - <span className="text-xs text-zinc-500 truncate flex-1"> + <div className="terminal-pane flex h-full flex-col" onMouseDown={handleFocus}> + <div className="terminal-header flex flex-shrink-0 items-center border-b px-2"> + <span className="min-w-0 flex-1 truncate text-[11px] text-[var(--ok-text-secondary)]"> {name ?? "Terminal"} </span> <div className="flex items-center gap-0.5 ml-2"> @@ -198,24 +209,35 @@ export function TerminalPane({ <> <button onClick={(e) => { e.stopPropagation(); handleSplit("horizontal"); }} - className="p-1 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 rounded text-xs" + className="icon-button" title="Split horizontal" + aria-label="Split horizontal" > - │ + ─ </button> <button onClick={(e) => { e.stopPropagation(); handleSplit("vertical"); }} - className="p-1 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 rounded text-xs" + className="icon-button" title="Split vertical" + aria-label="Split vertical" > - ─ + │ </button> </> )} + <button + onClick={(e) => { e.stopPropagation(); handleRename(); }} + className="icon-button" + title="Rename terminal" + aria-label="Rename terminal" + > + R + </button> <button onClick={(e) => { e.stopPropagation(); handleClose(); }} - className="p-1 text-zinc-500 hover:text-red-400 hover:bg-zinc-700 rounded text-xs" + className="icon-button icon-button-danger" title="Close terminal" + aria-label="Close terminal" > ✕ </button> 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<string | null>(null); + const projects = resolveOverviewProjects( + state.workspace?.projects ?? [], + state.selectedProjectId, ); + const fileViewerProject = state.workspace?.projects.find((project) => project.id === fileViewerProjectId); + useRequestGitPollForVisibleProjects(projects, state.selectedProjectId); return ( - <div className="flex flex-col h-screen"> + <div className="app-shell flex h-screen flex-col"> <div className="flex flex-1 min-h-0"> - <aside className="w-56 flex-shrink-0 border-r border-zinc-800"> + <aside className="app-sidebar w-64 flex-shrink-0 border-r"> <Sidebar /> </aside> - <main className="flex-1 min-w-0"> - {project?.layout ? ( - <TerminalArea layout={project.layout} project={project} /> - ) : ( - <div className="flex items-center justify-center h-full text-zinc-500"> - {state.workspace ? ( - project ? ( - <button - className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors" - onClick={() => postAction({ action: "create_terminal", project_id: project.id })} - > - New Terminal - </button> - ) : ( - "Select a project" - ) + <main className="project-overview flex min-w-0 flex-1 flex-col"> + <div className="project-strip"> + {state.workspace ? ( + projects.length > 0 ? ( + projects.map((project) => ( + <ProjectColumn + key={project.id} + project={project} + selected={project.id === state.selectedProjectId} + onOpenFiles={() => setFileViewerProjectId(project.id)} + /> + )) ) : ( - "Loading..." - )} - </div> - )} + <OverviewEmptyState label="No projects in this workspace" /> + ) + ) : ( + <OverviewEmptyState label="Loading workspace..." /> + )} + </div> </main> </div> <StatusBar /> + {fileViewerProject && ( + <FileViewerModal + project={fileViewerProject} + onClose={() => setFileViewerProjectId(null)} + /> + )} + </div> + ); +} + +function useRequestGitPollForVisibleProjects( + projects: readonly ApiProject[], + selectedProjectId: string | null, +): void { + const previousVisibleProjectIds = useRef<Set<string>>(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 ( + <section + className="project-column flex min-h-0 flex-col" + data-selected={selected} + onMouseDown={() => dispatch({ type: "select_project", projectId: project.id })} + > + <div className="h-px flex-shrink-0" style={{ backgroundColor: accent }} /> + <header className="project-header flex min-h-[44px] flex-shrink-0 items-center gap-3 border-b px-3 py-2"> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <span + className="h-2 w-2 flex-shrink-0 rounded-full" + style={{ backgroundColor: accent }} + /> + <h2 className="truncate text-[13px] font-bold leading-4 text-[var(--ok-text)]"> + {project.name} + </h2> + {project.pinned && ( + <span className="rounded-[3px] border border-[var(--ok-border)] px-1 text-[10px] text-[var(--ok-text-muted)]"> + pin + </span> + )} + </div> + <div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--ok-text-muted)]"> + <span className="truncate">{compactPath(project.path)}</span> + <span>{terminalCount} term{terminalCount === 1 ? "" : "s"}</span> + </div> + </div> + <div className="flex flex-shrink-0 items-center gap-1"> + <button + className="rounded-[3px] px-2 py-1 text-[11px] text-[var(--ok-text-muted)] hover:bg-[var(--ok-hover)] hover:text-[var(--ok-text)]" + title="Open files" + onMouseDown={(event) => event.stopPropagation()} + onClick={onOpenFiles} + > + files + </button> + <button + className="icon-button" + title="New terminal" + aria-label="New terminal" + onMouseDown={(event) => event.stopPropagation()} + onClick={() => postAction({ action: "create_terminal", project_id: project.id }).catch(() => {})} + > + + + </button> + <button + className="icon-button" + title={project.show_in_overview ? "Hide from overview" : "Show in overview"} + aria-label={project.show_in_overview ? "Hide from overview" : "Show in overview"} + onMouseDown={(event) => event.stopPropagation()} + onClick={() => + postAction({ + action: "set_project_show_in_overview", + project_id: project.id, + show: !project.show_in_overview, + }).catch(() => {}) + } + > + {project.show_in_overview ? "H" : "S"} + </button> + </div> + </header> + <GitPanel project={project} /> + <div className="min-h-0 flex-1"> + {project.layout ? ( + <TerminalArea layout={project.layout} project={project} /> + ) : ( + <ProjectEmptyState project={project} /> + )} + </div> + </section> + ); +} + +function ProjectEmptyState({ project }: { project: ApiProject }) { + return ( + <div className="flex h-full items-center justify-center bg-[var(--ok-panel)] px-4 text-center"> + <div className="max-w-72"> + <div className="mb-2 text-[11px] text-[var(--ok-text-muted)]">empty project</div> + <button + className="border border-[var(--ok-border)] bg-[var(--ok-header)] px-3 py-2 text-[12px] text-[var(--ok-text)] hover:bg-[var(--ok-hover)]" + onClick={() => postAction({ action: "create_terminal", project_id: project.id }).catch(() => {})} + > + New Terminal + </button> + </div> </div> ); } +function OverviewEmptyState({ label }: { label: string }) { + return ( + <div className="flex h-full flex-1 items-center justify-center text-[12px] text-[var(--ok-text-muted)]"> + {label} + </div> + ); +} + +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<string>(); + + 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<string>(); + 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); +}