From ae6d862f9b5a3272c6d9b561b3f4256b3bfdc20d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:45:53 +1000 Subject: [PATCH 1/2] feat(web): open diff files from a right-click context menu Right-clicking a changed file in the chat changed-files tree, the diff panel, or the workspace file browser now offers Open (default app), Reveal in Finder/File Explorer/Files, and an Open with submenu of the environment's detected editors. Reveal rides the existing shell.openInEditor reveal support and its shellRevealInFileManager config gate, so the menu only offers what the environment can do. --- apps/web/src/components/DiffPanel.tsx | 21 ++ .../src/components/chat/ChangedFilesTree.tsx | 26 ++- .../src/components/chat/MessagesTimeline.tsx | 30 +++ .../src/components/files/FileBrowserPanel.tsx | 13 ++ apps/web/src/fileContextMenu.test.ts | 102 +++++++++ apps/web/src/fileContextMenu.ts | 193 ++++++++++++++++++ 6 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/fileContextMenu.test.ts create mode 100644 apps/web/src/fileContextMenu.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ca0bdaa7b4d5..73012650ecf5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -26,6 +26,7 @@ import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; import { useOpenInPreferredEditor } from "../editorPreferences"; +import { useFileContextMenuHandler } from "../fileContextMenu"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; @@ -152,6 +153,7 @@ export default function DiffPanel({ const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); + const onFileContextMenu = useFileContextMenuHandler(activeThread?.environmentId ?? null); const openInPreferredEditor = useOpenInPreferredEditor( activeThread?.environmentId ?? null, serverConfig?.availableEditors ?? [], @@ -976,6 +978,25 @@ export default function DiffPanel({ ); if (file) toggleDiffFileCollapsed(file.fileKey); }} + onContextMenuCapture={(event) => { + const composedPath = event.nativeEvent.composedPath?.() ?? []; + const title = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const filePath = title?.textContent?.trim(); + if (!filePath) return; + event.preventDefault(); + onFileContextMenu( + { + environmentId: activeThread?.environmentId ?? null, + filePath, + workspaceRoot: activeCwd, + repositoryRoot: activeRepositoryRoot, + }, + event, + ); + }} > = {}; +/** Opens the OS-level context menu for a changed file (reveal in file manager, open in editor). */ +export type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void; + export const ChangedFilesCard = memo(function ChangedFilesCard(props: { turnId: TurnId; files: ReadonlyArray; @@ -29,6 +32,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onFileContextMenu?: ChangedFileContextMenuHandler | undefined; }) { const { turnId, @@ -37,6 +41,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { resolvedTheme, onToggleAllDirectories, onOpenTurnDiff, + onFileContextMenu, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); @@ -117,6 +122,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { allDirectoriesExpanded={allDirectoriesExpanded} resolvedTheme={resolvedTheme} onOpenTurnDiff={onOpenTurnDiff} + onFileContextMenu={onFileContextMenu} /> ); @@ -128,8 +134,16 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onFileContextMenu?: ChangedFileContextMenuHandler | undefined; }) { - const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; + const { + files, + allDirectoriesExpanded, + onOpenTurnDiff, + resolvedTheme, + turnId, + onFileContextMenu, + } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); const directoryPathsKey = useMemo( () => collectDirectoryPaths(treeNodes).join("\u0000"), @@ -214,6 +228,14 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { 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" style={{ paddingLeft: `${leftPadding}px` }} onClick={() => onOpenTurnDiff(turnId, node.path)} + onContextMenu={ + onFileContextMenu + ? (event) => { + event.preventDefault(); + onFileContextMenu(node.path, event); + } + : undefined + } > {hasDirectoryNodes || depth > 0 ? (