2026-07-08 11:25:45 +08:00
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
AlertCircle,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
CircleDashed,
|
|
|
|
|
ExternalLink,
|
|
|
|
|
} from "lucide-react";
|
2026-05-30 23:45:26 +08:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
|
|
|
|
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
2026-07-08 11:25:45 +08:00
|
|
|
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";
|
2026-05-30 23:45:26 +08:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
import { ActivityStep } from "./ActivityStep";
|
|
|
|
|
import { DiffPair } from "./DiffPair";
|
|
|
|
|
|
2026-07-08 11:25:45 +08:00
|
|
|
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 };
|
|
|
|
|
|
2026-05-30 23:45:26 +08:00
|
|
|
export interface FileEditSummary {
|
|
|
|
|
key: string;
|
|
|
|
|
path: string;
|
|
|
|
|
absolute_path?: string;
|
|
|
|
|
added: number;
|
|
|
|
|
deleted: number;
|
|
|
|
|
approximate: boolean;
|
|
|
|
|
binary: boolean;
|
|
|
|
|
status: UIFileEdit["status"];
|
|
|
|
|
operation?: UIFileEdit["operation"];
|
|
|
|
|
pending: boolean;
|
|
|
|
|
error?: string;
|
2026-07-08 11:25:45 +08:00
|
|
|
diff?: UIFileDiff;
|
2026-05-30 23:45:26 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
export function FileEditGroup({
|
|
|
|
|
edits,
|
2026-07-08 11:25:45 +08:00
|
|
|
displayMode,
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenFilePreview,
|
2026-07-08 11:25:45 +08:00
|
|
|
density = "default",
|
2026-06-06 19:49:33 +08:00
|
|
|
}: {
|
|
|
|
|
edits: FileEditSummary[];
|
2026-07-08 11:25:45 +08:00
|
|
|
displayMode: FileEditDisplayMode;
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenFilePreview?: (path: string) => void;
|
2026-07-08 11:25:45 +08:00
|
|
|
density?: "default" | "diff-only";
|
2026-06-06 19:49:33 +08:00
|
|
|
}) {
|
2026-05-30 23:45:26 +08:00
|
|
|
if (edits.length === 0) return null;
|
|
|
|
|
return (
|
|
|
|
|
<ul className="space-y-1">
|
2026-07-08 11:25:45 +08:00
|
|
|
{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}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-30 23:45:26 +08:00
|
|
|
</ul>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 11:25:45 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
function FileEditRow({
|
|
|
|
|
edit,
|
2026-07-08 11:25:45 +08:00
|
|
|
displayMode,
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenFilePreview,
|
|
|
|
|
}: {
|
|
|
|
|
edit: FileEditSummary;
|
2026-07-08 11:25:45 +08:00
|
|
|
displayMode: FileEditDisplayMode;
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenFilePreview?: (path: string) => void;
|
|
|
|
|
}) {
|
2026-05-30 23:45:26 +08:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const editing = edit.status === "editing";
|
|
|
|
|
const failed = edit.status === "error";
|
|
|
|
|
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
2026-07-08 11:25:45 +08:00
|
|
|
const showDiff = canRenderDiff(edit, displayMode);
|
2026-05-31 17:00:42 +08:00
|
|
|
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
|
2026-05-30 23:45:26 +08:00
|
|
|
const failureDetail = failed
|
|
|
|
|
? formatFileEditError(edit.error)
|
|
|
|
|
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
|
|
|
|
|
: "";
|
|
|
|
|
const statusIcon = failed ? (
|
|
|
|
|
<AlertCircle className="h-3 w-3" aria-hidden />
|
|
|
|
|
) : editing ? (
|
|
|
|
|
<CircleDashed className="h-3 w-3 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<CheckCircle2 className="h-3 w-3" aria-hidden />
|
|
|
|
|
);
|
|
|
|
|
return (
|
|
|
|
|
<ActivityStep
|
|
|
|
|
as="li"
|
|
|
|
|
marker={(
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
|
|
|
|
|
failed && "border-destructive/30 text-destructive/78",
|
|
|
|
|
editing && "border-muted-foreground/24 text-muted-foreground/65",
|
|
|
|
|
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{statusIcon}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
active={editing}
|
|
|
|
|
tone={failed ? "error" : editing ? "active" : "success"}
|
|
|
|
|
className="text-xs"
|
2026-07-08 11:25:45 +08:00
|
|
|
contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
|
2026-05-31 17:00:42 +08:00
|
|
|
title={rawFailureDetail || edit.absolute_path || edit.path}
|
2026-05-30 23:45:26 +08:00
|
|
|
label={edit.pending && !edit.path
|
|
|
|
|
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
|
|
|
|
: (
|
|
|
|
|
<FileReferenceChip
|
|
|
|
|
path={edit.path}
|
|
|
|
|
tooltipPath={edit.absolute_path}
|
2026-06-06 19:49:33 +08:00
|
|
|
previewPath={edit.absolute_path || edit.path}
|
|
|
|
|
onOpen={onOpenFilePreview}
|
2026-05-30 23:45:26 +08:00
|
|
|
display="path"
|
|
|
|
|
active={editing}
|
|
|
|
|
className="min-w-0"
|
|
|
|
|
textClassName="text-[12px]"
|
|
|
|
|
testId="activity-file-reference"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-05-31 17:00:42 +08:00
|
|
|
detail={null}
|
|
|
|
|
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
|
|
|
|
>
|
|
|
|
|
{failed ? (
|
|
|
|
|
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
|
2026-05-30 23:45:26 +08:00
|
|
|
{failureDetail}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-07-08 11:25:45 +08:00
|
|
|
{showDiff ? (
|
|
|
|
|
<FileUnifiedDiff
|
|
|
|
|
diff={edit.diff!}
|
|
|
|
|
collapsed={displayMode === "collapsed_diff"}
|
|
|
|
|
added={edit.added}
|
|
|
|
|
deleted={edit.deleted}
|
|
|
|
|
previewPath={edit.absolute_path || edit.path}
|
|
|
|
|
onOpenFilePreview={onOpenFilePreview}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-05-31 17:00:42 +08:00
|
|
|
</ActivityStep>
|
2026-05-30 23:45:26 +08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
|
|
|
|
return edit.added > 0 || edit.deleted > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:00:42 +08:00
|
|
|
function cleanFileEditError(error?: string): string {
|
2026-05-30 23:45:26 +08:00
|
|
|
const firstLine = (error || "").replace(/\s+/g, " ").trim();
|
|
|
|
|
if (!firstLine) return "";
|
2026-05-31 17:00:42 +08:00
|
|
|
return firstLine
|
2026-05-30 23:45:26 +08:00
|
|
|
.replace(/^Error applying patch:\s*/i, "")
|
|
|
|
|
.replace(/^Error writing file:\s*/i, "")
|
|
|
|
|
.replace(/^Error editing file:\s*/i, "")
|
|
|
|
|
.replace(/^Error:\s*/i, "");
|
2026-05-31 17:00:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatFileEditError(error?: string): string {
|
|
|
|
|
const cleaned = cleanFileEditError(error);
|
|
|
|
|
if (!cleaned) return "";
|
|
|
|
|
|
|
|
|
|
if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) {
|
|
|
|
|
return "No permission to change this location.";
|
|
|
|
|
}
|
2026-05-30 23:45:26 +08:00
|
|
|
|
|
|
|
|
return cleaned
|
|
|
|
|
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
|
|
|
|
|
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
|
|
|
|
|
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
|
|
|
|
|
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
|
|
|
|
|
.slice(0, 180);
|
|
|
|
|
}
|
2026-07-08 11:25:45 +08:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|