diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 580a8b95684d..b58f9535a12f 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -77,14 +77,24 @@ describe("ElectronShell", () => { openExternalMock.mockResolvedValue(undefined); const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal( + const urls = [ "vscode://vscode-remote/ssh-remote+example.com/home/user/project", + "vscode://vscode-remote/ssh-remote+example.com/home/user/my%20file%20%231.json:1", + "cursor://vscode-remote/ssh-remote+example.com/home/user/README:1", + "vscode-insiders://vscode-remote/ssh-remote+example.com/home/user/README:1", + "vscodium://vscode-remote/ssh-remote+example.com/home/user/README:1", + "vscode://vscode-remote/ssh-remote+example.com/tmp/parent%3A12/", + ]; + const results = yield* Effect.forEach(urls, (url) => electronShell.openExternal(url)); + + assert.deepEqual( + results, + urls.map(() => true), + ); + assert.deepEqual( + openExternalMock.mock.calls, + urls.map((url) => [url]), ); - - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [ - ["vscode://vscode-remote/ssh-remote+example.com/home/user/project"], - ]); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/web/src/components/chat/OpenInPicker.test.tsx b/apps/web/src/components/chat/OpenInPicker.test.tsx index a2d9e175b9cc..36ff706fd016 100644 --- a/apps/web/src/components/chat/OpenInPicker.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.test.tsx @@ -100,6 +100,9 @@ vi.mock("../ui/menu", () => ({ disabled?: boolean; }) => cloneElement(render, disabled === undefined ? {} : { disabled }, children), MenuPopup: "section", + MenuSub: "div", + MenuSubTrigger: "button", + MenuSubPopup: "section", MenuItem: "button", MenuItemLabel: "span", MenuShortcut: "span", @@ -150,7 +153,19 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -async function renderPicker(availableEditors: readonly EditorId[] = editors, panel = false) { +async function renderPicker({ + pathKind = "folder", + openInPath = "/work/project", + availableEditors = editors, + displayMode = "toolbar", + presentation = "toolbar", +}: { + pathKind?: "file" | "folder" | null; + openInPath?: string | null; + availableEditors?: readonly EditorId[]; + displayMode?: "toolbar" | "panel"; + presentation?: "toolbar" | "menu"; +} = {}) { await act(async () => { for (const listener of state.listeners) listener(); const node = ( @@ -158,9 +173,11 @@ async function renderPicker(availableEditors: readonly EditorId[] = editors, pan environmentId={selected} keybindings={DEFAULT_RESOLVED_KEYBINDINGS} availableEditors={availableEditors} - openInCwd="/work/project" - displayMode={panel ? "panel" : "toolbar"} - compact={!panel} + openInPath={openInPath} + pathKind={pathKind} + displayMode={displayMode} + presentation={presentation} + compact={displayMode === "toolbar"} /> ); if (renderer) renderer.update(node); @@ -176,20 +193,30 @@ function keyboardEvent() { return { preventDefault: vi.fn() } as unknown as KeyboardEvent; } -it.each(["local-exec", "remote-links"] as const)( - "selects the preferred editor without launching a %s workspace", - async (mode) => { +it.each([ + ["local-exec", "panel"], + ["remote-links", "panel"], + ["local-exec", "toolbar"], + ["remote-links", "toolbar"], +] as const)( + "selects the preferred editor without launching a %s workspace from the %s", + async (mode, displayMode) => { state.allowed.add(selected); state.clientEditors = ["cursor", "vscode"]; state.remote = mode === "local-exec" ? { mode } : { mode, host: { kind: "ssh-alias", host: "test-host" } }; - await renderPicker(["cursor", "vscode"], true); - expect(renderer!.root.findByProps({ "aria-label": "Open in Cursor" })).toBeDefined(); + await renderPicker({ availableEditors: ["cursor", "vscode"], displayMode }); + if (displayMode === "panel") { + expect(renderer!.root.findByProps({ "aria-label": "Open in Cursor" })).toBeDefined(); + } await act(async () => { renderer!.root.findByProps({ children: "VS Code" }).parent!.props.onClick(); }); expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscode"'); - const openButton = renderer!.root.findByProps({ "aria-label": "Open in VS Code" }); + const openButton = + displayMode === "panel" + ? renderer!.root.findByProps({ "aria-label": "Open in VS Code" }) + : primaryButton(); expect(state.run).not.toHaveBeenCalled(); expect(state.openUrl).not.toHaveBeenCalled(); @@ -201,12 +228,36 @@ it.each(["local-exec", "remote-links"] as const)( }); } else { expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( - "vscode://vscode-remote/ssh-remote+test-host/work/project", + "vscode://vscode-remote/ssh-remote+test-host/work/project/", ); } }, ); +it.each(["local-exec", "remote-links"] as const)( + "still launches the selected file from a %s menu", + async (mode) => { + state.allowed.add(selected); + state.remote = + mode === "local-exec" ? { mode } : { mode, host: { kind: "ssh-alias", host: "test-host" } }; + await renderPicker({ pathKind: "file", presentation: "menu" }); + await act(async () => { + renderer!.root.findByProps({ children: "VS Code" }).parent!.props.onClick(); + }); + if (mode === "local-exec") { + expect(state.run).toHaveBeenCalledExactlyOnceWith({ + environmentId: selected, + input: { cwd: "/work/project", editor: "vscode" }, + }); + } else { + expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( + "vscode://vscode-remote/ssh-remote+test-host/work/project:1", + ); + } + expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscode"'); + }, +); + describe("host editor access", () => { it("uses the selected grant for both the button and a retained shortcut", async () => { state.allowed.add(selected); @@ -252,19 +303,65 @@ describe("host editor access", () => { }); describe("client editor links", () => { - it("opens an SSH editor URL without host operate permission", async () => { + it.each([ + ["file", ":1"], + ["folder", "/"], + ] as const)("opens an SSH %s URL without host operate permission", async (pathKind, suffix) => { state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; - await renderPicker(); + await renderPicker({ pathKind }); expect(primaryButton().props.disabled).toBe(false); await act(async () => primaryButton().props.onClick()); expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( - "vscode://vscode-remote/ssh-remote+test-host/work/project", + `vscode://vscode-remote/ssh-remote+test-host/work/project${suffix}`, ); expect(state.run).not.toHaveBeenCalled(); expect(state.markHintSeen).toHaveBeenCalledOnce(); expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode"); }); + it("blocks remote buttons and shortcuts until the preview path kind is known", async () => { + state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; + await renderPicker({ pathKind: null }); + expect(primaryButton().props.disabled).toBe(true); + expect(renderer!.root.findByProps({ "aria-label": "Choose editor" }).props.disabled).toBe(true); + expect(state.keydown).toBeNull(); + const event = keyboardEvent(); + await act(async () => { + primaryButton().props.onClick(); + state.keydown?.(event); + }); + expect(state.openUrl).not.toHaveBeenCalled(); + expect(state.run).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + await renderPicker({ pathKind: "file" }); + const fileEvent = keyboardEvent(); + await act(async () => state.keydown!(fileEvent)); + expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( + "vscode://vscode-remote/ssh-remote+test-host/work/project:1", + ); + expect(fileEvent.preventDefault).toHaveBeenCalledOnce(); + state.openUrl.mockClear(); + await renderPicker({ pathKind: null }); + expect(state.keydown).toBeNull(); + await renderPicker({ pathKind: "folder" }); + await act(async () => primaryButton().props.onClick()); + expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( + "vscode://vscode-remote/ssh-remote+test-host/work/project/", + ); + }); + + it("lets the host editor resolve a local path whose preview type is unknown", async () => { + state.allowed.add(selected); + await renderPicker({ pathKind: null }); + expect(primaryButton().props.disabled).toBe(false); + await act(async () => primaryButton().props.onClick()); + expect(state.run).toHaveBeenCalledExactlyOnceWith({ + environmentId: selected, + input: { cwd: "/work/project", editor: "vscode" }, + }); + expect(state.openUrl).not.toHaveBeenCalled(); + }); + it("does not change preferences when the client rejects the SSH URL", async () => { state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; state.openUrl.mockResolvedValue(false); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 5cf20d954ae7..fc202e599042 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -207,7 +207,8 @@ export const OpenInPicker = memo(function OpenInPicker({ environmentId, keybindings, availableEditors, - openInCwd, + openInPath, + pathKind, presentation = "toolbar", compact = false, enableShortcut = true, @@ -216,7 +217,8 @@ export const OpenInPicker = memo(function OpenInPicker({ environmentId: EnvironmentId; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; - openInCwd: string | null; + openInPath: string | null; + pathKind: "file" | "folder" | null; presentation?: "toolbar" | "menu"; compact?: boolean; enableShortcut?: boolean; @@ -229,8 +231,10 @@ export const OpenInPicker = memo(function OpenInPicker({ const remote = useRemoteOpenState(environmentId); const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const isHostEditorDenied = remote.mode === "local-exec" && !canOperateHost; - const canOpenEditor = remote.mode !== "remote-unavailable" && !isHostEditorDenied; - const [menuOpen, setMenuOpen] = useComposerMenuState(isHostEditorDenied); + const isRemotePathUnknown = remote.mode === "remote-links" && pathKind === null; + const canOpenEditor = + remote.mode !== "remote-unavailable" && !isHostEditorDenied && !isRemotePathUnknown; + const [menuOpen, setMenuOpen] = useComposerMenuState(isHostEditorDenied || isRemotePathUnknown); const remoteCapableEditors = useRemoteCapableEditors(); const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; @@ -246,15 +250,17 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - if (!openInCwd) return; + if (!openInPath) return; const editor = editorId ?? preferredEditor; if (!editor) return; if (remote.mode === "remote-unavailable") return; if (remote.mode === "remote-links") { + if (pathKind === null) return; const url = buildRemoteOpenUrl({ editor, host: remote.host.host, - absolutePath: openInCwd, + absolutePath: openInPath, + pathKind, }); if (url === undefined) return; // Only record hint-seen/preferred when the shell actually accepted @@ -270,7 +276,7 @@ export const OpenInPicker = memo(function OpenInPicker({ const result = openInEditorMutation({ environmentId, input: { - cwd: openInCwd, + cwd: openInPath, editor, }, }); @@ -280,8 +286,9 @@ export const OpenInPicker = memo(function OpenInPicker({ [ environmentId, markRemoteHintSeen, - openInCwd, + openInPath, openInEditorMutation, + pathKind, preferredEditor, remote, setPreferredEditor, @@ -297,7 +304,7 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!enableShortcut || !canOpenEditor) return; const handler = (e: globalThis.KeyboardEvent) => { if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!openInCwd) return; + if (!openInPath) return; if (!preferredEditor) return; if ( remote.mode === "local-exec" && @@ -315,7 +322,7 @@ export const OpenInPicker = memo(function OpenInPicker({ enableShortcut, environmentId, keybindings, - openInCwd, + openInPath, openInEditor, preferredEditor, remote.mode, @@ -338,7 +345,7 @@ export const OpenInPicker = memo(function OpenInPicker({ {options.map(({ label, Icon, value, kind }) => ( { if (presentation === "menu") { @@ -371,7 +378,7 @@ export const OpenInPicker = memo(function OpenInPicker({ openInEditor(preferredEditor)} > @@ -382,7 +389,7 @@ export const OpenInPicker = memo(function OpenInPicker({ )} - + Open in… @@ -406,7 +413,7 @@ export const OpenInPicker = memo(function OpenInPicker({ variant={isPanel ? "ghost" : "outline"} part="primary" panel={isPanel} - disabled={!preferredEditor || !openInCwd || !canOpenEditor} + disabled={!preferredEditor || !openInPath || !canOpenEditor} onClick={() => openInEditor(preferredEditor)} > {primaryOption?.Icon ? ( @@ -441,7 +448,7 @@ export const OpenInPicker = memo(function OpenInPicker({ )} ) : null} diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 45304cb58639..0330ecb2203c 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -9,6 +9,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { + filePreviewPathKind, filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, @@ -192,6 +193,54 @@ describe("resolveFilePreviewPath", () => { }); }); +describe("remote preview path kind", () => { + const contents = { contents: "cached text" }; + const state = { data: null, error: null, readError: null, isPending: false }; + + it("waits for the initial read and pending refreshes", () => { + expect(filePreviewPathKind(state)).toBeNull(); + expect(filePreviewPathKind({ ...state, data: contents, isPending: true })).toBeNull(); + }); + + it("recognizes a successful text read", () => { + expect(filePreviewPathKind({ ...state, data: contents })).toBe("file"); + }); + + it.each([ + ["path_not_file", "folder"], + ["binary_file", "file"], + ["operation_failed", null], + ["workspace_path_outside_root", null], + ["resolved_path_outside_root", null], + ] as const)("classifies %s before considering stale contents", (failure, expected) => { + const readError = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "preview", + failure, + }); + for (const data of [null, contents]) { + expect(filePreviewPathKind({ ...state, data, error: readError.message, readError })).toBe( + expected, + ); + } + }); + + it("does not infer a file from untyped errors or cached contents after access is lost", () => { + for (const data of [null, contents]) { + expect( + filePreviewPathKind({ ...state, data, error: "This connection cannot read host files." }), + ).toBeNull(); + const readError = decodeReadError({ + _tag: "ProjectReadFileError", + message: "Legacy read failure.", + }); + expect( + filePreviewPathKind({ ...state, data, error: readError.message, readError }), + ).toBeNull(); + } + }); +}); + describe("workspaceAssetResource", () => { const input = { kind: "workspace-file" as const, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 4c0767ad5af4..1ff0fde4a5b2 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -101,8 +101,10 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey } from "./fileContentRevision"; import { + filePreviewPathKind, filePreviewReadErrorMessage, isMarkdownPreviewFile, + resolveFilePreviewEditorPath, resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -1082,6 +1084,7 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && relativePath !== null, ); + const pathKind = filePreviewPathKind(file); const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview @@ -1277,7 +1280,8 @@ export default function FilePreviewPanel({ environmentId={environmentId} keybindings={keybindings} availableEditors={availableEditors} - openInCwd={absolutePath} + openInPath={resolveFilePreviewEditorPath(relativePath, cwd)} + pathKind={pathKind} compact /> ) : null} diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 3d1ad62b68c4..3ad1741146a6 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,5 +1,9 @@ import type { AssetResource, ProjectReadFileError, ScopedThreadRef } from "@t3tools/contracts"; -import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path"; +import { + isAbsolutePath, + resolveWorkspaceFilePath, + workspaceRelativeFilePath, +} from "@t3tools/shared/path"; /** Resolve workspace links before choosing between the explorer and a file preview. */ export function resolveFilePreviewPath(path: string | null, cwd: string): string | null { @@ -7,6 +11,26 @@ export function resolveFilePreviewPath(path: string | null, cwd: string): string return path === "." || workspaceRelativeFilePath(path, cwd) === "." ? null : path; } +/** Match the reader's literal paths, including a workspace directory named ~. */ +export function resolveFilePreviewEditorPath(path: string, cwd: string): string { + const literalPath = path.startsWith("~/") || path.startsWith("~\\") ? `./${path}` : path; + return resolveWorkspaceFilePath(literalPath, cwd); +} + +/** Use the latest read outcome, never cached contents after a failed refresh. */ +export function filePreviewPathKind(file: { + readonly data: { readonly contents: string } | null; + readonly error: string | null; + readonly readError: ProjectReadFileError | null; + readonly isPending: boolean; +}): "file" | "folder" | null { + if (file.isPending) return null; + if (file.readError?.failure === "path_not_file") return "folder"; + if (file.readError?.failure === "binary_file") return "file"; + if (file.error !== null || file.readError !== null) return null; + return file.data === null ? null : "file"; +} + export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); /** Describe existing failure codes without exposing the underlying platform cause. */ diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index 4a7f70a988f6..6f9c7dd56602 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -101,6 +102,7 @@ vi.mock("~/state/queries", () => ({ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts"; import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; +import { filePreviewPathKind } from "./filePreviewMode"; const environmentId = EnvironmentId.make("environment-1"); const decodeReadError = Schema.decodeSync(ProjectReadFileError); @@ -446,6 +448,43 @@ describe("project query refresh", () => { } }); + it.each([ + ["path_not_file", false, "folder"], + ["binary_file", false, "file"], + ["operation_failed", false, null], + ["resolved_path_outside_root", false, null], + ["path_not_file", true, null], + ["binary_file", true, null], + ] as const)( + "classifies a %s read with cached contents and waiting=%s", + (failure, waiting, expectedKind) => { + const error = new ProjectReadFileError({ + cwd: "/repo", + relativePath: "src/preview.ts", + failure, + }); + const readAtom = Atom.make( + AsyncResult.failure(Cause.fail(error), { + previousSuccess: Option.some(AsyncResult.success(file("previous contents"))), + waiting, + }), + ); + const registry = AtomRegistry.make(); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + try { + const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + expect(query.data?.contents).toBe("previous contents"); + expect(query.readError).toBe(error); + expect(filePreviewPathKind(query)).toBe(expectedKind); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }, + ); + it("reports a directory named like an image as not a file", async () => { const readAtom = Atom.make( Effect.fail( diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index d64161c66917..d39be58647e8 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -5,9 +5,15 @@ import { SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { resolveRemoteOpenState } from "./remoteOpen"; +import { + resolveFilePreviewEditorPath, + resolveFilePreviewPath, +} from "./components/files/filePreviewMode"; const environmentId = EnvironmentId.make("environment-1"); @@ -118,68 +124,273 @@ describe("resolveRemoteOpenState", () => { }); }); +// Protocol oracle from VS Code eef761e: app.ts getWindowOpenableFromProtocolUrl, +// workspace.ts hasWorkspaceFileExtension, and extpath.ts parseLineAndColumnAware. +// Serialize first, matching the browser and Electron handoff, then decode as URI.parse does. +function interpretVsCodeRemoteUrl(url: string, protocolPath: Path.Path) { + const decodedPath = decodeURIComponent(new URL(url).pathname); + const path = decodedPath.slice(decodedPath.indexOf("/", 1)); + if (protocolPath.extname(path) === ".code-workspace") return { kind: "workspace", path }; + if (!/:\d+$/.test(path)) return { kind: "folder", path }; + return { + kind: "file", + path: path + .split(":") + .filter((segment) => Number.isNaN(Number(segment))) + .join(":"), + }; +} + describe("buildRemoteOpenUrl", () => { - it("builds a vscode-remote deep link", () => { + it.effect.each(["vscode", "cursor", "vscode-insiders", "vscodium"] as const)( + "preserves literal preview paths before %s's protocol handling", + (editor) => + Effect.gen(function* () { + const protocolPath = yield* Path.Path; + for (const [cwd, requestedPath, pathKind, expectedKind, expectedPath] of [ + ["/tmp", "foo:0", "file", "folder", "/tmp/"], + ["/tmp", "foo:00", "file", "folder", "/tmp/"], + ["/tmp", "build:0012", "folder", "folder", "/tmp/build:0012/"], + ["/tmp", "build:12:00", "folder", "folder", "/tmp/build:12:00/"], + ["/tmp", "/other/build:0012", "folder", "folder", "/other/build:0012/"], + ["C:\\repo", "src\\notes.ts", "file", "file", "/C:/repo/src/notes.ts"], + ["/tmp", "~/notes.ts", "file", "file", "/tmp/~/notes.ts"], + ["C:\\repo", "~\\notes.ts", "file", "file", "/C:/repo/~/notes.ts"], + ] as const) { + const previewPath = resolveFilePreviewPath(requestedPath, cwd); + expect(previewPath).toBe(requestedPath); + const absolutePath = resolveFilePreviewEditorPath(previewPath!, cwd); + const url = buildRemoteOpenUrl({ editor, host: "sol", absolutePath, pathKind }); + expect(interpretVsCodeRemoteUrl(url!, protocolPath)).toEqual({ + kind: expectedKind, + path: expectedPath, + }); + } + }).pipe(Effect.provide(Path.layer)), + ); + + it("preserves literal colon preview filenames for Zed and Toolbox", () => { + const absolutePath = resolveFilePreviewEditorPath("foo:0", "/tmp"); + expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath, pathKind: "file" })).toBe( + "zed://ssh/sol/tmp/foo%3A0", + ); + const toolbox = buildRemoteOpenUrl({ + editor: "idea", + host: "sol", + absolutePath, + pathKind: "file", + }); + expect(new URL(toolbox!).searchParams.get("projectHint")).toBe("/tmp/foo:0"); + }); + + it.effect.each(["vscode", "cursor", "vscode-insiders", "vscodium"] as const)( + "preserves file identity or a safe folder through %s's remote protocol parser", + (editor) => + Effect.gen(function* () { + const protocolPath = yield* Path.Path; + for (const [absolutePath, pathKind, expectedKind, expectedPath] of [ + ["/tmp/README", "file", "file", "/tmp/README"], + ["/tmp/project.code-workspace", "file", "file", "/tmp/project.code-workspace"], + ["/tmp/a:12/file.ts", "file", "file", "/tmp/a:12/file.ts"], + ["/tmp/a:12:bar.ts", "file", "folder", "/tmp/"], + ["/tmp/a:0x10:bar.ts", "file", "folder", "/tmp/"], + ["/tmp/project.code-workspace/sub/../a:12", "file", "folder", "/tmp/"], + ["C:\\tmp\\file.ts", "file", "file", "/C:/tmp/file.ts"], + ["/tmp/build:12", "folder", "folder", "/tmp/build:12/"], + ["/tmp/project.code-workspace", "folder", "folder", "/tmp/"], + ["/outer.code-workspace/inner.code-workspace", "folder", "folder", "/"], + ["/tmp/.code-workspace", "folder", "folder", "/tmp/.code-workspace/"], + ["/tmp/project.CODE-WORKSPACE", "folder", "folder", "/tmp/project.CODE-WORKSPACE/"], + ] as const) { + const url = buildRemoteOpenUrl({ editor, host: "sol", absolutePath, pathKind }); + expect(url).toBeDefined(); + expect(interpretVsCodeRemoteUrl(url!, protocolPath)).toEqual({ + kind: expectedKind, + path: expectedPath, + }); + } + }).pipe(Effect.provide(Path.layer)), + ); + + it.each([ + ["/home/user/.local/share/app/settings.json", "/home/user/.local/share/app/settings.json"], + ["/tmp/README", "/tmp/README"], + ["/tmp/my file #1?.json", "/tmp/my%20file%20%231%3F.json"], + ["C:\\Users\\user\\settings.json", "/C%3A/Users/user/settings.json"], + ["/tmp/project.code-workspace", "/tmp/project.code-workspace"], + ["/tmp/foo:bar.txt", "/tmp/foo%3Abar.txt"], + ["/tmp/a:12/file.ts", "/tmp/a%3A12/file.ts"], + ])("opens %s as a remote file", (absolutePath, encodedPath) => { expect( buildRemoteOpenUrl({ editor: "vscode", - host: "sol.tail1234.ts.net", - absolutePath: "/home/theo/code/my repo", + host: "sol", + absolutePath, + pathKind: "file", }), - ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); + ).toBe(`vscode://vscode-remote/ssh-remote+sol${encodedPath}:1`); }); - it("uses the fork's scheme", () => { - expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( - "cursor://vscode-remote/ssh-remote+sol/tmp/x", - ); - }); + it.each(["vscode", "cursor", "vscode-insiders", "vscodium"] as const)( + "opens the parent of ambiguous colon paths in %s", + (editor) => { + for (const [absolutePath, encodedParent] of [ + ["/tmp/foo:12", "/tmp/"], + ["/tmp/foo:12:bar.txt", "/tmp/"], + ["/foo:12", "/"], + ["/tmp/foo::bar.txt", "/tmp/"], + ["/tmp/foo:0x10:bar.txt", "/tmp/"], + ["/tmp/foo:1e2:bar.txt", "/tmp/"], + ["/tmp/foo:Infinity:bar.txt", "/tmp/"], + ["/tmp/a:12:bar/file.ts", "/tmp/a%3A12%3Abar/"], + ["/tmp/parent:12/foo:12", "/tmp/parent%3A12/"], + ["/tmp/project.code-workspace/foo:12", "/tmp/"], + ["/tmp/project.code-workspace/./foo:12", "/tmp/"], + ["/tmp/project.code-workspace//foo:12", "/tmp/"], + ["/tmp/project.code-workspace/sub/../foo:12", "/tmp/"], + ["/outer.code-workspace/inner.code-workspace/foo:12", "/"], + ["/outer.code-workspace//inner.code-workspace/foo:12", "/"], + ["C:\\outer.code-workspace\\foo:12", "/C%3A/"], + ["/tmp/my folder #1?/foo:12", "/tmp/my%20folder%20%231%3F/"], + ["C:\\tmp\\foo:12", "/C%3A/tmp/"], + ] as const) { + const url = buildRemoteOpenUrl({ editor, host: "sol", absolutePath, pathKind: "file" }); + expect(url).toBe(`${editor}://vscode-remote/ssh-remote+sol${encodedParent}`); + // Desktop and browser URL serialization must preserve the folder fallback. + expect(new URL(url!).href).toBe(url); + } + }, + ); - it("roots Windows paths", () => { + it.each(["vscode", "cursor", "vscode-insiders", "vscodium"] as const)( + "keeps explicit folders out of %s's file and workspace handlers", + (editor) => { + for (const [absolutePath, encodedFolder] of [ + ["/tmp/build:12", "/tmp/build%3A12/"], + ["/tmp/.code-workspace", "/tmp/.code-workspace/"], + ["/tmp/project.CODE-WORKSPACE", "/tmp/project.CODE-WORKSPACE/"], + ["/tmp/project.code-workspace", "/tmp/"], + ["/tmp/project.code-workspace/./", "/tmp/"], + ["/outer.code-workspace/inner.code-workspace", "/"], + ["C:\\tmp\\build:12", "/C%3A/tmp/build%3A12/"], + ] as const) { + const url = buildRemoteOpenUrl({ editor, host: "sol", absolutePath, pathKind: "folder" }); + expect(url).toBe(`${editor}://vscode-remote/ssh-remote+sol${encodedFolder}`); + expect(new URL(url!).href).toBe(url); + } + }, + ); + + it("keeps colon filenames unchanged for Zed", () => { expect( - buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), - ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); + buildRemoteOpenUrl({ + editor: "zed", + host: "sol", + absolutePath: "/tmp/foo:12:bar.txt", + pathKind: "file", + }), + ).toBe("zed://ssh/sol/tmp/foo%3A12%3Abar.txt"); }); - it("builds Zed's ssh deep link", () => { + it("builds a vscode-remote folder deep link", () => { expect( buildRemoteOpenUrl({ - editor: "zed", + editor: "vscode", host: "sol.tail1234.ts.net", absolutePath: "/home/theo/code/my repo", + pathKind: "folder", }), - ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo/"); }); - it("drops the Windows drive letter for Zed", () => { + it.each(["cursor", "vscode-insiders", "vscodium"] as const)("uses %s's scheme", (editor) => { expect( - buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "C:\\Users\\theo" }), - ).toBe("zed://ssh/sol/Users/theo"); - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/C:/project" })).toBe( - "zed://ssh/sol/C%3A/project", - ); + buildRemoteOpenUrl({ editor, host: "sol", absolutePath: "/tmp/x", pathKind: "file" }), + ).toBe(`${editor}://vscode-remote/ssh-remote+sol/tmp/x:1`); + expect( + buildRemoteOpenUrl({ editor, host: "sol", absolutePath: "/tmp/x", pathKind: "folder" }), + ).toBe(`${editor}://vscode-remote/ssh-remote+sol/tmp/x/`); }); - it("builds a Toolbox ssh deep link for JetBrains IDEs", () => { + it("keeps folders with file extensions as folders", () => { expect( buildRemoteOpenUrl({ - editor: "pycharm", + editor: "vscode", + host: "sol", + absolutePath: "/tmp/project.json", + pathKind: "folder", + }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/tmp/project.json/"); + }); + + it("roots Windows paths", () => { + expect( + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol", + absolutePath: "C:\\Users\\theo", + pathKind: "folder", + }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo/"); + }); + + it.each(["file", "folder"] as const)("keeps Zed's ssh deep link for a %s", (pathKind) => { + expect( + buildRemoteOpenUrl({ + editor: "zed", host: "sol.tail1234.ts.net", absolutePath: "/home/theo/code/my repo", + pathKind, }), - ).toBe( - "jetbrains://gateway/ssh/environment?h=sol.tail1234.ts.net&launchIde=true&ideHint=PY&projectHint=%2Fhome%2Ftheo%2Fcode%2Fmy+repo", - ); - expect( - buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "C:\\Users\\theo" }), - ).toBe( - "jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=IU&projectHint=C%3A%2FUsers%2Ftheo", - ); + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); }); + it.each(["file", "folder"] as const)( + "drops the Windows drive letter for a Zed %s", + (pathKind) => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol", + absolutePath: "C:\\Users\\theo", + pathKind, + }), + ).toBe("zed://ssh/sol/Users/theo"); + expect( + buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/C:/project", pathKind }), + ).toBe("zed://ssh/sol/C%3A/project"); + }, + ); + + it.each(["file", "folder"] as const)( + "preserves Toolbox ssh deep links for %s targets", + (pathKind) => { + expect( + buildRemoteOpenUrl({ + editor: "pycharm", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + pathKind, + }), + ).toBe( + "jetbrains://gateway/ssh/environment?h=sol.tail1234.ts.net&launchIde=true&ideHint=PY&projectHint=%2Fhome%2Ftheo%2Fcode%2Fmy+repo", + ); + expect( + buildRemoteOpenUrl({ + editor: "idea", + host: "sol", + absolutePath: "C:\\Users\\theo", + pathKind, + }), + ).toBe( + "jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=IU&projectHint=C%3A%2FUsers%2Ftheo", + ); + }, + ); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x" })).toBe( - undefined, - ); + expect( + buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x", pathKind: "file" }), + ).toBe(undefined); }); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 7fa324a425ad..c5fe6a94a566 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -200,6 +200,7 @@ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; readonly absolutePath: string; + readonly pathKind: "file" | "folder"; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); if (scheme === undefined) { @@ -230,8 +231,31 @@ export const buildRemoteOpenUrl = (input: { const encodedZedPath = zedPath.split("/").map(encodeURIComponent).join("/"); return `${scheme}://ssh/${encodedHost}${encodedZedPath}`; } - const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; + // VS Code decodes paths before parsing :line, including numeric colon segments + // inside filenames. Open a containing folder instead. Drive colons are safe. + const openParent = + input.pathKind === "file" && + rootedPath.split(":").some((segment) => !Number.isNaN(Number(segment))); + let targetPath = rootedPath; + if (openParent || input.pathKind === "folder") { + const folderPath = openParent ? rootedPath.slice(0, rootedPath.lastIndexOf("/")) : rootedPath; + const encodedFolder = folderPath.split("/").map(encodeURIComponent).join("/"); + // Match browser/desktop dot-segment normalization before choosing a folder. + targetPath = decodeURIComponent( + new URL(`${scheme}://vscode-remote${encodedFolder}/`).pathname, + ).replace(/\/+$/, ""); + // A trailing slash prevents :digits from being treated as a line suffix. + // Workspace-looking folders must be skipped too: VS Code treats their + // extension as a workspace file even with the slash. + while (/\/[^/]+\.code-workspace$/.test(targetPath)) { + targetPath = targetPath.slice(0, targetPath.lastIndexOf("/")).replace(/\/+$/, ""); + } + targetPath += "/"; + } + const encodedPath = targetPath.split("/").map(encodeURIComponent).join("/"); + // A :line suffix makes VS Code's remote URL handler open a file instead of a folder. + const position = input.pathKind === "file" && !openParent ? ":1" : ""; + return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}${position}`; }; /**