authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-18 10:30:23+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 17:30:23-07:00
log9051ed9c3923b7b5fb88d99fe9267380d69baa51
treea94039eea42f79170dc8e022462910f2e1c3624e
parent256e630ef7b8372f70f80590e85aada62da821ab
signature Signed by PGP key B5690EEEBB952194

feat(web): open diff files from a right-click context menu (#11842)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

6 files changed, 398 insertions(+), 2 deletions(-)

apps/web/src/components/DiffPanel.tsx+21
...@@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime";...@@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime";
27import { useCallback, useEffect, useMemo, useRef, useState } from "react";27import { useCallback, useEffect, useMemo, useRef, useState } from "react";
28import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";28import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
29import { useOpenInPreferredEditor } from "../editorPreferences";29import { useOpenInPreferredEditor } from "../editorPreferences";
30import { useFileContextMenuHandler } from "../fileContextMenu";
30import { type DraftId } from "../composerDraftStore";31import { type DraftId } from "../composerDraftStore";
31import { openDiffFilePrimaryAction } from "../diffFileActions";32import { openDiffFilePrimaryAction } from "../diffFileActions";
32import { useCheckpointDiff } from "~/lib/checkpointDiffState";33import { useCheckpointDiff } from "~/lib/checkpointDiffState";
...@@ -170,6 +171,7 @@ export default function DiffPanel({...@@ -170,6 +171,7 @@ export default function DiffPanel({
170 const serverConfig = useAtomValue(171 const serverConfig = useAtomValue(
171 serverEnvironment.configValueAtom(activeThread?.environmentId ?? null),172 serverEnvironment.configValueAtom(activeThread?.environmentId ?? null),
172 );173 );
174 const onFileContextMenu = useFileContextMenuHandler(activeThread?.environmentId ?? null);
173 const openInPreferredEditor = useOpenInPreferredEditor(175 const openInPreferredEditor = useOpenInPreferredEditor(
174 activeThread?.environmentId ?? null,176 activeThread?.environmentId ?? null,
175 serverConfig?.availableEditors ?? [],177 serverConfig?.availableEditors ?? [],
...@@ -1067,6 +1069,25 @@ export default function DiffPanel({...@@ -1067,6 +1069,25 @@ export default function DiffPanel({
1067 );1069 );
1068 if (file) toggleDiffFileCollapsed(file.fileKey);1070 if (file) toggleDiffFileCollapsed(file.fileKey);
1069 }}1071 }}
1072 onContextMenuCapture={(event) => {
1073 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1074 const title = composedPath.find(
1075 (node): node is HTMLElement =>
1076 node instanceof HTMLElement && node.hasAttribute("data-title"),
1077 );
1078 const filePath = title?.textContent?.trim();
1079 if (!filePath) return;
1080 event.preventDefault();
1081 onFileContextMenu(
1082 {
1083 environmentId: activeThread?.environmentId ?? null,
1084 filePath,
1085 workspaceRoot: activeCwd,
1086 repositoryRoot: activeRepositoryRoot,
1087 },
1088 event,
1089 );
1090 }}
1070 >1091 >
1071 <AnnotatableCodeView1092 <AnnotatableCodeView
1072 key={collapseScopeKey ?? reviewSectionId}1093 key={collapseScopeKey ?? reviewSectionId}
apps/web/src/components/chat/ChangedFilesTree.tsx+24-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { type TurnId } from "@t3tools/contracts";1import { type TurnId } from "@t3tools/contracts";
2import { memo, useCallback, useMemo, useState } from "react";2import { type MouseEvent, memo, useCallback, useMemo, useState } from "react";
3import { type TurnDiffFileChange } from "../../types";3import { type TurnDiffFileChange } from "../../types";
4import {4import {
5 buildTurnDiffTree,5 buildTurnDiffTree,
...@@ -22,6 +22,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -22,6 +22,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2222
23const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};23const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2424
25/** Opens the OS-level context menu for a changed file (reveal in file manager, open in editor). */
26export type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void;
27
25export const ChangedFilesCard = memo(function ChangedFilesCard(props: {28export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
26 turnId: TurnId;29 turnId: TurnId;
27 files: ReadonlyArray<TurnDiffFileChange>;30 files: ReadonlyArray<TurnDiffFileChange>;
...@@ -29,6 +32,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -29,6 +32,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
29 resolvedTheme: "light" | "dark";32 resolvedTheme: "light" | "dark";
30 onToggleAllDirectories: () => void;33 onToggleAllDirectories: () => void;
31 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;34 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
35 onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
32}) {36}) {
33 const {37 const {
34 turnId,38 turnId,
...@@ -37,6 +41,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -37,6 +41,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
37 resolvedTheme,41 resolvedTheme,
38 onToggleAllDirectories,42 onToggleAllDirectories,
39 onOpenTurnDiff,43 onOpenTurnDiff,
44 onFileContextMenu,
40 } = props;45 } = props;
41 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);46 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
42 const hasDirectories = files.some((file) => /[/\\]/.test(file.path));47 const hasDirectories = files.some((file) => /[/\\]/.test(file.path));
...@@ -117,6 +122,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -117,6 +122,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
117 allDirectoriesExpanded={allDirectoriesExpanded}122 allDirectoriesExpanded={allDirectoriesExpanded}
118 resolvedTheme={resolvedTheme}123 resolvedTheme={resolvedTheme}
119 onOpenTurnDiff={onOpenTurnDiff}124 onOpenTurnDiff={onOpenTurnDiff}
125 onFileContextMenu={onFileContextMenu}
120 />126 />
121 </div>127 </div>
122 );128 );
...@@ -128,8 +134,16 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -128,8 +134,16 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
128 allDirectoriesExpanded: boolean;134 allDirectoriesExpanded: boolean;
129 resolvedTheme: "light" | "dark";135 resolvedTheme: "light" | "dark";
130 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;136 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
137 onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
131}) {138}) {
132 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;139 const {
140 files,
141 allDirectoriesExpanded,
142 onOpenTurnDiff,
143 resolvedTheme,
144 turnId,
145 onFileContextMenu,
146 } = props;
133 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);147 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
134 const directoryPathsKey = useMemo(148 const directoryPathsKey = useMemo(
135 () => collectDirectoryPaths(treeNodes).join("\u0000"),149 () => collectDirectoryPaths(treeNodes).join("\u0000"),
...@@ -214,6 +228,14 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -214,6 +228,14 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
214 className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"228 className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
215 style={{ paddingLeft: `${leftPadding}px` }}229 style={{ paddingLeft: `${leftPadding}px` }}
216 onClick={() => onOpenTurnDiff(turnId, node.path)}230 onClick={() => onOpenTurnDiff(turnId, node.path)}
231 onContextMenu={
232 onFileContextMenu
233 ? (event) => {
234 event.preventDefault();
235 onFileContextMenu(node.path, event);
236 }
237 : undefined
238 }
217 >239 >
218 {hasDirectoryNodes || depth > 0 ? (240 {hasDirectoryNodes || depth > 0 ? (
219 <span aria-hidden="true" className="size-3.5 shrink-0" />241 <span aria-hidden="true" className="size-3.5 shrink-0" />
apps/web/src/components/chat/MessagesTimeline.tsx+30
...@@ -151,6 +151,10 @@ import {...@@ -151,6 +151,10 @@ import {
151} from "./SnapShotAttachmentDetails";151} from "./SnapShotAttachmentDetails";
152import { ProposedPlanCard } from "./ProposedPlanCard";152import { ProposedPlanCard } from "./ProposedPlanCard";
153import { ChangedFilesCard } from "./ChangedFilesTree";153import { ChangedFilesCard } from "./ChangedFilesTree";
154import { useAtomValue } from "@effect/atom-react";
155import { useFileContextMenuHandler } from "../../fileContextMenu";
156import { useProject, useThread } from "../../state/entities";
157import { serverEnvironment } from "../../state/server";
154import {158import {
155 CHAT_TIMELINE_ANCHOR_OFFSET,159 CHAT_TIMELINE_ANCHOR_OFFSET,
156 readTimelinePosition,160 readTimelinePosition,
...@@ -3355,12 +3359,24 @@ function AssistantChangedFilesSectionInner({...@@ -3355,12 +3359,24 @@ function AssistantChangedFilesSectionInner({
3355 resolvedTheme: "light" | "dark";3359 resolvedTheme: "light" | "dark";
3356 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;3360 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
3357}) {3361}) {
3362 const ctx = use(TimelineRowCtx);
3358 const persistedExpanded = useUiStateStore(3363 const persistedExpanded = useUiStateStore(
3359 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],3364 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
3360 );3365 );
3361 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);3366 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
3362 const allDirectoriesExpanded = persistedExpanded ?? false;3367 const allDirectoriesExpanded = persistedExpanded ?? false;
33633368
3369 const thread = useThread(ctx.threadRef);
3370 const activeProject = useProject(
3371 thread && thread.projectId
3372 ? { environmentId: thread.environmentId, projectId: thread.projectId }
3373 : null,
3374 );
3375 const serverConfig = useAtomValue(
3376 serverEnvironment.configValueAtom(ctx.activeThreadEnvironmentId),
3377 );
3378 const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId);
3379
3364 return (3380 return (
3365 <ChangedFilesCard3381 <ChangedFilesCard
3366 turnId={turnSummary.turnId}3382 turnId={turnSummary.turnId}
...@@ -3371,6 +3387,20 @@ function AssistantChangedFilesSectionInner({...@@ -3371,6 +3387,20 @@ function AssistantChangedFilesSectionInner({
3371 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)3387 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
3372 }3388 }
3373 onOpenTurnDiff={onOpenTurnDiff}3389 onOpenTurnDiff={onOpenTurnDiff}
3390 onFileContextMenu={(filePath, event) =>
3391 onFileContextMenu(
3392 {
3393 environmentId: ctx.activeThreadEnvironmentId,
3394 filePath,
3395 workspaceRoot: ctx.workspaceRoot,
3396 repositoryRoot:
3397 thread?.worktreePath == null
3398 ? activeProject?.repositoryIdentity?.rootPath
3399 : undefined,
3400 },
3401 event,
3402 )
3403 }
3374 />3404 />
3375 );3405 );
3376}3406}
apps/web/src/components/files/FileBrowserPanel.tsx+15
...@@ -17,6 +17,7 @@ import { useComposerHandleContext } from "~/composerHandleContext";...@@ -17,6 +17,7 @@ import { useComposerHandleContext } from "~/composerHandleContext";
17import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";17import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
18import { useTheme } from "~/hooks/useTheme";18import { useTheme } from "~/hooks/useTheme";
19import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";19import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
20import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu";
20import { readLocalApi } from "~/localApi";21import { readLocalApi } from "~/localApi";
21import { T3_PIERRE_ICONS } from "~/pierre-icons";22import { T3_PIERRE_ICONS } from "~/pierre-icons";
22import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";23import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
...@@ -105,6 +106,7 @@ export default function FileBrowserPanel({...@@ -105,6 +106,7 @@ export default function FileBrowserPanel({
105}: FileBrowserPanelProps) {106}: FileBrowserPanelProps) {
106 const { resolvedTheme } = useTheme();107 const { resolvedTheme } = useTheme();
107 const composerRef = useComposerHandleContext();108 const composerRef = useComposerHandleContext();
109 const fileContextMenu = useFileContextMenu(environmentId);
108 const {110 const {
109 entries: directoryEntries,111 entries: directoryEntries,
110 load,112 load,
...@@ -157,6 +159,7 @@ export default function FileBrowserPanel({...@@ -157,6 +159,7 @@ export default function FileBrowserPanel({
157 return () => document.removeEventListener("contextmenu", capturePointer, true);159 return () => document.removeEventListener("contextmenu", capturePointer, true);
158 }, []);160 }, []);
159161
162 /** Combines the file actions (open/reveal/open with) with the panel's own mention actions. */
160 const showEntryContextMenu = async (163 const showEntryContextMenu = async (
161 item: TreeContextMenuItem,164 item: TreeContextMenuItem,
162 context: TreeContextMenuOpenContext,165 context: TreeContextMenuOpenContext,
...@@ -174,14 +177,26 @@ export default function FileBrowserPanel({...@@ -174,14 +177,26 @@ export default function FileBrowserPanel({
174 const position = pointerIsFresh177 const position = pointerIsFresh
175 ? { x: pointer.x, y: pointer.y }178 ? { x: pointer.x, y: pointer.y }
176 : { x: anchorRect.left, y: anchorRect.bottom };179 : { x: anchorRect.left, y: anchorRect.bottom };
180 const fileTarget = { environmentId, filePath: relativePath, workspaceRoot: cwd };
181 const fileMenuItems = fileContextMenu.buildItems(fileTarget);
177 try {182 try {
178 const clicked = await api.contextMenu.show(183 const clicked = await api.contextMenu.show(
179 [184 [
185 ...fileMenuItems,
180 { id: "copy-mention", label: "Copy mention" },186 { id: "copy-mention", label: "Copy mention" },
181 { id: "add-to-chat", label: "Add to chat" },187 { id: "add-to-chat", label: "Add to chat" },
182 ],188 ],
183 position,189 position,
184 );190 );
191 if (clicked === null) return;
192 // "Open with" submenu selections report the child id ("editor:<id>"),
193 // which is not present in the top-level item list.
194 const isFileMenuAction =
195 fileMenuItems.some((entry) => entry.id === clicked) || clicked.startsWith("editor:");
196 if (isFileMenuAction) {
197 await fileContextMenu.activate(clicked as FileContextMenuAction, fileTarget);
198 return;
199 }
185 if (clicked === "copy-mention") {200 if (clicked === "copy-mention") {
186 try {201 try {
187 await writeTextToClipboard(mention);202 await writeTextToClipboard(mention);
apps/web/src/fileContextMenu.test.ts created+102
...@@ -0,0 +1,102 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as NodeAssert from "node:assert/strict";
3import { describe, expect, it } from "vite-plus/test";
4
5import { buildFileContextMenuItems, resolveFileContextMenuAbsolutePath } from "./fileContextMenu";
6
7const BASE_TARGET = {
8 environmentId: EnvironmentId.make("environment-local"),
9 filePath: "src/index.ts",
10 workspaceRoot: "/workspace/project",
11};
12
13const EMPTY_CAPABILITIES = {
14 revealLabel: undefined,
15 canOpenDefault: false,
16 editorIds: [],
17};
18
19describe("resolveFileContextMenuAbsolutePath", () => {
20 it("joins workspace-relative diff paths onto the workspace root", () => {
21 expect(resolveFileContextMenuAbsolutePath(BASE_TARGET)).toBe("/workspace/project/src/index.ts");
22 });
23
24 it("strips the repository prefix when the repo root is nested in the workspace", () => {
25 expect(
26 resolveFileContextMenuAbsolutePath({
27 ...BASE_TARGET,
28 workspaceRoot: "/workspace/project/packages/app",
29 repositoryRoot: "/workspace/project",
30 filePath: "packages/app/src/index.ts",
31 }),
32 ).toBe("/workspace/project/packages/app/src/index.ts");
33 });
34
35 it("returns null for paths outside the workspace when a repository root is set", () => {
36 expect(
37 resolveFileContextMenuAbsolutePath({
38 ...BASE_TARGET,
39 workspaceRoot: "/workspace/project/packages/app",
40 repositoryRoot: "/workspace/project",
41 filePath: "other/src/index.ts",
42 }),
43 ).toBeNull();
44 });
45
46 it("rejects absolute paths without a workspace root, matching diff path resolution", () => {
47 expect(
48 resolveFileContextMenuAbsolutePath({
49 ...BASE_TARGET,
50 workspaceRoot: undefined,
51 filePath: "/absolute/src/index.ts",
52 }),
53 ).toBeNull();
54 });
55});
56
57describe("buildFileContextMenuItems", () => {
58 it("offers open, reveal, and an open-with submenu when all are available", () => {
59 const items = buildFileContextMenuItems({
60 hasAbsolutePath: true,
61 capabilities: {
62 revealLabel: "Reveal in Finder",
63 canOpenDefault: true,
64 editorIds: ["vscode", "cursor", "file-manager"],
65 },
66 });
67
68 expect(items.map((item) => item.id)).toEqual(["open", "reveal-in-folder", "open-with"]);
69 expect(items[0]).toMatchObject({ label: "Open" });
70 expect(items[1]).toMatchObject({ label: "Reveal in Finder" });
71 const openWith = items[2];
72 NodeAssert.ok(openWith);
73 expect(openWith.children?.map((child) => child.id)).toEqual(["editor:vscode", "editor:cursor"]);
74 });
75
76 it("offers only the reveal item when just reveal is enabled", () => {
77 const items = buildFileContextMenuItems({
78 hasAbsolutePath: true,
79 capabilities: {
80 revealLabel: "Reveal in File Explorer",
81 canOpenDefault: false,
82 editorIds: [],
83 },
84 });
85
86 expect(items.map((item) => item.id)).toEqual(["reveal-in-folder"]);
87 expect(items[0]).toMatchObject({ label: "Reveal in File Explorer" });
88 });
89
90 it("offers nothing when the path cannot be resolved", () => {
91 expect(
92 buildFileContextMenuItems({
93 hasAbsolutePath: false,
94 capabilities: {
95 revealLabel: "Reveal in Finder",
96 canOpenDefault: true,
97 editorIds: ["vscode"],
98 },
99 }),
100 ).toEqual([]);
101 });
102});
apps/web/src/fileContextMenu.ts created+206
...@@ -0,0 +1,206 @@
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 "./terminal-links";
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}