feat(app): persist expanded state in file explorer and diff panes

Remember which directories and diff entries are expanded across sidebar
close/reopen cycles. State is stored per-workspace in the panel store
(zustand + AsyncStorage) with no local React state copy, following the
proven explorerTabByCheckout pattern. On desktop remount, expanded
subdirectory listings are re-fetched so the tree renders fully.
This commit is contained in:
Mohamed Boudra
2026-04-07 11:40:37 +07:00
parent 940bc6243b
commit 7b1144dafe
3 changed files with 149 additions and 60 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -118,7 +118,6 @@ export function FileExplorerPane({
); );
const { const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing, requestDirectoryListing,
requestFileDownloadToken, requestFileDownloadToken,
selectExplorerEntry, selectExplorerEntry,
@@ -129,6 +128,16 @@ export function FileExplorerPane({
}); });
const sortOption = usePanelStore((state) => state.explorerSortOption); const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setExpandedPathsForWorkspace = usePanelStore(
(state) => state.setExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
[expandedPathsArray],
);
const directories = explorerState?.directories ?? new Map(); const directories = explorerState?.directories ?? new Map();
const pendingRequest = explorerState?.pendingRequest ?? null; const pendingRequest = explorerState?.pendingRequest ?? null;
@@ -144,7 +153,6 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path], [isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
); );
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null); const treeListRef = useRef<FlatList<TreeRow>>(null);
const scrollbar = useWebScrollViewScrollbar(treeListRef, { const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar, enabled: showDesktopWebScrollbar,
@@ -154,8 +162,7 @@ export function FileExplorerPane({
useEffect(() => { useEffect(() => {
hasInitializedRef.current = false; hasInitializedRef.current = false;
setExpandedPaths(new Set(["."])); }, [workspaceStateKey]);
}, [actionsWorkspaceStateKey]);
useEffect(() => { useEffect(() => {
if (!hasWorkspaceScope) { if (!hasWorkspaceScope) {
@@ -169,23 +176,35 @@ export function FileExplorerPane({
recordHistory: false, recordHistory: false,
setCurrentPath: false, setCurrentPath: false,
}); });
}, [hasWorkspaceScope, requestDirectoryListing]); const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click) // Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => { useEffect(() => {
if (!selectedEntryPath || !hasWorkspaceScope) { if (!selectedEntryPath || !workspaceStateKey) {
return; return;
} }
const parentDir = getParentDirectory(selectedEntryPath); const parentDir = getParentDirectory(selectedEntryPath);
const ancestors = getAncestorDirectories(parentDir); const ancestors = getAncestorDirectories(parentDir);
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
setExpandedPaths((prev) => { if (newPaths.length === 0) {
const next = new Set(prev); return;
ancestors.forEach((path) => next.add(path)); }
return next; setExpandedPathsForWorkspace(
}); workspaceStateKey,
[...Array.from(expandedPaths), ...newPaths],
ancestors.forEach((path) => { );
newPaths.forEach((path) => {
if (!directories.has(path)) { if (!directories.has(path)) {
void requestDirectoryListing(path, { void requestDirectoryListing(path, {
recordHistory: false, recordHistory: false,
@@ -193,34 +212,46 @@ export function FileExplorerPane({
}); });
} }
}); });
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]); }, [
directories,
workspaceStateKey,
expandedPaths,
requestDirectoryListing,
selectedEntryPath,
setExpandedPathsForWorkspace,
]);
const handleToggleDirectory = useCallback( const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => { (entry: ExplorerEntry) => {
if (!hasWorkspaceScope) { if (!workspaceStateKey) {
return; return;
} }
const isExpanded = expandedPaths.has(entry.path); const isExpanded = expandedPaths.has(entry.path);
const nextExpanded = !isExpanded; if (isExpanded) {
setExpandedPaths((prev) => { setExpandedPathsForWorkspace(
const next = new Set(prev); workspaceStateKey,
if (isExpanded) { Array.from(expandedPaths).filter((path) => path !== entry.path),
next.delete(entry.path); );
} else { } else {
next.add(entry.path); setExpandedPathsForWorkspace(
workspaceStateKey,
[...Array.from(expandedPaths), entry.path],
);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
} }
return next;
});
if (nextExpanded && !directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
} }
}, },
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing], [
workspaceStateKey,
expandedPaths,
directories,
requestDirectoryListing,
setExpandedPathsForWorkspace,
],
); );
const handleOpenFile = useCallback( const handleOpenFile = useCallback(
+48 -25
View File
@@ -72,6 +72,8 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -541,7 +543,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}); });
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({}); const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId],
);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setDiffExpandedPathsForWorkspace = usePanelStore(
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray ?? []),
[expandedPathsArray],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null); const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const scrollbar = useWebScrollViewScrollbar(diffListRef, { const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar, enabled: showDesktopWebScrollbar,
@@ -601,7 +621,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const stickyIndices: number[] = []; const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const isExpanded = expandedByPath[file.path] ?? false; const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded }); items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) { if (isExpanded) {
stickyIndices.push(items.length - 1); stickyIndices.push(items.length - 1);
@@ -611,7 +631,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
} }
} }
return { flatItems: items, stickyHeaderIndices: stickyIndices }; return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [files, expandedByPath]); }, [expandedPaths, files]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => { const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) { if (!Number.isFinite(height) || height <= 0) {
@@ -657,18 +677,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
break; break;
} }
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight; offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedByPath[file.path]) { if (expandedPaths.has(file.path)) {
offset += bodyHeightByPathRef.current[file.path] ?? 0; offset += bodyHeightByPathRef.current[file.path] ?? 0;
} }
} }
return Math.max(0, offset); return Math.max(0, offset);
}, },
[expandedByPath, files], [expandedPaths, files],
); );
const handleToggleExpanded = useCallback( const handleToggleExpanded = useCallback(
(path: string) => { (path: string) => {
const isCurrentlyExpanded = expandedByPath[path] ?? false; if (!workspaceStateKey) {
return;
}
const isCurrentlyExpanded = expandedPaths.has(path);
const nextExpanded = !isCurrentlyExpanded; const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
@@ -690,32 +713,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}); });
} }
setExpandedByPath((prev) => ({ const nextPaths = nextExpanded
...prev, ? [...expandedPaths, path]
// Use a deterministic target value (instead of toggling from prev) so duplicate : Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
// onPress events from sticky headers on Android can't flip back immediately. setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
[path]: nextExpanded,
}));
}, },
[computeHeaderOffset, expandedByPath], [computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
); );
const allExpanded = useMemo(() => { const allExpanded = useMemo(() => {
if (files.length === 0) return false; if (files.length === 0) return false;
return files.every((file) => expandedByPath[file.path]); return files.every((file) => expandedPaths.has(file.path));
}, [files, expandedByPath]); }, [expandedPaths, files]);
const handleToggleExpandAll = useCallback(() => { const handleToggleExpandAll = useCallback(() => {
if (allExpanded) { if (!workspaceStateKey) {
setExpandedByPath({}); return;
} else {
const newExpanded: Record<string, boolean> = {};
for (const file of files) {
newExpanded[file.path] = true;
}
setExpandedByPath(newExpanded);
} }
}, [allExpanded, files]); if (allExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
workspaceStateKey,
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Reset manual refresh flag when fetch completes // Reset manual refresh flag when fetch completes
useEffect(() => { useEffect(() => {
@@ -961,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
renderItem={renderFlatItem} renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor} keyExtractor={flatKeyExtractor}
stickyHeaderIndices={stickyHeaderIndices} stickyHeaderIndices={stickyHeaderIndices}
extraData={{ expandedByPath, effectiveLayout, wrapLines }} extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll" testID="git-diff-scroll"
+36 -1
View File
@@ -66,6 +66,8 @@ interface PanelState {
// File explorer settings (shared between mobile/desktop) // File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab; explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>; explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
activeExplorerCheckout: ExplorerCheckoutContext | null; activeExplorerCheckout: ExplorerCheckoutContext | null;
sidebarWidth: number; sidebarWidth: number;
explorerWidth: number; explorerWidth: number;
@@ -85,6 +87,8 @@ interface PanelState {
// File explorer settings actions // File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void; setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void; setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setSidebarWidth: (width: number) => void; setSidebarWidth: (width: number) => void;
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
// File explorer defaults // File explorer defaults
explorerTab: "changes", explorerTab: "changes",
explorerTabByCheckout: {}, explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
activeExplorerCheckout: null, activeExplorerCheckout: null,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH, sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
} }
return nextState; return nextState;
}), }),
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
})),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {
...state.diffExpandedPathsByWorkspace,
[workspaceKey]: paths,
},
})),
activateExplorerTabForCheckout: (checkout) => activateExplorerTabForCheckout: (checkout) =>
set((state) => ({ set((state) => ({
activeExplorerCheckout: checkout, activeExplorerCheckout: checkout,
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
}), }),
{ {
name: "panel-state", name: "panel-state",
version: 8, version: 10,
storage: createJSONStorage(() => AsyncStorage), storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>; const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
} }
if (
version < 9 ||
typeof state.expandedPathsByWorkspace !== "object" ||
!state.expandedPathsByWorkspace
) {
state.expandedPathsByWorkspace = {};
}
if (
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
state.activeExplorerCheckout = null; state.activeExplorerCheckout = null;
return state as PanelState; return state as PanelState;
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
desktop: state.desktop, desktop: state.desktop,
explorerTab: state.explorerTab, explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout, explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
sidebarWidth: state.sidebarWidth, sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth, explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption, explorerSortOption: state.explorerSortOption,