From b307d6291d36330a4a79da878fc0f7860bb27070 Mon Sep 17 00:00:00 2001 From: fengpanhe Date: Fri, 16 Oct 2026 12:26:32 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=91=BD=E4=BB=A4=E6=94=AF=E6=8C=81=EF=BC=8C=E5=8F=AF?= =?UTF-8?q?=E5=9C=A8=E6=A1=8C=E9=9D=A2=E7=AB=AF=E6=89=A7=E8=A1=8C=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 可选正文(补充说明) 新增 `local:` 前缀的命令,可在桌面应用中直接在本机执行,而非项目主机。支持多语言国际化,新增全局命令功能,命令可在所有文件夹中显示,同时适配 Tauri 桌面端实现本地命令执行逻辑,新增相关测试和类型定义。 --- src-tauri/src/commands/local_command.rs | 155 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 4 + src-tauri/src/lib.rs | 3 +- .../layout/command-dropdown.test.tsx | 60 ++++++- src/components/layout/command-dropdown.tsx | 58 ++++++- .../layout/command-manage-dialog.tsx | 113 +++++++++++-- src/contexts/terminal-context.tsx | 6 +- src/i18n/messages/ar.json | 6 + src/i18n/messages/de.json | 6 + src/i18n/messages/en.json | 6 + src/i18n/messages/es.json | 6 + src/i18n/messages/fr.json | 6 + src/i18n/messages/ja.json | 6 + src/i18n/messages/ko.json | 6 + src/i18n/messages/pt.json | 6 + src/i18n/messages/zh-CN.json | 6 + src/i18n/messages/zh-TW.json | 6 + src/lib/api.ts | 18 ++ src/lib/global-commands.test.ts | 68 ++++++++ src/lib/global-commands.ts | 118 +++++++++++++ src/lib/local-command.test.ts | 41 +++++ src/lib/local-command.ts | 27 +++ 22 files changed, 707 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/commands/local_command.rs create mode 100644 src/lib/global-commands.test.ts create mode 100644 src/lib/global-commands.ts create mode 100644 src/lib/local-command.test.ts create mode 100644 src/lib/local-command.ts diff --git a/src-tauri/src/commands/local_command.rs b/src-tauri/src/commands/local_command.rs new file mode 100644 index 0000000000..5bb12f72b6 --- /dev/null +++ b/src-tauri/src/commands/local_command.rs @@ -0,0 +1,155 @@ +use std::ffi::OsString; +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`. +/// +/// `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, +) -> Result<(), AppCommandError> { + let command = validate_local_command(&command)?; + let shell = crate::terminal::manager::resolve_shell(); + let mut child = tokio::process::Command::new(&shell); + child + .args(shell_args(&shell, command)) + .env("CODEG_CMD", command) + .env("CODEG_FOLDER_PATH", folder_path.unwrap_or_default()) + .env("CODEG_REMOTE_HOST", remote_host.unwrap_or_default()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(home) = dirs::home_dir() { + child.current_dir(home); + } + #[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}")) + }) +} + +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) +} + +/// Shell arguments that run `$CODEG_CMD` and exit. The command travels in the +/// env var 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. +fn shell_args(shell: &str, command: &str) -> Vec { + let args: Vec<&str> = match classify_shell_family(shell) { + ShellFamily::PowerShell => vec![ + "-NoLogo", + "-NoProfile", + "-Command", + "Invoke-Expression $env:CODEG_CMD", + ], + ShellFamily::Cmd => vec!["/D", "/S", "/C", "%CODEG_CMD%"], + 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], + }; + 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 ."); + } + + #[test] + fn bash_like_shells_eval_the_env_var_in_a_login_shell() { + let args = shell_args("/bin/zsh", "zed ."); + assert_eq!(args, ["-l", "-c", "eval \"$CODEG_CMD\""]); + } + + #[test] + fn unknown_posix_shells_get_the_command_verbatim() { + let args = shell_args("/usr/bin/nu", "zed ."); + assert_eq!(args, ["-c", "zed ."]); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn runs_command_with_folder_env() { + 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' \"$CODEG_FOLDER_PATH\" \"$CODEG_REMOTE_HOST\" > '{}'", + marker.display() + ), + Some("/srv/repo".into()), + Some("devbox".into()), + ) + .await + .expect("spawn"); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(contents) = std::fs::read_to_string(&marker) { + if !contents.is_empty() { + assert_eq!(contents, "/srv/repo|devbox"); + 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..cdd2b7ac4e 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 run on the desktop's own machine (`local:` commands), 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/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/components/layout/command-dropdown.test.tsx b/src/components/layout/command-dropdown.test.tsx index 8b5f0d17cc..e06f173af5 100644 --- a/src/components/layout/command-dropdown.test.tsx +++ b/src/components/layout/command-dropdown.test.tsx @@ -1,5 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { listFolderCommands } from "@/lib/api" +import { createGlobalCommand } from "@/lib/global-commands" import { useCommandTerminalLinkStore } from "@/stores/command-terminal-link-store" import { CommandDropdown } from "./command-dropdown" @@ -7,11 +9,21 @@ 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, + command: "pnpm dev", 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: () => true, + getServerBaseUrl: () => "https://devbox.example:3080", +})) vi.mock("@/contexts/active-folder-context", () => ({ useActiveFolder: () => ({ activeFolder: { id: 1, path: "/repo" } }), })) @@ -28,13 +40,14 @@ vi.mock("@/lib/api", () => ({ id: 5, folder_id: 1, name: "dev", - command: "pnpm dev", + command: h.command, sort_order: 0, created_at: "", updated_at: "", }, ]), bootstrapFolderCommandsFromPackageJson: vi.fn(async () => []), + runLocalCommand: h.runLocal, terminalStop: h.stop, terminalKill: h.kill, })) @@ -45,6 +58,10 @@ describe("CommandDropdown", () => { h.stop.mockClear() h.kill.mockClear() h.create.mockClear() + h.runLocal.mockClear() + h.toastError.mockClear() + h.desktop = true + h.command = "pnpm dev" h.tabs = [] useCommandTerminalLinkStore.setState({ links: {} }) localStorage.clear() @@ -72,4 +89,45 @@ describe("CommandDropdown", () => { expect(h.kill).not.toHaveBeenCalled() expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) + + it("runs a local: command on this machine instead of in a terminal", async () => { + h.command = 'local: zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', + "/repo", + "devbox.example" + ) + ) + expect(h.create).not.toHaveBeenCalled() + expect(useCommandTerminalLinkStore.getState().links).toEqual({}) + }) + + it("refuses a local: command outside the desktop app", async () => { + h.desktop = false + h.command = "local: zed ." + 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("offers a global command in a folder that has none of its own", async () => { + vi.mocked(listFolderCommands).mockResolvedValueOnce([]) + createGlobalCommand("zed", "local: zed .") + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + "zed .", + "/repo", + "devbox.example" + ) + ) + }) }) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index d702926aa4..8ec989e23c 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, 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,8 +17,13 @@ import { useTerminalContext } from "@/contexts/terminal-context" import { bootstrapFolderCommandsFromPackageJson, listFolderCommands, + runLocalCommand, terminalStop, } from "@/lib/api" +import { toErrorMessage } from "@/lib/app-error" +import { isGlobalCommandId, listGlobalCommands } from "@/lib/global-commands" +import { getRemoteHostName, parseLocalCommand } from "@/lib/local-command" +import { isDesktop } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { resolveLiveCommandTerminalId, @@ -52,6 +58,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 +128,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 +141,7 @@ export function CommandDropdown() { useEffect(() => { if (!folderId) return loadEpochRef.current += 1 + setGlobalCommands(listGlobalCommands()) let ignore = false const loadCommands = async () => { try { @@ -167,6 +177,26 @@ export function CommandDropdown() { const runCommand = useCallback( async (cmd: FolderCommand) => { if (!folderPath) return + + // `local:` commands launch on this machine, detached — no terminal tab + // and no running state to track. + const localCommand = parseLocalCommand(cmd.command) + if (localCommand !== null) { + selectCommand(cmd.id) + if (!isDesktop()) { + toast.error(t("localUnavailable")) + return + } + try { + await runLocalCommand(localCommand, folderPath, getRemoteHostName()) + } 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. @@ -195,6 +225,7 @@ export function CommandDropdown() { isTerminalLive, selectCommand, setLink, + t, ] ) @@ -215,10 +246,17 @@ export function CommandDropdown() { [clearLink] ) + // The folder's own commands first, then the ones shown in every folder. + const allCommands = useMemo( + () => [...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) @@ -249,7 +287,7 @@ export function CommandDropdown() { // the dialog mid-edit. return ( <> - {commands.length === 0 ? ( + {allCommands.length === 0 ? ( // No commands → show add command button
-
- {command.name} +
+ + {command.name} + + {isGlobalCommandId(command.id) ? ( + + ) : null}
{command.command} @@ -523,6 +587,29 @@ export function CommandManageDialog({ updateDraft({ command: event.target.value }) } /> +

+ {t("localCommandHint")} +

+
+
+
+ +

+ {t("globalHint")} +

+
+ + updateDraft({ global: checked }) + } + />
diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index e9544468d7..6d401944f3 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -91,10 +91,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 } /** diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index cf11890146..ea4dbfc6af 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3240,6 +3240,9 @@ "manageCommands": "إدارة الأوامر...", "runCommandTitle": "تشغيل: {command}", "stopCommandTitle": "إيقاف: {command}", + "localUnavailable": "الأوامر المحلية متاحة فقط في تطبيق سطح المكتب", + "localRunFailed": "فشل تشغيل الأمر المحلي", + "globalCommand": "متاح في كل المجلدات", "manageDialog": { "title": "إدارة الأوامر", "empty": "لا توجد أوامر بعد", @@ -3248,6 +3251,9 @@ "newCommand": "أمر جديد", "nameLabel": "الاسم", "commandLabel": "الأمر", + "localCommandHint": "ابدأ الأمر بـ \"local:\" لتشغيله على هذا الكمبيوتر بدلاً من مضيف المشروع؛ ‎$CODEG_FOLDER_PATH هو المسار المطلق للمجلد و‎$CODEG_REMOTE_HOST اسم المضيف البعيد، مثال: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "globalLabel": "إظهار في كل المجلدات", + "globalHint": "متاح في كل مجلد وworktree واتصال بعيد؛ ويُحفظ على هذا الكمبيوتر", "dragSort": "اسحب للترتيب", "dragSortCommand": "اسحب لترتيب {name}", "orderFailed": "فشل حفظ ترتيب الأوامر", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4c24e021f4..1a1be66bfa 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3240,6 +3240,9 @@ "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", "manageDialog": { "title": "Befehle verwalten", "empty": "Noch keine Befehle", @@ -3248,6 +3251,9 @@ "newCommand": "Neuer Befehl", "nameLabel": "Bezeichnung", "commandLabel": "Befehl", + "localCommandHint": "Mit dem Präfix \"local:\" läuft der Befehl auf diesem Computer statt auf dem Projekt-Host; $CODEG_FOLDER_PATH ist der absolute Pfad des Ordners, $CODEG_REMOTE_HOST der Name des Remote-Hosts, z. B. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index aae9f6032f..dd4e14a1b2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3240,6 +3240,9 @@ "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", "manageDialog": { "title": "Manage Commands", "empty": "No commands yet", @@ -3248,6 +3251,9 @@ "newCommand": "New command", "nameLabel": "Name", "commandLabel": "Command", + "localCommandHint": "Prefix with \"local:\" to run on this computer instead of the project host; $CODEG_FOLDER_PATH is the folder's absolute path and $CODEG_REMOTE_HOST the remote host name, e.g. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "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", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b75b0683e4..b70c40ec26 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3240,6 +3240,9 @@ "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", "manageDialog": { "title": "Gestionar comandos", "empty": "Aún no hay comandos", @@ -3248,6 +3251,9 @@ "newCommand": "Nuevo comando", "nameLabel": "Nombre", "commandLabel": "Comando", + "localCommandHint": "Usa el prefijo \"local:\" para ejecutarlo en este equipo en lugar del host del proyecto; $CODEG_FOLDER_PATH es la ruta absoluta de la carpeta y $CODEG_REMOTE_HOST el nombre del host remoto, p. ej. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 82905a1c38..d38933b2e2 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3240,6 +3240,9 @@ "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", "manageDialog": { "title": "Gérer les commandes", "empty": "Aucune commande pour le moment", @@ -3248,6 +3251,9 @@ "newCommand": "Nouvelle commande", "nameLabel": "Nom", "commandLabel": "Commande", + "localCommandHint": "Préfixez par \"local:\" pour exécuter sur cet ordinateur plutôt que sur l'hôte du projet ; $CODEG_FOLDER_PATH est le chemin absolu du dossier et $CODEG_REMOTE_HOST le nom de l'hôte distant, par ex. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "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", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index acbd5fab52..7a57b542cd 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3240,6 +3240,9 @@ "manageCommands": "コマンドを管理...", "runCommandTitle": "実行: {command}", "stopCommandTitle": "停止: {command}", + "localUnavailable": "ローカルコマンドはデスクトップアプリでのみ利用できます", + "localRunFailed": "ローカルコマンドの実行に失敗しました", + "globalCommand": "すべてのフォルダーで利用可能", "manageDialog": { "title": "コマンド管理", "empty": "コマンドはまだありません", @@ -3248,6 +3251,9 @@ "newCommand": "新しいコマンド", "nameLabel": "名前", "commandLabel": "コマンド", + "localCommandHint": "\"local:\" で始めると、プロジェクトのホストではなくこのコンピューターで実行します。$CODEG_FOLDER_PATH はフォルダーの絶対パス、$CODEG_REMOTE_HOST はリモートホスト名です。例: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "globalLabel": "すべてのフォルダーに表示", + "globalHint": "すべてのフォルダー、worktree、リモート接続で利用できます。このコンピューターに保存されます", "dragSort": "ドラッグして並び替え", "dragSortCommand": "{name} をドラッグして並び替え", "orderFailed": "コマンドの並び順の保存に失敗しました", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3519bb91b8..998254837f 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3240,6 +3240,9 @@ "manageCommands": "명령 관리...", "runCommandTitle": "실행: {command}", "stopCommandTitle": "중지: {command}", + "localUnavailable": "로컬 명령은 데스크톱 앱에서만 사용할 수 있습니다", + "localRunFailed": "로컬 명령 실행 실패", + "globalCommand": "모든 폴더에서 사용 가능", "manageDialog": { "title": "명령 관리", "empty": "아직 명령이 없습니다", @@ -3248,6 +3251,9 @@ "newCommand": "새 명령", "nameLabel": "이름", "commandLabel": "명령", + "localCommandHint": "\"local:\"로 시작하면 프로젝트 호스트가 아닌 이 컴퓨터에서 실행됩니다. $CODEG_FOLDER_PATH는 폴더의 절대 경로, $CODEG_REMOTE_HOST는 원격 호스트 이름입니다. 예: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "globalLabel": "모든 폴더에 표시", + "globalHint": "모든 폴더, worktree, 원격 연결에서 사용할 수 있으며 이 컴퓨터에 저장됩니다", "dragSort": "드래그하여 정렬", "dragSortCommand": "{name} 드래그하여 정렬", "orderFailed": "명령 순서 저장에 실패했습니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 1c333481e3..54266e6d1a 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3240,6 +3240,9 @@ "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", "manageDialog": { "title": "Gerenciar comandos", "empty": "Ainda não há comandos", @@ -3248,6 +3251,9 @@ "newCommand": "Novo comando", "nameLabel": "Nome", "commandLabel": "Comando", + "localCommandHint": "Use o prefixo \"local:\" para executar neste computador em vez do host do projeto; $CODEG_FOLDER_PATH é o caminho absoluto da pasta e $CODEG_REMOTE_HOST o nome do host remoto, ex.: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6ef841df26..833cf3a529 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3240,6 +3240,9 @@ "manageCommands": "管理命令...", "runCommandTitle": "运行:{command}", "stopCommandTitle": "停止:{command}", + "localUnavailable": "本地命令仅在桌面应用中可用", + "localRunFailed": "运行本地命令失败", + "globalCommand": "所有文件夹可用", "manageDialog": { "title": "管理命令", "empty": "暂无命令", @@ -3248,6 +3251,9 @@ "newCommand": "新建命令", "nameLabel": "名称", "commandLabel": "命令", + "localCommandHint": "以 \"local:\" 开头的命令会在本机而不是项目所在主机上运行;$CODEG_FOLDER_PATH 为目录的绝对路径,$CODEG_REMOTE_HOST 为远程主机名,例如 local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "globalLabel": "在所有文件夹中显示", + "globalHint": "在所有文件夹、worktree 和远程连接中都可用,保存在本机", "dragSort": "拖拽排序", "dragSortCommand": "拖拽排序 {name}", "orderFailed": "保存命令排序失败", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 8bc732098c..28197e6e07 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3240,6 +3240,9 @@ "manageCommands": "管理命令...", "runCommandTitle": "執行:{command}", "stopCommandTitle": "停止:{command}", + "localUnavailable": "本機命令僅在桌面應用程式中可用", + "localRunFailed": "執行本機命令失敗", + "globalCommand": "所有資料夾可用", "manageDialog": { "title": "管理命令", "empty": "暫無命令", @@ -3248,6 +3251,9 @@ "newCommand": "新增命令", "nameLabel": "名稱", "commandLabel": "命令", + "localCommandHint": "以 \"local:\" 開頭的命令會在本機而非專案所在主機上執行;$CODEG_FOLDER_PATH 為目錄的絕對路徑,$CODEG_REMOTE_HOST 為遠端主機名稱,例如 local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "globalLabel": "在所有資料夾中顯示", + "globalHint": "在所有資料夾、worktree 和遠端連線中都可用,儲存在本機", "dragSort": "拖曳排序", "dragSortCommand": "拖曳排序 {name}", "orderFailed": "儲存命令排序失敗", diff --git a/src/lib/api.ts b/src/lib/api.ts index c5beefe50c..cd055c18f4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3462,6 +3462,24 @@ 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`. Desktop only. + */ +export async function runLocalCommand( + command: string, + folderPath: string, + remoteHost: string | null +): Promise { + return getShellTransport().call("run_local_command", { + command, + folderPath, + remoteHost, + }) +} + /** * Open a freshly created git worktree directory as a folder, recording the root * folder it descends from (`sourceFolderId` is the folder the worktree was diff --git a/src/lib/global-commands.test.ts b/src/lib/global-commands.test.ts new file mode 100644 index 0000000000..269523b325 --- /dev/null +++ b/src/lib/global-commands.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { + GLOBAL_COMMANDS_STORAGE_KEY, + createGlobalCommand, + deleteGlobalCommand, + isGlobalCommandId, + listGlobalCommands, + reorderGlobalCommands, + updateGlobalCommand, +} from "./global-commands" + +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", "local: zed .") + const b = createGlobalCommand("open", "local: open .") + 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") + const b = createGlobalCommand("b", "b") + deleteGlobalCommand(-1) + expect(createGlobalCommand("c", "c").id).toBe(b.id - 1) + }) + + it("updates, deletes and reorders", () => { + createGlobalCommand("a", "a") + createGlobalCommand("b", "b") + createGlobalCommand("c", "c") + updateGlobalCommand(-2, "B", "bb") + 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("throws when updating a command that is gone", () => { + expect(() => updateGlobalCommand(-9, "x", "x")).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([]) + }) +}) diff --git a/src/lib/global-commands.ts b/src/lib/global-commands.ts new file mode 100644 index 0000000000..c6c9af9f64 --- /dev/null +++ b/src/lib/global-commands.ts @@ -0,0 +1,118 @@ +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 +} + +export function isGlobalCommandId(id: number): boolean { + return id < 0 +} + +/** 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. */ +function parseEntry(value: unknown): StoredGlobalCommand | null { + if (!value || typeof value !== "object") return null + const { id, name, command } = 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 } +} + +function readStored(): StoredGlobalCommand[] { + try { + const raw = localStorage.getItem(GLOBAL_COMMANDS_STORAGE_KEY) + if (!raw) return [] + const value: unknown = JSON.parse(raw) + if (!Array.isArray(value)) return [] + const seen = new Set() + const out: StoredGlobalCommand[] = [] + for (const item of value) { + const entry = parseEntry(item) + if (!entry || seen.has(entry.id)) continue + seen.add(entry.id) + out.push(entry) + } + return out + } catch { + return [] + } +} + +function writeStored(entries: StoredGlobalCommand[]): void { + localStorage.setItem(GLOBAL_COMMANDS_STORAGE_KEY, JSON.stringify(entries)) +} + +function toFolderCommand( + entry: StoredGlobalCommand, + index: number +): FolderCommand { + return { + id: entry.id, + folder_id: 0, + name: entry.name, + command: entry.command, + sort_order: index, + created_at: "", + updated_at: "", + } +} + +export function listGlobalCommands(): FolderCommand[] { + return readStored().map(toFolderCommand) +} + +export function createGlobalCommand( + name: string, + command: string +): FolderCommand { + const entries = readStored() + const id = Math.min(0, ...entries.map((entry) => entry.id)) - 1 + const next = [...entries, { id, name, command }] + writeStored(next) + return toFolderCommand(next[next.length - 1], next.length - 1) +} + +export function updateGlobalCommand( + id: number, + name: string, + command: string +): FolderCommand { + const entries = readStored() + const index = entries.findIndex((entry) => entry.id === id) + if (index < 0) throw new Error("Command not found") + entries[index] = { id, name, command } + writeStored(entries) + return toFolderCommand(entries[index], index) +} + +export function deleteGlobalCommand(id: number): void { + writeStored(readStored().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 entries = readStored() + const byId = new Map(entries.map((entry) => [entry.id, entry])) + const ordered = ids.flatMap((id) => { + const entry = byId.get(id) + if (!entry) return [] + byId.delete(id) + return [entry] + }) + writeStored([...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..2aa161924e --- /dev/null +++ b/src/lib/local-command.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest" + +const h = vi.hoisted(() => ({ + remote: true, + baseUrl: "https://devbox.example:3080/", +})) + +vi.mock("@/lib/transport", () => ({ + isRemoteDesktopMode: () => h.remote, + getServerBaseUrl: () => h.baseUrl, +})) + +import { getRemoteHostName, parseLocalCommand } from "./local-command" + +describe("parseLocalCommand", () => { + it("strips the local: prefix", () => { + expect(parseLocalCommand("local: zed .")).toBe("zed .") + expect(parseLocalCommand(" LOCAL:open -a Zed")).toBe("open -a Zed") + }) + + it("leaves ordinary commands alone", () => { + expect(parseLocalCommand("pnpm dev")).toBeNull() + expect(parseLocalCommand("echo local: x")).toBeNull() + }) + + it("treats a bare prefix as no command", () => { + expect(parseLocalCommand("local: ")).toBeNull() + }) +}) + +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..09ada807f4 --- /dev/null +++ b/src/lib/local-command.ts @@ -0,0 +1,27 @@ +import { getServerBaseUrl, isRemoteDesktopMode } from "@/lib/transport" + +/** + * Folder commands written as `local: ` run on the desktop's own + * machine instead of the folder's host — the way to reach local tools (e.g. + * `local: zed "ssh://host$CODEG_FOLDER_PATH"`) from a remote project. Kept as + * a prefix on the stored command so it needs nothing from the server. + */ +const LOCAL_COMMAND_PREFIX = /^\s*local:\s*/i + +/** The command to run locally, or `null` when `command` isn't a local one. */ +export function parseLocalCommand(command: string): string | null { + const match = LOCAL_COMMAND_PREFIX.exec(command) + if (!match) return null + const rest = command.slice(match[0].length).trim() + return rest || null +} + +/** 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 + } +} From f1618d744850b8db1b1039cfa22a2f93b63e32b9 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 16:42:13 +0800 Subject: [PATCH 02/14] fix(local-command): run in a local folder and expand cmd.exe variables - Take an optional working directory and run there when it is a directory on this machine, else in the home directory as before. A local window passes its folder, so `local: code .` opens the folder, not home. - cmd.exe 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 reached the program literally. Hand cmd the command itself as its command line (/D /S /C "", appended raw), as if typed at a prompt. - Give the real-spawn test 30 s instead of 5 s: a login shell's profile can take seconds on a busy machine, and the wait ends as soon as the marker appears. It now also checks the working directory. --- src-tauri/src/commands/local_command.rs | 127 ++++++++++++++++++++---- 1 file changed, 108 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands/local_command.rs b/src-tauri/src/commands/local_command.rs index 5bb12f72b6..28dcfde2e8 100644 --- a/src-tauri/src/commands/local_command.rs +++ b/src-tauri/src/commands/local_command.rs @@ -1,4 +1,5 @@ use std::ffi::OsString; +use std::path::PathBuf; use std::process::Stdio; use crate::app_error::AppCommandError; @@ -18,7 +19,9 @@ use crate::terminal::shell_flavor::{classify_shell_family, ShellFamily}; /// /// `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`. +/// reach the command as `CODEG_FOLDER_PATH` / `CODEG_REMOTE_HOST`. It 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. @@ -27,20 +30,37 @@ pub async fn run_local_command( command: String, folder_path: Option, remote_host: Option, + working_dir: Option, ) -> Result<(), AppCommandError> { let command = validate_local_command(&command)?; let shell = crate::terminal::manager::resolve_shell(); let mut child = tokio::process::Command::new(&shell); + match shell_invocation(&shell, command) { + ShellInvocation::Args(args) => { + child.args(args); + } + #[cfg(windows)] + ShellInvocation::CmdLine(line) => { + use std::os::windows::process::CommandExt; + child.as_std_mut().raw_arg(line); + } + // Only a Windows host resolves its shell to cmd.exe. + #[cfg(not(windows))] + ShellInvocation::CmdLine(_) => { + return Err(AppCommandError::invalid_input(format!( + "{shell} can only run commands on Windows" + ))); + } + } child - .args(shell_args(&shell, command)) .env("CODEG_CMD", command) .env("CODEG_FOLDER_PATH", folder_path.unwrap_or_default()) .env("CODEG_REMOTE_HOST", remote_host.unwrap_or_default()) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - if let Some(home) = dirs::home_dir() { - child.current_dir(home); + if let Some(dir) = resolve_working_dir(working_dir.as_deref()) { + child.current_dir(dir); } #[cfg(windows)] { @@ -55,6 +75,15 @@ pub async fn run_local_command( }) } +/// `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() { @@ -68,11 +97,27 @@ fn validate_local_command(command: &str) -> Result<&str, AppCommandError> { Ok(trimmed) } -/// Shell arguments that run `$CODEG_CMD` and exit. The command travels in the -/// env var 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. -fn shell_args(shell: &str, command: &str) -> Vec { +/// 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. +fn shell_invocation(shell: &str, command: &str) -> ShellInvocation { let args: Vec<&str> = match classify_shell_family(shell) { ShellFamily::PowerShell => vec![ "-NoLogo", @@ -80,12 +125,12 @@ fn shell_args(shell: &str, command: &str) -> Vec { "-Command", "Invoke-Expression $env:CODEG_CMD", ], - ShellFamily::Cmd => vec!["/D", "/S", "/C", "%CODEG_CMD%"], + ShellFamily::Cmd => return ShellInvocation::CmdLine(format!("/D /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], }; - args.into_iter().map(OsString::from).collect() + ShellInvocation::Args(args.into_iter().map(OsString::from).collect()) } fn is_bash_like(shell: &str) -> bool { @@ -110,41 +155,85 @@ mod tests { 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() { - let args = shell_args("/bin/zsh", "zed ."); - assert_eq!(args, ["-l", "-c", "eval \"$CODEG_CMD\""]); + 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 /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 unknown_posix_shells_get_the_command_verbatim() { - let args = shell_args("/usr/bin/nu", "zed ."); - assert_eq!(args, ["-c", "zed ."]); + assert_eq!( + shell_invocation("/usr/bin/nu", "zed ."), + args(&["-c", "zed ."]) + ); } #[cfg(not(windows))] #[tokio::test] - async fn runs_command_with_folder_env() { + 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' \"$CODEG_FOLDER_PATH\" \"$CODEG_REMOTE_HOST\" > '{}'", + "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() + ); - let deadline = Instant::now() + Duration::from_secs(5); + // 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, "/srv/repo|devbox"); + assert_eq!(contents, expected); return; } } From d8d50ede793822b7e0bc9d4a350031fc86faffd2 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 16:42:22 +0800 Subject: [PATCH 03/14] fix(commands): run local: commands only from this computer's own list - Remote-desktop windows list the remote server's folder commands, which anything on that host can rewrite. Run a `local:` command there only when it is a global command, kept on this computer; refuse a server-stored one with a toast, and warn in the manage dialog while one is being edited. - Global commands are told apart by their negative ids, which the folder's host never hands out. Drop host entries claiming one where the two lists merge (launcher and manage dialog), and refuse a host save that returns one, so the host can't pass its command off as one kept here. - Key command-to-terminal links by folder and command. A global command has the same id in every folder, so running it in one folder made it read as running in every other, where Stop killed the first folder's terminal. - Never reuse a deleted global command's id: store the last id handed out. Deleting the newest one let the next command inherit its remembered selection and terminal link. - Re-read global commands when another window changes them. - Pass a local window's folder as the working directory. - Render the hint's literals as LTR code via t.rich, with the variables spelled for this machine's shell (%VAR% on Windows), and show it only in the desktop app. - Add manage dialog tests; extend launcher, store and terminal tests. --- .../layout/command-dropdown.test.tsx | 170 +++++++++++--- src/components/layout/command-dropdown.tsx | 61 ++++- .../layout/command-manage-dialog.test.tsx | 213 ++++++++++++++++++ .../layout/command-manage-dialog.tsx | 61 ++++- src/contexts/terminal-context.test.tsx | 37 ++- src/contexts/terminal-context.tsx | 9 +- src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/en.json | 4 +- src/i18n/messages/es.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/ja.json | 4 +- src/i18n/messages/ko.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/i18n/messages/zh-TW.json | 4 +- src/lib/api.ts | 8 +- src/lib/global-commands.test.ts | 57 +++++ src/lib/global-commands.ts | 90 ++++++-- .../command-terminal-link-store.test.ts | 63 ++++-- src/stores/command-terminal-link-store.ts | 46 ++-- 21 files changed, 733 insertions(+), 122 deletions(-) create mode 100644 src/components/layout/command-manage-dialog.test.tsx diff --git a/src/components/layout/command-dropdown.test.tsx b/src/components/layout/command-dropdown.test.tsx index e06f173af5..9c9f669d17 100644 --- a/src/components/layout/command-dropdown.test.tsx +++ b/src/components/layout/command-dropdown.test.tsx @@ -1,8 +1,14 @@ -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 { listFolderCommands } from "@/lib/api" -import { createGlobalCommand } from "@/lib/global-commands" -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(() => ({ @@ -12,7 +18,11 @@ const h = vi.hoisted(() => ({ runLocal: vi.fn(async () => {}), toastError: vi.fn(), desktop: true, + remote: true, + folder: { id: 1, path: "/repo" }, command: "pnpm dev", + // 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, })) @@ -21,11 +31,11 @@ vi.mock("next-intl", () => ({ useTranslations: () => h.t })) vi.mock("sonner", () => ({ toast: { error: h.toastError } })) vi.mock("@/lib/transport", () => ({ isDesktop: () => h.desktop, - isRemoteDesktopMode: () => true, + isRemoteDesktopMode: () => h.remote, getServerBaseUrl: () => "https://devbox.example:3080", })) vi.mock("@/contexts/active-folder-context", () => ({ - useActiveFolder: () => ({ activeFolder: { id: 1, path: "/repo" } }), + useActiveFolder: () => ({ activeFolder: h.folder }), })) vi.mock("@/contexts/terminal-context", () => ({ useTerminalContext: () => ({ @@ -35,17 +45,20 @@ vi.mock("@/contexts/terminal-context", () => ({ }), })) vi.mock("@/lib/api", () => ({ - listFolderCommands: vi.fn(async () => [ - { - id: 5, - folder_id: 1, - name: "dev", - command: h.command, - 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, + created_at: "", + updated_at: "", + }, + ] + ), bootstrapFolderCommandsFromPackageJson: vi.fn(async () => []), runLocalCommand: h.runLocal, terminalStop: h.stop, @@ -53,15 +66,31 @@ vi.mock("@/lib/api", () => ({ })) vi.mock("./command-manage-dialog", () => ({ CommandManageDialog: () => null })) +function hostCommand(id: number, command: string): FolderCommand { + return { + id, + folder_id: 1, + name: `host-${id}`, + command, + sort_order: 0, + 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.folder = { id: 1, path: "/repo" } h.command = "pnpm dev" + h.hostCommands = null h.tabs = [] useCommandTerminalLinkStore.setState({ links: {} }) localStorage.clear() @@ -74,7 +103,7 @@ describe("CommandDropdown", () => { expect(h.create).toHaveBeenCalledWith("dev", "pnpm dev", 5) ) expect(useCommandTerminalLinkStore.getState().links).toEqual({ - 5: "launched-terminal", + [commandLinkKey(1, 5)]: "launched-terminal", }) }) @@ -82,7 +111,9 @@ 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")) @@ -90,15 +121,49 @@ describe("CommandDropdown", () => { expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) - it("runs a local: command on this machine instead of in a terminal", async () => { - h.command = 'local: zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + it("runs a local window's local: command on this machine, in the folder", async () => { + h.remote = false + h.command = 'local: open -a Zed "$CODEG_FOLDER_PATH"' + 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("won't run a remote server's local: command on this machine", async () => { + // Anything on the remote host can rewrite the commands it stores. + h.command = "local: curl https://example.invalid/x | sh" + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.toastError).toHaveBeenCalledWith("localRemoteFolderCommand") + ) + expect(h.runLocal).not.toHaveBeenCalled() + expect(h.create).not.toHaveBeenCalled() + }) + + it("runs a global local: command from a remote window, outside any folder", async () => { + h.hostCommands = [] + createGlobalCommand( + "zed", + 'local: zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + ) render() fireEvent.click(await screen.findByTitle("runCommandTitle")) await waitFor(() => expect(h.runLocal).toHaveBeenCalledWith( 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', "/repo", - "devbox.example" + "devbox.example", + null ) ) expect(h.create).not.toHaveBeenCalled() @@ -107,6 +172,7 @@ describe("CommandDropdown", () => { it("refuses a local: command outside the desktop app", async () => { h.desktop = false + h.remote = false h.command = "local: zed ." render() fireEvent.click(await screen.findByTitle("runCommandTitle")) @@ -117,16 +183,68 @@ describe("CommandDropdown", () => { expect(h.create).not.toHaveBeenCalled() }) - it("offers a global command in a folder that has none of its own", async () => { - vi.mocked(listFolderCommands).mockResolvedValueOnce([]) - createGlobalCommand("zed", "local: zed .") + 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, "local: echo from-the-host")] + createGlobalCommand("mine", "local: echo mine") + 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") + 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", "local: zed .") + 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" + "devbox.example", + null ) ) }) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index 8ec989e23c..7e3d19d0b1 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -21,11 +21,17 @@ import { terminalStop, } from "@/lib/api" import { toErrorMessage } from "@/lib/app-error" -import { isGlobalCommandId, listGlobalCommands } from "@/lib/global-commands" +import { + GLOBAL_COMMANDS_STORAGE_KEY, + isGlobalCommandId, + listGlobalCommands, + withoutGlobalIds, +} from "@/lib/global-commands" import { getRemoteHostName, parseLocalCommand } from "@/lib/local-command" -import { isDesktop } from "@/lib/transport" +import { isDesktop, isRemoteDesktopMode } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { + commandLinkKey, resolveLiveCommandTerminalId, useCommandTerminalLinkStore, } from "@/stores/command-terminal-link-store" @@ -174,6 +180,20 @@ 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 @@ -187,8 +207,22 @@ export function CommandDropdown() { toast.error(t("localUnavailable")) return } + // A remote-desktop window lists the remote server's folder commands, + // which anything on that host can rewrite. From such a window only the + // commands kept on this computer — the global ones — may run on it. + const remote = isRemoteDesktopMode() + if (remote && !isGlobalCommandId(cmd.id)) { + toast.error(t("localRemoteFolderCommand")) + return + } try { - await runLocalCommand(localCommand, folderPath, getRemoteHostName()) + await runLocalCommand( + localCommand, + folderPath, + getRemoteHostName(), + // 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), @@ -200,10 +234,11 @@ export function CommandDropdown() { // 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 ) ) @@ -217,10 +252,11 @@ export function CommandDropdown() { ) if (!terminalId) return - setLink(cmd.id, terminalId) + setLink(linkKey, terminalId) }, [ createTerminalWithCommand, + folderId, folderPath, isTerminalLive, selectCommand, @@ -231,10 +267,11 @@ export function CommandDropdown() { 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. @@ -243,12 +280,12 @@ 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( - () => [...commands, ...globalCommands], + () => [...withoutGlobalIds(commands), ...globalCommands], [commands, globalCommands] ) const activeCmd = useMemo( @@ -259,7 +296,11 @@ export function CommandDropdown() { [allCommands, selectedCommandId] ) const activeTerminalId = activeCmd - ? resolveLiveCommandTerminalId(links, activeCmd.id, isTerminalLive) + ? resolveLiveCommandTerminalId( + links, + commandLinkKey(folderId, activeCmd.id), + isTerminalLive + ) : undefined const isActiveCommandRunning = Boolean(activeTerminalId) diff --git a/src/components/layout/command-manage-dialog.test.tsx b/src/components/layout/command-manage-dialog.test.tsx new file mode 100644 index 0000000000..b13d73c3d9 --- /dev/null +++ b/src/components/layout/command-manage-dialog.test.tsx @@ -0,0 +1,213 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import enMessages from "@/i18n/messages/en.json" +import { createGlobalCommand, listGlobalCommands } from "@/lib/global-commands" +import type { FolderCommand } from "@/lib/types" +import { CommandManageDialog } from "./command-manage-dialog" + +const h = vi.hoisted(() => ({ + desktop: true, + remote: true, + platform: "macos", + hostCommands: [] as unknown[], + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(async () => {}), + reorder: vi.fn(async () => {}), +})) + +vi.mock("@/lib/transport", () => ({ + isDesktop: () => h.desktop, + isRemoteDesktopMode: () => h.remote, + getServerBaseUrl: () => "https://devbox.example:3080", +})) +vi.mock("@/hooks/use-platform", () => ({ detectPlatform: () => h.platform })) +vi.mock("@/lib/api", () => ({ + listFolderCommands: vi.fn(async () => h.hostCommands), + createFolderCommand: h.create, + updateFolderCommand: h.update, + deleteFolderCommand: h.remove, + reorderFolderCommands: h.reorder, +})) + +const md = enMessages.Folder.commandDropdown.manageDialog + +function hostCommand(id: number, name: string, command: string): FolderCommand { + return { + id, + folder_id: 1, + name, + command, + sort_order: 0, + created_at: "", + updated_at: "", + } +} + +function renderDialog() { + const onChanged = vi.fn() + render( + + {}} + folderId={1} + onChanged={onChanged} + /> + + ) + return { onChanged } +} + +async function startNewCommand(name: string, command: string) { + fireEvent.click(await screen.findByRole("button", { name: md.newCommand })) + fireEvent.change(screen.getByLabelText(md.nameLabel), { + target: { value: name }, + }) + fireEvent.change(screen.getByLabelText(md.commandLabel), { + target: { value: command }, + }) +} + +describe("CommandManageDialog", () => { + // The first render pays for the dialog's UI libraries and the messages' + // first compile, which can take seconds under a loaded full run. Pay it once + // here, with time to spare, rather than in whichever test comes first. + beforeAll(async () => { + const { unmount } = render( + + {}} + folderId={1} + onChanged={() => {}} + /> + + ) + await screen.findByRole( + "button", + { name: md.newCommand }, + { timeout: 10_000 } + ) + unmount() + }, 30_000) + + beforeEach(() => { + localStorage.clear() + h.desktop = true + h.remote = true + h.platform = "macos" + h.hostCommands = [] + h.create.mockReset() + h.update.mockReset() + h.remove.mockReset() + h.remove.mockImplementation(async () => {}) + h.reorder.mockClear() + }) + + it("lists the host's commands and this computer's, but no impostor", async () => { + // Global commands have negative ids; the host's database never does. + h.hostCommands = [ + hostCommand(3, "build", "pnpm build"), + hostCommand(-1, "impostor", "local: echo x"), + ] + createGlobalCommand("mine", "local: echo mine") + renderDialog() + expect(await screen.findByText("build")).toBeInTheDocument() + expect(screen.getByText("mine")).toBeInTheDocument() + expect(screen.queryByText("impostor")).not.toBeInTheDocument() + }) + + it("warns in a remote window that a folder's local: command won't run here", async () => { + renderDialog() + await startNewCommand("zed", "local: zed .") + expect(screen.getByText(md.localNeedsGlobal)).toBeInTheDocument() + fireEvent.click(screen.getByRole("switch")) + expect(screen.queryByText(md.localNeedsGlobal)).not.toBeInTheDocument() + }) + + it("doesn't warn in a local window, whose folders are this computer's", async () => { + h.remote = false + renderDialog() + await startNewCommand("zed", "local: zed .") + expect(screen.queryByText(md.localNeedsGlobal)).not.toBeInTheDocument() + }) + + it("shows the hint's values as code, spelled for this machine's shell", async () => { + h.platform = "windows" + renderDialog() + const example = await screen.findByText( + 'local: zed "ssh://user@%CODEG_REMOTE_HOST%%CODEG_FOLDER_PATH%"' + ) + expect(example.tagName).toBe("CODE") + expect(example).toHaveAttribute("dir", "ltr") + expect(screen.getByText("%CODEG_FOLDER_PATH%").tagName).toBe("CODE") + }) + + it("spells the hint's variables the POSIX way off Windows", async () => { + renderDialog() + expect( + await screen.findByText( + 'local: zed "ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + ) + ).toBeInTheDocument() + expect(screen.getByText("$CODEG_REMOTE_HOST").tagName).toBe("CODE") + }) + + it("leaves out the local-command hint outside the desktop app", async () => { + h.desktop = false + h.remote = false + renderDialog() + await screen.findByRole("button", { name: md.newCommand }) + expect(screen.queryByText("$CODEG_FOLDER_PATH")).not.toBeInTheDocument() + }) + + it("keeps a command shown in every folder on this computer", async () => { + const { onChanged } = renderDialog() + await startNewCommand("zed", "local: zed .") + fireEvent.click(screen.getByRole("switch")) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + await waitFor(() => + expect(listGlobalCommands().map((c) => [c.name, c.command])).toEqual([ + ["zed", "local: zed ."], + ]) + ) + expect(h.create).not.toHaveBeenCalled() + expect(onChanged).toHaveBeenCalled() + }) + + it("moves a folder command to every folder: a copy here first, then the host's goes", async () => { + h.hostCommands = [hostCommand(3, "zed", "local: zed .")] + h.remove.mockImplementation(async () => { + // A failed delete must leave the copy, never neither. + expect(listGlobalCommands()).toHaveLength(1) + }) + renderDialog() + await screen.findByText("zed") + fireEvent.click(screen.getByRole("switch")) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + await waitFor(() => expect(h.remove).toHaveBeenCalledWith(3)) + expect(listGlobalCommands().map((c) => [c.name, c.command])).toEqual([ + ["zed", "local: zed ."], + ]) + }) + + it("refuses a host save that comes back with a global command's id", async () => { + createGlobalCommand("mine", "pnpm mine") + h.create.mockResolvedValue(hostCommand(-1, "x", "local: echo injected")) + renderDialog() + await screen.findByText("mine") + await startNewCommand("dev", "pnpm dev") + fireEvent.click(screen.getByRole("button", { name: "Save" })) + expect( + await screen.findByText(new RegExp(`^${md.saveFailed}: `)) + ).toBeInTheDocument() + // The form doesn't take the host's entry for this computer's command. + expect(screen.getByRole("switch")).not.toBeChecked() + expect(screen.getByLabelText(md.commandLabel)).toHaveValue("pnpm dev") + expect(listGlobalCommands().map((c) => [c.id, c.command])).toEqual([ + [-1, "pnpm mine"], + ]) + }) +}) diff --git a/src/components/layout/command-manage-dialog.tsx b/src/components/layout/command-manage-dialog.tsx index cb4b34d107..cc9c27fd71 100644 --- a/src/components/layout/command-manage-dialog.tsx +++ b/src/components/layout/command-manage-dialog.tsx @@ -12,6 +12,7 @@ import { import { GripVertical, Globe, Loader2, Plus, Save, Trash2 } from "lucide-react" import { Reorder, useDragControls } from "motion/react" import { useTranslations } from "next-intl" +import { detectPlatform } from "@/hooks/use-platform" import { createFolderCommand, deleteFolderCommand, @@ -26,8 +27,12 @@ import { isGlobalCommandId, listGlobalCommands, reorderGlobalCommands, + requireHostCommand, updateGlobalCommand, + withoutGlobalIds, } from "@/lib/global-commands" +import { parseLocalCommand } from "@/lib/local-command" +import { isDesktop, isRemoteDesktopMode } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { Button } from "@/components/ui/button" import { @@ -59,6 +64,28 @@ import { cn } from "@/lib/utils" const LEFT_MIN_WIDTH = 260 const RIGHT_MIN_WIDTH = 380 +// A value in a hint, set apart from the prose so its punctuation is not read +// as the sentence's. `dir="ltr"` keeps it intact in Arabic. +const hintLiteral = (chunks: ReactNode) => ( + + {chunks} + +) + +/** The variables a `local:` command reads, spelled the way the shell it runs + * in on this machine expects: cmd.exe on Windows, a POSIX shell elsewhere. */ +function localCommandHintValues() { + const windows = detectPlatform() === "windows" + const variable = (name: string) => (windows ? `%${name}%` : `$${name}`) + const folderVar = variable("CODEG_FOLDER_PATH") + const hostVar = variable("CODEG_REMOTE_HOST") + return { + folderVar, + hostVar, + example: `local: zed "ssh://user@${hostVar}${folderVar}"`, + } +} + interface CommandManageDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -199,7 +226,7 @@ export function CommandManageDialog({ setLoadError(null) try { const list = [ - ...(await listFolderCommands(folderId)), + ...withoutGlobalIds(await listFolderCommands(folderId)), ...listGlobalCommands(), ] if (seq !== loadSeqRef.current) return @@ -290,6 +317,13 @@ export function CommandManageDialog({ }, [commands, searchQuery]) const searchActive = searchQuery.trim().length > 0 + const localHintValues = useMemo(localCommandHintValues, []) + // A remote-desktop window keeps this folder's commands on the remote + // server, and the launcher won't run a `local:` one from there. + const localNeedsGlobal = + isRemoteDesktopMode() && + !draft.global && + parseLocalCommand(draft.command) !== null const safeContainerWidth = panelContainerWidth > 0 ? panelContainerWidth : 900 const leftMinSize = clamp( toPercent(LEFT_MIN_WIDTH, safeContainerWidth), @@ -364,13 +398,17 @@ export function CommandManageDialog({ if (previousId !== null && !scopeChanged) { saved = isGlobalCommandId(previousId) ? updateGlobalCommand(previousId, name, command) - : await updateFolderCommand(previousId, name, command) + : requireHostCommand( + await updateFolderCommand(previousId, name, command) + ) } else { // New, or moving between this folder and every folder: create it in // its new home first, so a failure there loses nothing. saved = draft.global ? createGlobalCommand(name, command) - : await createFolderCommand(folderId, name, command) + : requireHostCommand( + await createFolderCommand(folderId, name, command) + ) if (previousId !== null) { if (isGlobalCommandId(previousId)) deleteGlobalCommand(previousId) else await deleteFolderCommand(previousId) @@ -587,9 +625,15 @@ export function CommandManageDialog({ updateDraft({ command: event.target.value }) } /> -

- {t("localCommandHint")} -

+ {/* Local commands run only from the desktop app. */} + {isDesktop() ? ( +

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

+ ) : null}
@@ -611,6 +655,11 @@ export function CommandManageDialog({ } />
+ {localNeedsGlobal ? ( +

+ {t("localNeedsGlobal")} +

+ ) : null}
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 6d401944f3..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 @@ -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 ea4dbfc6af..5618098f2a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "إيقاف: {command}", "localUnavailable": "الأوامر المحلية متاحة فقط في تطبيق سطح المكتب", "localRunFailed": "فشل تشغيل الأمر المحلي", + "localRemoteFolderCommand": "هذا الأمر المحلي محفوظ على الخادم البعيد، لذا لا يمكن تشغيله على هذا الكمبيوتر. فعّل «إظهار في كل المجلدات» لحفظه على هذا الكمبيوتر.", "globalCommand": "متاح في كل المجلدات", "manageDialog": { "title": "إدارة الأوامر", @@ -3251,7 +3252,8 @@ "newCommand": "أمر جديد", "nameLabel": "الاسم", "commandLabel": "الأمر", - "localCommandHint": "ابدأ الأمر بـ \"local:\" لتشغيله على هذا الكمبيوتر بدلاً من مضيف المشروع؛ ‎$CODEG_FOLDER_PATH هو المسار المطلق للمجلد و‎$CODEG_REMOTE_HOST اسم المضيف البعيد، مثال: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "ابدأ الأمر بـ local: لتشغيله على هذا الكمبيوتر بدلاً من مضيف المشروع؛ {folderVar} هو المسار المطلق للمجلد و{hostVar} اسم المضيف البعيد، مثال: {example}", + "localNeedsGlobal": "الأمر المحلي المحفوظ على الخادم البعيد لا يعمل على هذا الكمبيوتر. فعّل «إظهار في كل المجلدات» لحفظه على هذا الكمبيوتر.", "globalLabel": "إظهار في كل المجلدات", "globalHint": "متاح في كل مجلد وworktree واتصال بعيد؛ ويُحفظ على هذا الكمبيوتر", "dragSort": "اسحب للترتيب", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 1a1be66bfa..d6ff9a8525 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "Stoppen: {command}", "localUnavailable": "Lokale Befehle sind nur in der Desktop-App verfügbar", "localRunFailed": "Lokaler Befehl konnte nicht ausgeführt werden", + "localRemoteFolderCommand": "Dieser lokale Befehl ist auf dem Remote-Server gespeichert und kann daher nicht auf diesem Computer laufen. Aktivieren Sie „In allen Ordnern anzeigen“, um ihn auf diesem Computer zu speichern.", "globalCommand": "In allen Ordnern verfügbar", "manageDialog": { "title": "Befehle verwalten", @@ -3251,7 +3252,8 @@ "newCommand": "Neuer Befehl", "nameLabel": "Bezeichnung", "commandLabel": "Befehl", - "localCommandHint": "Mit dem Präfix \"local:\" läuft der Befehl auf diesem Computer statt auf dem Projekt-Host; $CODEG_FOLDER_PATH ist der absolute Pfad des Ordners, $CODEG_REMOTE_HOST der Name des Remote-Hosts, z. B. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "Mit dem Präfix local: läuft der Befehl auf diesem Computer statt auf dem Projekt-Host; {folderVar} ist der absolute Pfad des Ordners, {hostVar} der Name des Remote-Hosts, z. B. {example}", + "localNeedsGlobal": "Auf dem Remote-Server gespeichert, läuft ein lokaler Befehl nicht auf diesem Computer. Aktivieren Sie „In allen Ordnern anzeigen“, um ihn auf diesem Computer zu speichern.", "globalLabel": "In allen Ordnern anzeigen", "globalHint": "In jedem Ordner, Worktree und jeder Remote-Verbindung verfügbar; auf diesem Computer gespeichert", "dragSort": "Zum Sortieren ziehen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index dd4e14a1b2..28ba9ef2bb 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "Stop: {command}", "localUnavailable": "Local commands are only available in the desktop app", "localRunFailed": "Failed to run local command", + "localRemoteFolderCommand": "This local command is saved on the remote server, so it can't run on this computer. Turn on “Show in all folders” to keep it on this computer.", "globalCommand": "Shown in all folders", "manageDialog": { "title": "Manage Commands", @@ -3251,7 +3252,8 @@ "newCommand": "New command", "nameLabel": "Name", "commandLabel": "Command", - "localCommandHint": "Prefix with \"local:\" to run on this computer instead of the project host; $CODEG_FOLDER_PATH is the folder's absolute path and $CODEG_REMOTE_HOST the remote host name, e.g. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "Prefix with local: to run on this computer instead of the project host; {folderVar} is the folder's absolute path and {hostVar} the remote host name, e.g. {example}", + "localNeedsGlobal": "Saved on the remote server, a local command won't run on this computer. Turn on “Show in all folders” to keep it on this computer.", "globalLabel": "Show in all folders", "globalHint": "Available in every folder, worktree and remote connection; saved on this computer", "dragSort": "Drag to sort", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b70c40ec26..1bd5b28899 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3242,6 +3242,7 @@ "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", + "localRemoteFolderCommand": "Este comando local está guardado en el servidor remoto, así que no puede ejecutarse en este equipo. Activa «Mostrar en todas las carpetas» para guardarlo en este equipo.", "globalCommand": "Disponible en todas las carpetas", "manageDialog": { "title": "Gestionar comandos", @@ -3251,7 +3252,8 @@ "newCommand": "Nuevo comando", "nameLabel": "Nombre", "commandLabel": "Comando", - "localCommandHint": "Usa el prefijo \"local:\" para ejecutarlo en este equipo en lugar del host del proyecto; $CODEG_FOLDER_PATH es la ruta absoluta de la carpeta y $CODEG_REMOTE_HOST el nombre del host remoto, p. ej. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "Usa el prefijo local: para ejecutarlo en este equipo en lugar del host del proyecto; {folderVar} es la ruta absoluta de la carpeta y {hostVar} el nombre del host remoto, p. ej. {example}", + "localNeedsGlobal": "Guardado en el servidor remoto, un comando local no se ejecuta en este equipo. Activa «Mostrar en todas las carpetas» para guardarlo en este equipo.", "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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index d38933b2e2..85297e52e9 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3242,6 +3242,7 @@ "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", + "localRemoteFolderCommand": "Cette commande locale est enregistrée sur le serveur distant et ne peut donc pas s'exécuter sur cet ordinateur. Activez « Afficher dans tous les dossiers » pour l'enregistrer sur cet ordinateur.", "globalCommand": "Disponible dans tous les dossiers", "manageDialog": { "title": "Gérer les commandes", @@ -3251,7 +3252,8 @@ "newCommand": "Nouvelle commande", "nameLabel": "Nom", "commandLabel": "Commande", - "localCommandHint": "Préfixez par \"local:\" pour exécuter sur cet ordinateur plutôt que sur l'hôte du projet ; $CODEG_FOLDER_PATH est le chemin absolu du dossier et $CODEG_REMOTE_HOST le nom de l'hôte distant, par ex. local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "Préfixez par local: pour exécuter sur cet ordinateur plutôt que sur l'hôte du projet ; {folderVar} est le chemin absolu du dossier et {hostVar} le nom de l'hôte distant, par ex. {example}", + "localNeedsGlobal": "Enregistrée sur le serveur distant, une commande locale ne s'exécute pas sur cet ordinateur. Activez « Afficher dans tous les dossiers » pour l'enregistrer sur cet ordinateur.", "globalLabel": "Afficher dans tous les dossiers", "globalHint": "Disponible dans chaque dossier, worktree et connexion distante ; enregistré sur cet ordinateur", "dragSort": "Glisser pour trier", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 7a57b542cd..5bc9bd6f56 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "停止: {command}", "localUnavailable": "ローカルコマンドはデスクトップアプリでのみ利用できます", "localRunFailed": "ローカルコマンドの実行に失敗しました", + "localRemoteFolderCommand": "このローカルコマンドはリモートサーバーに保存されているため、このコンピューターでは実行できません。「すべてのフォルダーに表示」をオンにすると、このコンピューターに保存されます。", "globalCommand": "すべてのフォルダーで利用可能", "manageDialog": { "title": "コマンド管理", @@ -3251,7 +3252,8 @@ "newCommand": "新しいコマンド", "nameLabel": "名前", "commandLabel": "コマンド", - "localCommandHint": "\"local:\" で始めると、プロジェクトのホストではなくこのコンピューターで実行します。$CODEG_FOLDER_PATH はフォルダーの絶対パス、$CODEG_REMOTE_HOST はリモートホスト名です。例: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "local: で始めると、プロジェクトのホストではなくこのコンピューターで実行します。{folderVar} はフォルダーの絶対パス、{hostVar} はリモートホスト名です。例: {example}", + "localNeedsGlobal": "リモートサーバーに保存されたローカルコマンドは、このコンピューターでは実行されません。「すべてのフォルダーに表示」をオンにすると、このコンピューターに保存されます。", "globalLabel": "すべてのフォルダーに表示", "globalHint": "すべてのフォルダー、worktree、リモート接続で利用できます。このコンピューターに保存されます", "dragSort": "ドラッグして並び替え", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 998254837f..4d05eb11b0 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "중지: {command}", "localUnavailable": "로컬 명령은 데스크톱 앱에서만 사용할 수 있습니다", "localRunFailed": "로컬 명령 실행 실패", + "localRemoteFolderCommand": "이 로컬 명령은 원격 서버에 저장되어 있어 이 컴퓨터에서 실행할 수 없습니다. ‘모든 폴더에 표시’를 켜면 이 컴퓨터에 저장됩니다.", "globalCommand": "모든 폴더에서 사용 가능", "manageDialog": { "title": "명령 관리", @@ -3251,7 +3252,8 @@ "newCommand": "새 명령", "nameLabel": "이름", "commandLabel": "명령", - "localCommandHint": "\"local:\"로 시작하면 프로젝트 호스트가 아닌 이 컴퓨터에서 실행됩니다. $CODEG_FOLDER_PATH는 폴더의 절대 경로, $CODEG_REMOTE_HOST는 원격 호스트 이름입니다. 예: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "local:로 시작하면 프로젝트 호스트가 아닌 이 컴퓨터에서 실행됩니다. {folderVar}는 폴더의 절대 경로, {hostVar}는 원격 호스트 이름입니다. 예: {example}", + "localNeedsGlobal": "원격 서버에 저장된 로컬 명령은 이 컴퓨터에서 실행되지 않습니다. ‘모든 폴더에 표시’를 켜면 이 컴퓨터에 저장됩니다.", "globalLabel": "모든 폴더에 표시", "globalHint": "모든 폴더, worktree, 원격 연결에서 사용할 수 있으며 이 컴퓨터에 저장됩니다", "dragSort": "드래그하여 정렬", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 54266e6d1a..65a0e42d0e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "Parar: {command}", "localUnavailable": "Comandos locais estão disponíveis apenas no aplicativo de desktop", "localRunFailed": "Falha ao executar o comando local", + "localRemoteFolderCommand": "Este comando local está salvo no servidor remoto, por isso não pode ser executado neste computador. Ative “Mostrar em todas as pastas” para salvá-lo neste computador.", "globalCommand": "Disponível em todas as pastas", "manageDialog": { "title": "Gerenciar comandos", @@ -3251,7 +3252,8 @@ "newCommand": "Novo comando", "nameLabel": "Nome", "commandLabel": "Comando", - "localCommandHint": "Use o prefixo \"local:\" para executar neste computador em vez do host do projeto; $CODEG_FOLDER_PATH é o caminho absoluto da pasta e $CODEG_REMOTE_HOST o nome do host remoto, ex.: local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "Use o prefixo local: para executar neste computador em vez do host do projeto; {folderVar} é o caminho absoluto da pasta e {hostVar} o nome do host remoto, ex.: {example}", + "localNeedsGlobal": "Salvo no servidor remoto, um comando local não é executado neste computador. Ative “Mostrar em todas as pastas” para salvá-lo neste computador.", "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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 833cf3a529..5f4c57659f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "停止:{command}", "localUnavailable": "本地命令仅在桌面应用中可用", "localRunFailed": "运行本地命令失败", + "localRemoteFolderCommand": "这条本地命令保存在远程服务器上,不能在本机运行。打开「在所有文件夹中显示」即可把它保存到本机。", "globalCommand": "所有文件夹可用", "manageDialog": { "title": "管理命令", @@ -3251,7 +3252,8 @@ "newCommand": "新建命令", "nameLabel": "名称", "commandLabel": "命令", - "localCommandHint": "以 \"local:\" 开头的命令会在本机而不是项目所在主机上运行;$CODEG_FOLDER_PATH 为目录的绝对路径,$CODEG_REMOTE_HOST 为远程主机名,例如 local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "以 local: 开头的命令会在本机而不是项目所在主机上运行;{folderVar} 为目录的绝对路径,{hostVar} 为远程主机名,例如 {example}", + "localNeedsGlobal": "保存在远程服务器上的本地命令不会在本机运行。打开「在所有文件夹中显示」即可把它保存到本机。", "globalLabel": "在所有文件夹中显示", "globalHint": "在所有文件夹、worktree 和远程连接中都可用,保存在本机", "dragSort": "拖拽排序", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 28197e6e07..26ee418941 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3242,6 +3242,7 @@ "stopCommandTitle": "停止:{command}", "localUnavailable": "本機命令僅在桌面應用程式中可用", "localRunFailed": "執行本機命令失敗", + "localRemoteFolderCommand": "這條本機命令儲存在遠端伺服器上,無法在本機執行。開啟「在所有資料夾中顯示」即可將它儲存到本機。", "globalCommand": "所有資料夾可用", "manageDialog": { "title": "管理命令", @@ -3251,7 +3252,8 @@ "newCommand": "新增命令", "nameLabel": "名稱", "commandLabel": "命令", - "localCommandHint": "以 \"local:\" 開頭的命令會在本機而非專案所在主機上執行;$CODEG_FOLDER_PATH 為目錄的絕對路徑,$CODEG_REMOTE_HOST 為遠端主機名稱,例如 local: zed \"ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH\"", + "localCommandHint": "以 local: 開頭的命令會在本機而非專案所在主機上執行;{folderVar} 為目錄的絕對路徑,{hostVar} 為遠端主機名稱,例如 {example}", + "localNeedsGlobal": "儲存在遠端伺服器上的本機命令不會在本機執行。開啟「在所有資料夾中顯示」即可將它儲存到本機。", "globalLabel": "在所有資料夾中顯示", "globalHint": "在所有資料夾、worktree 和遠端連線中都可用,儲存在本機", "dragSort": "拖曳排序", diff --git a/src/lib/api.ts b/src/lib/api.ts index cd055c18f4..eb63afa866 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3466,17 +3466,21 @@ export async function openInCode(path: string): Promise { * 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`. Desktop only. + * `$CODEG_FOLDER_PATH` / `$CODEG_REMOTE_HOST`. 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 + remoteHost: string | null, + workingDir: string | null ): Promise { return getShellTransport().call("run_local_command", { command, folderPath, remoteHost, + workingDir, }) } diff --git a/src/lib/global-commands.test.ts b/src/lib/global-commands.test.ts index 269523b325..6781c9d8c9 100644 --- a/src/lib/global-commands.test.ts +++ b/src/lib/global-commands.test.ts @@ -7,7 +7,9 @@ import { listGlobalCommands, reorderGlobalCommands, updateGlobalCommand, + withoutGlobalIds, } from "./global-commands" +import type { FolderCommand } from "./types" const summary = () => listGlobalCommands().map(({ id, name, command }) => ({ id, name, command })) @@ -31,6 +33,38 @@ describe("global commands", () => { expect(createGlobalCommand("c", "c").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") + const b = createGlobalCommand("b", "b") + deleteGlobalCommand(b.id) + expect(createGlobalCommand("c", "c").id).toBe(b.id - 1) + deleteGlobalCommand(-1) + deleteGlobalCommand(-3) + expect(createGlobalCommand("d", "d").id).toBe(-4) + }) + + 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").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").id).toBe(-6) + }) + it("updates, deletes and reorders", () => { createGlobalCommand("a", "a") createGlobalCommand("b", "b") @@ -64,5 +98,28 @@ describe("global commands", () => { 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 index c6c9af9f64..2e5c798e1d 100644 --- a/src/lib/global-commands.ts +++ b/src/lib/global-commands.ts @@ -19,10 +19,39 @@ interface StoredGlobalCommand { command: string } +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. */ function parseEntry(value: unknown): StoredGlobalCommand | null { @@ -33,28 +62,41 @@ function parseEntry(value: unknown): StoredGlobalCommand | null { return { id: id as number, name, command } } -function readStored(): StoredGlobalCommand[] { +function readStored(): StoredGlobalCommands { + const empty: StoredGlobalCommands = { lastId: 0, commands: [] } try { const raw = localStorage.getItem(GLOBAL_COMMANDS_STORAGE_KEY) - if (!raw) return [] + if (!raw) return empty const value: unknown = JSON.parse(raw) - if (!Array.isArray(value)) return [] + // 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 value) { + for (const item of commands) { const entry = parseEntry(item) if (!entry || seen.has(entry.id)) continue seen.add(entry.id) out.push(entry) } - return out + // 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 [] + return empty } } -function writeStored(entries: StoredGlobalCommand[]): void { - localStorage.setItem(GLOBAL_COMMANDS_STORAGE_KEY, JSON.stringify(entries)) +function writeStored(stored: StoredGlobalCommands): void { + localStorage.setItem(GLOBAL_COMMANDS_STORAGE_KEY, JSON.stringify(stored)) } function toFolderCommand( @@ -73,17 +115,17 @@ function toFolderCommand( } export function listGlobalCommands(): FolderCommand[] { - return readStored().map(toFolderCommand) + return readStored().commands.map(toFolderCommand) } export function createGlobalCommand( name: string, command: string ): FolderCommand { - const entries = readStored() - const id = Math.min(0, ...entries.map((entry) => entry.id)) - 1 - const next = [...entries, { id, name, command }] - writeStored(next) + const { lastId, commands } = readStored() + const id = lastId - 1 + const next = [...commands, { id, name, command }] + writeStored({ lastId: id, commands: next }) return toFolderCommand(next[next.length - 1], next.length - 1) } @@ -92,27 +134,31 @@ export function updateGlobalCommand( name: string, command: string ): FolderCommand { - const entries = readStored() - const index = entries.findIndex((entry) => entry.id === id) + const { lastId, commands } = readStored() + const index = commands.findIndex((entry) => entry.id === id) if (index < 0) throw new Error("Command not found") - entries[index] = { id, name, command } - writeStored(entries) - return toFolderCommand(entries[index], index) + commands[index] = { id, name, command } + writeStored({ lastId, commands }) + return toFolderCommand(commands[index], index) } export function deleteGlobalCommand(id: number): void { - writeStored(readStored().filter((entry) => entry.id !== id)) + 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 entries = readStored() - const byId = new Map(entries.map((entry) => [entry.id, entry])) + 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([...ordered, ...byId.values()]) + writeStored({ lastId, commands: [...ordered, ...byId.values()] }) } 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 } From 67cb74e4ecd5efe9d3f3d9261b70fd4dbb1b29ae Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 17:17:52 +0800 Subject: [PATCH 04/14] fix(local-command): refuse remote values cmd.exe would read as syntax cmd.exe splices a %VAR% value into its command line before it parses it, so a remote folder named `/srv/x" & calc.exe & rem "` turned a global command such as `echo "%CODEG_FOLDER_PATH%"` into a launch of calc.exe on this machine. When the window is remote, refuse CODEG_FOLDER_PATH or CODEG_REMOTE_HOST holding a quote, & | < > ^ % ! or a control character before cmd.exe sees them; a local window's values are this machine's own. - Pass `remoteHost` in, and only in, a remote window (empty when its name can't be read), so the backend can tell where the folder path came from. - Run cmd.exe with /V:OFF so a `!` stays literal even where the registry turns delayed expansion on. - Split the spawn into a function taking the shell, so the refusal is tested on every platform without spawning anything. - Test that another window clearing the storage drops global commands. --- src-tauri/src/commands/local_command.rs | 148 +++++++++++++++--- .../layout/command-dropdown.test.tsx | 35 ++++- src/components/layout/command-dropdown.tsx | 4 +- src/lib/api.ts | 8 +- 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/local_command.rs b/src-tauri/src/commands/local_command.rs index 28dcfde2e8..720b1105b9 100644 --- a/src-tauri/src/commands/local_command.rs +++ b/src-tauri/src/commands/local_command.rs @@ -19,9 +19,12 @@ use crate::terminal::shell_flavor::{classify_shell_family, ShellFamily}; /// /// `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`. It 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. +/// 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. @@ -32,30 +35,39 @@ pub async fn run_local_command( remote_host: Option, working_dir: Option, ) -> Result<(), AppCommandError> { - let command = validate_local_command(&command)?; let shell = crate::terminal::manager::resolve_shell(); - let mut child = tokio::process::Command::new(&shell); - match shell_invocation(&shell, command) { + 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); } - #[cfg(windows)] ShellInvocation::CmdLine(line) => { - use std::os::windows::process::CommandExt; - child.as_std_mut().raw_arg(line); - } - // Only a Windows host resolves its shell to cmd.exe. - #[cfg(not(windows))] - ShellInvocation::CmdLine(_) => { - return Err(AppCommandError::invalid_input(format!( - "{shell} can only run commands on Windows" - ))); + if remote { + reject_cmd_syntax("CODEG_FOLDER_PATH", &folder_path)?; + reject_cmd_syntax("CODEG_REMOTE_HOST", &remote_host)?; + } + append_cmd_line(&mut child, line)?; } } child .env("CODEG_CMD", command) - .env("CODEG_FOLDER_PATH", folder_path.unwrap_or_default()) - .env("CODEG_REMOTE_HOST", remote_host.unwrap_or_default()) + .env("CODEG_FOLDER_PATH", folder_path) + .env("CODEG_REMOTE_HOST", remote_host) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -75,6 +87,48 @@ pub async fn run_local_command( }) } +/// 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(()), + } +} + /// `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 { @@ -116,7 +170,9 @@ enum ShellInvocation { /// 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. +/// 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_cmd_syntax` guards. fn shell_invocation(shell: &str, command: &str) -> ShellInvocation { let args: Vec<&str> = match classify_shell_family(shell) { ShellFamily::PowerShell => vec![ @@ -125,7 +181,9 @@ fn shell_invocation(shell: &str, command: &str) -> ShellInvocation { "-Command", "Invoke-Expression $env:CODEG_CMD", ], - ShellFamily::Cmd => return ShellInvocation::CmdLine(format!("/D /S /C \"{command}\"")), + 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], @@ -172,7 +230,7 @@ mod tests { // 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 /S /C "code "%CODEG_FOLDER_PATH%"""#.into()) + ShellInvocation::CmdLine(r#"/D /V:OFF /S /C "code "%CODEG_FOLDER_PATH%"""#.into()) ); } @@ -193,6 +251,54 @@ mod tests { 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"); + assert!(err.message.contains("CODEG_FOLDER_PATH"), "{err:?}"); + } + + #[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:?}"); + } + #[test] fn unknown_posix_shells_get_the_command_verbatim() { assert_eq!( diff --git a/src/components/layout/command-dropdown.test.tsx b/src/components/layout/command-dropdown.test.tsx index 9c9f669d17..4b6ea042d1 100644 --- a/src/components/layout/command-dropdown.test.tsx +++ b/src/components/layout/command-dropdown.test.tsx @@ -19,6 +19,7 @@ const h = vi.hoisted(() => ({ toastError: vi.fn(), desktop: true, remote: true, + baseUrl: "https://devbox.example:3080", folder: { id: 1, path: "/repo" }, command: "pnpm dev", // What the folder's host lists; null for its one "dev" command (`command`). @@ -32,7 +33,7 @@ vi.mock("sonner", () => ({ toast: { error: h.toastError } })) vi.mock("@/lib/transport", () => ({ isDesktop: () => h.desktop, isRemoteDesktopMode: () => h.remote, - getServerBaseUrl: () => "https://devbox.example:3080", + getServerBaseUrl: () => h.baseUrl, })) vi.mock("@/contexts/active-folder-context", () => ({ useActiveFolder: () => ({ activeFolder: h.folder }), @@ -88,6 +89,7 @@ describe("CommandDropdown", () => { 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.hostCommands = null @@ -170,6 +172,23 @@ describe("CommandDropdown", () => { 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", "local: echo %CODEG_FOLDER_PATH%") + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.runLocal).toHaveBeenCalledWith( + "echo %CODEG_FOLDER_PATH%", + "/repo", + "", + null + ) + ) + }) + it("refuses a local: command outside the desktop app", async () => { h.desktop = false h.remote = false @@ -248,4 +267,18 @@ describe("CommandDropdown", () => { ) ) }) + + it("drops global commands when another window clears the storage", async () => { + h.hostCommands = [] + createGlobalCommand("zed", "local: zed .") + 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 7e3d19d0b1..d3796e2b54 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -219,7 +219,9 @@ export function CommandDropdown() { await runLocalCommand( localCommand, folderPath, - getRemoteHostName(), + // 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 ) diff --git a/src/lib/api.ts b/src/lib/api.ts index eb63afa866..d1129e5620 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3466,9 +3466,11 @@ export async function openInCode(path: string): Promise { * 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`. It runs in `workingDir` when - * that is a directory on this machine, else in the home directory. Desktop - * only. + * `$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, From 28cb32a76cdf9fdfb6db95c132c6c2666f030792 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 17:17:52 +0800 Subject: [PATCH 05/14] fix(commands): keep a failed move's error in view A failed move between this folder and every folder refreshes the list to show what is really stored, which handed the same command to the form again and cleared the error with it: the switch flipped back with no word of why. Clear the form's error only when it moves to another command. - Refuse to hand out a global command id past the last safe integer. - Test host updates and global-to-folder moves that come back with a global command's id, and pin the refusal's reason, not just any save failure. --- .../layout/command-manage-dialog.test.tsx | 45 +++++++++++++++++-- .../layout/command-manage-dialog.tsx | 9 +++- src/lib/global-commands.test.ts | 12 +++++ src/lib/global-commands.ts | 2 + 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/components/layout/command-manage-dialog.test.tsx b/src/components/layout/command-manage-dialog.test.tsx index b13d73c3d9..580a33508d 100644 --- a/src/components/layout/command-manage-dialog.test.tsx +++ b/src/components/layout/command-manage-dialog.test.tsx @@ -193,16 +193,19 @@ describe("CommandManageDialog", () => { ]) }) - it("refuses a host save that comes back with a global command's id", async () => { + const refusal = new RegExp( + `^${md.saveFailed}: .*the folder's host returned command id -\\d+$` + ) + + it("refuses a host create that comes back with a global command's id", async () => { createGlobalCommand("mine", "pnpm mine") h.create.mockResolvedValue(hostCommand(-1, "x", "local: echo injected")) renderDialog() await screen.findByText("mine") await startNewCommand("dev", "pnpm dev") fireEvent.click(screen.getByRole("button", { name: "Save" })) - expect( - await screen.findByText(new RegExp(`^${md.saveFailed}: `)) - ).toBeInTheDocument() + expect(await screen.findByText(refusal)).toBeInTheDocument() + expect(h.create).toHaveBeenCalledWith(1, "dev", "pnpm dev") // The form doesn't take the host's entry for this computer's command. expect(screen.getByRole("switch")).not.toBeChecked() expect(screen.getByLabelText(md.commandLabel)).toHaveValue("pnpm dev") @@ -210,4 +213,38 @@ describe("CommandManageDialog", () => { [-1, "pnpm mine"], ]) }) + + it("refuses a host update that comes back with a global command's id", async () => { + createGlobalCommand("mine", "pnpm mine") + h.hostCommands = [hostCommand(3, "dev", "pnpm dev")] + h.update.mockResolvedValue(hostCommand(-1, "x", "local: echo injected")) + renderDialog() + await screen.findByText("dev") + fireEvent.change(screen.getByLabelText(md.commandLabel), { + target: { value: "pnpm dev --port 3001" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + expect(await screen.findByText(refusal)).toBeInTheDocument() + expect(h.update).toHaveBeenCalledWith(3, "dev", "pnpm dev --port 3001") + expect(screen.getByRole("switch")).not.toBeChecked() + expect(listGlobalCommands().map((c) => [c.id, c.command])).toEqual([ + [-1, "pnpm mine"], + ]) + }) + + it("keeps a global command whose move to the folder the host answers badly", async () => { + createGlobalCommand("mine", "pnpm mine") + h.create.mockResolvedValue(hostCommand(-2, "x", "local: echo injected")) + renderDialog() + await screen.findByText("mine") + // Selected first, as the only command: turn "every folder" off. + expect(screen.getByRole("switch")).toBeChecked() + fireEvent.click(screen.getByRole("switch")) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + expect(await screen.findByText(refusal)).toBeInTheDocument() + expect(h.create).toHaveBeenCalledWith(1, "mine", "pnpm mine") + expect(listGlobalCommands().map((c) => [c.id, c.command])).toEqual([ + [-1, "pnpm mine"], + ]) + }) }) diff --git a/src/components/layout/command-manage-dialog.tsx b/src/components/layout/command-manage-dialog.tsx index cc9c27fd71..04491af1c7 100644 --- a/src/components/layout/command-manage-dialog.tsx +++ b/src/components/layout/command-manage-dialog.tsx @@ -292,8 +292,15 @@ export function CommandManageDialog({ [commands, deleteTargetId] ) + // The command the form was last filled from. Moving to another one drops + // the last one's error; the same one read again — the refresh after a failed + // move — keeps it in view, or the failure would pass unexplained. + const formCommandIdRef = useRef(null) + useEffect(() => { - setFormError(null) + const id = selected?.id ?? null + if (id !== formCommandIdRef.current) setFormError(null) + formCommandIdRef.current = id if (!selected) { setDraft(EMPTY_DRAFT) return diff --git a/src/lib/global-commands.test.ts b/src/lib/global-commands.test.ts index 6781c9d8c9..78863ac34b 100644 --- a/src/lib/global-commands.test.ts +++ b/src/lib/global-commands.test.ts @@ -45,6 +45,18 @@ describe("global commands", () => { expect(createGlobalCommand("d", "d").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")).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, diff --git a/src/lib/global-commands.ts b/src/lib/global-commands.ts index 2e5c798e1d..6c4a45eaf2 100644 --- a/src/lib/global-commands.ts +++ b/src/lib/global-commands.ts @@ -124,6 +124,8 @@ export function createGlobalCommand( ): 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 }] writeStored({ lastId: id, commands: next }) return toFolderCommand(next[next.length - 1], next.length - 1) From 33e537ee967f405213663a39281055f4d10878e9 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 17:52:36 +0800 Subject: [PATCH 06/14] fix(local-command): make cmd.exe quote a remote window's variables Outside double quotes cmd parses a spliced %VAR% value like the rest of the line, so a remote folder path's spaces or parentheses could still put a command where cmd expects one: `if exist %CODEG_FOLDER_PATH% code .` with the path `C:\Windows calc.exe` launched calc.exe. From a remote window, a cmd.exe command must now name CODEG_FOLDER_PATH and CODEG_REMOTE_HOST only inside double quotes, where the existing value check keeps them one argument. The refusal test now pins its reason, as the off-Windows error also names the variable. --- src-tauri/src/commands/local_command.rs | 135 +++++++++++++++++++++++- 1 file changed, 131 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/local_command.rs b/src-tauri/src/commands/local_command.rs index 720b1105b9..2042f5edbe 100644 --- a/src-tauri/src/commands/local_command.rs +++ b/src-tauri/src/commands/local_command.rs @@ -58,8 +58,13 @@ fn spawn_local_command( } ShellInvocation::CmdLine(line) => { if remote { - reject_cmd_syntax("CODEG_FOLDER_PATH", &folder_path)?; - reject_cmd_syntax("CODEG_REMOTE_HOST", &remote_host)?; + for (name, value) in [ + ("CODEG_FOLDER_PATH", &folder_path), + ("CODEG_REMOTE_HOST", &remote_host), + ] { + reject_unquoted_cmd_variable(command, name)?; + reject_cmd_syntax(name, value)?; + } } append_cmd_line(&mut child, line)?; } @@ -129,6 +134,52 @@ fn reject_cmd_syntax(name: &str, value: &str) -> Result<(), AppCommandError> { } } +/// Refuse `command` when it names the variable `name` outside double quotes. +/// There cmd parses the value it splices in like the rest of the line, so the +/// value's spaces and parentheses can put a command where cmd expects one: a +/// remote folder named `C:\Windows calc.exe` makes +/// `if exist %CODEG_FOLDER_PATH% code .` launch calc.exe. Inside quotes the +/// value stays one argument, and `reject_cmd_syntax` keeps it from closing +/// them. Like that check, this one is for a remote window's commands only. +fn reject_unquoted_cmd_variable(command: &str, name: &str) -> Result<(), AppCommandError> { + if cmd_names_unquoted(command, name) { + return Err(AppCommandError::invalid_input(format!( + "%{name}% is outside double quotes, where cmd.exe would read the remote server's value as part of the command" + ))); + } + Ok(()) +} + +/// Whether `command` names the variable `name` — `%NAME%`, or `%NAME:…%` for a +/// substring or substitution, in any case — outside double quotes as cmd.exe +/// reads them: each `"` opens or closes them, but outside them `^` escapes the +/// character after it, a `"` included. A reference counts wherever it stands, +/// a caret before it included: cmd expands `%…%` before it reads carets. +fn cmd_names_unquoted(command: &str, name: &str) -> bool { + let mut quoted = false; + let mut escaped = false; + for (i, c) in command.char_indices() { + if c == '%' && !quoted && names_variable(&command[i + 1..], name) { + return true; + } + if escaped { + escaped = false; + } else if c == '"' { + quoted = !quoted; + } else if c == '^' && !quoted { + escaped = true; + } + } + false +} + +/// Whether `rest`, the text after a `%`, starts with the variable `name`. +fn names_variable(rest: &str, name: &str) -> bool { + rest.get(..name.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(name)) + && rest[name.len()..].starts_with(['%', ':']) +} + /// `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 { @@ -172,7 +223,7 @@ enum ShellInvocation { /// 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_cmd_syntax` guards. +/// line is what `reject_unquoted_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![ @@ -262,7 +313,83 @@ mod tests { None, ) .expect_err("refused"); - assert!(err.message.contains("CODEG_FOLDER_PATH"), "{err:?}"); + // 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() { + for (command, folder, refused) in [ + // Spaces alone put `calc.exe` where `if` expects its command. + ( + "if exist %CODEG_FOLDER_PATH% code .", + r"C:\Windows calc.exe", + "%CODEG_FOLDER_PATH%", + ), + ( + "for %i in (%CODEG_FOLDER_PATH%) do echo %i", + "/srv/repo) do calc.exe #", + "%CODEG_FOLDER_PATH%", + ), + ( + r#"zed ssh://%CODEG_REMOTE_HOST%"%CODEG_FOLDER_PATH%""#, + "/srv/repo", + "%CODEG_REMOTE_HOST%", + ), + ] { + let err = spawn_local_command( + "cmd.exe", + command, + Some(folder.into()), + Some("devbox".into()), + None, + ) + .expect_err(command); + assert!( + err.message + .contains(&format!("{refused} is outside double quotes")), + "{command}: {err:?}" + ); + } + } + + #[test] + fn cmd_reads_quotes_and_carets_as_cmd_does() { + for command in [ + "code %CODEG_FOLDER_PATH%", + "code %codeg_folder_path%", + "code %CODEG_FOLDER_PATH:~1%", + r#"code "a" %CODEG_FOLDER_PATH% "b""#, + // A caret doesn't keep cmd from expanding the reference after it. + "code ^%CODEG_FOLDER_PATH%", + // Escaped quotes open nothing. + r#"code ^"%CODEG_FOLDER_PATH%^""#, + // Inside quotes a caret is literal, so the quote after it closes. + r#"code "^"%CODEG_FOLDER_PATH%""#, + ] { + assert!( + cmd_names_unquoted(command, "CODEG_FOLDER_PATH"), + "{command}" + ); + } + for command in [ + r#"code "%CODEG_FOLDER_PATH%""#, + r#"zed "ssh://me@%CODEG_REMOTE_HOST%%CODEG_FOLDER_PATH%""#, + // A literal quote, then a quoted reference. + r#"code "x"^""%CODEG_FOLDER_PATH%""#, + // Other variables, delayed expansion (off) and bare text. + "echo %CODEG_FOLDER_PATHS% %OTHER% !CODEG_FOLDER_PATH! CODEG_FOLDER_PATH", + ] { + assert!( + !cmd_names_unquoted(command, "CODEG_FOLDER_PATH"), + "{command}" + ); + } } #[test] From 4b62f3de316f4c00d2c14a38957751705d4e79d2 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 17:52:36 +0800 Subject: [PATCH 07/14] fix(commands): hold the command list while a save is in flight Picking another command mid-save let the save's error, or its result, land on the other command's form. The list ignores selection and New is disabled until the save settles. --- .../layout/command-manage-dialog.test.tsx | 31 +++++++++++++++++++ .../layout/command-manage-dialog.tsx | 13 ++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/components/layout/command-manage-dialog.test.tsx b/src/components/layout/command-manage-dialog.test.tsx index 580a33508d..b5259a298e 100644 --- a/src/components/layout/command-manage-dialog.test.tsx +++ b/src/components/layout/command-manage-dialog.test.tsx @@ -232,6 +232,37 @@ describe("CommandManageDialog", () => { ]) }) + it("keeps a save's failure on its own command, whatever is clicked meanwhile", async () => { + h.hostCommands = [ + hostCommand(3, "dev", "pnpm dev"), + hostCommand(4, "test", "pnpm test"), + ] + let fail: (err: Error) => void = () => {} + h.update.mockImplementation( + () => + new Promise((_, reject) => { + fail = reject + }) + ) + renderDialog() + await screen.findByText("test") + // "dev", listed first, is the one selected. + fireEvent.change(screen.getByLabelText(md.commandLabel), { + target: { value: "pnpm dev --port 3001" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + fireEvent.click(screen.getByText("test")) + expect(screen.getByRole("button", { name: md.newCommand })).toBeDisabled() + fail(new Error("host down")) + expect( + await screen.findByText(`${md.saveFailed}: host down`) + ).toBeInTheDocument() + expect(screen.getByLabelText(md.nameLabel)).toHaveValue("dev") + expect(screen.getByLabelText(md.commandLabel)).toHaveValue( + "pnpm dev --port 3001" + ) + }) + it("keeps a global command whose move to the folder the host answers badly", async () => { createGlobalCommand("mine", "pnpm mine") h.create.mockResolvedValue(hostCommand(-2, "x", "local: echo injected")) diff --git a/src/components/layout/command-manage-dialog.tsx b/src/components/layout/command-manage-dialog.tsx index 04491af1c7..2876d9e0f4 100644 --- a/src/components/layout/command-manage-dialog.tsx +++ b/src/components/layout/command-manage-dialog.tsx @@ -355,6 +355,15 @@ export function CommandManageDialog({ setDraft(EMPTY_DRAFT) }, []) + // A save holds the form until it settles, so what it ends in — the saved + // command, or why it wasn't — shows on the command that was saved. + const selectCommand = useCallback( + (id: number) => { + if (!saving) setSelectedId(id) + }, + [saving] + ) + const persistReorder = useCallback( async (ids: number[]) => { if (ids.length === 0) return @@ -505,7 +514,7 @@ export function CommandManageDialog({ onChange={(event) => setSearchQuery(event.target.value)} placeholder={t("searchPlaceholder")} /> - @@ -544,7 +553,7 @@ export function CommandManageDialog({ command={command} selected={selectedId === command.id} disabled={dragDisabled} - onSelect={setSelectedId} + onSelect={selectCommand} onDragEnd={() => { const order = pendingOrderRef.current pendingOrderRef.current = null From 1338dcd8d18e23c3df6dc9ebd55084504b731f40 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 16 Oct 2026 18:20:01 +0800 Subject: [PATCH 08/14] fix(local-command): allow only plain quoted remote variables for cmd.exe Reading the source text's quotes to prove a remote value is spliced inside cmd.exe's quotes was fooled by cmd's first expansion pass: a %VAR:"=% substitution consumes a quote before quotes are parsed, and a for-loop's bare %i pairs its % with a later one, so %CODEG_FOLDER_PATH% could land unquoted despite looking quoted. Rather than model cmd's lexer, a remote window's cmd command must now use only plain %IDENT% references and no caret; every quote is then a real one and a plain reference holds none, so the quoted-or-not reading is exact. %VAR:...% modifiers, for-loops and literal % are refused (they don't appear in launcher commands). --- src-tauri/src/commands/local_command.rs | 189 ++++++++++++------------ 1 file changed, 96 insertions(+), 93 deletions(-) diff --git a/src-tauri/src/commands/local_command.rs b/src-tauri/src/commands/local_command.rs index 2042f5edbe..3f0cf0d8d2 100644 --- a/src-tauri/src/commands/local_command.rs +++ b/src-tauri/src/commands/local_command.rs @@ -62,7 +62,7 @@ fn spawn_local_command( ("CODEG_FOLDER_PATH", &folder_path), ("CODEG_REMOTE_HOST", &remote_host), ] { - reject_unquoted_cmd_variable(command, name)?; + reject_unsafe_cmd_variable(command, name)?; reject_cmd_syntax(name, value)?; } } @@ -134,50 +134,65 @@ fn reject_cmd_syntax(name: &str, value: &str) -> Result<(), AppCommandError> { } } -/// Refuse `command` when it names the variable `name` outside double quotes. -/// There cmd parses the value it splices in like the rest of the line, so the -/// value's spaces and parentheses can put a command where cmd expects one: a -/// remote folder named `C:\Windows calc.exe` makes -/// `if exist %CODEG_FOLDER_PATH% code .` launch calc.exe. Inside quotes the -/// value stays one argument, and `reject_cmd_syntax` keeps it from closing -/// them. Like that check, this one is for a remote window's commands only. -fn reject_unquoted_cmd_variable(command: &str, name: &str) -> Result<(), AppCommandError> { - if cmd_names_unquoted(command, name) { - return Err(AppCommandError::invalid_input(format!( - "%{name}% is outside double quotes, where cmd.exe would read the remote server's value as part of the command" - ))); +/// 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 `local:` command cmd.exe runs 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"); } - Ok(()) -} - -/// Whether `command` names the variable `name` — `%NAME%`, or `%NAME:…%` for a -/// substring or substitution, in any case — outside double quotes as cmd.exe -/// reads them: each `"` opens or closes them, but outside them `^` escapes the -/// character after it, a `"` included. A reference counts wherever it stands, -/// a caret before it included: cmd expands `%…%` before it reads carets. -fn cmd_names_unquoted(command: &str, name: &str) -> bool { let mut quoted = false; - let mut escaped = false; - for (i, c) in command.char_indices() { - if c == '%' && !quoted && names_variable(&command[i + 1..], name) { - return true; - } - if escaped { - escaped = false; - } else if c == '"' { - quoted = !quoted; - } else if c == '^' && !quoted { - escaped = true; + 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"); + } + } + _ => {} } } - false -} - -/// Whether `rest`, the text after a `%`, starts with the variable `name`. -fn names_variable(rest: &str, name: &str) -> bool { - rest.get(..name.len()) - .is_some_and(|head| head.eq_ignore_ascii_case(name)) - && rest[name.len()..].starts_with(['%', ':']) + Ok(()) } /// `requested` when it is a directory on this machine, else the home directory. @@ -223,7 +238,7 @@ enum ShellInvocation { /// 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_unquoted_cmd_variable` and `reject_cmd_syntax` guard. +/// 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![ @@ -324,69 +339,57 @@ mod tests { #[test] fn cmd_refuses_a_remote_variable_outside_double_quotes() { - for (command, folder, refused) in [ - // Spaces alone put `calc.exe` where `if` expects its command. - ( - "if exist %CODEG_FOLDER_PATH% code .", - r"C:\Windows calc.exe", - "%CODEG_FOLDER_PATH%", - ), - ( - "for %i in (%CODEG_FOLDER_PATH%) do echo %i", - "/srv/repo) do calc.exe #", - "%CODEG_FOLDER_PATH%", - ), - ( - r#"zed ssh://%CODEG_REMOTE_HOST%"%CODEG_FOLDER_PATH%""#, - "/srv/repo", - "%CODEG_REMOTE_HOST%", - ), - ] { - let err = spawn_local_command( - "cmd.exe", - command, - Some(folder.into()), - Some("devbox".into()), - None, - ) - .expect_err(command); - assert!( - err.message - .contains(&format!("{refused} is outside double quotes")), - "{command}: {err:?}" - ); - } + // 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:?}" + ); } #[test] - fn cmd_reads_quotes_and_carets_as_cmd_does() { + fn cmd_allows_only_plainly_quoted_remote_variables() { + // Quoted plain references — alone, beside quoted text, or two in a row. for command in [ - "code %CODEG_FOLDER_PATH%", - "code %codeg_folder_path%", - "code %CODEG_FOLDER_PATH:~1%", - r#"code "a" %CODEG_FOLDER_PATH% "b""#, - // A caret doesn't keep cmd from expanding the reference after it. - "code ^%CODEG_FOLDER_PATH%", - // Escaped quotes open nothing. - r#"code ^"%CODEG_FOLDER_PATH%^""#, - // Inside quotes a caret is literal, so the quote after it closes. - r#"code "^"%CODEG_FOLDER_PATH%""#, + 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!( - cmd_names_unquoted(command, "CODEG_FOLDER_PATH"), + 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 [ - r#"code "%CODEG_FOLDER_PATH%""#, - r#"zed "ssh://me@%CODEG_REMOTE_HOST%%CODEG_FOLDER_PATH%""#, - // A literal quote, then a quoted reference. - r#"code "x"^""%CODEG_FOLDER_PATH%""#, - // Other variables, delayed expansion (off) and bare text. - "echo %CODEG_FOLDER_PATHS% %OTHER% !CODEG_FOLDER_PATH! CODEG_FOLDER_PATH", + "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!( - !cmd_names_unquoted(command, "CODEG_FOLDER_PATH"), + reject_unsafe_cmd_variable(command, "CODEG_FOLDER_PATH").is_err(), "{command}" ); } From 143b4ee7a31729976bf3d87e5fcc468c5ca4d66e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 20 Oct 2026 22:06:13 +0800 Subject: [PATCH 09/14] fix(commands): run a remote server's local: commands like any other A remote-desktop window refused a local: command saved on the remote server and warned about it in the manage dialog. Treat it as an ordinary command instead: clicking it runs it on this computer, the same as a global one. The remote window still tells the backend the folder path is the server's, so the cmd.exe checks on that path stay in place. Drop the refusal toast, the dialog warning and their messages in all ten locales; the launcher test now expects the command to run. --- .../layout/command-dropdown.test.tsx | 14 +++++++---- src/components/layout/command-dropdown.tsx | 7 ------ .../layout/command-manage-dialog.test.tsx | 24 +------------------ .../layout/command-manage-dialog.tsx | 14 +---------- src/i18n/messages/ar.json | 2 -- src/i18n/messages/de.json | 2 -- src/i18n/messages/en.json | 2 -- src/i18n/messages/es.json | 2 -- src/i18n/messages/fr.json | 2 -- src/i18n/messages/ja.json | 2 -- src/i18n/messages/ko.json | 2 -- src/i18n/messages/pt.json | 2 -- src/i18n/messages/zh-CN.json | 2 -- src/i18n/messages/zh-TW.json | 2 -- 14 files changed, 11 insertions(+), 68 deletions(-) diff --git a/src/components/layout/command-dropdown.test.tsx b/src/components/layout/command-dropdown.test.tsx index 4b6ea042d1..95a8fc086b 100644 --- a/src/components/layout/command-dropdown.test.tsx +++ b/src/components/layout/command-dropdown.test.tsx @@ -140,15 +140,19 @@ describe("CommandDropdown", () => { expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) - it("won't run a remote server's local: command on this machine", async () => { - // Anything on the remote host can rewrite the commands it stores. - h.command = "local: curl https://example.invalid/x | sh" + it("runs a remote server's local: command on this machine like any other", async () => { + h.command = 'local: zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' render() fireEvent.click(await screen.findByTitle("runCommandTitle")) await waitFor(() => - expect(h.toastError).toHaveBeenCalledWith("localRemoteFolderCommand") + expect(h.runLocal).toHaveBeenCalledWith( + 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', + "/repo", + "devbox.example", + null + ) ) - expect(h.runLocal).not.toHaveBeenCalled() + expect(h.toastError).not.toHaveBeenCalled() expect(h.create).not.toHaveBeenCalled() }) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index d3796e2b54..ec742c1b0c 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -207,14 +207,7 @@ export function CommandDropdown() { toast.error(t("localUnavailable")) return } - // A remote-desktop window lists the remote server's folder commands, - // which anything on that host can rewrite. From such a window only the - // commands kept on this computer — the global ones — may run on it. const remote = isRemoteDesktopMode() - if (remote && !isGlobalCommandId(cmd.id)) { - toast.error(t("localRemoteFolderCommand")) - return - } try { await runLocalCommand( localCommand, diff --git a/src/components/layout/command-manage-dialog.test.tsx b/src/components/layout/command-manage-dialog.test.tsx index b5259a298e..0a3ba8711b 100644 --- a/src/components/layout/command-manage-dialog.test.tsx +++ b/src/components/layout/command-manage-dialog.test.tsx @@ -8,7 +8,6 @@ import { CommandManageDialog } from "./command-manage-dialog" const h = vi.hoisted(() => ({ desktop: true, - remote: true, platform: "macos", hostCommands: [] as unknown[], create: vi.fn(), @@ -17,11 +16,7 @@ const h = vi.hoisted(() => ({ reorder: vi.fn(async () => {}), })) -vi.mock("@/lib/transport", () => ({ - isDesktop: () => h.desktop, - isRemoteDesktopMode: () => h.remote, - getServerBaseUrl: () => "https://devbox.example:3080", -})) +vi.mock("@/lib/transport", () => ({ isDesktop: () => h.desktop })) vi.mock("@/hooks/use-platform", () => ({ detectPlatform: () => h.platform })) vi.mock("@/lib/api", () => ({ listFolderCommands: vi.fn(async () => h.hostCommands), @@ -96,7 +91,6 @@ describe("CommandManageDialog", () => { beforeEach(() => { localStorage.clear() h.desktop = true - h.remote = true h.platform = "macos" h.hostCommands = [] h.create.mockReset() @@ -119,21 +113,6 @@ describe("CommandManageDialog", () => { expect(screen.queryByText("impostor")).not.toBeInTheDocument() }) - it("warns in a remote window that a folder's local: command won't run here", async () => { - renderDialog() - await startNewCommand("zed", "local: zed .") - expect(screen.getByText(md.localNeedsGlobal)).toBeInTheDocument() - fireEvent.click(screen.getByRole("switch")) - expect(screen.queryByText(md.localNeedsGlobal)).not.toBeInTheDocument() - }) - - it("doesn't warn in a local window, whose folders are this computer's", async () => { - h.remote = false - renderDialog() - await startNewCommand("zed", "local: zed .") - expect(screen.queryByText(md.localNeedsGlobal)).not.toBeInTheDocument() - }) - it("shows the hint's values as code, spelled for this machine's shell", async () => { h.platform = "windows" renderDialog() @@ -157,7 +136,6 @@ describe("CommandManageDialog", () => { it("leaves out the local-command hint outside the desktop app", async () => { h.desktop = false - h.remote = false renderDialog() await screen.findByRole("button", { name: md.newCommand }) expect(screen.queryByText("$CODEG_FOLDER_PATH")).not.toBeInTheDocument() diff --git a/src/components/layout/command-manage-dialog.tsx b/src/components/layout/command-manage-dialog.tsx index 2876d9e0f4..c606f27064 100644 --- a/src/components/layout/command-manage-dialog.tsx +++ b/src/components/layout/command-manage-dialog.tsx @@ -31,8 +31,7 @@ import { updateGlobalCommand, withoutGlobalIds, } from "@/lib/global-commands" -import { parseLocalCommand } from "@/lib/local-command" -import { isDesktop, isRemoteDesktopMode } from "@/lib/transport" +import { isDesktop } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { Button } from "@/components/ui/button" import { @@ -325,12 +324,6 @@ export function CommandManageDialog({ const searchActive = searchQuery.trim().length > 0 const localHintValues = useMemo(localCommandHintValues, []) - // A remote-desktop window keeps this folder's commands on the remote - // server, and the launcher won't run a `local:` one from there. - const localNeedsGlobal = - isRemoteDesktopMode() && - !draft.global && - parseLocalCommand(draft.command) !== null const safeContainerWidth = panelContainerWidth > 0 ? panelContainerWidth : 900 const leftMinSize = clamp( toPercent(LEFT_MIN_WIDTH, safeContainerWidth), @@ -671,11 +664,6 @@ export function CommandManageDialog({ } />
- {localNeedsGlobal ? ( -

- {t("localNeedsGlobal")} -

- ) : null}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 5618098f2a..4ddf3b0d21 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "إيقاف: {command}", "localUnavailable": "الأوامر المحلية متاحة فقط في تطبيق سطح المكتب", "localRunFailed": "فشل تشغيل الأمر المحلي", - "localRemoteFolderCommand": "هذا الأمر المحلي محفوظ على الخادم البعيد، لذا لا يمكن تشغيله على هذا الكمبيوتر. فعّل «إظهار في كل المجلدات» لحفظه على هذا الكمبيوتر.", "globalCommand": "متاح في كل المجلدات", "manageDialog": { "title": "إدارة الأوامر", @@ -3253,7 +3252,6 @@ "nameLabel": "الاسم", "commandLabel": "الأمر", "localCommandHint": "ابدأ الأمر بـ local: لتشغيله على هذا الكمبيوتر بدلاً من مضيف المشروع؛ {folderVar} هو المسار المطلق للمجلد و{hostVar} اسم المضيف البعيد، مثال: {example}", - "localNeedsGlobal": "الأمر المحلي المحفوظ على الخادم البعيد لا يعمل على هذا الكمبيوتر. فعّل «إظهار في كل المجلدات» لحفظه على هذا الكمبيوتر.", "globalLabel": "إظهار في كل المجلدات", "globalHint": "متاح في كل مجلد وworktree واتصال بعيد؛ ويُحفظ على هذا الكمبيوتر", "dragSort": "اسحب للترتيب", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d6ff9a8525..193b9b71ff 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "Stoppen: {command}", "localUnavailable": "Lokale Befehle sind nur in der Desktop-App verfügbar", "localRunFailed": "Lokaler Befehl konnte nicht ausgeführt werden", - "localRemoteFolderCommand": "Dieser lokale Befehl ist auf dem Remote-Server gespeichert und kann daher nicht auf diesem Computer laufen. Aktivieren Sie „In allen Ordnern anzeigen“, um ihn auf diesem Computer zu speichern.", "globalCommand": "In allen Ordnern verfügbar", "manageDialog": { "title": "Befehle verwalten", @@ -3253,7 +3252,6 @@ "nameLabel": "Bezeichnung", "commandLabel": "Befehl", "localCommandHint": "Mit dem Präfix local: läuft der Befehl auf diesem Computer statt auf dem Projekt-Host; {folderVar} ist der absolute Pfad des Ordners, {hostVar} der Name des Remote-Hosts, z. B. {example}", - "localNeedsGlobal": "Auf dem Remote-Server gespeichert, läuft ein lokaler Befehl nicht auf diesem Computer. Aktivieren Sie „In allen Ordnern anzeigen“, um ihn auf diesem Computer zu speichern.", "globalLabel": "In allen Ordnern anzeigen", "globalHint": "In jedem Ordner, Worktree und jeder Remote-Verbindung verfügbar; auf diesem Computer gespeichert", "dragSort": "Zum Sortieren ziehen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 28ba9ef2bb..6147e2e29e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "Stop: {command}", "localUnavailable": "Local commands are only available in the desktop app", "localRunFailed": "Failed to run local command", - "localRemoteFolderCommand": "This local command is saved on the remote server, so it can't run on this computer. Turn on “Show in all folders” to keep it on this computer.", "globalCommand": "Shown in all folders", "manageDialog": { "title": "Manage Commands", @@ -3253,7 +3252,6 @@ "nameLabel": "Name", "commandLabel": "Command", "localCommandHint": "Prefix with local: to run on this computer instead of the project host; {folderVar} is the folder's absolute path and {hostVar} the remote host name, e.g. {example}", - "localNeedsGlobal": "Saved on the remote server, a local command won't run on this computer. Turn on “Show in all folders” to keep it on this computer.", "globalLabel": "Show in all folders", "globalHint": "Available in every folder, worktree and remote connection; saved on this computer", "dragSort": "Drag to sort", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1bd5b28899..a804e937ac 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3242,7 +3242,6 @@ "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", - "localRemoteFolderCommand": "Este comando local está guardado en el servidor remoto, así que no puede ejecutarse en este equipo. Activa «Mostrar en todas las carpetas» para guardarlo en este equipo.", "globalCommand": "Disponible en todas las carpetas", "manageDialog": { "title": "Gestionar comandos", @@ -3253,7 +3252,6 @@ "nameLabel": "Nombre", "commandLabel": "Comando", "localCommandHint": "Usa el prefijo local: para ejecutarlo en este equipo en lugar del host del proyecto; {folderVar} es la ruta absoluta de la carpeta y {hostVar} el nombre del host remoto, p. ej. {example}", - "localNeedsGlobal": "Guardado en el servidor remoto, un comando local no se ejecuta en este equipo. Activa «Mostrar en todas las carpetas» para guardarlo en este equipo.", "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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 85297e52e9..73893dc84c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3242,7 +3242,6 @@ "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", - "localRemoteFolderCommand": "Cette commande locale est enregistrée sur le serveur distant et ne peut donc pas s'exécuter sur cet ordinateur. Activez « Afficher dans tous les dossiers » pour l'enregistrer sur cet ordinateur.", "globalCommand": "Disponible dans tous les dossiers", "manageDialog": { "title": "Gérer les commandes", @@ -3253,7 +3252,6 @@ "nameLabel": "Nom", "commandLabel": "Commande", "localCommandHint": "Préfixez par local: pour exécuter sur cet ordinateur plutôt que sur l'hôte du projet ; {folderVar} est le chemin absolu du dossier et {hostVar} le nom de l'hôte distant, par ex. {example}", - "localNeedsGlobal": "Enregistrée sur le serveur distant, une commande locale ne s'exécute pas sur cet ordinateur. Activez « Afficher dans tous les dossiers » pour l'enregistrer sur cet ordinateur.", "globalLabel": "Afficher dans tous les dossiers", "globalHint": "Disponible dans chaque dossier, worktree et connexion distante ; enregistré sur cet ordinateur", "dragSort": "Glisser pour trier", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 5bc9bd6f56..13154f7aea 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "停止: {command}", "localUnavailable": "ローカルコマンドはデスクトップアプリでのみ利用できます", "localRunFailed": "ローカルコマンドの実行に失敗しました", - "localRemoteFolderCommand": "このローカルコマンドはリモートサーバーに保存されているため、このコンピューターでは実行できません。「すべてのフォルダーに表示」をオンにすると、このコンピューターに保存されます。", "globalCommand": "すべてのフォルダーで利用可能", "manageDialog": { "title": "コマンド管理", @@ -3253,7 +3252,6 @@ "nameLabel": "名前", "commandLabel": "コマンド", "localCommandHint": "local: で始めると、プロジェクトのホストではなくこのコンピューターで実行します。{folderVar} はフォルダーの絶対パス、{hostVar} はリモートホスト名です。例: {example}", - "localNeedsGlobal": "リモートサーバーに保存されたローカルコマンドは、このコンピューターでは実行されません。「すべてのフォルダーに表示」をオンにすると、このコンピューターに保存されます。", "globalLabel": "すべてのフォルダーに表示", "globalHint": "すべてのフォルダー、worktree、リモート接続で利用できます。このコンピューターに保存されます", "dragSort": "ドラッグして並び替え", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 4d05eb11b0..94bd2c0685 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "중지: {command}", "localUnavailable": "로컬 명령은 데스크톱 앱에서만 사용할 수 있습니다", "localRunFailed": "로컬 명령 실행 실패", - "localRemoteFolderCommand": "이 로컬 명령은 원격 서버에 저장되어 있어 이 컴퓨터에서 실행할 수 없습니다. ‘모든 폴더에 표시’를 켜면 이 컴퓨터에 저장됩니다.", "globalCommand": "모든 폴더에서 사용 가능", "manageDialog": { "title": "명령 관리", @@ -3253,7 +3252,6 @@ "nameLabel": "이름", "commandLabel": "명령", "localCommandHint": "local:로 시작하면 프로젝트 호스트가 아닌 이 컴퓨터에서 실행됩니다. {folderVar}는 폴더의 절대 경로, {hostVar}는 원격 호스트 이름입니다. 예: {example}", - "localNeedsGlobal": "원격 서버에 저장된 로컬 명령은 이 컴퓨터에서 실행되지 않습니다. ‘모든 폴더에 표시’를 켜면 이 컴퓨터에 저장됩니다.", "globalLabel": "모든 폴더에 표시", "globalHint": "모든 폴더, worktree, 원격 연결에서 사용할 수 있으며 이 컴퓨터에 저장됩니다", "dragSort": "드래그하여 정렬", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 65a0e42d0e..6b17532958 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "Parar: {command}", "localUnavailable": "Comandos locais estão disponíveis apenas no aplicativo de desktop", "localRunFailed": "Falha ao executar o comando local", - "localRemoteFolderCommand": "Este comando local está salvo no servidor remoto, por isso não pode ser executado neste computador. Ative “Mostrar em todas as pastas” para salvá-lo neste computador.", "globalCommand": "Disponível em todas as pastas", "manageDialog": { "title": "Gerenciar comandos", @@ -3253,7 +3252,6 @@ "nameLabel": "Nome", "commandLabel": "Comando", "localCommandHint": "Use o prefixo local: para executar neste computador em vez do host do projeto; {folderVar} é o caminho absoluto da pasta e {hostVar} o nome do host remoto, ex.: {example}", - "localNeedsGlobal": "Salvo no servidor remoto, um comando local não é executado neste computador. Ative “Mostrar em todas as pastas” para salvá-lo neste computador.", "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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 5f4c57659f..9f91b89b33 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "停止:{command}", "localUnavailable": "本地命令仅在桌面应用中可用", "localRunFailed": "运行本地命令失败", - "localRemoteFolderCommand": "这条本地命令保存在远程服务器上,不能在本机运行。打开「在所有文件夹中显示」即可把它保存到本机。", "globalCommand": "所有文件夹可用", "manageDialog": { "title": "管理命令", @@ -3253,7 +3252,6 @@ "nameLabel": "名称", "commandLabel": "命令", "localCommandHint": "以 local: 开头的命令会在本机而不是项目所在主机上运行;{folderVar} 为目录的绝对路径,{hostVar} 为远程主机名,例如 {example}", - "localNeedsGlobal": "保存在远程服务器上的本地命令不会在本机运行。打开「在所有文件夹中显示」即可把它保存到本机。", "globalLabel": "在所有文件夹中显示", "globalHint": "在所有文件夹、worktree 和远程连接中都可用,保存在本机", "dragSort": "拖拽排序", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 26ee418941..d496df623f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3242,7 +3242,6 @@ "stopCommandTitle": "停止:{command}", "localUnavailable": "本機命令僅在桌面應用程式中可用", "localRunFailed": "執行本機命令失敗", - "localRemoteFolderCommand": "這條本機命令儲存在遠端伺服器上,無法在本機執行。開啟「在所有資料夾中顯示」即可將它儲存到本機。", "globalCommand": "所有資料夾可用", "manageDialog": { "title": "管理命令", @@ -3253,7 +3252,6 @@ "nameLabel": "名稱", "commandLabel": "命令", "localCommandHint": "以 local: 開頭的命令會在本機而非專案所在主機上執行;{folderVar} 為目錄的絕對路徑,{hostVar} 為遠端主機名稱,例如 {example}", - "localNeedsGlobal": "儲存在遠端伺服器上的本機命令不會在本機執行。開啟「在所有資料夾中顯示」即可將它儲存到本機。", "globalLabel": "在所有資料夾中顯示", "globalHint": "在所有資料夾、worktree 和遠端連線中都可用,儲存在本機", "dragSort": "拖曳排序", From 3b3d47aca7f5fc113221aa7c63e511d4e5d7e571 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 20 Oct 2026 23:00:19 +0800 Subject: [PATCH 10/14] feat(commands): pick where a command runs with a switch, not a local: prefix A command ran on this computer only if its text started with `local:`, so a typo silently moved it to the folder's host. The manage dialog now has a "Run on this computer" switch, and the choice is stored as data: a `run_local` column on folder commands (new migration, false for every existing row) and a `run_local` field on each global command. Text that starts with `local:` is an ordinary command again. The launcher and the dialog's list mark local commands with an icon. Older peers don't know the flag: - A client that doesn't send `includeLocal` isn't shown local commands by the HTTP list and package.json bootstrap routes. It would run their text in a terminal on the host, the wrong machine. - An older host saves a command without the flag. The dialog checks what came back. A new command or a move into the folder is taken back out before the original is touched; an edit shows what the host kept. - A legacy preflight id that names a local command gets a red light instead of running it on the host. The task settings dialog no longer copies such a command into the preflight field. --- src-tauri/src/commands/folder_commands.rs | 16 +- src-tauri/src/db/entities/folder_command.rs | 1 + ...0261020_000001_folder_command_run_local.rs | 81 +++++++++ src-tauri/src/db/migration/mod.rs | 2 + .../src/db/service/folder_command_service.rs | 64 +++++++ src-tauri/src/models/folder.rs | 3 + src-tauri/src/web/handlers/folder_commands.rs | 84 ++++++++- src-tauri/src/work_task/engine.rs | 95 +++++++++- .../layout/command-dropdown.test.tsx | 52 ++++-- src/components/layout/command-dropdown.tsx | 19 +- .../layout/command-manage-dialog.test.tsx | 172 ++++++++++++++---- .../layout/command-manage-dialog.tsx | 111 ++++++++--- .../tasks/task-settings-dialog.test.tsx | 51 +++++- src/components/tasks/task-settings-dialog.tsx | 8 +- src/i18n/messages/ar.json | 5 +- src/i18n/messages/de.json | 5 +- src/i18n/messages/en.json | 5 +- src/i18n/messages/es.json | 5 +- src/i18n/messages/fr.json | 5 +- src/i18n/messages/ja.json | 5 +- src/i18n/messages/ko.json | 5 +- src/i18n/messages/pt.json | 5 +- src/i18n/messages/zh-CN.json | 5 +- src/i18n/messages/zh-TW.json | 5 +- src/lib/api.ts | 16 +- src/lib/global-commands.test.ts | 67 +++++-- src/lib/global-commands.ts | 19 +- src/lib/local-command.test.ts | 35 ++-- src/lib/local-command.ts | 19 +- src/lib/tauri.ts | 9 +- src/lib/types.ts | 4 + 31 files changed, 821 insertions(+), 157 deletions(-) create mode 100644 src-tauri/src/db/migration/m20261020_000001_folder_command_run_local.rs 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/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..9f65e953b1 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -52,6 +52,7 @@ mod m20261015_000001_kiro_builtin_agent; mod m20261015_000001_work_task_deferred_at; mod m20261016_000001_assistant; mod m20261016_000002_conversation_assistant; +mod m20261020_000001_folder_command_run_local; mod m20261017_000002_conversation_source; pub struct Migrator; @@ -111,6 +112,7 @@ impl MigratorTrait for Migrator { Box::new(m20261015_000001_work_task_deferred_at::Migration), Box::new(m20261016_000001_assistant::Migration), Box::new(m20261016_000002_conversation_assistant::Migration), + Box::new(m20261020_000001_folder_command_run_local::Migration), Box::new(m20261017_000002_conversation_source::Migration), ] } diff --git a/src-tauri/src/db/service/folder_command_service.rs b/src-tauri/src/db/service/folder_command_service.rs index 5692b46aa9..c53e1b059e 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), }) @@ -111,6 +115,7 @@ pub async fn update( name: Option, command: Option, sort_order: Option, + run_local: Option, ) -> Result { let row = folder_command::Entity::find_by_id(id) .one(conn) @@ -127,6 +132,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 +177,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/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..cc65e6359d 100644 --- a/src-tauri/src/web/handlers/folder_commands.rs +++ b/src-tauri/src/web/handlers/folder_commands.rs @@ -14,8 +14,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 +27,7 @@ pub struct CreateFolderCommandParams { pub folder_id: i32, pub name: String, pub command: String, + pub run_local: Option, } #[derive(Deserialize)] @@ -33,6 +37,7 @@ pub struct UpdateFolderCommandParams { pub name: Option, pub command: Option, pub sort_order: Option, + pub run_local: Option, } #[derive(Deserialize)] @@ -52,15 +57,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 +86,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)) } @@ -86,6 +109,7 @@ pub async fn update_folder_command( params.name, params.command, params.sort_order, + params.run_local, ) .await .map_err(AppCommandError::from)?; @@ -119,6 +143,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 +154,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 +184,43 @@ 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]); + } + + #[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 95a8fc086b..053f2f5965 100644 --- a/src/components/layout/command-dropdown.test.tsx +++ b/src/components/layout/command-dropdown.test.tsx @@ -22,6 +22,7 @@ const h = vi.hoisted(() => ({ 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 }[], @@ -55,6 +56,7 @@ vi.mock("@/lib/api", () => ({ name: "dev", command: h.command, sort_order: 0, + run_local: h.isLocal, created_at: "", updated_at: "", }, @@ -67,13 +69,18 @@ vi.mock("@/lib/api", () => ({ })) vi.mock("./command-manage-dialog", () => ({ CommandManageDialog: () => null })) -function hostCommand(id: number, command: string): FolderCommand { +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: "", } @@ -92,6 +99,7 @@ describe("CommandDropdown", () => { 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: {} }) @@ -123,9 +131,10 @@ describe("CommandDropdown", () => { expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) - it("runs a local window's local: command on this machine, in the folder", async () => { + it("runs a local window's command on this machine, in the folder", async () => { h.remote = false - h.command = 'local: open -a Zed "$CODEG_FOLDER_PATH"' + h.command = 'open -a Zed "$CODEG_FOLDER_PATH"' + h.isLocal = true render() fireEvent.click(await screen.findByTitle("runCommandTitle")) await waitFor(() => @@ -140,8 +149,19 @@ describe("CommandDropdown", () => { expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) - it("runs a remote server's local: command on this machine like any other", async () => { - h.command = 'local: zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + 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(() => @@ -156,11 +176,12 @@ describe("CommandDropdown", () => { expect(h.create).not.toHaveBeenCalled() }) - it("runs a global local: command from a remote window, outside any folder", async () => { + it("runs a global command from a remote window on this machine, outside any folder", async () => { h.hostCommands = [] createGlobalCommand( "zed", - 'local: zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + 'zed "ssh://$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"', + true ) render() fireEvent.click(await screen.findByTitle("runCommandTitle")) @@ -180,7 +201,7 @@ describe("CommandDropdown", () => { // The backend checks a remote folder's path before cmd.exe splices it in. h.baseUrl = "not a url" h.hostCommands = [] - createGlobalCommand("open", "local: echo %CODEG_FOLDER_PATH%") + createGlobalCommand("open", "echo %CODEG_FOLDER_PATH%", true) render() fireEvent.click(await screen.findByTitle("runCommandTitle")) await waitFor(() => @@ -193,10 +214,11 @@ describe("CommandDropdown", () => { ) }) - it("refuses a local: command outside the desktop app", async () => { + it("refuses a command set to run on this computer outside the desktop app", async () => { h.desktop = false h.remote = false - h.command = "local: zed ." + h.command = "zed ." + h.isLocal = true render() fireEvent.click(await screen.findByTitle("runCommandTitle")) await waitFor(() => @@ -208,8 +230,8 @@ describe("CommandDropdown", () => { 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, "local: echo from-the-host")] - createGlobalCommand("mine", "local: echo mine") + 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)) @@ -223,7 +245,7 @@ describe("CommandDropdown", () => { it("keeps a global command's run in one folder out of the others", async () => { h.hostCommands = [] - createGlobalCommand("dev", "pnpm dev") + createGlobalCommand("dev", "pnpm dev", false) h.create.mockImplementationOnce(async () => "terminal-a") const { rerender } = render() fireEvent.click(await screen.findByTitle("runCommandTitle")) @@ -255,7 +277,7 @@ describe("CommandDropdown", () => { await screen.findByText("addCommand") // Another window writes the store; this one only hears the event. - createGlobalCommand("zed", "local: zed .") + createGlobalCommand("zed", "zed .", true) act(() => { window.dispatchEvent( new StorageEvent("storage", { key: GLOBAL_COMMANDS_STORAGE_KEY }) @@ -274,7 +296,7 @@ describe("CommandDropdown", () => { it("drops global commands when another window clears the storage", async () => { h.hostCommands = [] - createGlobalCommand("zed", "local: zed .") + createGlobalCommand("zed", "zed .", true) render() await screen.findByTitle("runCommandTitle") diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index ec742c1b0c..3484ec168e 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useCallback, useMemo, useRef } from "react" -import { Globe, 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" @@ -27,7 +27,7 @@ import { listGlobalCommands, withoutGlobalIds, } from "@/lib/global-commands" -import { getRemoteHostName, parseLocalCommand } from "@/lib/local-command" +import { getRemoteHostName, isLocalCommand } from "@/lib/local-command" import { isDesktop, isRemoteDesktopMode } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { @@ -198,10 +198,9 @@ export function CommandDropdown() { async (cmd: FolderCommand) => { if (!folderPath) return - // `local:` commands launch on this machine, detached — no terminal tab - // and no running state to track. - const localCommand = parseLocalCommand(cmd.command) - if (localCommand !== null) { + // 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")) @@ -210,7 +209,7 @@ export function CommandDropdown() { const remote = isRemoteDesktopMode() try { await runLocalCommand( - localCommand, + 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. @@ -365,6 +364,12 @@ export function CommandDropdown() { > {cmd.name} + {isLocalCommand(cmd) ? ( + + ) : null} {isGlobalCommandId(cmd.id) ? ( ({ const md = enMessages.Folder.commandDropdown.manageDialog -function hostCommand(id: number, name: string, command: string): FolderCommand { +/** `runLocal` left out: a row from a host older than the flag. */ +function hostCommand( + id: number, + name: string, + command: string, + runLocal?: boolean +): FolderCommand { return { id, folder_id: 1, name, command, sort_order: 0, + ...(runLocal === undefined ? {} : { run_local: runLocal }), created_at: "", updated_at: "", } @@ -55,6 +62,10 @@ function renderDialog() { return { onChanged } } +const globalSwitch = () => screen.getByRole("switch", { name: md.globalLabel }) +const runLocalSwitch = () => + screen.getByRole("switch", { name: md.runLocalLabel }) + async function startNewCommand(name: string, command: string) { fireEvent.click(await screen.findByRole("button", { name: md.newCommand })) fireEvent.change(screen.getByLabelText(md.nameLabel), { @@ -103,10 +114,10 @@ describe("CommandManageDialog", () => { it("lists the host's commands and this computer's, but no impostor", async () => { // Global commands have negative ids; the host's database never does. h.hostCommands = [ - hostCommand(3, "build", "pnpm build"), - hostCommand(-1, "impostor", "local: echo x"), + hostCommand(3, "build", "pnpm build", false), + hostCommand(-1, "impostor", "echo x", true), ] - createGlobalCommand("mine", "local: echo mine") + createGlobalCommand("mine", "echo mine", true) renderDialog() expect(await screen.findByText("build")).toBeInTheDocument() expect(screen.getByText("mine")).toBeInTheDocument() @@ -117,7 +128,7 @@ describe("CommandManageDialog", () => { h.platform = "windows" renderDialog() const example = await screen.findByText( - 'local: zed "ssh://user@%CODEG_REMOTE_HOST%%CODEG_FOLDER_PATH%"' + 'zed "ssh://user@%CODEG_REMOTE_HOST%%CODEG_FOLDER_PATH%"' ) expect(example.tagName).toBe("CODE") expect(example).toHaveAttribute("dir", "ltr") @@ -128,47 +139,136 @@ describe("CommandManageDialog", () => { renderDialog() expect( await screen.findByText( - 'local: zed "ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' + 'zed "ssh://user@$CODEG_REMOTE_HOST$CODEG_FOLDER_PATH"' ) ).toBeInTheDocument() expect(screen.getByText("$CODEG_REMOTE_HOST").tagName).toBe("CODE") }) - it("leaves out the local-command hint outside the desktop app", async () => { + it("leaves out running on this computer outside the desktop app", async () => { h.desktop = false renderDialog() await screen.findByRole("button", { name: md.newCommand }) + expect( + screen.queryByRole("switch", { name: md.runLocalLabel }) + ).not.toBeInTheDocument() expect(screen.queryByText("$CODEG_FOLDER_PATH")).not.toBeInTheDocument() }) it("keeps a command shown in every folder on this computer", async () => { const { onChanged } = renderDialog() - await startNewCommand("zed", "local: zed .") - fireEvent.click(screen.getByRole("switch")) + await startNewCommand("zed", "zed .") + fireEvent.click(runLocalSwitch()) + fireEvent.click(globalSwitch()) fireEvent.click(screen.getByRole("button", { name: "Save" })) await waitFor(() => - expect(listGlobalCommands().map((c) => [c.name, c.command])).toEqual([ - ["zed", "local: zed ."], - ]) + expect( + listGlobalCommands().map((c) => [c.name, c.command, c.run_local]) + ).toEqual([["zed", "zed .", true]]) ) expect(h.create).not.toHaveBeenCalled() expect(onChanged).toHaveBeenCalled() }) + it("saves a folder command to run on this computer", async () => { + h.create.mockResolvedValue(hostCommand(7, "zed", "zed .", true)) + renderDialog() + await startNewCommand("zed", "zed .") + expect(runLocalSwitch()).not.toBeChecked() + fireEvent.click(runLocalSwitch()) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + await waitFor(() => + expect( + document.querySelector( + `[data-folder-command-id="7"] svg[aria-label="${md.runLocalLabel}"]` + ) + ).not.toBeNull() + ) + expect(h.create).toHaveBeenCalledWith(1, "zed", "zed .", true) + expect(runLocalSwitch()).toBeChecked() + expect(screen.getByLabelText(md.commandLabel)).toHaveValue("zed .") + }) + + it("shows whether a host's command runs on this computer", async () => { + h.hostCommands = [ + hostCommand(3, "zed", "zed .", true), + hostCommand(4, "dev", "pnpm dev", false), + ] + renderDialog() + await screen.findByText("dev") + expect(runLocalSwitch()).toBeChecked() + fireEvent.click(screen.getByText("dev")) + await waitFor(() => expect(runLocalSwitch()).not.toBeChecked()) + expect( + document.querySelector( + `[data-folder-command-id="4"] svg[aria-label="${md.runLocalLabel}"]` + ) + ).toBeNull() + }) + it("moves a folder command to every folder: a copy here first, then the host's goes", async () => { - h.hostCommands = [hostCommand(3, "zed", "local: zed .")] + h.hostCommands = [hostCommand(3, "zed", "zed .", true)] h.remove.mockImplementation(async () => { // A failed delete must leave the copy, never neither. expect(listGlobalCommands()).toHaveLength(1) }) renderDialog() await screen.findByText("zed") - fireEvent.click(screen.getByRole("switch")) + fireEvent.click(globalSwitch()) fireEvent.click(screen.getByRole("button", { name: "Save" })) await waitFor(() => expect(h.remove).toHaveBeenCalledWith(3)) - expect(listGlobalCommands().map((c) => [c.name, c.command])).toEqual([ - ["zed", "local: zed ."], - ]) + expect( + listGlobalCommands().map((c) => [c.name, c.command, c.run_local]) + ).toEqual([["zed", "zed .", true]]) + }) + + const notKept = `${md.saveFailed}: ${md.runLocalNotKept}` + + it("takes back a new command an older host saved without running on this computer", async () => { + h.create.mockResolvedValue(hostCommand(7, "zed", "zed .")) + renderDialog() + await startNewCommand("zed", "zed .") + fireEvent.click(runLocalSwitch()) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + expect(await screen.findByText(notKept)).toBeInTheDocument() + expect(h.remove).toHaveBeenCalledWith(7) + // The form keeps what was asked for, to save another way. + expect(screen.getByLabelText(md.nameLabel)).toHaveValue("zed") + expect(runLocalSwitch()).toBeChecked() + expect(screen.queryByText("zed .")).not.toBeInTheDocument() + }) + + it("keeps this computer's command when an older host can't take it", async () => { + createGlobalCommand("zed", "zed .", true) + h.create.mockResolvedValue(hostCommand(7, "zed", "zed .")) + renderDialog() + await screen.findByText("zed") + // Selected first, as the only command: turn "every folder" off. + fireEvent.click(globalSwitch()) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + expect(await screen.findByText(notKept)).toBeInTheDocument() + expect(h.create).toHaveBeenCalledWith(1, "zed", "zed .", true) + expect(h.remove).toHaveBeenCalledWith(7) + expect( + listGlobalCommands().map((c) => [c.id, c.command, c.run_local]) + ).toEqual([[-1, "zed .", true]]) + }) + + it("shows what an older host kept of an edit that asked to run on this computer", async () => { + h.hostCommands = [hostCommand(3, "zed", "zed .")] + h.update.mockResolvedValue(hostCommand(3, "zed", "zed -n .")) + const { onChanged } = renderDialog() + await screen.findByText("zed .") + fireEvent.change(screen.getByLabelText(md.commandLabel), { + target: { value: "zed -n ." }, + }) + fireEvent.click(runLocalSwitch()) + fireEvent.click(screen.getByRole("button", { name: "Save" })) + expect(await screen.findByText(notKept)).toBeInTheDocument() + expect(h.update).toHaveBeenCalledWith(3, "zed", "zed -n .", undefined, true) + expect(screen.getByText("zed -n .")).toBeInTheDocument() + expect(runLocalSwitch()).not.toBeChecked() + expect(onChanged).toHaveBeenCalled() }) const refusal = new RegExp( @@ -176,16 +276,16 @@ describe("CommandManageDialog", () => { ) it("refuses a host create that comes back with a global command's id", async () => { - createGlobalCommand("mine", "pnpm mine") - h.create.mockResolvedValue(hostCommand(-1, "x", "local: echo injected")) + createGlobalCommand("mine", "pnpm mine", false) + h.create.mockResolvedValue(hostCommand(-1, "x", "echo injected", true)) renderDialog() await screen.findByText("mine") await startNewCommand("dev", "pnpm dev") fireEvent.click(screen.getByRole("button", { name: "Save" })) expect(await screen.findByText(refusal)).toBeInTheDocument() - expect(h.create).toHaveBeenCalledWith(1, "dev", "pnpm dev") + expect(h.create).toHaveBeenCalledWith(1, "dev", "pnpm dev", false) // The form doesn't take the host's entry for this computer's command. - expect(screen.getByRole("switch")).not.toBeChecked() + expect(globalSwitch()).not.toBeChecked() expect(screen.getByLabelText(md.commandLabel)).toHaveValue("pnpm dev") expect(listGlobalCommands().map((c) => [c.id, c.command])).toEqual([ [-1, "pnpm mine"], @@ -193,9 +293,9 @@ describe("CommandManageDialog", () => { }) it("refuses a host update that comes back with a global command's id", async () => { - createGlobalCommand("mine", "pnpm mine") - h.hostCommands = [hostCommand(3, "dev", "pnpm dev")] - h.update.mockResolvedValue(hostCommand(-1, "x", "local: echo injected")) + createGlobalCommand("mine", "pnpm mine", false) + h.hostCommands = [hostCommand(3, "dev", "pnpm dev", false)] + h.update.mockResolvedValue(hostCommand(-1, "x", "echo injected", true)) renderDialog() await screen.findByText("dev") fireEvent.change(screen.getByLabelText(md.commandLabel), { @@ -203,8 +303,14 @@ describe("CommandManageDialog", () => { }) fireEvent.click(screen.getByRole("button", { name: "Save" })) expect(await screen.findByText(refusal)).toBeInTheDocument() - expect(h.update).toHaveBeenCalledWith(3, "dev", "pnpm dev --port 3001") - expect(screen.getByRole("switch")).not.toBeChecked() + expect(h.update).toHaveBeenCalledWith( + 3, + "dev", + "pnpm dev --port 3001", + undefined, + false + ) + expect(globalSwitch()).not.toBeChecked() expect(listGlobalCommands().map((c) => [c.id, c.command])).toEqual([ [-1, "pnpm mine"], ]) @@ -212,8 +318,8 @@ describe("CommandManageDialog", () => { it("keeps a save's failure on its own command, whatever is clicked meanwhile", async () => { h.hostCommands = [ - hostCommand(3, "dev", "pnpm dev"), - hostCommand(4, "test", "pnpm test"), + hostCommand(3, "dev", "pnpm dev", false), + hostCommand(4, "test", "pnpm test", false), ] let fail: (err: Error) => void = () => {} h.update.mockImplementation( @@ -242,16 +348,16 @@ describe("CommandManageDialog", () => { }) it("keeps a global command whose move to the folder the host answers badly", async () => { - createGlobalCommand("mine", "pnpm mine") - h.create.mockResolvedValue(hostCommand(-2, "x", "local: echo injected")) + createGlobalCommand("mine", "pnpm mine", false) + h.create.mockResolvedValue(hostCommand(-2, "x", "echo injected", true)) renderDialog() await screen.findByText("mine") // Selected first, as the only command: turn "every folder" off. - expect(screen.getByRole("switch")).toBeChecked() - fireEvent.click(screen.getByRole("switch")) + expect(globalSwitch()).toBeChecked() + fireEvent.click(globalSwitch()) fireEvent.click(screen.getByRole("button", { name: "Save" })) expect(await screen.findByText(refusal)).toBeInTheDocument() - expect(h.create).toHaveBeenCalledWith(1, "mine", "pnpm mine") + expect(h.create).toHaveBeenCalledWith(1, "mine", "pnpm mine", false) expect(listGlobalCommands().map((c) => [c.id, c.command])).toEqual([ [-1, "pnpm mine"], ]) diff --git a/src/components/layout/command-manage-dialog.tsx b/src/components/layout/command-manage-dialog.tsx index c606f27064..7bdd4877ec 100644 --- a/src/components/layout/command-manage-dialog.tsx +++ b/src/components/layout/command-manage-dialog.tsx @@ -9,7 +9,15 @@ import { type PointerEvent, type ReactNode, } from "react" -import { GripVertical, Globe, Loader2, Plus, Save, Trash2 } from "lucide-react" +import { + GripVertical, + Globe, + Loader2, + Monitor, + Plus, + Save, + Trash2, +} from "lucide-react" import { Reorder, useDragControls } from "motion/react" import { useTranslations } from "next-intl" import { detectPlatform } from "@/hooks/use-platform" @@ -31,6 +39,7 @@ import { updateGlobalCommand, withoutGlobalIds, } from "@/lib/global-commands" +import { isLocalCommand } from "@/lib/local-command" import { isDesktop } from "@/lib/transport" import type { FolderCommand } from "@/lib/types" import { Button } from "@/components/ui/button" @@ -71,8 +80,8 @@ const hintLiteral = (chunks: ReactNode) => ( ) -/** The variables a `local:` command reads, spelled the way the shell it runs - * in on this machine expects: cmd.exe on Windows, a POSIX shell elsewhere. */ +/** The variables a command run on this computer reads, spelled the way the + * shell it runs in here expects: cmd.exe on Windows, a POSIX shell elsewhere. */ function localCommandHintValues() { const windows = detectPlatform() === "windows" const variable = (name: string) => (windows ? `%${name}%` : `$${name}`) @@ -81,7 +90,7 @@ function localCommandHintValues() { return { folderVar, hostVar, - example: `local: zed "ssh://user@${hostVar}${folderVar}"`, + example: `zed "ssh://user@${hostVar}${folderVar}"`, } } @@ -98,6 +107,9 @@ interface Draft { command: string /** Shown in every folder (stored on this client) rather than this one. */ global: boolean + /** Run on this computer, detached, rather than in a terminal on the + * folder's host. */ + runLocal: boolean } interface CommandReorderItemProps { @@ -116,6 +128,7 @@ const EMPTY_DRAFT: Draft = { name: "", command: "", global: false, + runLocal: false, } function clamp(value: number, min: number, max: number): number { @@ -309,6 +322,7 @@ export function CommandManageDialog({ name: selected.name, command: selected.command, global: isGlobalCommandId(selected.id), + runLocal: isLocalCommand(selected), }) }, [selected]) @@ -403,21 +417,50 @@ export function CommandManageDialog({ try { const name = draft.name.trim() const command = draft.command.trim() + const { runLocal } = draft + // An older folder host doesn't know "run on this computer": it stores + // the command without it, to run in its own terminal. + const notKept = () => new Error(t("runLocalNotKept")) let saved: FolderCommand if (previousId !== null && !scopeChanged) { - saved = isGlobalCommandId(previousId) - ? updateGlobalCommand(previousId, name, command) - : requireHostCommand( - await updateFolderCommand(previousId, name, command) + if (isGlobalCommandId(previousId)) { + saved = updateGlobalCommand(previousId, name, command, runLocal) + } else { + const updated = requireHostCommand( + await updateFolderCommand( + previousId, + name, + command, + undefined, + runLocal + ) + ) + if (runLocal && !isLocalCommand(updated)) { + // It kept the rest of the edit: show what it stored. + setCommands((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)) ) + onChanged() + throw notKept() + } + saved = updated + } } else { // New, or moving between this folder and every folder: create it in // its new home first, so a failure there loses nothing. - saved = draft.global - ? createGlobalCommand(name, command) - : requireHostCommand( - await createFolderCommand(folderId, name, command) - ) + if (draft.global) { + saved = createGlobalCommand(name, command, runLocal) + } else { + const created = requireHostCommand( + await createFolderCommand(folderId, name, command, runLocal) + ) + if (runLocal && !isLocalCommand(created)) { + // Take it back out, so the save adds nothing. + await deleteFolderCommand(created.id).catch(() => {}) + throw notKept() + } + saved = created + } if (previousId !== null) { if (isGlobalCommandId(previousId)) deleteGlobalCommand(previousId) else await deleteFolderCommand(previousId) @@ -439,6 +482,7 @@ export function CommandManageDialog({ name: saved.name, command: saved.command, global: isGlobalCommandId(saved.id), + runLocal: isLocalCommand(saved), }) onChanged() } catch (err) { @@ -580,6 +624,12 @@ export function CommandManageDialog({ {command.name} + {isLocalCommand(command) ? ( + + ) : null} {isGlobalCommandId(command.id) ? ( - {/* Local commands run only from the desktop app. */} - {isDesktop() ? ( -

- {t.rich("localCommandHint", { - ...localHintValues, - code: hintLiteral, - })} -

- ) : null}
+ {/* Only the desktop app runs a command on its own machine. */} + {isDesktop() ? ( +
+
+ +

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

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