feat: Add stacked diff mode controls and mobile dropdown mode selector (#142)

* feat: add diffViewMode to UI store

* feat(PierreDiffViewer): add layout prop for inline diff

* feat: add diff view modes and change descriptors

* feat(openchamber): add diff view mode toggle

* feat: add diff view mode selector
This commit is contained in:
Nelson Pires
2026-01-14 15:15:16 +02:00
committed by GitHub
parent 4fa4c8d40b
commit e95bfdebaf
4 changed files with 724 additions and 31 deletions
@@ -56,6 +56,19 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
},
];
const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
{
id: 'single',
label: 'Single file',
description: 'Show one file at a time in the Diff tab.',
},
{
id: 'stacked',
label: 'All files',
description: 'Stack all changed files together in the Diff tab.',
},
];
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'reasoning' | 'queueMode';
interface OpenChamberVisualSettingsProps {
@@ -77,6 +90,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setInputBarOffset = useUIStore(state => state.setInputBarOffset);
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const diffViewMode = useUIStore(state => state.diffViewMode);
const setDiffViewMode = useUIStore(state => state.setDiffViewMode);
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const {
@@ -296,7 +311,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('diffLayout') && !isMobile && (
<div className="space-y-4">
<div className="space-y-6">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Diff layout (Diff tab)
@@ -323,6 +338,33 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{DIFF_LAYOUT_OPTIONS.find((option) => option.id === diffLayoutPreference)?.description}
</p>
</div>
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Diff view (Diff tab)
</h3>
<p className="typography-meta text-muted-foreground/80">
Choose whether the Diff tab defaults to a single file or all files.
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-1 w-fit">
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
<ButtonSmall
key={option.id}
variant={diffViewMode === option.id ? 'default' : 'outline'}
className={cn(diffViewMode === option.id ? undefined : 'text-foreground')}
onClick={() => setDiffViewMode(option.id)}
>
{option.label}
</ButtonSmall>
))}
</div>
<p className="typography-meta text-muted-foreground/80 max-w-xl">
{DIFF_VIEW_MODE_OPTIONS.find((option) => option.id === diffViewMode)?.description}
</p>
</div>
</div>
)}
+666 -28
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -9,11 +9,14 @@ import type { GitStatus } from '@/lib/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiArrowDownSLine } from '@remixicon/react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
@@ -23,6 +26,7 @@ import { useDeviceInfo } from '@/lib/device';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
const DIFF_REQUEST_TIMEOUT_MS = 15000;
type FileEntry = GitStatus['files'][number] & {
insertions: number;
@@ -32,6 +36,57 @@ type FileEntry = GitStatus['files'][number] & {
type DiffData = { original: string; modified: string };
type DiffTabViewMode = 'single' | 'stacked';
type ChangeDescriptor = {
code: string;
color: string;
description: string;
};
const CHANGE_DESCRIPTORS: Record<string, ChangeDescriptor> = {
'?': { code: '?', color: 'var(--status-info)', description: 'Untracked file' },
A: { code: 'A', color: 'var(--status-success)', description: 'New file' },
D: { code: 'D', color: 'var(--status-error)', description: 'Deleted file' },
R: { code: 'R', color: 'var(--status-info)', description: 'Renamed file' },
C: { code: 'C', color: 'var(--status-info)', description: 'Copied file' },
M: { code: 'M', color: 'var(--status-warning)', description: 'Modified file' },
};
const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M;
const DIFF_VIEW_MODE_OPTIONS: Array<{
value: DiffTabViewMode;
label: string;
description: string;
}> = [
{
value: 'single',
label: 'Single file',
description: 'Show one file at a time',
},
{
value: 'stacked',
label: 'All files',
description: 'Stack all modified files together',
},
];
const getChangeSymbol = (file: GitStatus['files'][number]): string => {
const indexCode = file.index?.trim();
const workingCode = file.working_dir?.trim();
if (indexCode && indexCode !== '?') return indexCode.charAt(0);
if (workingCode) return workingCode.charAt(0);
return indexCode?.charAt(0) || workingCode?.charAt(0) || 'M';
};
const describeChange = (file: GitStatus['files'][number]): ChangeDescriptor => {
const symbol = getChangeSymbol(file);
return CHANGE_DESCRIPTORS[symbol] ?? DEFAULT_CHANGE_DESCRIPTOR;
};
const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
const { index, working_dir: workingDir } = file;
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
@@ -55,6 +110,9 @@ interface FileSelectorProps {
selectedFileEntry: FileEntry | null;
onSelectFile: (path: string) => void;
isMobile: boolean;
showModeSelector?: boolean;
mode?: DiffTabViewMode;
onModeChange?: (mode: DiffTabViewMode) => void;
}
const FileSelector = React.memo<FileSelectorProps>(({
@@ -63,6 +121,9 @@ const FileSelector = React.memo<FileSelectorProps>(({
selectedFileEntry,
onSelectFile,
isMobile,
showModeSelector = false,
mode,
onModeChange,
}) => {
const getLabel = React.useCallback((path: string) => {
if (!isMobile) return path;
@@ -90,6 +151,30 @@ const FileSelector = React.memo<FileSelectorProps>(({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] min-w-[320px] overflow-y-auto">
{showModeSelector && mode && onModeChange ? (
<>
<DropdownMenuLabel className="typography-meta text-muted-foreground">
View mode
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={mode}
onValueChange={(value) => onModeChange(value as DiffTabViewMode)}
>
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
className="items-center"
>
<span className="typography-meta text-foreground">
{option.label}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuRadioGroup value={selectedFile ?? ''} onValueChange={onSelectFile}>
{changedFiles.map((file) => (
<DropdownMenuRadioItem key={file.path} value={file.path}>
@@ -109,6 +194,111 @@ const FileSelector = React.memo<FileSelectorProps>(({
);
});
interface DiffViewModeSelectorProps {
mode: DiffTabViewMode;
onModeChange: (mode: DiffTabViewMode) => void;
}
const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onModeChange }) => {
const currentOption =
DIFF_VIEW_MODE_OPTIONS.find((option) => option.value === mode) ?? DIFF_VIEW_MODE_OPTIONS[0];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex h-8 items-center gap-2 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 truncate typography-meta">
{currentOption.label}
</span>
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[220px]">
<DropdownMenuRadioGroup
value={mode}
onValueChange={(value) => onModeChange(value as DiffTabViewMode)}
>
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5">
<span className="typography-meta text-foreground">
{option.label}
</span>
<span className="typography-micro text-muted-foreground">
{option.description}
</span>
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
});
interface FileListProps {
changedFiles: FileEntry[];
selectedFile: string | null;
onSelectFile: (path: string) => void;
isCompact: boolean;
}
const FileList = React.memo<FileListProps>(({
changedFiles,
selectedFile,
onSelectFile,
isCompact,
}) => {
const getLabel = React.useCallback((path: string) => {
if (!isCompact) return path;
const lastSlash = path.lastIndexOf('/');
return lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
}, [isCompact]);
if (changedFiles.length === 0) return null;
return (
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-2 py-2">
<ul className="flex flex-col gap-1">
{changedFiles.map((file) => {
const descriptor = describeChange(file);
const isActive = selectedFile === file.path;
return (
<li key={file.path}>
<button
type="button"
onClick={() => onSelectFile(file.path)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
isActive
? 'bg-accent/70 text-foreground'
: 'text-muted-foreground hover:bg-accent/40 hover:text-foreground'
}`}
>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
>
{descriptor.code}
</span>
<span
className="min-w-0 flex-1 truncate typography-meta"
title={file.path}
>
{getLabel(file.path)}
</span>
{formatDiffTotals(file.insertions, file.deletions)}
</button>
</li>
);
})}
</ul>
</ScrollableOverlay>
);
});
// Image diff viewer for binary image files
interface ImageDiffViewerProps {
filePath: string;
@@ -171,6 +361,103 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
);
});
interface InlineImageDiffViewerProps {
filePath: string;
diff: DiffData;
renderSideBySide: boolean;
}
const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
filePath,
diff,
renderSideBySide,
}) => {
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
const containerClass = renderSideBySide
? 'flex flex-row gap-6 items-start justify-center'
: 'flex flex-col gap-4 items-center';
const imageContainerClass = renderSideBySide
? 'flex flex-col items-center gap-2 flex-1 min-w-0'
: 'flex flex-col items-center gap-2';
return (
<div className="w-full overflow-auto p-4" style={{ contain: 'layout' }}>
<div className={containerClass}>
{hasOriginal && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">Original</span>
<img
src={diff.original}
alt={`Original: ${filePath}`}
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
</div>
)}
{hasModified && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">
{hasOriginal ? 'Modified' : 'New'}
</span>
<img
src={diff.modified}
alt={`Modified: ${filePath}`}
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
</div>
)}
</div>
</div>
);
});
interface InlineDiffViewerProps {
filePath: string;
diff: DiffData;
renderSideBySide: boolean;
wrapLines: boolean;
}
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
filePath,
diff,
renderSideBySide,
wrapLines,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath) || 'text',
[filePath]
);
if (isImageFile(filePath)) {
return (
<InlineImageDiffViewer
filePath={filePath}
diff={diff}
renderSideBySide={renderSideBySide}
/>
);
}
return (
<div className="w-full" style={{ contain: 'layout' }}>
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
language={language}
fileName={filePath}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
layout="inline"
/>
</div>
);
});
// Single diff viewer instance - stays mounted
interface SingleDiffViewerProps {
filePath: string;
@@ -274,6 +561,228 @@ const DiffViewerEntry = React.memo<DiffViewerEntryProps>(({
);
});
interface MultiFileDiffEntryProps {
directory: string;
file: FileEntry;
layout: 'inline' | 'side-by-side';
wrapLines: boolean;
scrollRootRef: React.RefObject<HTMLElement | null>;
isSelected: boolean;
onSelect: (path: string) => void;
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
directory,
file,
layout,
wrapLines,
scrollRootRef,
isSelected,
onSelect,
registerSectionRef,
}) => {
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
React.useCallback((state) => {
return state.directories.get(directory)?.diffCache.get(file.path) ?? null;
}, [directory, file.path])
);
const setDiff = useGitStore((state) => state.setDiff);
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const [isExpanded, setIsExpanded] = React.useState(true);
const [hasBeenVisible, setHasBeenVisible] = React.useState(false);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const lastDiffRequestRef = React.useRef<string | null>(null);
const sectionRef = React.useRef<HTMLDivElement | null>(null);
const descriptor = React.useMemo(() => describeChange(file), [file]);
const renderSideBySide = layout === 'side-by-side';
const diffData = React.useMemo(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified };
}, [cachedDiff]);
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
sectionRef.current = node;
registerSectionRef(file.path, node);
}, [file.path, registerSectionRef]);
const handleOpenChange = React.useCallback((open: boolean) => {
setIsExpanded(open);
if (open) {
setHasBeenVisible(true);
}
}, []);
const handleSelect = React.useCallback(() => {
onSelect(file.path);
}, [file.path, onSelect]);
React.useEffect(() => {
if (!isExpanded || hasBeenVisible) return;
const target = sectionRef.current;
if (!target) return;
if (!scrollRootRef.current || typeof IntersectionObserver === 'undefined') {
setHasBeenVisible(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setHasBeenVisible(true);
observer.disconnect();
}
},
{ root: scrollRootRef.current, rootMargin: '200px 0px', threshold: 0.1 }
);
observer.observe(target);
return () => observer.disconnect();
}, [hasBeenVisible, isExpanded, scrollRootRef]);
React.useEffect(() => {
if (!isExpanded || !hasBeenVisible) return;
if (!directory || diffData) {
lastDiffRequestRef.current = null;
setIsLoading(false);
return;
}
const requestKey = `${directory}::${file.path}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
lastDiffRequestRef.current = requestKey;
setDiffLoadError(null);
setIsLoading(true);
let cancelled = false;
void (async () => {
try {
const fetchPromise = git.getGitFileDiff(directory, { path: file.path });
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (cancelled) return;
setDiff(directory, file.path, {
original: response.original ?? '',
modified: response.modified ?? '',
});
setIsLoading(false);
} catch (error) {
if (cancelled) return;
const message = error instanceof Error ? error.message : String(error);
setDiffLoadError(message);
setIsLoading(false);
}
})();
return () => {
cancelled = true;
if (lastDiffRequestRef.current === requestKey) {
lastDiffRequestRef.current = null;
}
};
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]);
return (
<div ref={setSectionRef} className="scroll-mt-4">
<Collapsible
open={isExpanded}
onOpenChange={handleOpenChange}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
>
<CollapsibleTrigger
onClick={handleSelect}
className={`group gap-2 ${isSelected ? 'bg-accent/60' : ''}`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="flex size-5 items-center justify-center text-muted-foreground">
{isExpanded ? (
<RiArrowDownSLine className="size-4" />
) : (
<RiArrowRightSLine className="size-4" />
)}
</span>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
>
{descriptor.code}
</span>
<span
className="min-w-0 flex-1 truncate typography-ui-label"
title={file.path}
>
{file.path}
</span>
</div>
<div className="flex items-center gap-2">
{formatDiffTotals(file.insertions, file.deletions)}
<DiffViewToggle
mode={renderSideBySide ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(file.path, nextLayout);
}}
className="opacity-70"
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border/60 bg-background">
<div className="relative">
{diffLoadError ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
</div>
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
>
Retry
</button>
</div>
) : null}
{isLoading && !diffData && !diffLoadError ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
) : null}
{diffData ? (
<InlineDiffViewer
filePath={file.path}
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
});
const useEffectiveDirectory = () => {
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
@@ -307,9 +816,18 @@ export const DiffView: React.FC = () => {
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const diffWrapLinesStore = useUIStore((state) => state.diffWrapLines);
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const diffViewMode = useUIStore((state) => state.diffViewMode);
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
// Default to wrap on mobile
const diffWrapLines = isMobile || diffWrapLinesStore;
const isStackedView = diffViewMode === 'stacked';
const isMobileLayout = isMobile || screenWidth <= 768;
const showFileSidebar = !isMobileLayout && screenWidth >= 1024;
const diffScrollRef = React.useRef<HTMLElement | null>(null);
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
const pendingScrollTargetRef = React.useRef<string | null>(null);
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
const diffStats = status.diffStats ?? {};
@@ -329,14 +847,10 @@ export const DiffView: React.FC = () => {
return changedFiles.find((file) => file.path === selectedFile) ?? null;
}, [changedFiles, selectedFile]);
const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => {
if (!selectedFileEntry) return null;
// Per-file override takes priority
const override = diffFileLayout[selectedFileEntry.path];
const getLayoutForFile = React.useCallback((file: FileEntry): 'inline' | 'side-by-side' => {
const override = diffFileLayout[file.path];
if (override) return override;
// Explicit user preference - respect it regardless of screen width
if (diffLayoutPreference === 'inline') {
return 'inline';
}
@@ -345,14 +859,18 @@ export const DiffView: React.FC = () => {
return 'side-by-side';
}
// Dynamic mode: auto-switch based on file type and screen width
const isNarrow = screenWidth < SIDE_BY_SIDE_MIN_WIDTH;
if (selectedFileEntry.isNew || isNarrow) {
if (file.isNew || isNarrow) {
return 'inline';
}
return 'side-by-side';
}, [selectedFileEntry, diffFileLayout, diffLayoutPreference, screenWidth]);
}, [diffFileLayout, diffLayoutPreference, screenWidth]);
const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => {
if (!selectedFileEntry) return null;
return getLayoutForFile(selectedFileEntry);
}, [getLayoutForFile, selectedFileEntry]);
// Fetch git status on mount
React.useEffect(() => {
@@ -370,8 +888,11 @@ export const DiffView: React.FC = () => {
if (pendingDiffFile) {
setSelectedFile(pendingDiffFile);
setPendingDiffFile(null);
if (isStackedView) {
pendingScrollTargetRef.current = pendingDiffFile;
}
}
}, [pendingDiffFile, setPendingDiffFile]);
}, [isStackedView, pendingDiffFile, setPendingDiffFile]);
// Auto-select first file (skip if we have a pending file to consume)
React.useEffect(() => {
@@ -380,6 +901,22 @@ export const DiffView: React.FC = () => {
}
}, [changedFiles, selectedFile, pendingDiffFile]);
React.useEffect(() => {
if (!isStackedView) {
pendingScrollTargetRef.current = null;
return;
}
const target = pendingScrollTargetRef.current;
if (!target) return;
const node = fileSectionRefs.current.get(target);
if (!node) return;
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
pendingScrollTargetRef.current = null;
}, [changedFiles, isStackedView]);
// Clear selection if file no longer exists
React.useEffect(() => {
if (selectedFile && changedFiles.length > 0) {
@@ -390,11 +927,57 @@ export const DiffView: React.FC = () => {
}
}, [changedFiles, selectedFile]);
const registerSectionRef = React.useCallback((path: string, node: HTMLDivElement | null) => {
const map = fileSectionRefs.current;
if (node) {
map.set(path, node);
} else {
map.delete(path);
}
}, []);
const scrollToFile = React.useCallback((path: string, behavior: ScrollBehavior = 'smooth') => {
const node = fileSectionRefs.current.get(path);
if (!node) return false;
node.scrollIntoView({ behavior, block: 'start' });
return true;
}, []);
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
}, []);
const handleSelectFileAndScroll = React.useCallback((value: string) => {
setSelectedFile(value);
if (isStackedView && !scrollToFile(value)) {
pendingScrollTargetRef.current = value;
}
}, [isStackedView, scrollToFile]);
const handleDiffViewModeChange = React.useCallback((mode: DiffTabViewMode) => {
setDiffViewMode(mode);
if (mode === 'stacked' && selectedFile && !scrollToFile(selectedFile, 'auto')) {
pendingScrollTargetRef.current = selectedFile;
}
}, [scrollToFile, selectedFile, setDiffViewMode]);
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
if (isStackedView) {
changedFiles.forEach((file) => {
setDiffFileLayout(file.path, nextLayout);
});
return;
}
if (!selectedFileEntry) return;
setDiffFileLayout(selectedFileEntry.path, nextLayout);
}, [changedFiles, isStackedView, selectedFileEntry, setDiffFileLayout]);
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
const showFileSelector = !isStackedView || !showFileSidebar;
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory || !selectedFile) return null;
@@ -402,9 +985,13 @@ export const DiffView: React.FC = () => {
}, [effectiveDirectory, selectedFile]));
const hasCurrentDiff = !!selectedCachedDiff;
const isCurrentFileLoading = !!selectedFile && !hasCurrentDiff;
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
React.useEffect(() => {
if (isStackedView) {
return;
}
setDiffLoadError(null);
if (!effectiveDirectory || !selectedFile) {
@@ -427,7 +1014,7 @@ export const DiffView: React.FC = () => {
void (async () => {
try {
const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile });
const timeoutMs = 15000;
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
@@ -453,7 +1040,7 @@ export const DiffView: React.FC = () => {
lastDiffRequestRef.current = null;
}
};
}, [effectiveDirectory, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]);
}, [effectiveDirectory, isStackedView, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]);
// Render all diff viewers - they stay mounted
const renderAllDiffViewers = () => {
@@ -471,7 +1058,52 @@ export const DiffView: React.FC = () => {
));
};
const renderStackedDiffView = () => {
if (!effectiveDirectory) return null;
return (
<div className="flex flex-1 min-h-0 gap-3 px-3 pb-3 pt-2">
{showFileSidebar && (
<section className="hidden lg:flex w-72 flex-col rounded-xl border border-border/60 bg-background/70 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
<span className="typography-ui-header font-semibold text-foreground">Files</span>
<span className="typography-meta text-muted-foreground">{changedFiles.length}</span>
</div>
<FileList
changedFiles={changedFiles}
selectedFile={selectedFile}
onSelectFile={handleSelectFileAndScroll}
isCompact={screenWidth < 1280}
/>
</section>
)}
<ScrollableOverlay
ref={diffScrollRef}
outerClassName="flex-1 min-h-0"
className="pr-2"
>
<div className="flex flex-col gap-3 py-1">
{changedFiles.map((file) => (
<MultiFileDiffEntry
key={file.path}
directory={effectiveDirectory}
file={file}
layout={getLayoutForFile(file)}
wrapLines={diffWrapLines}
scrollRootRef={diffScrollRef}
isSelected={file.path === selectedFile}
onSelect={handleSelectFile}
registerSectionRef={registerSectionRef}
/>
))}
</div>
</ScrollableOverlay>
</div>
);
};
const renderContent = () => {
if (!effectiveDirectory) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
@@ -505,6 +1137,9 @@ export const DiffView: React.FC = () => {
);
}
if (isStackedView) {
return renderStackedDiffView();
}
return (
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative">
@@ -555,13 +1190,21 @@ export const DiffView: React.FC = () => {
</span>
</div>
)}
<FileSelector
changedFiles={changedFiles}
selectedFile={selectedFile}
selectedFileEntry={selectedFileEntry}
onSelectFile={handleSelectFile}
isMobile={isMobile || screenWidth <= 768}
/>
{!isMobileLayout && (
<DiffViewModeSelector mode={diffViewMode} onModeChange={handleDiffViewModeChange} />
)}
{showFileSelector && (
<FileSelector
changedFiles={changedFiles}
selectedFile={selectedFile}
selectedFileEntry={selectedFileEntry}
onSelectFile={handleSelectFileAndScroll}
isMobile={isMobileLayout}
showModeSelector={isMobileLayout}
mode={diffViewMode}
onModeChange={handleDiffViewModeChange}
/>
)}
<div className="flex-1" />
{selectedFileEntry && (
<button
@@ -580,12 +1223,7 @@ export const DiffView: React.FC = () => {
{selectedFileEntry && currentLayoutForSelectedFile && (
<DiffViewToggle
mode={currentLayoutForSelectedFile === 'side-by-side' ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
if (!selectedFileEntry) return;
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(selectedFileEntry.path, nextLayout);
}}
onModeChange={handleHeaderLayoutChange}
/>
)}
</div>
@@ -14,6 +14,7 @@ interface PierreDiffViewerProps {
fileName?: string;
renderSideBySide: boolean;
wrapLines?: boolean;
layout?: 'fill' | 'inline';
}
// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization
@@ -89,6 +90,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
fileName = 'file',
renderSideBySide,
wrapLines = false,
layout = 'fill',
}) => {
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
@@ -149,14 +151,17 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
}), [isDark, renderSideBySide, wrapLines]);
const isInlineLayout = layout === 'inline';
if (typeof window === 'undefined') {
return null;
}
return (
<ScrollableOverlay
outerClassName="pierre-diff-wrapper size-full"
outerClassName={isInlineLayout ? "pierre-diff-wrapper w-full" : "pierre-diff-wrapper size-full"}
disableHorizontal={false}
fillContainer={!isInlineLayout}
>
<FileDiff
fileDiff={fileDiff}
+8
View File
@@ -52,6 +52,7 @@ interface UIStore {
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
diffWrapLines: boolean;
diffViewMode: 'single' | 'stacked';
isTimelineDialogOpen: boolean;
nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only';
@@ -95,6 +96,7 @@ interface UIStore {
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
setDiffWrapLines: (wrap: boolean) => void;
setDiffViewMode: (mode: 'single' | 'stacked') => void;
setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void;
setNativeNotificationsEnabled: (value: boolean) => void;
@@ -141,6 +143,7 @@ export const useUIStore = create<UIStore>()(
diffLayoutPreference: 'dynamic',
diffFileLayout: {},
diffWrapLines: false,
diffViewMode: 'single',
isTimelineDialogOpen: false,
nativeNotificationsEnabled: false,
notificationMode: 'hidden-only',
@@ -365,6 +368,10 @@ export const useUIStore = create<UIStore>()(
set({ diffWrapLines: wrap });
},
setDiffViewMode: (mode) => {
set({ diffViewMode: mode });
},
setInputBarOffset: (offset) => {
set({ inputBarOffset: offset });
},
@@ -503,6 +510,7 @@ export const useUIStore = create<UIStore>()(
recentModels: state.recentModels,
diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines,
diffViewMode: state.diffViewMode,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
})