diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6a1b1c62e7471..eb7e910f23cdd 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -47,6 +47,7 @@ import { checkSystemPermission, pasteAsText, probeRemoteEditors, + openRemoteTerminal, revealWindow, pickFolder, pickProjectFavicon, @@ -157,6 +158,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(revealWindow); yield* ipc.handle(syncHtmlFullscreen); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(openRemoteTerminal); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..01de981ce5eb5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const READ_MEDIA_CHANNEL = "desktop:read-media"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; +export const OPEN_REMOTE_TERMINAL_CHANNEL = "desktop:open-remote-terminal"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index e0eea5c74c9ed..f33470b76fe02 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,6 +3,7 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopMediaReadSchema, + DesktopOpenRemoteTerminalInputSchema, DesktopThemeSchema, EDITORS, EditorId, @@ -14,6 +15,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -43,6 +45,7 @@ import * as MacPermissions from "../../permissions/MacPermissions.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; +import { openRemoteTerminal as launchRemoteTerminal } from "../../shell/openRemoteTerminal.ts"; import { extractDistroFromUncPath, resolveWslPickFolderDefaultPath, @@ -407,6 +410,16 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const openRemoteTerminal = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_REMOTE_TERMINAL_CHANNEL, + payload: DesktopOpenRemoteTerminalInputSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openRemoteTerminal")(function* (input) { + // Own Node services so installing IPC handlers does not demand them at bootstrap. + return yield* launchRemoteTerminal(input).pipe(Effect.provide(NodeServices.layer)); + }), +}); + export const pasteAsText = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..35f417bd94334 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -228,6 +228,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + openRemoteTerminal: (input) => + ipcRenderer.invoke(IpcChannels.OPEN_REMOTE_TERMINAL_CHANNEL, input), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts new file mode 100644 index 0000000000000..2af34611aec3b --- /dev/null +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -0,0 +1,277 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + buildRemoteSshTerminalRemoteCommand, + DesktopOpenRemoteTerminalInputSchema, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { + linuxRemoteTerminalArgs, + openRemoteTerminal, + powerShellStartRemoteArgs, + serializeWindowsSshArgumentList, + windowsTerminalRemoteArgs, +} from "./openRemoteTerminal.ts"; + +const decodePowerShellEncodedCommand = (encoded: string): string => { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new TextDecoder("utf-16le").decode(bytes); +}; + +const captureSpawner = (spawned: Array<{ command: string; args: ReadonlyArray }>) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) { + spawned.push({ + command: command.command, + args: command.args.map(String), + }); + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + +describe("openRemoteTerminal", () => { + it.effect("opens Terminal.app via osascript with an ssh -t login shell", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "sol", + username: "theo", + absolutePath: "/work/project", + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, {}), + ); + assert.isTrue(opened); + assert.equal(spawned.length, 1); + assert.equal(spawned[0]?.command, "osascript"); + const script = spawned[0]?.args.join(" ") ?? ""; + assert.match(script, /tell application "Terminal" to do script/); + // Each argv is shell-quoted: 'ssh' '-t' '--' 'theo@sol' 'exec /bin/sh -c ...' + assert.match(script, /'ssh' '-t' '--' 'theo@sol'/); + assert.match(script, /exec \/bin\/sh -c/); + assert.match(script, /\/work\/project/); + assert.match(script, /\$\{SHELL:-\/bin\/sh\}/); + }), + ); + + it.effect("passes -p before -- when the profile sets a non-default SSH port", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "sol", + username: "theo", + port: 2222, + absolutePath: "/work/project", + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, {}), + ); + assert.isTrue(opened); + const script = spawned[0]?.args.join(" ") ?? ""; + assert.match(script, /'ssh' '-t' '-p' '2222' '--' 'theo@sol'/); + }), + ); + + it.effect("falls back to PowerShell Start-Process when Windows Terminal is missing", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "box", + absolutePath: "/home/theo/app/README.md", + isFile: true, + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "win32"), + // Empty PATH so wt.exe is unavailable and the PowerShell fallback runs. + Effect.provideService(HostProcess.Environment, { + PATH: "", + Path: "", + path: "", + SYSTEMROOT: String.raw`C:\Windows`, + }), + ); + assert.isTrue(opened); + assert.equal( + spawned[0]?.command, + String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, + ); + assert.deepEqual(spawned[0]?.args.slice(0, 5), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const script = decodePowerShellEncodedCommand(spawned[0]?.args[5] ?? ""); + assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); + assert.match(script, /ArgumentList '-t -- box "/); + assert.match(script, /exec \/bin\/sh -c/); + assert.match(script, /\/home\/theo\/app/); + }), + ); + + it.effect("keeps ampersand destinations inside the Windows ArgumentList string", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "box&calc&rem", + absolutePath: "/home/%USERPROFILE%/app", + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { + PATH: "", + Path: "", + path: "", + SYSTEMROOT: String.raw`C:\Windows`, + }), + ); + assert.isTrue(opened); + const script = decodePowerShellEncodedCommand(spawned[0]?.args[5] ?? ""); + assert.match(script, /ArgumentList '-t -- box&calc&rem "/); + assert.match(script, /%USERPROFILE%/); + }), + ); +}); + +describe("linuxRemoteTerminalArgs", () => { + it("runs SSH through xfce4-terminal -x", () => { + assert.deepEqual(linuxRemoteTerminalArgs("xfce4-terminal", "ssh", ["-t", "--", "box", "cmd"]), [ + "-x", + "ssh", + "-t", + "--", + "box", + "cmd", + ]); + }); + + it("keeps gnome-terminal option terminator", () => { + assert.deepEqual(linuxRemoteTerminalArgs("gnome-terminal", "ssh", ["-t", "--", "box"]), [ + "--", + "ssh", + "-t", + "--", + "box", + ]); + }); +}); + +describe("Windows launch argv", () => { + it("opens a new Windows Terminal window and escapes semicolon paths", () => { + assert.deepEqual( + windowsTerminalRemoteArgs("ssh.exe", [ + "-t", + "--", + "box", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/theo/weird;dir'", + ]), + [ + "-w", + "-1", + "nt", + "--", + "ssh.exe", + "-t", + "--", + "box", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/theo/weird\\;dir'", + ], + ); + }); + + it("quotes spaces, quotes, and backslashes for Start-Process ArgumentList", () => { + const remoteCommand = buildRemoteSshTerminalRemoteCommand(String.raw`/work/my "proj"\path`); + const serialized = serializeWindowsSshArgumentList(["-t", "--", "box", remoteCommand]); + assert.equal( + serialized, + `-t -- box "exec /bin/sh -c 'cd -- \\"$1\\" && exec \\"\${SHELL:-/bin/sh}\\" -l' sh '/work/my \\"proj\\"\\path'"`, + ); + + const args = powerShellStartRemoteArgs("ssh.exe", ["-t", "--", "box&calc&rem", remoteCommand]); + const script = decodePowerShellEncodedCommand(args[5] ?? ""); + assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); + assert.match(script, /ArgumentList '-t -- box&calc&rem "/); + assert.match(script, /my \\"proj\\"\\path/); + }); + + it("keeps smart single quotes inside PowerShell string literals", () => { + const host = "box\u2019 ; Start-Process calc; #"; + const args = powerShellStartRemoteArgs("ssh.exe", ["-t", "--", host, "remote"]); + const script = decodePowerShellEncodedCommand(args[5] ?? ""); + assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); + // Typographic ’ is doubled so it cannot close the ArgumentList literal. + assert.match(script, /ArgumentList '-t -- "box\u2019\u2019 ; Start-Process calc; #" remote'/); + assert.isFalse(script.includes(`box\u2019 ; Start-Process`)); + }); +}); + +describe("DesktopOpenRemoteTerminalInputSchema", () => { + const decode = Schema.decodeUnknownExit(DesktopOpenRemoteTerminalInputSchema); + + it("accepts ordinary host and username segments", () => { + assert.isTrue( + Exit.isSuccess( + decode({ + host: "sol", + username: "theo", + absolutePath: "/work/project", + }), + ), + ); + }); + + it("rejects a leading-dash host that would look like an SSH option", () => { + assert.isTrue( + Exit.isFailure( + decode({ + host: "-oProxyCommand=touch,/tmp/pwned", + absolutePath: "/work/project", + }), + ), + ); + }); + + it("rejects a leading-dash username", () => { + assert.isTrue( + Exit.isFailure( + decode({ + host: "sol", + username: "-oProxyCommand=touch,/tmp/pwned", + absolutePath: "/work/project", + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts new file mode 100644 index 0000000000000..46ffa34831743 --- /dev/null +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -0,0 +1,218 @@ +import { + buildRemoteSshTerminalDestination, + buildRemoteSshTerminalRemoteCommand, + remoteTerminalWorkingDirectory, + type DesktopOpenRemoteTerminalInput, +} from "@t3tools/contracts"; +import { resolveSshCommand } from "@t3tools/ssh/command"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isCommandAvailable } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Base64 from "effect/encoding/Base64"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as CommandLine from "effect/process/CommandLine"; + +const LINUX_TERMINAL_COMMANDS = [ + "xdg-terminal-exec", + "x-terminal-emulator", + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "kitty", + "alacritty", + "foot", + "xterm", +] as const; + +const DETACHED_IGNORE_STDIO = { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +} as const; + +const POWERSHELL_ATTACHED_STDIO = { + // Detached Windows PowerShell exits 0 without executing the encoded command. + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +} as const; + +const POWERSHELL_ARGUMENTS_PREFIX = [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", +] as const; + +const quoteSh = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`; + +const escapeAppleScriptString = (value: string): string => + value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + +/** wt.exe treats `;` as a command separator even inside quoted argv. */ +const escapeWindowsTerminalArg = (value: string): string => value.replaceAll(";", "\\;"); + +/** Doubles ASCII and typographic single quotes so they cannot end a PS literal. */ +const escapePowerShellStringLiteral = (value: string): string => + Result.getOrThrow(CommandLine.quote(value, { format: "powershell" })); + +const encodeUtf16LeBase64 = (input: string): string => { + const bytes = new Uint8Array(input.length * 2); + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + bytes[index * 2] = code & 0xff; + bytes[index * 2 + 1] = code >>> 8; + } + return Base64.encode(bytes); +}; + +const resolvePowerShellPath = (env: NodeJS.ProcessEnv): string => + `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + +export const windowsTerminalRemoteArgs = ( + sshCommand: string, + sshArgs: ReadonlyArray, +): ReadonlyArray => [ + // `-w -1` opens a new window; `-w 0` would reuse the last focused window. + "-w", + "-1", + "nt", + "--", + escapeWindowsTerminalArg(sshCommand), + ...sshArgs.map(escapeWindowsTerminalArg), +]; + +/** + * Start-Process joins ArgumentList array elements with spaces and does not + * re-quote them. Serialize once for CommandLineToArgvW, then pass that string + * as a single ArgumentList value so spaces/quotes/backslashes survive. + */ +export const serializeWindowsSshArgumentList = (sshArgs: ReadonlyArray): string => + sshArgs.map((arg) => Result.getOrThrow(CommandLine.quote(arg, { format: "windows" }))).join(" "); + +/** Avoids cmd.exe parsing so metacharacters in host/path stay SSH argv. */ +export const powerShellStartRemoteArgs = ( + sshCommand: string, + sshArgs: ReadonlyArray, +): ReadonlyArray => { + const argumentList = escapePowerShellStringLiteral(serializeWindowsSshArgumentList(sshArgs)); + const script = `$ProgressPreference = 'SilentlyContinue'; Start-Process -FilePath ${escapePowerShellStringLiteral(sshCommand)} -ArgumentList ${argumentList}`; + return [...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(script)]; +}; + +const launchAndUnref = Effect.fn("desktop.openRemoteTerminal.launchAndUnref")(function* ( + command: string, + args: ReadonlyArray, + options: ChildProcess.CommandOptions = DETACHED_IGNORE_STDIO, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* spawner.spawn(ChildProcess.make(command, args, options)).pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + ); +}); + +export const linuxRemoteTerminalArgs = ( + command: string, + sshCommand: string, + sshArgs: ReadonlyArray, +): ReadonlyArray => { + switch (command) { + case "gnome-terminal": + case "kgx": + return ["--", sshCommand, ...sshArgs]; + case "xfce4-terminal": + // `--` only ends Xfce options; `-x` is required to run a command. + return ["-x", sshCommand, ...sshArgs]; + default: + return ["-e", sshCommand, ...sshArgs]; + } +}; + +const resolveLinuxTerminalCommand = Effect.fn("desktop.openRemoteTerminal.resolveLinuxTerminal")( + function* (env: NodeJS.ProcessEnv) { + for (const command of LINUX_TERMINAL_COMMANDS) { + if (yield* isCommandAvailable(command, { env })) return command; + } + return undefined; + }, +); + +const launchDarwin = Effect.fn("desktop.openRemoteTerminal.launchDarwin")(function* ( + sshCommand: string, + sshArgs: ReadonlyArray, +) { + const shellLine = [sshCommand, ...sshArgs].map(quoteSh).join(" "); + yield* launchAndUnref("osascript", [ + "-e", + `tell application "Terminal" to do script "${escapeAppleScriptString(shellLine)}"`, + "-e", + 'tell application "Terminal" to activate', + ]); +}); + +const launchWindows = Effect.fn("desktop.openRemoteTerminal.launchWindows")(function* ( + sshCommand: string, + sshArgs: ReadonlyArray, + env: NodeJS.ProcessEnv, +) { + if (yield* isCommandAvailable("wt.exe", { env })) { + yield* launchAndUnref("wt.exe", windowsTerminalRemoteArgs(sshCommand, sshArgs)); + return; + } + yield* launchAndUnref( + resolvePowerShellPath(env), + powerShellStartRemoteArgs(sshCommand, sshArgs), + POWERSHELL_ATTACHED_STDIO, + ); +}); + +const launchLinux = Effect.fn("desktop.openRemoteTerminal.launchLinux")(function* ( + sshCommand: string, + sshArgs: ReadonlyArray, + env: NodeJS.ProcessEnv, +) { + const terminal = yield* resolveLinuxTerminalCommand(env); + if (terminal === undefined) return false; + yield* launchAndUnref(terminal, linuxRemoteTerminalArgs(terminal, sshCommand, sshArgs)); + return true; +}); + +/** Spawns a local system terminal that SSHs to the remote working directory. */ +export const openRemoteTerminal = Effect.fn("desktop.openRemoteTerminal")(function* ( + input: DesktopOpenRemoteTerminalInput, +) { + const platform = yield* HostProcess.Platform; + const env = yield* HostProcess.Environment; + const directory = remoteTerminalWorkingDirectory(input.absolutePath, input.isFile === true); + const destination = buildRemoteSshTerminalDestination(input.host, input.username); + const remoteCommand = buildRemoteSshTerminalRemoteCommand(directory); + const sshCommand = yield* resolveSshCommand; + // `-p` before `--`; `--` ends option parsing so a leading-dash destination stays a hostname. + const sshArgs = [ + "-t", + ...(input.port === undefined ? [] : (["-p", String(input.port)] as const)), + "--", + destination, + remoteCommand, + ] as const; + + if (platform === "linux") { + return yield* launchLinux(sshCommand, sshArgs, env).pipe(Effect.orElseSucceed(() => false)); + } + + return yield* ( + platform === "darwin" + ? launchDarwin(sshCommand, sshArgs) + : launchWindows(sshCommand, sshArgs, env) + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 9f975ee209e3d..a5b2f837d9830 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -257,6 +257,113 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Needs POSIX stubs; the Windows PowerShell stub is one file named with backslashes. +it.effect.skipIf(windowsHost)("opens the terminal in the target's folder on each platform", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3 it's a repo-" }); + const script = path.join(workspace, "run.sh"); + yield* fileSystem.writeFileString(script, "#!/bin/sh\n"); + const stub = path.join(binDir, "x-terminal-emulator"); + yield* fileSystem.writeFileString(stub, "#!/bin/sh\n"); + yield* fileSystem.chmod(stub, 0o755); + + const launch = ( + cwd: string, + platform: NodeJS.Platform, + env: Record, + display?: string, + ) => + Effect.gen(function* () { + const spawned: ChildProcess.StandardCommand[] = []; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + if (editors.includes("terminal")) { + yield* launcher.launchEditor({ editor: "terminal", cwd }); + } + return editors; + }).pipe( + Effect.provide( + layerTest({ + platform, + env, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => + command.command === "busctl" + ? { stdout: JSON.stringify({ data: display ? [`DISPLAY=${display}`] : [] }) } + : undefined, + }), + ), + Effect.provideService(HostProcess.Environment, env), + ); + return { available: editors.includes("terminal"), spawned: spawned.at(-1) }; + }); + + for (const cwd of [workspace, script]) { + const mac = yield* launch(cwd, "darwin", { PATH: "/usr/bin" }); + assert.equal(mac.available, true); + assert.equal(mac.spawned?.command, "open"); + assert.deepEqual(mac.spawned?.args, ["-a", "Terminal", workspace]); + + const linux = yield* launch(cwd, "linux", { PATH: binDir, DISPLAY: ":0" }); + assert.equal(linux.spawned?.command, "x-terminal-emulator"); + assert.deepEqual(linux.spawned?.args, [ + "-e", + "/bin/sh", + "-c", + 'cd -- "$1" && exec "${SHELL:-/bin/sh}"', + "sh", + workspace, + ]); + assert.equal(linux.spawned?.options.cwd, workspace); + } + + const headless = yield* launch(workspace, "linux", { PATH: binDir }); + assert.equal(headless.available, false); + const recovered = yield* launch(workspace, "linux", { PATH: binDir }, ":0"); + assert.equal(recovered.available, true); + assert.equal(recovered.spawned?.options.env?.DISPLAY, ":0"); + const noTerminal = yield* launch(workspace, "linux", { PATH: "/nonexistent", DISPLAY: ":0" }); + assert.equal(noTerminal.available, false); + + for (const [command, args] of [ + ["xdg-terminal-exec", [`--dir=${workspace}`]], + ["konsole", ["--workdir", workspace]], + ["kitty", ["--directory", workspace]], + ...["gnome-terminal", "kgx", "xfce4-terminal", "alacritty", "foot"].map( + (command) => [command, [`--working-directory=${workspace}`]] as const, + ), + ] as const) { + const terminalBin = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-terminal-" }); + const executable = path.join(terminalBin, command); + yield* fileSystem.writeFileString(executable, "#!/bin/sh\n"); + yield* fileSystem.chmod(executable, 0o755); + const linux = yield* launch(workspace, "linux", { PATH: terminalBin, DISPLAY: ":0" }); + assert.equal(linux.spawned?.command, command); + assert.deepEqual(linux.spawned?.args, args); + } + + const systemRoot = path.join(binDir, "Windows"); + yield* fileSystem.writeFileString( + `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + "", + ); + const windows = yield* launch("C:/it's a repo’;calc;#/src/index.ts", "win32", { + PATH: binDir, + PATHEXT: ".EXE", + SYSTEMROOT: systemRoot, + }); + assert.equal(windows.spawned?.options.detached, false); + assert.equal( + Buffer.from(String(windows.spawned?.args.at(-1)), "base64").toString("utf16le"), + `$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${Buffer.from("C:\\it's a repo’;calc;#\\src", "utf16le").toString("base64")}')))`, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.skipIf(windowsHost)( "reveals a WSL file in Windows File Explorer through its UNC path", () => diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 7f4417d1fb67a..87eed526925d6 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -48,6 +48,7 @@ interface EditorLaunch { readonly target: string; readonly command: string; readonly args: ReadonlyArray; + readonly cwd?: string; readonly detached?: boolean; } @@ -94,6 +95,19 @@ const compactEnv = (input: Record>): NodeJS.Proces ), ); +const LINUX_TERMINAL_COMMANDS = [ + "xdg-terminal-exec", + "x-terminal-emulator", + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "kitty", + "alacritty", + "foot", + "xterm", +] as const; + const BrowserLaunchEnvConfig = Config.all({ SYSTEMROOT: Config.String("SYSTEMROOT").pipe(Config.option), windir: Config.String("windir").pipe(Config.option), @@ -357,6 +371,27 @@ const fileManagerRevealKindForPlatform = Effect.fn( return hasGraphicalLinuxSession(env) ? "files" : undefined; }); +const resolveTerminalCommand = Effect.fn("externalLauncher.resolveTerminalCommand")(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "open"; + if (platform === "win32") { + const powershell = resolvePowerShellPath(env); + return (yield* isCommandAvailable(powershell, { env })) ? powershell : undefined; + } + if (!hasGraphicalLinuxSession(env) && !hasGraphicalLinuxSession(yield* readGraphicalSessionEnv())) + return undefined; + for (const command of LINUX_TERMINAL_COMMANDS) { + if (yield* isCommandAvailable(command, { env })) return command; + } + return undefined; +}); + function resolveWslFileManagerPath(target: string, distroName: string): string { const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; @@ -406,7 +441,11 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" for (const editor of EDITORS) { if (editor.commands === null) { - if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { + const command = + editor.id === "terminal" + ? yield* resolveTerminalCommand(platform, env) + : yield* resolveUsableFileManagerCommand(platform, env); + if (command !== undefined) { available.push(editor.id); } continue; @@ -491,6 +530,14 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; } + if (editorDef.id === "terminal") { + const command = yield* resolveTerminalCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + return yield* resolveTerminalLaunch(input.cwd, platform, command); + } + if (editorDef.id !== "file-manager") { return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } @@ -518,6 +565,69 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; }); +// Terminal.app runs a file it is asked to open, so a file target must become its folder. +const resolveTerminalLaunch = Effect.fn("resolveTerminalLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + command: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const isDirectory = yield* fileSystem.stat(target).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + + if (platform === "win32") { + const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); + const windowsTarget = normalizeWindowsFileManagerPath(target); + const directory = isDirectory ? windowsTarget : path.dirname(windowsTarget); + return { + editor: "terminal", + target, + command, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64( + `$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encodeUtf16LeBase64(directory)}')))`, + ), + ], + detached: WINDOWS_POWERSHELL_DETACHED, + }; + } + + const path = yield* Path.Path; + const directory = isDirectory ? target : path.dirname(target); + return platform === "darwin" + ? { editor: "terminal", target, command, args: ["-a", "Terminal", directory] } + : { + editor: "terminal", + target, + command, + args: linuxTerminalArgs(command, directory), + cwd: directory, + }; +}); + +function linuxTerminalArgs(command: string, directory: string): ReadonlyArray { + switch (command) { + case "xdg-terminal-exec": + return [`--dir=${directory}`]; + case "konsole": + return ["--workdir", directory]; + case "kitty": + return ["--directory", directory]; + case "gnome-terminal": + case "kgx": + case "xfce4-terminal": + case "alacritty": + case "foot": + return [`--working-directory=${directory}`]; + default: + // The desktop's x-terminal-emulator can ignore its inherited cwd. + return ["-e", "/bin/sh", "-c", 'cd -- "$1" && exec "${SHELL:-/bin/sh}"', "sh", directory]; + } +} + // A replacement manager registers a default Directory shell verb; stock Windows // uses "none". Explorer needs its /select argument passed verbatim through // PowerShell because Node's argument quoting changes how Explorer parses it. @@ -774,6 +884,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu command: spawnCommand.command, args: spawnCommand.args, options: { + ...(launch.cwd === undefined ? {} : { cwd: launch.cwd }), detached: launch.detached ?? true, shell: spawnCommand.shell, env: yield* readGraphicalSessionEnv(), diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index d6977fc043097..0ab7e2b3cb7e0 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -25,6 +25,28 @@ export const FinderIcon: Icon = (props) => ( ); +export const TerminalAppIcon: Icon = (props) => ( + + + + +); + export const FileExplorerIcon: Icon = (props) => ( ({ clientEditors: ["vscode"] as readonly EditorId[], run: vi.fn(), openUrl: vi.fn(), + openTerminal: vi.fn(), setPreferred: vi.fn(), markHintSeen: vi.fn(), listeners: new Set<() => void>(), @@ -63,6 +64,7 @@ vi.mock(import("../../remoteOpen"), async (importOriginal) => ({ useRemoteCapableEditors: () => state.clientEditors, useRemoteOpenHint: () => [false, state.markHintSeen] as const, openRemoteEditorUrl: state.openUrl, + openRemoteTerminal: state.openTerminal, })); import { OpenInPicker } from "./OpenInPicker"; @@ -81,6 +83,7 @@ async function setup() { state.clientEditors = editors; state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.openUrl.mockReset().mockResolvedValue(true); + state.openTerminal.mockReset().mockResolvedValue(true); state.setPreferred.mockReset(); state.markHintSeen.mockReset(); state.listeners.clear(); @@ -236,3 +239,25 @@ describe("open in editor announcement", () => { expect(await spoken()).toBe("Opened in VS Code"); }); }); + +describe("remote SSH terminal", () => { + it("opens a local SSH terminal instead of an editor URL", async () => { + await using fixture = await setup(); + const { renderPicker, primaryButton, click } = fixture; + state.remote = { + mode: "remote-links", + host: { kind: "ssh-alias", host: "sol", username: "theo" }, + }; + state.clientEditors = ["vscode", "terminal"]; + window.localStorage.setItem("t3code:last-editor", JSON.stringify("terminal")); + await renderPicker(["vscode", "terminal"]); + await click(primaryButton()); + expect(state.openTerminal).toHaveBeenCalledWith({ + host: { kind: "ssh-alias", host: "sol", username: "theo" }, + absolutePath: "/work/project", + isFile: false, + }); + expect(state.openUrl).not.toHaveBeenCalled(); + expect(state.run).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index a19491adcd15e..a86565bb9dccb 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -14,6 +14,7 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, + openRemoteTerminal, useRemoteCapableEditors, useRemoteOpenHint, useRemoteOpenState, @@ -40,6 +41,7 @@ import { FinderIcon, Icon, KiroIcon, + TerminalAppIcon, TraeIcon, VisualStudioCode, VisualStudioCodeInsiders, @@ -192,6 +194,11 @@ const resolveOpenInOptions = (platform: string, availableEditors: ReadonlyArray< value: "file-manager", kind: isMacPlatform(platform) || isWindowsPlatform(platform) ? "brand" : "generic", }, + { + Icon: TerminalAppIcon, + value: "terminal", + kind: "brand", + }, ]; const availableEditorSet = new Set(availableEditors); return baseOptions @@ -248,16 +255,24 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!editor) return false; if (remote.mode === "remote-unavailable") return false; if (remote.mode === "remote-links") { - const url = buildRemoteOpenUrl({ - editor, - ...remote.host, - absolutePath: openInCwd, - isFile: openInCwdIsFile, - }); - if (url === undefined) return false; - // Only record hint-seen/preferred when the shell actually accepted - // the URL (an older desktop build can refuse the editor scheme). - const opened = await openRemoteEditorUrl(url); + let opened = false; + if (editor === "terminal") { + opened = await openRemoteTerminal({ + host: remote.host, + absolutePath: openInCwd, + isFile: openInCwdIsFile, + }); + } else { + const url = buildRemoteOpenUrl({ + editor, + ...remote.host, + absolutePath: openInCwd, + isFile: openInCwdIsFile, + }); + // Only record hint-seen/preferred when the shell actually accepted + // the URL (an older desktop build can refuse the editor scheme). + opened = url !== undefined && (await openRemoteEditorUrl(url)); + } if (!opened) return false; markRemoteHintSeen(); setPreferredEditor(editor); diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 833c3f3784987..0b8233095f98e 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -10,7 +10,12 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; import { announce } from "../../lib/announce"; -import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen"; +import { + openRemoteEditorUrl, + openRemoteTerminal, + useRemoteCapableEditors, + useRemoteOpenState, +} from "../../remoteOpen"; import { registerCommands } from "../../shortcuts/dispatcher"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -76,13 +81,18 @@ export function useOpenFavoriteEditorShortcut({ event.preventDefault(); const label = editorLabelForPlatform(preferredEditor, navigator.platform); if (remote.mode === "remote-links") { - const url = buildRemoteOpenUrl({ - editor: preferredEditor, - ...remote.host, - absolutePath: openInCwd, - }); - if (url === undefined) return; - void openRemoteEditorUrl(url).then((opened) => { + const open = + preferredEditor === "terminal" + ? openRemoteTerminal({ host: remote.host, absolutePath: openInCwd }) + : (() => { + const url = buildRemoteOpenUrl({ + editor: preferredEditor, + ...remote.host, + absolutePath: openInCwd, + }); + return url === undefined ? Promise.resolve(false) : openRemoteEditorUrl(url); + })(); + void open.then((opened) => { if (opened) announce(`Opened in ${label}`); }); return; diff --git a/apps/web/src/editorLabels.ts b/apps/web/src/editorLabels.ts index 7d44bc328b74c..951c69505cc53 100644 --- a/apps/web/src/editorLabels.ts +++ b/apps/web/src/editorLabels.ts @@ -13,7 +13,7 @@ export function editorLabelForPlatform(editorId: EditorId, platform: string): st } export function openInEditorMenuLabel(editorId: EditorId | null): string { - return editorId === null || editorId === "file-manager" + return editorId === null || editorId === "file-manager" || editorId === "terminal" ? "Open in editor" : `Open in ${editorLabels.get(editorId) ?? "Editor"}`; } diff --git a/apps/web/src/editorPreferences.test.ts b/apps/web/src/editorPreferences.test.ts index 212eda3304221..ae30dbcd783f3 100644 --- a/apps/web/src/editorPreferences.test.ts +++ b/apps/web/src/editorPreferences.test.ts @@ -67,3 +67,11 @@ it("checks revocation and regrant when a retained editor callback is invoked", a }); expect(state.setPreference).toHaveBeenCalledTimes(1); }); + +it("opens files in an editor without losing a stored terminal preference", async () => { + state.allowed.add(primary); + state.getPreference.mockReturnValue("terminal"); + const open = useOpenInPreferredEditor(primary, ["vscode", "terminal"]); + expect(await open("/work/readme.md:5")).toMatchObject({ _tag: "Success", value: "vscode" }); + expect(state.setPreference).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index e6aacd32b02de..51b77b36fab2d 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -55,12 +55,14 @@ export function usePreferredEditor(availableEditors: ReadonlyArray) { return [effectiveEditor, setLastEditor] as const; } +// These targets are files, which a terminal cannot show, so a stored terminal choice is skipped and kept. function resolveAndPersistPreferredEditor(availableEditors: readonly EditorId[]): EditorId | null { const availableEditorIds = new Set(availableEditors); + availableEditorIds.delete("terminal"); const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); if (stored && availableEditorIds.has(stored)) return stored; const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; - if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); + if (editor && stored !== "terminal") setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); return editor ?? null; } diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 1ea95c96de518..3bb89a1928b1f 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -72,7 +72,9 @@ function buildFileContextMenuItems(input: { icon: "folder-tree", }); } - const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); + const editorIds = input.capabilities.editorIds.filter( + (id) => id !== "file-manager" && id !== "terminal", + ); if (editorIds.length > 0) { items.push({ id: "open-with", diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 365ec4a3d50af..11a5bcb366944 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -3,11 +3,21 @@ import { RelayConnectionTarget, SshConnectionTarget, } from "@t3tools/client-runtime/connection"; -import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + buildRemoteSshTerminalDestination, + buildRemoteSshTerminalRemoteCommand, + DesktopOpenRemoteTerminalInputSchema, + EnvironmentId, + remoteTerminalWorkingDirectory, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { resolveRemoteOpenState } from "./remoteOpen"; +const decodeRemoteTerminalInput = Schema.decodeUnknownExit(DesktopOpenRemoteTerminalInputSchema); + const environmentId = EnvironmentId.make("environment-1"); const primaryTarget = (httpBaseUrl: string) => @@ -85,6 +95,29 @@ describe("resolveRemoteOpenState", () => { ); }); + it("carries the profile SSH port into remote-links state for Terminal", () => { + const state = resolveRemoteOpenState({ + target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), + sshTarget: { alias: "sol", username: "theo", port: 2222 }, + isDesktopRenderer: true, + remoteOpenTargets: [], + }); + expect(state).toEqual({ + mode: "remote-links", + host: { kind: "ssh-alias", host: "sol", username: "theo", port: 2222 }, + }); + }); + + it("omits a null profile SSH port so OpenSSH uses config / default 22", () => { + const state = resolveRemoteOpenState({ + target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), + sshTarget: { alias: "sol", username: null, port: null }, + isDesktopRenderer: true, + remoteOpenTargets: [], + }); + expect(state).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); + }); + it.each([ { alias: "server", username: "admin-user" }, { alias: "admin-user@server", username: null }, @@ -143,6 +176,54 @@ describe("resolveRemoteOpenState", () => { }); }); +describe("remote SSH terminal helpers", () => { + it("builds a destination that matches remote editor hosts", () => { + expect(buildRemoteSshTerminalDestination("sol")).toBe("sol"); + expect(buildRemoteSshTerminalDestination("sol.tail.ts.net", "theo")).toBe( + "theo@sol.tail.ts.net", + ); + }); + + it("opens file targets in their parent folder", () => { + expect(remoteTerminalWorkingDirectory("/work/project", false)).toBe("/work/project"); + expect(remoteTerminalWorkingDirectory("/work/project/README.md", true)).toBe("/work/project"); + expect(remoteTerminalWorkingDirectory("C:\\Users\\theo\\app\\file.ts", true)).toBe( + "/C:/Users/theo/app", + ); + }); + + it("runs cd and the login shell through /bin/sh for non-POSIX login shells", () => { + expect(buildRemoteSshTerminalRemoteCommand("/work/my project")).toBe( + `exec /bin/sh -c 'cd -- "$1" && exec "\${SHELL:-/bin/sh}" -l' sh '/work/my project'`, + ); + expect(buildRemoteSshTerminalRemoteCommand("/tmp/o'brian")).toBe( + `exec /bin/sh -c 'cd -- "$1" && exec "\${SHELL:-/bin/sh}" -l' sh '/tmp/o'\\''brian'`, + ); + }); + + it("rejects advertised hosts that would become ssh options", () => { + expect( + decodeRemoteTerminalInput({ + host: "-oProxyCommand=x", + absolutePath: "/work/project", + })._tag, + ).toBe("Failure"); + expect( + decodeRemoteTerminalInput({ + host: "sol", + username: "-oProxyCommand=x", + absolutePath: "/work/project", + })._tag, + ).toBe("Failure"); + expect( + decodeRemoteTerminalInput({ + host: "sol", + absolutePath: "/work/project", + })._tag, + ).toBe("Success"); + }); +}); + describe("buildRemoteOpenUrl", () => { it("roots Windows paths", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 741f6f3c60cde..7f17d770dc649 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -19,6 +19,8 @@ export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; readonly host: string; readonly username?: string; + /** SSH listen port from the connection profile; not the forwarded HTTP port. */ + readonly port?: number; } export type RemoteOpenState = @@ -50,7 +52,11 @@ function parseHostname(url: string): string | null { export function resolveRemoteOpenState(input: { readonly target: ConnectionTarget | null; - readonly sshTarget: { readonly alias: string; readonly username: string | null } | null; + readonly sshTarget: { + readonly alias: string; + readonly username: string | null; + readonly port?: number | null; + } | null; readonly remoteOpenTargets: ReadonlyArray | undefined; readonly isDesktopRenderer: boolean; }): RemoteOpenState { @@ -78,12 +84,14 @@ export function resolveRemoteOpenState(input: { const username = input.sshTarget.username?.trim() || (userSeparator === -1 ? null : alias.slice(0, userSeparator)); + const port = input.sshTarget.port ?? null; return { mode: "remote-links", host: { kind: "ssh-alias", host, ...(username === null ? {} : { username }), + ...(port === null ? {} : { port }), }, }; } @@ -100,7 +108,8 @@ function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState) return ( previous.host.kind === next.host.kind && previous.host.host === next.host.host && - previous.host.username === next.host.username + previous.host.username === next.host.username && + previous.host.port === next.host.port ); } @@ -146,17 +155,22 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; +const withRemoteTerminal = (ids: ReadonlyArray): ReadonlyArray => + window.desktopBridge?.openRemoteTerminal !== undefined && !ids.includes("terminal") + ? [...ids, "terminal"] + : ids; + // Probed once per page: installed editors rarely change, and the probe spawns processes. const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray => { const probe = window.desktopBridge?.probeRemoteEditors; probe?.().then( (ids) => { const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); - if (remoteCapable.length > 0) get.setSelf(remoteCapable); + if (remoteCapable.length > 0) get.setSelf(withRemoteTerminal(remoteCapable)); }, () => undefined, ); - return REMOTE_FALLBACK_EDITORS; + return withRemoteTerminal(REMOTE_FALLBACK_EDITORS); }).pipe(Atom.keepAlive, Atom.withLabel("remote-capable-editors")); export function useRemoteCapableEditors(): ReadonlyArray { @@ -177,6 +191,27 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } +/** Opens a local system terminal that SSHs into the remote host. Desktop only. */ +export async function openRemoteTerminal(input: { + readonly host: RemoteOpenHost; + readonly absolutePath: string; + readonly isFile?: boolean; +}): Promise { + const bridge = window.desktopBridge; + if (bridge?.openRemoteTerminal === undefined) return false; + try { + return await bridge.openRemoteTerminal({ + host: input.host.host, + ...(input.host.username === undefined ? {} : { username: input.host.username }), + ...(input.host.port === undefined ? {} : { port: input.host.port }), + absolutePath: input.absolutePath, + ...(input.isFile === undefined ? {} : { isFile: input.isFile }), + }); + } catch { + return false; + } +} + const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { diff --git a/docs/user/terminal.md b/docs/user/terminal.md index dd921eb9a9650..5a8fa2cd570a8 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -20,6 +20,19 @@ Jump to latest in the context menu returns to the current output. On iPhone and iPad, long-press the terminal to paste or to choose Select Text. Select Text shows the visible output, where you can copy text or tap a link. +To work in a system terminal instead, choose Terminal from the Open in menu. It +opens a new window in the project folder on the machine running T3 Code; from a +file preview it opens in that file's folder. On macOS this is Terminal.app, on +Windows it is PowerShell, and on Linux T3 Code uses the first terminal it finds, +starting with your desktop's default. + +When you are connected to a remote environment over SSH or an advertised +Tailscale or local network host, Terminal (desktop app) opens a local system +terminal that SSHs into that host and starts in the project folder, the same way +Open in opens editors over SSH. The remote host needs a POSIX `/bin/sh` (Linux +and macOS SSH servers); Windows OpenSSH targets are not supported for this +action. On Windows desktop, T3 Code prefers Windows Terminal in a new window. + ## fish T3 Code starts fish with `no-query-term`, avoiding startup waits for terminal diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75e5aebbc31a4..b53eb297df085 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -146,6 +146,7 @@ export const EDITORS = [ jetbrainsProductCode: "WS", }, { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, + { id: "terminal", label: "Terminal", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); @@ -232,6 +233,39 @@ export const buildRemoteOpenUrl = (input: { return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(destination)}${encodedPath}${input.isFile ? ":1:1" : ""}`; }; +/** Directory for a remote SSH terminal; file targets open in their parent folder. */ +export function remoteTerminalWorkingDirectory(absolutePath: string, isFile: boolean): string { + const posixPath = absolutePath.replaceAll("\\", "/"); + const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; + if (!isFile) return rootedPath; + const lastSlash = rootedPath.lastIndexOf("/"); + return lastSlash <= 0 ? "/" : rootedPath.slice(0, lastSlash); +} + +export function buildRemoteSshTerminalDestination(host: string, username?: string): string { + return username === undefined || username.length === 0 ? host : `${username}@${host}`; +} + +/** + * Host or username for a desktop SSH terminal launch. Leading dashes would be + * parsed as ssh options without `--`, so reject them at the IPC boundary too. + */ +export const SshTerminalDestinationSegment = TrimmedNonEmptyString.check( + Schema.makeFilter((value: string) => !value.startsWith("-"), { + expected: "a host or username that does not start with '-'", + }), +); +export type SshTerminalDestinationSegment = typeof SshTerminalDestinationSegment.Type; + +/** + * Single ssh remote-command argv: run POSIX cd + login shell through /bin/sh so + * fish/csh login shells do not have to parse parameter expansion. + */ +export function buildRemoteSshTerminalRemoteCommand(directory: string): string { + const quoted = `'${directory.replaceAll("'", `'\\''`)}'`; + return `exec /bin/sh -c 'cd -- "$1" && exec "\${SHELL:-/bin/sh}" -l' sh ${quoted}`; +} + export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219f..1622d545e2c24 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import type { PreviewCaptureFailureEncoded } from "./previewCapture.ts"; import { SnapShotSource } from "./chatAttachment.ts"; -import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, PortSchema, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; import type { BrowserImportResult, @@ -13,7 +13,7 @@ import { AuthAccessTokenResult, AuthSessionState } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; -import type { EditorId } from "./editor.ts"; +import { SshTerminalDestinationSegment, type EditorId } from "./editor.ts"; import { KeybindingShortcut, type PreviewForwardedShortcut } from "./keybindings.ts"; import type { @@ -1029,6 +1029,16 @@ export type DesktopMediaRead = typeof DesktopMediaReadSchema.Type; export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; +export const DesktopOpenRemoteTerminalInputSchema = Schema.Struct({ + host: SshTerminalDestinationSegment, + username: Schema.optionalKey(SshTerminalDestinationSegment), + /** Profile SSH port when set; omit so OpenSSH uses config / default 22. */ + port: Schema.optionalKey(PortSchema), + absolutePath: TrimmedNonEmptyString, + isFile: Schema.optionalKey(Schema.Boolean), +}); +export type DesktopOpenRemoteTerminalInput = typeof DesktopOpenRemoteTerminalInputSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getRelayTelemetryEnabled: () => boolean; @@ -1122,6 +1132,8 @@ export interface DesktopBridge { openSystemSettings: (pane: SystemSettingsPane) => Promise; checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; + /** Opens a local system terminal that SSHs to the remote host at absolutePath. */ + openRemoteTerminal: (input: DesktopOpenRemoteTerminalInput) => Promise; pasteAsText: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void;