feat: add file edit diff progress view
Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react";
|
||||
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
@@ -24,11 +24,6 @@ type PreviewState =
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; payload: FilePreviewPayload };
|
||||
|
||||
function supportsHoverCloseControl(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
}
|
||||
|
||||
export function FilePreviewPanel({
|
||||
sessionKey,
|
||||
path,
|
||||
@@ -41,26 +36,12 @@ export function FilePreviewPanel({
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setEntered(true));
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const update = () => setSupportsHoverClose(query.matches);
|
||||
update();
|
||||
if (typeof query.addEventListener === "function") {
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}
|
||||
query.addListener(update);
|
||||
return () => query.removeListener(update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
@@ -89,15 +70,28 @@ export function FilePreviewPanel({
|
||||
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
|
||||
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
|
||||
const { name } = splitFilePath(displayPath);
|
||||
const breadcrumbs = useMemo(
|
||||
const fileName = name || displayPath;
|
||||
const pathParts = useMemo(
|
||||
() => normalizedPreviewPath.split("/").filter(Boolean),
|
||||
[normalizedPreviewPath],
|
||||
);
|
||||
const compactBreadcrumbs = useMemo(
|
||||
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
|
||||
[breadcrumbs],
|
||||
const directoryParts = useMemo(
|
||||
() => (pathParts.length > 1 ? pathParts.slice(0, -1) : []),
|
||||
[pathParts],
|
||||
);
|
||||
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length;
|
||||
const breadcrumbParts = useMemo(
|
||||
() => (directoryParts.length > 0 ? [...directoryParts, fileName] : [fileName]),
|
||||
[directoryParts, fileName],
|
||||
);
|
||||
const compactBreadcrumbParts = useMemo(
|
||||
() => (breadcrumbParts.length > 3 ? breadcrumbParts.slice(-3) : breadcrumbParts),
|
||||
[breadcrumbParts],
|
||||
);
|
||||
const hasCompactPrefix = breadcrumbParts.length > compactBreadcrumbParts.length;
|
||||
const breadcrumbTitle = `${hasRootPrefix ? "/" : ""}${[
|
||||
...directoryParts,
|
||||
fileName,
|
||||
].join("/")}`;
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -144,144 +138,115 @@ export function FilePreviewPanel({
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3">
|
||||
{supportsHoverClose ? (
|
||||
<div
|
||||
className={cn(
|
||||
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
|
||||
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
|
||||
)}
|
||||
title={name || displayPath}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
|
||||
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
|
||||
"group-focus-within:bg-foreground group-focus-within:text-background",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
>
|
||||
<FileText
|
||||
className={cn(
|
||||
"absolute h-4 w-4 transition-all duration-150 ease-out",
|
||||
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
|
||||
"group-focus-within:scale-75 group-focus-within:opacity-0",
|
||||
)}
|
||||
<div
|
||||
className="flex h-11 shrink-0 items-center gap-2 border-b border-border/60 px-3"
|
||||
title={previewPath}
|
||||
>
|
||||
<nav
|
||||
aria-label={t("filePreview.breadcrumb", { defaultValue: "File path" })}
|
||||
className="flex min-w-0 flex-1 items-center overflow-hidden text-sm leading-5"
|
||||
title={breadcrumbTitle}
|
||||
data-testid="file-preview-breadcrumb"
|
||||
>
|
||||
{hasCompactPrefix ? (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground/55">...</span>
|
||||
<ChevronRight
|
||||
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
|
||||
aria-hidden
|
||||
/>
|
||||
<X
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out",
|
||||
"group-hover:scale-100 group-hover:opacity-100",
|
||||
"group-focus-within:scale-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
</>
|
||||
) : hasRootPrefix ? (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground/55">/</span>
|
||||
<ChevronRight
|
||||
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<span className="min-w-0 truncate">{name || displayPath}</span>
|
||||
</>
|
||||
) : null}
|
||||
{compactBreadcrumbParts.map((part, index) => {
|
||||
const isLast = index === compactBreadcrumbParts.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={`${part}-${index}`}
|
||||
className="flex min-w-0 items-center overflow-hidden"
|
||||
>
|
||||
{index > 0 ? (
|
||||
<ChevronRight
|
||||
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate rounded-[4px] px-1 py-0.5",
|
||||
isLast
|
||||
? "font-medium text-foreground"
|
||||
: "max-w-[26vw] shrink text-muted-foreground/78",
|
||||
)}
|
||||
data-testid={isLast ? "file-preview-title" : undefined}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
title={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
data-testid="file-preview-close"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{state.status === "loading" ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
|
||||
</div>
|
||||
) : state.status === "error" ? (
|
||||
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
|
||||
<div className="max-w-sm">
|
||||
<AlertCircle
|
||||
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
|
||||
aria-hidden
|
||||
/>
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{name || displayPath}
|
||||
</span>
|
||||
</>
|
||||
<div className="min-h-full">
|
||||
{state.payload.truncated ? (
|
||||
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
|
||||
{t("filePreview.truncated", {
|
||||
defaultValue: "Preview is truncated because this file is large.",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<CodeBlock
|
||||
language={state.payload.language}
|
||||
code={state.payload.content}
|
||||
chrome="none"
|
||||
showLineNumbers
|
||||
wrapLongLines={false}
|
||||
className="min-h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
|
||||
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
|
||||
)}
|
||||
title={previewPath}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{hasCompactPrefix ? (
|
||||
<span className="shrink-0 text-muted-foreground/55">...</span>
|
||||
) : hasRootPrefix ? (
|
||||
<span className="shrink-0 text-muted-foreground/55">/</span>
|
||||
) : null}
|
||||
{compactBreadcrumbs.length > 0 ? (
|
||||
compactBreadcrumbs.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
|
||||
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
|
||||
<ChevronRight
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
index === compactBreadcrumbs.length - 1
|
||||
? "font-medium text-foreground"
|
||||
: "max-w-[42vw] shrink text-muted-foreground/76",
|
||||
)}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="truncate">{previewPath}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{state.status === "loading" ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
|
||||
</div>
|
||||
) : state.status === "error" ? (
|
||||
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
|
||||
<div className="max-w-sm">
|
||||
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-full">
|
||||
{state.payload.truncated ? (
|
||||
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
|
||||
{t("filePreview.truncated", {
|
||||
defaultValue: "Preview is truncated because this file is large.",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<CodeBlock
|
||||
language={state.payload.language}
|
||||
code={state.payload.content}
|
||||
chrome="none"
|
||||
showLineNumbers
|
||||
wrapLongLines={false}
|
||||
className="min-h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import {
|
||||
LOCAL_PREFS_STORAGE_KEY,
|
||||
readLocalPreferences,
|
||||
type FileEditDisplayMode,
|
||||
type LocalActivityMode,
|
||||
type LocalDensity,
|
||||
type LocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
import { getHostApi } from "@/lib/runtime";
|
||||
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||
@@ -155,8 +163,6 @@ export type SettingsSectionKey =
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
type LocalDensity = "comfortable" | "compact";
|
||||
type LocalActivityMode = "auto" | "expanded";
|
||||
type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
|
||||
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
||||
type AutomationSort = "next" | "last" | "updated" | "name";
|
||||
@@ -166,13 +172,6 @@ type AppsCatalogItem =
|
||||
| { id: string; kind: "cli"; app: CliAppInfo }
|
||||
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
||||
|
||||
interface LocalPreferences {
|
||||
density: LocalDensity;
|
||||
activityMode: LocalActivityMode;
|
||||
codeWrap: boolean;
|
||||
brandLogos: boolean;
|
||||
}
|
||||
|
||||
interface AgentSettingsDraft {
|
||||
model: string;
|
||||
provider: string;
|
||||
@@ -259,14 +258,6 @@ interface CustomMcpForm {
|
||||
toolTimeout: string;
|
||||
}
|
||||
|
||||
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
|
||||
|
||||
const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
density: "comfortable",
|
||||
activityMode: "auto",
|
||||
codeWrap: true,
|
||||
brandLogos: false,
|
||||
};
|
||||
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "chat_completions", label: "Chat Completions" },
|
||||
@@ -318,22 +309,6 @@ interface SettingsViewProps {
|
||||
hostChromeInset?: boolean;
|
||||
}
|
||||
|
||||
function readLocalPreferences(): LocalPreferences {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_LOCAL_PREFS;
|
||||
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
|
||||
return {
|
||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos === true,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_LOCAL_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
function modelPresetValue(payload: SettingsPayload): string {
|
||||
return payload.agent.model_preset || "default";
|
||||
}
|
||||
@@ -2337,6 +2312,25 @@ function AppearanceSettings({
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.fileEditDisplay", "File edit display")}
|
||||
description={tx("settings.help.fileEditDisplay", "Choose whether file edit activity opens as line counts or a diff.")}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={localPrefs.fileEditDisplayMode}
|
||||
options={[
|
||||
{ value: "summary", label: tx("settings.values.summary", "Summary") },
|
||||
{ value: "diff", label: tx("settings.values.diff", "Diff") },
|
||||
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
|
||||
]}
|
||||
onChange={(fileEditDisplayMode) =>
|
||||
onChangeLocalPrefs((prev) => ({
|
||||
...prev,
|
||||
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.codeWrap", "Code wrapping")}
|
||||
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
isReasoningOnlyAssistant,
|
||||
type ActivityEvidence,
|
||||
} from "@/lib/activity-timeline";
|
||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||
import { hasRenderableFileDiff } from "@/lib/file-diff";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import { formatToolCallTrace } from "@/lib/tool-traces";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -190,6 +193,7 @@ export function AgentActivityCluster({
|
||||
onOpenFilePreview,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
@@ -282,7 +286,7 @@ export function AgentActivityCluster({
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
|
||||
count: fileCount,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} changes`,
|
||||
})
|
||||
: "";
|
||||
|
||||
@@ -438,6 +442,7 @@ export function AgentActivityCluster({
|
||||
added={added}
|
||||
deleted={deleted}
|
||||
hasDiffStats={hasDiffStats}
|
||||
fileEditDisplayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
@@ -532,6 +537,7 @@ export function AgentActivityCluster({
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
@@ -561,6 +567,7 @@ function FileEditFlatActivity({
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
fileEditDisplayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
@@ -575,9 +582,23 @@ function FileEditFlatActivity({
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
fileEditDisplayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
|
||||
const diffOnlyRows = edits.length === 1
|
||||
&& !!singleFilePath
|
||||
&& fileEditDisplayMode !== "summary"
|
||||
&& edits.some((edit) => (
|
||||
edit.status !== "editing"
|
||||
&& edit.status !== "error"
|
||||
&& hasRenderableFileDiff(edit.diff)
|
||||
));
|
||||
const showRows = edits.length > 1
|
||||
|| edits.some((edit) => edit.status === "error" || edit.pending)
|
||||
|| (
|
||||
fileEditDisplayMode !== "summary"
|
||||
&& edits.some((edit) => hasRenderableFileDiff(edit.diff))
|
||||
);
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
|
||||
<div
|
||||
@@ -611,7 +632,12 @@ function FileEditFlatActivity({
|
||||
</div>
|
||||
{showRows ? (
|
||||
<div className="mt-0.5 pl-4">
|
||||
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
|
||||
<FileEditGroup
|
||||
edits={edits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
density={diffOnlyRows ? "diff-only" : "default"}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1579,122 +1605,32 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
interface MutableSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
absolute_path?: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
pending: boolean;
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
operation?: UIFileEdit["operation"];
|
||||
error?: string;
|
||||
}
|
||||
return latestFileEditEvents(edits).flatMap((edit) => {
|
||||
const editing = active && edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
if (!edit.path && edit.pending && !editing) return [];
|
||||
if (!edit.path && !editing && !failed) return [];
|
||||
|
||||
const order: string[] = [];
|
||||
const byPath = new Map<string, MutableSummary>();
|
||||
for (const edit of latestFileEditEvents(edits)) {
|
||||
const key = edit.path || edit.call_id || edit.tool;
|
||||
let summary = byPath.get(key);
|
||||
if (!summary) {
|
||||
summary = {
|
||||
key,
|
||||
path: edit.path || "",
|
||||
absolute_path: edit.absolute_path,
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
binary: false,
|
||||
pending: false,
|
||||
hasSuccessfulChange: false,
|
||||
hasActiveEditing: false,
|
||||
hasFailed: false,
|
||||
operation: undefined,
|
||||
};
|
||||
byPath.set(key, summary);
|
||||
order.push(key);
|
||||
}
|
||||
|
||||
if (edit.path && !summary.path) {
|
||||
summary.path = edit.path;
|
||||
}
|
||||
if (edit.absolute_path) {
|
||||
summary.absolute_path = edit.absolute_path;
|
||||
}
|
||||
if (edit.operation === "delete") {
|
||||
summary.operation = "delete";
|
||||
}
|
||||
summary.pending = summary.pending || !!edit.pending || !edit.path;
|
||||
if (!edit.path && edit.pending) {
|
||||
if (active && edit.status === "editing") {
|
||||
summary.hasActiveEditing = true;
|
||||
summary.approximate = summary.approximate || !!edit.approximate;
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (active && edit.status === "editing") {
|
||||
summary.hasActiveEditing = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = summary.approximate || !!edit.approximate;
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.status === "error") {
|
||||
summary.hasFailed = true;
|
||||
summary.error = edit.error ?? summary.error;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.hasSuccessfulChange = true;
|
||||
summary.binary = summary.binary || !!edit.binary;
|
||||
summary.approximate = active && (summary.approximate || !!edit.approximate);
|
||||
if (!edit.binary) {
|
||||
summary.added += edit.added;
|
||||
summary.deleted += edit.deleted;
|
||||
}
|
||||
}
|
||||
|
||||
return order.flatMap((key) => {
|
||||
const summary = byPath.get(key)!;
|
||||
if (
|
||||
!summary.path
|
||||
&& !summary.hasActiveEditing
|
||||
&& !summary.hasSuccessfulChange
|
||||
&& !summary.hasFailed
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const status: UIFileEdit["status"] = summary.hasActiveEditing
|
||||
const status: UIFileEdit["status"] = editing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
? "done"
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
: failed
|
||||
? "error"
|
||||
: "done";
|
||||
const binary = !!edit.binary;
|
||||
const diff = hasRenderableFileDiff(edit.diff) ? edit.diff : undefined;
|
||||
return [{
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
absolute_path: summary.absolute_path,
|
||||
added: summary.added,
|
||||
deleted: summary.deleted,
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
key: fileEditCallKey(edit),
|
||||
path: edit.path || "",
|
||||
absolute_path: edit.absolute_path,
|
||||
added: binary ? 0 : edit.added,
|
||||
deleted: binary ? 0 : edit.deleted,
|
||||
approximate: active && !!edit.approximate,
|
||||
binary,
|
||||
status,
|
||||
operation: summary.operation,
|
||||
pending: summary.pending && !summary.path,
|
||||
error: summary.error,
|
||||
operation: edit.operation,
|
||||
pending: !!edit.pending && !edit.path,
|
||||
error: edit.error,
|
||||
diff,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
|
||||
@@ -31,83 +29,7 @@ function DiffValue({ sign, value, className }: { sign: string; value: number; cl
|
||||
>
|
||||
<span className="inline-flex items-baseline leading-none" aria-hidden>
|
||||
{sign}
|
||||
<AnimatedNumber value={safeValue} />
|
||||
</span>
|
||||
<span className="sr-only">{sign}{safeValue}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value }: { value: number }) {
|
||||
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||||
const [display, setDisplay] = useState(0);
|
||||
const displayRef = useRef(0);
|
||||
|
||||
const setAnimatedDisplay = useCallback((next: number) => {
|
||||
displayRef.current = next;
|
||||
setDisplay(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduceMotion) {
|
||||
setAnimatedDisplay(safeValue);
|
||||
return;
|
||||
}
|
||||
const start = displayRef.current;
|
||||
const delta = safeValue - start;
|
||||
if (delta === 0) {
|
||||
setAnimatedDisplay(safeValue);
|
||||
return;
|
||||
}
|
||||
const duration = 260;
|
||||
const startedAt = performance.now();
|
||||
let frame = 0;
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setAnimatedDisplay(Math.round(start + delta * eased));
|
||||
if (progress < 1) {
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
displayRef.current = safeValue;
|
||||
};
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [safeValue, setAnimatedDisplay]);
|
||||
|
||||
return <RollingNumber value={display} />;
|
||||
}
|
||||
|
||||
function RollingNumber({ value }: { value: number }) {
|
||||
const digits = String(value).split("");
|
||||
return (
|
||||
<span className="inline-flex items-baseline leading-none" aria-hidden>
|
||||
{digits.map((digit, index) => (
|
||||
<RollingDigit
|
||||
key={`${digits.length}-${index}`}
|
||||
digit={Number(digit)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RollingDigit({ digit }: { digit: number }) {
|
||||
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
|
||||
return (
|
||||
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
|
||||
<span className="invisible block h-[1em] leading-none">0</span>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
|
||||
style={{ transform: `translateY(-${safeDigit}em)` }}
|
||||
>
|
||||
{Array.from({ length: 10 }, (_, n) => (
|
||||
<span key={n} className="block h-[1em] leading-none">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
{safeValue}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleDashed,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import type { UIFileEdit } from "@/lib/types";
|
||||
import {
|
||||
hasRenderableFileDiff,
|
||||
parseRenderableFileDiff,
|
||||
type RenderableFileDiff,
|
||||
type RenderableFileDiffHunk,
|
||||
type RenderableFileDiffLine,
|
||||
} from "@/lib/file-diff";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ActivityStep } from "./ActivityStep";
|
||||
import { DiffPair } from "./DiffPair";
|
||||
|
||||
const INITIAL_VISIBLE_DIFF_LINES = 160;
|
||||
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
|
||||
|
||||
type DiffFileEditDisplayMode = Exclude<FileEditDisplayMode, "summary">;
|
||||
|
||||
interface VisibleDiffHunk {
|
||||
hunk: RenderableFileDiffHunk;
|
||||
skippedBefore: number;
|
||||
}
|
||||
|
||||
interface VisibleDiff {
|
||||
hunks: VisibleDiffHunk[];
|
||||
hiddenLineCount: number;
|
||||
}
|
||||
|
||||
const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 };
|
||||
|
||||
export interface FileEditSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
@@ -20,40 +54,97 @@ export interface FileEditSummary {
|
||||
operation?: UIFileEdit["operation"];
|
||||
pending: boolean;
|
||||
error?: string;
|
||||
diff?: UIFileDiff;
|
||||
}
|
||||
|
||||
export function FileEditGroup({
|
||||
edits,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
density = "default",
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
displayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
density?: "default" | "diff-only";
|
||||
}) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
))}
|
||||
{edits.map((edit) => {
|
||||
if (density === "diff-only" && canRenderDiff(edit, displayMode)) {
|
||||
return (
|
||||
<FileEditDiffOnly
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
displayMode={displayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
displayMode={displayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function canRenderDiff(
|
||||
edit: FileEditSummary,
|
||||
displayMode: FileEditDisplayMode,
|
||||
): displayMode is DiffFileEditDisplayMode {
|
||||
return (
|
||||
displayMode !== "summary"
|
||||
&& edit.status !== "editing"
|
||||
&& edit.status !== "error"
|
||||
&& hasRenderableFileDiff(edit.diff)
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditDiffOnly({
|
||||
edit,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
displayMode: DiffFileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<li className="min-w-0 py-0.5">
|
||||
<FileUnifiedDiff
|
||||
diff={edit.diff!}
|
||||
collapsed={displayMode === "collapsed_diff"}
|
||||
added={edit.added}
|
||||
deleted={edit.deleted}
|
||||
showCollapsedStats={false}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditRow({
|
||||
edit,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
displayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||
const showDiff = canRenderDiff(edit, displayMode);
|
||||
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
|
||||
const failureDetail = failed
|
||||
? formatFileEditError(edit.error)
|
||||
@@ -84,7 +175,7 @@ function FileEditRow({
|
||||
active={editing}
|
||||
tone={failed ? "error" : editing ? "active" : "success"}
|
||||
className="text-xs"
|
||||
contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
|
||||
contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
|
||||
title={rawFailureDetail || edit.absolute_path || edit.path}
|
||||
label={edit.pending && !edit.path
|
||||
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
||||
@@ -109,6 +200,16 @@ function FileEditRow({
|
||||
{failureDetail}
|
||||
</span>
|
||||
) : null}
|
||||
{showDiff ? (
|
||||
<FileUnifiedDiff
|
||||
diff={edit.diff!}
|
||||
collapsed={displayMode === "collapsed_diff"}
|
||||
added={edit.added}
|
||||
deleted={edit.deleted}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
</ActivityStep>
|
||||
);
|
||||
}
|
||||
@@ -142,3 +243,279 @@ function formatFileEditError(error?: string): string {
|
||||
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function FileUnifiedDiff({
|
||||
diff,
|
||||
collapsed,
|
||||
added,
|
||||
deleted,
|
||||
showCollapsedStats = true,
|
||||
previewPath,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
diff: UIFileDiff;
|
||||
collapsed: boolean;
|
||||
added: number;
|
||||
deleted: number;
|
||||
showCollapsedStats?: boolean;
|
||||
previewPath?: string;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expandedLines, setExpandedLines] = useState(false);
|
||||
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
|
||||
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
|
||||
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
|
||||
const startsCollapsed = collapsed || shouldAutoCollapse;
|
||||
const shouldRenderBody = !startsCollapsed || open;
|
||||
const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES;
|
||||
const lineLimit = expandedLines || !shouldLimitLines
|
||||
? totalLineCount
|
||||
: INITIAL_VISIBLE_DIFF_LINES;
|
||||
const visibleDiff = useMemo(
|
||||
() => shouldRenderBody
|
||||
? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount)
|
||||
: EMPTY_VISIBLE_DIFF,
|
||||
[lineLimit, renderableDiff, shouldRenderBody, totalLineCount],
|
||||
);
|
||||
const lineCountLabel = t("message.fileEditDiffLineCount", {
|
||||
count: diff.truncated ? `${totalLineCount}+` : totalLineCount,
|
||||
defaultValue: "{{count}} lines",
|
||||
});
|
||||
const viewDiffLabel = shouldAutoCollapse
|
||||
? tx("message.fileEditViewLargeDiff", "View large diff")
|
||||
: tx("message.fileEditViewDiff", "View diff");
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setExpandedLines(false);
|
||||
}, [diff]);
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
if (open) setExpandedLines(false);
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
if (totalLineCount === 0) return null;
|
||||
|
||||
const renderBody = () => (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-md border border-border/55 bg-background/80 shadow-[0_1px_0_rgba(15,23,42,0.03)]"
|
||||
data-testid="file-edit-diff"
|
||||
>
|
||||
{visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => (
|
||||
<div
|
||||
key={`${hunk.old_start}-${hunk.new_start}-${index}`}
|
||||
className={cn("min-w-0", index > 0 && "border-t border-border/45")}
|
||||
>
|
||||
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse font-mono text-[11px] leading-5">
|
||||
<tbody>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLineRow
|
||||
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${lineIndex}`}
|
||||
line={line}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{visibleDiff.hiddenLineCount > 0 ? (
|
||||
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
|
||||
)}
|
||||
data-testid="file-edit-diff-expand-lines"
|
||||
onClick={() => setExpandedLines(true)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden />
|
||||
{t("message.fileEditShowMoreLines", {
|
||||
count: visibleDiff.hiddenLineCount,
|
||||
defaultValue: "Show {{count}} more lines",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : expandedLines && shouldLimitLines ? (
|
||||
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
|
||||
)}
|
||||
data-testid="file-edit-diff-collapse-lines"
|
||||
onClick={() => setExpandedLines(false)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" aria-hidden />
|
||||
{tx("message.fileEditShowFewerLines", "Show fewer lines")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{diff.truncated ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/45 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
|
||||
data-testid="file-edit-diff-truncated"
|
||||
>
|
||||
<span>
|
||||
{tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")}
|
||||
</span>
|
||||
{previewPath && onOpenFilePreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1 py-0.5 font-medium",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
|
||||
)}
|
||||
data-testid="file-edit-diff-open-file"
|
||||
onClick={() => onOpenFilePreview(previewPath)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
{tx("message.fileEditOpenFile", "Open file")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!startsCollapsed) return renderBody();
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
data-testid="file-edit-diff-toggle"
|
||||
onClick={handleToggleOpen}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-md border border-border/45 bg-muted/35 px-2 py-1 text-left",
|
||||
"text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">{viewDiffLabel}</span>
|
||||
<span className="shrink-0 text-muted-foreground/65">{lineCountLabel}</span>
|
||||
{showCollapsedStats ? <DiffPair added={added} deleted={deleted} /> : null}
|
||||
</button>
|
||||
{open ? renderBody() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countDiffLines(diff: RenderableFileDiff): number {
|
||||
return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0);
|
||||
}
|
||||
|
||||
function selectVisibleDiffLines(
|
||||
diff: RenderableFileDiff,
|
||||
lineLimit: number,
|
||||
totalLineCount: number,
|
||||
): VisibleDiff {
|
||||
if (lineLimit >= totalLineCount) {
|
||||
return {
|
||||
hunks: diff.hunks.map((hunk, index) => ({
|
||||
hunk,
|
||||
skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0,
|
||||
})),
|
||||
hiddenLineCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let remaining = Math.max(0, lineLimit);
|
||||
const hunks: VisibleDiffHunk[] = [];
|
||||
let previousHunk: RenderableFileDiffHunk | null = null;
|
||||
for (const hunk of diff.hunks) {
|
||||
if (remaining <= 0) break;
|
||||
const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0;
|
||||
if (hunk.lines.length <= remaining) {
|
||||
hunks.push({ hunk, skippedBefore });
|
||||
remaining -= hunk.lines.length;
|
||||
previousHunk = hunk;
|
||||
continue;
|
||||
}
|
||||
hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore });
|
||||
remaining = 0;
|
||||
previousHunk = hunk;
|
||||
}
|
||||
return {
|
||||
hunks,
|
||||
hiddenLineCount: Math.max(0, totalLineCount - lineLimit),
|
||||
};
|
||||
}
|
||||
|
||||
function countSkippedUnchangedLines(
|
||||
previous: RenderableFileDiffHunk,
|
||||
current: RenderableFileDiffHunk,
|
||||
): number {
|
||||
const oldGap = current.old_start - (previous.old_start + previous.old_lines);
|
||||
const newGap = current.new_start - (previous.new_start + previous.new_lines);
|
||||
return Math.max(0, oldGap, newGap);
|
||||
}
|
||||
|
||||
function DiffHunkGap({ lineCount }: { lineCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
|
||||
data-testid="file-edit-diff-hunk-gap"
|
||||
>
|
||||
<span
|
||||
className="select-none rounded border border-border/45 bg-background/70 px-1 font-mono text-muted-foreground/70"
|
||||
aria-hidden
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<span>
|
||||
{t("message.fileEditUnchangedLinesHidden", {
|
||||
count: lineCount,
|
||||
defaultValue: "{{count}} unchanged lines hidden",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLineRow({ line }: { line: RenderableFileDiffLine }) {
|
||||
const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context";
|
||||
const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " ";
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-0",
|
||||
kind === "add" && "bg-emerald-500/[0.09] dark:bg-emerald-300/[0.11]",
|
||||
kind === "delete" && "bg-rose-500/[0.09] dark:bg-rose-300/[0.11]",
|
||||
)}
|
||||
>
|
||||
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
|
||||
{line.old_lineno ?? ""}
|
||||
</td>
|
||||
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
|
||||
{line.new_lineno ?? ""}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"w-5 select-none px-1 text-center",
|
||||
kind === "add" && "text-emerald-600/80 dark:text-emerald-300/85",
|
||||
kind === "delete" && "text-rose-600/80 dark:text-rose-300/85",
|
||||
kind === "context" && "text-muted-foreground/45",
|
||||
)}
|
||||
>
|
||||
{marker}
|
||||
</td>
|
||||
<td className="min-w-[16rem] px-1.5 text-foreground/86">
|
||||
<span className="whitespace-pre">{line.content || " "}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user