diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b0209754b0d0..729f3e77c4a1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -106,10 +106,6 @@ import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, -} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -122,11 +118,7 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; -import { - PreferredEditorEnvironmentRequiredError, - useOpenInPreferredEditor, - usePreferredEditor, -} from "../editorPreferences"; +import { useOpenInPreferredEditor, usePreferredEditor } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -156,13 +148,13 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { useFileContextMenu, type FileContextMenuTarget } from "../fileContextMenu"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; -import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; @@ -184,7 +176,7 @@ import { import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; +import { isAbsolutePath } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -1144,10 +1136,15 @@ interface MarkdownFileLinkProps { openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; - onReveal?: (() => Promise>) | undefined; - /** Platform-specific menu label ("Reveal in Finder", ...); required for the - reveal item to show. */ - revealLabel?: string | undefined; + /** Shared file-menu machinery for reveal and the Open with submenu. */ + fileMenu?: ReturnType | undefined; + /** Position-stripped path the shared menu acts on; required for it to show. */ + menuPath?: string | undefined; + /** Workspace root the shared menu resolves menuPath against. */ + menuWorkspaceRoot?: string | undefined; + /** Resolves a bare display path (e.g. `ChatView.tsx`) to the indexed + workspace path before a shared action runs on it. */ + resolveMenuPath?: ((filePath: string) => Promise) | undefined; className?: string | undefined; } @@ -1855,8 +1852,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ openInEditorMenuLabel, onOpenInBrowser, onOpenMedia, - onReveal, - revealLabel, + fileMenu, + menuPath, + menuWorkspaceRoot, + resolveMenuPath, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { @@ -1947,44 +1946,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); - const handleRevealInFileManager = useCallback(() => { - if (!onReveal) { - return; - } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }, [onReveal, targetPath]); - const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -2029,26 +1990,51 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; + const menuTarget: FileContextMenuTarget | undefined = + fileMenu && menuPath + ? { + environmentId: threadRef?.environmentId ?? null, + filePath: menuPath, + workspaceRoot: menuWorkspaceRoot, + } + : undefined; + // The chip already renders "Open in ", so the default-app Open + // folds into the shared Open with submenu instead of a third open row. + const sharedItems = menuTarget + ? (fileMenu?.buildItems(menuTarget, { hasPrimaryOpenItem: onOpen !== undefined }) ?? []) + : []; + try { const clicked = await api.contextMenu.show( [ ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), + ...(onOpen ? ([{ id: "open-editor", label: openInEditorMenuLabel }] as const) : []), + ...sharedItems, ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, - ] as const, + ], position, ); + if (clicked === null) return; + // "Open with" selections report the child id ("editor:" or the + // nested "open"), which is not a top-level item. + const sharedClicked = sharedItems + .flatMap((item) => [item, ...(item.children ?? [])]) + .find((item) => item.id === clicked); + if (menuTarget && fileMenu && sharedClicked) { + const filePath = (await resolveMenuPath?.(menuTarget.filePath)) ?? menuTarget.filePath; + await fileMenu.activate(sharedClicked.id, { ...menuTarget, filePath }); + return; + } if (clicked === "preview-media") { onOpenMedia?.(); return; } - if (clicked === "open") { + if (clicked === "open-editor") { handleOpenInEditor(); return; } @@ -2056,10 +2042,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -2076,17 +2058,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [ displayPath, + fileMenu, handleCopy, handleOpenInBrowser, handleOpenInEditor, - handleRevealInFileManager, + menuPath, + menuWorkspaceRoot, onOpenInBrowser, onOpenMedia, onOpen, - onReveal, openInEditorMenuLabel, - revealLabel, + resolveMenuPath, targetPath, + threadRef, ], ); @@ -2206,8 +2190,10 @@ function areMarkdownFileLinkPropsEqual( previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && - previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel && + previous.fileMenu === next.fileMenu && + previous.menuPath === next.menuPath && + previous.menuWorkspaceRoot === next.menuWorkspaceRoot && + previous.resolveMenuPath === next.resolveMenuPath && previous.className === next.className ); } @@ -2301,37 +2287,11 @@ function useChatMarkdownState({ ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const projects = useProjects(); - const availableEditors = serverConfig?.availableEditors ?? []; + const availableEditors = useMemo(() => serverConfig?.availableEditors ?? [], [serverConfig]); const [preferredEditor] = usePreferredEditor(availableEditors); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); - const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { - reportFailure: false, - }); - const revealInFileManagerLabel = - environmentId !== null && - serverConfig?.shellRevealInFileManager === true && - serverConfig.availableEditors.includes("file-manager") - ? serverConfig.shellRevealInFileManagerKind === undefined - ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) - : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) - : undefined; - const revealFileInFileManager = useCallback( - (filePath: string) => { - if (environmentId === null) { - return Promise.resolve( - AsyncResult.failure( - Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), - ), - ); - } - return openInEditor({ - environmentId, - input: { cwd: filePath, editor: "file-manager", reveal: true }, - }); - }, - [environmentId, openInEditor], - ); + const fileMenu = useFileContextMenu(environmentId); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -2523,17 +2483,6 @@ function useChatMarkdownState({ }, [cwd, findWorkspaceBasenameMatch, threadRef], ); - const revealMarkdownFileInFileManager = useCallback( - async (fileLinkMeta: MarkdownFileLinkMeta) => { - const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; - const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; - return revealFileInFileManager(filePath); - }, - [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], - ); const fileLinkChip = useCallback( ( fileLinkMeta: MarkdownFileLinkMeta, @@ -2584,12 +2533,10 @@ function useChatMarkdownState({ : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} - onReveal={ - canUseShellActions && revealInFileManagerLabel !== undefined - ? () => revealMarkdownFileInFileManager(fileLinkMeta) - : undefined - } - revealLabel={revealInFileManagerLabel} + fileMenu={fileMenu} + menuPath={fileLinkMeta.workspaceRelativePath ?? fileLinkMeta.filePath} + menuWorkspaceRoot={cwd} + resolveMenuPath={findWorkspaceBasenameMatch} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -2603,15 +2550,16 @@ function useChatMarkdownState({ }, [ canUseShellActions, + cwd, fileLinkParentSuffixByPath, + fileMenu, + findWorkspaceBasenameMatch, openFileInPanel, openInPreferredEditor, openMarkdownFileInPreview, openMarkdownMedia, preferredEditorMenuLabel, resolvedTheme, - revealInFileManagerLabel, - revealMarkdownFileInFileManager, threadRef, ], ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ca0bdaa7b4d5..f73a0b100f2b 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,39 @@ 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"), + ); + // Metadata and blank header areas have no data-title in + // the click path; fall back to the enclosing header's + // filename like onClickCapture does. + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), + ); + const filePath = ( + title?.textContent ?? header?.querySelector("[data-title]")?.textContent + )?.trim(); + if (!filePath) return; + event.preventDefault(); + onFileContextMenu( + { + environmentId: activeThread?.environmentId ?? null, + filePath, + // The branch preview can retry at the environment cwd + // when the worktree is rejected; resolve files against + // the cwd the rendered diff actually came from. + workspaceRoot: selectedTurn + ? activeCwd + : (branchDiffPreview.data?.cwd ?? 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 ? (