Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);

Expand Down
125 changes: 111 additions & 14 deletions apps/web/src/components/chat/OpenInPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -150,17 +153,31 @@ 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 = (
<OpenInPicker
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);
Expand All @@ -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();

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 22 additions & 15 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ export const OpenInPicker = memo(function OpenInPicker({
environmentId,
keybindings,
availableEditors,
openInCwd,
openInPath,
pathKind,
presentation = "toolbar",
compact = false,
enableShortcut = true,
Expand All @@ -216,7 +217,8 @@ export const OpenInPicker = memo(function OpenInPicker({
environmentId: EnvironmentId;
keybindings: ResolvedKeybindingsConfig;
availableEditors: ReadonlyArray<EditorId>;
openInCwd: string | null;
openInPath: string | null;
pathKind: "file" | "folder" | null;
presentation?: "toolbar" | "menu";
compact?: boolean;
enableShortcut?: boolean;
Expand All @@ -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";
Expand All @@ -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
Expand All @@ -270,7 +276,7 @@ export const OpenInPicker = memo(function OpenInPicker({
const result = openInEditorMutation({
environmentId,
input: {
cwd: openInCwd,
cwd: openInPath,
editor,
},
});
Expand All @@ -280,8 +286,9 @@ export const OpenInPicker = memo(function OpenInPicker({
[
environmentId,
markRemoteHintSeen,
openInCwd,
openInPath,
openInEditorMutation,
pathKind,
preferredEditor,
remote,
setPreferredEditor,
Expand All @@ -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" &&
Expand All @@ -315,7 +322,7 @@ export const OpenInPicker = memo(function OpenInPicker({
enableShortcut,
environmentId,
keybindings,
openInCwd,
openInPath,
openInEditor,
preferredEditor,
remote.mode,
Expand All @@ -338,7 +345,7 @@ export const OpenInPicker = memo(function OpenInPicker({
{options.map(({ label, Icon, value, kind }) => (
<MenuItem
density={presentation === "menu" ? "touch" : "default"}
disabled={!openInCwd || !canOpenEditor}
disabled={!openInPath || !canOpenEditor}
key={value}
onClick={() => {
if (presentation === "menu") {
Expand Down Expand Up @@ -371,7 +378,7 @@ export const OpenInPicker = memo(function OpenInPicker({
<MenuItem
density={presentation === "menu" ? "touch" : "default"}

disabled={!openInCwd || !canOpenEditor}
disabled={!openInPath || !canOpenEditor}
onClick={() => openInEditor(preferredEditor)}
>
<primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} />
Expand All @@ -382,7 +389,7 @@ export const OpenInPicker = memo(function OpenInPicker({
</MenuItem>
)}
<MenuSub>
<MenuSubTrigger density="touch" disabled={isHostEditorDenied}>
<MenuSubTrigger density="touch" disabled={isHostEditorDenied || isRemotePathUnknown}>
<SquareArrowOutUpRightIcon className="size-4" />
<MenuItemLabel>Open in…</MenuItemLabel>
</MenuSubTrigger>
Expand All @@ -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 ? (
Expand Down Expand Up @@ -441,7 +448,7 @@ export const OpenInPicker = memo(function OpenInPicker({
)}
<Menu open={menuOpen} onOpenChange={setMenuOpen}>
<MenuTrigger
disabled={isHostEditorDenied}
disabled={isHostEditorDenied || isRemotePathUnknown}
render={
<ThreadDetailsControl
aria-label="Choose editor"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
keybindings={props.keybindings}
environmentId={props.environmentId}
availableEditors={props.availableEditors}
openInCwd={props.gitCwd}
openInPath={props.gitCwd}
pathKind="folder"
displayMode="panel"
/>
) : null}
Expand Down
Loading
Loading