| 1 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | import { |
| 3 | AuthOrchestrationOperateScope, |
| 4 | buildRemoteOpenUrl, |
| 5 | EditorId, |
| 6 | type EnvironmentId, |
| 7 | type ResolvedKeybindingsConfig, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { memo, useCallback, useEffect, useMemo, useRef } from "react"; |
| 10 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 11 | import { usePreferredEditor } from "../../editorPreferences"; |
| 12 | import { editorLabelForPlatform } from "../../editorLabels"; |
| 13 | import { |
| 14 | openRemoteEditorUrl, |
| 15 | useRemoteCapableEditors, |
| 16 | useRemoteOpenHint, |
| 17 | useRemoteOpenState, |
| 18 | } from "../../remoteOpen"; |
| 19 | import { useEnvironment } from "../../state/environments"; |
| 20 | import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react"; |
| 21 | |
| 22 | import { Group, GroupSeparator } from "../ui/group"; |
| 23 | import { |
| 24 | Menu, |
| 25 | MenuItem, |
| 26 | MenuItemLabel, |
| 27 | MenuPopup, |
| 28 | MenuShortcut, |
| 29 | MenuSub, |
| 30 | MenuSubTrigger, |
| 31 | MenuSubPopup, |
| 32 | MenuTrigger, |
| 33 | } from "../ui/menu"; |
| 34 | import { |
| 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"; |
| 47 | import { |
| 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"; |
| 61 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 62 | import { shellEnvironment } from "~/state/shell"; |
| 63 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 64 | import { |
| 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"; |
| 71 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 72 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 73 | |
| 74 | type OpenInOption = { |
| 75 | label: string; |
| 76 | Icon: Icon; |
| 77 | value: EditorId; |
| 78 | kind: "brand" | "generic"; |
| 79 | }; |
| 80 | |
| 81 | export 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 | |
| 202 | function getOpenInIconClass(kind: OpenInOption["kind"]) { |
| 203 | return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground"); |
| 204 | } |
| 205 | |
| 206 | export 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 | }); |