diff --git a/src-tauri/src/commands/folder_commands.rs b/src-tauri/src/commands/folder_commands.rs index 2c5b7ec45a..fc214f2d70 100644 --- a/src-tauri/src/commands/folder_commands.rs +++ b/src-tauri/src/commands/folder_commands.rs @@ -78,6 +78,9 @@ pub(crate) fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String commands } +/// Every command, `run_local` ones too: this app's own frontend is the only +/// caller here, and it knows that flag (unlike an older client of the HTTP +/// route, which `web::handlers::folder_commands` guards against). #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn list_folder_commands( @@ -94,8 +97,16 @@ pub async fn create_folder_command( folder_id: i32, name: String, command: String, + run_local: Option, ) -> Result { - folder_command_service::create(&db.conn, folder_id, &name, &command).await + folder_command_service::create( + &db.conn, + folder_id, + &name, + &command, + run_local.unwrap_or(false), + ) + .await } #[cfg(feature = "tauri-runtime")] @@ -106,8 +117,9 @@ pub async fn update_folder_command( name: Option, command: Option, sort_order: Option, + run_local: Option, ) -> Result { - folder_command_service::update(&db.conn, id, name, command, sort_order).await + folder_command_service::update(&db.conn, id, name, command, sort_order, run_local).await } #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/commands/local_command.rs b/src-tauri/src/commands/local_command.rs new file mode 100644 index 0000000000..1c299dd28a --- /dev/null +++ b/src-tauri/src/commands/local_command.rs @@ -0,0 +1,484 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Stdio; + +use crate::app_error::AppCommandError; +use crate::terminal::shell_flavor::{classify_shell_family, ShellFamily}; + +/// Run a folder command on the machine this desktop app runs on, even when the +/// window is bound to a remote codeg-server. +/// +/// Every other folder-command path goes through the window's transport, which +/// in a remote-desktop window means the remote host. This one is only ever +/// called through the local shell transport, and is not routed in the web +/// server, so the command can only ever land on the user's own machine. +/// +/// The command runs detached through the user's shell with nothing attached to +/// stdio — it is meant for launchers (`zed ssh://…`, `open …`), not for jobs +/// whose output matters. A successful return only means the shell was spawned. +/// +/// `folder_path` is the active folder's path *on its host* (the remote path in a +/// remote-desktop window) and `remote_host` the remote server's host name; both +/// reach the command as `CODEG_FOLDER_PATH` / `CODEG_REMOTE_HOST`. `remote_host` +/// is present exactly when the window is bound to a remote server (empty when +/// its name can't be read), and so exactly when `folder_path` came from that +/// server rather than from this machine. The command runs in `working_dir` — +/// the folder, when the window is a local one — or, failing that, the home +/// directory: a remote folder's path names nothing here. +/// +/// `async` because the spawn needs a tokio runtime in scope, not because it +/// awaits anything. +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn run_local_command( + command: String, + folder_path: Option, + remote_host: Option, + working_dir: Option, +) -> Result<(), AppCommandError> { + let shell = crate::terminal::manager::resolve_shell(); + spawn_local_command(&shell, &command, folder_path, remote_host, working_dir) +} + +/// [`run_local_command`] in `shell`. +fn spawn_local_command( + shell: &str, + command: &str, + folder_path: Option, + remote_host: Option, + working_dir: Option, +) -> Result<(), AppCommandError> { + let command = validate_local_command(command)?; + let remote = remote_host.is_some(); + let folder_path = folder_path.unwrap_or_default(); + let remote_host = remote_host.unwrap_or_default(); + let mut child = tokio::process::Command::new(shell); + match shell_invocation(shell, command) { + ShellInvocation::Args(args) => { + child.args(args); + } + ShellInvocation::CmdLine(line) => { + if remote { + for (name, value) in [ + ("CODEG_FOLDER_PATH", &folder_path), + ("CODEG_REMOTE_HOST", &remote_host), + ] { + reject_unsafe_cmd_variable(command, name)?; + reject_cmd_syntax(name, value)?; + } + } + append_cmd_line(&mut child, line)?; + } + } + child + .env("CODEG_CMD", command) + .env("CODEG_FOLDER_PATH", folder_path) + .env("CODEG_REMOTE_HOST", remote_host) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(dir) = resolve_working_dir(working_dir.as_deref()) { + child.current_dir(dir); + } + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + child.creation_flags(CREATE_NO_WINDOW); + } + + // Dropping tokio's `Child` hands it to the runtime's orphan queue, which + // reaps it once it exits (see `open_in::spawn_vscode`). + child.spawn().map(|_| ()).map_err(|err| { + AppCommandError::external_command("Failed to run local command", format!("{shell}: {err}")) + }) +} + +/// Hand cmd.exe its command line as it is. Passed as an argument, std would +/// quote it the MSVC way, which cmd does not undo. +#[cfg(windows)] +fn append_cmd_line( + child: &mut tokio::process::Command, + line: String, +) -> Result<(), AppCommandError> { + use std::os::windows::process::CommandExt; + child.as_std_mut().raw_arg(line); + Ok(()) +} + +/// Only a Windows host resolves its shell to cmd.exe. +#[cfg(not(windows))] +fn append_cmd_line( + _child: &mut tokio::process::Command, + line: String, +) -> Result<(), AppCommandError> { + Err(AppCommandError::invalid_input(format!( + "cmd.exe runs only on Windows: {line}" + ))) +} + +/// Refuse `value`, the variable `name`, when it holds a character cmd.exe reads +/// as syntax. cmd splices a `%VAR%` value into its command line before it +/// parses that line, so such a value can close a quoted argument or chain a +/// command of its own: a remote folder named `/srv/x" & calc.exe & rem "` turns +/// `echo "%CODEG_FOLDER_PATH%"` into a launch of calc.exe. Only a remote +/// window's values are checked: they come from the remote server, which is not +/// trusted with this machine, while a local window's are this machine's own. +fn reject_cmd_syntax(name: &str, value: &str) -> Result<(), AppCommandError> { + let syntax = value + .chars() + .find(|c| c.is_control() || matches!(c, '"' | '&' | '|' | '<' | '>' | '^' | '%' | '!')); + match syntax { + Some(c) => Err(AppCommandError::invalid_input(format!( + "{name} from the remote server holds {c:?}, which cmd.exe would read as part of the command" + ))), + None => Ok(()), + } +} + +/// Refuse `command` unless cmd.exe is sure to splice the variable `name` inside +/// double quotes, the one place a remote window's value stays a single argument +/// (`reject_cmd_syntax` then keeps the value from closing those quotes). Outside +/// them cmd parses the spliced value like the rest of the line, so its spaces or +/// parentheses can put a command where cmd expects one: a remote folder named +/// `C:\Windows calc.exe` turns `if exist %CODEG_FOLDER_PATH% code .` into a +/// launch of calc.exe. For a remote window's commands only, as elsewhere. +/// +/// The quoting is read off the source text, so the check refuses — rather than +/// try to model — anything that would make that reading lie. cmd runs `%…%` +/// expansion over the whole line before it parses quotes, and a reference's +/// `:` modifier or an embedded quote is consumed in that pass (so a +/// `%VAR:"=%` could swallow a quote the reading counts); a caret escapes the +/// quote after it; and a bare `%` can pair with a later one in ways cmd alone +/// resolves. So every `%` must open a plain `%IDENT%` reference and the command +/// must hold no caret; then each `"` is a real quote and a plain reference +/// carries none, so the count is exact. +fn reject_unsafe_cmd_variable(command: &str, name: &str) -> Result<(), AppCommandError> { + let refuse = |why: &str| -> Result<(), AppCommandError> { + Err(AppCommandError::invalid_input(format!( + "a command cmd.exe runs on this computer from a remote window {why}, \ + so the remote server's %{name}% can't be read as quoted; \ + write it as plain `\"%{name}%\"`" + ))) + }; + if command.contains('^') { + return refuse("uses a caret (^), which can escape a double quote"); + } + let mut quoted = false; + let mut chars = command.chars(); + while let Some(c) = chars.next() { + match c { + '"' => quoted = !quoted, + '%' => { + // A plain `%IDENT%` reference: letters, digits, `_`, then `%`. + let mut ident = String::new(); + loop { + match chars.next() { + Some('%') => break, + Some(ch) if ch.is_ascii_alphanumeric() || ch == '_' => ident.push(ch), + _ => { + return refuse( + "has a `%` that isn't a plain `%VAR%` reference \ + (a `%VAR:…%` modifier, a `for` loop's `%i`, or a literal `%`)", + ) + } + } + } + if ident.is_empty() { + return refuse("has an empty `%%`"); + } + if ident.eq_ignore_ascii_case(name) && !quoted { + return refuse("names it outside double quotes"); + } + } + _ => {} + } + } + Ok(()) +} + +/// `requested` when it is a directory on this machine, else the home directory. +/// Never trimmed: a path is used exactly as given. +fn resolve_working_dir(requested: Option<&str>) -> Option { + requested + .map(PathBuf::from) + .filter(|dir| dir.is_dir()) + .or_else(dirs::home_dir) +} + +fn validate_local_command(command: &str) -> Result<&str, AppCommandError> { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err(AppCommandError::invalid_input("command is required")); + } + if trimmed.contains('\0') { + return Err(AppCommandError::invalid_input( + "command must not contain NUL characters", + )); + } + Ok(trimmed) +} + +/// How a command is handed to the shell that runs it. +#[derive(Debug, PartialEq, Eq)] +enum ShellInvocation { + /// Arguments, quoted for the program the usual way. + Args(Vec), + /// cmd.exe's whole command line, appended verbatim (Windows only). + CmdLine(String), +} + +/// Run `command` and exit. Where the shell can evaluate a variable, the +/// command travels in `$CODEG_CMD` rather than argv to sidestep quoting — the +/// same indirection the built-in terminal uses. POSIX shells get `-l` so the +/// user's login profile (PATH for `zed`, `code`, …) applies; no `-i`, as there +/// is no TTY here. +/// +/// cmd.exe is the exception: it expands `%VAR%` once, over its own command +/// line, and never inside a value it expanded, so behind `%CODEG_CMD%` the +/// `%CODEG_FOLDER_PATH%` in a command would reach the program literally. The +/// command is the command line itself instead, as if typed at a prompt; `/S` +/// strips only the outer quotes around it, and `/V:OFF` keeps a `!` literal +/// even where the registry turns delayed expansion on. Splicing values into the +/// line is what `reject_unsafe_cmd_variable` and `reject_cmd_syntax` guard. +fn shell_invocation(shell: &str, command: &str) -> ShellInvocation { + let args: Vec<&str> = match classify_shell_family(shell) { + ShellFamily::PowerShell => vec![ + "-NoLogo", + "-NoProfile", + "-Command", + "Invoke-Expression $env:CODEG_CMD", + ], + ShellFamily::Cmd => { + return ShellInvocation::CmdLine(format!("/D /V:OFF /S /C \"{command}\"")) + } + ShellFamily::Posix if is_bash_like(shell) => vec!["-l", "-c", "eval \"$CODEG_CMD\""], + // nu / xonsh / elvish: no assumptions about `-l` or `eval`. + ShellFamily::Posix => vec!["-c", command], + }; + ShellInvocation::Args(args.into_iter().map(OsString::from).collect()) +} + +fn is_bash_like(shell: &str) -> bool { + matches!( + crate::terminal::shell_flavor::shell_basename(shell).trim_end_matches(".exe"), + "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "mksh" | "busybox" | "fish" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_rejects_blank_command() { + let err = validate_local_command(" ").expect_err("blank"); + assert!(err.message.contains("required"), "{err:?}"); + } + + #[test] + fn validate_trims_command() { + assert_eq!(validate_local_command(" zed . ").unwrap(), "zed ."); + } + + fn args(items: &[&str]) -> ShellInvocation { + ShellInvocation::Args(items.iter().map(OsString::from).collect()) + } + + #[test] + fn bash_like_shells_eval_the_env_var_in_a_login_shell() { + assert_eq!( + shell_invocation("/bin/zsh", "zed ."), + args(&["-l", "-c", "eval \"$CODEG_CMD\""]) + ); + } + + #[test] + fn cmd_runs_the_command_as_its_own_command_line() { + // So cmd's one expansion pass reaches the variables in the command. + assert_eq!( + shell_invocation("cmd.exe", r#"code "%CODEG_FOLDER_PATH%""#), + ShellInvocation::CmdLine(r#"/D /V:OFF /S /C "code "%CODEG_FOLDER_PATH%"""#.into()) + ); + } + + #[test] + fn runs_in_the_requested_directory_when_it_exists() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().to_str().expect("utf-8 temp path"); + assert_eq!(resolve_working_dir(Some(path)), Some(dir.path().into())); + } + + #[test] + fn falls_back_to_home_for_a_path_that_names_nothing_here() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("not-here"); + let missing = missing.to_str().expect("utf-8 temp path"); + assert_eq!(resolve_working_dir(Some(missing)), dirs::home_dir()); + assert_eq!(resolve_working_dir(Some("")), dirs::home_dir()); + assert_eq!(resolve_working_dir(None), dirs::home_dir()); + } + + #[test] + fn cmd_refuses_a_remote_folder_path_it_would_read_as_syntax() { + // Refused before anything is spawned, on every platform. + let err = spawn_local_command( + "cmd.exe", + r#"echo "%CODEG_FOLDER_PATH%""#, + Some(r#"/srv/repo" & calc.exe & rem ""#.into()), + Some("devbox".into()), + None, + ) + .expect_err("refused"); + // Off Windows, cmd.exe not running would be an error naming the + // variable too, through the command line it quotes. + assert!( + err.message + .contains(r#"CODEG_FOLDER_PATH from the remote server holds '"'"#), + "{err:?}" + ); + } + + #[test] + fn cmd_refuses_a_remote_variable_outside_double_quotes() { + // Spaces alone put `calc.exe` where `if` expects its command; refused + // before anything is spawned, on every platform. + let err = spawn_local_command( + "cmd.exe", + "if exist %CODEG_FOLDER_PATH% code .", + Some(r"C:\Windows calc.exe".into()), + Some("devbox".into()), + None, + ) + .expect_err("refused"); + assert!( + err.message.contains("names it outside double quotes") + && err.message.contains("%CODEG_FOLDER_PATH%"), + "{err:?}" + ); + // Where a command runs is a switch now; there is no prefix to name. + assert!(!err.message.contains("local:"), "{err:?}"); + } + + #[test] + fn cmd_allows_only_plainly_quoted_remote_variables() { + // Quoted plain references — alone, beside quoted text, or two in a row. + for command in [ + r#"code "%CODEG_FOLDER_PATH%""#, + r#"code "%codeg_folder_path%""#, // the name is matched case-blind + r#"zed "ssh://me@%CODEG_REMOTE_HOST%%CODEG_FOLDER_PATH%""#, + r#"code "a" "%CODEG_FOLDER_PATH%""#, + // No reference to this variable: a longer name, another var, text. + "echo %CODEG_FOLDER_PATHS% %USERPROFILE% CODEG_FOLDER_PATH", + ] { + assert!( + reject_unsafe_cmd_variable(command, "CODEG_FOLDER_PATH").is_ok(), + "{command}" + ); + } + // Refused: unquoted, or a construct whose quoting can't be read off the + // text — a `:` modifier (even one that eats a quote), a caret, a `for` + // loop's bare `%i`, or a literal `%`. + for command in [ + "code %CODEG_FOLDER_PATH%", + "code %codeg_folder_path%", // unquoted, found case-blind + r#"echo "a" %CODEG_FOLDER_PATH%"#, + r#"code "%CODEG_FOLDER_PATH:~3%""#, + r#"code "%CODEG_FOLDER_PATH:%""#, // a bare `:` modifier + r#"echo "%CODEG_REMOTE_HOST:"=%" %CODEG_FOLDER_PATH%"#, + // The variable is quoted here, so only the other construct — a + // `for` loop's `%i`, a literal `%`, a caret — draws the refusal. + r#"for %i in ("%CODEG_FOLDER_PATH%") do echo %i"#, + r#"echo 100% done "%CODEG_FOLDER_PATH%""#, + r#"echo ^"%CODEG_FOLDER_PATH%^""#, + ] { + assert!( + reject_unsafe_cmd_variable(command, "CODEG_FOLDER_PATH").is_err(), + "{command}" + ); + } + } + + #[test] + fn cmd_syntax_is_refused_and_ordinary_paths_pass() { + for path in ["/home/me/My Projects/repo-1.0_x", "/srv/仓库/@scope+x=1,y~"] { + assert!( + reject_cmd_syntax("CODEG_FOLDER_PATH", path).is_ok(), + "{path}" + ); + } + for path in [ + "/a\"b", "/a&b", "/a|b", "/ab", "/a^b", "/a%PATH%", "/a!b", "/a\nb", + ] { + assert!( + reject_cmd_syntax("CODEG_FOLDER_PATH", path).is_err(), + "{path}" + ); + } + } + + #[cfg(not(windows))] + #[test] + fn cmd_takes_a_local_windows_folder_path_as_it_is() { + // `C:\R&D` is a folder of this machine: no refusal, so the only error + // left off Windows is that cmd.exe does not run here. + let err = spawn_local_command( + "cmd.exe", + "echo %CODEG_FOLDER_PATH%", + Some(r"C:\R&D".into()), + None, + None, + ) + .expect_err("cmd.exe off Windows"); + assert!(err.message.contains("only on Windows"), "{err:?}"); + } + + // On Windows a shell it does not recognize is taken for a cmd replacement. + #[cfg(not(windows))] + #[test] + fn unknown_posix_shells_get_the_command_verbatim() { + assert_eq!( + shell_invocation("/usr/bin/nu", "zed ."), + args(&["-c", "zed ."]) + ); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn runs_command_with_folder_env_in_the_working_dir() { + use std::time::{Duration, Instant}; + + let dir = tempfile::tempdir().expect("tempdir"); + let marker = dir.path().join("marker.txt"); + run_local_command( + format!( + "printf '%s|%s|%s' \"$CODEG_FOLDER_PATH\" \"$CODEG_REMOTE_HOST\" \"$(pwd -P)\" > '{}'", + marker.display() + ), + Some("/srv/repo".into()), + Some("devbox".into()), + Some(dir.path().to_str().expect("utf-8 temp path").into()), + ) + .await + .expect("spawn"); + // `pwd -P` resolves symlinks (macOS's /var is one). + let expected = format!( + "/srv/repo|devbox|{}", + std::fs::canonicalize(dir.path()) + .expect("canonical temp dir") + .display() + ); + + // Generous: the wait ends as soon as the marker appears, and a login + // shell's profile (nvm, conda, …) on a busy machine can take seconds. + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Ok(contents) = std::fs::read_to_string(&marker) { + if !contents.is_empty() { + assert_eq!(contents, expected); + return; + } + } + assert!(Instant::now() < deadline, "command never wrote the marker"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d9501fddb6..515cd04005 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -47,6 +47,10 @@ pub mod folders; pub mod forge; /// The `ui-render` skill and the agents it is enabled for (Skill Packs > UI). pub mod generative_ui; +/// Folder commands set to run on the desktop's own machine ("Run on this +/// computer"), even from a remote-desktop window. Desktop-only by design. +#[cfg(feature = "tauri-runtime")] +pub mod local_command; pub mod logging; pub mod mcp; pub mod mcp_service; diff --git a/src-tauri/src/db/entities/folder_command.rs b/src-tauri/src/db/entities/folder_command.rs index e59afefd91..8cc7f1e70a 100644 --- a/src-tauri/src/db/entities/folder_command.rs +++ b/src-tauri/src/db/entities/folder_command.rs @@ -9,6 +9,7 @@ pub struct Model { pub name: String, pub command: String, pub sort_order: i32, + pub run_local: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, } diff --git a/src-tauri/src/db/migration/m20261020_000001_folder_command_run_local.rs b/src-tauri/src/db/migration/m20261020_000001_folder_command_run_local.rs new file mode 100644 index 0000000000..3521d89463 --- /dev/null +++ b/src-tauri/src/db/migration/m20261020_000001_folder_command_run_local.rs @@ -0,0 +1,81 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // `run_local` marks a command the desktop app runs on its own machine, + // detached, instead of in a terminal on the folder's host — the way a + // remote window reaches local tools. Every existing command runs in a + // terminal, so they all start out false. + manager + .alter_table( + Table::alter() + .table(FolderCommand::Table) + .add_column( + ColumnDef::new(FolderCommand::RunLocal) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(FolderCommand::Table) + .drop_column(FolderCommand::RunLocal) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum FolderCommand { + Table, + RunLocal, +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm_migration::sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; + + #[tokio::test] + async fn up_leaves_existing_commands_running_in_a_terminal() { + let conn = Database::connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + conn.execute_unprepared( + "CREATE TABLE folder_command (id INTEGER PRIMARY KEY AUTOINCREMENT, command TEXT NOT NULL)", + ) + .await + .expect("create stub table"); + conn.execute_unprepared("INSERT INTO folder_command (command) VALUES ('pnpm dev')") + .await + .expect("insert existing row"); + + Migration + .up(&SchemaManager::new(&conn)) + .await + .expect("run migration up"); + + let rows = conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + "SELECT run_local FROM folder_command".to_owned(), + )) + .await + .expect("query rows"); + assert_eq!(rows.len(), 1); + let run_local: i32 = rows[0].try_get("", "run_local").expect("run_local"); + assert_eq!(run_local, 0); + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 7923f47edd..eb28b0338e 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -53,6 +53,7 @@ mod m20261015_000001_work_task_deferred_at; mod m20261016_000001_assistant; mod m20261016_000002_conversation_assistant; mod m20261017_000002_conversation_source; +mod m20261020_000001_folder_command_run_local; pub struct Migrator; #[async_trait::async_trait] @@ -112,6 +113,33 @@ impl MigratorTrait for Migrator { Box::new(m20261016_000001_assistant::Migration), Box::new(m20261016_000002_conversation_assistant::Migration), Box::new(m20261017_000002_conversation_source::Migration), + Box::new(m20261020_000001_folder_command_run_local::Migration), ] } } + +#[cfg(test)] +mod tests { + use sea_orm_migration::MigratorTrait; + + use crate::db::migration::Migrator; + + /// A backup records the last migration here as its schema version, and a + /// build refuses a backup naming a migration it does not know. A new + /// migration listed ahead of a released one (an easy slip when a rebase + /// conflicts on this list) leaves the released name last, so that release + /// accepts the newer backup and then cannot start on the migration the + /// restore brought in. Names are dated when written, so name order keeps + /// the newest migration last. A new migration whose name sorts before a + /// released one needs a later name, not an earlier place in the list. + #[test] + fn migrations_are_listed_in_name_order() { + let names: Vec = Migrator::migrations() + .iter() + .map(|m| m.name().to_string()) + .collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + } +} diff --git a/src-tauri/src/db/service/folder_command_service.rs b/src-tauri/src/db/service/folder_command_service.rs index 5692b46aa9..bcd7f24a22 100644 --- a/src-tauri/src/db/service/folder_command_service.rs +++ b/src-tauri/src/db/service/folder_command_service.rs @@ -16,6 +16,7 @@ fn to_info(m: folder_command::Model) -> FolderCommandInfo { name: m.name, command: m.command, sort_order: m.sort_order, + run_local: m.run_local, created_at: m.created_at, updated_at: m.updated_at, } @@ -39,6 +40,7 @@ pub async fn create( folder_id: i32, name: &str, command: &str, + run_local: bool, ) -> Result { let now = Utc::now(); @@ -57,6 +59,7 @@ pub async fn create( name: Set(name.to_string()), command: Set(command.to_string()), sort_order: Set(max_order + 1), + run_local: Set(run_local), created_at: Set(now), updated_at: Set(now), }; @@ -93,6 +96,7 @@ pub async fn create_many( name: Set(name.clone()), command: Set(command.clone()), sort_order: Set(max_order + idx as i32 + 1), + run_local: Set(false), created_at: Set(now), updated_at: Set(now), }) @@ -105,12 +109,22 @@ pub async fn create_many( Ok(()) } +/// Whether command `id` runs on the desktop's own machine; false when there is +/// no such command. +pub async fn is_run_local(conn: &DatabaseConnection, id: i32) -> Result { + Ok(folder_command::Entity::find_by_id(id) + .one(conn) + .await? + .is_some_and(|m| m.run_local)) +} + pub async fn update( conn: &DatabaseConnection, id: i32, name: Option, command: Option, sort_order: Option, + run_local: Option, ) -> Result { let row = folder_command::Entity::find_by_id(id) .one(conn) @@ -127,6 +141,9 @@ pub async fn update( if let Some(s) = sort_order { active.sort_order = Set(s); } + if let Some(r) = run_local { + active.run_local = Set(r); + } active.updated_at = Set(Utc::now()); let model = active.update(conn).await?; @@ -169,3 +186,59 @@ pub async fn reorder( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + + #[tokio::test] + async fn keeps_where_a_command_runs() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/repo").await; + + let local = create(&db.conn, folder_id, "zed", "zed .", true) + .await + .unwrap(); + let terminal = create(&db.conn, folder_id, "dev", "pnpm dev", false) + .await + .unwrap(); + assert!(local.run_local); + assert!(!terminal.run_local); + + // An update that doesn't mention it leaves it as it was. + let renamed = update(&db.conn, local.id, Some("editor".into()), None, None, None) + .await + .unwrap(); + assert!(renamed.run_local); + let listed = list_by_folder(&db.conn, folder_id).await.unwrap(); + assert_eq!( + listed + .iter() + .map(|c| (c.name.as_str(), c.run_local)) + .collect::>(), + [("editor", true), ("dev", false)] + ); + + let in_a_terminal = update(&db.conn, local.id, None, None, None, Some(false)) + .await + .unwrap(); + assert!(!in_a_terminal.run_local); + } + + #[tokio::test] + async fn package_scripts_run_in_a_terminal() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/repo").await; + create_many( + &db.conn, + folder_id, + &[("dev".to_string(), "pnpm run dev".to_string())], + ) + .await + .unwrap(); + let listed = list_by_folder(&db.conn, folder_id).await.unwrap(); + assert_eq!(listed.len(), 1); + assert!(!listed[0].run_local); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab9227d3d7..b2ec9807b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,7 +113,7 @@ mod tauri_app { custom_skills as custom_skills_commands, deepseek_settings as deepseek_settings_commands, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folder_links, office_tools as office_tools_commands, open_in, + folder_links, local_command, office_tools as office_tools_commands, open_in, folders, logging as logging_commands, mcp as mcp_commands, model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, @@ -2252,6 +2252,7 @@ mod tauri_app { folder_commands::delete_folder_command, folder_commands::reorder_folder_commands, folder_commands::bootstrap_folder_commands_from_package_json, + local_command::run_local_command, quick_messages_commands::quick_messages_list, quick_messages_commands::quick_messages_create, quick_messages_commands::quick_messages_update, diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs index 8e82a6d5c7..aefb53b2de 100644 --- a/src-tauri/src/models/folder.rs +++ b/src-tauri/src/models/folder.rs @@ -115,6 +115,9 @@ pub struct FolderCommandInfo { pub name: String, pub command: String, pub sort_order: i32, + /// Run by the desktop app on its own machine, detached, instead of in a + /// terminal on the folder's host. + pub run_local: bool, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/src-tauri/src/web/handlers/folder_commands.rs b/src-tauri/src/web/handlers/folder_commands.rs index 1cb6858713..3eb642539f 100644 --- a/src-tauri/src/web/handlers/folder_commands.rs +++ b/src-tauri/src/web/handlers/folder_commands.rs @@ -1,10 +1,12 @@ use std::sync::Arc; use axum::{extract::Extension, Json}; +use sea_orm::DatabaseConnection; use serde::Deserialize; use crate::app_error::AppCommandError; use crate::app_state::AppState; +use crate::db::error::DbError; use crate::db::service::folder_command_service; use crate::models::*; @@ -14,8 +16,11 @@ use crate::models::*; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct FolderIdParams { +pub struct ListFolderCommandsParams { pub folder_id: i32, + /// See [`visible_to_client`]. + #[serde(default)] + pub include_local: bool, } #[derive(Deserialize)] @@ -24,6 +29,7 @@ pub struct CreateFolderCommandParams { pub folder_id: i32, pub name: String, pub command: String, + pub run_local: Option, } #[derive(Deserialize)] @@ -33,6 +39,10 @@ pub struct UpdateFolderCommandParams { pub name: Option, pub command: Option, pub sort_order: Option, + pub run_local: Option, + /// See [`visible_to_client`]. + #[serde(default)] + pub include_local: bool, } #[derive(Deserialize)] @@ -52,15 +62,28 @@ pub struct ReorderFolderCommandsParams { // Handlers // --------------------------------------------------------------------------- +/// The commands a client is shown. One the desktop app runs on its own +/// machine goes only to a client that says it knows that flag: an older one +/// would run the command's text in a terminal here, on the wrong machine. +fn visible_to_client( + commands: Vec, + include_local: bool, +) -> Vec { + if include_local { + return commands; + } + commands.into_iter().filter(|c| !c.run_local).collect() +} + pub async fn list_folder_commands( Extension(state): Extension>, - Json(params): Json, + Json(params): Json, ) -> Result>, AppCommandError> { let db = &state.db; let result = folder_command_service::list_by_folder(&db.conn, params.folder_id) .await .map_err(AppCommandError::from)?; - Ok(Json(result)) + Ok(Json(visible_to_client(result, params.include_local))) } pub async fn create_folder_command( @@ -68,10 +91,15 @@ pub async fn create_folder_command( Json(params): Json, ) -> Result, AppCommandError> { let db = &state.db; - let result = - folder_command_service::create(&db.conn, params.folder_id, ¶ms.name, ¶ms.command) - .await - .map_err(AppCommandError::from)?; + let result = folder_command_service::create( + &db.conn, + params.folder_id, + ¶ms.name, + ¶ms.command, + params.run_local.unwrap_or(false), + ) + .await + .map_err(AppCommandError::from)?; Ok(Json(result)) } @@ -79,17 +107,30 @@ pub async fn update_folder_command( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let db = &state.db; - let result = folder_command_service::update( - &db.conn, + let result = update_for_client(&state.db.conn, params) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +async fn update_for_client( + conn: &DatabaseConnection, + params: UpdateFolderCommandParams, +) -> Result { + // A client that isn't shown a local command (see `visible_to_client`) can + // only hold its id from before it became one: to that client it is gone. + if !params.include_local && folder_command_service::is_run_local(conn, params.id).await? { + return Err(DbError::NotFound(format!("FolderCommand {}", params.id))); + } + folder_command_service::update( + conn, params.id, params.name, params.command, params.sort_order, + params.run_local, ) .await - .map_err(AppCommandError::from)?; - Ok(Json(result)) } pub async fn delete_folder_command( @@ -119,6 +160,9 @@ pub async fn reorder_folder_commands( pub struct BootstrapFolderCommandsParams { pub folder_id: i32, pub folder_path: String, + /// See [`visible_to_client`]. + #[serde(default)] + pub include_local: bool, } pub async fn bootstrap_folder_commands_from_package_json( @@ -127,11 +171,12 @@ pub async fn bootstrap_folder_commands_from_package_json( ) -> Result>, AppCommandError> { let db = &state.db; + let include_local = params.include_local; let existing = folder_command_service::list_by_folder(&db.conn, params.folder_id) .await .map_err(AppCommandError::from)?; if !existing.is_empty() { - return Ok(Json(existing)); + return Ok(Json(visible_to_client(existing, include_local))); } let commands_to_create = tokio::task::spawn_blocking(move || { @@ -156,5 +201,80 @@ pub async fn bootstrap_folder_commands_from_package_json( let result = folder_command_service::list_by_folder(&db.conn, params.folder_id) .await .map_err(AppCommandError::from)?; - Ok(Json(result)) + Ok(Json(visible_to_client(result, include_local))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command(id: i32, run_local: bool) -> FolderCommandInfo { + FolderCommandInfo { + id, + folder_id: 1, + name: format!("c{id}"), + command: "x".to_string(), + sort_order: 0, + run_local, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + #[test] + fn a_client_that_does_not_know_local_commands_is_not_shown_them() { + let params: ListFolderCommandsParams = + serde_json::from_value(serde_json::json!({ "folderId": 1 })).unwrap(); + assert!(!params.include_local); + let shown = visible_to_client(vec![command(1, false), command(2, true)], false); + assert_eq!(shown.iter().map(|c| c.id).collect::>(), [1]); + } + + #[tokio::test] + async fn a_client_that_does_not_know_local_commands_cannot_edit_one() { + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/repo").await; + let local = folder_command_service::create(&db.conn, folder_id, "zed", "zed .", true) + .await + .unwrap(); + let terminal = + folder_command_service::create(&db.conn, folder_id, "dev", "pnpm dev", false) + .await + .unwrap(); + let rename = |id: i32, include_local: bool| -> UpdateFolderCommandParams { + serde_json::from_value(serde_json::json!({ + "id": id, + "name": "renamed", + "includeLocal": include_local, + })) + .unwrap() + }; + + let refused = update_for_client(&db.conn, rename(local.id, false)).await; + assert!(matches!(refused, Err(DbError::NotFound(_))), "{refused:?}"); + let renamed = update_for_client(&db.conn, rename(terminal.id, false)) + .await + .unwrap(); + assert_eq!(renamed.name, "renamed"); + let renamed = update_for_client(&db.conn, rename(local.id, true)) + .await + .unwrap(); + assert_eq!( + (renamed.name.as_str(), renamed.run_local), + ("renamed", true) + ); + } + + #[test] + fn a_client_that_knows_local_commands_is_shown_them_all() { + let params: BootstrapFolderCommandsParams = serde_json::from_value( + serde_json::json!({ "folderId": 1, "folderPath": "/repo", "includeLocal": true }), + ) + .unwrap(); + assert!(params.include_local); + let shown = visible_to_client(vec![command(1, false), command(2, true)], true); + assert_eq!(shown.iter().map(|c| c.id).collect::>(), [1, 2]); + } } diff --git a/src-tauri/src/work_task/engine.rs b/src-tauri/src/work_task/engine.rs index 8f06a3e4e4..a7c2dd5bea 100644 --- a/src-tauri/src/work_task/engine.rs +++ b/src-tauri/src/work_task/engine.rs @@ -3372,7 +3372,7 @@ impl TaskEngine { .map(str::trim) .filter(|c| !c.is_empty()); let (display_name, command_line) = match custom { - Some(cmd) => (cmd.to_string(), cmd.to_string()), + Some(cmd) => (cmd.to_string(), Ok(cmd.to_string())), None => { let Some(command_id) = settings.preflight_command_id else { return; @@ -3386,7 +3386,14 @@ impl TaskEngine { if command.folder_id != task.folder_id { return; } - (command.name, command.command) + // One the desktop app runs on its own machine is never run + // here, on the folder's host: its light goes red saying so. + let line = if command.run_local { + Err("it is set to run on the desktop's own computer".to_string()) + } else { + Ok(command.command) + }; + (command.name, line) } }; let Some(wt_id) = task.worktree_folder_id else { @@ -3410,7 +3417,11 @@ impl TaskEngine { _ => return, // task already moved on } - match run_shell_capture(&command_line, &wt.path).await { + let outcome = match command_line { + Ok(line) => run_shell_capture(&line, &wt.path).await, + Err(why) => Err(why), + }; + match outcome { Ok((code, tail)) => { let passed = code == Some(0); light.status = if passed { "passed" } else { "failed" }.to_string(); @@ -8031,6 +8042,84 @@ mod tests { })); } + /// A legacy preflight that names a folder command runs it in the worktree — + /// unless the desktop app runs that command on its own machine, which this + /// host is not. + #[tokio::test] + async fn a_legacy_preflight_never_runs_a_local_command_on_the_host() { + use crate::db::service::folder_command_service; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use sea_orm::{ActiveModelTrait, Set}; + + for run_local in [false, true] { + let dir = tempfile::tempdir().expect("temp dir"); + let db = fresh_in_memory_db().await; + let project = seed_folder(&db, "/repo").await; + let worktree = seed_folder(&db, &dir.path().to_string_lossy()).await; + let command = folder_command_service::create( + &db.conn, + project, + "check", + "echo ran > ran-here", + run_local, + ) + .await + .expect("command"); + work_task_service::settings_set( + &db.conn, + project, + &WorkTaskFolderSettings { + preflight_command_id: Some(command.id), + ..Default::default() + }, + ) + .await + .expect("settings"); + let now = chrono::Utc::now(); + let task_id = crate::db::entities::work_task::ActiveModel { + folder_id: Set(project), + title: Set("Fix the login flow".to_string()), + config: Set("{}".to_string()), + status: Set(WorkTaskStatus::Review), + run_seq: Set(1), + worktree_folder_id: Set(Some(worktree)), + sort_order: Set(0), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(&db.conn) + .await + .expect("insert task") + .id; + + let engine = test_engine(db); + engine.run_preflight(task_id, 1).await; + + let light: WorkTaskPreflight = serde_json::from_str( + &work_task_service::get_model(&engine.db.conn, task_id) + .await + .expect("task row") + .preflight + .expect("a light"), + ) + .expect("light json"); + let ran = dir.path().join("ran-here").exists(); + if run_local { + assert!(!ran, "a local command ran on the host"); + assert_eq!(light.status, "failed"); + let tail = light.output_tail.unwrap_or_default(); + assert!( + tail.ends_with("run on the desktop's own computer"), + "{tail}" + ); + } else { + assert!(ran, "the folder command didn't run in the worktree"); + assert_eq!(light.status, "passed"); + } + } + } + /// The refusal wordings that mean "lost a race" are skipped silently by /// the sweep; the strings live in `merge_task`, and this pin keeps the /// match and the wording from drifting apart. diff --git a/src/components/layout/command-dropdown.test.tsx b/src/components/layout/command-dropdown.test.tsx index 8b5f0d17cc..053f2f5965 100644 --- a/src/components/layout/command-dropdown.test.tsx +++ b/src/components/layout/command-dropdown.test.tsx @@ -1,19 +1,43 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { useCommandTerminalLinkStore } from "@/stores/command-terminal-link-store" +import { + GLOBAL_COMMANDS_STORAGE_KEY, + createGlobalCommand, +} from "@/lib/global-commands" +import type { FolderCommand } from "@/lib/types" +import { + commandLinkKey, + useCommandTerminalLinkStore, +} from "@/stores/command-terminal-link-store" import { CommandDropdown } from "./command-dropdown" const h = vi.hoisted(() => ({ stop: vi.fn(async () => {}), kill: vi.fn(async () => {}), create: vi.fn(async () => "launched-terminal"), + runLocal: vi.fn(async () => {}), + toastError: vi.fn(), + desktop: true, + remote: true, + baseUrl: "https://devbox.example:3080", + folder: { id: 1, path: "/repo" }, + command: "pnpm dev", + isLocal: false, + // What the folder's host lists; null for its one "dev" command (`command`). + hostCommands: null as unknown[] | null, tabs: [] as { id: string }[], t: (key: string) => key, })) vi.mock("next-intl", () => ({ useTranslations: () => h.t })) +vi.mock("sonner", () => ({ toast: { error: h.toastError } })) +vi.mock("@/lib/transport", () => ({ + isDesktop: () => h.desktop, + isRemoteDesktopMode: () => h.remote, + getServerBaseUrl: () => h.baseUrl, +})) vi.mock("@/contexts/active-folder-context", () => ({ - useActiveFolder: () => ({ activeFolder: { id: 1, path: "/repo" } }), + useActiveFolder: () => ({ activeFolder: h.folder }), })) vi.mock("@/contexts/terminal-context", () => ({ useTerminalContext: () => ({ @@ -23,28 +47,60 @@ vi.mock("@/contexts/terminal-context", () => ({ }), })) vi.mock("@/lib/api", () => ({ - listFolderCommands: vi.fn(async () => [ - { - id: 5, - folder_id: 1, - name: "dev", - command: "pnpm dev", - sort_order: 0, - created_at: "", - updated_at: "", - }, - ]), + listFolderCommands: vi.fn( + async () => + h.hostCommands ?? [ + { + id: 5, + folder_id: 1, + name: "dev", + command: h.command, + sort_order: 0, + run_local: h.isLocal, + created_at: "", + updated_at: "", + }, + ] + ), bootstrapFolderCommandsFromPackageJson: vi.fn(async () => []), + runLocalCommand: h.runLocal, terminalStop: h.stop, terminalKill: h.kill, })) vi.mock("./command-manage-dialog", () => ({ CommandManageDialog: () => null })) +function hostCommand( + id: number, + command: string, + runLocal = false +): FolderCommand { + return { + id, + folder_id: 1, + name: `host-${id}`, + command, + sort_order: 0, + run_local: runLocal, + created_at: "", + updated_at: "", + } +} + describe("CommandDropdown", () => { beforeEach(() => { h.stop.mockClear() h.kill.mockClear() h.create.mockClear() + h.create.mockImplementation(async () => "launched-terminal") + h.runLocal.mockClear() + h.toastError.mockClear() + h.desktop = true + h.remote = true + h.baseUrl = "https://devbox.example:3080" + h.folder = { id: 1, path: "/repo" } + h.command = "pnpm dev" + h.isLocal = false + h.hostCommands = null h.tabs = [] useCommandTerminalLinkStore.setState({ links: {} }) localStorage.clear() @@ -57,7 +113,7 @@ describe("CommandDropdown", () => { expect(h.create).toHaveBeenCalledWith("dev", "pnpm dev", 5) ) expect(useCommandTerminalLinkStore.getState().links).toEqual({ - 5: "launched-terminal", + [commandLinkKey(1, 5)]: "launched-terminal", }) }) @@ -65,11 +121,190 @@ describe("CommandDropdown", () => { // The tab stays open on what the command printed. A plain kill would // also forget that output, and a reload of the tab would find nothing. h.tabs = [{ id: "running-terminal" }] - useCommandTerminalLinkStore.setState({ links: { 5: "running-terminal" } }) + useCommandTerminalLinkStore.setState({ + links: { [commandLinkKey(1, 5)]: "running-terminal" }, + }) render() fireEvent.click(await screen.findByTitle("stopCommandTitle")) await waitFor(() => expect(h.stop).toHaveBeenCalledWith("running-terminal")) expect(h.kill).not.toHaveBeenCalled() expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) + + it("runs a local window's command on this machine, in the folder", async () => { + h.remote = false + h.command = 'open -a Zed "$CODEG_FOLDER_PATH"' + h.isLocal = true + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + 'open -a Zed "$CODEG_FOLDER_PATH"', + "/repo", + null, + "/repo" + ) + ) + expect(h.create).not.toHaveBeenCalled() + expect(useCommandTerminalLinkStore.getState().links).toEqual({}) + }) + + it("runs a command that merely starts with local: in a terminal", async () => { + h.command = "local: zed ." + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.create).toHaveBeenCalledWith("dev", "local: zed .", 5) + ) + expect(h.runLocal).not.toHaveBeenCalled() + }) + + it("runs a remote server's command on this machine like any other", async () => { + h.command = 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + h.isLocal = true + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', + "/repo", + "devbox.example", + null + ) + ) + expect(h.toastError).not.toHaveBeenCalled() + expect(h.create).not.toHaveBeenCalled() + }) + + it("runs a global command from a remote window on this machine, outside any folder", async () => { + h.hostCommands = [] + createGlobalCommand( + "zed", + 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', + true + ) + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', + "/repo", + "devbox.example", + null + ) + ) + expect(h.create).not.toHaveBeenCalled() + expect(useCommandTerminalLinkStore.getState().links).toEqual({}) + }) + + it("still tells the backend the folder is remote when its host can't be read", async () => { + // The backend checks a remote folder's path before cmd.exe splices it in. + h.baseUrl = "not a url" + h.hostCommands = [] + createGlobalCommand("open", "echo %CODEG_FOLDER_PATH%", true) + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + "echo %CODEG_FOLDER_PATH%", + "/repo", + "", + null + ) + ) + }) + + it("refuses a command set to run on this computer outside the desktop app", async () => { + h.desktop = false + h.remote = false + h.command = "zed ." + h.isLocal = true + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.toastError).toHaveBeenCalledWith("localUnavailable") + ) + expect(h.runLocal).not.toHaveBeenCalled() + expect(h.create).not.toHaveBeenCalled() + }) + + it("doesn't let the host pass a command off as one kept on this computer", async () => { + // Global commands have negative ids; the host's database never does. + h.hostCommands = [hostCommand(-1, "echo from-the-host", true)] + createGlobalCommand("mine", "echo mine", true) + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => expect(h.runLocal).toHaveBeenCalledTimes(1)) + expect(h.runLocal).toHaveBeenCalledWith( + "echo mine", + "/repo", + "devbox.example", + null + ) + }) + + it("keeps a global command's run in one folder out of the others", async () => { + h.hostCommands = [] + createGlobalCommand("dev", "pnpm dev", false) + h.create.mockImplementationOnce(async () => "terminal-a") + const { rerender } = render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => expect(h.create).toHaveBeenCalledTimes(1)) + h.tabs = [{ id: "terminal-a" }] + rerender() + await screen.findByTitle("stopCommandTitle") + + // Folder 2 never ran it: it offers to, and runs its own copy. + h.folder = { id: 2, path: "/other" } + h.create.mockImplementationOnce(async () => "terminal-b") + rerender() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => expect(h.create).toHaveBeenCalledTimes(2)) + h.tabs = [{ id: "terminal-a" }, { id: "terminal-b" }] + rerender() + + // Stopping it there leaves folder 1's run alone. + fireEvent.click(await screen.findByTitle("stopCommandTitle")) + await waitFor(() => expect(h.stop).toHaveBeenCalledWith("terminal-b")) + expect(useCommandTerminalLinkStore.getState().links).toEqual({ + [commandLinkKey(1, -1)]: "terminal-a", + }) + }) + + it("picks up global commands another window added", async () => { + h.hostCommands = [] + render() + await screen.findByText("addCommand") + + // Another window writes the store; this one only hears the event. + createGlobalCommand("zed", "zed .", true) + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { key: GLOBAL_COMMANDS_STORAGE_KEY }) + ) + }) + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + "zed .", + "/repo", + "devbox.example", + null + ) + ) + }) + + it("drops global commands when another window clears the storage", async () => { + h.hostCommands = [] + createGlobalCommand("zed", "zed .", true) + render() + await screen.findByTitle("runCommandTitle") + + // `clear()` reaches other windows as a storage event with no key. + localStorage.clear() + act(() => { + window.dispatchEvent(new StorageEvent("storage", { key: null })) + }) + expect(await screen.findByText("addCommand")).toBeInTheDocument() + }) }) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index d702926aa4..3484ec168e 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -1,8 +1,9 @@ "use client" import { useState, useEffect, useCallback, useMemo, useRef } from "react" -import { Play, Plus, Square, Terminal } from "lucide-react" +import { Globe, Monitor, Play, Plus, Square, Terminal } from "lucide-react" import { useTranslations } from "next-intl" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -16,10 +17,21 @@ import { useTerminalContext } from "@/contexts/terminal-context" import { bootstrapFolderCommandsFromPackageJson, listFolderCommands, + runLocalCommand, terminalStop, } from "@/lib/api" +import { toErrorMessage } from "@/lib/app-error" +import { + GLOBAL_COMMANDS_STORAGE_KEY, + isGlobalCommandId, + listGlobalCommands, + withoutGlobalIds, +} from "@/lib/global-commands" +import { getRemoteHostName, isLocalCommand } from "@/lib/local-command" +import { isDesktop, isRemoteDesktopMode } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { + commandLinkKey, resolveLiveCommandTerminalId, useCommandTerminalLinkStore, } from "@/stores/command-terminal-link-store" @@ -52,6 +64,8 @@ export function CommandDropdown() { tabs: terminalTabs, } = useTerminalContext() const [commands, setCommands] = useState([]) + // Shown in every folder, after the folder's own (see `global-commands`). + const [globalCommands, setGlobalCommands] = useState([]) const [manageOpen, setManageOpen] = useState(false) const [bootstrapping, setBootstrapping] = useState(false) const [selectedCommandId, setSelectedCommandIdState] = useState< @@ -120,6 +134,7 @@ export function CommandDropdown() { const refreshCommands = useCallback(async () => { if (!folderId) return const epoch = (loadEpochRef.current += 1) + setGlobalCommands(listGlobalCommands()) try { const list = await listFolderCommands(folderId) if (epoch !== loadEpochRef.current) return @@ -132,6 +147,7 @@ export function CommandDropdown() { useEffect(() => { if (!folderId) return loadEpochRef.current += 1 + setGlobalCommands(listGlobalCommands()) let ignore = false const loadCommands = async () => { try { @@ -164,16 +180,59 @@ export function CommandDropdown() { } }, [folderId, folderPath]) + // Another window added, edited or removed a global command (`key` is null + // when it cleared the whole storage). Re-read rather than trust the event's + // value, which can be older than what is stored by the time it arrives. + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== null && event.key !== GLOBAL_COMMANDS_STORAGE_KEY) { + return + } + setGlobalCommands(listGlobalCommands()) + } + window.addEventListener("storage", onStorage) + return () => window.removeEventListener("storage", onStorage) + }, []) + const runCommand = useCallback( async (cmd: FolderCommand) => { if (!folderPath) return + + // A command set to run on this computer launches detached — no terminal + // tab and no running state to track. + if (isLocalCommand(cmd)) { + selectCommand(cmd.id) + if (!isDesktop()) { + toast.error(t("localUnavailable")) + return + } + const remote = isRemoteDesktopMode() + try { + await runLocalCommand( + cmd.command, + folderPath, + // Set in, and only in, a remote window, even if its host's name + // can't be read: it tells the backend `folderPath` is the server's. + remote ? (getRemoteHostName() ?? "") : null, + // Only a local window's folder exists on this computer to run in. + remote ? null : folderPath + ) + } catch (err) { + toast.error(t("localRunFailed"), { + description: toErrorMessage(err), + }) + } + return + } + // Don't double-launch a command that already has a *live* terminal; a // stale link (dead terminal) must not block a restart, hence the liveness // check rather than a bare presence check. + const linkKey = commandLinkKey(folderId, cmd.id) if ( resolveLiveCommandTerminalId( useCommandTerminalLinkStore.getState().links, - cmd.id, + linkKey, isTerminalLive ) ) @@ -187,23 +246,26 @@ export function CommandDropdown() { ) if (!terminalId) return - setLink(cmd.id, terminalId) + setLink(linkKey, terminalId) }, [ createTerminalWithCommand, + folderId, folderPath, isTerminalLive, selectCommand, setLink, + t, ] ) const stopCommand = useCallback( async (cmd: FolderCommand) => { - const terminalId = useCommandTerminalLinkStore.getState().links[cmd.id] + const linkKey = commandLinkKey(folderId, cmd.id) + const terminalId = useCommandTerminalLinkStore.getState().links[linkKey] if (!terminalId) return - clearLink(cmd.id) + clearLink(linkKey) try { // The tab stays open with what the command printed, so the backend // keeps that output for it — a reload of the tab still shows it. @@ -212,16 +274,27 @@ export function CommandDropdown() { console.error("Failed to stop command terminal:", err) } }, - [clearLink] + [clearLink, folderId] ) + // The folder's own commands first, then the ones shown in every folder. + const allCommands = useMemo( + () => [...withoutGlobalIds(commands), ...globalCommands], + [commands, globalCommands] + ) const activeCmd = useMemo( () => - commands.find((c) => c.id === selectedCommandId) ?? commands[0] ?? null, - [commands, selectedCommandId] + allCommands.find((c) => c.id === selectedCommandId) ?? + allCommands[0] ?? + null, + [allCommands, selectedCommandId] ) const activeTerminalId = activeCmd - ? resolveLiveCommandTerminalId(links, activeCmd.id, isTerminalLive) + ? resolveLiveCommandTerminalId( + links, + commandLinkKey(folderId, activeCmd.id), + isTerminalLive + ) : undefined const isActiveCommandRunning = Boolean(activeTerminalId) @@ -249,7 +322,7 @@ export function CommandDropdown() { // the dialog mid-edit. return ( <> - {commands.length === 0 ? ( + {allCommands.length === 0 ? ( // No commands → show add command button @@ -443,7 +594,7 @@ export function CommandManageDialog({ command={command} selected={selectedId === command.id} disabled={dragDisabled} - onSelect={setSelectedId} + onSelect={selectCommand} onDragEnd={() => { const order = pendingOrderRef.current pendingOrderRef.current = null @@ -473,8 +624,22 @@ export function CommandManageDialog({
-
- {command.name} +
+ + {command.name} + + {isLocalCommand(command) ? ( + + ) : null} + {isGlobalCommandId(command.id) ? ( + + ) : null}
{command.command} @@ -524,6 +689,52 @@ export function CommandManageDialog({ } />
+ {/* Only the desktop app runs a command on its own machine. */} + {isDesktop() ? ( +
+
+ +

+ {t.rich("runLocalHint", { + ...localHintValues, + code: hintLiteral, + })} +

+
+ + updateDraft({ runLocal: checked }) + } + /> +
+ ) : null} +
+
+ +

+ {t("globalHint")} +

+
+ + updateDraft({ global: checked }) + } + /> +
diff --git a/src/components/tasks/task-settings-dialog.test.tsx b/src/components/tasks/task-settings-dialog.test.tsx index 37f282a13f..7a98f13e95 100644 --- a/src/components/tasks/task-settings-dialog.test.tsx +++ b/src/components/tasks/task-settings-dialog.test.tsx @@ -9,13 +9,14 @@ const getMock = vi.fn() const getOwnMock = vi.fn() const setMock = vi.fn().mockResolvedValue(undefined) const deleteMock = vi.fn().mockResolvedValue(undefined) +const commandsMock = vi.fn() vi.mock("@/lib/api", () => ({ workTaskSettingsGet: (...args: unknown[]) => getMock(...args), workTaskSettingsGetOwn: (...args: unknown[]) => getOwnMock(...args), workTaskSettingsSet: (...args: unknown[]) => setMock(...args), workTaskSettingsDelete: (...args: unknown[]) => deleteMock(...args), - listFolderCommands: () => Promise.resolve([]), + listFolderCommands: (...args: unknown[]) => commandsMock(...args), })) // The agent surface probes a live CLI; none of it is under test here. @@ -132,6 +133,7 @@ beforeEach(() => { getOwnMock.mockReset().mockResolvedValue(settings()) setMock.mockClear() deleteMock.mockClear() + commandsMock.mockReset().mockResolvedValue([]) }) describe("TaskSettingsDialog stage prompts", () => { @@ -285,6 +287,53 @@ describe("TaskSettingsDialog merge tab", () => { }) describe("TaskSettingsDialog worktree tab", () => { + it("carries a legacy preflight reference over as the command it names", async () => { + getOwnMock.mockResolvedValue(settings({ preflight_command_id: 7 })) + commandsMock.mockResolvedValue([ + { + id: 7, + folder_id: 1, + name: "test", + command: "pnpm test", + sort_order: 0, + run_local: false, + created_at: "", + updated_at: "", + }, + ]) + const user = userEvent.setup() + renderDialog(1) + await saveButton() + await openTab(user, "Worktree") + expect( + screen.getByRole("textbox", { name: "Preflight command" }) + ).toHaveValue("pnpm test") + }) + + it("doesn't carry over a legacy reference to a command run on the desktop", async () => { + // The preflight runs on the folder's host. + getOwnMock.mockResolvedValue(settings({ preflight_command_id: 7 })) + commandsMock.mockResolvedValue([ + { + id: 7, + folder_id: 1, + name: "zed", + command: "zed .", + sort_order: 0, + run_local: true, + created_at: "", + updated_at: "", + }, + ]) + const user = userEvent.setup() + renderDialog(1) + await saveButton() + await openTab(user, "Worktree") + expect( + screen.getByRole("textbox", { name: "Preflight command" }) + ).toHaveValue("") + }) + it("saves a chosen worktree directory, and blanks it back to the default", async () => { const user = userEvent.setup() renderDialog(1) diff --git a/src/components/tasks/task-settings-dialog.tsx b/src/components/tasks/task-settings-dialog.tsx index f563422e2b..3d0c740850 100644 --- a/src/components/tasks/task-settings-dialog.tsx +++ b/src/components/tasks/task-settings-dialog.tsx @@ -36,6 +36,7 @@ import { workTaskSettingsSet, } from "@/lib/api" import { toErrorMessage } from "@/lib/app-error" +import { isLocalCommand } from "@/lib/local-command" import { cn } from "@/lib/utils" import { SettingCard, @@ -270,10 +271,13 @@ function TaskSettingsBody({ setAutoCompactPercent(String(s.auto_compact_percent ?? 0)) setCompactCommand(s.compact_command ?? "") setStagePrompts(s.stage_prompts ?? {}) + // Never one the desktop app runs on its own machine: the preflight + // runs on the folder's host. const legacy = s.preflight_command_id != null - ? (commands.find((c) => c.id === s.preflight_command_id)?.command ?? - "") + ? (commands.find( + (c) => c.id === s.preflight_command_id && !isLocalCommand(c) + )?.command ?? "") : "" setPreflightCommand(s.preflight_command?.trim() || legacy) }) diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx index d2ca0f4c02..945c2990d1 100644 --- a/src/contexts/terminal-context.test.tsx +++ b/src/contexts/terminal-context.test.tsx @@ -1,7 +1,10 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { TerminalProvider, useTerminalContext } from "./terminal-context" -import { useCommandTerminalLinkStore } from "@/stores/command-terminal-link-store" +import { + commandLinkKey, + useCommandTerminalLinkStore, +} from "@/stores/command-terminal-link-store" const h = vi.hoisted(() => ({ terminalKill: vi.fn(async () => {}), @@ -469,7 +472,9 @@ describe("TerminalProvider reload recovery", () => { ) - expect(useCommandTerminalLinkStore.getState().links).toEqual({ 42: id }) + expect(useCommandTerminalLinkStore.getState().links).toEqual({ + [commandLinkKey(7, 42)]: id, + }) expect(sessionStorage.getItem("codeg:terminal-session:v1")).not.toContain( "sleep 120" ) @@ -507,6 +512,34 @@ describe("TerminalProvider reload recovery", () => { expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) + it("links a global command's tab back to the folder it ran in", () => { + // Global commands have negative ids, the same id in every folder. + const pageId = "0b6f3d7e-5a1c-4e2b-9f8a-1c2d3e4f5a6b" + const id = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" + window.name = `codeg-terminal-page:${pageId}` + sessionStorage.setItem( + "codeg:terminal-session:v1", + JSON.stringify({ + version: 1, + scope: JSON.stringify(["main", null]), + pageId, + isOpen: true, + activeTabId: id, + tabs: [ + { id, folderId: 9, title: "dev", workingDir: "/tmp", commandId: -3 }, + ], + }) + ) + render( + + + + ) + expect(useCommandTerminalLinkStore.getState().links).toEqual({ + [commandLinkKey(9, -3)]: id, + }) + }) + function storeCommandTab(id: string) { const pageId = "0b6f3d7e-5a1c-4e2b-9f8a-1c2d3e4f5a6b" window.name = `codeg-terminal-page:${pageId}` diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index e9544468d7..153a1343f6 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -22,7 +22,10 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" import { useActiveFolder } from "@/contexts/active-folder-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" -import { useCommandTerminalLinkStore } from "@/stores/command-terminal-link-store" +import { + commandLinkKey, + useCommandTerminalLinkStore, +} from "@/stores/command-terminal-link-store" export interface TerminalTab { id: string @@ -91,10 +94,10 @@ function isRestorableTab(tab: unknown): tab is StoredTerminalTab { ) } +/** Any safe integer: folder commands have positive ids, global ones (see + * `global-commands`) negative. */ function storedCommandId(value: unknown): number | undefined { - return Number.isSafeInteger(value) && (value as number) >= 0 - ? (value as number) - : undefined + return Number.isSafeInteger(value) ? (value as number) : undefined } /** @@ -348,7 +351,9 @@ export function TerminalProvider({ children }: { children: ReactNode }) { useEffect(() => { const { setLink } = useCommandTerminalLinkStore.getState() for (const tab of restoredSession.tabs) { - if (tab.commandId !== undefined) setLink(tab.commandId, tab.id) + if (tab.commandId !== undefined) { + setLink(commandLinkKey(tab.folderId, tab.commandId), tab.id) + } } }, [restoredSession]) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index cf11890146..07d7e31b6c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3240,6 +3240,10 @@ "manageCommands": "إدارة الأوامر...", "runCommandTitle": "تشغيل: {command}", "stopCommandTitle": "إيقاف: {command}", + "localUnavailable": "الأوامر المحلية متاحة فقط في تطبيق سطح المكتب", + "localRunFailed": "فشل تشغيل الأمر المحلي", + "globalCommand": "متاح في كل المجلدات", + "localCommand": "يعمل على هذا الكمبيوتر", "manageDialog": { "title": "إدارة الأوامر", "empty": "لا توجد أوامر بعد", @@ -3248,11 +3252,16 @@ "newCommand": "أمر جديد", "nameLabel": "الاسم", "commandLabel": "الأمر", + "runLocalLabel": "التشغيل على هذا الكمبيوتر", + "runLocalHint": "يعمل على هذا الكمبيوتر بدلاً من مضيف المشروع ومن دون طرفية؛ {folderVar} هو المسار المطلق للمجلد و{hostVar} اسم المضيف البعيد، مثال: {example}", + "globalLabel": "إظهار في كل المجلدات", + "globalHint": "متاح في كل مجلد وworktree واتصال بعيد؛ ويُحفظ على هذا الكمبيوتر", "dragSort": "اسحب للترتيب", "dragSortCommand": "اسحب لترتيب {name}", "orderFailed": "فشل حفظ ترتيب الأوامر", "loadFailed": "فشل تحميل الأوامر", "saveFailed": "فشل حفظ الأمر", + "runLocalNotKept": "مضيف المجلد لا يمكنه حفظ \"التشغيل على هذا الكمبيوتر\"؛ حدّثه، أو فعّل \"إظهار في كل المجلدات\" ليُحفظ الأمر على هذا الكمبيوتر", "deleteFailed": "فشل حذف الأمر", "confirmDelete": { "title": "حذف الأمر؟", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4c24e021f4..f26c12e571 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3240,6 +3240,10 @@ "manageCommands": "Befehle verwalten...", "runCommandTitle": "Ausführen: {command}", "stopCommandTitle": "Stoppen: {command}", + "localUnavailable": "Lokale Befehle sind nur in der Desktop-App verfügbar", + "localRunFailed": "Lokaler Befehl konnte nicht ausgeführt werden", + "globalCommand": "In allen Ordnern verfügbar", + "localCommand": "Läuft auf diesem Computer", "manageDialog": { "title": "Befehle verwalten", "empty": "Noch keine Befehle", @@ -3248,11 +3252,16 @@ "newCommand": "Neuer Befehl", "nameLabel": "Bezeichnung", "commandLabel": "Befehl", + "runLocalLabel": "Auf diesem Computer ausführen", + "runLocalHint": "Läuft ohne Terminal auf diesem Computer statt auf dem Projekt-Host; {folderVar} ist der absolute Pfad des Ordners, {hostVar} der Name des Remote-Hosts, z. B. {example}", + "globalLabel": "In allen Ordnern anzeigen", + "globalHint": "In jedem Ordner, Worktree und jeder Remote-Verbindung verfügbar; auf diesem Computer gespeichert", "dragSort": "Zum Sortieren ziehen", "dragSortCommand": "{name} zum Sortieren ziehen", "orderFailed": "Befehlsreihenfolge konnte nicht gespeichert werden", "loadFailed": "Befehle konnten nicht geladen werden", "saveFailed": "Befehl konnte nicht gespeichert werden", + "runLocalNotKept": "der Host des Ordners kann \"Auf diesem Computer ausführen\" nicht speichern – aktualisieren Sie ihn oder aktivieren Sie \"In allen Ordnern anzeigen\", damit der Befehl auf diesem Computer gespeichert wird", "deleteFailed": "Befehl konnte nicht gelöscht werden", "confirmDelete": { "title": "Befehl löschen?", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index aae9f6032f..bfbcd0b9e6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3240,6 +3240,10 @@ "manageCommands": "Manage Commands...", "runCommandTitle": "Run: {command}", "stopCommandTitle": "Stop: {command}", + "localUnavailable": "Local commands are only available in the desktop app", + "localRunFailed": "Failed to run local command", + "globalCommand": "Shown in all folders", + "localCommand": "Runs on this computer", "manageDialog": { "title": "Manage Commands", "empty": "No commands yet", @@ -3248,11 +3252,16 @@ "newCommand": "New command", "nameLabel": "Name", "commandLabel": "Command", + "runLocalLabel": "Run on this computer", + "runLocalHint": "Runs on this computer instead of the project host, without a terminal; {folderVar} is the folder's absolute path and {hostVar} the remote host name, e.g. {example}", + "globalLabel": "Show in all folders", + "globalHint": "Available in every folder, worktree and remote connection; saved on this computer", "dragSort": "Drag to sort", "dragSortCommand": "Drag to sort {name}", "orderFailed": "Failed to save command order", "loadFailed": "Failed to load commands", "saveFailed": "Failed to save command", + "runLocalNotKept": "the folder's host can't save \"Run on this computer\" — update it, or turn on \"Show in all folders\" to keep the command on this computer", "deleteFailed": "Failed to delete command", "confirmDelete": { "title": "Delete command?", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b75b0683e4..47e168e001 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3240,6 +3240,10 @@ "manageCommands": "Gestionar comandos...", "runCommandTitle": "Ejecutar: {command}", "stopCommandTitle": "Detener: {command}", + "localUnavailable": "Los comandos locales solo están disponibles en la aplicación de escritorio", + "localRunFailed": "No se pudo ejecutar el comando local", + "globalCommand": "Disponible en todas las carpetas", + "localCommand": "Se ejecuta en este equipo", "manageDialog": { "title": "Gestionar comandos", "empty": "Aún no hay comandos", @@ -3248,11 +3252,16 @@ "newCommand": "Nuevo comando", "nameLabel": "Nombre", "commandLabel": "Comando", + "runLocalLabel": "Ejecutar en este equipo", + "runLocalHint": "Se ejecuta en este equipo en lugar del host del proyecto, sin terminal; {folderVar} es la ruta absoluta de la carpeta y {hostVar} el nombre del host remoto, p. ej. {example}", + "globalLabel": "Mostrar en todas las carpetas", + "globalHint": "Disponible en cada carpeta, worktree y conexión remota; se guarda en este equipo", "dragSort": "Arrastra para ordenar", "dragSortCommand": "Arrastra para ordenar {name}", "orderFailed": "No se pudo guardar el orden de los comandos", "loadFailed": "No se pudieron cargar los comandos", "saveFailed": "No se pudo guardar el comando", + "runLocalNotKept": "el host de la carpeta no puede guardar \"Ejecutar en este equipo\"; actualízalo o activa \"Mostrar en todas las carpetas\" para guardar el comando en este equipo", "deleteFailed": "No se pudo eliminar el comando", "confirmDelete": { "title": "¿Eliminar comando?", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 82905a1c38..b60ad5c0b7 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3240,6 +3240,10 @@ "manageCommands": "Gérer les commandes...", "runCommandTitle": "Exécuter : {command}", "stopCommandTitle": "Arrêter : {command}", + "localUnavailable": "Les commandes locales ne sont disponibles que dans l'application de bureau", + "localRunFailed": "Échec de l'exécution de la commande locale", + "globalCommand": "Disponible dans tous les dossiers", + "localCommand": "S'exécute sur cet ordinateur", "manageDialog": { "title": "Gérer les commandes", "empty": "Aucune commande pour le moment", @@ -3248,11 +3252,16 @@ "newCommand": "Nouvelle commande", "nameLabel": "Nom", "commandLabel": "Commande", + "runLocalLabel": "Exécuter sur cet ordinateur", + "runLocalHint": "S'exécute sur cet ordinateur plutôt que sur l'hôte du projet, sans terminal ; {folderVar} est le chemin absolu du dossier et {hostVar} le nom de l'hôte distant, par ex. {example}", + "globalLabel": "Afficher dans tous les dossiers", + "globalHint": "Disponible dans chaque dossier, worktree et connexion distante ; enregistré sur cet ordinateur", "dragSort": "Glisser pour trier", "dragSortCommand": "Glisser pour trier {name}", "orderFailed": "Échec de l'enregistrement de l'ordre des commandes", "loadFailed": "Échec du chargement des commandes", "saveFailed": "Échec de l'enregistrement de la commande", + "runLocalNotKept": "l'hôte du dossier ne peut pas enregistrer \"Exécuter sur cet ordinateur\" ; mettez-le à jour ou activez \"Afficher dans tous les dossiers\" pour garder la commande sur cet ordinateur", "deleteFailed": "Échec de la suppression de la commande", "confirmDelete": { "title": "Supprimer la commande ?", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index acbd5fab52..60dcc947cb 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3240,6 +3240,10 @@ "manageCommands": "コマンドを管理...", "runCommandTitle": "実行: {command}", "stopCommandTitle": "停止: {command}", + "localUnavailable": "ローカルコマンドはデスクトップアプリでのみ利用できます", + "localRunFailed": "ローカルコマンドの実行に失敗しました", + "globalCommand": "すべてのフォルダーで利用可能", + "localCommand": "このコンピューターで実行", "manageDialog": { "title": "コマンド管理", "empty": "コマンドはまだありません", @@ -3248,11 +3252,16 @@ "newCommand": "新しいコマンド", "nameLabel": "名前", "commandLabel": "コマンド", + "runLocalLabel": "このコンピューターで実行", + "runLocalHint": "プロジェクトのホストではなく、このコンピューターでターミナルを使わずに実行します。{folderVar} はフォルダーの絶対パス、{hostVar} はリモートホスト名です。例: {example}", + "globalLabel": "すべてのフォルダーに表示", + "globalHint": "すべてのフォルダー、worktree、リモート接続で利用できます。このコンピューターに保存されます", "dragSort": "ドラッグして並び替え", "dragSortCommand": "{name} をドラッグして並び替え", "orderFailed": "コマンドの並び順の保存に失敗しました", "loadFailed": "コマンドの読み込みに失敗しました", "saveFailed": "コマンドの保存に失敗しました", + "runLocalNotKept": "フォルダーのホストが「このコンピューターで実行」を保存できません。ホストを更新するか、「すべてのフォルダーに表示」をオンにしてコマンドをこのコンピューターに保存してください", "deleteFailed": "コマンドの削除に失敗しました", "confirmDelete": { "title": "コマンドを削除しますか?", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3519bb91b8..cf5b9e1654 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3240,6 +3240,10 @@ "manageCommands": "명령 관리...", "runCommandTitle": "실행: {command}", "stopCommandTitle": "중지: {command}", + "localUnavailable": "로컬 명령은 데스크톱 앱에서만 사용할 수 있습니다", + "localRunFailed": "로컬 명령 실행 실패", + "globalCommand": "모든 폴더에서 사용 가능", + "localCommand": "이 컴퓨터에서 실행", "manageDialog": { "title": "명령 관리", "empty": "아직 명령이 없습니다", @@ -3248,11 +3252,16 @@ "newCommand": "새 명령", "nameLabel": "이름", "commandLabel": "명령", + "runLocalLabel": "이 컴퓨터에서 실행", + "runLocalHint": "프로젝트 호스트가 아닌 이 컴퓨터에서 터미널 없이 실행됩니다. {folderVar}는 폴더의 절대 경로, {hostVar}는 원격 호스트 이름입니다. 예: {example}", + "globalLabel": "모든 폴더에 표시", + "globalHint": "모든 폴더, worktree, 원격 연결에서 사용할 수 있으며 이 컴퓨터에 저장됩니다", "dragSort": "드래그하여 정렬", "dragSortCommand": "{name} 드래그하여 정렬", "orderFailed": "명령 순서 저장에 실패했습니다", "loadFailed": "명령을 불러오지 못했습니다", "saveFailed": "명령을 저장하지 못했습니다", + "runLocalNotKept": "폴더의 호스트가 \"이 컴퓨터에서 실행\"을 저장할 수 없습니다. 호스트를 업데이트하거나 \"모든 폴더에 표시\"를 켜서 명령을 이 컴퓨터에 저장하세요", "deleteFailed": "명령을 삭제하지 못했습니다", "confirmDelete": { "title": "명령을 삭제하시겠습니까?", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 1c333481e3..8853987a04 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3240,6 +3240,10 @@ "manageCommands": "Gerenciar comandos...", "runCommandTitle": "Executar: {command}", "stopCommandTitle": "Parar: {command}", + "localUnavailable": "Comandos locais estão disponíveis apenas no aplicativo de desktop", + "localRunFailed": "Falha ao executar o comando local", + "globalCommand": "Disponível em todas as pastas", + "localCommand": "Executa neste computador", "manageDialog": { "title": "Gerenciar comandos", "empty": "Ainda não há comandos", @@ -3248,11 +3252,16 @@ "newCommand": "Novo comando", "nameLabel": "Nome", "commandLabel": "Comando", + "runLocalLabel": "Executar neste computador", + "runLocalHint": "Executa neste computador em vez do host do projeto, sem terminal; {folderVar} é o caminho absoluto da pasta e {hostVar} o nome do host remoto, ex.: {example}", + "globalLabel": "Mostrar em todas as pastas", + "globalHint": "Disponível em todas as pastas, worktrees e conexões remotas; salvo neste computador", "dragSort": "Arraste para ordenar", "dragSortCommand": "Arraste para ordenar {name}", "orderFailed": "Falha ao salvar a ordem dos comandos", "loadFailed": "Falha ao carregar os comandos", "saveFailed": "Falha ao salvar o comando", + "runLocalNotKept": "o host da pasta não consegue salvar \"Executar neste computador\"; atualize-o ou ative \"Mostrar em todas as pastas\" para salvar o comando neste computador", "deleteFailed": "Falha ao excluir o comando", "confirmDelete": { "title": "Excluir comando?", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6ef841df26..b2dcb87635 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3240,6 +3240,10 @@ "manageCommands": "管理命令...", "runCommandTitle": "运行:{command}", "stopCommandTitle": "停止:{command}", + "localUnavailable": "本地命令仅在桌面应用中可用", + "localRunFailed": "运行本地命令失败", + "globalCommand": "所有文件夹可用", + "localCommand": "在本机运行", "manageDialog": { "title": "管理命令", "empty": "暂无命令", @@ -3248,11 +3252,16 @@ "newCommand": "新建命令", "nameLabel": "名称", "commandLabel": "命令", + "runLocalLabel": "在本机运行", + "runLocalHint": "在本机而不是项目所在主机上运行,不打开终端;{folderVar} 为目录的绝对路径,{hostVar} 为远程主机名,例如 {example}", + "globalLabel": "在所有文件夹中显示", + "globalHint": "在所有文件夹、worktree 和远程连接中都可用,保存在本机", "dragSort": "拖拽排序", "dragSortCommand": "拖拽排序 {name}", "orderFailed": "保存命令排序失败", "loadFailed": "加载命令失败", "saveFailed": "保存命令失败", + "runLocalNotKept": "文件夹所在主机无法保存“在本机运行”,请更新该主机,或打开“在所有文件夹中显示”把命令保存在本机", "deleteFailed": "删除命令失败", "confirmDelete": { "title": "删除命令?", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 8bc732098c..995b0219ca 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3240,6 +3240,10 @@ "manageCommands": "管理命令...", "runCommandTitle": "執行:{command}", "stopCommandTitle": "停止:{command}", + "localUnavailable": "本機命令僅在桌面應用程式中可用", + "localRunFailed": "執行本機命令失敗", + "globalCommand": "所有資料夾可用", + "localCommand": "在本機執行", "manageDialog": { "title": "管理命令", "empty": "暫無命令", @@ -3248,11 +3252,16 @@ "newCommand": "新增命令", "nameLabel": "名稱", "commandLabel": "命令", + "runLocalLabel": "在本機執行", + "runLocalHint": "在本機而非專案所在主機上執行,不開啟終端機;{folderVar} 為目錄的絕對路徑,{hostVar} 為遠端主機名稱,例如 {example}", + "globalLabel": "在所有資料夾中顯示", + "globalHint": "在所有資料夾、worktree 和遠端連線中都可用,儲存在本機", "dragSort": "拖曳排序", "dragSortCommand": "拖曳排序 {name}", "orderFailed": "儲存命令排序失敗", "loadFailed": "載入命令失敗", "saveFailed": "儲存命令失敗", + "runLocalNotKept": "資料夾所在主機無法儲存「在本機執行」,請更新該主機,或開啟「在所有資料夾中顯示」將命令儲存在本機", "deleteFailed": "刪除命令失敗", "confirmDelete": { "title": "刪除命令?", diff --git a/src/lib/api.ts b/src/lib/api.ts index c5beefe50c..1ab68c476b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3462,6 +3462,30 @@ export async function openInCode(path: string): Promise { return getTransport().call("open_in_code", { path }) } +/** + * Run a shell command detached on THIS desktop machine, even from a + * remote-desktop window — hence the shell transport. `folderPath` (the folder's + * path on its own host) and `remoteHost` reach the command as + * `$CODEG_FOLDER_PATH` / `$CODEG_REMOTE_HOST`. `remoteHost` is non-null exactly + * in a remote-desktop window (empty when its host's name can't be read): the + * backend then treats `folderPath` as the remote server's, not this machine's. + * It runs in `workingDir` when that is a directory on this machine, else in + * the home directory. Desktop only. + */ +export async function runLocalCommand( + command: string, + folderPath: string, + remoteHost: string | null, + workingDir: string | null +): Promise { + return getShellTransport().call("run_local_command", { + command, + folderPath, + remoteHost, + workingDir, + }) +} + /** * Open a freshly created git worktree directory as a folder, recording the root * folder it descends from (`sourceFolderId` is the folder the worktree was @@ -3757,18 +3781,25 @@ export async function deleteConversation( export async function listFolderCommands( folderId: number ): Promise { - return getTransport().call("list_folder_commands", { folderId }) + // `includeLocal` tells the host this client knows `run_local`; it withholds + // such commands from older clients, which would run them in its terminal. + return getTransport().call("list_folder_commands", { + folderId, + includeLocal: true, + }) } export async function createFolderCommand( folderId: number, name: string, - command: string + command: string, + runLocal: boolean ): Promise { return getTransport().call("create_folder_command", { folderId, name, command, + runLocal, }) } @@ -3776,13 +3807,16 @@ export async function updateFolderCommand( id: number, name?: string, command?: string, - sortOrder?: number + sortOrder?: number, + runLocal?: boolean ): Promise { return getTransport().call("update_folder_command", { id, name: name ?? null, command: command ?? null, sortOrder: sortOrder ?? null, + runLocal: runLocal ?? null, + includeLocal: true, }) } @@ -3804,6 +3838,7 @@ export async function bootstrapFolderCommandsFromPackageJson( return getTransport().call("bootstrap_folder_commands_from_package_json", { folderId, folderPath, + includeLocal: true, }) } diff --git a/src/lib/global-commands.test.ts b/src/lib/global-commands.test.ts new file mode 100644 index 0000000000..a0569af6f0 --- /dev/null +++ b/src/lib/global-commands.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { + GLOBAL_COMMANDS_STORAGE_KEY, + createGlobalCommand, + deleteGlobalCommand, + isGlobalCommandId, + listGlobalCommands, + reorderGlobalCommands, + updateGlobalCommand, + withoutGlobalIds, +} from "./global-commands" +import type { FolderCommand } from "./types" + +const summary = () => + listGlobalCommands().map(({ id, name, command }) => ({ id, name, command })) + +describe("global commands", () => { + beforeEach(() => localStorage.clear()) + + it("hands out negative ids, apart from folder commands' primary keys", () => { + const a = createGlobalCommand("zed", "zed .", true) + const b = createGlobalCommand("open", "open .", true) + expect(a.id).toBe(-1) + expect(b.id).toBe(-2) + expect(isGlobalCommandId(a.id)).toBe(true) + expect(isGlobalCommandId(5)).toBe(false) + }) + + it("does not reuse the id of a deleted command", () => { + createGlobalCommand("a", "a", false) + const b = createGlobalCommand("b", "b", false) + deleteGlobalCommand(-1) + expect(createGlobalCommand("c", "c", false).id).toBe(b.id - 1) + }) + + it("does not reuse the id of the newest command once it is deleted", () => { + // A folder's remembered selection, or a terminal it still runs in, would + // otherwise pass to the next command created. + createGlobalCommand("a", "a", false) + const b = createGlobalCommand("b", "b", false) + deleteGlobalCommand(b.id) + expect(createGlobalCommand("c", "c", false).id).toBe(b.id - 1) + deleteGlobalCommand(-1) + deleteGlobalCommand(-3) + expect(createGlobalCommand("d", "d", false).id).toBe(-4) + }) + + it("refuses to hand out an id past the last safe integer", () => { + localStorage.setItem( + GLOBAL_COMMANDS_STORAGE_KEY, + JSON.stringify({ + lastId: Number.MIN_SAFE_INTEGER, + commands: [{ id: Number.MIN_SAFE_INTEGER, name: "a", command: "a" }], + }) + ) + expect(() => createGlobalCommand("b", "b", false)).toThrow() + expect(summary().map((c) => c.name)).toEqual(["a"]) + }) + + it("reads the bare list earlier builds stored, and counts on below it", () => { + localStorage.setItem( + GLOBAL_COMMANDS_STORAGE_KEY, + JSON.stringify([{ id: -4, name: "old", command: "x" }]) + ) + expect(summary()).toEqual([{ id: -4, name: "old", command: "x" }]) + expect(createGlobalCommand("new", "y", false).id).toBe(-5) + }) + + it("never hands out an id at or above one in use, whatever the mark says", () => { + localStorage.setItem( + GLOBAL_COMMANDS_STORAGE_KEY, + JSON.stringify({ + lastId: -1, + commands: [{ id: -5, name: "a", command: "a" }], + }) + ) + expect(createGlobalCommand("b", "b", false).id).toBe(-6) + }) + + it("updates, deletes and reorders", () => { + createGlobalCommand("a", "a", false) + createGlobalCommand("b", "b", false) + createGlobalCommand("c", "c", false) + updateGlobalCommand(-2, "B", "bb", false) + reorderGlobalCommands([-3, -1]) + expect(summary()).toEqual([ + { id: -3, name: "c", command: "c" }, + { id: -1, name: "a", command: "a" }, + { id: -2, name: "B", command: "bb" }, + ]) + deleteGlobalCommand(-3) + expect(summary().map((c) => c.id)).toEqual([-1, -2]) + }) + + it("keeps whether a command runs on this computer", () => { + createGlobalCommand("zed", "zed .", true) + createGlobalCommand("dev", "pnpm dev", false) + expect(listGlobalCommands().map((c) => [c.name, c.run_local])).toEqual([ + ["zed", true], + ["dev", false], + ]) + updateGlobalCommand(-1, "zed", "zed .", false) + updateGlobalCommand(-2, "dev", "pnpm dev", true) + expect(listGlobalCommands().map((c) => [c.name, c.run_local])).toEqual([ + ["zed", false], + ["dev", true], + ]) + }) + + it("runs a stored command on this computer only when it says so", () => { + // Entries from builds before the flag have none; whatever else a newer + // build might store there doesn't count either. + localStorage.setItem( + GLOBAL_COMMANDS_STORAGE_KEY, + JSON.stringify([ + { id: -1, name: "older", command: "local: zed ." }, + { id: -2, name: "odd", command: "zed .", run_local: "yes" }, + { id: -3, name: "local", command: "zed .", run_local: true }, + ]) + ) + expect(listGlobalCommands().map((c) => [c.name, c.run_local])).toEqual([ + ["older", false], + ["odd", false], + ["local", true], + ]) + }) + + it("throws when updating a command that is gone", () => { + expect(() => updateGlobalCommand(-9, "x", "x", false)).toThrow() + }) + + it("ignores malformed storage", () => { + localStorage.setItem( + GLOBAL_COMMANDS_STORAGE_KEY, + JSON.stringify([ + { id: 3, name: "positive", command: "x" }, + { id: -1, name: "ok", command: "y" }, + { id: -1, name: "duplicate", command: "z" }, + { id: -2, name: 7, command: "w" }, + null, + ]) + ) + expect(summary()).toEqual([{ id: -1, name: "ok", command: "y" }]) + localStorage.setItem(GLOBAL_COMMANDS_STORAGE_KEY, "{not json") + expect(listGlobalCommands()).toEqual([]) + localStorage.setItem( + GLOBAL_COMMANDS_STORAGE_KEY, + JSON.stringify({ lastId: -3, commands: "nope" }) + ) + expect(listGlobalCommands()).toEqual([]) + }) +}) + +describe("withoutGlobalIds", () => { + const command = (id: number): FolderCommand => ({ + id, + folder_id: 1, + name: `c${id}`, + command: "x", + sort_order: 0, + created_at: "", + updated_at: "", + }) + + it("drops host entries that claim a global command's id", () => { + expect( + withoutGlobalIds([command(3), command(-1), command(7)]).map((c) => c.id) + ).toEqual([3, 7]) + }) +}) diff --git a/src/lib/global-commands.ts b/src/lib/global-commands.ts new file mode 100644 index 0000000000..c13b0f6d98 --- /dev/null +++ b/src/lib/global-commands.ts @@ -0,0 +1,171 @@ +import type { FolderCommand } from "@/lib/types" + +/** + * Commands shown in every folder — other repos, worktrees, remote connections + * alike — rather than in the one folder they were added to. Folder commands + * live in the folder host's database, keyed by folder; these live in this + * client's localStorage instead, so they need nothing from the server and + * follow the user to whichever host a window is bound to. + * + * They wear the `FolderCommand` shape so the launcher and the manage dialog + * treat both kinds alike. Their ids are negative, which keeps them apart from + * the database's (positive) primary keys in every map keyed by command id. + */ +export const GLOBAL_COMMANDS_STORAGE_KEY = "codeg:global-commands:v1" + +interface StoredGlobalCommand { + id: number + name: string + command: string + run_local: boolean +} + +interface StoredGlobalCommands { + /** + * The last id handed out. Ids are never reused: a deleted command's id can + * still be a folder's remembered selection, or linked to a terminal it is + * running in, and either would pass to the next command created. + */ + lastId: number + commands: StoredGlobalCommand[] +} + +export function isGlobalCommandId(id: number): boolean { + return id < 0 +} + +/** + * Drop entries that claim a global command's id from a list the folder's host + * returned. Its database hands out positive ids only, so such an entry is not + * the host's own; letting it through would pass it off as one of the commands + * kept on this computer. + */ +export function withoutGlobalIds(commands: FolderCommand[]): FolderCommand[] { + return commands.filter((command) => !isGlobalCommandId(command.id)) +} + +/** A command the folder's host returned from a save, refused for the same + * reason `withoutGlobalIds` drops one: it claims a global command's id. */ +export function requireHostCommand(command: FolderCommand): FolderCommand { + if (isGlobalCommandId(command.id)) { + throw new Error(`the folder's host returned command id ${command.id}`) + } + return command +} + +/** One stored entry, or null when it isn't `{id<0, name, command}` — the + * storage is shared with older and newer builds, so it is untrusted input. + * Only an explicit `run_local: true` runs a command on this computer. */ +function parseEntry(value: unknown): StoredGlobalCommand | null { + if (!value || typeof value !== "object") return null + const { id, name, command, run_local } = value as Record + if (!Number.isSafeInteger(id) || !isGlobalCommandId(id as number)) return null + if (typeof name !== "string" || typeof command !== "string") return null + return { id: id as number, name, command, run_local: run_local === true } +} + +function readStored(): StoredGlobalCommands { + const empty: StoredGlobalCommands = { lastId: 0, commands: [] } + try { + const raw = localStorage.getItem(GLOBAL_COMMANDS_STORAGE_KEY) + if (!raw) return empty + const value: unknown = JSON.parse(raw) + // Builds before `lastId` stored the bare list. + const record = Array.isArray(value) ? { commands: value } : value + if (!record || typeof record !== "object") return empty + const { lastId, commands } = record as Record + if (!Array.isArray(commands)) return empty + const seen = new Set() + const out: StoredGlobalCommand[] = [] + for (const item of commands) { + const entry = parseEntry(item) + if (!entry || seen.has(entry.id)) continue + seen.add(entry.id) + out.push(entry) + } + // Never above an id in use, whatever the stored mark says. + const floor = out.reduce((min, entry) => Math.min(min, entry.id), 0) + return { + lastId: + Number.isSafeInteger(lastId) && (lastId as number) < floor + ? (lastId as number) + : floor, + commands: out, + } + } catch { + return empty + } +} + +function writeStored(stored: StoredGlobalCommands): void { + localStorage.setItem(GLOBAL_COMMANDS_STORAGE_KEY, JSON.stringify(stored)) +} + +function toFolderCommand( + entry: StoredGlobalCommand, + index: number +): FolderCommand { + return { + id: entry.id, + folder_id: 0, + name: entry.name, + command: entry.command, + sort_order: index, + run_local: entry.run_local, + created_at: "", + updated_at: "", + } +} + +export function listGlobalCommands(): FolderCommand[] { + return readStored().commands.map(toFolderCommand) +} + +export function createGlobalCommand( + name: string, + command: string, + runLocal: boolean +): FolderCommand { + const { lastId, commands } = readStored() + const id = lastId - 1 + // Past the last safe integer an id no longer reads back as one. + if (!Number.isSafeInteger(id)) throw new Error("No command ids left") + const next = [...commands, { id, name, command, run_local: runLocal }] + writeStored({ lastId: id, commands: next }) + return toFolderCommand(next[next.length - 1], next.length - 1) +} + +export function updateGlobalCommand( + id: number, + name: string, + command: string, + runLocal: boolean +): FolderCommand { + const { lastId, commands } = readStored() + const index = commands.findIndex((entry) => entry.id === id) + if (index < 0) throw new Error("Command not found") + commands[index] = { id, name, command, run_local: runLocal } + writeStored({ lastId, commands }) + return toFolderCommand(commands[index], index) +} + +export function deleteGlobalCommand(id: number): void { + const { lastId, commands } = readStored() + writeStored({ + lastId, + commands: commands.filter((entry) => entry.id !== id), + }) +} + +/** Reorder to `ids`; entries missing from it keep their place at the end. */ +export function reorderGlobalCommands(ids: number[]): void { + const { lastId, commands } = readStored() + const byId = new Map(commands.map((entry) => [entry.id, entry])) + const ordered = ids.flatMap((id) => { + const entry = byId.get(id) + if (!entry) return [] + byId.delete(id) + return [entry] + }) + writeStored({ lastId, commands: [...ordered, ...byId.values()] }) +} diff --git a/src/lib/local-command.test.ts b/src/lib/local-command.test.ts new file mode 100644 index 0000000000..458d2c3ac2 --- /dev/null +++ b/src/lib/local-command.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest" +import type { FolderCommand } from "@/lib/types" + +const h = vi.hoisted(() => ({ + remote: true, + baseUrl: "https://devbox.example:3080/", +})) + +vi.mock("@/lib/transport", () => ({ + isRemoteDesktopMode: () => h.remote, + getServerBaseUrl: () => h.baseUrl, +})) + +import { getRemoteHostName, isLocalCommand } from "./local-command" + +function command(command: string, runLocal?: boolean): FolderCommand { + return { + id: 1, + folder_id: 1, + name: "x", + command, + sort_order: 0, + ...(runLocal === undefined ? {} : { run_local: runLocal }), + created_at: "", + updated_at: "", + } +} + +describe("isLocalCommand", () => { + it("follows the command's flag", () => { + expect(isLocalCommand(command("zed .", true))).toBe(true) + expect(isLocalCommand(command("pnpm dev", false))).toBe(false) + }) + + it("runs an older server's command, which has no flag, in a terminal", () => { + expect(isLocalCommand(command("pnpm dev"))).toBe(false) + }) + + it("reads nothing into the command's text", () => { + expect(isLocalCommand(command("local: zed ."))).toBe(false) + }) +}) + +describe("getRemoteHostName", () => { + it("returns the remote server's host name", () => { + h.remote = true + expect(getRemoteHostName()).toBe("devbox.example") + }) + + it("is null outside a remote-desktop window", () => { + h.remote = false + expect(getRemoteHostName()).toBeNull() + }) +}) diff --git a/src/lib/local-command.ts b/src/lib/local-command.ts new file mode 100644 index 0000000000..ba92aad7a4 --- /dev/null +++ b/src/lib/local-command.ts @@ -0,0 +1,22 @@ +import { getServerBaseUrl, isRemoteDesktopMode } from "@/lib/transport" +import type { FolderCommand } from "@/lib/types" + +/** + * Whether `command` runs on the desktop's own machine instead of in a terminal + * on the folder's host — the way to reach local tools (e.g. + * `zed "ssh://host$CODEG_FOLDER_PATH"`) from a remote project. Rows from a + * server older than the flag don't carry it: those run in a terminal. + */ +export function isLocalCommand(command: FolderCommand): boolean { + return command.run_local === true +} + +/** Host name of the remote codeg-server this window is bound to, if any. */ +export function getRemoteHostName(): string | null { + if (!isRemoteDesktopMode()) return null + try { + return new URL(getServerBaseUrl()).hostname || null + } catch { + return null + } +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index d187eb0d2c..12e1432fa5 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -1129,22 +1129,25 @@ export async function listFolderCommands( export async function createFolderCommand( folderId: number, name: string, - command: string + command: string, + runLocal: boolean ): Promise { - return invoke("create_folder_command", { folderId, name, command }) + return invoke("create_folder_command", { folderId, name, command, runLocal }) } export async function updateFolderCommand( id: number, name?: string, command?: string, - sortOrder?: number + sortOrder?: number, + runLocal?: boolean ): Promise { return invoke("update_folder_command", { id, name: name ?? null, command: command ?? null, sortOrder: sortOrder ?? null, + runLocal: runLocal ?? null, }) } diff --git a/src/lib/types.ts b/src/lib/types.ts index 70af86e6e5..1b9ef8e350 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -4648,6 +4648,10 @@ export interface FolderCommand { name: string command: string sort_order: number + /** Run by the desktop app on its own machine, detached, instead of in a + * terminal on the folder's host. Absent from older servers' rows (then + * treated as `false`). */ + run_local?: boolean created_at: string updated_at: string } diff --git a/src/stores/command-terminal-link-store.test.ts b/src/stores/command-terminal-link-store.test.ts index 8af5cac75a..4887950ae3 100644 --- a/src/stores/command-terminal-link-store.test.ts +++ b/src/stores/command-terminal-link-store.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest" import { + commandLinkKey, resolveLiveCommandTerminalId, useCommandTerminalLinkStore, } from "./command-terminal-link-store" +// Command `n` as run in folder 1. +const k = (n: number) => commandLinkKey(1, n) + beforeEach(() => { useCommandTerminalLinkStore.setState({ links: {} }) }) @@ -11,26 +15,37 @@ beforeEach(() => { describe("command-terminal-link store", () => { describe("setLink", () => { it("adds a link", () => { - useCommandTerminalLinkStore.getState().setLink(1, "term-a") + useCommandTerminalLinkStore.getState().setLink(k(1), "term-a") expect(useCommandTerminalLinkStore.getState().links).toEqual({ - 1: "term-a", + [k(1)]: "term-a", }) }) it("overwrites the link for the same command", () => { const store = useCommandTerminalLinkStore.getState() - store.setLink(1, "term-a") - store.setLink(1, "term-b") + store.setLink(k(1), "term-a") + store.setLink(k(1), "term-b") expect(useCommandTerminalLinkStore.getState().links).toEqual({ - 1: "term-b", + [k(1)]: "term-b", + }) + }) + + it("keeps one command's runs in different folders apart", () => { + // A global command has one id in every folder it is shown in. + const store = useCommandTerminalLinkStore.getState() + store.setLink(commandLinkKey(1, -1), "term-a") + store.setLink(commandLinkKey(2, -1), "term-b") + store.clearLink(commandLinkKey(1, -1)) + expect(useCommandTerminalLinkStore.getState().links).toEqual({ + [commandLinkKey(2, -1)]: "term-b", }) }) it("keeps the links reference stable when unchanged", () => { const store = useCommandTerminalLinkStore.getState() - store.setLink(1, "term-a") + store.setLink(k(1), "term-a") const before = useCommandTerminalLinkStore.getState().links - store.setLink(1, "term-a") + store.setLink(k(1), "term-a") expect(useCommandTerminalLinkStore.getState().links).toBe(before) }) }) @@ -38,19 +53,19 @@ describe("command-terminal-link store", () => { describe("clearLink", () => { it("removes a link, leaving the others", () => { const store = useCommandTerminalLinkStore.getState() - store.setLink(1, "term-a") - store.setLink(2, "term-b") - store.clearLink(1) + store.setLink(k(1), "term-a") + store.setLink(k(2), "term-b") + store.clearLink(k(1)) expect(useCommandTerminalLinkStore.getState().links).toEqual({ - 2: "term-b", + [k(2)]: "term-b", }) }) it("is a stable no-op when the command has no link", () => { const store = useCommandTerminalLinkStore.getState() - store.setLink(1, "term-a") + store.setLink(k(1), "term-a") const before = useCommandTerminalLinkStore.getState().links - store.clearLink(999) + store.clearLink(k(999)) expect(useCommandTerminalLinkStore.getState().links).toBe(before) }) }) @@ -58,19 +73,19 @@ describe("command-terminal-link store", () => { describe("pruneTerminals", () => { it("drops links whose terminal matches the predicate", () => { const store = useCommandTerminalLinkStore.getState() - store.setLink(1, "term-a") - store.setLink(2, "term-b") - store.setLink(3, "term-c") + store.setLink(k(1), "term-a") + store.setLink(k(2), "term-b") + store.setLink(k(3), "term-c") store.pruneTerminals((termId) => termId === "term-b") expect(useCommandTerminalLinkStore.getState().links).toEqual({ - 1: "term-a", - 3: "term-c", + [k(1)]: "term-a", + [k(3)]: "term-c", }) }) it("is a stable no-op when nothing matches", () => { const store = useCommandTerminalLinkStore.getState() - store.setLink(1, "term-a") + store.setLink(k(1), "term-a") const before = useCommandTerminalLinkStore.getState().links store.pruneTerminals(() => false) expect(useCommandTerminalLinkStore.getState().links).toBe(before) @@ -81,18 +96,18 @@ describe("command-terminal-link store", () => { const isLive = (id: string) => id === "term-a" it("returns the terminal id when the link exists and is live", () => { - expect(resolveLiveCommandTerminalId({ 1: "term-a" }, 1, isLive)).toBe( - "term-a" - ) + expect( + resolveLiveCommandTerminalId({ [k(1)]: "term-a" }, k(1), isLive) + ).toBe("term-a") }) it("returns undefined when the command has no link", () => { - expect(resolveLiveCommandTerminalId({}, 1, isLive)).toBeUndefined() + expect(resolveLiveCommandTerminalId({}, k(1), isLive)).toBeUndefined() }) it("returns undefined when the linked terminal is not live (exited/closed)", () => { expect( - resolveLiveCommandTerminalId({ 1: "term-dead" }, 1, isLive) + resolveLiveCommandTerminalId({ [k(1)]: "term-dead" }, k(1), isLive) ).toBeUndefined() }) }) diff --git a/src/stores/command-terminal-link-store.ts b/src/stores/command-terminal-link-store.ts index ec700c2f33..6b6853f0d8 100644 --- a/src/stores/command-terminal-link-store.ts +++ b/src/stores/command-terminal-link-store.ts @@ -12,40 +12,50 @@ import { create } from "zustand" */ interface CommandTerminalLinkState { /** - * Maps a folder command's id to the terminal it was launched into. Folder - * command ids are globally-unique DB primary keys, so a flat map is safe - * across folders. + * Maps a launcher command, as run in one folder (see `commandLinkKey`), to + * the terminal it was launched into. */ - links: Record - setLink: (commandId: number, terminalId: string) => void - clearLink: (commandId: number) => void + links: Record + setLink: (key: string, terminalId: string) => void + clearLink: (key: string) => void /** Drop every link whose terminal id matches the predicate (exited/closed). */ pruneTerminals: (shouldRemove: (terminalId: string) => boolean) => void } +/** + * The key a command's link is stored under: the folder it runs in as well as + * its id. A folder command's id is a database key no other folder shares, but + * a global command (see `global-commands`) has the same id in every folder, + * and running it in one folder must not read as running — or be stopped — in + * another. + */ +export function commandLinkKey(folderId: number, commandId: number): string { + return `${folderId}:${commandId}` +} + export const useCommandTerminalLinkStore = create( (set) => ({ links: {}, - setLink: (commandId, terminalId) => + setLink: (key, terminalId) => set((s) => - s.links[commandId] === terminalId + s.links[key] === terminalId ? s - : { links: { ...s.links, [commandId]: terminalId } } + : { links: { ...s.links, [key]: terminalId } } ), - clearLink: (commandId) => + clearLink: (key) => set((s) => { - if (!(commandId in s.links)) return s + if (!(key in s.links)) return s const next = { ...s.links } - delete next[commandId] + delete next[key] return { links: next } }), pruneTerminals: (shouldRemove) => set((s) => { let changed = false - const next: Record = {} - for (const [cmdId, termId] of Object.entries(s.links)) { + const next: Record = {} + for (const [key, termId] of Object.entries(s.links)) { if (shouldRemove(termId)) changed = true - else next[Number(cmdId)] = termId + else next[key] = termId } return changed ? { links: next } : s }), @@ -60,10 +70,10 @@ export const useCommandTerminalLinkStore = create( * Pure + exported for unit testing. */ export function resolveLiveCommandTerminalId( - links: Record, - commandId: number, + links: Record, + key: string, isLive: (terminalId: string) => boolean ): string | undefined { - const terminalId = links[commandId] + const terminalId = links[key] return terminalId && isLive(terminalId) ? terminalId : undefined }