feat(app): folder tree in the Changes view (#1918)
Group changed files in the Changes view into a collapsible directory tree instead of a flat list, on every form factor — consistent with the Files explorer, which already renders a directory tree on mobile. Folders are collapsible and expanded by default, single-child directory chains are compressed into one row (packages/app/src), and folder rows show aggregate +/- stats. Client-only, built entirely from the existing ParsedDiffFile paths: - diff-tree.ts: pure build/compress/flatten of the directory hierarchy, keyed by full uncompressed dir path so collapse state survives compression changes as the diff mutates. - diff-flat-items.ts: pure buildDiffFlatItems + sumHeightsBefore. The function retains a flat mode (showTree=false) as a tested seam for a possible future flat/tree toggle; sticky header indices are file-headers-only, derived from the post-collapse list. - panel-store: diffCollapsedFoldersByWorkspace slice (collapsed-set, so folders default to expanded), persisted via version 12. The set is reconciled against the current diff so folders that leave and reappear default to expanded and the stored set stays bounded. Presentation primitives (chevron, indent guides, indent constant) are extracted into components/tree-primitives and shared with the Files explorer, which drops its private copies (and a banned useUnistyles call site). The two trees now render identical rows; only the tree-building stays separate (server listings vs. diff paths). The toolbar's expand-all stays file-diffs-only (renamed allFileDiffsExpanded); a separate control collapses/expands all folders. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cab0e4b3f8
commit
3259a69466
@@ -36,6 +36,7 @@ import {
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
Download,
|
||||
FolderTree,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
ListChevronsDownUp,
|
||||
@@ -52,6 +53,12 @@ import {
|
||||
type DiffLine,
|
||||
type HighlightToken,
|
||||
} from "@/git/use-diff-query";
|
||||
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
|
||||
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
|
||||
import { DiffFolderRow } from "@/git/diff-folder-row";
|
||||
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
@@ -191,6 +198,10 @@ function HighlightedText({
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
isExpanded: boolean;
|
||||
/** Tree indentation level (0 on the flat/mobile path). */
|
||||
depth?: number;
|
||||
/** Show the muted directory suffix (flat list); false inside the folder tree. */
|
||||
showDir?: boolean;
|
||||
onToggle: (path: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
@@ -889,6 +900,8 @@ function SplitDiffColumn({
|
||||
const DiffFileHeader = memo(function DiffFileHeader({
|
||||
file,
|
||||
isExpanded,
|
||||
depth = 0,
|
||||
showDir = true,
|
||||
onToggle,
|
||||
onHeaderHeightChange,
|
||||
testID,
|
||||
@@ -940,13 +953,27 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
const headerPressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType) =>
|
||||
depth > 0
|
||||
? [
|
||||
fileHeaderPressableStyle(state),
|
||||
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
|
||||
]
|
||||
: fileHeaderPressableStyle(state),
|
||||
[depth],
|
||||
);
|
||||
|
||||
const fileName = file.path.split("/").pop() ?? file.path;
|
||||
|
||||
return (
|
||||
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={fileHeaderPressableStyle}
|
||||
style={headerPressableStyle}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={handlePressIn}
|
||||
@@ -954,14 +981,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
onPress={toggleExpanded}
|
||||
>
|
||||
<View style={styles.fileHeaderLeft}>
|
||||
{showDir ? null : (
|
||||
<View style={styles.fileIcon}>
|
||||
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{file.path.split("/").pop()}
|
||||
</Text>
|
||||
<Text style={styles.fileDir} numberOfLines={1}>
|
||||
{file.path.includes("/")
|
||||
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
||||
: ""}
|
||||
{fileName}
|
||||
</Text>
|
||||
{showDir ? (
|
||||
<Text style={styles.fileDir} numberOfLines={1}>
|
||||
{file.path.includes("/")
|
||||
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
||||
: ""}
|
||||
</Text>
|
||||
) : (
|
||||
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
|
||||
// stays right-aligned next to the diff stats, as in the flat list.
|
||||
<View style={styles.fileDirSpacer} />
|
||||
)}
|
||||
{file.isNew && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
|
||||
@@ -1191,6 +1229,7 @@ const ThemedPilcrow = withUnistyles(Pilcrow);
|
||||
const ThemedWrapText = withUnistyles(WrapText);
|
||||
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
|
||||
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
@@ -1307,32 +1346,68 @@ function DiffWhitespaceToggle({
|
||||
|
||||
interface DiffFilesToolbarProps {
|
||||
wrapLines: boolean;
|
||||
allExpanded: boolean;
|
||||
// Controls file-DIFF bodies only (not folders). Named explicitly so the
|
||||
// folder control below stays unambiguous (Codex item 4).
|
||||
allFileDiffsExpanded: boolean;
|
||||
showFolderControl: boolean;
|
||||
allFoldersCollapsed: boolean;
|
||||
isMobile: boolean;
|
||||
wrapLinesToggleStyle: PressableStyleFn;
|
||||
expandAllToggleStyle: PressableStyleFn;
|
||||
folderToggleStyle: PressableStyleFn;
|
||||
onToggleWrapLines: () => void;
|
||||
onToggleExpandAll: () => void;
|
||||
onToggleCollapseAllFolders: () => void;
|
||||
}
|
||||
|
||||
function DiffFilesToolbar({
|
||||
wrapLines,
|
||||
allExpanded,
|
||||
allFileDiffsExpanded,
|
||||
showFolderControl,
|
||||
allFoldersCollapsed,
|
||||
isMobile,
|
||||
wrapLinesToggleStyle,
|
||||
expandAllToggleStyle,
|
||||
folderToggleStyle,
|
||||
onToggleWrapLines,
|
||||
onToggleExpandAll,
|
||||
onToggleCollapseAllFolders,
|
||||
}: DiffFilesToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const wrapLinesLabel = wrapLines
|
||||
? t("workspace.git.diff.scrollLongLines")
|
||||
: t("workspace.git.diff.wrapLongLines");
|
||||
const expandAllLabel = allExpanded
|
||||
const expandAllLabel = allFileDiffsExpanded
|
||||
? t("workspace.git.diff.collapseAll")
|
||||
: t("workspace.git.diff.expandAll");
|
||||
const folderLabel = allFoldersCollapsed
|
||||
? t("workspace.git.diff.expandAllFolders")
|
||||
: t("workspace.git.diff.collapseAllFolders");
|
||||
return (
|
||||
<View style={styles.diffStatusButtons}>
|
||||
{showFolderControl ? (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={folderLabel}
|
||||
testID="changes-toggle-folders"
|
||||
style={folderToggleStyle}
|
||||
onPress={onToggleCollapseAllFolders}
|
||||
>
|
||||
<ThemedFolderTree
|
||||
size={isMobile ? 18 : 14}
|
||||
uniProps={
|
||||
allFoldersCollapsed ? foregroundIconColorMapping : foregroundMutedIconColorMapping
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>{folderLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
|
||||
@@ -1348,8 +1423,13 @@ function DiffFilesToolbar({
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
|
||||
{allExpanded ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={expandAllLabel}
|
||||
style={expandAllToggleStyle}
|
||||
onPress={onToggleExpandAll}
|
||||
>
|
||||
{allFileDiffsExpanded ? (
|
||||
<ThemedListChevronsDownUp
|
||||
size={isMobile ? 18 : 14}
|
||||
uniProps={foregroundMutedIconColorMapping}
|
||||
@@ -1414,9 +1494,6 @@ function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshBu
|
||||
);
|
||||
}
|
||||
|
||||
type DiffFlatItem =
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
|
||||
|
||||
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
|
||||
@@ -1749,6 +1826,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
|
||||
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||
|
||||
const folderToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||
|
||||
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||
|
||||
const toast = useToast();
|
||||
@@ -1892,6 +1971,29 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
(state) => state.setDiffExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
|
||||
// The Changes view groups files into a directory tree on every form factor,
|
||||
// consistent with the Files explorer (which is also a tree on mobile).
|
||||
const collapsedFoldersArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
|
||||
(state) => state.setDiffCollapsedFoldersForWorkspace,
|
||||
);
|
||||
// Build the directory tree once per files-change; collapse/expand toggles only
|
||||
// re-flatten it (they don't change tree shape).
|
||||
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
||||
// Every directory path currently in the tree — used by "collapse all folders" and to
|
||||
// filter stale collapse state.
|
||||
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
||||
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
||||
// Effective collapsed set: intersect the persisted paths with the folders actually
|
||||
// present, purely at render (no store-syncing effect). A folder that left the diff and
|
||||
// reappears defaults to expanded; toggles write back this pruned set, so the stored
|
||||
// array stays bounded. (empty = all folders expanded, the default)
|
||||
const collapsedFolders = useMemo(
|
||||
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
|
||||
[collapsedFoldersArray, allFolderPathSet],
|
||||
);
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -1900,26 +2002,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
|
||||
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
|
||||
// (Codex item 6) so file/folder heights can't collide by path.
|
||||
const folderRowHeightRef = useRef<number>(0);
|
||||
const defaultHeaderHeightRef = useRef<number>(44);
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
|
||||
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
|
||||
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
||||
const items: DiffFlatItem[] = [];
|
||||
const stickyIndices: number[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isExpanded = expandedPaths.has(file.path);
|
||||
items.push({ type: "header", file, fileIndex: i, isExpanded });
|
||||
if (isExpanded) {
|
||||
stickyIndices.push(items.length - 1);
|
||||
}
|
||||
if (isExpanded) {
|
||||
items.push({ type: "body", file, fileIndex: i });
|
||||
}
|
||||
}
|
||||
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
|
||||
files,
|
||||
tree: compressedTree,
|
||||
collapsedFolders,
|
||||
expandedPaths,
|
||||
});
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
}, [expandedPaths, files]);
|
||||
}, [compressedTree, collapsedFolders, expandedPaths, files]);
|
||||
|
||||
const getBodyHeightKey = useCallback(
|
||||
(file: ParsedDiffFile): string => {
|
||||
@@ -1958,6 +2056,35 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
|
||||
);
|
||||
|
||||
// Single height source of truth for both getItemLayout and the collapse
|
||||
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
|
||||
// falling back to the default header height before first measurement.
|
||||
const getFlatItemHeight = useCallback(
|
||||
(item: DiffFlatItem): number => {
|
||||
if (item.type === "folder") {
|
||||
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
||||
}
|
||||
if (item.type === "header") {
|
||||
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
|
||||
}
|
||||
const bodyHeightKey = getBodyHeightKey(item.file);
|
||||
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
|
||||
},
|
||||
[estimateBodyHeight, getBodyHeightKey],
|
||||
);
|
||||
|
||||
const handleFolderRowHeightChange = useCallback((height: number) => {
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
const previousHeight = folderRowHeightRef.current;
|
||||
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
|
||||
return;
|
||||
}
|
||||
folderRowHeightRef.current = height;
|
||||
setHeightVersion((version) => version + 1);
|
||||
}, []);
|
||||
|
||||
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
@@ -2013,23 +2140,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
[scrollbar],
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
(path: string): number => {
|
||||
const defaultHeaderHeight = defaultHeaderHeightRef.current;
|
||||
let offset = 0;
|
||||
for (const file of files) {
|
||||
if (file.path === path) {
|
||||
break;
|
||||
}
|
||||
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
||||
if (expandedPaths.has(file.path)) {
|
||||
const bodyHeightKey = getBodyHeightKey(file);
|
||||
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
|
||||
}
|
||||
// Offset of the first item matching `predicate`, walking the SAME flatItems
|
||||
// list getFlatItemLayout uses so folder rows are counted (single source of
|
||||
// truth — Codex item 5 / finding 2).
|
||||
const computeItemOffset = useCallback(
|
||||
(predicate: (item: DiffFlatItem) => boolean): number | null => {
|
||||
const index = flatItems.findIndex(predicate);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, offset);
|
||||
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
|
||||
},
|
||||
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
|
||||
[flatItems, getFlatItemHeight],
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
(path: string): number =>
|
||||
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
|
||||
[computeItemOffset],
|
||||
);
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
@@ -2067,7 +2195,41 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
||||
);
|
||||
|
||||
const allExpanded = useMemo(() => {
|
||||
const handleToggleFolder = useCallback(
|
||||
(dirPath: string) => {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
|
||||
// Collapsing hides the subtree below this row; anchor to the folder row
|
||||
// first so the viewport doesn't jump to a dead offset (Codex item 5).
|
||||
if (!isCurrentlyCollapsed) {
|
||||
const targetOffset = computeItemOffset(
|
||||
(item) => item.type === "folder" && item.dirPath === dirPath,
|
||||
);
|
||||
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
||||
if (
|
||||
targetOffset !== null &&
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: targetOffset,
|
||||
headerHeight: folderHeight,
|
||||
viewportOffset: diffListScrollOffsetRef.current,
|
||||
viewportHeight: diffListViewportHeightRef.current,
|
||||
})
|
||||
) {
|
||||
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
|
||||
}
|
||||
}
|
||||
|
||||
const nextCollapsed = isCurrentlyCollapsed
|
||||
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
|
||||
: [...collapsedFolders, dirPath];
|
||||
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
|
||||
},
|
||||
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
|
||||
);
|
||||
|
||||
const allFileDiffsExpanded = useMemo(() => {
|
||||
if (files.length === 0) return false;
|
||||
return files.every((file) => expandedPaths.has(file.path));
|
||||
}, [expandedPaths, files]);
|
||||
@@ -2076,7 +2238,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
if (allExpanded) {
|
||||
if (allFileDiffsExpanded) {
|
||||
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
||||
} else {
|
||||
setDiffExpandedPathsForWorkspace(
|
||||
@@ -2084,15 +2246,51 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
files.map((file) => file.path),
|
||||
);
|
||||
}
|
||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||
|
||||
// Membership, not count: collapsedFolders is already filtered to present folders, so
|
||||
// "all collapsed" means every present folder path is in it.
|
||||
const allFoldersCollapsed =
|
||||
allFolderPaths.length > 0 && allFolderPaths.every((path) => collapsedFolders.has(path));
|
||||
|
||||
const handleToggleCollapseAllFolders = useCallback(() => {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
// Anchor to the top before collapsing everything so the viewport isn't stranded.
|
||||
if (!allFoldersCollapsed) {
|
||||
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}
|
||||
setDiffCollapsedFoldersForWorkspace(
|
||||
workspaceStateKey,
|
||||
allFoldersCollapsed ? [] : allFolderPaths,
|
||||
);
|
||||
}, [allFolderPaths, allFoldersCollapsed, setDiffCollapsedFoldersForWorkspace, workspaceStateKey]);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
if (item.type === "folder") {
|
||||
return (
|
||||
<DiffFolderRow
|
||||
dirPath={item.dirPath}
|
||||
displayName={item.displayName}
|
||||
depth={item.depth}
|
||||
collapsed={item.collapsed}
|
||||
additions={item.additions}
|
||||
deletions={item.deletions}
|
||||
onToggle={handleToggleFolder}
|
||||
onHeightChange={handleFolderRowHeightChange}
|
||||
testID={`diff-folder-${item.dirPath}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<DiffFileHeader
|
||||
file={item.file}
|
||||
isExpanded={item.isExpanded}
|
||||
depth={item.depth}
|
||||
showDir={false}
|
||||
onToggle={handleToggleExpanded}
|
||||
onHeaderHeightChange={handleHeaderHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}`}
|
||||
@@ -2117,40 +2315,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
diffTextMetricsStyle,
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleFolderRowHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleToggleExpanded,
|
||||
handleToggleFolder,
|
||||
reviewActions,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
|
||||
const flatKeyExtractor = useCallback(
|
||||
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
|
||||
(item: DiffFlatItem) =>
|
||||
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const getFlatItemHeight = useCallback(
|
||||
(item: DiffFlatItem): number => {
|
||||
if (item.type === "header") {
|
||||
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
|
||||
}
|
||||
|
||||
const bodyHeightKey = getBodyHeightKey(item.file);
|
||||
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
|
||||
},
|
||||
[estimateBodyHeight, getBodyHeightKey],
|
||||
);
|
||||
|
||||
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
|
||||
(_data, index) => {
|
||||
let offset = 0;
|
||||
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
|
||||
const item = flatItems[itemIndex];
|
||||
if (item) {
|
||||
offset += getFlatItemHeight(item);
|
||||
}
|
||||
}
|
||||
|
||||
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
|
||||
const item = flatItems[index];
|
||||
const length = item ? getFlatItemHeight(item) : 0;
|
||||
return { length, offset, index };
|
||||
@@ -2161,6 +2343,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
const flatExtraData = useMemo(
|
||||
() => ({
|
||||
expandedPathsArray,
|
||||
collapsedFoldersArray,
|
||||
effectiveLayout,
|
||||
diffBodyTypographyKey,
|
||||
heightVersion,
|
||||
@@ -2169,6 +2352,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
}),
|
||||
[
|
||||
expandedPathsArray,
|
||||
collapsedFoldersArray,
|
||||
effectiveLayout,
|
||||
diffBodyTypographyKey,
|
||||
heightVersion,
|
||||
@@ -2318,12 +2502,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
{files.length > 0 ? (
|
||||
<DiffFilesToolbar
|
||||
wrapLines={wrapLines}
|
||||
allExpanded={allExpanded}
|
||||
allFileDiffsExpanded={allFileDiffsExpanded}
|
||||
showFolderControl={allFolderPaths.length > 0}
|
||||
allFoldersCollapsed={allFoldersCollapsed}
|
||||
isMobile={isMobile}
|
||||
wrapLinesToggleStyle={wrapLinesToggleStyle}
|
||||
expandAllToggleStyle={expandAllToggleStyle}
|
||||
folderToggleStyle={folderToggleStyle}
|
||||
onToggleWrapLines={handleToggleWrapLines}
|
||||
onToggleExpandAll={handleToggleExpandAll}
|
||||
onToggleCollapseAllFolders={handleToggleCollapseAllFolders}
|
||||
/>
|
||||
) : null}
|
||||
{refreshSupported ? (
|
||||
@@ -2574,6 +2762,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
fileIcon: {
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
fileName: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
@@ -2588,6 +2781,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
fileDirSpacer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
newBadge: {
|
||||
backgroundColor: "rgba(46, 160, 67, 0.2)",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
|
||||
Reference in New Issue
Block a user