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:
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user