From 728a7e23aa5e0a4130d2e8922af100cc0778dfdd Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 06:51:12 -0600 Subject: [PATCH] feat(web): add file context actions across diff and browser --- apps/web/src/components/DiffPanel.tsx | 75 +++++ .../src/components/chat/ChangedFilesTree.tsx | 47 +++- .../src/components/chat/MessagesTimeline.tsx | 30 ++ .../src/components/files/FileBrowserPanel.tsx | 25 +- apps/web/src/fileContextMenu.test.ts | 155 +++++++++++ apps/web/src/fileContextMenu.ts | 262 ++++++++++++++++++ 6 files changed, 591 insertions(+), 3 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 d954a7ff69..b9c16d26b5 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 * as DateTime from "effect/DateTime"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; +import { useFileContextMenuHandler } from "../fileContextMenu"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; @@ -170,6 +171,10 @@ export default function DiffPanel({ const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); + const onFileContextMenu = useFileContextMenuHandler( + activeThread?.environmentId ?? null, + activeCwd, + ); const openInPreferredEditor = useOpenInPreferredEditor( activeThread?.environmentId ?? null, serverConfig?.availableEditors ?? [], @@ -495,6 +500,29 @@ export default function DiffPanel({ }), [collapsedDiffFileKeys, renderableFileEntries, lazySource, readyFilePaths], ); + const showDiffFileContextMenu = useCallback( + (filePath: string, position: { clientX: number; clientY: number }) => { + const matching = codeViewFiles.filter((file) => file.filePath === filePath); + if (matching.length === 0) return false; + return onFileContextMenu( + { + environmentId: activeThread?.environmentId ?? null, + filePath, + fileExists: matching.some((file) => file.fileDiff.type !== "deleted"), + workspaceRoot: activeCwd, + repositoryRoot: activeRepositoryRoot, + }, + position, + ); + }, + [ + activeCwd, + activeRepositoryRoot, + activeThread?.environmentId, + codeViewFiles, + onFileContextMenu, + ], + ); const diffFileKeys = useMemo( () => renderableFileEntries.map((file) => file.fileKey), [renderableFileEntries], @@ -1071,6 +1099,53 @@ export default function DiffPanel({ ); if (file) toggleDiffFileCollapsed(file.fileKey); }} + onContextMenuCapture={(event) => { + const path = event.nativeEvent.composedPath?.() ?? []; + if ( + path.some( + (node) => + node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement, + ) + ) + return; + const title = path.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const header = path.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), + ); + const filePath = + title?.textContent ?? header?.querySelector("[data-title]")?.textContent; + if (!filePath) return; + const rect = + title?.getBoundingClientRect() ?? header?.getBoundingClientRect(); + if ( + showDiffFileContextMenu(filePath, { + clientX: event.clientX || rect?.left || 0, + clientY: event.clientY || rect?.bottom || 0, + }) + ) + event.preventDefault(); + }} + onKeyDownCapture={(event) => { + if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + return; + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const header = target.closest("[data-diffs-header]"); + const filePath = header?.querySelector("[data-title]")?.textContent; + if (!filePath || !header) return; + const rect = header.getBoundingClientRect(); + if ( + showDiffFileContextMenu(filePath, { + clientX: rect.left, + clientY: rect.bottom, + }) + ) + event.preventDefault(); + }} > = {}; +/** Opens the OS-level context menu for a changed file (reveal in file manager, open in editor). */ +export type ChangedFileContextMenuHandler = ( + filePath: string, + position: { readonly clientX: number; readonly clientY: number }, + fileExists: boolean, +) => boolean; + export const ChangedFilesCard = memo(function ChangedFilesCard(props: { turnId: TurnId; files: ReadonlyArray; @@ -30,6 +37,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onFileContextMenu?: ChangedFileContextMenuHandler | undefined; }) { const { turnId, @@ -38,6 +46,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)); @@ -118,6 +127,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { allDirectoriesExpanded={allDirectoriesExpanded} resolvedTheme={resolvedTheme} onOpenTurnDiff={onOpenTurnDiff} + onFileContextMenu={onFileContextMenu} /> ); @@ -129,9 +139,26 @@ 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 deletedPaths = useMemo( + () => + new Set( + files + .filter((file) => /^(deleted|removed|d)$/iu.test(file.kind)) + .map((file) => file.path.replaceAll("\\", "/")), + ), + [files], + ); const directoryPathsKey = useMemo( () => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes], @@ -215,6 +242,24 @@ 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) => { + const rect = event.currentTarget.getBoundingClientRect(); + if ( + onFileContextMenu( + node.path, + { + clientX: event.clientX || rect.left, + clientY: event.clientY || rect.bottom, + }, + !deletedPaths.has(node.path), + ) + ) + event.preventDefault(); + } + : undefined + } > {hasDirectoryNodes || depth > 0 ? (