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 */
8import {
9 EDITORS,
10 type ContextMenuItem,
11 type EditorId,
12 type EnvironmentId,
13} from "@t3tools/contracts";
14import { useCallback, useMemo } from "react";
15
16import { resolveDiffPathForWorkspace } from "./diffFileActions";
17import {
18 revealInFileExplorerLabelForKind,
19 revealInFileExplorerLabelForOs,
20} from "~/components/preview/fileExplorerLabel";
21import { readLocalApi } from "./localApi";
22import { serverEnvironment } from "./state/server";
23import { shellEnvironment } from "./state/shell";
24import { useAtomCommand } from "./state/use-atom-command";
25import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
26import { toastManager } from "./components/ui/toast";
27import { useAtomValue } from "@effect/atom-react";
28
29export type FileContextMenuAction =
30 | "reveal-in-folder"
31 | "open"
32 /** Submenu parent; never the activated id. */
33 | "open-with"
34 | `editor:${EditorId}`;
35
36export 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 */
51export 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
66const EDITOR_LABEL_BY_ID = new Map(EDITORS.map((editor) => [editor.id, editor.label]));
67
68export 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 */
79export 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. */
116export 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. */
198export 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}