Skip to content
Merged
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
75 changes: 75 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 * 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";
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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<HTMLElement>("[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();
}}
>
<AnnotatableCodeView
key={collapseScopeKey ?? reviewSectionId}
Expand Down
47 changes: 46 additions & 1 deletion apps/web/src/components/chat/ChangedFilesTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ import { MiddleTruncate } from "../ui/middle-truncate";

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,
position: { readonly clientX: number; readonly clientY: number },
fileExists: boolean,
) => boolean;

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 @@ -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));
Expand Down Expand Up @@ -118,6 +127,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
allDirectoriesExpanded={allDirectoriesExpanded}
resolvedTheme={resolvedTheme}
onOpenTurnDiff={onOpenTurnDiff}
onFileContextMenu={onFileContextMenu}
/>
</div>
);
Expand All @@ -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],
Expand Down Expand Up @@ -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 ? (
<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 @@ -145,6 +145,9 @@ import {
import { ProposedPlanCard } from "./ProposedPlanCard";
import { SessionNotificationRow } from "./ComposerSessionInteractionPanel";
import { ChangedFilesCard } from "./ChangedFilesTree";
import { useAtomValue } from "@effect/atom-react";
import { useFileContextMenuHandler } from "../../fileContextMenu";
import { useProject, useThread } from "../../state/entities";
import {
CHAT_TIMELINE_ANCHOR_OFFSET,
readTimelinePosition,
Expand Down Expand Up @@ -2814,12 +2817,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 onFileContextMenu = useFileContextMenuHandler(
ctx.activeThreadEnvironmentId,
ctx.workspaceRoot,
);

return (
<ChangedFilesCard
turnId={turnSummary.turnId}
Expand All @@ -2830,6 +2845,21 @@ function AssistantChangedFilesSectionInner({
setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
}
onOpenTurnDiff={onOpenTurnDiff}
onFileContextMenu={(filePath, event, fileExists) =>
onFileContextMenu(
{
environmentId: ctx.activeThreadEnvironmentId,
filePath,
fileExists,
workspaceRoot: ctx.workspaceRoot,
repositoryRoot:
thread?.worktreePath == null
? activeProject?.repositoryIdentity?.rootPath
: undefined,
},
event,
)
}
/>
);
}
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";

import { Button } from "~/components/ui/button";
import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
Expand All @@ -18,6 +18,7 @@ import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
import { readLocalApi } from "~/localApi";
import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu";
import { T3_PIERRE_ICONS } from "~/pierre-icons";
import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";

Expand Down Expand Up @@ -104,6 +105,7 @@ export default function FileBrowserPanel({
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const fileContextMenu = useFileContextMenu(environmentId, cwd);
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand Down Expand Up @@ -143,6 +145,14 @@ export default function FileBrowserPanel({
return;
}
const relativePath = item.path.replace(/\/$/, "");
const fileTarget = {
environmentId,
filePath: relativePath,
workspaceRoot: cwd,
fileExists: entryKinds.get(relativePath) === "file",
};
const fileMenuItems = fileContextMenu.buildItems(fileTarget);
const fileMenuScope = fileContextMenu.captureScope(fileTarget);
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
Expand All @@ -155,9 +165,11 @@ export default function FileBrowserPanel({
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
...fileMenuItems,
],
position,
);
if (!fileContextMenu.isCurrent(fileTarget, fileMenuScope)) return;
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
Expand Down Expand Up @@ -189,13 +201,22 @@ export default function FileBrowserPanel({
description: "The chat isn't ready to accept input right now.",
});
}
return;
}
if (
clicked &&
fileMenuItems.some(
(item) => item.id === clicked || item.children?.some((child) => child.id === clicked),
)
) {
await fileContextMenu.activate(clicked as FileContextMenuAction, fileTarget, fileMenuScope);
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
useLayoutEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

Expand Down
Loading
Loading