* feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
352 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|