Skip to content
Merged
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
41 changes: 41 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,47 @@ for (const { platform, installPath, editor, args } of [
);
}

for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) {
it.effect.skipIf(windowsHost)(
`opens ${cwd} with the macOS Zed bundle when another zed is on PATH`,
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-collision-" });
const executable = path.join(home, "Applications/Zed.app/Contents/MacOS/cli");
const binDir = path.join(home, "zfs/bin");
yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
yield* fs.makeDirectory(binDir, { recursive: true });
for (const command of [executable, path.join(binDir, "zed")]) {
yield* fs.writeFileString(command, "#!/bin/sh\n");
yield* fs.chmod(command, 0o755);
}

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
assert.include(yield* launcher.resolveAvailableEditors(), "zed");
yield* launcher.launchEditor({ editor: "zed", cwd });
}).pipe(
Effect.provide(
layerTest({
platform: "darwin",
env: { HOME: home, PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);
assert.ok(spawned);
assert.equal(spawned.command, executable);
assert.deepEqual(spawned.args, [cwd]);
assert.equal(spawned.options.shell, false);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
}

// JetBrains Toolbox names its bundles after the app and the installed version
// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the
// Applications directories the way the win32 branch does instead of probing
Expand Down
80 changes: 67 additions & 13 deletions apps/web/src/components/chat/OpenInPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RemoteOpenState } from "../../remoteOpen";
const state = vi.hoisted(() => ({
allowed: new Set<string>(),
remote: { mode: "local-exec" } as RemoteOpenState,
clientEditors: ["vscode"] as readonly EditorId[],
run: vi.fn(),
openUrl: vi.fn(),
setPreferred: vi.fn(),
Expand Down Expand Up @@ -37,15 +38,25 @@ vi.mock("../../state/session", async () => {
vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) }));
vi.mock("../../editorPreferences", () => ({
usePreferredEditor: (available: readonly EditorId[]) => [
available[0] ?? null,
state.setPreferred,
],
}));
vi.mock("../../editorPreferences", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../editorPreferences")>();
return {
...actual,
usePreferredEditor: (available: readonly EditorId[]) => {
const [preferred, setPreferred] = actual.usePreferredEditor(available);
return [
preferred,
(editor: EditorId) => {
state.setPreferred(editor);
setPreferred(editor);
},
] as const;
},
};
});
vi.mock("../../remoteOpen", () => ({
useRemoteOpenState: () => state.remote,
useRemoteCapableEditors: () => editors,
useRemoteCapableEditors: () => state.clientEditors,
useRemoteOpenHint: () => [false, state.markHintSeen],
openRemoteEditorUrl: state.openUrl,
}));
Expand Down Expand Up @@ -104,6 +115,7 @@ let renderer: ReactTestRenderer | undefined;
beforeEach(() => {
state.allowed.clear();
state.remote = { mode: "local-exec" };
state.clientEditors = editors;
state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
state.openUrl.mockReset().mockResolvedValue(true);
state.setPreferred.mockReset();
Expand All @@ -112,11 +124,21 @@ beforeEach(() => {
state.listeners.clear();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("navigator", { platform: "Linux" });
const events = new EventTarget();
const storage = new Map<string, string>();
vi.stubGlobal("window", {
addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {
state.keydown = callback;
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
dispatchEvent: (event: Event) => events.dispatchEvent(event),
addEventListener: (type: string, callback: EventListener) => {
events.addEventListener(type, callback);
if (type === "keydown") state.keydown = callback;
},
removeEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {
removeEventListener: (type: string, callback: EventListener) => {
events.removeEventListener(type, callback);
if (state.keydown === callback) state.keydown = null;
},
});
Expand All @@ -128,16 +150,17 @@ afterEach(async () => {
vi.unstubAllGlobals();
});

async function renderPicker() {
async function renderPicker(availableEditors: readonly EditorId[] = editors, panel = false) {
await act(async () => {
for (const listener of state.listeners) listener();
const node = (
<OpenInPicker
environmentId={selected}
keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
availableEditors={editors}
availableEditors={availableEditors}
openInCwd="/work/project"
compact
displayMode={panel ? "panel" : "toolbar"}
compact={!panel}
/>
);
if (renderer) renderer.update(node);
Expand All @@ -153,6 +176,37 @@ 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) => {
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 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" });
expect(state.run).not.toHaveBeenCalled();
expect(state.openUrl).not.toHaveBeenCalled();

await act(async () => openButton.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",
);
}
},
);

describe("host editor access", () => {
it("uses the selected grant for both the button and a retained shortcut", async () => {
state.allowed.add(selected);
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,13 @@ export const OpenInPicker = memo(function OpenInPicker({
density={presentation === "menu" ? "touch" : "default"}
disabled={!openInCwd || !canOpenEditor}
key={value}
onClick={() => openInEditor(value)}
onClick={() => {
if (presentation === "menu") {
void openInEditor(value);
} else {
setPreferredEditor(value);
}
}}
>
<Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
<MenuItemLabel>{label}</MenuItemLabel>
Expand Down
12 changes: 9 additions & 3 deletions packages/shared/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
) {
if (editor.commands === null) return Option.none();
const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
for (const command of editor.commands) {
if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });
const platform = yield* HostProcess.Platform;
// OpenZFS also installs a `zed` executable. Prefer the macOS editor bundle
// so a PATH entry for the ZFS Event Daemon cannot take over editor opens.
const preferBundle = platform === "darwin" && editor.id === "zed";
if (!preferBundle) {
for (const command of editor.commands) {
if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });
}
}

const platform = yield* HostProcess.Platform;
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const home = env.HOME;
Expand Down Expand Up @@ -134,6 +139,7 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
}
}

if (preferBundle) candidates.push(...editor.commands);
for (const candidate of candidates) {
if (yield* isCommandAvailable(candidate, { env })) {
return Option.some({
Expand Down
Loading