Files
SRCmail/stores/file-store.ts
T

856 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from 'zustand';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
export interface FileResource {
id: string;
name: string;
serverName: string;
isDirectory: boolean;
contentType: string;
contentLength: number;
lastModified: string;
blobId: string | null;
parentId: string | null;
}
interface UploadProgress {
name: string;
loaded: number;
total: number;
current: number;
totalFiles: number;
}
interface ClipboardState {
mode: 'cut' | 'copy';
ids: string[];
names: string[];
serverNames: string[];
sourceParentId: string | null;
sourcePath: string;
}
interface UndoAction {
type: 'rename' | 'move';
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
sourceParentId: string | null;
}
interface FileState {
currentParentId: string | null;
currentPath: string;
pathStack: { id: string | null; name: string }[];
resources: FileResource[];
isLoading: boolean;
error: string | null;
supportsFiles: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
/** Progress of the one-time legacy flat-node migration; null when idle. */
migrationProgress: { current: number; total: number } | null;
client: IJMAPClient | null;
/** Which connected account's files are being browsed. Pro shell only - null in single-account contexts. */
currentAccountId: string | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
recentFiles: { name: string; id: string; timestamp: number }[];
lastAction: UndoAction | null;
// Actions
initClient: (client: IJMAPClient, accountId?: string | null) => void;
/** Detach the current client and reset browse state. Used by the Pro shell to return to the cross-account picker. */
clearClient: () => void;
checkSupport: () => Promise<boolean>;
/**
* One-time upgrade of files created by older Bulwark builds, which encoded
* the folder tree into flat node names with a Unicode separator. Reparents
* those nodes into the real FileNode hierarchy. No-op once migrated.
* Returns true if any node was migrated.
*/
migrateLegacyFlatNodes: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
navigateUp: () => Promise<void>;
refresh: () => Promise<void>;
createDirectory: (name: string) => Promise<void>;
uploadFile: (file: File) => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
uploadFolder: (files: File[]) => Promise<void>;
cancelUpload: () => void;
deleteResource: (name: string) => Promise<void>;
deleteResources: (names: string[]) => Promise<void>;
renameResource: (oldName: string, newName: string) => Promise<void>;
downloadResource: (name: string) => Promise<void>;
downloadResources: (names: string[]) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
createTextFile: (name: string) => Promise<void>;
duplicateResource: (name: string) => Promise<void>;
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
moveToParent: (names: string[]) => Promise<void>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<void>;
selectResource: (name: string | null) => void;
toggleSelect: (name: string) => void;
selectAll: () => void;
clearSelection: () => void;
setSelection: (names: Set<string>) => void;
listPath: (path: string) => Promise<FileResource[]>;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
toggleFavorite: (path: string) => void;
addRecentFile: (name: string, id: string) => void;
undoLastAction: () => Promise<void>;
}
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
// Legacy builds encoded the folder hierarchy into flat node names using a path
// separator. Depending on the build / how the data was created (folder upload,
// WebDAV) this is either a plain "/" or the Unicode DIVISION SLASH (U+2215) that
// older webmail used to dodge Stalwart's "/" rejection. We accept both so the
// one-time migration into the real parentId hierarchy can't miss data (#379).
const LEGACY_PATH_SEPS = ['', '/', '', ''];
function lastLegacySepIndex(name: string): number {
let idx = -1;
for (const sep of LEGACY_PATH_SEPS) {
const i = name.lastIndexOf(sep);
if (i > idx) idx = i;
}
return idx;
}
function isDirectoryType(type: string | undefined): boolean {
if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory');
}
// Direct children of a given parent in the FileNode hierarchy.
function childrenOf(nodes: FileNode[], parentId: string | null): FileNode[] {
return nodes.filter(n => (n.parentId ?? null) === parentId);
}
function nodeToResource(node: FileNode): FileResource {
const isDir = isDirectoryType(node.type);
return {
id: node.id,
name: node.name,
serverName: node.name,
isDirectory: isDir,
contentType: isDir ? '' : node.type,
contentLength: node.size,
lastModified: node.updated || node.created,
blobId: node.blobId,
parentId: node.parentId,
};
}
function sortResources(resources: FileResource[]): FileResource[] {
// Directories first, then alphabetically.
return resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
// Resolve a display path (e.g. "/Documents/Notes") to a FileNode id by walking
// the hierarchy from the root. Returns null for the root, or undefined if any
// segment can't be found.
function resolvePathToId(nodes: FileNode[], path: string): string | null | undefined {
if (path === '/' || path === '') return null;
const segments = path.split('/').filter(Boolean);
let parentId: string | null = null;
for (const segment of segments) {
const match: FileNode | undefined = childrenOf(nodes, parentId).find(n => n.name === segment && isDirectoryType(n.type));
if (!match) return undefined;
parentId = match.id;
}
return parentId;
}
function getUniqueName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.substring(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.substring(dotIndex) : '';
let counter = 1;
while (existingNames.has(`${base} (${counter})${ext}`)) counter++;
return `${base} (${counter})${ext}`;
}
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
if (stack.length <= 1) return '/';
return '/' + stack.slice(1).map(s => s.name).join('/');
}
export const useFileStore = create<FileState>((set, get) => ({
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
isLoading: false,
error: null,
supportsFiles: null,
selectedResources: new Set<string>(),
uploadProgress: null,
migrationProgress: null,
client: null,
currentAccountId: null,
clipboard: null,
uploadAbortController: null,
lastAction: null,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
})(),
recentFiles: (() => {
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(),
initClient: (client: IJMAPClient, accountId?: string | null) => {
const patch: Partial<FileState> = { client };
if (accountId !== undefined) patch.currentAccountId = accountId;
set(patch);
},
clearClient: () => {
set({
client: null,
currentAccountId: null,
supportsFiles: null,
pathStack: [{ id: null, name: '' }],
currentPath: '/',
currentParentId: null,
resources: [],
selectedResources: new Set<string>(),
error: null,
isLoading: false,
});
},
checkSupport: async () => {
const { client } = get();
if (!client) {
set({ supportsFiles: false });
return false;
}
// First check capability, then probe with a real request
const supported = await client.probeFileNodeSupport();
if (!supported) {
console.warn('[Files] JMAP FileNode not supported. Available capabilities:', Object.keys(client.getCapabilities()));
}
set({ supportsFiles: supported });
return supported;
},
migrateLegacyFlatNodes: async () => {
const { client } = get();
if (!client) return false;
let allNodes: FileNode[];
try {
allNodes = await client.listAllFileNodes();
} catch {
return false;
}
const legacy = allNodes.filter(n => lastLegacySepIndex(n.name) >= 0);
if (legacy.length === 0) return false;
// Resolve parents by their original encoded name. Building the map up front
// means the order in which we rewrite nodes does not matter: each node's
// parent is located by id, captured before any rename happens.
const idByEncodedName = new Map<string, string>();
for (const n of allNodes) idByEncodedName.set(n.name, n.id);
// Build the per-node patches (new leaf name + resolved parentId).
const patches: { id: string; name: string; parentId: string | null }[] = [];
let skipped = 0;
for (const node of legacy) {
const idx = lastLegacySepIndex(node.name);
const parentName = node.name.slice(0, idx);
const leafName = node.name.slice(idx + 1);
const parentId = idByEncodedName.get(parentName);
// Skip orphans (missing parent) to avoid name collisions at the root;
// they keep their encoded name and remain accessible.
if (!parentId) {
skipped++;
console.warn('[Files] migration: no parent for', JSON.stringify(node.name), '(expected', JSON.stringify(parentName) + ')');
continue;
}
patches.push({ id: node.id, name: leafName, parentId });
}
set({ migrationProgress: { current: 0, total: patches.length } });
let migrated = 0;
let firstError: string | null = null;
const CHUNK = 100;
try {
for (let i = 0; i < patches.length; i += CHUNK) {
const slice = patches.slice(i, i + CHUNK);
const updates: Record<string, { name: string; parentId: string | null }> = {};
for (const p of slice) updates[p.id] = { name: p.name, parentId: p.parentId };
try {
const { updated, notUpdated } = await client.updateFileNodes(updates);
migrated += updated.length;
const failedIds = Object.keys(notUpdated);
if (failedIds.length > 0 && !firstError) firstError = notUpdated[failedIds[0]];
for (const id of failedIds) {
console.error('[Files] migration: server rejected node', id, '→', notUpdated[id]);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!firstError) firstError = msg;
console.error('[Files] migration: batch failed →', msg);
}
set({ migrationProgress: { current: Math.min(i + CHUNK, patches.length), total: patches.length } });
}
} finally {
set({ migrationProgress: null });
}
if (migrated === 0) {
console.error(`[Files] migration found ${legacy.length} legacy node(s) but reparented none ` +
`(skipped ${skipped}, first error: ${firstError ?? 'none'}).`);
} else {
console.info(`[Files] migration reparented ${migrated}/${legacy.length} legacy node(s).`);
}
return migrated > 0;
},
navigate: async (parentId: string | null, name?: string) => {
const { client, pathStack } = get();
if (!client) return;
set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() });
// Update path stack
let newStack: { id: string | null; name: string }[];
if (parentId === null) {
newStack = [{ id: null, name: '' }];
} else {
// Check if navigating to a parent in the stack
const existingIdx = pathStack.findIndex(s => s.id === parentId);
if (existingIdx >= 0) {
newStack = pathStack.slice(0, existingIdx + 1);
} else {
newStack = [...pathStack, { id: parentId, name: name || parentId }];
}
}
const newPath = buildPathFromStack(newStack);
set({ pathStack: newStack, currentPath: newPath });
try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ }
try { localStorage.setItem('files-path-stack', JSON.stringify(newStack)); } catch { /* ignore */ }
try {
// Fetch the whole tree once and select the current parent's direct
// children locally. Hierarchy is derived from parentId links, exactly as
// the JMAP FileNode spec intends (issue #379).
const allNodes = await client.listAllFileNodes();
const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
// Prune recent files whose backing node no longer exists on the server
const { recentFiles } = get();
const existingIds = new Set(allNodes.map(n => n.id));
const prunedRecent = recentFiles.filter(r => existingIds.has(r.id));
if (prunedRecent.length !== recentFiles.length) {
try { localStorage.setItem('files-recent-files', JSON.stringify(prunedRecent)); } catch { /* ignore */ }
set({ resources, recentFiles: prunedRecent, isLoading: false });
} else {
set({ resources, isLoading: false });
}
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to list directory',
isLoading: false,
resources: [],
});
}
},
navigateByPath: async (path: string) => {
const { pathStack, navigate } = get();
if (path === '/') {
await navigate(null);
return;
}
// Try to match the path against the current pathStack
const segments = path.split('/').filter(Boolean);
const targetDepth = segments.length;
// pathStack[0] is root (id: null, name: ''), subsequent entries match path segments
if (targetDepth < pathStack.length) {
const entry = pathStack[targetDepth];
// Verify the names match
const stackPath = pathStack.slice(1, targetDepth + 1).map(s => s.name).join('/');
if (stackPath === segments.join('/')) {
await navigate(entry.id, entry.name);
return;
}
}
// Fallback: resolve the path against the live hierarchy (covers favorites
// and recent paths outside the current breadcrumb stack).
const { client } = get();
if (client) {
try {
const allNodes = await client.listAllFileNodes();
const id = resolvePathToId(allNodes, path);
if (id !== undefined) {
await navigate(id, segments[segments.length - 1]);
}
} catch { /* ignore */ }
}
},
navigateUp: async () => {
const { pathStack, navigate } = get();
if (pathStack.length <= 1) return;
const parent = pathStack[pathStack.length - 2];
await navigate(parent.id, parent.name);
},
refresh: async () => {
const { currentParentId, navigate, pathStack } = get();
const currentEntry = pathStack[pathStack.length - 1];
await navigate(currentParentId, currentEntry?.name);
},
createDirectory: async (name: string) => {
const { client, currentParentId, refresh } = get();
if (!client) return;
await client.createFileDirectory(name, currentParentId);
await refresh();
},
uploadFile: async (file: File) => {
const { client, currentParentId } = get();
if (!client) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
try {
if (abortController.signal.aborted) return;
const { blobId, type } = await client.uploadBlob(file, {
signal: abortController.signal,
onProgress: (loaded, total) => {
set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } });
},
});
if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (files: File[]) => {
const { client, currentParentId, resources } = get();
if (!client) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
const existingNames = new Set(resources.map(r => r.name));
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const uniqueName = getUniqueName(file.name, existingNames);
existingNames.add(uniqueName);
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
const idx = i;
const { blobId, type } = await client.uploadBlob(file, {
signal: abortController.signal,
onProgress: (loaded, total) => {
set({ uploadProgress: { name: file.name, loaded, total, current: idx + 1, totalFiles } });
},
});
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
cancelUpload: () => {
const { uploadAbortController } = get();
if (uploadAbortController) {
uploadAbortController.abort();
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFolder: async (files: File[]) => {
const { client, currentParentId } = get();
if (!client || files.length === 0) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect unique directory paths (relative to the dropped folder) and
// create them as real nested directories, mapping each path to its node id.
const dirs = new Set<string>();
for (const file of files) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const parts = relativePath.split('/');
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join('/'));
}
}
// Map a directory path to its created node id. Root ('') maps to the
// current folder we are uploading into.
const dirIds = new Map<string, string | null>();
dirIds.set('', currentParentId);
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
if (abortController.signal.aborted) break;
const slash = dir.lastIndexOf('/');
const parentPath = slash >= 0 ? dir.slice(0, slash) : '';
const dirName = slash >= 0 ? dir.slice(slash + 1) : dir;
const parentId = dirIds.get(parentPath) ?? currentParentId;
try {
const created = await client.createFileDirectory(dirName, parentId);
dirIds.set(dir, created.id);
} catch {
// Directory may already exist - leave it unmapped; files fall back to
// the closest known parent below.
}
}
// Upload files into their containing directory.
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const slash = relativePath.lastIndexOf('/');
const dirPath = slash >= 0 ? relativePath.slice(0, slash) : '';
const parentId = dirIds.get(dirPath) ?? currentParentId;
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
deleteResource: async (name: string) => {
const { client, resources, recentFiles, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
// The server removes descendant nodes (onDestroyRemoveChildren).
await client.destroyFileNodes([resource.id]);
const nextRecentFiles = recentFiles.filter(r => r.id !== resource.id);
set({ recentFiles: nextRecentFiles });
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
await refresh();
},
deleteResources: async (names: string[]) => {
const { client, resources, recentFiles, refresh } = get();
if (!client) return;
const idsToDelete: string[] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (resource) idsToDelete.push(resource.id);
}
if (idsToDelete.length === 0) return;
// The server removes descendant nodes (onDestroyRemoveChildren).
await client.destroyFileNodes(idsToDelete);
const deletedIdSet = new Set(idsToDelete);
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id));
set({ selectedResources: new Set() });
set({ recentFiles: nextRecentFiles });
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
await refresh();
},
renameResource: async (oldName: string, newName: string) => {
const { client, resources, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === oldName);
if (!resource) return;
await client.updateFileNode(resource.id, { name: newName });
set({
lastAction: {
type: 'rename',
entries: [{ id: resource.id, from: { name: oldName }, to: { name: newName } }],
sourceParentId: null,
},
});
await refresh();
},
downloadResource: async (name: string) => {
const { client, resources } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) return;
await client.downloadBlob(resource.blobId, resource.name, resource.contentType);
},
downloadResources: async (names: string[]) => {
const { downloadResource } = get();
for (const name of names) {
await downloadResource(name);
}
},
getImageUrl: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
return client.fetchBlobAsObjectUrl(resource.blobId, resource.name, resource.contentType);
},
getFileContent: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
const response = await fetch(url, {
headers: { 'Authorization': client.getAuthHeader() },
});
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
const blob = await response.blob();
return { blob, contentType: resource.contentType || 'application/octet-stream' };
},
createTextFile: async (name: string) => {
const { client, currentParentId, refresh } = get();
if (!client) return;
const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob);
await client.createFileNode(name, blobId, 'text/plain', 0, currentParentId);
await refresh();
},
duplicateResource: async (name: string) => {
const { client, resources, currentParentId, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
await client.copyFileNode(resource.id, copyName, currentParentId);
await refresh();
},
moveToFolder: async (names: string[], targetFolder: string) => {
const { client, resources, refresh } = get();
if (!client) return;
const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory);
if (!targetResource) return;
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource || resource.id === targetResource.id) continue;
await client.updateFileNode(resource.id, { parentId: targetResource.id });
entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetResource.id } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
moveToParent: async (names: string[]) => {
const { client, resources, pathStack, refresh } = get();
if (!client || pathStack.length <= 1) return;
// Move into the grandparent of the current folder's contents, i.e. the
// entry one level up in the breadcrumb stack.
const newParentId = pathStack[pathStack.length - 2].id;
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
await client.updateFileNode(resource.id, { parentId: newParentId });
entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: newParentId } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
cutResources: (names: string[]) => {
const { currentPath, currentParentId, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath, currentParentId, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
},
pasteResources: async () => {
const { client, currentParentId, clipboard, refresh } = get();
if (!client || !clipboard) return;
const entries: UndoAction['entries'] = [];
for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i];
const displayName = clipboard.names[i];
if (clipboard.mode === 'cut') {
await client.updateFileNode(id, { parentId: currentParentId });
entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } });
} else {
await client.copyFileNode(id, displayName, currentParentId);
}
}
if (clipboard.mode === 'cut') {
set({
clipboard: null,
lastAction: { type: 'move', entries, sourceParentId: null },
});
}
await refresh();
},
selectResource: (name: string | null) => {
set({ selectedResources: name ? new Set([name]) : new Set() });
},
toggleSelect: (name: string) => {
const { selectedResources } = get();
const next = new Set(selectedResources);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
set({ selectedResources: next });
},
selectAll: () => {
const { resources } = get();
set({ selectedResources: new Set(resources.map(r => r.name)) });
},
clearSelection: () => {
set({ selectedResources: new Set() });
},
setSelection: (names: Set<string>) => {
set({ selectedResources: new Set(names) });
},
listPath: async (path: string) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listAllFileNodes();
const parentId = resolvePathToId(allNodes, path);
if (parentId === undefined) return [];
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
} catch {
return [];
}
},
listByParentId: async (parentId: string | null) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listAllFileNodes();
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
} catch {
return [];
}
},
toggleFavorite: (path: string) => {
const { favorites } = get();
const next = favorites.includes(path)
? favorites.filter(f => f !== path)
: [...favorites, path];
set({ favorites: next });
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
},
addRecentFile: (name: string, id: string) => {
const { recentFiles } = get();
const entry = { name, id, timestamp: Date.now() };
const filtered = recentFiles.filter(r => r.id !== id);
const next = [entry, ...filtered].slice(0, 20);
set({ recentFiles: next });
try { localStorage.setItem('files-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
},
undoLastAction: async () => {
const { client, lastAction, refresh } = get();
if (!client || !lastAction) return;
for (const entry of lastAction.entries) {
await client.updateFileNode(entry.id, entry.from);
}
set({ lastAction: null });
await refresh();
},
}));