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 761b67627f..29ead34ab7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -42,6 +42,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 da95f8d067..a2e9474b23 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -101,7 +101,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, @@ -2101,6 +2101,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 7ab7f9fdf9..b9791adc62 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3135,6 +3135,9 @@ "manageCommands": "إدارة الأوامر...", "runCommandTitle": "تشغيل: {command}", "stopCommandTitle": "إيقاف: {command}", + "localUnavailable": "الأوامر المحلية متاحة فقط في تطبيق سطح المكتب", + "localRunFailed": "فشل تشغيل الأمر المحلي", + "globalCommand": "متاح في كل المجلدات", "manageDialog": { "title": "إدارة الأوامر", "empty": "لا توجد أوامر بعد", @@ -3143,6 +3146,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 7af3d6c2a1..08019bf13a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3135,6 +3135,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", @@ -3143,6 +3146,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 87a63583d8..88dcbb4681 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3135,6 +3135,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", @@ -3143,6 +3146,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 4333b4ae07..5471f315fc 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3135,6 +3135,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", @@ -3143,6 +3146,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 f09ad2b1fa..fd5b0d6e10 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3135,6 +3135,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", @@ -3143,6 +3146,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 3d7b17a00a..5634938001 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3135,6 +3135,9 @@ "manageCommands": "コマンドを管理...", "runCommandTitle": "実行: {command}", "stopCommandTitle": "停止: {command}", + "localUnavailable": "ローカルコマンドはデスクトップアプリでのみ利用できます", + "localRunFailed": "ローカルコマンドの実行に失敗しました", + "globalCommand": "すべてのフォルダーで利用可能", "manageDialog": { "title": "コマンド管理", "empty": "コマンドはまだありません", @@ -3143,6 +3146,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 7e200c5076..d6488e3d76 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3135,6 +3135,9 @@ "manageCommands": "명령 관리...", "runCommandTitle": "실행: {command}", "stopCommandTitle": "중지: {command}", + "localUnavailable": "로컬 명령은 데스크톱 앱에서만 사용할 수 있습니다", + "localRunFailed": "로컬 명령 실행 실패", + "globalCommand": "모든 폴더에서 사용 가능", "manageDialog": { "title": "명령 관리", "empty": "아직 명령이 없습니다", @@ -3143,6 +3146,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 54327620fb..8164b58e11 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3135,6 +3135,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", @@ -3143,6 +3146,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 f5a3f01eb5..9674736665 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3135,6 +3135,9 @@ "manageCommands": "管理命令...", "runCommandTitle": "运行:{command}", "stopCommandTitle": "停止:{command}", + "localUnavailable": "本地命令仅在桌面应用中可用", + "localRunFailed": "运行本地命令失败", + "globalCommand": "所有文件夹可用", "manageDialog": { "title": "管理命令", "empty": "暂无命令", @@ -3143,6 +3146,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 15c51a9a1b..8e3c3c380d 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3135,6 +3135,9 @@ "manageCommands": "管理命令...", "runCommandTitle": "執行:{command}", "stopCommandTitle": "停止:{command}", + "localUnavailable": "本機命令僅在桌面應用程式中可用", + "localRunFailed": "執行本機命令失敗", + "globalCommand": "所有資料夾可用", "manageDialog": { "title": "管理命令", "empty": "暫無命令", @@ -3143,6 +3146,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 b3f179f55f..3a12cf58cf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3389,6 +3389,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 + } +}