1import {
2 EDITORS,
3 type ContextMenuItem,
4 type EditorId,
5 type EnvironmentId,
6} from "@t3tools/contracts";
7
8import { resolveDiffPathForWorkspace } from "./diffFileActions";
9import {
10 revealInFileExplorerLabelForKind,
11 revealInFileExplorerLabelForOs,
12} from "~/components/preview/fileExplorerLabel";
13import { readLocalApi } from "./localApi";
14import { serverEnvironment } from "./state/server";
15import { shellEnvironment } from "./state/shell";
16import { useAtomCommand } from "./state/use-atom-command";
17import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
18import { toastManager } from "./components/ui/toast";
19import { useAtomValue } from "@effect/atom-react";
20
21export type FileContextMenuAction =
22 | "reveal-in-folder"
23 | "open"
24 /** Submenu parent; never the activated id. */
25 | "open-with"
26 | `editor:${EditorId}`;
27
28export interface FileContextMenuTarget {
29 readonly environmentId: EnvironmentId | null;
30 /** Repo- or workspace-relative file path, as shown in diffs. */
31 readonly filePath: string;
32 readonly workspaceRoot: string | undefined;
33 readonly repositoryRoot?: string | undefined;
34}
35
36export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget): string | null {
37 const workspaceFilePath = resolveDiffPathForWorkspace({
38 filePath: target.filePath,
39 workspaceRoot: target.workspaceRoot,
40 repositoryRoot: target.repositoryRoot,
41 });
42 if (workspaceFilePath === null) return null;
43 if (target.workspaceRoot === undefined) {
44 return workspaceFilePath.startsWith("/") || /^[a-zA-Z]:/.test(workspaceFilePath)
45 ? workspaceFilePath
46 : null;
47 }
48 return resolvePathLinkTarget(workspaceFilePath, target.workspaceRoot);
49}
50
51const EDITOR_LABEL_BY_ID = new Map(EDITORS.map((editor) => [editor.id, editor.label]));
52
53export interface FileContextMenuCapabilities {
54 readonly revealLabel: string | undefined;
55 readonly canOpenDefault: boolean;
56 readonly editorIds: ReadonlyArray<EditorId>;
57}
58
59function buildFileContextMenuItems(input: {
60 readonly hasAbsolutePath: boolean;
61 readonly capabilities: FileContextMenuCapabilities;
62}): readonly ContextMenuItem<FileContextMenuAction>[] {
63 if (!input.hasAbsolutePath) return [];
64 const items: ContextMenuItem<FileContextMenuAction>[] = [];
65 if (input.capabilities.canOpenDefault) {
66 items.push({ id: "open", label: "Open", icon: "pencil" });
67 }
68 if (input.capabilities.revealLabel !== undefined) {
69 items.push({
70 id: "reveal-in-folder",
71 label: input.capabilities.revealLabel,
72 icon: "folder-tree",
73 });
74 }
75 const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager");
76 if (editorIds.length > 0) {
77 items.push({
78 id: "open-with",
79 label: "Open with",
80 children: editorIds.map((editorId) => ({
81 id: `editor:${editorId}` as FileContextMenuAction,
82 label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId,
83 })),
84 });
85 }
86 return items;
87}
88
89export function useFileContextMenu(environmentId: EnvironmentId | null) {
90 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false });
91 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
92
93 const availableEditors = serverConfig?.availableEditors ?? [];
94 const capabilities: FileContextMenuCapabilities = {
95 // WSL can reveal in Windows Explorer despite reporting Linux; use server wording.
96 revealLabel:
97 environmentId !== null &&
98 serverConfig?.shellRevealInFileManager === true &&
99 serverConfig.availableEditors.includes("file-manager")
100 ? serverConfig.shellRevealInFileManagerKind === undefined
101 ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os)
102 : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind)
103 : undefined,
104 canOpenDefault: availableEditors.includes("file-manager"),
105 editorIds: availableEditors,
106 };
107
108 const activate = async (
109 action: FileContextMenuAction,
110 target: FileContextMenuTarget,
111 ): Promise<void> => {
112 const absolutePath = resolveFileContextMenuAbsolutePath(target);
113 if (absolutePath === null || environmentId === null) return;
114
115 const reveal = action === "reveal-in-folder";
116 const editor =
117 action === "open" || reveal
118 ? ("file-manager" as const)
119 : (action.slice("editor:".length) as EditorId);
120 if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return;
121
122 const result = await openInEditor({
123 environmentId,
124 input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) },
125 });
126 if (result._tag !== "Failure") return;
127 toastManager.add({
128 type: "error",
129 title:
130 action === "open"
131 ? "Could not open file"
132 : reveal
133 ? "Unable to reveal file"
134 : `Could not open in ${EDITOR_LABEL_BY_ID.get(editor) ?? editor}`,
135 description: absolutePath,
136 });
137 };
138
139 const show = async (
140 target: FileContextMenuTarget,
141 position?: { x: number; y: number },
142 ): Promise<void> => {
143 const api = readLocalApi();
144 const items = buildFileContextMenuItems({
145 hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null,
146 capabilities,
147 });
148 if (items.length === 0 || api === undefined) return;
149 const clicked = await api.contextMenu.show(items, position);
150 if (clicked === null) return;
151 await activate(clicked as FileContextMenuAction, target);
152 };
153
154 return {
155 buildItems: (target: FileContextMenuTarget) =>
156 buildFileContextMenuItems({
157 hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null,
158 capabilities,
159 }),
160 capabilities,
161 activate,
162 show,
163 };
164}
165
166export function useFileContextMenuHandler(environmentId: EnvironmentId | null) {
167 const contextMenu = useFileContextMenu(environmentId);
168 return (target: FileContextMenuTarget, event?: { clientX: number; clientY: number }) => {
169 void contextMenu.show(target, event ? { x: event.clientX, y: event.clientY } : undefined);
170 };
171}