mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
refactor(site): consolidate Git panel diff viewers and polish UI (#23080)
## Summary Refactors the Git panel in the Agents page to consolidate duplicated diff viewer code and significantly improve the UI. ### Deduplication - **RemoteDiffPanel** now uses the shared `DiffViewer` component instead of duplicating file tree, lazy loading, scroll tracking, and layout (~500 lines removed). - Renamed `RepoChangesPanel` → `LocalDiffPanel`, `FilesChangedPanel` → `RemoteDiffPanel` to reflect actual scope. - Removed `headerLeft`/`headerRight` abstraction from `DiffViewer` — each consumer owns its own header. - Replaced hand-rolled `ChatDiffStatusResponse` with auto-generated `ChatDiffStatus` from `typesGenerated.ts`. ### Tab Redesign - Per-repo tabs: each local repo gets its own tab (`Working <repo>`) instead of a single stacked view. - PR tab shows state icon + PR title; branch-only tab shows branch icon. - Tabs use `Button variant="outline"` matching the Git/Desktop tab style. - Radix `ScrollArea` with thin horizontal scrollbar for tab overflow. - Diff style toggle and refresh button lifted to shared toolbar, always visible. ### PR Header - Compact sub-header: `base_branch ←`, state badge (`Open`/`Draft`/`Merged`/`Closed`), diff stats, and `View PR` button. - GitHub-style state-aware icons (green open, gray draft, purple merged, red closed). - New API fields synced: `base_branch`, `author_login`, `pr_number`, `commits`, `approved`, `reviewer_count`. ### Local Changes Header - Compact sub-header: branch name, repo root path, diff stats, and `Commit` button (styled to match `View PR`). - `CircleDotIcon` (amber) for working changes tabs — universal "modified" indicator. ### Visual Polish - All text in sub-headers and buttons at 13px matching chat font size. - All badges (`DiffStatBadge`, PR state, `View PR`, `Commit`) use consistent `border-border-default`, `rounded-sm`, `leading-5`. - No background color on diff viewer header bars. - Tabs hidden when their view has no content; auto-switch when active tab disappears. ### Stories - New `GitPanel.stories.tsx` covering: open PR + working changes, draft PR, merged PR, closed PR, branch only, working changes only, multiple repos, empty state. - Removed old `LocalDiffPanel.stories.tsx` and `RemoteDiffPanel.stories.tsx`.
This commit is contained in:
+2
-16
@@ -365,20 +365,6 @@ interface ChatGitChangeResponse extends TypesGen.ChatGitChange {
|
||||
readonly diffs_link?: string;
|
||||
}
|
||||
|
||||
export type ChatDiffStatusResponse = Readonly<
|
||||
{
|
||||
chat_id: string;
|
||||
url?: string;
|
||||
pull_request_state?: string;
|
||||
changes_requested: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changed_files: number;
|
||||
refreshed_at?: string;
|
||||
stale_at?: string;
|
||||
} & Record<string, unknown>
|
||||
>;
|
||||
|
||||
function normalizeGetTemplatesOptions(
|
||||
options: GetTemplatesOptions | GetTemplatesQuery = {},
|
||||
): Record<string, string> {
|
||||
@@ -3059,8 +3045,8 @@ class ApiMethods {
|
||||
|
||||
getChatDiffStatus = async (
|
||||
chatId: string,
|
||||
): Promise<ChatDiffStatusResponse> => {
|
||||
const response = await this.axios.get<ChatDiffStatusResponse>(
|
||||
): Promise<TypesGen.ChatDiffStatus> => {
|
||||
const response = await this.axios.get<TypesGen.ChatDiffStatus>(
|
||||
`/api/experimental/chats/${chatId}/diff-status`,
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API, type ChatDiffStatusResponse } from "api/api";
|
||||
import { API } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { QueryClient, UseInfiniteQueryOptions } from "react-query";
|
||||
|
||||
@@ -357,7 +357,8 @@ export const chatDiffStatusKey = (chatId: string) =>
|
||||
|
||||
export const chatDiffStatus = (chatId: string) => ({
|
||||
queryKey: chatDiffStatusKey(chatId),
|
||||
queryFn: (): Promise<ChatDiffStatusResponse> => API.getChatDiffStatus(chatId),
|
||||
queryFn: (): Promise<TypesGen.ChatDiffStatus> =>
|
||||
API.getChatDiffStatus(chatId),
|
||||
});
|
||||
|
||||
export const chatDiffContentsKey = (chatId: string) =>
|
||||
|
||||
@@ -3,32 +3,67 @@
|
||||
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
|
||||
*/
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root> {
|
||||
scrollBarClassName?: string;
|
||||
viewportClassName?: string;
|
||||
/** Which scrollbar(s) to show. Defaults to "vertical". */
|
||||
orientation?: "vertical" | "horizontal" | "both";
|
||||
}
|
||||
|
||||
export const ScrollArea: React.FC<ScrollAreaProps> = ({
|
||||
className,
|
||||
scrollBarClassName,
|
||||
viewportClassName,
|
||||
orientation = "vertical",
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Translate vertical wheel events into horizontal scroll when the
|
||||
// scroll area only scrolls horizontally. Without this, the mouse
|
||||
// wheel does nothing on a horizontal-only container.
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (orientation !== "horizontal") return;
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
// Only redirect when the user is scrolling vertically.
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
e.preventDefault();
|
||||
el.scrollBy({ left: e.deltaY, behavior: "smooth" });
|
||||
},
|
||||
[orientation],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
onWheel={handleWheel}
|
||||
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar className={cn("z-10", scrollBarClassName)} />
|
||||
{(orientation === "vertical" || orientation === "both") && (
|
||||
<ScrollBar
|
||||
orientation="vertical"
|
||||
className={cn("z-10", scrollBarClassName)}
|
||||
/>
|
||||
)}
|
||||
{(orientation === "horizontal" || orientation === "both") && (
|
||||
<ScrollBar
|
||||
orientation="horizontal"
|
||||
className={cn("z-10", scrollBarClassName)}
|
||||
/>
|
||||
)}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MockUserOwner } from "testHelpers/entities";
|
||||
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ChatDiffStatusResponse } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatDiffStatus } from "api/typesGenerated";
|
||||
import type { ModelSelectorOption } from "components/ai-elements";
|
||||
import { fn } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
@@ -199,11 +199,13 @@ export const WithSidebarPanel: Story = {
|
||||
diffStatusData: {
|
||||
chat_id: AGENT_ID,
|
||||
url: "https://github.com/coder/coder/pull/123",
|
||||
pull_request_title: "fix: resolve race condition in workspace builds",
|
||||
pull_request_draft: false,
|
||||
changes_requested: false,
|
||||
additions: 42,
|
||||
deletions: 7,
|
||||
changed_files: 5,
|
||||
} satisfies ChatDiffStatusResponse,
|
||||
} satisfies ChatDiffStatus,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ChatDiffStatusResponse } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatDiffStatus } from "api/typesGenerated";
|
||||
import type { ModelSelectorOption } from "components/ai-elements";
|
||||
import { ArchiveIcon } from "lucide-react";
|
||||
import { type FC, type RefObject, useMemo, useState } from "react";
|
||||
import { type FC, type RefObject, useState } from "react";
|
||||
import type { UrlTransform } from "streamdown";
|
||||
import { cn } from "utils/cn";
|
||||
import { pageTitle } from "utils/page";
|
||||
@@ -89,7 +89,7 @@ interface AgentDetailViewProps {
|
||||
|
||||
// Sidebar content data.
|
||||
prNumber: number | undefined;
|
||||
diffStatusData: ChatDiffStatusResponse | undefined;
|
||||
diffStatusData: ChatDiffStatus | undefined;
|
||||
gitWatcher: {
|
||||
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
|
||||
refresh: () => void;
|
||||
@@ -180,21 +180,6 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
|
||||
|
||||
// Compute local diff stats from git watcher unified diffs.
|
||||
const localDiffStats = useMemo(() => {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const repo of gitWatcher.repositories.values()) {
|
||||
if (!repo.unified_diff) continue;
|
||||
for (const line of repo.unified_diff.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
additions++;
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { additions, deletions, changed_files: 0 };
|
||||
}, [gitWatcher.repositories]);
|
||||
|
||||
const titleElement = (
|
||||
<title>
|
||||
@@ -335,7 +320,6 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
remoteDiffStats={diffStatusData}
|
||||
localDiffStats={localDiffStats}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -12,14 +12,14 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex h-full items-center self-stretch overflow-hidden font-mono text-xs font-medium">
|
||||
<span className="inline-flex items-center overflow-hidden rounded-sm border border-solid border-border-default font-mono text-[13px] font-medium leading-5">
|
||||
{additions > 0 && (
|
||||
<span className="flex h-full items-center bg-surface-git-added px-1.5 text-git-added-bright">
|
||||
<span className="flex items-center px-1.5 bg-surface-git-added text-git-added-bright">
|
||||
+{additions}
|
||||
</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="flex h-full items-center bg-surface-git-deleted px-1.5 text-git-deleted-bright">
|
||||
<span className="flex items-center px-1.5 bg-surface-git-deleted text-git-deleted-bright">
|
||||
−{deletions}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import type { DiffLineAnnotation, FileDiffMetadata } from "@pierre/diffs";
|
||||
import { FileDiff } from "@pierre/diffs/react";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import {
|
||||
@@ -28,8 +28,6 @@ import { changeColor, changeLabel } from "./diffColors";
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface DiffViewerProps {
|
||||
/** Fragment to display in the top-left of the header bar. */
|
||||
headerLeft?: ReactNode;
|
||||
/** Parsed file diffs to render. */
|
||||
parsedFiles: readonly FileDiffMetadata[];
|
||||
/** Cache key prefix for parsePatchFiles worker pool LRU cache. */
|
||||
@@ -44,6 +42,44 @@ interface DiffViewerProps {
|
||||
emptyMessage?: string;
|
||||
/** Which diff rendering style to use. */
|
||||
diffStyle: DiffStyle;
|
||||
/**
|
||||
* Called when a line number gutter element is clicked.
|
||||
* Receives the file name and click metadata.
|
||||
*/
|
||||
onLineNumberClick?: (
|
||||
fileName: string,
|
||||
props: { lineNumber: number; annotationSide: "additions" | "deletions" },
|
||||
) => void;
|
||||
/**
|
||||
* Called when a range of lines is selected (e.g. shift-click).
|
||||
* Receives the file name and the selected range (or null on
|
||||
* deselection).
|
||||
*/
|
||||
onLineSelected?: (
|
||||
fileName: string,
|
||||
range: {
|
||||
start: number;
|
||||
end: number;
|
||||
side?: "additions" | "deletions";
|
||||
} | null,
|
||||
) => void;
|
||||
/**
|
||||
* Returns line annotations for the given file. Used to render
|
||||
* inline widgets such as comment inputs.
|
||||
*/
|
||||
getLineAnnotations?: (fileName: string) => DiffLineAnnotation<string>[];
|
||||
/**
|
||||
* Renderer for line annotations returned by `getLineAnnotations`.
|
||||
*/
|
||||
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
|
||||
/**
|
||||
* When set to a file name, DiffViewer scrolls to that file and
|
||||
* highlights it in the tree. The parent should reset this to
|
||||
* null via `onScrollToFileComplete` after the scroll completes.
|
||||
*/
|
||||
scrollToFile?: string | null;
|
||||
/** Called after scrollToFile has been processed. */
|
||||
onScrollToFileComplete?: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -307,7 +343,14 @@ const FileTreeNodeView: FC<{
|
||||
const LazyFileDiff: FC<{
|
||||
fileDiff: FileDiffMetadata;
|
||||
options: ComponentProps<typeof FileDiff>["options"];
|
||||
}> = ({ fileDiff, options }) => {
|
||||
lineAnnotations?: DiffLineAnnotation<string>[];
|
||||
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
|
||||
}> = ({
|
||||
fileDiff,
|
||||
options,
|
||||
lineAnnotations,
|
||||
renderAnnotation: renderAnnotationProp,
|
||||
}) => {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -348,7 +391,13 @@ const LazyFileDiff: FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<FileDiff fileDiff={fileDiff} options={options} style={DIFFS_FONT_STYLE} />
|
||||
<FileDiff
|
||||
fileDiff={fileDiff}
|
||||
options={options}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
lineAnnotations={lineAnnotations}
|
||||
renderAnnotation={renderAnnotationProp}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -357,13 +406,18 @@ const LazyFileDiff: FC<{
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
headerLeft,
|
||||
parsedFiles,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
error,
|
||||
emptyMessage = "No file changes to display.",
|
||||
diffStyle,
|
||||
onLineNumberClick,
|
||||
onLineSelected,
|
||||
getLineAnnotations,
|
||||
renderAnnotation,
|
||||
scrollToFile,
|
||||
onScrollToFileComplete,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
@@ -396,6 +450,38 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
[diffOptions],
|
||||
);
|
||||
|
||||
// When the parent provides per-file callbacks (e.g. line click
|
||||
// handlers for comment inputs), build options per file. Otherwise
|
||||
// share a single stable object to avoid unnecessary re-highlights.
|
||||
const hasPerFileCallbacks = !!(onLineNumberClick || onLineSelected);
|
||||
|
||||
const getOptionsForFile = useCallback(
|
||||
(fileName: string) => ({
|
||||
...diffOptions,
|
||||
overflow: "wrap" as const,
|
||||
enableLineSelection: true,
|
||||
enableHoverUtility: true,
|
||||
...(onLineNumberClick && {
|
||||
onLineNumberClick: (props: {
|
||||
lineNumber: number;
|
||||
annotationSide: "additions" | "deletions";
|
||||
}) => onLineNumberClick(fileName, props),
|
||||
}),
|
||||
onLineSelected: onLineSelected
|
||||
? (
|
||||
range: {
|
||||
start: number;
|
||||
end: number;
|
||||
side?: "additions" | "deletions";
|
||||
} | null,
|
||||
) => onLineSelected(fileName, range)
|
||||
: () => {
|
||||
// TODO: Make this add context to the input.
|
||||
},
|
||||
}),
|
||||
[diffOptions, onLineNumberClick, onLineSelected],
|
||||
);
|
||||
|
||||
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
|
||||
|
||||
// Sort diff blocks in the same order the file tree displays them
|
||||
@@ -540,6 +626,20 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to a file programmatically when the parent sets
|
||||
// scrollToFile. This enables external navigation (e.g.
|
||||
// clicking a file reference chip in the chat input).
|
||||
useEffect(() => {
|
||||
if (scrollToFile) {
|
||||
const el = fileRefs.current.get(scrollToFile);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
setActiveFile(scrollToFile);
|
||||
}
|
||||
onScrollToFileComplete?.();
|
||||
}
|
||||
}, [scrollToFile, onScrollToFileComplete]);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Loading state
|
||||
// ---------------------------------------------------------------
|
||||
@@ -579,11 +679,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
ref={containerRef}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1 bg-surface-secondary px-3 py-2">
|
||||
{headerLeft}
|
||||
</div>
|
||||
{/* Diff contents */}
|
||||
{/* Diff contents */}{" "}
|
||||
{sortedFiles.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
|
||||
{emptyMessage}
|
||||
@@ -632,7 +728,16 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
key={fileDiff.name}
|
||||
ref={(el) => setFileRef(fileDiff.name, el)}
|
||||
>
|
||||
<LazyFileDiff fileDiff={fileDiff} options={fileOptions} />
|
||||
<LazyFileDiff
|
||||
fileDiff={fileDiff}
|
||||
options={
|
||||
hasPerFileCallbacks
|
||||
? getOptionsForFile(fileDiff.name)
|
||||
: fileOptions
|
||||
}
|
||||
lineAnnotations={getLineAnnotations?.(fileDiff.name)}
|
||||
renderAnnotation={renderAnnotation}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Spacer so the last file can scroll fully to the top. */}
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ChatDiffStatusResponse } from "api/api";
|
||||
import { API } from "api/api";
|
||||
import type { ChatDiffContents } from "api/typesGenerated";
|
||||
import { expect, screen, spyOn } from "storybook/test";
|
||||
import { FilesChangedPanel } from "./FilesChangedPanel";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Large-diff generator — produces a realistic unified diff with the
|
||||
// requested number of additions and deletions spread across multiple
|
||||
// TypeScript files. Used by the LargeDiff story to reproduce the
|
||||
// performance characteristics of a +2000 -1000 agent chat.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a block of realistic-looking TypeScript lines. */
|
||||
function tsLines(prefix: string, count: number, startIdx = 0): string[] {
|
||||
const templates = [
|
||||
(i: number) => ` const ${prefix}Val${i} = computeValue(${i}, opts);`,
|
||||
(i: number) => ` if (${prefix}Val${i} !== undefined) {`,
|
||||
(i: number) =>
|
||||
` logger.info("Processing ${prefix} item", { index: ${i} });`,
|
||||
(i: number) => ` results.push(await transform(${prefix}Val${i}));`,
|
||||
(_i: number) => " }",
|
||||
(i: number) => ` // Handle edge case for ${prefix} iteration ${i}`,
|
||||
(i: number) =>
|
||||
` const ${prefix}Mapped${i} = items.map((x) => x.${prefix}Field${i});`,
|
||||
(i: number) =>
|
||||
` await db.query(\`SELECT * FROM ${prefix}_table WHERE id = \${${i}}\`);`,
|
||||
(i: number) =>
|
||||
` export function ${prefix}Handler${i}(req: Request): Response {`,
|
||||
(i: number) =>
|
||||
` return new Response(JSON.stringify({ ${prefix}: ${i} }));`,
|
||||
];
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tpl = templates[(startIdx + i) % templates.length];
|
||||
lines.push(tpl(startIdx + i));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single file section of a unified diff. `contextLines` lines
|
||||
* of shared context surround each hunk. `additions` new lines and
|
||||
* `deletions` removed lines are interleaved across multiple hunks.
|
||||
*/
|
||||
function buildFileDiff(opts: {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
contextPerHunk?: number;
|
||||
hunks?: number;
|
||||
}): string {
|
||||
const {
|
||||
oldPath,
|
||||
newPath,
|
||||
additions,
|
||||
deletions,
|
||||
contextPerHunk = 5,
|
||||
hunks = Math.max(1, Math.ceil((additions + deletions) / 80)),
|
||||
} = opts;
|
||||
|
||||
const addPerHunk = Math.ceil(additions / hunks);
|
||||
const delPerHunk = Math.ceil(deletions / hunks);
|
||||
const prefix = oldPath.replace(/[^a-zA-Z]/g, "").slice(0, 6);
|
||||
|
||||
let output = `diff --git a/${oldPath} b/${newPath}\n`;
|
||||
output += "index 1a2b3c4..5d6e7f8 100644\n";
|
||||
output += `--- a/${oldPath}\n`;
|
||||
output += `+++ b/${newPath}\n`;
|
||||
|
||||
let oldLine = 1;
|
||||
let additionsLeft = additions;
|
||||
let deletionsLeft = deletions;
|
||||
|
||||
for (let h = 0; h < hunks; h++) {
|
||||
const ctxLines = tsLines(prefix, contextPerHunk, oldLine);
|
||||
const hunkDel = Math.min(delPerHunk, deletionsLeft);
|
||||
const hunkAdd = Math.min(addPerHunk, additionsLeft);
|
||||
deletionsLeft -= hunkDel;
|
||||
additionsLeft -= hunkAdd;
|
||||
|
||||
const oldCount = contextPerHunk + hunkDel + contextPerHunk;
|
||||
const newCount = contextPerHunk + hunkAdd + contextPerHunk;
|
||||
output += `@@ -${oldLine},${oldCount} +${oldLine},${newCount} @@\n`;
|
||||
|
||||
// Leading context.
|
||||
for (const l of ctxLines) {
|
||||
output += ` ${l}\n`;
|
||||
}
|
||||
|
||||
// Deletions.
|
||||
for (const l of tsLines(
|
||||
`old${prefix}`,
|
||||
hunkDel,
|
||||
oldLine + contextPerHunk,
|
||||
)) {
|
||||
output += `-${l}\n`;
|
||||
}
|
||||
|
||||
// Additions.
|
||||
for (const l of tsLines(
|
||||
`new${prefix}`,
|
||||
hunkAdd,
|
||||
oldLine + contextPerHunk,
|
||||
)) {
|
||||
output += `+${l}\n`;
|
||||
}
|
||||
|
||||
// Trailing context.
|
||||
for (const l of tsLines(
|
||||
prefix,
|
||||
contextPerHunk,
|
||||
oldLine + contextPerHunk + hunkDel,
|
||||
)) {
|
||||
output += ` ${l}\n`;
|
||||
}
|
||||
|
||||
oldLine += oldCount + 20;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete multi-file unified diff targeting roughly
|
||||
* `totalAdditions` added lines and `totalDeletions` removed lines.
|
||||
*/
|
||||
function generateLargeDiff(
|
||||
totalAdditions: number,
|
||||
totalDeletions: number,
|
||||
): string {
|
||||
const files = [
|
||||
{
|
||||
path: "site/src/pages/AgentsPage/AgentDetail.tsx",
|
||||
addPct: 0.25,
|
||||
delPct: 0.2,
|
||||
},
|
||||
{
|
||||
path: "site/src/components/ai-elements/tool/utils.ts",
|
||||
addPct: 0.15,
|
||||
delPct: 0.15,
|
||||
},
|
||||
{
|
||||
path: "site/src/pages/AgentsPage/FilesChangedPanel.tsx",
|
||||
addPct: 0.15,
|
||||
delPct: 0.1,
|
||||
},
|
||||
{ path: "site/src/api/queries/chats.ts", addPct: 0.1, delPct: 0.15 },
|
||||
{
|
||||
path: "site/src/modules/resources/AgentLogs/AgentLogs.tsx",
|
||||
addPct: 0.1,
|
||||
delPct: 0.1,
|
||||
},
|
||||
{ path: "coderd/database/queries/chats.sql", addPct: 0.05, delPct: 0.1 },
|
||||
{
|
||||
path: "site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx",
|
||||
addPct: 0.1,
|
||||
delPct: 0.05,
|
||||
},
|
||||
{
|
||||
path: "site/src/pages/AgentsPage/RightPanel.tsx",
|
||||
addPct: 0.05,
|
||||
delPct: 0.1,
|
||||
},
|
||||
{ path: "site/src/hooks/useDiffViewer.ts", addPct: 0.05, delPct: 0.05 },
|
||||
];
|
||||
const patches: string[] = [];
|
||||
for (const f of files) {
|
||||
const add = Math.round(totalAdditions * f.addPct);
|
||||
const del = Math.round(totalDeletions * f.delPct);
|
||||
if (add === 0 && del === 0) continue;
|
||||
patches.push(
|
||||
buildFileDiff({
|
||||
oldPath: f.path,
|
||||
newPath: f.path,
|
||||
additions: add,
|
||||
deletions: del,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return patches.join("");
|
||||
}
|
||||
|
||||
const sampleUnifiedDiff = `diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.tsx
|
||||
index abc1234..def5678 100644
|
||||
--- a/site/src/pages/AgentsPage/FilesChangedPanel.tsx
|
||||
+++ b/site/src/pages/AgentsPage/FilesChangedPanel.tsx
|
||||
@@ -1,10 +1,15 @@
|
||||
+import { useTheme } from "@emotion/react";
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import { FileDiff } from "@pierre/diffs/react";
|
||||
+import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react"; // deliberately long import to verify horizontal overflow handling in narrow panels
|
||||
+import {
|
||||
+ DIFFS_FONT_STYLE,
|
||||
+ getDiffViewerOptions,
|
||||
+} from "components/ai-elements/tool/utils";
|
||||
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
-import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
diff --git a/site/src/components/ai-elements/tool/utils.ts b/site/src/components/ai-elements/tool/utils.ts
|
||||
index 1234567..abcdef0 100644
|
||||
--- a/site/src/components/ai-elements/tool/utils.ts
|
||||
+++ b/site/src/components/ai-elements/tool/utils.ts
|
||||
@@ -10,6 +10,18 @@ export const diffViewerCSS =
|
||||
export function getDiffViewerOptions(isDark: boolean) {
|
||||
return {
|
||||
diffStyle: "unified" as const,
|
||||
+ diffIndicators: "bars" as const,
|
||||
+ overflow: "scroll" as const,
|
||||
themeType: (isDark ? "dark" : "light") as "dark" | "light",
|
||||
+ theme: isDark ? "github-dark-high-contrast" : "github-light",
|
||||
+ unsafeCSS: diffViewerCSS,
|
||||
};
|
||||
}
|
||||
+
|
||||
+export const DIFFS_FONT_STYLE = {
|
||||
+ "--diffs-font-size": "11px",
|
||||
+ "--diffs-line-height": "1.5",
|
||||
+} as React.CSSProperties;
|
||||
`;
|
||||
|
||||
const defaultDiffStatus: ChatDiffStatusResponse = {
|
||||
chat_id: "test-chat",
|
||||
pull_request_title: "",
|
||||
pull_request_draft: false,
|
||||
changes_requested: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changed_files: 0,
|
||||
};
|
||||
|
||||
const defaultDiffContents: ChatDiffContents = {
|
||||
chat_id: "test-chat",
|
||||
};
|
||||
|
||||
const meta: Meta<typeof FilesChangedPanel> = {
|
||||
title: "pages/AgentsPage/FilesChangedPanel",
|
||||
component: FilesChangedPanel,
|
||||
args: {
|
||||
chatId: "test-chat",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 600, width: 500 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(defaultDiffStatus);
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue(defaultDiffContents);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FilesChangedPanel>;
|
||||
|
||||
export const EmptyDiff: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: undefined,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: "",
|
||||
});
|
||||
},
|
||||
play: async () => {
|
||||
await screen.findByText("No file changes to display.");
|
||||
expect(screen.getByText("No file changes to display.")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ParseError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/123",
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: "not-a-valid-unified-diff",
|
||||
});
|
||||
},
|
||||
play: async () => {
|
||||
await screen.findByText("No file changes to display.");
|
||||
expect(screen.getByText("No file changes to display.")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDiffDark: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/456",
|
||||
additions: 14,
|
||||
deletions: 2,
|
||||
changed_files: 2,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleUnifiedDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDiffLight: Story = {
|
||||
globals: {
|
||||
theme: "light",
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/456",
|
||||
additions: 14,
|
||||
deletions: 2,
|
||||
changed_files: 2,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleUnifiedDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPullRequestDark: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/456",
|
||||
additions: 14,
|
||||
deletions: 2,
|
||||
changed_files: 2,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleUnifiedDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPullRequestLight: Story = {
|
||||
globals: {
|
||||
theme: "light",
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/456",
|
||||
additions: 14,
|
||||
deletions: 2,
|
||||
changed_files: 2,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleUnifiedDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stress-test story: renders a diff with approximately +2000 -1000
|
||||
* lines spread across 9 TypeScript files. Use this to reproduce
|
||||
* and profile the performance issues reported for large agent chats.
|
||||
*
|
||||
* Open the browser DevTools Performance tab and record while this
|
||||
* story loads to measure:
|
||||
* - Initial render blocking time (shiki tokenization)
|
||||
* - Total DOM node count
|
||||
* - Scroll jank / frame drops
|
||||
*/
|
||||
export const LargeDiff: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 800, width: 600 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
beforeEach: () => {
|
||||
const largeDiff = generateLargeDiff(2000, 1000);
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/789",
|
||||
additions: 2000,
|
||||
deletions: 1000,
|
||||
changed_files: 9,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: largeDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as LargeDiff but in light mode for visual comparison.
|
||||
*/
|
||||
export const LargeDiffLight: Story = {
|
||||
globals: {
|
||||
theme: "light",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 800, width: 600 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
beforeEach: () => {
|
||||
const largeDiff = generateLargeDiff(2000, 1000);
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/789",
|
||||
additions: 2000,
|
||||
deletions: 1000,
|
||||
changed_files: 9,
|
||||
});
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: largeDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,993 +0,0 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import type { DiffLineAnnotation, FileDiffMetadata } from "@pierre/diffs";
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import { FileDiff } from "@pierre/diffs/react";
|
||||
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import {
|
||||
DIFFS_FONT_STYLE,
|
||||
getDiffViewerOptions,
|
||||
} from "components/ai-elements/tool/utils";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FileIcon } from "components/FileIcon/FileIcon";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
Columns2Icon,
|
||||
CornerDownLeftIcon,
|
||||
ExternalLinkIcon,
|
||||
GitBranchIcon,
|
||||
GitPullRequestIcon,
|
||||
Rows3Icon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { changeColor, changeLabel } from "./diffColors";
|
||||
|
||||
interface FilesChangedPanelProps {
|
||||
chatId: string;
|
||||
isExpanded?: boolean;
|
||||
chatInputRef?: React.RefObject<ChatMessageInputRef | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum container width (px) at which the file tree sidebar
|
||||
* is shown alongside the diff list.
|
||||
*/
|
||||
const FILE_TREE_THRESHOLD = 1000;
|
||||
|
||||
/**
|
||||
* Extra CSS injected via the diff viewer's `unsafeCSS` option to make
|
||||
* file headers sticky and adjust metadata layout.
|
||||
*/
|
||||
const STICKY_HEADER_CSS = [
|
||||
"[data-diffs-header] {",
|
||||
" position: sticky; top: 0; z-index: 10;",
|
||||
" font-size: 13px;",
|
||||
" border-bottom: 1px solid hsl(var(--border-default));",
|
||||
" background-color: hsl(var(--surface-quaternary)) !important;",
|
||||
"}",
|
||||
"[data-diffs-header] [data-metadata] { flex-direction: row-reverse; }",
|
||||
"@media (prefers-color-scheme: dark) {",
|
||||
" [data-diffs-header] { background-color: hsl(var(--surface-secondary)) !important; }",
|
||||
"}",
|
||||
].join(" ");
|
||||
|
||||
type DiffStyle = "unified" | "split";
|
||||
const DIFF_STYLE_KEY = "agents.diff-view-style";
|
||||
|
||||
/**
|
||||
* Walk the parsed hunks for a file and collect code lines that fall
|
||||
* within `startLine..endLine` on the given side. For "additions"
|
||||
* lines are matched against addition line numbers (using
|
||||
* `hunk.additionStart`); for "deletions" against deletion line
|
||||
* numbers (using `hunk.deletionStart`). Context lines that fall
|
||||
* in range are included as well.
|
||||
*/
|
||||
function extractDiffContent(
|
||||
parsedFiles: readonly FileDiffMetadata[],
|
||||
fileName: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
side: "additions" | "deletions",
|
||||
): string {
|
||||
const file = parsedFiles.find((f) => f.name === fileName);
|
||||
if (!file) return "";
|
||||
|
||||
const lines = side === "additions" ? file.additionLines : file.deletionLines;
|
||||
const collected: string[] = [];
|
||||
for (const hunk of file.hunks) {
|
||||
let addLine = hunk.additionStart;
|
||||
let delLine = hunk.deletionStart;
|
||||
|
||||
for (const block of hunk.hunkContent) {
|
||||
if (block.type === "context") {
|
||||
for (let i = 0; i < block.lines; i++) {
|
||||
const ln = side === "additions" ? addLine : delLine;
|
||||
if (ln >= startLine && ln <= endLine) {
|
||||
const idx =
|
||||
side === "additions"
|
||||
? block.additionLineIndex + i
|
||||
: block.deletionLineIndex + i;
|
||||
if (lines[idx] != null) collected.push(lines[idx]);
|
||||
}
|
||||
addLine++;
|
||||
delLine++;
|
||||
}
|
||||
} else {
|
||||
// ChangeContent block.
|
||||
if (side === "deletions") {
|
||||
for (let i = 0; i < block.deletions; i++) {
|
||||
if (delLine >= startLine && delLine <= endLine) {
|
||||
const line = lines[block.deletionLineIndex + i];
|
||||
if (line != null) collected.push(line);
|
||||
}
|
||||
delLine++;
|
||||
}
|
||||
// Addition lines in a change block still advance
|
||||
// the addition counter.
|
||||
addLine += block.additions;
|
||||
} else {
|
||||
// side === "additions"
|
||||
// Deletion lines in a change block still advance
|
||||
// the deletion counter.
|
||||
delLine += block.deletions;
|
||||
for (let i = 0; i < block.additions; i++) {
|
||||
if (addLine >= startLine && addLine <= endLine) {
|
||||
const line = lines[block.additionLineIndex + i];
|
||||
if (line != null) collected.push(line);
|
||||
}
|
||||
addLine++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
function loadDiffStyle(): DiffStyle {
|
||||
if (typeof window === "undefined") {
|
||||
return "unified";
|
||||
}
|
||||
const stored = localStorage.getItem(DIFF_STYLE_KEY);
|
||||
if (stored === "split" || stored === "unified") {
|
||||
return stored;
|
||||
}
|
||||
return "unified";
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of the file tree sidebar in pixels.
|
||||
*/
|
||||
const FILE_TREE_WIDTH = 300;
|
||||
|
||||
/**
|
||||
* Parses a GitHub PR URL into its components.
|
||||
* Returns null if parsing fails.
|
||||
*/
|
||||
function parsePullRequestUrl(url: string): {
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: string;
|
||||
} | null {
|
||||
try {
|
||||
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
||||
if (match) {
|
||||
return { owner: match[1], repo: match[2], number: match[3] };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// File tree data model
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface FileTreeNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
type: "file" | "directory";
|
||||
children: FileTreeNode[];
|
||||
fileDiff?: FileDiffMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a nested tree from a flat list of file diffs. Directory
|
||||
* nodes are created for every intermediate path segment. The
|
||||
* result is sorted with directories first, then alphabetically.
|
||||
* Single-child directory chains are collapsed so that e.g.
|
||||
* `src/pages/AgentsPage` renders as one row.
|
||||
*/
|
||||
function buildFileTree(files: FileDiffMetadata[]): FileTreeNode[] {
|
||||
const root: FileTreeNode[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const segments = file.name.split("/");
|
||||
let children = root;
|
||||
|
||||
// Walk / create intermediate directory nodes.
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
let dir = children.find((n) => n.type === "directory" && n.name === seg);
|
||||
if (!dir) {
|
||||
dir = {
|
||||
name: seg,
|
||||
fullPath: segments.slice(0, i + 1).join("/"),
|
||||
type: "directory",
|
||||
children: [],
|
||||
};
|
||||
children.push(dir);
|
||||
}
|
||||
children = dir.children;
|
||||
}
|
||||
|
||||
// Leaf file node.
|
||||
const fileName = segments[segments.length - 1];
|
||||
children.push({
|
||||
name: fileName,
|
||||
fullPath: file.name,
|
||||
type: "file",
|
||||
children: [],
|
||||
fileDiff: file,
|
||||
});
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length > 0) {
|
||||
node.children = sortNodes(node.children);
|
||||
}
|
||||
}
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
// Collapse single-child directory chains into one node whose
|
||||
// name uses path separators, e.g. "src/pages/AgentsPage".
|
||||
const collapse = (nodes: FileTreeNode[]): FileTreeNode[] => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "directory") {
|
||||
node.children = collapse(node.children);
|
||||
// If this directory has exactly one child and it is also
|
||||
// a directory, merge them.
|
||||
while (
|
||||
node.children.length === 1 &&
|
||||
node.children[0].type === "directory"
|
||||
) {
|
||||
const child = node.children[0];
|
||||
node.name = `${node.name}/${child.name}`;
|
||||
node.fullPath = child.fullPath;
|
||||
node.children = child.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
return collapse(sortNodes(root));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Tree node renderer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const FileTreeNodeView: FC<{
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
activeFile: string | null;
|
||||
onFileClick: (fullPath: string) => void;
|
||||
}> = ({ node, depth, activeFile, onFileClick }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (node.type === "directory") {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left text-content-secondary hover:bg-surface-secondary cursor-pointer outline-none"
|
||||
style={{ paddingLeft: 4 + depth * 8, fontSize: 13 }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-3 shrink-0 transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
{expanded &&
|
||||
node.children.map((child) => (
|
||||
<FileTreeNodeView
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = activeFile === node.fullPath;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFileClick(node.fullPath)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left cursor-pointer outline-none border-0 border-r-2 border-solid border-transparent",
|
||||
"hover:bg-surface-secondary",
|
||||
isActive && "bg-surface-secondary border-content-link",
|
||||
)}
|
||||
style={{ paddingLeft: 4 + depth * 8 + 12, fontSize: 13 }}
|
||||
title={node.fullPath}
|
||||
>
|
||||
<FileIcon fileName={node.name} className="shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
changeColor(node.fileDiff?.type) ??
|
||||
(isActive ? "text-content-primary" : "text-content-secondary"),
|
||||
)}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.fileDiff?.type && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pr-2 text-xs",
|
||||
changeColor(node.fileDiff.type),
|
||||
)}
|
||||
>
|
||||
{changeLabel(node.fileDiff.type)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline input rendered as a diff annotation under the selected
|
||||
* line(s). Supports multiline via Shift+Enter. Enter submits,
|
||||
* Escape dismisses.
|
||||
*/
|
||||
const InlinePromptInput: FC<{
|
||||
onSubmit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ onSubmit, onCancel }) => {
|
||||
const [text, setText] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus the textarea on mount. We use a ref callback via rAF
|
||||
// rather than autoFocus because the component renders inside
|
||||
// Shadow DOM where autoFocus is unreliable.
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="rounded-lg border border-border-default bg-surface-secondary p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full resize-none border-none bg-transparent px-2.5 py-1.5 font-sans text-[13px] leading-5 text-content-primary placeholder:text-content-secondary outline-none ring-0 focus:outline-none focus:ring-0"
|
||||
placeholder="Add a comment to include with this reference..."
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (text.trim()) {
|
||||
onSubmit(text.trim());
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-end px-1.5 pb-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
className="h-6 gap-1.5 px-2 text-xs text-content-secondary hover:text-content-primary"
|
||||
disabled={!text.trim()}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent blur from firing before click.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
if (text.trim()) {
|
||||
onSubmit(text.trim());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CornerDownLeftIcon className="size-3" />
|
||||
Add to chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
|
||||
chatId,
|
||||
isExpanded,
|
||||
chatInputRef,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
||||
const handleSetDiffStyle = useCallback((style: DiffStyle) => {
|
||||
setDiffStyle(style);
|
||||
localStorage.setItem(DIFF_STYLE_KEY, style);
|
||||
}, []);
|
||||
|
||||
const [activeCommentBox, setActiveCommentBox] = useState<{
|
||||
fileName: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
side: "additions" | "deletions";
|
||||
} | null>(null);
|
||||
|
||||
const diffOptions = useMemo(() => {
|
||||
const base = getDiffViewerOptions(isDark);
|
||||
return {
|
||||
...base,
|
||||
diffStyle,
|
||||
// Extend the base CSS to make file headers sticky so they
|
||||
// remain visible while scrolling through long diffs.
|
||||
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
|
||||
};
|
||||
}, [isDark, diffStyle]);
|
||||
|
||||
// Returns per-file diff options that include a line-number click
|
||||
// handler scoped to the given file name.
|
||||
const getFileOptions = useCallback(
|
||||
(fileName: string) => ({
|
||||
...diffOptions,
|
||||
overflow: "wrap" as const,
|
||||
enableLineSelection: true,
|
||||
enableHoverUtility: true,
|
||||
onLineNumberClick(props: {
|
||||
lineNumber: number;
|
||||
annotationSide: "additions" | "deletions";
|
||||
}) {
|
||||
setActiveCommentBox({
|
||||
fileName,
|
||||
startLine: props.lineNumber,
|
||||
endLine: props.lineNumber,
|
||||
side: props.annotationSide,
|
||||
});
|
||||
},
|
||||
onLineSelected(
|
||||
range: {
|
||||
start: number;
|
||||
end: number;
|
||||
side?: "additions" | "deletions";
|
||||
} | null,
|
||||
) {
|
||||
if (!range || range.start === range.end) return;
|
||||
const side = range.side ?? "additions";
|
||||
setActiveCommentBox({
|
||||
fileName,
|
||||
startLine: Math.min(range.start, range.end),
|
||||
endLine: Math.max(range.start, range.end),
|
||||
side,
|
||||
});
|
||||
},
|
||||
}),
|
||||
[diffOptions],
|
||||
);
|
||||
|
||||
const getAnnotationsForFile = useCallback(
|
||||
(fileName: string): DiffLineAnnotation<string>[] => {
|
||||
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
||||
return [
|
||||
{
|
||||
side: activeCommentBox.side,
|
||||
lineNumber: activeCommentBox.startLine,
|
||||
metadata: "active-input",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[activeCommentBox],
|
||||
);
|
||||
const handleCancelComment = useCallback(() => {
|
||||
setActiveCommentBox(null);
|
||||
}, []);
|
||||
|
||||
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
|
||||
const diffContentsQuery = useQuery({
|
||||
...chatDiffContents(chatId),
|
||||
enabled: Boolean(diffStatusQuery.data?.url),
|
||||
});
|
||||
|
||||
const parsedFiles = useMemo(() => {
|
||||
const diff = diffContentsQuery.data?.diff;
|
||||
if (!diff) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
// The cacheKeyPrefix enables the worker pool's LRU cache
|
||||
// so highlighted ASTs are reused across re-renders instead
|
||||
// of being re-computed on every render cycle. We include
|
||||
// dataUpdatedAt so that when the diff content changes
|
||||
// (e.g. new commits pushed) the old cached highlight AST
|
||||
// is not reused with mismatched line indices, which would
|
||||
// cause DiffHunksRenderer.processDiffResult to throw.
|
||||
const patches = parsePatchFiles(
|
||||
diff,
|
||||
`chat-${chatId}-${diffContentsQuery.dataUpdatedAt}`,
|
||||
);
|
||||
return patches.flatMap((p) => p.files);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [diffContentsQuery.data?.diff, diffContentsQuery.dataUpdatedAt, chatId]);
|
||||
|
||||
const handleSubmitComment = useCallback(
|
||||
(text: string) => {
|
||||
if (!activeCommentBox) return;
|
||||
const content = extractDiffContent(
|
||||
parsedFiles,
|
||||
activeCommentBox.fileName,
|
||||
activeCommentBox.startLine,
|
||||
activeCommentBox.endLine,
|
||||
activeCommentBox.side,
|
||||
);
|
||||
// Single imperative call — chip inserted atomically
|
||||
// in one Lexical update. No rAF hack needed.
|
||||
chatInputRef?.current?.addFileReference({
|
||||
fileName: activeCommentBox.fileName,
|
||||
startLine: activeCommentBox.startLine,
|
||||
endLine: activeCommentBox.endLine,
|
||||
content,
|
||||
});
|
||||
if (text.trim()) {
|
||||
chatInputRef?.current?.insertText(text);
|
||||
}
|
||||
setActiveCommentBox(null);
|
||||
},
|
||||
[activeCommentBox, chatInputRef, parsedFiles],
|
||||
);
|
||||
|
||||
const renderAnnotation = useCallback(
|
||||
(annotation: DiffLineAnnotation<string>) => {
|
||||
if (annotation.metadata === "active-input") {
|
||||
if (!activeCommentBox) return null;
|
||||
return (
|
||||
<InlinePromptInput
|
||||
onSubmit={handleSubmitComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[activeCommentBox, handleSubmitComment, handleCancelComment],
|
||||
);
|
||||
|
||||
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
|
||||
|
||||
// Sort diff blocks in the same order the file tree displays them
|
||||
// (directories first, then alphabetical) so the rendering is
|
||||
// consistent regardless of whether the sidebar is visible.
|
||||
const sortedFiles = useMemo(() => {
|
||||
const order = new Map<string, number>();
|
||||
let idx = 0;
|
||||
const walk = (nodes: FileTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "file") {
|
||||
order.set(node.fullPath, idx++);
|
||||
} else {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(fileTree);
|
||||
return [...parsedFiles].sort(
|
||||
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
|
||||
);
|
||||
}, [fileTree, parsedFiles]);
|
||||
|
||||
const pullRequestUrl = diffStatusQuery.data?.url;
|
||||
const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Container width measurement via ResizeObserver so we can decide
|
||||
// whether to show the file tree sidebar without a prop from the
|
||||
// parent.
|
||||
// ---------------------------------------------------------------
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (roRef.current) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
setContainerWidth(el.getBoundingClientRect().width);
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
const showTree =
|
||||
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
|
||||
sortedFiles.length > 0;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Refs for each file diff wrapper so we can scroll-to and track
|
||||
// which file is currently visible.
|
||||
// ---------------------------------------------------------------
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const [activeFile, setActiveFile] = useState<string | null>(null);
|
||||
|
||||
// Keep a ref callback that sets up per-file refs.
|
||||
const setFileRef = useCallback((name: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
fileRefs.current.set(name, el);
|
||||
} else {
|
||||
fileRefs.current.delete(name);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track which file is at the top of the diff scroll area by
|
||||
// listening to scroll events on the viewport. The active file
|
||||
// is whichever file wrapper's top edge is closest to (but not
|
||||
// below) the container's top — i.e. the one whose sticky
|
||||
// header would be showing.
|
||||
const diffViewportRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTree || sortedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = diffViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const containerTop = viewport.getBoundingClientRect().top;
|
||||
let bestName: string | null = null;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const [name, el] of fileRefs.current.entries()) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// The file "owns" the scroll position when its top
|
||||
// is at or above the container top and its bottom is
|
||||
// still below it.
|
||||
if (rect.bottom > containerTop && rect.top <= containerTop + 1) {
|
||||
const distance = Math.abs(rect.top - containerTop);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing is at the top (e.g. scrolled to the very top
|
||||
// with padding), pick the first file whose top is closest
|
||||
// to the container top.
|
||||
if (!bestName) {
|
||||
for (const [name, el] of fileRefs.current.entries()) {
|
||||
const dist = Math.abs(
|
||||
el.getBoundingClientRect().top - containerTop,
|
||||
);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
bestName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestName) {
|
||||
setActiveFile(bestName);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fire once to set initial state.
|
||||
onScroll();
|
||||
|
||||
viewport.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [showTree, sortedFiles.length]);
|
||||
|
||||
const handleFileClick = useCallback((name: string) => {
|
||||
const el = fileRefs.current.get(name);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start" });
|
||||
setActiveFile(name);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for chip clicks from the chat input to scroll to the
|
||||
// corresponding comment annotation in the diff.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { fileName } = (e as CustomEvent).detail ?? {};
|
||||
if (typeof fileName !== "string") return;
|
||||
const el = fileRefs.current.get(fileName);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
setActiveFile(fileName);
|
||||
}
|
||||
};
|
||||
window.addEventListener("file-reference-click", handler);
|
||||
return () => window.removeEventListener("file-reference-click", handler);
|
||||
}, []);
|
||||
|
||||
if (diffContentsQuery.isLoading || diffStatusQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col overflow-hidden">
|
||||
<div className="space-y-4 p-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diffContentsQuery.isError) {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<ErrorAlert error={diffContentsQuery.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 bg-surface-secondary px-3 py-2">
|
||||
{" "}
|
||||
{pullRequestUrl && parsedPr ? (
|
||||
<a
|
||||
href={pullRequestUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs text-content-secondary no-underline hover:text-content-primary"
|
||||
>
|
||||
<GitPullRequestIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
<span className="text-content-secondary">
|
||||
{parsedPr.owner}/{parsedPr.repo}
|
||||
</span>
|
||||
<span className="text-content-primary">#{parsedPr.number}</span>
|
||||
</span>
|
||||
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</a>
|
||||
) : pullRequestUrl ? (
|
||||
<a
|
||||
href={pullRequestUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs text-content-secondary no-underline hover:text-content-primary"
|
||||
>
|
||||
<GitPullRequestIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{pullRequestUrl}</span>
|
||||
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<GitBranchIcon className="h-3.5 w-3.5" />
|
||||
<span>Uncommitted changes</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Diff style toggle */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant={diffStyle === "unified" ? "outline" : "subtle"}
|
||||
size="lg"
|
||||
onClick={() => handleSetDiffStyle("unified")}
|
||||
className={cn(
|
||||
"min-w-0 h-6 px-2 py-0",
|
||||
diffStyle === "unified" && "bg-surface-secondary",
|
||||
)}
|
||||
aria-label="Unified diff view"
|
||||
>
|
||||
<Rows3Icon className="!p-0 !size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={diffStyle === "split" ? "outline" : "subtle"}
|
||||
size="lg"
|
||||
onClick={() => handleSetDiffStyle("split")}
|
||||
className={cn(
|
||||
"min-w-0 h-6 px-2 py-0",
|
||||
diffStyle === "split" && "bg-surface-secondary",
|
||||
)}
|
||||
aria-label="Split diff view"
|
||||
>
|
||||
<Columns2Icon className="!p-0 !size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Diff contents */}
|
||||
{sortedFiles.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
|
||||
No file changes to display.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 flex-row overflow-hidden">
|
||||
{/* File tree sidebar */}
|
||||
{showTree && (
|
||||
<ScrollArea
|
||||
className="shrink-0 border-r border-border"
|
||||
style={{ width: FILE_TREE_WIDTH }}
|
||||
scrollBarClassName="w-1"
|
||||
>
|
||||
<nav className="flex flex-col py-1">
|
||||
{fileTree.map((node) => (
|
||||
<FileTreeNodeView
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
depth={1}
|
||||
activeFile={activeFile}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{/* Diff list */}
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
showTree &&
|
||||
"border-0 border-l border-t border-solid border-border-default rounded-tl-md",
|
||||
)}
|
||||
scrollBarClassName="w-1.5"
|
||||
viewportClassName="[&>div]:!block"
|
||||
ref={(node) => {
|
||||
const vp = node?.querySelector<HTMLElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
diffViewportRef.current = vp ?? null;
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 text-xs">
|
||||
{sortedFiles.map((fileDiff) => (
|
||||
<div
|
||||
key={fileDiff.name}
|
||||
ref={(el) => setFileRef(fileDiff.name, el)}
|
||||
>
|
||||
<LazyFileDiff
|
||||
fileDiff={fileDiff}
|
||||
options={getFileOptions(fileDiff.name)}
|
||||
lineAnnotations={getAnnotationsForFile(fileDiff.name)}
|
||||
renderAnnotation={renderAnnotation}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Spacer so the last file can scroll fully to the top. */}
|
||||
<div className="h-[calc(100vh-100px)]" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Estimated height per line in the diff viewer (px). Derived from
|
||||
// the --diffs-font-size (11px) and --diffs-line-height (1.5)
|
||||
// values set via DIFFS_FONT_STYLE, plus 1px for the border/gap.
|
||||
// -----------------------------------------------------------------------
|
||||
const LINE_HEIGHT_PX = 17.5;
|
||||
|
||||
// Height of the file header row rendered by @pierre/diffs.
|
||||
const HEADER_HEIGHT_PX = 36;
|
||||
|
||||
/**
|
||||
* Estimate the rendered pixel height of a file diff so the
|
||||
* placeholder occupies roughly the same space. This keeps the
|
||||
* scroll position stable as files are lazily mounted.
|
||||
*/
|
||||
function estimateDiffHeight(fileDiff: FileDiffMetadata): number {
|
||||
return HEADER_HEIGHT_PX + fileDiff.unifiedLineCount * LINE_HEIGHT_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a single `<FileDiff>` with an IntersectionObserver so the
|
||||
* heavy component (Shadow DOM + shiki highlighting) is only mounted
|
||||
* once the placeholder scrolls into or near the viewport.
|
||||
*
|
||||
* Once mounted the component stays mounted — we never unmount a
|
||||
* FileDiff that the user has already scrolled past, which avoids
|
||||
* layout shifts and repeated highlighting work.
|
||||
*/
|
||||
const LazyFileDiff: FC<{
|
||||
fileDiff: FileDiffMetadata;
|
||||
options: ComponentProps<typeof FileDiff>["options"];
|
||||
lineAnnotations?: DiffLineAnnotation<string>[];
|
||||
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
|
||||
}> = ({
|
||||
fileDiff,
|
||||
options,
|
||||
lineAnnotations,
|
||||
renderAnnotation: renderAnnotationProp,
|
||||
}) => {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = placeholderRef.current;
|
||||
if (!el || visible) {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
// Pre-load files that are within one viewport-height of
|
||||
// the visible area so they are ready before the user
|
||||
// scrolls to them.
|
||||
{ rootMargin: "100% 0px" },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<div
|
||||
ref={placeholderRef}
|
||||
style={{ height: estimateDiffHeight(fileDiff) }}
|
||||
className="p-4 space-y-2"
|
||||
>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileDiff
|
||||
fileDiff={fileDiff}
|
||||
options={options}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
lineAnnotations={lineAnnotations}
|
||||
renderAnnotation={renderAnnotationProp}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import type {
|
||||
ChatDiffContents,
|
||||
ChatDiffStatus,
|
||||
WorkspaceAgentRepoChanges,
|
||||
} from "api/typesGenerated";
|
||||
import { expect, fn, spyOn, userEvent } from "storybook/test";
|
||||
import { GitPanel } from "./GitPanel";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sampleDiff = `diff --git a/src/main.ts b/src/main.ts
|
||||
index abc1234..def5678 100644
|
||||
--- a/src/main.ts
|
||||
+++ b/src/main.ts
|
||||
@@ -1,5 +1,7 @@
|
||||
import { start } from "./server";
|
||||
+import { logger } from "./logger";
|
||||
|
||||
const port = 3000;
|
||||
+logger.info("Starting server...");
|
||||
start(port);
|
||||
diff --git a/src/server.ts b/src/server.ts
|
||||
index 1111111..2222222 100644
|
||||
--- a/src/server.ts
|
||||
+++ b/src/server.ts
|
||||
@@ -10,3 +10,5 @@
|
||||
app.listen(port, () => {
|
||||
console.log("Listening on port " + port);
|
||||
});
|
||||
+
|
||||
+ return app;
|
||||
}
|
||||
`;
|
||||
|
||||
const secondRepoDiff = `diff --git a/README.md b/README.md
|
||||
index aaa1111..bbb2222 100644
|
||||
--- a/README.md
|
||||
+++ b/README.md
|
||||
@@ -1,3 +1,5 @@
|
||||
# My Project
|
||||
+
|
||||
+This project does things.
|
||||
|
||||
## Getting Started
|
||||
-Follow the steps below.
|
||||
+Follow the steps below to get started.
|
||||
`;
|
||||
|
||||
const makeRepo = (
|
||||
overrides: Partial<WorkspaceAgentRepoChanges> = {},
|
||||
): WorkspaceAgentRepoChanges => ({
|
||||
repo_root: "/home/coder/coder",
|
||||
branch: "feat/add-logging",
|
||||
remote_origin: "https://github.com/coder/coder.git",
|
||||
unified_diff: sampleDiff,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultDiffStatus: ChatDiffStatus = {
|
||||
chat_id: "test-chat",
|
||||
pull_request_title: "",
|
||||
pull_request_draft: false,
|
||||
changes_requested: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changed_files: 0,
|
||||
};
|
||||
|
||||
const defaultDiffContents: ChatDiffContents = {
|
||||
chat_id: "test-chat",
|
||||
};
|
||||
|
||||
/** Reusable PR diff status with head/base branches. */
|
||||
const makePrStatus = (
|
||||
overrides: Partial<ChatDiffStatus> = {},
|
||||
): ChatDiffStatus => ({
|
||||
...defaultDiffStatus,
|
||||
url: "https://github.com/coder/coder/pull/23020",
|
||||
pull_request_title: "feat(agents): add MCP server configuration to agents",
|
||||
pull_request_state: "open",
|
||||
pull_request_draft: false,
|
||||
base_branch: "main",
|
||||
head_branch: "feat/add-mcp-config",
|
||||
additions: 4037,
|
||||
deletions: 7,
|
||||
changed_files: 12,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta<typeof GitPanel> = {
|
||||
title: "pages/AgentsPage/GitPanel",
|
||||
component: GitPanel,
|
||||
args: {
|
||||
onRefresh: fn(),
|
||||
onCommit: fn(),
|
||||
repositories: new Map(),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 600, width: 480 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(defaultDiffStatus);
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue(defaultDiffContents);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GitPanel>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** PR is open with a title, head/base branches, and working changes. */
|
||||
export const PullRequestAndWorkingChanges: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 23020, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus(),
|
||||
repositories: new Map([["/home/coder/coder", makeRepo()]]),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(makePrStatus());
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Draft PR with head/base branches. */
|
||||
export const DraftPullRequest: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 22950, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus({
|
||||
url: "https://github.com/coder/coder/pull/22950",
|
||||
pull_request_title: "fix: resolve race condition in workspace builds",
|
||||
pull_request_draft: true,
|
||||
head_branch: "fix/race-condition",
|
||||
additions: 142,
|
||||
deletions: 38,
|
||||
changed_files: 5,
|
||||
}),
|
||||
repositories: new Map([
|
||||
["/home/coder/coder", makeRepo({ branch: "fix/race-condition" })],
|
||||
]),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(
|
||||
makePrStatus({
|
||||
url: "https://github.com/coder/coder/pull/22950",
|
||||
pull_request_title: "fix: resolve race condition in workspace builds",
|
||||
pull_request_draft: true,
|
||||
head_branch: "fix/race-condition",
|
||||
additions: 142,
|
||||
deletions: 38,
|
||||
changed_files: 5,
|
||||
}),
|
||||
);
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Merged PR. */
|
||||
export const MergedPullRequest: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 23000, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus({
|
||||
url: "https://github.com/coder/coder/pull/23000",
|
||||
pull_request_title: "chore: update dependencies to latest",
|
||||
pull_request_state: "merged",
|
||||
head_branch: "chore/update-deps",
|
||||
additions: 89,
|
||||
deletions: 45,
|
||||
changed_files: 3,
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(
|
||||
makePrStatus({
|
||||
url: "https://github.com/coder/coder/pull/23000",
|
||||
pull_request_title: "chore: update dependencies to latest",
|
||||
pull_request_state: "merged",
|
||||
head_branch: "chore/update-deps",
|
||||
additions: 89,
|
||||
deletions: 45,
|
||||
changed_files: 3,
|
||||
}),
|
||||
);
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Closed PR. */
|
||||
export const ClosedPullRequest: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 22800, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus({
|
||||
url: "https://github.com/coder/coder/pull/22800",
|
||||
pull_request_title: "feat: experimental websocket transport",
|
||||
pull_request_state: "closed",
|
||||
head_branch: "feat/websocket-transport",
|
||||
additions: 200,
|
||||
deletions: 10,
|
||||
changed_files: 4,
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(
|
||||
makePrStatus({
|
||||
url: "https://github.com/coder/coder/pull/22800",
|
||||
pull_request_title: "feat: experimental websocket transport",
|
||||
pull_request_state: "closed",
|
||||
head_branch: "feat/websocket-transport",
|
||||
additions: 200,
|
||||
deletions: 10,
|
||||
changed_files: 4,
|
||||
}),
|
||||
);
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Branch pushed but no PR opened yet. */
|
||||
export const BranchOnly: Story = {
|
||||
args: {
|
||||
remoteDiffStats: {
|
||||
...defaultDiffStatus,
|
||||
additions: 42,
|
||||
deletions: 7,
|
||||
changed_files: 3,
|
||||
},
|
||||
repositories: new Map([["/home/coder/coder", makeRepo()]]),
|
||||
},
|
||||
};
|
||||
|
||||
/** Only local working changes, no remote/PR. */
|
||||
export const WorkingChangesOnly: Story = {
|
||||
args: {
|
||||
repositories: new Map([["/home/coder/coder", makeRepo()]]),
|
||||
},
|
||||
};
|
||||
|
||||
/** Multiple repos with working changes. */
|
||||
export const MultipleRepos: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 23020, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus({
|
||||
pull_request_title: "feat: multi-repo workspace support",
|
||||
head_branch: "feat/multi-repo",
|
||||
additions: 500,
|
||||
deletions: 120,
|
||||
changed_files: 8,
|
||||
}),
|
||||
repositories: new Map([
|
||||
["/home/coder/coder", makeRepo()],
|
||||
[
|
||||
"/home/coder/other-project",
|
||||
makeRepo({
|
||||
repo_root: "/home/coder/other-project",
|
||||
branch: "main",
|
||||
remote_origin: "https://github.com/coder/other-project.git",
|
||||
unified_diff: secondRepoDiff,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(
|
||||
makePrStatus({
|
||||
pull_request_title: "feat: multi-repo workspace support",
|
||||
head_branch: "feat/multi-repo",
|
||||
additions: 500,
|
||||
deletions: 120,
|
||||
changed_files: 8,
|
||||
}),
|
||||
);
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** No remote changes, no working changes — empty state. */
|
||||
export const EmptyState: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 23020, chatId: "test-chat" },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* PR diff with the inline comment input visible. The play function
|
||||
* waits for the diff to render, then clicks a line number gutter
|
||||
* to trigger the annotation input.
|
||||
*/
|
||||
export const InlineCommentInput: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 23020, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus(),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 700, width: 600 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffStatus").mockResolvedValue(makePrStatus());
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Wait for the diff to load and render inside Shadow DOM.
|
||||
// The line numbers live inside @pierre/diffs FileDiff web
|
||||
// components, so we need to wait a bit for them to mount.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Find a line number element inside a Shadow DOM diff viewer.
|
||||
// The diff renders in shadow roots, so we look for the
|
||||
// host elements and query inside their shadow DOMs.
|
||||
const diffHosts = canvasElement.querySelectorAll("[data-diffs]");
|
||||
|
||||
for (const host of diffHosts) {
|
||||
const shadow = host.shadowRoot;
|
||||
if (!shadow) continue;
|
||||
|
||||
// Look for a line number cell — they have data-line-number.
|
||||
const lineNumber = shadow.querySelector(
|
||||
"[data-line-number]",
|
||||
) as HTMLElement | null;
|
||||
if (lineNumber) {
|
||||
await userEvent.click(lineNumber);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the inline prompt appeared.
|
||||
const textarea = canvasElement.querySelector("textarea");
|
||||
if (textarea) {
|
||||
expect(textarea).toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import type {
|
||||
ChatDiffStatus,
|
||||
WorkspaceAgentRepoChanges,
|
||||
} from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleDotIcon,
|
||||
ColumnsIcon,
|
||||
FileDiffIcon,
|
||||
GitBranchIcon,
|
||||
GitCompareArrowsIcon,
|
||||
GitMergeIcon,
|
||||
GitPullRequestClosedIcon,
|
||||
GitPullRequestDraftIcon,
|
||||
GitPullRequestIcon,
|
||||
RefreshCwIcon,
|
||||
RowsIcon,
|
||||
} from "lucide-react";
|
||||
@@ -22,10 +30,10 @@ import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { type DiffStyle, loadDiffStyle, saveDiffStyle } from "./DiffViewer";
|
||||
import { FilesChangedPanel } from "./FilesChangedPanel";
|
||||
import { RepoChangesPanel } from "./RepoChangesPanel";
|
||||
import { LocalDiffPanel } from "./LocalDiffPanel";
|
||||
import { RemoteDiffPanel } from "./RemoteDiffPanel";
|
||||
|
||||
type GitView = "remote" | "local";
|
||||
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
@@ -46,11 +54,9 @@ interface GitPanelProps {
|
||||
onCommit: (repoRoot: string) => void;
|
||||
/** Whether the panel is in expanded/fullscreen mode. */
|
||||
isExpanded?: boolean;
|
||||
/** Diff stats for the remote/branch view. */
|
||||
remoteDiffStats?: DiffStats;
|
||||
/** Diff stats for the local/working tree view. */
|
||||
localDiffStats?: DiffStats;
|
||||
/** Ref to the chat input, forwarded to FilesChangedPanel. */
|
||||
/** Diff status for the remote/branch view (includes PR metadata). */
|
||||
remoteDiffStats?: ChatDiffStatus;
|
||||
/** Ref to the chat input, forwarded to RemoteDiffPanel. */
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
}
|
||||
|
||||
@@ -66,24 +72,70 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
onCommit,
|
||||
isExpanded,
|
||||
remoteDiffStats,
|
||||
localDiffStats,
|
||||
chatInputRef,
|
||||
}) => {
|
||||
const hasRemoteStats =
|
||||
!!remoteDiffStats &&
|
||||
(remoteDiffStats.additions > 0 || remoteDiffStats.deletions > 0);
|
||||
const hasLocalStats =
|
||||
!!localDiffStats &&
|
||||
(localDiffStats.additions > 0 || localDiffStats.deletions > 0);
|
||||
|
||||
// Default to "local" when there are only local changes and no
|
||||
// remote stats, so the user sees content immediately.
|
||||
const [view, setView] = useState<GitView>(
|
||||
!hasRemoteStats && hasLocalStats ? "local" : "remote",
|
||||
const showRemoteTab = !!prTab || hasRemoteStats;
|
||||
|
||||
const prTitle = remoteDiffStats?.pull_request_title;
|
||||
const prState = remoteDiffStats?.pull_request_state;
|
||||
const prDraft = remoteDiffStats?.pull_request_draft;
|
||||
|
||||
// Compute per-repo diff stats from unified diffs.
|
||||
const repoStats = useMemo(() => {
|
||||
const stats = new Map<string, DiffStats>();
|
||||
for (const [root, repo] of repositories.entries()) {
|
||||
if (!repo.unified_diff) continue;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of repo.unified_diff.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
additions++;
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
if (additions > 0 || deletions > 0) {
|
||||
stats.set(root, { additions, deletions });
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}, [repositories]);
|
||||
|
||||
const localRepos = useMemo(
|
||||
() => Array.from(repoStats.keys()).sort((a, b) => a.localeCompare(b)),
|
||||
[repoStats],
|
||||
);
|
||||
|
||||
// Diff style is managed here for the local view only.
|
||||
// FilesChangedPanel manages its own diff style internally.
|
||||
// Default to the first local repo when there are only local
|
||||
// changes and no remote stats.
|
||||
const [view, setView] = useState<GitView>(() => {
|
||||
if (!showRemoteTab && localRepos.length > 0) {
|
||||
return { type: "local", repoRoot: localRepos[0] };
|
||||
}
|
||||
return { type: "remote" };
|
||||
});
|
||||
|
||||
// If the active tab gets hidden, switch to the first available.
|
||||
useEffect(() => {
|
||||
if (view.type === "remote" && !showRemoteTab) {
|
||||
if (localRepos.length > 0) {
|
||||
setView({ type: "local", repoRoot: localRepos[0] });
|
||||
}
|
||||
} else if (view.type === "local") {
|
||||
if (!repoStats.has(view.repoRoot)) {
|
||||
if (showRemoteTab) {
|
||||
setView({ type: "remote" });
|
||||
} else if (localRepos.length > 0) {
|
||||
setView({ type: "local", repoRoot: localRepos[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [view, showRemoteTab, localRepos, repoStats]);
|
||||
|
||||
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
||||
|
||||
const handleDiffStyleChange = useCallback((style: DiffStyle) => {
|
||||
@@ -91,70 +143,90 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
setDiffStyle(style);
|
||||
}, []);
|
||||
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
|
||||
const handleRefresh = useCallback(() => {
|
||||
onRefresh();
|
||||
setSpinning(true);
|
||||
clearTimeout(spinTimerRef.current);
|
||||
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
||||
}, [onRefresh]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
|
||||
{/* Remote / Local segmented control */}
|
||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("remote")}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 border-none font-medium transition-colors outline-none focus-visible:outline-none",
|
||||
view === "remote"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
hasRemoteStats ? "pl-3 pr-0" : "px-3",
|
||||
)}
|
||||
>
|
||||
Remote
|
||||
{hasRemoteStats && (
|
||||
<span
|
||||
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3">
|
||||
{/* Tabs — scrollable when they overflow */}
|
||||
<ScrollArea
|
||||
className="min-w-0 flex-1"
|
||||
orientation="horizontal"
|
||||
scrollBarClassName="h-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-0.5 py-1.5 text-xs">
|
||||
{showRemoteTab && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setView({ type: "remote" })}
|
||||
className={cn(
|
||||
"flex -my-px items-center self-stretch transition-opacity",
|
||||
view !== "remote" && "opacity-50",
|
||||
"shrink-0 h-6 min-w-0 gap-1.5 px-2 py-0 bg-surface-primary",
|
||||
view.type === "remote" &&
|
||||
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
|
||||
)}
|
||||
>
|
||||
<DiffStatBadge
|
||||
additions={remoteDiffStats.additions}
|
||||
deletions={remoteDiffStats.deletions}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("local")}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 border-0 border-l border-solid border-border-default font-medium transition-colors outline-none focus-visible:outline-none",
|
||||
view === "local"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
hasLocalStats ? "pl-3 pr-0" : "px-3",
|
||||
)}
|
||||
>
|
||||
Local
|
||||
{hasLocalStats && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex -my-px items-center self-stretch transition-opacity",
|
||||
view !== "local" && "opacity-50",
|
||||
{prTab ? (
|
||||
<>
|
||||
<PrStateIcon
|
||||
state={prState}
|
||||
draft={prDraft}
|
||||
className="!size-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{prTitle || `PR #${prTab.prNumber}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitBranchIcon className="!size-3.5 shrink-0" />
|
||||
<span className="truncate">Branch</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<DiffStatBadge
|
||||
additions={localDiffStats.additions}
|
||||
deletions={localDiffStats.deletions}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{/* Split / Unified toggle — only shown for local view since
|
||||
FilesChangedPanel has its own toggle built in. */}
|
||||
{view === "local" && (
|
||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default text-xs">
|
||||
{localRepos.map((repoRoot) => {
|
||||
const isActive =
|
||||
view.type === "local" && view.repoRoot === repoRoot;
|
||||
return (
|
||||
<Button
|
||||
key={repoRoot}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setView({ type: "local", repoRoot })}
|
||||
className={cn(
|
||||
"shrink-0 h-6 min-w-0 gap-1.5 px-2 py-0 bg-surface-primary",
|
||||
isActive &&
|
||||
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
|
||||
)}
|
||||
>
|
||||
<CircleDotIcon
|
||||
className={cn(
|
||||
"!size-3.5 shrink-0",
|
||||
isActive ? "text-amber-400" : "text-amber-400/60",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
Working{" "}
|
||||
<span className="opacity-50">{repoTabLabel(repoRoot)}</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* Controls */}
|
||||
<div className="flex shrink-0 items-center gap-1 py-1.5">
|
||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDiffStyleChange("unified")}
|
||||
@@ -182,21 +254,38 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
<ColumnsIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
className="h-6 w-6 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
spinning && "motion-safe:animate-spin-once",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{view === "remote" ? (
|
||||
{view.type === "remote" ? (
|
||||
<RemoteContent
|
||||
prTab={prTab}
|
||||
isExpanded={isExpanded}
|
||||
chatInputRef={chatInputRef}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
) : (
|
||||
<LocalContent
|
||||
repositories={repositories}
|
||||
onRefresh={onRefresh}
|
||||
<LocalRepoContent
|
||||
repoRoot={view.repoRoot}
|
||||
repo={repositories.get(view.repoRoot)}
|
||||
diffStats={
|
||||
repoStats.get(view.repoRoot) ?? { additions: 0, deletions: 0 }
|
||||
}
|
||||
onCommit={onCommit}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
@@ -215,7 +304,8 @@ const RemoteContent: FC<{
|
||||
prTab?: { prNumber: number; chatId: string };
|
||||
isExpanded?: boolean;
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
}> = ({ prTab, isExpanded, chatInputRef }) => {
|
||||
diffStyle: DiffStyle;
|
||||
}> = ({ prTab, isExpanded, chatInputRef, diffStyle }) => {
|
||||
if (!prTab) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
@@ -233,75 +323,44 @@ const RemoteContent: FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesChangedPanel
|
||||
<RemoteDiffPanel
|
||||
chatId={prTab.chatId}
|
||||
isExpanded={isExpanded}
|
||||
chatInputRef={chatInputRef}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Local view (working tree changes)
|
||||
// Local view (single repo)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const LocalContent: FC<{
|
||||
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
||||
onRefresh: () => void;
|
||||
const LocalRepoContent: FC<{
|
||||
repoRoot: string;
|
||||
repo: WorkspaceAgentRepoChanges | undefined;
|
||||
diffStats: DiffStats;
|
||||
onCommit: (repoRoot: string) => void;
|
||||
isExpanded?: boolean;
|
||||
diffStyle: DiffStyle;
|
||||
}> = ({ repositories, onRefresh, onCommit, isExpanded, diffStyle }) => {
|
||||
const repoEntries = useMemo(
|
||||
() =>
|
||||
Array.from(repositories.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||
[repositories],
|
||||
);
|
||||
|
||||
if (repoEntries.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-solid border-border-default bg-surface-secondary">
|
||||
<FileDiffIcon className="size-5 text-content-secondary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-primary">
|
||||
No uncommitted changes
|
||||
</p>
|
||||
<p className="mt-1 max-w-52 text-xs text-content-secondary">
|
||||
Local file modifications will appear here as you edit.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}> = ({ repoRoot, repo, diffStats, onCommit, isExpanded, diffStyle }) => {
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{repoEntries.map(([repoRoot, repo], index) => {
|
||||
const showSeparator = index > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={repoRoot}
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
showSeparator &&
|
||||
"border-0 border-t border-solid border-border-default",
|
||||
)}
|
||||
>
|
||||
<RepoHeader
|
||||
repoRoot={repoRoot}
|
||||
repo={repo}
|
||||
onRefresh={onRefresh}
|
||||
onCommit={() => onCommit(repoRoot)}
|
||||
/>
|
||||
<RepoChangesPanel
|
||||
repo={repo}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<RepoHeader
|
||||
repoRoot={repoRoot}
|
||||
repo={repo}
|
||||
diffStats={diffStats}
|
||||
onCommit={() => onCommit(repoRoot)}
|
||||
/>
|
||||
<LocalDiffPanel
|
||||
repo={repo}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -313,60 +372,60 @@ const LocalContent: FC<{
|
||||
const RepoHeader: FC<{
|
||||
repoRoot: string;
|
||||
repo: WorkspaceAgentRepoChanges;
|
||||
onRefresh: () => void;
|
||||
diffStats: DiffStats;
|
||||
onCommit: () => void;
|
||||
}> = ({ repoRoot, repo, onRefresh, onCommit }) => {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
|
||||
const handleRefresh = useCallback(() => {
|
||||
onRefresh();
|
||||
setSpinning(true);
|
||||
clearTimeout(spinTimerRef.current);
|
||||
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
||||
}, [onRefresh]);
|
||||
|
||||
}> = ({ repoRoot, repo, diffStats, onCommit }) => {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 bg-surface-secondary px-3 py-2">
|
||||
{/* Repo identity */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<GitBranchIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
<span className="truncate text-sm font-medium text-content-primary">
|
||||
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-content-secondary">
|
||||
<GitBranchIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{repo.branch?.trim() || repoTabLabel(repoRoot)}
|
||||
</span>
|
||||
{repo.branch?.trim() && (
|
||||
<span className="truncate text-xs text-content-secondary">
|
||||
{repoTabLabel(repoRoot)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate opacity-50">{repoRoot}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<DiffStatBadge
|
||||
additions={diffStats.additions}
|
||||
deletions={diffStats.deletions}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCommit}
|
||||
disabled={!repo.unified_diff}
|
||||
className="h-7 gap-1.5 border border-transparent bg-surface-invert-primary px-2 text-xs text-content-invert hover:bg-surface-invert-secondary active:opacity-80"
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded-sm border border-solid border-border-default bg-transparent px-2 text-[13px] font-medium leading-5 text-content-secondary no-underline transition-colors hover:bg-surface-secondary hover:text-content-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon className="size-3" />
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
spinning && "motion-safe:animate-spin-once",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// PR state icon (compact, for the tab bar)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const PrStateIcon: FC<{
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
className?: string;
|
||||
}> = ({ state, draft, className }) => {
|
||||
if (state === "merged") {
|
||||
return <GitMergeIcon className={cn("text-purple-400", className)} />;
|
||||
}
|
||||
if (state === "closed") {
|
||||
return (
|
||||
<GitPullRequestClosedIcon className={cn("text-red-400", className)} />
|
||||
);
|
||||
}
|
||||
if (draft) {
|
||||
return (
|
||||
<GitPullRequestDraftIcon
|
||||
className={cn("text-content-secondary", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <GitPullRequestIcon className={cn("text-green-400", className)} />;
|
||||
};
|
||||
|
||||
+2
-2
@@ -3,13 +3,13 @@ import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { type DiffStyle, DiffViewer } from "./DiffViewer";
|
||||
|
||||
interface RepoChangesPanelProps {
|
||||
interface LocalDiffPanelProps {
|
||||
repo: WorkspaceAgentRepoChanges;
|
||||
isExpanded?: boolean;
|
||||
diffStyle: DiffStyle;
|
||||
}
|
||||
|
||||
export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
|
||||
export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
|
||||
repo,
|
||||
isExpanded,
|
||||
diffStyle,
|
||||
@@ -0,0 +1,500 @@
|
||||
import type { DiffLineAnnotation, FileDiffMetadata } from "@pierre/diffs";
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CornerDownLeftIcon,
|
||||
ExternalLinkIcon,
|
||||
GitBranchIcon,
|
||||
GitMergeIcon,
|
||||
GitPullRequestClosedIcon,
|
||||
GitPullRequestDraftIcon,
|
||||
GitPullRequestIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import type { DiffStyle } from "./DiffViewer";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Diff content extraction
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk the parsed hunks for a file and collect code lines that fall
|
||||
* within `startLine..endLine` on the given side. For "additions"
|
||||
* lines are matched against addition line numbers (using
|
||||
* `hunk.additionStart`); for "deletions" against deletion line
|
||||
* numbers (using `hunk.deletionStart`). Context lines that fall
|
||||
* in range are included as well.
|
||||
*/
|
||||
function extractDiffContent(
|
||||
parsedFiles: readonly FileDiffMetadata[],
|
||||
fileName: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
side: "additions" | "deletions",
|
||||
): string {
|
||||
const file = parsedFiles.find((f) => f.name === fileName);
|
||||
if (!file) return "";
|
||||
|
||||
const lines = side === "additions" ? file.additionLines : file.deletionLines;
|
||||
const collected: string[] = [];
|
||||
for (const hunk of file.hunks) {
|
||||
let addLine = hunk.additionStart;
|
||||
let delLine = hunk.deletionStart;
|
||||
|
||||
for (const block of hunk.hunkContent) {
|
||||
if (block.type === "context") {
|
||||
for (let i = 0; i < block.lines; i++) {
|
||||
const ln = side === "additions" ? addLine : delLine;
|
||||
if (ln >= startLine && ln <= endLine) {
|
||||
const idx =
|
||||
side === "additions"
|
||||
? block.additionLineIndex + i
|
||||
: block.deletionLineIndex + i;
|
||||
if (lines[idx] != null) collected.push(lines[idx]);
|
||||
}
|
||||
addLine++;
|
||||
delLine++;
|
||||
}
|
||||
} else {
|
||||
// ChangeContent block.
|
||||
if (side === "deletions") {
|
||||
for (let i = 0; i < block.deletions; i++) {
|
||||
if (delLine >= startLine && delLine <= endLine) {
|
||||
const line = lines[block.deletionLineIndex + i];
|
||||
if (line != null) collected.push(line);
|
||||
}
|
||||
delLine++;
|
||||
}
|
||||
// Addition lines in a change block still advance
|
||||
// the addition counter.
|
||||
addLine += block.additions;
|
||||
} else {
|
||||
// side === "additions"
|
||||
// Deletion lines in a change block still advance
|
||||
// the deletion counter.
|
||||
delLine += block.deletions;
|
||||
for (let i = 0; i < block.additions; i++) {
|
||||
if (addLine >= startLine && addLine <= endLine) {
|
||||
const line = lines[block.additionLineIndex + i];
|
||||
if (line != null) collected.push(line);
|
||||
}
|
||||
addLine++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a GitHub PR URL into its components.
|
||||
* Returns null if parsing fails.
|
||||
*/
|
||||
// -------------------------------------------------------------------
|
||||
// PR state badge
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PullRequestStateBadge: FC<{
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
}> = ({ state, draft }) => {
|
||||
let Icon = GitPullRequestIcon;
|
||||
let label = "Open";
|
||||
let colorClasses = "border-border-default bg-green-500/10 text-green-400";
|
||||
|
||||
if (state === "merged") {
|
||||
Icon = GitMergeIcon;
|
||||
label = "Merged";
|
||||
colorClasses = "border-border-default bg-purple-500/10 text-purple-400";
|
||||
} else if (state === "closed") {
|
||||
Icon = GitPullRequestClosedIcon;
|
||||
label = "Closed";
|
||||
colorClasses = "border-border-default bg-red-500/10 text-red-400";
|
||||
} else if (draft) {
|
||||
Icon = GitPullRequestDraftIcon;
|
||||
label = "Draft";
|
||||
colorClasses =
|
||||
"border-border-default bg-surface-secondary text-content-secondary";
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-sm border border-solid px-2 text-[13px] font-medium leading-5",
|
||||
colorClasses,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function parsePullRequestUrl(url: string): {
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: string;
|
||||
} | null {
|
||||
try {
|
||||
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
||||
if (match) {
|
||||
return { owner: match[1], repo: match[2], number: match[3] };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Inline prompt input
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inline input rendered as a diff annotation under the selected
|
||||
* line(s). Supports multiline via Shift+Enter. Enter submits,
|
||||
* Escape dismisses.
|
||||
*/
|
||||
const InlinePromptInput: FC<{
|
||||
onSubmit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ onSubmit, onCancel }) => {
|
||||
const [text, setText] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus the textarea on mount. We use a ref callback via rAF
|
||||
// rather than autoFocus because the component renders inside
|
||||
// Shadow DOM where autoFocus is unreliable.
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="rounded-lg border border-border-default bg-surface-secondary p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full resize-none border-none bg-transparent px-2.5 py-1.5 font-sans text-[13px] leading-5 text-content-primary placeholder:text-content-secondary outline-none ring-0 focus:outline-none focus:ring-0"
|
||||
placeholder="Add a comment to include with this reference..."
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (text.trim()) {
|
||||
onSubmit(text.trim());
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-end px-1.5 pb-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
className="h-6 gap-1.5 px-2 text-xs text-content-secondary hover:text-content-primary"
|
||||
disabled={!text.trim()}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
// Prevent blur from firing before click.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
if (text.trim()) {
|
||||
onSubmit(text.trim());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CornerDownLeftIcon className="size-3" />
|
||||
Add to chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface RemoteDiffPanelProps {
|
||||
chatId: string;
|
||||
isExpanded?: boolean;
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
diffStyle: DiffStyle;
|
||||
}
|
||||
|
||||
export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
|
||||
chatId,
|
||||
isExpanded,
|
||||
chatInputRef,
|
||||
diffStyle,
|
||||
}) => {
|
||||
// ---------------------------------------------------------------
|
||||
// Comment / annotation state
|
||||
// ---------------------------------------------------------------
|
||||
const [activeCommentBox, setActiveCommentBox] = useState<{
|
||||
fileName: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
side: "additions" | "deletions";
|
||||
} | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------
|
||||
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
|
||||
const diffContentsQuery = useQuery({
|
||||
...chatDiffContents(chatId),
|
||||
enabled: Boolean(diffStatusQuery.data?.url),
|
||||
});
|
||||
|
||||
const parsedFiles = useMemo(() => {
|
||||
const diff = diffContentsQuery.data?.diff;
|
||||
if (!diff) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
// The cacheKeyPrefix enables the worker pool's LRU cache
|
||||
// so highlighted ASTs are reused across re-renders instead
|
||||
// of being re-computed on every render cycle. We include
|
||||
// dataUpdatedAt so that when the diff content changes
|
||||
// (e.g. new commits pushed) the old cached highlight AST
|
||||
// is not reused with mismatched line indices, which would
|
||||
// cause DiffHunksRenderer.processDiffResult to throw.
|
||||
const patches = parsePatchFiles(
|
||||
diff,
|
||||
`chat-${chatId}-${diffContentsQuery.dataUpdatedAt}`,
|
||||
);
|
||||
return patches.flatMap((p) => p.files);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [diffContentsQuery.data?.diff, diffContentsQuery.dataUpdatedAt, chatId]);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Line interaction callbacks
|
||||
// ---------------------------------------------------------------
|
||||
const handleLineNumberClick = useCallback(
|
||||
(
|
||||
fileName: string,
|
||||
props: {
|
||||
lineNumber: number;
|
||||
annotationSide: "additions" | "deletions";
|
||||
},
|
||||
) => {
|
||||
setActiveCommentBox({
|
||||
fileName,
|
||||
startLine: props.lineNumber,
|
||||
endLine: props.lineNumber,
|
||||
side: props.annotationSide,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLineSelected = useCallback(
|
||||
(
|
||||
fileName: string,
|
||||
range: {
|
||||
start: number;
|
||||
end: number;
|
||||
side?: "additions" | "deletions";
|
||||
} | null,
|
||||
) => {
|
||||
if (!range || range.start === range.end) return;
|
||||
const side = range.side ?? "additions";
|
||||
setActiveCommentBox({
|
||||
fileName,
|
||||
startLine: Math.min(range.start, range.end),
|
||||
endLine: Math.max(range.start, range.end),
|
||||
side,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Annotation helpers
|
||||
// ---------------------------------------------------------------
|
||||
const getLineAnnotations = useCallback(
|
||||
(fileName: string): DiffLineAnnotation<string>[] => {
|
||||
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
||||
return [
|
||||
{
|
||||
side: activeCommentBox.side,
|
||||
lineNumber: activeCommentBox.startLine,
|
||||
metadata: "active-input",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[activeCommentBox],
|
||||
);
|
||||
|
||||
const handleCancelComment = useCallback(() => {
|
||||
setActiveCommentBox(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmitComment = useCallback(
|
||||
(text: string) => {
|
||||
if (!activeCommentBox) return;
|
||||
const content = extractDiffContent(
|
||||
parsedFiles,
|
||||
activeCommentBox.fileName,
|
||||
activeCommentBox.startLine,
|
||||
activeCommentBox.endLine,
|
||||
activeCommentBox.side,
|
||||
);
|
||||
// Single imperative call — chip inserted atomically
|
||||
// in one Lexical update. No rAF hack needed.
|
||||
chatInputRef?.current?.addFileReference({
|
||||
fileName: activeCommentBox.fileName,
|
||||
startLine: activeCommentBox.startLine,
|
||||
endLine: activeCommentBox.endLine,
|
||||
content,
|
||||
});
|
||||
if (text.trim()) {
|
||||
chatInputRef?.current?.insertText(text);
|
||||
}
|
||||
setActiveCommentBox(null);
|
||||
},
|
||||
[activeCommentBox, chatInputRef, parsedFiles],
|
||||
);
|
||||
|
||||
const renderAnnotation = useCallback(
|
||||
(annotation: DiffLineAnnotation<string>) => {
|
||||
if (annotation.metadata === "active-input") {
|
||||
if (!activeCommentBox) return null;
|
||||
return (
|
||||
<InlinePromptInput
|
||||
onSubmit={handleSubmitComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[activeCommentBox, handleSubmitComment, handleCancelComment],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Scroll-to-file from chat input chip clicks
|
||||
// ---------------------------------------------------------------
|
||||
const [scrollTarget, setScrollTarget] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { fileName } = (e as CustomEvent).detail ?? {};
|
||||
if (typeof fileName !== "string") return;
|
||||
setScrollTarget(fileName);
|
||||
};
|
||||
window.addEventListener("file-reference-click", handler);
|
||||
return () => window.removeEventListener("file-reference-click", handler);
|
||||
}, []);
|
||||
|
||||
const handleScrollComplete = useCallback(() => {
|
||||
setScrollTarget(null);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Header content
|
||||
// ---------------------------------------------------------------
|
||||
const pullRequestUrl = diffStatusQuery.data?.url;
|
||||
const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null;
|
||||
const prState = diffStatusQuery.data?.pull_request_state;
|
||||
const prDraft = diffStatusQuery.data?.pull_request_draft;
|
||||
const baseBranch = diffStatusQuery.data?.base_branch;
|
||||
const headBranch = diffStatusQuery.data?.head_branch;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Compact PR sub-header */}
|
||||
{pullRequestUrl && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-content-secondary">
|
||||
{baseBranch || headBranch ? (
|
||||
<>
|
||||
<GitBranchIcon className="size-3.5 shrink-0" />
|
||||
{baseBranch && <span className="truncate">{baseBranch}</span>}
|
||||
{headBranch && baseBranch && (
|
||||
<ArrowLeftIcon className="size-3 shrink-0 opacity-50" />
|
||||
)}
|
||||
{headBranch && (
|
||||
<span className="truncate"> {headBranch}</span>
|
||||
)}{" "}
|
||||
</>
|
||||
) : parsedPr ? (
|
||||
<span className="truncate">
|
||||
{parsedPr.owner}/{parsedPr.repo}#{parsedPr.number}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{pullRequestUrl}</span>
|
||||
)}
|
||||
</div>{" "}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<PullRequestStateBadge state={prState} draft={prDraft} />
|
||||
{diffStatusQuery.data?.additions ||
|
||||
diffStatusQuery.data?.deletions ? (
|
||||
<DiffStatBadge
|
||||
additions={diffStatusQuery.data.additions}
|
||||
deletions={diffStatusQuery.data.deletions}
|
||||
/>
|
||||
) : null}
|
||||
<a
|
||||
href={pullRequestUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-solid border-border-default px-2 text-[13px] font-medium leading-5 text-content-secondary no-underline transition-colors hover:bg-surface-secondary hover:text-content-primary"
|
||||
>
|
||||
View PR
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}{" "}
|
||||
<DiffViewer
|
||||
parsedFiles={parsedFiles}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
isLoading={diffContentsQuery.isLoading || diffStatusQuery.isLoading}
|
||||
error={diffContentsQuery.isError ? diffContentsQuery.error : undefined}
|
||||
onLineNumberClick={handleLineNumberClick}
|
||||
onLineSelected={handleLineSelected}
|
||||
getLineAnnotations={getLineAnnotations}
|
||||
renderAnnotation={renderAnnotation}
|
||||
scrollToFile={scrollTarget}
|
||||
onScrollToFileComplete={handleScrollComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { RepoChangesPanel } from "./RepoChangesPanel";
|
||||
|
||||
const sampleDiff = `--- a/src/main.ts
|
||||
+++ b/src/main.ts
|
||||
@@ -1,5 +1,7 @@
|
||||
import { start } from "./server";
|
||||
+import { logger } from "./logger";
|
||||
|
||||
const port = 3000;
|
||||
+logger.info("Starting server...");
|
||||
start(port);
|
||||
--- a/src/server.ts
|
||||
+++ b/src/server.ts
|
||||
@@ -10,3 +10,5 @@
|
||||
app.listen(port, () => {
|
||||
console.log("Listening on port " + port);
|
||||
});
|
||||
+
|
||||
+ return app;
|
||||
}
|
||||
`;
|
||||
|
||||
const baseRepo: WorkspaceAgentRepoChanges = {
|
||||
repo_root: "/home/coder/project",
|
||||
branch: "feat/add-logging",
|
||||
remote_origin: "https://github.com/coder/project.git",
|
||||
unified_diff: sampleDiff,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof RepoChangesPanel> = {
|
||||
title: "pages/AgentsPage/RepoChangesPanel",
|
||||
component: RepoChangesPanel,
|
||||
args: {
|
||||
repo: baseRepo,
|
||||
diffStyle: "unified",
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RepoChangesPanel>;
|
||||
|
||||
export const WithChanges: Story = {};
|
||||
|
||||
export const NoChanges: Story = {
|
||||
args: {
|
||||
repo: {
|
||||
...baseRepo,
|
||||
unified_diff: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SplitDiffStyle: Story = {
|
||||
args: {
|
||||
repo: baseRepo,
|
||||
diffStyle: "split",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user