Files
paseo/packages/app/src/hooks/use-file-explorer-actions.ts
T
Mohamed BoudraandGitHub 68a215d40c 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
2026-07-29 22:10:51 +08:00

273 lines
8.0 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
function createExplorerState(): AgentFileExplorerState {
return {
directories: new Map(),
files: new Map(),
isLoading: false,
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
selectedEntryPath: null,
};
}
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
const last = normalizedHistory[normalizedHistory.length - 1];
if (last === path) {
return normalizedHistory;
}
return [...normalizedHistory, path];
}
export interface FileExplorerWorkspaceScope {
workspaceId?: string | null;
workspaceRoot?: string | null;
}
function normalizeWorkspaceValue(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope): string | null {
const normalizedWorkspaceId = normalizeWorkspaceValue(scope.workspaceId);
if (normalizedWorkspaceId) {
return `workspace:${normalizedWorkspaceId}`;
}
const normalizedWorkspaceRoot = normalizeWorkspaceValue(scope.workspaceRoot);
if (!normalizedWorkspaceRoot) {
return null;
}
return `root:${normalizedWorkspaceRoot}`;
}
export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) {
const { t } = useTranslation();
const { serverId, workspaceId, workspaceRoot } = params;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspaceValue(workspaceRoot),
[workspaceRoot],
);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[workspaceId, normalizedWorkspaceRoot],
);
const updateExplorerState = useCallback(
(updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
if (!workspaceStateKey) {
return;
}
setFileExplorer(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(workspaceStateKey) ?? createExplorerState();
next.set(workspaceStateKey, updater(current));
return next;
});
},
[serverId, setFileExplorer, workspaceStateKey],
);
const requestDirectoryListing = useCallback(
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<ExplorerDirectory | null> => {
if (!workspaceStateKey) {
return null;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
updateExplorerState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: normalizedPath,
history: shouldRecordHistory
? pushHistory(state.history, normalizedPath)
: state.history,
lastVisitedPath: normalizedPath,
}
: {}),
}));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return null;
}
if (!client) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return null;
}
try {
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
const directories = new Map(state.directories);
directories.set(directory.path, directory);
nextState.directories = directories;
return nextState;
});
return directory;
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError:
error instanceof Error
? error.message
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return null;
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const requestFilePreview = useCallback(
async (path: string) => {
if (!workspaceStateKey) {
return;
}
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "file" },
}));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return;
}
if (!client) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return;
}
try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
const files = new Map(state.files);
const explorerFile = explorerFileFromReadResult(file);
files.set(explorerFile.path, explorerFile);
nextState.files = files;
return nextState;
});
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : t("panels.file.failedToLoadPreview"),
pendingRequest: null,
}));
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const requestFileDownloadToken = useCallback(
async (path: string) => {
if (!normalizedWorkspaceRoot) {
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path);
if (payload.error) {
throw new Error(payload.error);
}
return payload;
},
[client, normalizedWorkspaceRoot, t],
);
const selectExplorerEntry = useCallback(
(path: string | null) => {
updateExplorerState((state) => ({
...state,
selectedEntryPath: path,
}));
},
[updateExplorerState],
);
return {
workspaceStateKey,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
selectExplorerEntry,
};
}