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) => ( { + 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/docs/user/terminal.md b/docs/user/terminal.md index dd921eb9a9650..b5aacf6812bc9 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -20,6 +20,12 @@ 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. + ## 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..0f52d2c667743 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));