Prevent restored file trees from crashing desktop startup (#2595)
* fix(explorer): prevent persisted trees from crashing startup Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack. * fix(explorer): preserve expansion changes during restore Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten. * fix(explorer): show hidden files before restoration * fix(explorer): apply directory toggles atomically
This commit is contained in:
@@ -24,16 +24,28 @@ import {
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerDirectory,
|
||||
ExplorerEntry,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import {
|
||||
flattenExplorerTree,
|
||||
reconcileRestoredExpandedPaths,
|
||||
restoreExpandedDirectories,
|
||||
setExpandedDirectoryPath,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
type ExplorerTreeRow,
|
||||
} from "@/file-explorer/tree";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
@@ -83,7 +95,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
||||
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
|
||||
}
|
||||
|
||||
function treeRowKeyExtractor(row: TreeRow) {
|
||||
function treeRowKeyExtractor(row: ExplorerTreeRow) {
|
||||
return row.entry.path;
|
||||
}
|
||||
|
||||
@@ -199,11 +211,6 @@ interface FileExplorerPaneProps {
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function FileExplorerPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -263,7 +270,7 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest],
|
||||
);
|
||||
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -276,9 +283,19 @@ export function FileExplorerPane({
|
||||
hasWorkspaceScope,
|
||||
hasInitializedRef,
|
||||
workspaceStateKey,
|
||||
persistedExpandedPaths: expandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
}, [
|
||||
expandedPaths,
|
||||
hasWorkspaceScope,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
showHiddenFiles,
|
||||
workspaceStateKey,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) =>
|
||||
@@ -351,11 +368,42 @@ export function FileExplorerPane({
|
||||
|
||||
const handleToggleHiddenFiles = useCallback(() => {
|
||||
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
|
||||
toggleExplorerShowHiddenFiles();
|
||||
if (willShow) {
|
||||
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
|
||||
if (!willShow) {
|
||||
toggleExplorerShowHiddenFiles();
|
||||
return;
|
||||
}
|
||||
}, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]);
|
||||
const rootDirectory = directories.get(".");
|
||||
if (!rootDirectory || !workspaceStateKey) {
|
||||
toggleExplorerShowHiddenFiles();
|
||||
return;
|
||||
}
|
||||
void showHiddenFilesAndRestoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths: expandedPaths,
|
||||
showHiddenFiles: toggleExplorerShowHiddenFiles,
|
||||
requestDirectoryListing: (path) =>
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
}),
|
||||
}).then((restoredPaths) => {
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths: expandedPaths,
|
||||
currentExpandedPaths: new Set(currentPaths),
|
||||
restoredExpandedPaths: restoredPaths,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}, [
|
||||
directories,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
toggleExplorerShowHiddenFiles,
|
||||
workspaceStateKey,
|
||||
]);
|
||||
|
||||
const refreshExplorer = useCallback(
|
||||
() =>
|
||||
@@ -387,7 +435,7 @@ export function FileExplorerPane({
|
||||
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
|
||||
|
||||
const treeRows = useMemo(
|
||||
() => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }),
|
||||
() => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }),
|
||||
[directories, expandedPaths, showHiddenFiles, sortOption],
|
||||
);
|
||||
|
||||
@@ -400,7 +448,7 @@ export function FileExplorerPane({
|
||||
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
|
||||
|
||||
const renderTreeRow = useCallback(
|
||||
(info: ListRenderItemInfo<TreeRow>) => (
|
||||
(info: ListRenderItemInfo<ExplorerTreeRow>) => (
|
||||
<TreeRowDispatcher
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
@@ -480,11 +528,11 @@ interface FileExplorerPaneContentProps {
|
||||
error: string | null;
|
||||
showInitialLoading: boolean;
|
||||
showBackFromError: boolean;
|
||||
treeRows: TreeRow[];
|
||||
treeRows: ExplorerTreeRow[];
|
||||
currentSortLabel: string;
|
||||
isRefreshFetching: boolean;
|
||||
treeListRef: RefObject<FlatList<TreeRow> | null>;
|
||||
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
|
||||
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
|
||||
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
|
||||
handleSortCycle: () => void;
|
||||
handleToggleHiddenFiles: () => void;
|
||||
handleRefresh: () => void;
|
||||
@@ -637,71 +685,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||
const sorted = [...entries];
|
||||
sorted.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === "directory" ? -1 : 1;
|
||||
}
|
||||
switch (sortOption) {
|
||||
case "name":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "modified":
|
||||
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
|
||||
case "size":
|
||||
return b.size - a.size;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function buildTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
path,
|
||||
depth,
|
||||
}: {
|
||||
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
||||
expandedPaths: Set<string>;
|
||||
sortOption: SortOption;
|
||||
showHiddenFiles: boolean;
|
||||
path: string;
|
||||
depth: number;
|
||||
}): TreeRow[] {
|
||||
const directory = directories.get(path);
|
||||
if (!directory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: TreeRow[] = [];
|
||||
const entries = sortEntries(
|
||||
filterVisibleExplorerEntries(directory.entries, showHiddenFiles),
|
||||
sortOption,
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
rows.push({ entry, depth });
|
||||
if (entry.kind === "directory" && expandedPaths.has(entry.path)) {
|
||||
rows.push(
|
||||
...buildTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
path: entry.path,
|
||||
depth: depth + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function deriveExplorerFields(state: AgentFileExplorerState | undefined) {
|
||||
return {
|
||||
directories:
|
||||
@@ -751,30 +734,6 @@ function resolveCurrentSortLabel(
|
||||
return labels[sortOption] ?? labels.name;
|
||||
}
|
||||
|
||||
function resolveTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
}: {
|
||||
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
||||
expandedPaths: Set<string>;
|
||||
sortOption: SortOption;
|
||||
showHiddenFiles: boolean;
|
||||
}): TreeRow[] {
|
||||
if (!directories.get(".")) {
|
||||
return [];
|
||||
}
|
||||
return buildTreeRows({
|
||||
directories,
|
||||
expandedPaths,
|
||||
sortOption,
|
||||
showHiddenFiles,
|
||||
path: ".",
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDirectory({
|
||||
entry,
|
||||
workspaceStateKey,
|
||||
@@ -786,26 +745,25 @@ function toggleDirectory({
|
||||
entry: ExplorerEntry;
|
||||
workspaceStateKey: string | null;
|
||||
expandedPaths: Set<string>;
|
||||
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
||||
directories: Map<string, ExplorerDirectory>;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): void {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
||||
if (!directories.has(entry.path)) {
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
setExpandedDirectoryPath({
|
||||
currentExpandedPaths: currentPaths,
|
||||
directoryPath: entry.path,
|
||||
expanded: !isExpanded,
|
||||
}),
|
||||
);
|
||||
if (!isExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
@@ -827,7 +785,7 @@ function TreeRowDispatcher({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
info: ListRenderItemInfo<TreeRow>;
|
||||
info: ListRenderItemInfo<ExplorerTreeRow>;
|
||||
expandedPaths: Set<string>;
|
||||
selectedEntryPath: string | null;
|
||||
isDirectoryLoading: (path: string) => boolean;
|
||||
@@ -865,54 +823,59 @@ async function initializeExplorer({
|
||||
hasWorkspaceScope,
|
||||
hasInitializedRef,
|
||||
workspaceStateKey,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
}: {
|
||||
hasWorkspaceScope: boolean;
|
||||
hasInitializedRef: RefObject<boolean>;
|
||||
workspaceStateKey: string | null;
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): Promise<void> {
|
||||
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
const succeeded = await requestDirectoryListing(".", {
|
||||
const rootDirectory = await requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
if (!succeeded) {
|
||||
if (!rootDirectory) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
}
|
||||
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
|
||||
}
|
||||
|
||||
function requestPersistedExpandedPaths({
|
||||
workspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
}: {
|
||||
workspaceStateKey: string | null;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
}): void {
|
||||
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (!persistedPaths) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) {
|
||||
void requestDirectoryListing(path, {
|
||||
|
||||
const restoredPaths = await restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing: (path) =>
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
const hiddenPersistedPaths = showHiddenFiles
|
||||
? []
|
||||
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
||||
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths,
|
||||
currentExpandedPaths: new Set(currentPaths),
|
||||
restoredExpandedPaths: restoredPathsWithHidden,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshExplorerDirectories({
|
||||
@@ -925,7 +888,7 @@ async function refreshExplorerDirectories({
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<boolean>;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
}): Promise<null> {
|
||||
if (!hasWorkspaceScope) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user