import { buildRemoteOpenUrl, EditorId, type EnvironmentId, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenHint, useRemoteOpenState, } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; import { AntigravityIcon, CursorIcon, Icon, KiroIcon, TraeIcon, VisualStudioCode, VisualStudioCodeInsiders, VSCodium, Zed, } from "../Icons"; import { AquaIcon, CLionIcon, DataGripIcon, DataSpellIcon, GoLandIcon, IntelliJIdeaIcon, PhpStormIcon, PyCharmIcon, RiderIcon, RubyMineIcon, RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; import { cn } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; type OpenInOption = { label: string; Icon: Icon; value: EditorId; kind: "brand" | "generic"; }; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray> = [ { Icon: CursorIcon, value: "cursor", kind: "brand", }, { Icon: TraeIcon, value: "trae", kind: "brand", }, { Icon: KiroIcon, value: "kiro", kind: "brand", }, { Icon: VisualStudioCode, value: "vscode", kind: "brand", }, { Icon: VisualStudioCodeInsiders, value: "vscode-insiders", kind: "brand", }, { Icon: VSCodium, value: "vscodium", kind: "brand", }, { Icon: Zed, value: "zed", kind: "brand", }, { Icon: AntigravityIcon, value: "antigravity", kind: "brand", }, { Icon: IntelliJIdeaIcon, value: "idea", kind: "brand", }, { Icon: AquaIcon, value: "aqua", kind: "brand", }, { Icon: CLionIcon, value: "clion", kind: "brand", }, { Icon: DataGripIcon, value: "datagrip", kind: "brand", }, { Icon: DataSpellIcon, value: "dataspell", kind: "brand", }, { Icon: GoLandIcon, value: "goland", kind: "brand", }, { Icon: PhpStormIcon, value: "phpstorm", kind: "brand", }, { Icon: PyCharmIcon, value: "pycharm", kind: "brand", }, { Icon: RiderIcon, value: "rider", kind: "brand", }, { Icon: RubyMineIcon, value: "rubymine", kind: "brand", }, { Icon: RustRoverIcon, value: "rustrover", kind: "brand", }, { Icon: WebStormIcon, value: "webstorm", kind: "brand", }, { Icon: FolderClosedIcon, value: "file-manager", kind: "generic", }, ]; const availableEditorSet = new Set(availableEditors); return baseOptions .filter((option) => availableEditorSet.has(option.value)) .map((option) => ({ ...option, label: editorLabelForPlatform(option.value, platform) })); }; function getOpenInIconClass(kind: OpenInOption["kind"]) { return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground"); } export const OpenInPicker = memo(function OpenInPicker({ environmentId, keybindings, availableEditors, openInCwd, compact = false, enableShortcut = true, }: { environmentId: EnvironmentId; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInCwd: string | null; compact?: boolean; enableShortcut?: boolean; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const remote = useRemoteOpenState(environmentId); const remoteCapableEditors = useRemoteCapableEditors(); const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; // Remote mode ignores the server's PATH probe: what matters is what runs on // the viewing machine, which only the desktop app can probe. const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); const options = useMemo( () => resolveOptions(navigator.platform, effectiveEditors), [effectiveEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; if (remote.mode === "remote-unavailable") return; if (remote.mode === "remote-links") { const url = buildRemoteOpenUrl({ editor, host: remote.host.host, absolutePath: openInCwd, }); if (url === undefined) return; // Only record hint-seen/preferred when the shell actually accepted // the URL (an older desktop build can refuse the editor scheme). void openRemoteEditorUrl(url).then((opened) => { if (!opened) return; markRemoteHintSeen(); setPreferredEditor(editor); }); return; } const result = openInEditorMutation({ environmentId, input: { cwd: openInCwd, editor, }, }); setPreferredEditor(editor); return result; }, [ environmentId, markRemoteHintSeen, openInCwd, openInEditorMutation, preferredEditor, remote, setPreferredEditor, ], ); const openFavoriteEditorShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), [keybindings], ); useEffect(() => { if (!enableShortcut) return; const handler = (e: globalThis.KeyboardEvent) => { if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!openInCwd) return; if (!preferredEditor) return; e.preventDefault(); void openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); return ( } > {remote.mode === "remote-unavailable" ? ( No SSH route to {environmentLabel} ) : ( <> {options.length === 0 && No installed editors found} {options.map(({ label, Icon, value, kind }) => ( openInEditor(value)}> ))} {remote.mode === "remote-links" && !remoteHintSeen && ( Opens over SSH. Needs your key on {environmentLabel} )} )} ); });