Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 63 additions & 115 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -1144,10 +1136,15 @@ interface MarkdownFileLinkProps {
openInEditorMenuLabel: string;
onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
onOpenMedia?: (() => void) | undefined;
onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | 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<typeof useFileContextMenu> | 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<string | null>) | undefined;
className?: string | undefined;
}

Expand Down Expand Up @@ -1855,8 +1852,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
openInEditorMenuLabel,
onOpenInBrowser,
onOpenMedia,
onReveal,
revealLabel,
fileMenu,
menuPath,
menuWorkspaceRoot,
resolveMenuPath,
className,
}: MarkdownFileLinkProps) {
const handleOpenInEditor = useCallback(() => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2029,37 +1990,58 @@ 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 <editor>", 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:<id>" 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;
}
if (clicked === "open-in-browser") {
handleOpenInBrowser();
return;
}
if (clicked === "reveal") {
handleRevealInFileManager();
return;
}
if (clicked === "copy-relative") {
handleCopy(displayPath, "Relative path");
return;
Expand All @@ -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,
],
);

Expand Down Expand Up @@ -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
);
}
Expand Down Expand Up @@ -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<void, PreferredEditorEnvironmentRequiredError>(
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<
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Comment thread
saphid marked this conversation as resolved.
menuWorkspaceRoot={cwd}
resolveMenuPath={findWorkspaceBasenameMatch}
onOpenInBrowser={
threadRef &&
isPreviewSupportedInRuntime() &&
Expand All @@ -2603,15 +2550,16 @@ function useChatMarkdownState({
},
[
canUseShellActions,
cwd,
fileLinkParentSuffixByPath,
fileMenu,
findWorkspaceBasenameMatch,
openFileInPanel,
openInPreferredEditor,
openMarkdownFileInPreview,
openMarkdownMedia,
preferredEditorMenuLabel,
resolvedTheme,
revealInFileManagerLabel,
revealMarkdownFileInFileManager,
threadRef,
],
);
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -976,6 +978,39 @@ export default function DiffPanel({
);
if (file) toggleDiffFileCollapsed(file.fileKey);
}}
onContextMenuCapture={(event) => {
const composedPath = event.nativeEvent.composedPath?.() ?? [];
const title = composedPath.find(
Comment thread
saphid marked this conversation as resolved.
(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,
);
}}
>
<AnnotatableCodeView
key={collapseScopeKey ?? reviewSectionId}
Expand Down
Loading
Loading