1import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts";
2import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3import { AsyncResult } from "effect/reactivity";
4import { cloneElement, useState, type ReactElement, type ReactNode } from "react";
5import { act, create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8import type { RemoteOpenState } from "../../remoteOpen";
9
10const state = vi.hoisted(() => ({
11 allowed: new Set<string>(),
12 remote: { mode: "local-exec" } as RemoteOpenState,
13 clientEditors: ["vscode"] as readonly EditorId[],
14 run: vi.fn(),
15 openUrl: vi.fn(),
16 setPreferred: vi.fn(),
17 markHintSeen: vi.fn(),
18 keydown: null as ((event: KeyboardEvent) => void) | null,
19 listeners: new Set<() => void>(),
20}));
21
22vi.mock("../../state/session", async () => {
23 const { useSyncExternalStore } = await import("react");
24 const readEnvironmentScope = (environmentId: string, scope: string) =>
25 scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId);
26 return {
27 readEnvironmentScope,
28 useEnvironmentScope: (environmentId: string, scope: string) =>
29 useSyncExternalStore(
30 (listener) => {
31 state.listeners.add(listener);
32 return () => state.listeners.delete(listener);
33 },
34 () => readEnvironmentScope(environmentId, scope),
35 ),
36 };
37});
38vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
39vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
40vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) }));
41vi.mock("../../editorPreferences", async (importOriginal) => {
42 const actual = await importOriginal<typeof import("../../editorPreferences")>();
43 return {
44 ...actual,
45 usePreferredEditor: (available: readonly EditorId[]) => {
46 const [preferred, setPreferred] = actual.usePreferredEditor(available);
47 return [
48 preferred,
49 (editor: EditorId) => {
50 state.setPreferred(editor);
51 setPreferred(editor);
52 },
53 ] as const;
54 },
55 };
56});
57vi.mock("../../remoteOpen", () => ({
58 useRemoteOpenState: () => state.remote,
59 useRemoteCapableEditors: () => state.clientEditors,
60 useRemoteOpenHint: () => [false, state.markHintSeen],
61 openRemoteEditorUrl: state.openUrl,
62}));
63vi.mock("../../keybindings", () => ({
64 isOpenFavoriteEditorShortcut: () => true,
65 shortcutLabelForCommand: () => "Ctrl+O",
66}));
67vi.mock("../ui/button", () => ({ Button: "button" }));
68vi.mock("../ui/group", () => ({ Group: "div", GroupSeparator: "span" }));
69vi.mock("../ui/menu", () => ({
70 Menu: ({
71 open,
72 onOpenChange,
73 children,
74 }: {
75 open?: boolean;
76 onOpenChange?: (open: boolean) => void;
77 children: ReactNode;
78 }) => {
79 const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
80 const visible = open ?? uncontrolledOpen;
81 return (
82 <div
83 data-menu-open={visible}
84 onDoubleClick={() => {
85 setUncontrolledOpen(!visible);
86 onOpenChange?.(!visible);
87 }}
88 >
89 {children}
90 </div>
91 );
92 },
93 MenuTrigger: ({
94 render,
95 children,
96 disabled,
97 }: {
98 render: ReactElement<{ disabled?: boolean }>;
99 children: ReactNode;
100 disabled?: boolean;
101 }) => cloneElement(render, disabled === undefined ? {} : { disabled }, children),
102 MenuPopup: "section",
103 MenuItem: "button",
104 MenuItemLabel: "span",
105 MenuShortcut: "span",
106}));
107
108import { OpenInPicker } from "./OpenInPicker";
109
110const primary = EnvironmentId.make("primary");
111const selected = EnvironmentId.make("selected");
112const editors: readonly EditorId[] = ["vscode"];
113let renderer: ReactTestRenderer | undefined;
114
115beforeEach(() => {
116 state.allowed.clear();
117 state.remote = { mode: "local-exec" };
118 state.clientEditors = editors;
119 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
120 state.openUrl.mockReset().mockResolvedValue(true);
121 state.setPreferred.mockReset();
122 state.markHintSeen.mockReset();
123 state.keydown = null;
124 state.listeners.clear();
125 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
126 vi.stubGlobal("navigator", { platform: "Linux" });
127 const events = new EventTarget();
128 const storage = new Map<string, string>();
129 vi.stubGlobal("window", {
130 localStorage: {
131 getItem: (key: string) => storage.get(key) ?? null,
132 setItem: (key: string, value: string) => storage.set(key, value),
133 removeItem: (key: string) => storage.delete(key),
134 },
135 dispatchEvent: (event: Event) => events.dispatchEvent(event),
136 addEventListener: (type: string, callback: EventListener) => {
137 events.addEventListener(type, callback);
138 if (type === "keydown") state.keydown = callback;
139 },
140 removeEventListener: (type: string, callback: EventListener) => {
141 events.removeEventListener(type, callback);
142 if (state.keydown === callback) state.keydown = null;
143 },
144 });
145});
146
147afterEach(async () => {
148 await act(async () => renderer?.unmount());
149 renderer = undefined;
150 vi.unstubAllGlobals();
151});
152
153async function renderPicker(availableEditors: readonly EditorId[] = editors, panel = false) {
154 await act(async () => {
155 for (const listener of state.listeners) listener();
156 const node = (
157 <OpenInPicker
158 environmentId={selected}
159 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
160 availableEditors={availableEditors}
161 openInCwd="/work/project"
162 displayMode={panel ? "panel" : "toolbar"}
163 compact={!panel}
164 />
165 );
166 if (renderer) renderer.update(node);
167 else renderer = create(node);
168 });
169}
170
171function primaryButton() {
172 return renderer!.root.findByProps({ "aria-label": "Open file in preferred editor" });
173}
174
175function keyboardEvent() {
176 return { preventDefault: vi.fn() } as unknown as KeyboardEvent;
177}
178
179it.each(["local-exec", "remote-links"] as const)(
180 "selects the preferred editor without launching a %s workspace",
181 async (mode) => {
182 state.allowed.add(selected);
183 state.clientEditors = ["cursor", "vscode"];
184 state.remote =
185 mode === "local-exec" ? { mode } : { mode, host: { kind: "ssh-alias", host: "test-host" } };
186 await renderPicker(["cursor", "vscode"], true);
187 expect(renderer!.root.findByProps({ "aria-label": "Open in Cursor" })).toBeDefined();
188 await act(async () => {
189 renderer!.root.findByProps({ children: "VS Code" }).parent!.props.onClick();
190 });
191 expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscode"');
192 const openButton = renderer!.root.findByProps({ "aria-label": "Open in VS Code" });
193 expect(state.run).not.toHaveBeenCalled();
194 expect(state.openUrl).not.toHaveBeenCalled();
195
196 await act(async () => openButton.props.onClick());
197 if (mode === "local-exec") {
198 expect(state.run).toHaveBeenCalledExactlyOnceWith({
199 environmentId: selected,
200 input: { cwd: "/work/project", editor: "vscode" },
201 });
202 } else {
203 expect(state.openUrl).toHaveBeenCalledExactlyOnceWith(
204 "vscode://vscode-remote/ssh-remote+test-host/work/project",
205 );
206 }
207 },
208);
209
210describe("host editor access", () => {
211 it("uses the selected grant for both the button and a retained shortcut", async () => {
212 state.allowed.add(selected);
213 await renderPicker();
214 const open = primaryButton().props.onClick;
215 const keydown = state.keydown!;
216 state.allowed.delete(selected);
217 state.allowed.add(primary);
218 const event = keyboardEvent();
219 await act(async () => {
220 open();
221 keydown(event);
222 });
223 expect(state.run).not.toHaveBeenCalled();
224 expect(state.setPreferred).not.toHaveBeenCalled();
225 expect(event.preventDefault).not.toHaveBeenCalled();
226
227 await renderPicker();
228 expect(primaryButton().props.disabled).toBe(true);
229 expect(renderer!.root.findByProps({ "aria-label": "Choose editor" }).props.disabled).toBe(true);
230 expect(state.keydown).toBeNull();
231 });
232
233 it("closes an open picker on revocation and launches only after a fresh grant", async () => {
234 state.allowed.add(selected);
235 await renderPicker();
236 await act(async () => {
237 renderer!.root.findByProps({ "data-menu-open": false }).props.onDoubleClick();
238 });
239 expect(renderer!.root.findByProps({ "data-menu-open": true })).toBeDefined();
240 state.allowed.delete(selected);
241 await renderPicker();
242 expect(renderer!.root.findByProps({ "data-menu-open": false })).toBeDefined();
243 state.allowed.add(selected);
244 await renderPicker();
245 expect(renderer!.root.findByProps({ "data-menu-open": false })).toBeDefined();
246 await act(async () => primaryButton().props.onClick());
247 expect(state.run).toHaveBeenCalledExactlyOnceWith({
248 environmentId: selected,
249 input: { cwd: "/work/project", editor: "vscode" },
250 });
251 });
252});
253
254describe("client editor links", () => {
255 it("opens an SSH editor URL without host operate permission", async () => {
256 state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } };
257 await renderPicker();
258 expect(primaryButton().props.disabled).toBe(false);
259 await act(async () => primaryButton().props.onClick());
260 expect(state.openUrl).toHaveBeenCalledExactlyOnceWith(
261 "vscode://vscode-remote/ssh-remote+test-host/work/project",
262 );
263 expect(state.run).not.toHaveBeenCalled();
264 expect(state.markHintSeen).toHaveBeenCalledOnce();
265 expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode");
266 });
267
268 it("does not change preferences when the client rejects the SSH URL", async () => {
269 state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } };
270 state.openUrl.mockResolvedValue(false);
271 await renderPicker();
272 const event = keyboardEvent();
273 await act(async () => state.keydown!(event));
274 expect(state.openUrl).toHaveBeenCalledOnce();
275 expect(event.preventDefault).toHaveBeenCalledOnce();
276 expect(state.run).not.toHaveBeenCalled();
277 expect(state.setPreferred).not.toHaveBeenCalled();
278 expect(state.markHintSeen).not.toHaveBeenCalled();
279 });
280});