diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index d3261b31e..1c1d34510 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -134,6 +134,16 @@ pub enum Action { tool: Harness, name: String, }, + OpenSession { + id: String, + }, + PreviewSessionCleanup { + id: String, + }, + RemoveSession { + id: String, + preview_token: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -456,7 +466,7 @@ pub struct Controller { generation: u64, route_reviews: std::collections::HashMap, tool_review: Option, - + session_cleanup: Option<(String, String, Value)>, account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, @@ -472,7 +482,7 @@ impl Controller { generation: 0, route_reviews: Default::default(), tool_review: None, - + session_cleanup: None, account_archive: None, route_change: None, update_review: None, @@ -817,7 +827,13 @@ impl Controller { // Account and session folders open in the editor too, because a terminal may run // over SSH, where `open` would start a file manager on another screen. Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), - + Action::OpenSession { id } => { + let receipt = sessions::managed_session(id)?; + let worktree = receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?; + Ok(Some(PathBuf::from(worktree))) + } _ => Ok(None), } } @@ -1293,6 +1309,37 @@ impl Controller { server::command("open", &[&path.display().to_string()])?; Ok(Reply::message("Opened the account folder.".into())) } + Action::OpenSession { id } => { + let receipt = sessions::managed_session(&id)?; + server::command( + "open", + &[receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?], + )?; + Ok(Reply::message("Opened the session folder.".into())) + } + Action::PreviewSessionCleanup { id } => { + let data = sessions::cleanup_preview(&id)?; + let token = uuid::Uuid::now_v7().to_string(); + self.session_cleanup = Some((id, token.clone(), data.clone())); + let mut data = data; + data["preview_token"] = json!(token); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::RemoveSession { id, preview_token } => { + let (expected_id, token, data) = self + .session_cleanup + .as_ref() + .ok_or("Review session cleanup first.")?; + if &id != expected_id || preview_token.as_deref() != Some(token.as_str()) { + return Err("Review session cleanup again.".into()); + } + sessions::cleanup(&id, data).map(Reply::message) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), @@ -1750,6 +1797,169 @@ policy = { type = "target_selector", selector = "/target" } } } + // The subprocess isolates harness discovery and every launch destination. + #[cfg(unix)] + #[test] + fn session_launch_requires_an_unchanged_review_before_creating_files() { + use std::os::unix::fs::PermissionsExt; + use std::{fs, process::Command}; + if let Some(home) = std::env::var_os("SWITCHYARD_LAUNCH_REVIEW_FIXTURE") { + let home = PathBuf::from(home); + let project = home.join("project"); + let git = |args: &[&str]| { + let result = Command::new("/usr/bin/git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + }; + fs::create_dir(&project).expect("project"); + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + let settings = home.join("desktop.toml"); + let server = home.join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + fs::write(&server, text).expect("server"); + fs::write( + &settings, + format!( + "config_file={server:?}\nrouting_log={:?}\n", + home.join("routing.jsonl") + ), + ) + .expect("settings"); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let preview = |controller: &mut Controller| { + controller + .dispatch(Action::PreviewLaunch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: project.display().to_string(), + }) + .expect("preview") + }; + let launch = |token: Option, path: String| Action::Launch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: path, + preview_token: token, + }; + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + for rejected in [ + launch(None, project.display().to_string()), + launch(Some("wrong".into()), project.display().to_string()), + launch(Some(token.clone()), home.display().to_string()), + ] { + assert!(controller.dispatch(rejected).is_err()); + } + fs::write(&server, format!("{text}# external edit\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + assert_eq!( + fs::read_to_string(&server).expect("preserved"), + format!("{text}# external edit\n") + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + let original = fs::read_to_string(&settings).expect("settings"); + fs::write(&settings, format!("{original}# external settings\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "changed", + ]); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .err() + .expect("changed HEAD") + .contains("project changed") + ); + assert!(!home.join(".switchyard/worktrees").exists()); + assert!(!home.join("terminal-called").exists()); + assert_eq!(fs::read_dir(&home).expect("files").count(), 4); + return; + } + let home = tempfile::tempdir().expect("fixture"); + let bin = home.path().join("bin"); + fs::create_dir(&bin).expect("bin"); + for name in ["codex", "open", "osascript"] { + let path = bin.join(name); + fs::write( + &path, + "#!/bin/sh\ntouch \"$HOME/terminal-called\"\nexit 0\n", + ) + .expect("stub"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable"); + } + let output=Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", "controller::tests::session_launch_requires_an_unchanged_review_before_creating_files"]) + .env("SWITCHYARD_LAUNCH_REVIEW_FIXTURE",home.path()).env("HOME",home.path()) + .env("PATH",format!("{}:/usr/bin:/bin",bin.display())).output().expect("child"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } #[test] // An algorithm switch must not give stale choices a revision for externally edited bytes. fn algorithm_switches_keep_the_first_revision_and_external_edits_reject_it() { diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index 3f7538d94..076d0c32b 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -134,6 +134,114 @@ pub fn inventory() -> Result, String> { Ok(sessions) } +pub fn managed_session(id: &str) -> Result { + let suffix = id.strip_prefix("sy-").ok_or("Choose a managed session.")?; + uuid::Uuid::parse_str(suffix).map_err(|_| "Choose a managed session.")?; + let root = session_root()?; + let private = root.join(format!("{id}-settings")); + let worktree = root.join(id); + for path in [&root, &private, &worktree] { + if std::fs::symlink_metadata(path) + .map_err(|error| error.to_string())? + .file_type() + .is_symlink() + { + return Err("Session directories must not be symlinks.".into()); + } + } + let receipt: serde_json::Value = serde_json::from_slice( + &std::fs::read(private.join("session.json")).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + if receipt["id"] != id || receipt["worktree"] != worktree.display().to_string() { + return Err("The session receipt does not match this worktree.".into()); + } + Ok(receipt) +} + +pub fn cleanup_preview(id: &str) -> Result { + let mut receipt = managed_session(id)?; + let worktree = receipt["worktree"] + .as_str() + .ok_or("Missing session worktree.")? + .to_string(); + let run = |args: &[&str]| -> Result { + let output = std::process::Command::new("git") + .args(["-C", &worktree]) + .args(args) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + // Plain `git worktree remove` deletes ignored files, so the review lists them. + let status = run(&["status", "--porcelain", "--ignored=matching"])?; + receipt["dirty"] = serde_json::json!(status.lines().any(|line| !line.starts_with("!! "))); + receipt["ignored"] = serde_json::json!( + status + .lines() + .filter_map(|line| line.strip_prefix("!! ")) + .collect::>() + ); + receipt["current_revision"] = serde_json::json!(run(&["rev-parse", "HEAD"])?); + receipt["current_branch"] = serde_json::json!(run(&["branch", "--show-current"])?); + receipt["cleanup_notice"] = serde_json::json!( + "Cleanup removes a clean worktree and its unchanged session branch. \ + It also deletes the Git-ignored files in the worktree, which the ignored list names. \ + Recorded usage stays in the routing log. Unknown private files are preserved." + ); + Ok(receipt) +} + +pub fn cleanup(id: &str, reviewed: &serde_json::Value) -> Result { + let current = cleanup_preview(id)?; + if ¤t != reviewed { + return Err("The session changed since review. Review cleanup again.".into()); + } + if current["dirty"] != false + || current["current_revision"] != current["revision"] + || current["current_branch"] != id + { + return Err("Save or move the session’s work before cleanup. \ + Cleanup requires a clean worktree with its original commit and branch." + .into()); + } + let repository = current["repository"] + .as_str() + .ok_or("Missing session repository.")?; + let worktree = current["worktree"] + .as_str() + .ok_or("Missing session worktree.")?; + let revision = current["revision"] + .as_str() + .ok_or("Missing session commit.")?; + // Receipt path resolution must succeed before cleanup removes the worktree. + let private = session_root()?.join(format!("{id}-settings")); + let output = std::process::Command::new("git") + .args(["-C", repository, "worktree", "remove", worktree]) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + + // The worktree is gone, so the receipt goes too; otherwise the inventory keeps the session. + // Cleanup removes only the receipt because settings can contain later user edits. + std::fs::remove_file(private.join("session.json")).map_err(|error| error.to_string())?; + let _ = std::fs::remove_dir(&private); + if !delete_session_branch(Path::new(repository), id, revision) { + return Ok(format!( + "Removed the clean session worktree. Git kept the branch {id}; \ + inspect it before deleting it. Retained private settings and recorded usage." + )); + } + Ok("Removed the clean session worktree and branch. \ + Retained private settings and recorded usage." + .into()) +} + pub fn launch( tool: Harness, project: &Path, @@ -367,3 +475,357 @@ pub fn launch( worktree.display() )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_session_setup_removes_its_worktree_branch_and_private_settings() { + let dir = tempfile::tempdir().expect("directory"); + let project = dir.path().join("project"); + std::fs::create_dir(&project).expect("project"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output + }; + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ]); + let worktree = dir.path().join("worktree"); + let private = dir.path().join("settings"); + git(&[ + "worktree", + "add", + "-b", + "session-fixture", + worktree.to_str().expect("path"), + ]); + std::fs::create_dir(&private).expect("private"); + std::fs::write(private.join("settings.json"), "{}").expect("settings"); + let revision = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the session branch. + git(&["checkout", "--orphan", "unrelated"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + drop(PendingSession { + project: project.clone(), + worktree: worktree.clone(), + private: private.clone(), + branch: "session-fixture".into(), + revision, + opened: false, + }); + assert!(!worktree.exists()); + assert!(!private.exists()); + assert!( + !String::from_utf8_lossy(&git(&["branch", "--list", "session-fixture"]).stdout) + .contains("session-fixture") + ); + } + // The subprocess isolates HOME and PATH. + // The Terminal stub injects a conflicting directory when the script must select one. + // Claude's default case requires an unset CLAUDE_CONFIG_DIR, not an explicit ~/.claude. + #[cfg(unix)] + #[test] + fn generated_sessions_use_explicit_accounts_without_changing_user_defaults() { + use std::os::unix::fs::PermissionsExt; + if let Ok(fixture) = std::env::var("SWITCHYARD_SESSION_FIXTURE") { + let fixture = std::path::PathBuf::from(fixture); + let tool = if std::env::var("SWITCHYARD_SESSION_TOOL").expect("tool") == "codex" { + Harness::CodexCli + } else { + Harness::Claude + }; + let account = + std::env::var_os("SWITCHYARD_SESSION_ACCOUNT").map(std::path::PathBuf::from); + let revision = preview(&fixture.join("project")).expect("preview")["revision"] + .as_str() + .expect("revision") + .to_string(); + // Advancing the original checkout makes a launch from HEAD differ from the review. + if tool == Harness::CodexCli && account.is_none() { + let committed = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "advanced", + ]) + .output() + .expect("advance HEAD"); + assert!(committed.status.success()); + } + launch( + tool, + &fixture.join("project"), + "route'$(literal)", + "http://localhost:4123", + tool == Harness::Claude, + account.as_deref(), + &revision, + ) + .expect("launch"); + if tool == Harness::CodexCli && account.is_none() { + let records = inventory().expect("inventory"); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["revision"], revision); + assert_ne!( + preview(&fixture.join("project")).expect("advanced project")["revision"], + revision + ); + let id = records[0]["id"].as_str().expect("session id"); + let receipt = session_root() + .expect("session root") + .join(format!("{id}-settings/session.json")); + let original = std::fs::read(&receipt).expect("receipt"); + // Valid JSON roots must still be objects before inventory adds process_status. + for malformed in ["null", "[]", "false", "42"] { + std::fs::write(&receipt, malformed).expect("malformed receipt"); + assert!(inventory().is_err()); + assert!(cleanup_preview(id).is_err()); + } + std::fs::write(&receipt, original).expect("restore receipt"); + let clean = cleanup_preview(id).expect("preview cleanup"); + let worktree = Path::new(records[0]["worktree"].as_str().expect("worktree")); + let unsaved = worktree.join("unsaved-user-work.txt"); + std::fs::write(&unsaved, "keep this work").expect("user work"); + assert!(cleanup(id, &clean).is_err()); + assert!(unsaved.exists()); + let dirty = cleanup_preview(id).expect("dirty preview"); + assert!(cleanup(id, &dirty).is_err()); + assert!(worktree.exists()); + std::fs::remove_file(&unsaved).expect("save fixture work"); + let clean = cleanup_preview(id).expect("clean preview"); + // Cleanup deletes ignored files, so a new ignored file requires a new review. + let info = fixture.join("project/.git/info"); + std::fs::create_dir_all(&info).expect("git info"); + std::fs::write(info.join("exclude"), "ignored-notes.txt\n").expect("exclude"); + std::fs::write(worktree.join("ignored-notes.txt"), "notes").expect("notes"); + assert!(cleanup(id, &clean).is_err()); + assert!(worktree.join("ignored-notes.txt").exists()); + let reviewed = cleanup_preview(id).expect("ignored preview"); + assert_eq!( + reviewed["ignored"], + serde_json::json!(["ignored-notes.txt"]) + ); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the branch. + let project_git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args(args) + .output() + .expect("git"); + assert!(output.status.success()); + output + }; + project_git(&["checkout", "--orphan", "unrelated"]); + project_git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + cleanup(id, &reviewed).expect("safe cleanup"); + assert!(!worktree.exists()); + assert!(inventory().expect("inventory after cleanup").is_empty()); + assert!(project_git(&["branch", "--list", id]).stdout.is_empty()); + } + return; + } + for (name, named, inherited_dir) in [ + ("codex", false, true), + ("codex", true, true), + ("claude", false, true), + ("claude", true, true), + ("claude", false, false), + ("claude", true, false), + ] { + let fixture = tempfile::tempdir().expect("fixture"); + let home = fixture.path().join("home"); + let bin = fixture.path().join("bin"); + let project = fixture.path().join("project"); + for path in [&home, &bin, &project] { + std::fs::create_dir(path).expect("directory"); + } + for args in [ + vec!["init"], + vec![ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + ] { + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git") + .status + .success() + ); + } + let default = home.join("default-login"); + std::fs::create_dir(&default).expect("default"); + std::fs::write(default.join("settings.json"), "{\"user\":true}").expect("settings"); + let account = home.join(".switchyard/accounts").join(name).join("named"); + if named { + std::fs::create_dir_all(&account).expect("account"); + } + let output = fixture.path().join("arguments"); + let captured = fixture.path().join("session.command"); + // macOS runs the script through `open`; Linux runs it through the program in + // TERMINAL, which receives `-e` before the script path. + let open = bin.join(if cfg!(target_os = "linux") { + "terminal" + } else { + "open" + }); + let terminal_claude = if inherited_dir || named { + "export CLAUDE_CONFIG_DIR=wrong-terminal-login" + } else { + "unset CLAUDE_CONFIG_DIR" + }; + std::fs::write( + &open, + format!( + "#!/bin/bash\n\ + [ \"$1\" = -e ] && shift\n\ + cp -- \"$1\" {}\n\ + export CODEX_HOME=wrong-terminal-login ANTHROPIC_AUTH_TOKEN=wrong-token\n\ + {terminal_claude}\n\ + bash \"$1\"\n", + quote(&captured.display().to_string()) + ), + ) + .expect("open"); + let tool = bin.join(name); + std::fs::write( + &tool, + format!( + "#!/bin/bash\n\ + printf '%s\\n' \"${{CODEX_HOME:-}}\" \"${{CLAUDE_CONFIG_DIR:-}}\" \ + \"${{ANTHROPIC_AUTH_TOKEN:-}}\" \"$@\" > {0}.partial && mv {0}.partial {0}\n", + quote(&output.display().to_string()) + ), + ) + .expect("tool"); + for path in [&open, &tool] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("executable"); + } + let mut child = std::process::Command::new(std::env::current_exe().expect("runner")); + child + .args([ + "--exact", + "sessions::tests::\ + generated_sessions_use_explicit_accounts_without_changing_user_defaults", + ]) + .env("SWITCHYARD_SESSION_FIXTURE", fixture.path()) + .env("SWITCHYARD_SESSION_TOOL", name) + .env("HOME", &home) + .env("CODEX_HOME", &default) + .env("CLAUDE_CONFIG_DIR", &default) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .env("TERMINAL", &open); + if named { + child.env("SWITCHYARD_SESSION_ACCOUNT", &account); + } + if !inherited_dir { + child.env_remove("CLAUDE_CONFIG_DIR"); + } + let result = child.output().expect("child"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + // A Linux terminal starts apart from the launch, so the arguments can arrive + // after the child exits. The tool stub renames the file into place when done. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !output.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let arguments = std::fs::read_to_string(output).expect("arguments"); + let lines: Vec<_> = arguments.lines().collect(); + let expected = if named { + account.to_str().expect("path") + } else if inherited_dir { + default.to_str().expect("path") + } else { + "" + }; + assert_eq!(lines[usize::from(name == "claude")], expected); + if name == "claude" { + assert_eq!(lines[2], ""); + } + assert!(arguments.contains("route'$(literal)")); + let script = std::fs::read_to_string(captured).expect("script"); + assert!(script.contains("x-switchyard-session-id")); + if name == "claude" && !named && !inherited_dir { + assert!(!script.contains("export CLAUDE_CONFIG_DIR=")); + } + assert_eq!( + std::fs::read_to_string(default.join("settings.json")).expect("settings"), + "{\"user\":true}" + ); + } + } +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 0a73f9dd4..4882d60ef 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -150,7 +150,7 @@ const SESSIONS_KEYS: &[Key] = &[ ( "s", "session", - "Select the next recorded session and show its details.", + "Select the next recorded session. o opens its folder and d removes it.", ), ]; const SETTINGS_KEYS: &[Key] = &[ @@ -323,6 +323,21 @@ const ACTIONS: &[ActionRow] = &[ "Creates a saved account and opens the harness's login in a new terminal window.", &[ActionKind::AddAccount], ), + ( + Tab::Sessions, + 'o', + "open session", + "Opens the selected session folder in $VISUAL or $EDITOR.", + &[ActionKind::OpenSession], + ), + ( + Tab::Sessions, + 'd', + "remove", + "Shows what cleanup deletes, including Git-ignored files, then removes the clean \ + worktree and its session branch after you type REMOVE.", + &[ActionKind::PreviewSessionCleanup, ActionKind::RemoveSession], + ), ( Tab::Settings, 'o', @@ -977,7 +992,7 @@ impl Form { Action::Restore { .. } => "Restoring the harness settings", Action::ArchiveAccount { .. } => "Archiving the account", Action::Launch { .. } => "Launching the session", - + Action::RemoveSession { .. } => "Removing the session", _ => "Saving the route", }, FormKind::Remove { .. } => "Deleting the route", @@ -1134,7 +1149,7 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { id, project, }, - + Action::PreviewSessionCleanup { id } => Action::RemoveSession { id, preview_token }, _ => return None, }) } @@ -1799,6 +1814,17 @@ impl App { ))); } } + (Some(ActionKind::PreviewSessionCleanup), Ok(reply)) if popup_matches => { + if let Some(remove) = action.and_then(|action| reviewed(action, &reply.data)) { + self.set_popup(Some(Popup::review( + cleanup_lines(self.theme, &reply.data), + "Remove", + FormKind::Confirm("REMOVE".into(), Box::new(remove)), + "Remove the session worktree?", + "Type REMOVE to delete it", + ))); + } + } (Some(ActionKind::Models), Ok(reply)) if popup_matches => { let models: Vec<&str> = reply.data["models"] .as_array() @@ -2400,6 +2426,19 @@ impl App { vec![("Git project folder", String::new())], )) } + // Controller::editor_target returns the session worktree, so it opens in the editor. + ActionKind::OpenSession => { + let id = self.session_id()?; + request(Action::OpenSession { id }, "Opening the session folder") + } + // The review window lists the Git-ignored files that cleanup deletes and submits + // RemoveSession. The controller rejects the removal when the worktree changed after + // the review. + ActionKind::PreviewSessionCleanup | ActionKind::RemoveSession => { + let id = self.session_id()?; + let cleanup = Action::PreviewSessionCleanup { id }; + request(cleanup, "Reviewing the session cleanup") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -4884,6 +4923,45 @@ fn launch_lines(theme: Theme, preview: &Value) -> Vec> { lines } +/// This function shows a cleanup preview: the worktree, whether it has uncommitted changes, +/// and the Git-ignored files that cleanup deletes. Like the desktop app, it names at most 20 +/// files and counts the rest. +fn cleanup_lines(theme: Theme, preview: &Value) -> Vec> { + let field = |name: &str, value: String| labeled(theme, name, 11, value); + let mut lines = vec![ + Line::from(text(preview, "cleanup_notice")), + Line::default(), + field("Directory", text(preview, "worktree")), + field( + "Branch", + text_or(preview, "current_branch", "Detached HEAD"), + ), + field("Revision", text(preview, "current_revision")), + Line::default(), + if preview["dirty"] == true { + Line::styled( + "This worktree has uncommitted changes. Cleanup will refuse to remove it.", + theme.warn, + ) + } else { + Line::styled("The worktree has no uncommitted changes.", theme.ok) + }, + ]; + let ignored: Vec<&str> = preview["ignored"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + if !ignored.is_empty() { + lines.push(Line::default()); + // The review lists every path the confirmation deletes; the popup scrolls. + lines.push(Line::from("Cleanup also deletes these Git-ignored files:")); + lines.extend(ignored.iter().map(|file| Line::from(file.to_string()))); + } + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| {