From 7b1144dafe70500e071b8983eff5ff7d89f49f24 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 11:40:37 +0700 Subject: [PATCH] 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. --- .../app/src/components/file-explorer-pane.tsx | 99 ++++++++++++------- packages/app/src/components/git-diff-pane.tsx | 73 +++++++++----- packages/app/src/stores/panel-store.ts | 37 ++++++- 3 files changed, 149 insertions(+), 60 deletions(-) diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 08a6dd284..44761930d 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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>(() => new Set(["."])); const treeListRef = useRef>(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( diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 6c27ee1bb..e790af7f2 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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>({}); + 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>(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 = {}; - 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" diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 9799026fb..cf1551d2e 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -66,6 +66,8 @@ interface PanelState { // File explorer settings (shared between mobile/desktop) explorerTab: ExplorerTab; explorerTabByCheckout: Record; + expandedPathsByWorkspace: Record; + diffExpandedPathsByWorkspace: Record; 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()( // 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()( } 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()( }), { name: "panel-state", - version: 8, + version: 10, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => { const state = persistedState as Partial & Record; @@ -371,6 +388,22 @@ export const usePanelStore = create()( 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()( desktop: state.desktop, explorerTab: state.explorerTab, explorerTabByCheckout: state.explorerTabByCheckout, + expandedPathsByWorkspace: state.expandedPathsByWorkspace, + diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace, sidebarWidth: state.sidebarWidth, explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption,