Skip to content
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export const OpenInPicker = memo(function OpenInPicker({
keybindings,
availableEditors,
openInCwd,
openInCwdIsFile = false,
presentation = "toolbar",
compact = false,
enableShortcut = true,
Expand All @@ -217,6 +218,8 @@ export const OpenInPicker = memo(function OpenInPicker({
keybindings: ResolvedKeybindingsConfig;
availableEditors: ReadonlyArray<EditorId>;
openInCwd: string | null;
/** Set only when `openInCwd` is known to be a file; remote links need to tell it from a folder. */
openInCwdIsFile?: boolean;
presentation?: "toolbar" | "menu";
compact?: boolean;
enableShortcut?: boolean;
Expand Down Expand Up @@ -249,6 +252,7 @@ export const OpenInPicker = memo(function OpenInPicker({
editor,
host: remote.host.host,
absolutePath: openInCwd,
isFile: openInCwdIsFile,
});
if (url === undefined) return;
// Only record hint-seen/preferred when the shell actually accepted
Expand Down
184 changes: 184 additions & 0 deletions apps/web/src/components/files/FilePreviewPanel.remoteOpen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { EnvironmentId, ThreadId, type EditorId } from "@t3tools/contracts";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { cloneElement, type ReactElement, type ReactNode } from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

import type { RemoteOpenState } from "~/remoteOpen";

const state = vi.hoisted(() => ({
file: {} as Record<string, unknown>,
openUrl: vi.fn(),
}));

// The real OpenInPicker and buildRemoteOpenUrl run; the rest of the panel is stubbed.
vi.mock("./projectFilesQueryState", () => ({
useProjectFileQuery: () => state.file,
getOptimisticProjectFileQueryData: vi.fn(),
getProjectFileContents: vi.fn(),
setProjectFileQueryData: vi.fn(),
}));
vi.mock("~/remoteOpen", () => ({
useRemoteOpenState: (): RemoteOpenState => ({
mode: "remote-links",
host: { kind: "ssh-alias", host: "test-host" },
}),
useRemoteCapableEditors: (): readonly EditorId[] => ["vscode"],
useRemoteOpenHint: () => [true, vi.fn()],
openRemoteEditorUrl: state.openUrl,
}));
vi.mock("~/state/session", () => ({
readEnvironmentScope: () => false,
useEnvironmentScope: () => false,
}));
vi.mock("~/state/environments", () => ({
useEnvironment: () => ({ label: "Test host" }),
useEnvironmentHttpBaseUrl: () => null,
usePrimaryEnvironmentId: () => "primary",
}));
vi.mock("~/state/filesystem", () => ({
useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }),
}));
vi.mock("~/state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
vi.mock("~/state/assets", () => ({ assetEnvironment: { createUrl: "createUrl" } }));
vi.mock("~/state/preview", () => ({ previewEnvironment: { open: "open" } }));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
vi.mock("~/browser/previewRuntime", () => ({ usePreviewAvailable: () => false }));
vi.mock("~/browser/openFileInPreview", () => ({
isBrowserPreviewFile: () => false,
openFileInPreview: vi.fn(),
}));
vi.mock("~/assets/assetUrls", () => ({ useAssetUrlRefresh: vi.fn(), useAssetUrlState: vi.fn() }));
vi.mock("~/hooks/useSettings", () => ({
useClientSettings: () => false,
useUpdateClientSettings: () => vi.fn(),
}));
vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
vi.mock("~/hooks/useLocalStorage", () => ({
getLocalStorageItem: () => null,
setLocalStorageItem: vi.fn(),
useLocalStorage: (_key: string, initial: boolean) => [initial, vi.fn()],
}));
vi.mock("~/hooks/useWorkspaceMutationRefresh", () => ({ useWorkspaceMutationRefresh: vi.fn() }));
vi.mock("~/composerDraftStore", () => ({ useComposerDraftStore: vi.fn() }));
vi.mock("~/keybindings", () => ({
isOpenFavoriteEditorShortcut: () => false,
shortcutLabelForCommand: () => null,
}));
vi.mock("~/components/ui/toast", () => ({ stackedThreadToast: vi.fn(), toastManager: {} }));
vi.mock("~/components/ui/scroll-area", () => ({
ScrollArea: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("~/components/ui/button", () => ({ Button: "button" }));
vi.mock("~/components/ui/group", () => ({ Group: "div", GroupSeparator: "span" }));
vi.mock("~/components/ui/menu", () => ({
Menu: ({ children }: { children: ReactNode }) => children,
MenuTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
cloneElement(render, {}, children),
MenuPopup: () => null,
MenuItem: "button",
MenuItemLabel: "span",
MenuShortcut: "span",
}));
vi.mock("~/components/MorphIcon", () => ({ MorphIcon: () => null }));
vi.mock("~/components/media/MediaVideoPlayer", () => ({ MediaVideoPlayer: () => null }));
vi.mock("~/components/media/MediaActions", () => ({ MediaActions: () => null }));
vi.mock("../DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: () => null }));
vi.mock("../diffs/DiffCommentAnnotation", () => ({ DiffCommentAnnotation: () => null }));
vi.mock("./AttachmentFilePreview", () => ({ AttachmentFilePreview: () => null }));
vi.mock("./AudioPreview", () => ({ AudioPreview: () => null }));
vi.mock("./BrowserDocumentFrame", () => ({
BrowserDocumentFrame: () => null,
isPdfPreviewFile: () => false,
}));
vi.mock("./DelimitedTablePreview", () => ({ DelimitedTablePreview: () => null }));
vi.mock("./FileBrowserPanel", () => ({ default: () => null }));
vi.mock("./FileBreadcrumbs", () => ({ FileBreadcrumbs: () => null }));
vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null }));
vi.mock("./ReadOnlySourcePreview", () => ({ default: () => null }));
vi.mock("./useFileSaveCoordinator", () => ({ useFileSaveCoordinator: vi.fn() }));
vi.mock("./fileSurfaceChrome", () => ({
FILE_LINK_REVEAL_ATTRIBUTE: "data-file-link-reveal",
FILE_LINK_REVEAL_UNSAFE_CSS: "",
FILE_SURFACE_SUBHEADER_CLASS: "",
FileSurfaceAction: () => null,
FileSurfaceFailure: () => null,
FileSurfaceLoading: () => null,
}));

import FilePreviewPanel from "./FilePreviewPanel";

const environmentId = EnvironmentId.make("remote");
const readFileResult = { relativePath: "src", contents: "", byteLength: 0, truncated: false };
let renderer: ReactTestRenderer | undefined;

beforeEach(() => {
state.openUrl.mockReset().mockResolvedValue(true);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("navigator", { platform: "Linux" });
vi.stubGlobal("window", {
localStorage: { getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
});

afterEach(async () => {
await act(async () => renderer?.unmount());
renderer = undefined;
vi.unstubAllGlobals();
});

async function openFromPreview(relativePath: string, file: Record<string, unknown>) {
state.file = {
data: null,
error: null,
isPending: false,
refresh: vi.fn(),
readError: null,
isNotFile: false,
isFile: false,
...file,
};
await act(async () => {
renderer = create(
<FilePreviewPanel
environmentId={environmentId}
cwd="/work/project"
projectName="project"
relativePath={relativePath}
threadRef={{ environmentId, threadId: ThreadId.make("thread") }}
composerDraftTarget={{ environmentId, threadId: ThreadId.make("thread") }}
keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
availableEditors={["vscode"]}
revealLine={null}
revealRequestId={0}
onOpenFile={vi.fn()}
onNavigateFolder={vi.fn()}
folderTrail={undefined}
onPendingChange={vi.fn()}
selectedFilePending={false}
workspaceMutationId={null}
/>,
);
});
await act(async () =>
renderer!.root.findByProps({ "aria-label": "Open file in preferred editor" }).props.onClick(),
);
}

it("opens a previewed file at a position so VS Code does not treat it as a folder", async () => {
await openFromPreview("src/app.ts", { data: readFileResult, isFile: true });
expect(state.openUrl).toHaveBeenCalledExactlyOnceWith(
"vscode://vscode-remote/ssh-remote+test-host/work/project/src/app.ts:1:1",
);
});

it("opens a folder reached through the preview as a plain folder link", async () => {
await openFromPreview("src", { isNotFile: true });
expect(state.openUrl).toHaveBeenCalledExactlyOnceWith(
"vscode://vscode-remote/ssh-remote+test-host/work/project/src",
);
});
1 change: 1 addition & 0 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ export default function FilePreviewPanel({
keybindings={keybindings}
availableEditors={availableEditors}
openInCwd={absolutePath}
openInCwdIsFile={file.isFile}
compact
/>
) : null}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/files/projectFilesQueryState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ describe("project query refresh", () => {
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", "assets.png");
expect(query.isNotFile).toBe(true);
expect(query.isFile).toBe(false);
expect(query.readError?.failure).toBe("path_not_file");
expect(query.data).toBeNull();
} finally {
Expand Down Expand Up @@ -497,6 +498,7 @@ describe("project query refresh", () => {
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills");
expect(query.isNotFile).toBe(true);
expect(query.isFile).toBe(false);
expect(query.readError?.failure).toBe("path_not_file");
expect(query.data).toBeNull();
} finally {
Expand All @@ -506,6 +508,56 @@ describe("project query refresh", () => {
}
});

it("reports a successful read as a file", async () => {
const readAtom = Atom.make(Effect.succeed(file("# readme")));
const registry = AtomRegistry.make();
const unmount = registry.mount(readAtom);
projectMocks.readFile.mockReturnValue(readAtom);
projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
atomHooks.registry = registry;

try {
await flushEffects();
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts");
expect(query.isFile).toBe(true);
expect(query.isNotFile).toBe(false);
} finally {
unmount();
registry.dispose();
atomHooks.registry = null;
}
});

it("reports a binary file as a file", async () => {
const readAtom = Atom.make(
Effect.fail(
new ProjectReadFileError({
cwd: "/repo",
relativePath: "logo.png",
failure: "binary_file",
}),
),
);
const registry = AtomRegistry.make();
const unmount = registry.mount(readAtom);
projectMocks.readFile.mockReturnValue(readAtom);
projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
atomHooks.registry = registry;

try {
await flushEffects();
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", "logo.png");
expect(query.isFile).toBe(true);
expect(query.data).toBeNull();
} finally {
unmount();
registry.dispose();
atomHooks.registry = null;
}
});

it("retains decoded read failure context for the connected environment", async () => {
const error = decodeReadError({
_tag: "ProjectReadFileError",
Expand All @@ -531,6 +583,7 @@ describe("project query refresh", () => {
expect(query.readError).toBe(error);
expect(query.error).toBe(error.message);
expect(query.isNotFile).toBe(false);
expect(query.isFile).toBe(false);
expect(query.data).toBeNull();
} finally {
unmount();
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/files/projectFilesQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ interface ProjectQueryState<A> {
interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> {
readonly readError: ProjectReadFileError | null;
readonly isNotFile: boolean;
/**
* Only a read that returned contents or hit a binary file counts, so an
* unknown path is never taken for a file.
*/
readonly isFile: boolean;
}

function getProjectEntriesQueryAtom(
Expand Down Expand Up @@ -265,9 +270,11 @@ export function useProjectFileQuery(
const optimisticFile = relativePath === null ? null : optimisticResult;
const cause = failureCause(result);
const readError = isProjectReadFileError(cause) ? cause : null;
const fileData = canReadFiles ? (optimisticFile?.data ?? data) : null;
const isNotFile = readError?.failure === "path_not_file";

return {
data: canReadFiles ? (optimisticFile?.data ?? data) : null,
data: fileData,
error:
!isQueryEnabled || fileAccess.isPending
? null
Expand All @@ -276,7 +283,10 @@ export function useProjectFileQuery(
: (fileAccess.error ?? "This connection cannot read host files."),
isPending: isQueryEnabled && (fileAccess.isPending || result.waiting),
readError,
isNotFile: readError?.failure === "path_not_file",
isNotFile,
// A refresh that fails keeps the last contents, so a path that became a
// folder is ruled out by the failure before the stale contents count.
isFile: !isNotFile && (fileData !== null || readError?.failure === "binary_file"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
refresh,
};
}
44 changes: 44 additions & 0 deletions apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,50 @@ describe("buildRemoteOpenUrl", () => {
);
});

it("marks file targets so VS Code-family editors open a file, not a folder", () => {
expect(
buildRemoteOpenUrl({
editor: "vscode",
host: "sol",
absolutePath: "/home/theo/my repo/README.md",
isFile: true,
}),
).toBe("vscode://vscode-remote/ssh-remote+sol/home/theo/my%20repo/README.md:1:1");
expect(
buildRemoteOpenUrl({
editor: "cursor",
host: "sol",
absolutePath: "/tmp/x.md",
isFile: true,
}),
).toBe("cursor://vscode-remote/ssh-remote+sol/tmp/x.md:1:1");
});

it("leaves folder and project targets unmarked", () => {
expect(
buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "/tmp/x", isFile: false }),
).toBe("vscode://vscode-remote/ssh-remote+sol/tmp/x");
expect(buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "/tmp/x" })).toBe(
"vscode://vscode-remote/ssh-remote+sol/tmp/x",
);
});

it("builds the same Zed and JetBrains links for file targets", () => {
expect(
buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x.md", isFile: true }),
).toBe("zed://ssh/sol/tmp/x.md");
expect(
buildRemoteOpenUrl({
editor: "pycharm",
host: "sol",
absolutePath: "/tmp/x.md",
isFile: true,
}),
).toBe(
"jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=PY&projectHint=%2Ftmp%2Fx.md",
);
});

it("returns undefined for editors without remote support", () => {
expect(buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x" })).toBe(
undefined,
Expand Down
Loading
Loading