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 {
ActivityIndicator,
@@ -118,7 +118,6 @@ export function FileExplorerPane({
);
const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing,
requestFileDownloadToken,
selectExplorerEntry,
@@ -129,6 +128,16 @@ export function FileExplorerPane({
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
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 pendingRequest = explorerState?.pendingRequest ?? null;
@@ -144,7 +153,6 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null);
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
@@ -154,8 +162,7 @@ export function FileExplorerPane({
useEffect(() => {
hasInitializedRef.current = false;
setExpandedPaths(new Set(["."]));
}, [actionsWorkspaceStateKey]);
}, [workspaceStateKey]);
useEffect(() => {
if (!hasWorkspaceScope) {
@@ -169,23 +176,35 @@ export function FileExplorerPane({
recordHistory: 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)
useEffect(() => {
if (!selectedEntryPath || !hasWorkspaceScope) {
if (!selectedEntryPath || !workspaceStateKey) {
return;
}
const parentDir = getParentDirectory(selectedEntryPath);
const ancestors = getAncestorDirectories(parentDir);
setExpandedPaths((prev) => {
const next = new Set(prev);
ancestors.forEach((path) => next.add(path));
return next;
});
ancestors.forEach((path) => {
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
if (newPaths.length === 0) {
return;
}
setExpandedPathsForWorkspace(
workspaceStateKey,
[...Array.from(expandedPaths), ...newPaths],
);
newPaths.forEach((path) => {
if (!directories.has(path)) {
void requestDirectoryListing(path, {
recordHistory: false,
@@ -193,34 +212,46 @@ export function FileExplorerPane({
});
}
});
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
}, [
directories,
workspaceStateKey,
expandedPaths,
requestDirectoryListing,
selectedEntryPath,
setExpandedPathsForWorkspace,
]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => {
if (!hasWorkspaceScope) {
if (!workspaceStateKey) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
const nextExpanded = !isExpanded;
setExpandedPaths((prev) => {
const next = new Set(prev);
if (isExpanded) {
next.delete(entry.path);
} else {
next.add(entry.path);
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
} else {
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(
+48 -25
View File
@@ -72,6 +72,8 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
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";
@@ -541,7 +543,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
});
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
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 scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
@@ -601,7 +621,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; 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 });
if (isExpanded) {
stickyIndices.push(items.length - 1);
@@ -611,7 +631,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [files, expandedByPath]);
}, [expandedPaths, files]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
@@ -657,18 +677,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedByPath[file.path]) {
if (expandedPaths.has(file.path)) {
offset += bodyHeightByPathRef.current[file.path] ?? 0;
}
}
return Math.max(0, offset);
},
[expandedByPath, files],
[expandedPaths, files],
);
const handleToggleExpanded = useCallback(
(path: string) => {
const isCurrentlyExpanded = expandedByPath[path] ?? false;
if (!workspaceStateKey) {
return;
}
const isCurrentlyExpanded = expandedPaths.has(path);
const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
@@ -690,32 +713,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
});
}
setExpandedByPath((prev) => ({
...prev,
// Use a deterministic target value (instead of toggling from prev) so duplicate
// onPress events from sticky headers on Android can't flip back immediately.
[path]: nextExpanded,
}));
const nextPaths = nextExpanded
? [...expandedPaths, path]
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
},
[computeHeaderOffset, expandedByPath],
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedByPath[file.path]);
}, [files, expandedByPath]);
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
const handleToggleExpandAll = useCallback(() => {
if (allExpanded) {
setExpandedByPath({});
} else {
const newExpanded: Record<string, boolean> = {};
for (const file of files) {
newExpanded[file.path] = true;
}
setExpandedByPath(newExpanded);
if (!workspaceStateKey) {
return;
}
}, [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
useEffect(() => {
@@ -961,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor}
stickyHeaderIndices={stickyHeaderIndices}
extraData={{ expandedByPath, effectiveLayout, wrapLines }}
extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
+36 -1
View File
@@ -66,6 +66,8 @@ interface PanelState {
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
activeExplorerCheckout: ExplorerCheckoutContext | null;
sidebarWidth: number;
explorerWidth: number;
@@ -85,6 +87,8 @@ interface PanelState {
// File explorer settings actions
setExplorerTab: (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;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setSidebarWidth: (width: number) => void;
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
activeExplorerCheckout: null,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
}
return nextState;
}),
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
})),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {
...state.diffExpandedPathsByWorkspace,
[workspaceKey]: paths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
activeExplorerCheckout: checkout,
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 8,
version: 10,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
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;
return state as PanelState;
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,