| 1 | /** |
| 2 | * Right-click actions for a workspace file: reveal it in the environment's |
| 3 | * file manager and open it in an editor. Reuse the chat file-chip menu's |
| 4 | * machinery: reveal rides `shell.openInEditor` with `reveal: true`, which the |
| 5 | * server only honors when its `shellRevealInFileManager` config flag is set, |
| 6 | * so both actions work for every client and connection mode. |
| 7 | */ |
| 8 | import { |
| 9 | EDITORS, |
| 10 | type ContextMenuItem, |
| 11 | type EditorId, |
| 12 | type EnvironmentId, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import { useCallback, useMemo } from "react"; |
| 15 | |
| 16 | import { resolveDiffPathForWorkspace } from "./diffFileActions"; |
| 17 | import { |
| 18 | revealInFileExplorerLabelForKind, |
| 19 | revealInFileExplorerLabelForOs, |
| 20 | } from "~/components/preview/fileExplorerLabel"; |
| 21 | import { readLocalApi } from "./localApi"; |
| 22 | import { serverEnvironment } from "./state/server"; |
| 23 | import { shellEnvironment } from "./state/shell"; |
| 24 | import { useAtomCommand } from "./state/use-atom-command"; |
| 25 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 26 | import { toastManager } from "./components/ui/toast"; |
| 27 | import { useAtomValue } from "@effect/atom-react"; |
| 28 | |
| 29 | export type FileContextMenuAction = |
| 30 | | "reveal-in-folder" |
| 31 | | "open" |
| 32 | /** Submenu parent; never the activated id. */ |
| 33 | | "open-with" |
| 34 | | `editor:${EditorId}`; |
| 35 | |
| 36 | export interface FileContextMenuTarget { |
| 37 | readonly environmentId: EnvironmentId | null; |
| 38 | /** Repo- or workspace-relative file path, as shown in diffs. */ |
| 39 | readonly filePath: string; |
| 40 | readonly workspaceRoot: string | undefined; |
| 41 | readonly repositoryRoot?: string | undefined; |
| 42 | } |
| 43 | |
| 44 | /** Absolute path on the environment host, or null when it cannot be resolved. */ |
| 45 | /** |
| 46 | * Absolute path on the environment host for a diff-style target, resolving |
| 47 | * repo-relative paths through the workspace root like every other diff |
| 48 | * surface. Returns null when the path cannot be resolved, which callers must |
| 49 | * treat as "no file actions available". |
| 50 | */ |
| 51 | export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget): string | null { |
| 52 | const workspaceFilePath = resolveDiffPathForWorkspace({ |
| 53 | filePath: target.filePath, |
| 54 | workspaceRoot: target.workspaceRoot, |
| 55 | repositoryRoot: target.repositoryRoot, |
| 56 | }); |
| 57 | if (workspaceFilePath === null) return null; |
| 58 | if (target.workspaceRoot === undefined) { |
| 59 | return workspaceFilePath.startsWith("/") || /^[a-zA-Z]:/.test(workspaceFilePath) |
| 60 | ? workspaceFilePath |
| 61 | : null; |
| 62 | } |
| 63 | return resolvePathLinkTarget(workspaceFilePath, target.workspaceRoot); |
| 64 | } |
| 65 | |
| 66 | const EDITOR_LABEL_BY_ID = new Map(EDITORS.map((editor) => [editor.id, editor.label])); |
| 67 | |
| 68 | export interface FileContextMenuCapabilities { |
| 69 | readonly revealLabel: string | undefined; |
| 70 | readonly canOpenDefault: boolean; |
| 71 | readonly editorIds: ReadonlyArray<EditorId>; |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Menu items for a resolved file, offering only what the environment's config |
| 76 | * advertises: default-app open, reveal (with server-provided wording), and an |
| 77 | * "Open with" submenu of detected editors. Empty when nothing can act. |
| 78 | */ |
| 79 | export function buildFileContextMenuItems(input: { |
| 80 | readonly hasAbsolutePath: boolean; |
| 81 | readonly capabilities: FileContextMenuCapabilities; |
| 82 | }): readonly ContextMenuItem<FileContextMenuAction>[] { |
| 83 | // Without a resolvable absolute path nothing here can act on the file. |
| 84 | if (!input.hasAbsolutePath) return []; |
| 85 | const items: ContextMenuItem<FileContextMenuAction>[] = []; |
| 86 | if (input.capabilities.canOpenDefault) { |
| 87 | items.push({ id: "open", label: "Open", icon: "pencil" }); |
| 88 | } |
| 89 | if (input.capabilities.revealLabel !== undefined) { |
| 90 | items.push({ |
| 91 | id: "reveal-in-folder", |
| 92 | label: input.capabilities.revealLabel, |
| 93 | icon: "folder-tree", |
| 94 | }); |
| 95 | } |
| 96 | const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); |
| 97 | if (editorIds.length > 0) { |
| 98 | items.push({ |
| 99 | id: "open-with", |
| 100 | label: "Open with", |
| 101 | children: editorIds.map((editorId) => ({ |
| 102 | id: `editor:${editorId}` as FileContextMenuAction, |
| 103 | label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId, |
| 104 | })), |
| 105 | }); |
| 106 | } |
| 107 | return items; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Context-menu actions for files. The environment id is fixed per component |
| 112 | * (a thread's environment, a file browser's environment), so capabilities |
| 113 | * resolve once per hook call. |
| 114 | */ |
| 115 | /** Builds and dispatches the file context menu for one environment's files. */ |
| 116 | export function useFileContextMenu(environmentId: EnvironmentId | null) { |
| 117 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false }); |
| 118 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 119 | |
| 120 | return useMemo(() => { |
| 121 | const availableEditors = serverConfig?.availableEditors ?? []; |
| 122 | const capabilities: FileContextMenuCapabilities = { |
| 123 | // The reveal wording comes from the server because on WSL the reveal can |
| 124 | // run through Windows File Explorer even though the host reports Linux. |
| 125 | revealLabel: |
| 126 | environmentId !== null && |
| 127 | serverConfig?.shellRevealInFileManager === true && |
| 128 | serverConfig.availableEditors.includes("file-manager") |
| 129 | ? serverConfig.shellRevealInFileManagerKind === undefined |
| 130 | ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) |
| 131 | : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) |
| 132 | : undefined, |
| 133 | canOpenDefault: availableEditors.includes("file-manager"), |
| 134 | editorIds: availableEditors, |
| 135 | }; |
| 136 | |
| 137 | const activate = async ( |
| 138 | action: FileContextMenuAction, |
| 139 | target: FileContextMenuTarget, |
| 140 | ): Promise<void> => { |
| 141 | const absolutePath = resolveFileContextMenuAbsolutePath(target); |
| 142 | if (absolutePath === null || environmentId === null) return; |
| 143 | |
| 144 | const reveal = action === "reveal-in-folder"; |
| 145 | const editor = |
| 146 | action === "open" || reveal |
| 147 | ? ("file-manager" as const) |
| 148 | : (action.slice("editor:".length) as EditorId); |
| 149 | if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return; |
| 150 | |
| 151 | const result = await openInEditor({ |
| 152 | environmentId, |
| 153 | input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) }, |
| 154 | }); |
| 155 | if (result._tag !== "Failure") return; |
| 156 | toastManager.add({ |
| 157 | type: "error", |
| 158 | title: |
| 159 | action === "open" |
| 160 | ? "Could not open file" |
| 161 | : reveal |
| 162 | ? "Unable to reveal file" |
| 163 | : `Could not open in ${EDITOR_LABEL_BY_ID.get(editor) ?? editor}`, |
| 164 | description: absolutePath, |
| 165 | }); |
| 166 | }; |
| 167 | |
| 168 | const show = async ( |
| 169 | target: FileContextMenuTarget, |
| 170 | position?: { x: number; y: number }, |
| 171 | ): Promise<void> => { |
| 172 | const api = readLocalApi(); |
| 173 | const items = buildFileContextMenuItems({ |
| 174 | hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null, |
| 175 | capabilities, |
| 176 | }); |
| 177 | if (items.length === 0 || api === undefined) return; |
| 178 | const clicked = await api.contextMenu.show(items, position); |
| 179 | if (clicked === null) return; |
| 180 | await activate(clicked as FileContextMenuAction, target); |
| 181 | }; |
| 182 | |
| 183 | return { |
| 184 | buildItems: (target: FileContextMenuTarget) => |
| 185 | buildFileContextMenuItems({ |
| 186 | hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null, |
| 187 | capabilities, |
| 188 | }), |
| 189 | capabilities, |
| 190 | activate, |
| 191 | show, |
| 192 | }; |
| 193 | }, [environmentId, openInEditor, serverConfig]); |
| 194 | } |
| 195 | |
| 196 | /** Convenience callback for onContextMenu handlers. */ |
| 197 | /** Returns an onContextMenu callback that shows the menu at the pointer. */ |
| 198 | export function useFileContextMenuHandler(environmentId: EnvironmentId | null) { |
| 199 | const contextMenu = useFileContextMenu(environmentId); |
| 200 | return useCallback( |
| 201 | (target: FileContextMenuTarget, event?: { clientX: number; clientY: number }) => { |
| 202 | void contextMenu.show(target, event ? { x: event.clientX, y: event.clientY } : undefined); |
| 203 | }, |
| 204 | [contextMenu], |
| 205 | ); |
| 206 | } |