Files
paseo/packages/app/src/hooks/use-file-explorer-actions.ts
T
ca2b633286 feat: add file manager context actions (#3027)
* feat(app): add file manager context actions

* fix: reshape file context actions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-10 18:18:23 +02:00

352 lines
10 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";
import { parentExplorerPath } from "@/utils/explorer-paths";
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 createEntry = useCallback(
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
if (!client || !normalizedWorkspaceRoot) {
return null;
}
const payload = await client.createFileEntry({
cwd: normalizedWorkspaceRoot,
...input,
});
if (payload.success) {
await requestDirectoryListing(input.parentPath, {
recordHistory: false,
setCurrentPath: false,
});
}
return payload;
},
[client, normalizedWorkspaceRoot, requestDirectoryListing],
);
const renameEntry = useCallback(
async (input: { path: string; name: string }) => {
if (!client || !normalizedWorkspaceRoot) {
return null;
}
const payload = await client.renameFileEntry({
cwd: normalizedWorkspaceRoot,
...input,
});
if (payload.success) {
await requestDirectoryListing(parentExplorerPath(input.path), {
recordHistory: false,
setCurrentPath: false,
});
}
return payload;
},
[client, normalizedWorkspaceRoot, requestDirectoryListing],
);
const duplicateEntry = useCallback(
async (path: string) => {
if (!client || !normalizedWorkspaceRoot) {
return null;
}
const payload = await client.duplicateFileEntry({ cwd: normalizedWorkspaceRoot, path });
if (payload.success) {
await requestDirectoryListing(parentExplorerPath(path), {
recordHistory: false,
setCurrentPath: false,
});
}
return payload;
},
[client, normalizedWorkspaceRoot, requestDirectoryListing],
);
const deleteEntry = useCallback(
async (path: string) => {
if (!client || !normalizedWorkspaceRoot) {
return null;
}
const payload = await client.deleteFileEntry({ cwd: normalizedWorkspaceRoot, path });
if (payload.success) {
await requestDirectoryListing(parentExplorerPath(path), {
recordHistory: false,
setCurrentPath: false,
});
}
return payload;
},
[client, normalizedWorkspaceRoot, requestDirectoryListing],
);
const selectExplorerEntry = useCallback(
(path: string | null) => {
updateExplorerState((state) => ({
...state,
selectedEntryPath: path,
}));
},
[updateExplorerState],
);
return {
workspaceStateKey,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
createEntry,
renameEntry,
duplicateEntry,
deleteEntry,
selectExplorerEntry,
};
}