1import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import {
3 AuthOrchestrationOperateScope,
4 buildRemoteOpenUrl,
5 EditorId,
6 type EnvironmentId,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import { memo, useCallback, useEffect, useMemo, useRef } from "react";
10import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
11import { usePreferredEditor } from "../../editorPreferences";
12import { editorLabelForPlatform } from "../../editorLabels";
13import {
14 openRemoteEditorUrl,
15 useRemoteCapableEditors,
16 useRemoteOpenHint,
17 useRemoteOpenState,
18} from "../../remoteOpen";
19import { useEnvironment } from "../../state/environments";
20import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react";
21
22import { Group, GroupSeparator } from "../ui/group";
23import {
24 Menu,
25 MenuItem,
26 MenuItemLabel,
27 MenuPopup,
28 MenuShortcut,
29 MenuSub,
30 MenuSubTrigger,
31 MenuSubPopup,
32 MenuTrigger,
33} from "../ui/menu";
34import {
35 AntigravityIcon,
36 CursorIcon,
37 FileExplorerIcon,
38 FinderIcon,
39 Icon,
40 KiroIcon,
41 TraeIcon,
42 VisualStudioCode,
43 VisualStudioCodeInsiders,
44 VSCodium,
45 Zed,
46} from "../Icons";
47import {
48 AquaIcon,
49 CLionIcon,
50 DataGripIcon,
51 DataSpellIcon,
52 GoLandIcon,
53 IntelliJIdeaIcon,
54 PhpStormIcon,
55 PyCharmIcon,
56 RiderIcon,
57 RubyMineIcon,
58 RustRoverIcon,
59 WebStormIcon,
60} from "../JetBrainsIcons";
61import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
62import { shellEnvironment } from "~/state/shell";
63import { useAtomCommand } from "~/state/use-atom-command";
64import {
65 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
66 THREAD_DETAILS_PANEL_ICON_CLASS,
67 THREAD_DETAILS_PANEL_LABEL_CLASS,
68 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
69 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
70} from "./threadDetailsPanelStyles";
71import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
72import { useComposerMenuState } from "./useComposerMenuState";
73
74type OpenInOption = {
75 label: string;
76 Icon: Icon;
77 value: EditorId;
78 kind: "brand" | "generic";
79};
80
81export const resolveOpenInOptions = (
82 platform: string,
83 availableEditors: ReadonlyArray<EditorId>,
84) => {
85 const baseOptions: ReadonlyArray<Omit<OpenInOption, "label">> = [
86 {
87 Icon: CursorIcon,
88 value: "cursor",
89 kind: "brand",
90 },
91 {
92 Icon: TraeIcon,
93 value: "trae",
94 kind: "brand",
95 },
96 {
97 Icon: KiroIcon,
98 value: "kiro",
99 kind: "brand",
100 },
101 {
102 Icon: VisualStudioCode,
103 value: "vscode",
104 kind: "brand",
105 },
106 {
107 Icon: VisualStudioCodeInsiders,
108 value: "vscode-insiders",
109 kind: "brand",
110 },
111 {
112 Icon: VSCodium,
113 value: "vscodium",
114 kind: "brand",
115 },
116 {
117 Icon: Zed,
118 value: "zed",
119 kind: "brand",
120 },
121 {
122 Icon: AntigravityIcon,
123 value: "antigravity",
124 kind: "brand",
125 },
126 {
127 Icon: IntelliJIdeaIcon,
128 value: "idea",
129 kind: "brand",
130 },
131 {
132 Icon: AquaIcon,
133 value: "aqua",
134 kind: "brand",
135 },
136 {
137 Icon: CLionIcon,
138 value: "clion",
139 kind: "brand",
140 },
141 {
142 Icon: DataGripIcon,
143 value: "datagrip",
144 kind: "brand",
145 },
146 {
147 Icon: DataSpellIcon,
148 value: "dataspell",
149 kind: "brand",
150 },
151 {
152 Icon: GoLandIcon,
153 value: "goland",
154 kind: "brand",
155 },
156 {
157 Icon: PhpStormIcon,
158 value: "phpstorm",
159 kind: "brand",
160 },
161 {
162 Icon: PyCharmIcon,
163 value: "pycharm",
164 kind: "brand",
165 },
166 {
167 Icon: RiderIcon,
168 value: "rider",
169 kind: "brand",
170 },
171 {
172 Icon: RubyMineIcon,
173 value: "rubymine",
174 kind: "brand",
175 },
176 {
177 Icon: RustRoverIcon,
178 value: "rustrover",
179 kind: "brand",
180 },
181 {
182 Icon: WebStormIcon,
183 value: "webstorm",
184 kind: "brand",
185 },
186 {
187 Icon: isMacPlatform(platform)
188 ? FinderIcon
189 : isWindowsPlatform(platform)
190 ? FileExplorerIcon
191 : FolderClosedIcon,
192 value: "file-manager",
193 kind: isMacPlatform(platform) || isWindowsPlatform(platform) ? "brand" : "generic",
194 },
195 ];
196 const availableEditorSet = new Set(availableEditors);
197 return baseOptions
198 .filter((option) => availableEditorSet.has(option.value))
199 .map((option) => ({ ...option, label: editorLabelForPlatform(option.value, platform) }));
200};
201
202function getOpenInIconClass(kind: OpenInOption["kind"]) {
203 return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground");
204}
205
206export const OpenInPicker = memo(function OpenInPicker({
207 environmentId,
208 keybindings,
209 availableEditors,
210 openInCwd,
211 presentation = "toolbar",
212 compact = false,
213 enableShortcut = true,
214 displayMode = "toolbar",
215}: {
216 environmentId: EnvironmentId;
217 keybindings: ResolvedKeybindingsConfig;
218 availableEditors: ReadonlyArray<EditorId>;
219 openInCwd: string | null;
220 presentation?: "toolbar" | "menu";
221 compact?: boolean;
222 enableShortcut?: boolean;
223 displayMode?: "toolbar" | "panel";
224}) {
225 const isPanel = displayMode === "panel";
226 const ActionGroup = isPanel ? "div" : Group;
227 const panelAnchorRef = useRef<HTMLDivElement | null>(null);
228 const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");
229 const remote = useRemoteOpenState(environmentId);
230 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
231 const isHostEditorDenied = remote.mode === "local-exec" && !canOperateHost;
232 const canOpenEditor = remote.mode !== "remote-unavailable" && !isHostEditorDenied;
233 const [menuOpen, setMenuOpen] = useComposerMenuState(isHostEditorDenied);
234 const remoteCapableEditors = useRemoteCapableEditors();
235 const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint();
236 const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine";
237 // Remote mode ignores the server's PATH probe: what matters is what runs on
238 // the viewing machine, which only the desktop app can probe.
239 const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors;
240 const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors);
241 const options = useMemo(
242 () => resolveOpenInOptions(navigator.platform, effectiveEditors),
243 [effectiveEditors],
244 );
245 const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null;
246
247 const openInEditor = useCallback(
248 (editorId: EditorId | null) => {
249 if (!openInCwd) return;
250 const editor = editorId ?? preferredEditor;
251 if (!editor) return;
252 if (remote.mode === "remote-unavailable") return;
253 if (remote.mode === "remote-links") {
254 const url = buildRemoteOpenUrl({
255 editor,
256 host: remote.host.host,
257 absolutePath: openInCwd,
258 });
259 if (url === undefined) return;
260 // Only record hint-seen/preferred when the shell actually accepted
261 // the URL (an older desktop build can refuse the editor scheme).
262 void openRemoteEditorUrl(url).then((opened) => {
263 if (!opened) return;
264 markRemoteHintSeen();
265 setPreferredEditor(editor);
266 });
267 return;
268 }
269 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
270 const result = openInEditorMutation({
271 environmentId,
272 input: {
273 cwd: openInCwd,
274 editor,
275 },
276 });
277 setPreferredEditor(editor);
278 return result;
279 },
280 [
281 environmentId,
282 markRemoteHintSeen,
283 openInCwd,
284 openInEditorMutation,
285 preferredEditor,
286 remote,
287 setPreferredEditor,
288 ],
289 );
290
291 const openFavoriteEditorShortcutLabel = useMemo(
292 () => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
293 [keybindings],
294 );
295
296 useEffect(() => {
297 if (!enableShortcut || !canOpenEditor) return;
298 const handler = (e: globalThis.KeyboardEvent) => {
299 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
300 if (!openInCwd) return;
301 if (!preferredEditor) return;
302 if (
303 remote.mode === "local-exec" &&
304 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
305 )
306 return;
307
308 e.preventDefault();
309 void openInEditor(preferredEditor);
310 };
311 window.addEventListener("keydown", handler);
312 return () => window.removeEventListener("keydown", handler);
313 }, [
314 canOpenEditor,
315 enableShortcut,
316 environmentId,
317 keybindings,
318 openInCwd,
319 openInEditor,
320 preferredEditor,
321 remote.mode,
322 ]);
323 const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open";
324
325 const editorItems = (
326 <>
327 {remote.mode === "remote-unavailable" ? (
328 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
329 No SSH route to {environmentLabel}
330 </MenuItem>
331 ) : (
332 <>
333 {options.length === 0 && (
334 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
335 No installed editors found
336 </MenuItem>
337 )}
338 {options.map(({ label, Icon, value, kind }) => (
339 <MenuItem
340 density={presentation === "menu" ? "touch" : "default"}
341 disabled={!openInCwd || !canOpenEditor}
342 key={value}
343 onClick={() => {
344 if (presentation === "menu") {
345 void openInEditor(value);
346 } else {
347 setPreferredEditor(value);
348 }
349 }}
350 >
351 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
352 <MenuItemLabel>{label}</MenuItemLabel>
353 {value === preferredEditor && openFavoriteEditorShortcutLabel && (
354 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
355 )}
356 </MenuItem>
357 ))}
358 {remote.mode === "remote-links" && !remoteHintSeen && (
359 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
360 Opens over SSH. Needs your key on {environmentLabel}
361 </MenuItem>
362 )}
363 </>
364 )}
365 </>
366 );
367 if (presentation === "menu") {
368 return (
369 <>
370 {primaryOption && (
371 <MenuItem
372 density={presentation === "menu" ? "touch" : "default"}
373
374 disabled={!openInCwd || !canOpenEditor}
375 onClick={() => openInEditor(preferredEditor)}
376 >
377 <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} />
378 <MenuItemLabel>Open in {primaryOption.label}</MenuItemLabel>
379 {openFavoriteEditorShortcutLabel && (
380 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
381 )}
382 </MenuItem>
383 )}
384 <MenuSub>
385 <MenuSubTrigger density="touch" disabled={isHostEditorDenied}>
386 <SquareArrowOutUpRightIcon className="size-4" />
387 <MenuItemLabel>Open in…</MenuItemLabel>
388 </MenuSubTrigger>
389 <MenuSubPopup>{editorItems}</MenuSubPopup>
390 </MenuSub>
391 </>
392 );
393 }
394
395 return (
396 <ActionGroup
397 aria-label="Open in editor"
398 role="group"
399 {...(isPanel
400 ? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef }
401 : {})}
402 >
403 <ThreadDetailsControl
404 aria-label={compact ? "Open file in preferred editor" : primaryLabel}
405 size={isPanel ? "sm" : "xs"}
406 variant={isPanel ? "ghost" : "outline"}
407 part="primary"
408 panel={isPanel}
409 disabled={!preferredEditor || !openInCwd || !canOpenEditor}
410 onClick={() => openInEditor(preferredEditor)}
411 >
412 {primaryOption?.Icon ? (
413 <primaryOption.Icon
414 aria-hidden="true"
415 className={cn(
416 isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3.5",
417 getOpenInIconClass(primaryOption.kind),
418 )}
419 />
420 ) : isPanel ? (
421 <SquareArrowOutUpRightIcon
422 aria-hidden="true"
423 className={THREAD_DETAILS_PANEL_ICON_CLASS}
424 />
425 ) : null}
426 <span
427 className={cn(
428 compact
429 ? "sr-only"
430 : "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
431 isPanel && cn("not-sr-only ml-0 min-w-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
432 )}
433 >
434 {primaryLabel}
435 </span>
436 </ThreadDetailsControl>
437 {isPanel ? (
438 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
439 ) : (
440 <GroupSeparator {...(!compact ? { className: "hidden @3xl/header-actions:block" } : {})} />
441 )}
442 <Menu open={menuOpen} onOpenChange={setMenuOpen}>
443 <MenuTrigger
444 disabled={isHostEditorDenied}
445 render={
446 <ThreadDetailsControl
447 aria-label="Choose editor"
448 size={isPanel ? "sm" : "icon-xs"}
449 variant={isPanel ? "ghost" : "outline"}
450 part="secondary"
451 panel={isPanel}
452 />
453 }
454 >
455 <ChevronDownIcon
456 aria-hidden="true"
457 className={isPanel ? THREAD_DETAILS_PANEL_CHEVRON_CLASS : "size-4"}
458 />
459 </MenuTrigger>
460 <MenuPopup
461 align="end"
462 {...(isPanel ? { anchor: panelAnchorRef } : {})}
463 className={isPanel ? "w-(--anchor-width)" : undefined}
464 >
465 {editorItems}
466 </MenuPopup>
467 </Menu>
468 </ActionGroup>
469 );
470});