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
21 changes: 21 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,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,
);
}}
>
<AnnotatableCodeView
key={collapseScopeKey ?? reviewSectionId}
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/components/chat/ChangedFilesTree.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type TurnId } from "@t3tools/contracts";
import { memo, useCallback, useMemo, useState } from "react";
import { type MouseEvent, memo, useCallback, useMemo, useState } from "react";
import { type TurnDiffFileChange } from "../../types";
import {
buildTurnDiffTree,
Expand All @@ -22,13 +22,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};

/** 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<TurnDiffFileChange>;
allDirectoriesExpanded: boolean;
resolvedTheme: "light" | "dark";
onToggleAllDirectories: () => void;
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
}) {
const {
turnId,
Expand All @@ -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));
Expand Down Expand Up @@ -117,6 +122,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
allDirectoriesExpanded={allDirectoriesExpanded}
resolvedTheme={resolvedTheme}
onOpenTurnDiff={onOpenTurnDiff}
onFileContextMenu={onFileContextMenu}
/>
</div>
);
Expand All @@ -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"),
Expand Down Expand Up @@ -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 ? (
<span aria-hidden="true" className="size-3.5 shrink-0" />
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ 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,
timelineContentOverflowsViewport,
Expand Down Expand Up @@ -2641,12 +2645,24 @@ function AssistantChangedFilesSectionInner({
resolvedTheme: "light" | "dark";
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
}) {
const ctx = use(TimelineRowCtx);
const persistedExpanded = useUiStateStore(
(store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
);
const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
const allDirectoriesExpanded = persistedExpanded ?? false;

const thread = useThread(ctx.threadRef);
const activeProject = useProject(
thread && thread.projectId
? { environmentId: thread.environmentId, projectId: thread.projectId }
: null,
);
const serverConfig = useAtomValue(
serverEnvironment.configValueAtom(ctx.activeThreadEnvironmentId),
);
const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId);

return (
<ChangedFilesCard
turnId={turnSummary.turnId}
Expand All @@ -2657,6 +2673,20 @@ function AssistantChangedFilesSectionInner({
setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
}
onOpenTurnDiff={onOpenTurnDiff}
onFileContextMenu={(filePath, event) =>
onFileContextMenu(
{
environmentId: ctx.activeThreadEnvironmentId,
filePath,
workspaceRoot: ctx.workspaceRoot,
repositoryRoot:
thread?.worktreePath == null
? activeProject?.repositoryIdentity?.rootPath
: undefined,
},
event,
)
}
/>
);
}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";
import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
Expand Down Expand Up @@ -105,6 +106,7 @@ export default function FileBrowserPanel({
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const fileContextMenu = useFileContextMenu(environmentId);
const {
entries: directoryEntries,
load,
Expand Down Expand Up @@ -157,6 +159,7 @@ export default function FileBrowserPanel({
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

/** Combines the file actions (open/reveal/open with) with the panel's own mention actions. */
const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
Expand All @@ -174,14 +177,26 @@ export default function FileBrowserPanel({
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
const fileTarget = { environmentId, filePath: relativePath, workspaceRoot: cwd };
const fileMenuItems = fileContextMenu.buildItems(fileTarget);
try {
const clicked = await api.contextMenu.show(
[
...fileMenuItems,
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === null) return;
// "Open with" submenu selections report the child id ("editor:<id>"),
// which is not present in the top-level item list.
const isFileMenuAction =
fileMenuItems.some((entry) => entry.id === clicked) || clicked.startsWith("editor:");
if (isFileMenuAction) {
await fileContextMenu.activate(clicked as FileContextMenuAction, fileTarget);
return;
}
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
Expand Down
102 changes: 102 additions & 0 deletions apps/web/src/fileContextMenu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { EnvironmentId } from "@t3tools/contracts";
import * as NodeAssert from "node:assert/strict";
import { describe, expect, it } from "vite-plus/test";

import { buildFileContextMenuItems, resolveFileContextMenuAbsolutePath } from "./fileContextMenu";

const BASE_TARGET = {
environmentId: EnvironmentId.make("environment-local"),
filePath: "src/index.ts",
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");
});

it("strips the repository prefix when the repo root is nested in the workspace", () => {
expect(
resolveFileContextMenuAbsolutePath({
...BASE_TARGET,
workspaceRoot: "/workspace/project/packages/app",
repositoryRoot: "/workspace/project",
filePath: "packages/app/src/index.ts",
}),
).toBe("/workspace/project/packages/app/src/index.ts");
});

it("returns null for paths outside the workspace when a repository root is set", () => {
expect(
resolveFileContextMenuAbsolutePath({
...BASE_TARGET,
workspaceRoot: "/workspace/project/packages/app",
repositoryRoot: "/workspace/project",
filePath: "other/src/index.ts",
}),
).toBeNull();
});

it("rejects absolute paths without a workspace root, matching diff path resolution", () => {
expect(
resolveFileContextMenuAbsolutePath({
...BASE_TARGET,
workspaceRoot: undefined,
filePath: "/absolute/src/index.ts",
}),
).toBeNull();
});
});

describe("buildFileContextMenuItems", () => {
it("offers open, reveal, and an open-with submenu when all are available", () => {
const items = buildFileContextMenuItems({
hasAbsolutePath: true,
capabilities: {
revealLabel: "Reveal in Finder",
canOpenDefault: true,
editorIds: ["vscode", "cursor", "file-manager"],
},
});

expect(items.map((item) => item.id)).toEqual(["open", "reveal-in-folder", "open-with"]);
expect(items[0]).toMatchObject({ label: "Open" });
expect(items[1]).toMatchObject({ label: "Reveal in Finder" });
const openWith = items[2];
NodeAssert.ok(openWith);
expect(openWith.children?.map((child) => child.id)).toEqual(["editor:vscode", "editor:cursor"]);
});

it("offers only the reveal item when just reveal is enabled", () => {
const items = buildFileContextMenuItems({
hasAbsolutePath: true,
capabilities: {
revealLabel: "Reveal in File Explorer",
canOpenDefault: false,
editorIds: [],
},
});

expect(items.map((item) => item.id)).toEqual(["reveal-in-folder"]);
expect(items[0]).toMatchObject({ label: "Reveal in File Explorer" });
});

it("offers nothing when the path cannot be resolved", () => {
expect(
buildFileContextMenuItems({
hasAbsolutePath: false,
capabilities: {
revealLabel: "Reveal in Finder",
canOpenDefault: true,
editorIds: ["vscode"],
},
}),
).toEqual([]);
});
});
Loading
Loading