diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f48c573aa8bc..923ced75d7de 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -107,10 +107,6 @@ import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, -} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -124,11 +120,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"; @@ -158,13 +150,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"; @@ -186,7 +178,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, @@ -1149,10 +1141,16 @@ 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; } const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; @@ -1888,8 +1886,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ openInEditorMenuLabel, onOpenInBrowser, onOpenMedia, - onReveal, - revealLabel, + fileMenu, + menuPath, + menuWorkspaceRoot, + resolveMenuPath, + className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { if (!onOpen) { @@ -1979,44 +1980,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) { @@ -2061,26 +2024,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; } @@ -2088,10 +2076,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -2108,17 +2092,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [ displayPath, + fileMenu, handleCopy, handleOpenInBrowser, handleOpenInEditor, - handleRevealInFileManager, + menuPath, + menuWorkspaceRoot, onOpenInBrowser, onOpenMedia, onOpen, - onReveal, openInEditorMenuLabel, - revealLabel, + resolveMenuPath, targetPath, + threadRef, ], ); @@ -2228,8 +2214,11 @@ 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 ); } @@ -2323,37 +2312,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< @@ -2545,17 +2508,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, copyMarkdown: string, mediaSource?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( @@ -2601,12 +2553,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() && @@ -2619,15 +2569,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 41cc4c82fa0f..58072f317717 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1063,14 +1063,28 @@ export default function DiffPanel({ (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-title"), ); - const filePath = title?.textContent?.trim(); + // 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, - workspaceRoot: activeCwd, + // 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, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 20b8438fbc1c..c9219a44fa96 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -154,10 +154,8 @@ import { } from "./SnapShotAttachmentDetails"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; -import { useAtomValue } from "@effect/atom-react"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThread } from "../../state/entities"; -import { serverEnvironment } from "../../state/server"; import { CHAT_TIMELINE_ANCHOR_OFFSET, readTimelinePosition, @@ -269,6 +267,9 @@ interface TimelineRowSharedState { listRef: React.RefObject; timestampFormat: TimestampFormat; routeThreadKey: string; + // The thread whose rows are currently painted — differs from routeThreadKey + // while a held snapshot of the previous thread fills the loading gap. + paintedThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -1129,6 +1130,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ listRef, timestampFormat, routeThreadKey, + paintedThreadKey: listIdentityKey, // Keep Markdown callbacks memoized during unrelated activity updates. threadRef: citationThreadRef, markdownCwd, @@ -1165,6 +1167,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ listRef, timestampFormat, routeThreadKey, + listIdentityKey, citationThreadRef, markdownCwd, resolvedTheme, @@ -2377,7 +2380,7 @@ function AssistantTimelineRow({ row }: { row: Extract @@ -3343,12 +3346,12 @@ function WorkGroupToggleTimelineRow({ * so toggling re-renders only this component — not the entire list. */ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ turnSummary, - routeThreadKey, + threadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary | undefined; - routeThreadKey: string; + threadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { @@ -3360,7 +3363,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( @@ -3372,33 +3375,37 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( function AssistantChangedFilesSectionInner({ turnSummary, checkpointFiles, - routeThreadKey, + threadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary; checkpointFiles: TurnDiffSummary["files"]; - routeThreadKey: string; + threadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const ctx = use(TimelineRowCtx); const persistedExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], + (store) => store.threadChangedFilesExpandedById[threadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const allDirectoriesExpanded = persistedExpanded ?? false; - const thread = useThread(ctx.threadRef); - const activeProject = useProject( + // Rows can belong to a held snapshot of the previous thread while the list + // settles; bind file actions and expansion state to the painted thread. + const paintedThreadRef = useMemo( + () => parseScopedThreadKey(threadKey) ?? ctx.threadRef, + [threadKey, ctx.threadRef], + ); + const thread = useThread(paintedThreadRef); + const project = useProject( thread && thread.projectId ? { environmentId: thread.environmentId, projectId: thread.projectId } : null, ); - const serverConfig = useAtomValue( - serverEnvironment.configValueAtom(ctx.activeThreadEnvironmentId), - ); - const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId); + const environmentId = paintedThreadRef?.environmentId ?? ctx.activeThreadEnvironmentId; + const onFileContextMenu = useFileContextMenuHandler(environmentId); return ( - setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) + setExpanded(threadKey, turnSummary.turnId, !allDirectoriesExpanded) } onOpenTurnDiff={onOpenTurnDiff} onFileContextMenu={(filePath, event) => onFileContextMenu( { - environmentId: ctx.activeThreadEnvironmentId, + environmentId, filePath, - workspaceRoot: ctx.workspaceRoot, + workspaceRoot: thread?.worktreePath ?? project?.workspaceRoot ?? ctx.workspaceRoot, repositoryRoot: - thread?.worktreePath == null - ? activeProject?.repositoryIdentity?.rootPath - : undefined, + thread?.worktreePath == null ? project?.repositoryIdentity?.rootPath : undefined, }, event, ) diff --git a/apps/web/src/fileContextMenu.test.ts b/apps/web/src/fileContextMenu.test.ts index 02cae8b5dabe..5c14dafb47a0 100644 --- a/apps/web/src/fileContextMenu.test.ts +++ b/apps/web/src/fileContextMenu.test.ts @@ -10,12 +10,6 @@ const BASE_TARGET = { workspaceRoot: "/workspace/project", }; -const EMPTY_CAPABILITIES = { - revealLabel: undefined, - canOpenDefault: false, - editorIds: [], -}; - describe("resolveFileContextMenuAbsolutePath", () => { it("joins workspace-relative diff paths onto the workspace root", () => { expect(resolveFileContextMenuAbsolutePath(BASE_TARGET)).toBe("/workspace/project/src/index.ts"); @@ -43,15 +37,58 @@ describe("resolveFileContextMenuAbsolutePath", () => { ).toBeNull(); }); - it("rejects absolute paths without a workspace root, matching diff path resolution", () => { + it("passes absolute environment-host paths through unchanged", () => { expect( resolveFileContextMenuAbsolutePath({ ...BASE_TARGET, - workspaceRoot: undefined, filePath: "/absolute/src/index.ts", }), + ).toBe("/absolute/src/index.ts"); + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + workspaceRoot: undefined, + filePath: "C:\\temp\\report.pdf", + }), + ).toBe("C:\\temp\\report.pdf"); + }); + + it("returns null for relative paths without a workspace root, matching diff path resolution", () => { + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + workspaceRoot: undefined, + filePath: "src/index.ts", + }), ).toBeNull(); }); + + it("does not treat drive-relative Windows paths as absolute", () => { + // C:notes.md is relative to the current directory on C:, so it cannot + // pass through as an environment-host path or resolve to a workspace file. + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + workspaceRoot: undefined, + filePath: "C:notes.md", + }), + ).toBeNull(); + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + filePath: "C:src\\index.ts", + }), + ).toBeNull(); + }); + + it("passes UNC environment-host paths through unchanged", () => { + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + filePath: "\\\\server\\share\\report.pdf", + }), + ).toBe("\\\\server\\share\\report.pdf"); + }); }); describe("buildFileContextMenuItems", () => { @@ -87,6 +124,45 @@ describe("buildFileContextMenuItems", () => { expect(items[0]).toMatchObject({ label: "Reveal in File Explorer" }); }); + it("folds the default-app open into Open with when the caller has its own open row", () => { + const items = buildFileContextMenuItems({ + hasAbsolutePath: true, + hasPrimaryOpenItem: true, + capabilities: { + revealLabel: "Reveal in Finder", + canOpenDefault: true, + editorIds: ["vscode", "cursor", "file-manager"], + }, + }); + + expect(items.map((item) => item.id)).toEqual(["reveal-in-folder", "open-with"]); + const openWith = items[1]; + NodeAssert.ok(openWith); + expect(openWith.children?.map((child) => child.id)).toEqual([ + "open", + "editor:vscode", + "editor:cursor", + ]); + expect(openWith.children?.[0]).toMatchObject({ label: "Default Application" }); + }); + + it("drops the default-app open entirely when the caller's row already covers it", () => { + // With no editors detected, the caller's "Open in editor" opens the + // default app through preferred-editor resolution, so a shared Open row + // would be a duplicate. + const items = buildFileContextMenuItems({ + hasAbsolutePath: true, + hasPrimaryOpenItem: true, + capabilities: { + revealLabel: "Reveal in Finder", + canOpenDefault: true, + editorIds: ["file-manager"], + }, + }); + + expect(items.map((item) => item.id)).toEqual(["reveal-in-folder"]); + }); + it("offers nothing when the path cannot be resolved", () => { expect( buildFileContextMenuItems({ diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 05425f205955..6bb674e3224d 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -22,7 +22,7 @@ import { readLocalApi } from "./localApi"; import { serverEnvironment } from "./state/server"; import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; -import { resolvePathLinkTarget } from "./terminal-links"; +import { isAbsolutePath, resolvePathLinkTarget } from "./terminal-links"; import { toastManager } from "./components/ui/toast"; import { useAtomValue } from "@effect/atom-react"; @@ -35,13 +35,16 @@ export type FileContextMenuAction = export interface FileContextMenuTarget { readonly environmentId: EnvironmentId | null; - /** Repo- or workspace-relative file path, as shown in diffs. */ + /** + * Repo- or workspace-relative path (as shown in diffs), or an absolute + * environment-host path (as carried by markdown file links pointing outside + * the workspace). + */ readonly filePath: string; readonly workspaceRoot: string | undefined; readonly repositoryRoot?: string | undefined; } -/** Absolute path on the environment host, or null when it cannot be resolved. */ /** * Absolute path on the environment host for a diff-style target, resolving * repo-relative paths through the workspace root like every other diff @@ -49,6 +52,11 @@ export interface FileContextMenuTarget { * treat as "no file actions available". */ export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget): string | null { + // Markdown file links can point outside the workspace with an absolute + // environment-host path; there is nothing to resolve. isAbsolutePath needs + // a separator after a drive colon, so drive-relative C:notes.md is treated + // as workspace-relative, and UNC shares pass through. + if (isAbsolutePath(target.filePath)) return target.filePath; const workspaceFilePath = resolveDiffPathForWorkspace({ filePath: target.filePath, workspaceRoot: target.workspaceRoot, @@ -56,9 +64,7 @@ export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget }); if (workspaceFilePath === null) return null; if (target.workspaceRoot === undefined) { - return workspaceFilePath.startsWith("/") || /^[a-zA-Z]:/.test(workspaceFilePath) - ? workspaceFilePath - : null; + return isAbsolutePath(workspaceFilePath) ? workspaceFilePath : null; } return resolvePathLinkTarget(workspaceFilePath, target.workspaceRoot); } @@ -79,11 +85,23 @@ export interface FileContextMenuCapabilities { export function buildFileContextMenuItems(input: { readonly hasAbsolutePath: boolean; readonly capabilities: FileContextMenuCapabilities; + /** + * Set when the caller already renders its own top-level open row (the file + * chip's "Open in "). The default-app Open folds into "Open with" + * so the menu keeps one primary open action plus alternatives. + */ + readonly hasPrimaryOpenItem?: boolean; }): readonly ContextMenuItem[] { // Without a resolvable absolute path nothing here can act on the file. if (!input.hasAbsolutePath) return []; const items: ContextMenuItem[] = []; - if (input.capabilities.canOpenDefault) { + const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); + // With no submenu to fold into, the caller's own open row already reaches + // the default app through preferred-editor resolution — a second Open row + // would do the same thing. + const hasOwnOpen = input.hasPrimaryOpenItem === true; + const nestDefaultOpen = hasOwnOpen && input.capabilities.canOpenDefault && editorIds.length > 0; + if (input.capabilities.canOpenDefault && !hasOwnOpen) { items.push({ id: "open", label: "Open", icon: "pencil" }); } if (input.capabilities.revealLabel !== undefined) { @@ -93,15 +111,17 @@ export function buildFileContextMenuItems(input: { icon: "folder-tree", }); } - const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); if (editorIds.length > 0) { items.push({ id: "open-with", label: "Open with", - children: editorIds.map((editorId) => ({ - id: `editor:${editorId}` as FileContextMenuAction, - label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId, - })), + children: [ + ...(nestDefaultOpen ? ([{ id: "open", label: "Default Application" }] as const) : []), + ...editorIds.map((editorId) => ({ + id: `editor:${editorId}` as FileContextMenuAction, + label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId, + })), + ], }); } return items; @@ -181,10 +201,14 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { }; return { - buildItems: (target: FileContextMenuTarget) => + buildItems: ( + target: FileContextMenuTarget, + options?: { readonly hasPrimaryOpenItem?: boolean }, + ) => buildFileContextMenuItems({ hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null, capabilities, + ...options, }), capabilities, activate, diff --git a/docs/internals/file-context-menus.md b/docs/internals/file-context-menus.md new file mode 100644 index 000000000000..b86064d54041 --- /dev/null +++ b/docs/internals/file-context-menus.md @@ -0,0 +1,53 @@ +# File context menus + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +Every surface that shows a workspace file — as a chip, a row, or a tree node — must offer the +shared file context menu on right-click. The menu is how a user bridges from the app to their own +tools, so a file surface without it dead-ends: the user can see the file but cannot reach Finder, +Explorer, or their editor. + +## The rule + +A file surface's context menu must include, when the environment supports them: + +- **Show in Finder / Explorer / Files** — reveals the file on the environment host, server-side, + so it works over local, remote, and tunnel connections. +- **Open** — opens the file with the host's default application. When a surface already renders + its own primary open row (a markdown chip's "Open in \"), this folds into the Open + with submenu as "Default Application" so the menu keeps one top-level open action. +- **Open with** — a submenu of the environment's detected editors, with remote deep links when + this client is not on the environment's machine. + +The menu may add surface-specific items around the shared ones (a diff panel adds diff actions, a +markdown chip adds preview and copy actions), but the shared items are never dropped and never +reimplemented with different behavior. + +## The mechanism + +`apps/web/src/fileContextMenu.ts` owns the menu. `useFileContextMenu(environmentId)` resolves +capabilities once per environment (detected editors, the server's `shellRevealInFileManager` +gate and its reveal wording, remote-open mode), and returns `buildItems` and `activate`. +`useFileContextMenuHandler(environmentId)` wraps them into an `onContextMenu` callback. + +To cover a new surface: + +1. Build a `FileContextMenuTarget`: an absolute environment-host path or a repo/workspace-relative + path, plus the `workspaceRoot` (and `repositoryRoot` for repo-relative paths inside a nested + worktree) needed to resolve it. +2. Wire the surface's right-click (and click-with-no-other-primary-action, for chips that are not + links) to `useFileContextMenuHandler` or, when the surface has extra items of its own, call + `buildItems` and `activate` around them the way `MarkdownFileLink` does. +3. Do not fork the actions. If a surface needs a variation, change `fileContextMenu.ts` so every + surface benefits. + +## Covered surfaces + +- Chat changed-files tree and activity rows (`ChangedFilesTree`, `MessagesTimeline`). +- Diff panel file headers (`DiffPanel`). +- Workspace file browser (`FileBrowserPanel`). +- Markdown file-link chips in messages and previews (`MarkdownFileLink` in `ChatMarkdown`). + +Not covered yet, tracked as follow-ups: composer file-mention chips (they live inside the Lexical +editor and need environment context threaded into the decorator) and mobile, which has no +right-click; long-press there should eventually map to the same menu. diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 019c9d53023a..6c0aa0f6f6e0 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -64,3 +64,10 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Attachment inventory | The ordered image records shown as thumbnails above the prose, including images with no inline references. | See [composer context references](./composer-context-references.md) for the contract and lifecycle. + +## File surfaces + +| Term | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File chip | An inline, clickable file path rendered as a pill in chat markdown, activity, or the composer. Not every file surface is a chip. | +| File context menu | The shared right-click menu for any shown workspace file: reveal in the file manager and an Open with submenu. See [file context menus](./file-context-menus.md). |