|
|
|
@@ -91,6 +91,8 @@ interface DiffViewerProps {
|
|
|
|
|
scrollToFile?: string | null;
|
|
|
|
|
/** Called after scrollToFile has been processed. */
|
|
|
|
|
onScrollToFileComplete?: () => void;
|
|
|
|
|
/** Optional key for persisting collapsed file state to localStorage. */
|
|
|
|
|
storageKey?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
@@ -116,6 +118,15 @@ const STICKY_HEADER_CSS = [
|
|
|
|
|
" background-color: hsl(var(--surface-secondary)) !important;",
|
|
|
|
|
" padding-block: 0 !important;",
|
|
|
|
|
"}",
|
|
|
|
|
|
|
|
|
|
// Ensure the header-prefix slot (collapse chevron) renders
|
|
|
|
|
// before the CSS-generated change-type letter (A/D/M/R) so
|
|
|
|
|
// the order matches in both collapsed and expanded states.
|
|
|
|
|
"[data-header-content] > slot[name='header-prefix'] {",
|
|
|
|
|
" order: -1;",
|
|
|
|
|
" display: flex;",
|
|
|
|
|
" align-items: center;",
|
|
|
|
|
"}",
|
|
|
|
|
].join(" ");
|
|
|
|
|
|
|
|
|
|
export type DiffStyle = "unified" | "split";
|
|
|
|
@@ -133,6 +144,38 @@ export function saveDiffStyle(style: DiffStyle): void {
|
|
|
|
|
localStorage.setItem(DIFF_STYLE_KEY, style);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const COLLAPSED_FILES_KEY_PREFIX = "agents.collapsed-diffs";
|
|
|
|
|
|
|
|
|
|
function loadCollapsedFiles(storageKey: string): Set<string> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(
|
|
|
|
|
`${COLLAPSED_FILES_KEY_PREFIX}.${storageKey}`,
|
|
|
|
|
);
|
|
|
|
|
if (raw) {
|
|
|
|
|
const parsed: unknown = JSON.parse(raw);
|
|
|
|
|
if (Array.isArray(parsed)) {
|
|
|
|
|
return new Set(
|
|
|
|
|
parsed.filter((v): v is string => typeof v === "string"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Corrupted data — start fresh.
|
|
|
|
|
}
|
|
|
|
|
return new Set();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NOTE: Each storageKey gets its own localStorage entry and old
|
|
|
|
|
// entries are never pruned. For `remote-${chatId}` this grows as
|
|
|
|
|
// new chats are created. A future improvement could add LRU
|
|
|
|
|
// eviction or cap the number of stored keys.
|
|
|
|
|
function saveCollapsedFiles(storageKey: string, files: Set<string>): void {
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
`${COLLAPSED_FILES_KEY_PREFIX}.${storageKey}`,
|
|
|
|
|
JSON.stringify([...files]),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Width of the file tree sidebar in pixels. */
|
|
|
|
|
const FILE_TREE_WIDTH = 300;
|
|
|
|
|
|
|
|
|
@@ -413,6 +456,33 @@ const DiffScrollContainer: FC<{
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
// Collapse toggle rendered in each file header via renderHeaderPrefix
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const CollapseChevron: FC<{
|
|
|
|
|
collapsed: boolean;
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
}> = ({ collapsed, onToggle }) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onToggle();
|
|
|
|
|
}}
|
|
|
|
|
className="flex items-center border-none bg-transparent cursor-pointer p-0 text-content-secondary hover:text-content-primary outline-none"
|
|
|
|
|
aria-label={collapsed ? "Expand file" : "Collapse file"}
|
|
|
|
|
data-testid="collapse-file-toggle"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRightIcon
|
|
|
|
|
className={cn(
|
|
|
|
|
"size-3.5 shrink-0 transition-transform",
|
|
|
|
|
!collapsed && "rotate-90",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
// Lazy file diff wrapper
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
@@ -429,6 +499,8 @@ const DiffScrollContainer: FC<{
|
|
|
|
|
interface LazyFileDiffProps {
|
|
|
|
|
fileDiff: FileDiffMetadata;
|
|
|
|
|
options: ComponentProps<typeof FileDiff>["options"];
|
|
|
|
|
collapsed?: boolean;
|
|
|
|
|
onToggleCollapsed?: () => void;
|
|
|
|
|
lineAnnotations?: DiffLineAnnotation<string>[];
|
|
|
|
|
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
|
|
|
|
|
selectedLines?: SelectedLineRange | null;
|
|
|
|
@@ -437,6 +509,8 @@ interface LazyFileDiffProps {
|
|
|
|
|
const LazyFileDiff: FC<LazyFileDiffProps> = ({
|
|
|
|
|
fileDiff,
|
|
|
|
|
options,
|
|
|
|
|
collapsed,
|
|
|
|
|
onToggleCollapsed,
|
|
|
|
|
lineAnnotations,
|
|
|
|
|
renderAnnotation: renderAnnotationProp,
|
|
|
|
|
selectedLines,
|
|
|
|
@@ -465,6 +539,14 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
|
|
|
|
|
return () => observer.disconnect();
|
|
|
|
|
}, [visible]);
|
|
|
|
|
|
|
|
|
|
const mergedOptions = collapsed ? { ...options, collapsed: true } : options;
|
|
|
|
|
|
|
|
|
|
const headerPrefix = onToggleCollapsed
|
|
|
|
|
? () => (
|
|
|
|
|
<CollapseChevron collapsed={!!collapsed} onToggle={onToggleCollapsed} />
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
if (!visible) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
@@ -483,11 +565,12 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
|
|
|
|
|
return (
|
|
|
|
|
<FileDiff
|
|
|
|
|
fileDiff={fileDiff}
|
|
|
|
|
options={options}
|
|
|
|
|
options={mergedOptions}
|
|
|
|
|
metrics={VIRTUALIZER_METRICS}
|
|
|
|
|
style={DIFFS_FONT_STYLE}
|
|
|
|
|
lineAnnotations={lineAnnotations}
|
|
|
|
|
renderAnnotation={renderAnnotationProp}
|
|
|
|
|
renderHeaderPrefix={headerPrefix}
|
|
|
|
|
selectedLines={selectedLines}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
@@ -511,8 +594,31 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|
|
|
|
renderAnnotation,
|
|
|
|
|
scrollToFile,
|
|
|
|
|
onScrollToFileComplete,
|
|
|
|
|
storageKey,
|
|
|
|
|
}) => {
|
|
|
|
|
const theme = useTheme();
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Collapsed file state
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() =>
|
|
|
|
|
storageKey ? loadCollapsedFiles(storageKey) : new Set(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const toggleFileCollapsed = (fileName: string) => {
|
|
|
|
|
setCollapsedFiles((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(fileName)) {
|
|
|
|
|
next.delete(fileName);
|
|
|
|
|
} else {
|
|
|
|
|
next.add(fileName);
|
|
|
|
|
}
|
|
|
|
|
if (storageKey) {
|
|
|
|
|
saveCollapsedFiles(storageKey, next);
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
const isDark = theme.palette.mode === "dark";
|
|
|
|
|
|
|
|
|
|
const diffOptions = (() => {
|
|
|
|
@@ -738,6 +844,9 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|
|
|
|
}, [showTree, sortedFiles.length]);
|
|
|
|
|
|
|
|
|
|
const handleFileClick = (name: string) => {
|
|
|
|
|
if (collapsedFiles.has(name)) {
|
|
|
|
|
toggleFileCollapsed(name);
|
|
|
|
|
}
|
|
|
|
|
const el = fileRefs.current.get(name);
|
|
|
|
|
if (el) {
|
|
|
|
|
el.scrollIntoView({ block: "start" });
|
|
|
|
@@ -748,8 +857,15 @@ 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).
|
|
|
|
|
// The effect should only fire when scrollToFile changes;
|
|
|
|
|
// collapsedFiles/toggleFileCollapsed are used incidentally
|
|
|
|
|
// to auto-expand the target file.
|
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (scrollToFile) {
|
|
|
|
|
if (collapsedFiles.has(scrollToFile)) {
|
|
|
|
|
toggleFileCollapsed(scrollToFile);
|
|
|
|
|
}
|
|
|
|
|
const el = fileRefs.current.get(scrollToFile);
|
|
|
|
|
if (el) {
|
|
|
|
|
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
|
|
@@ -848,6 +964,8 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|
|
|
|
<LazyFileDiff
|
|
|
|
|
fileDiff={fileDiff}
|
|
|
|
|
options={perFileOptions?.get(fileDiff.name) ?? fileOptions}
|
|
|
|
|
collapsed={collapsedFiles.has(fileDiff.name)}
|
|
|
|
|
onToggleCollapsed={() => toggleFileCollapsed(fileDiff.name)}
|
|
|
|
|
lineAnnotations={perFileAnnotations?.get(fileDiff.name)}
|
|
|
|
|
renderAnnotation={renderAnnotation}
|
|
|
|
|
selectedLines={
|
|
|
|
|