Files
SRCmail/stores/file-store.ts
T
Linus Rath 716db8d003 feat: add JMAP FileNode file storage backend and file settings
- Implement JMAP FileNode client methods (get, query, list, create,
  update, destroy, copy) with Stalwart-compatible flat name encoding
- Add FileNode/FileNodeFilter types to JMAP type definitions
- Create file-store with Zustand for file management state (navigate,
  upload, delete, rename, move, cut/copy/paste, undo, favorites)
- Add folder tree sidebar component for sidebar navigation layout
- Add files settings dialog and settings page component with options
  for view mode, sort, icons, thumbnails, hidden files, folder layout
- Update files page and file browser to support JMAP FileNode backend
  alongside WebDAV, with folder layout switching and settings integration
- Add settings tab for files configuration in the settings page
- Add translation keys for file settings, calendar subscriptions,
  identity deletion, contact deletion, email navigation, and
  reconnection messages across all 8 locales
- Change WebDAV file storage to File storage in availability messages
- Enhance translations test to verify source-referenced keys exist in
  the en locale
- Fix duplicate JSX attribute in folder-tree-sidebar
2026-03-16 01:05:59 +01:00

758 lines
26 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 { JMAPClient } from '@/lib/jmap/client';
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;
client: JMAPClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
recentFiles: { name: string; id: string; timestamp: number }[];
lastAction: UndoAction | null;
// Actions
initClient: (client: JMAPClient) => void;
checkSupport: () => 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']);
// Stalwart rejects "/" in file names, so we use Unicode DIVISION SLASH as the
// path separator when encoding folder hierarchy into flat file names.
const PATH_SEP = '\u2215'; //
function isDirectoryType(type: string | undefined): boolean {
if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory');
}
// Convert currentPath to a server-side name prefix for filtering
// "/" -> "", "/test" -> "test", "/test/sub" -> "testsub"
function getPathPrefix(currentPath: string): string {
if (currentPath === '/') return '';
return currentPath.slice(1).replace(/\//g, PATH_SEP) + PATH_SEP;
}
// Filter nodes to only direct children of a path prefix
function filterNodesByPrefix(nodes: FileNode[], prefix: string): FileNode[] {
if (prefix === '') {
// Root: nodes whose names have no PATH_SEP
return nodes.filter(n => !n.name.includes(PATH_SEP));
}
// Subfolder: nodes starting with prefix, with no additional PATH_SEP after the prefix
return nodes.filter(n => {
if (!n.name.startsWith(prefix)) return false;
const remaining = n.name.slice(prefix.length);
return remaining.length > 0 && !remaining.includes(PATH_SEP);
});
}
function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
const displayName = pathPrefix && node.name.startsWith(pathPrefix)
? node.name.slice(pathPrefix.length)
: node.name;
const isDir = isDirectoryType(node.type);
return {
id: node.id,
name: displayName,
serverName: node.name,
isDirectory: isDir,
contentType: isDir ? '' : node.type,
contentLength: node.size,
lastModified: node.updated || node.created,
blobId: node.blobId,
parentId: node.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,
client: 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: JMAPClient) => {
set({ client });
},
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;
},
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 {
// Always fetch all nodes from root — Stalwart doesn't support parentId nesting
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(newPath);
const filteredNodes = filterNodesByPrefix(allNodes, prefix);
const resources = filteredNodes.map(n => nodeToResource(n, prefix));
// Sort: directories first, then alphabetically
resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
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: if we can't resolve, stay at current location
},
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, currentPath, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
await client.createFileDirectory(fullName, null);
await refresh();
},
uploadFile: async (file: File) => {
const { client, currentPath } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + file.name;
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);
if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (files: File[]) => {
const { client, currentPath, resources } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
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);
const fullName = prefix + uniqueName;
set({ uploadProgress: { name: file.name, 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: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} 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, currentPath } = get();
if (!client || files.length === 0) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect unique directory paths from the uploaded folder structure
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('/'));
}
}
// Create directories as flat entries with prefixed names (no parentId nesting)
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
if (abortController.signal.aborted) break;
const fullDirName = prefix + dir;
try {
await client.createFileDirectory(fullDirName, null);
} catch {
// Directory may already exist — ignore
}
}
// Upload files with full prefixed paths
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 fullName = prefix + relativePath;
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(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} 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, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const idsToDelete = [resource.id];
// If deleting a folder, also delete all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
await client.destroyFileNodes(idsToDelete);
await refresh();
},
deleteResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return;
const idsToDelete: string[] = [];
let allNodes: FileNode[] | null = null;
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
idsToDelete.push(resource.id);
if (resource.isDirectory) {
if (!allNodes) allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
}
if (idsToDelete.length === 0) return;
await client.destroyFileNodes(idsToDelete);
set({ selectedResources: new Set() });
await refresh();
},
renameResource: async (oldName: string, newName: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === oldName);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const oldServerName = resource.serverName;
const newServerName = prefix + newName;
await client.updateFileNode(resource.id, { name: newServerName });
// If renaming a folder, also rename all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(oldFolderPrefix)) {
const newNodeName = newFolderPrefix + node.name.slice(oldFolderPrefix.length);
await client.updateFileNode(node.id, { name: newNodeName });
}
}
}
set({
lastAction: {
type: 'rename',
entries: [{ id: resource.id, from: { name: oldServerName }, to: { name: newServerName } }],
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, currentPath, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob);
await client.createFileNode(fullName, blobId, 'text/plain', 0, null);
await refresh();
},
duplicateResource: async (name: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
const fullCopyName = prefix + copyName;
await client.copyFileNode(resource.id, fullCopyName, null);
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) continue;
const newServerName = targetResource.serverName + PATH_SEP + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
moveToParent: async (names: string[]) => {
const { client, resources, currentPath, refresh } = get();
if (!client || currentPath === '/') return;
const prefix = getPathPrefix(currentPath);
// Parent prefix: strip the last segment from the current prefix
// e.g. "foldersub" → "folder", "folder" → ""
const parentPrefix = prefix.slice(0, prefix.lastIndexOf(PATH_SEP, prefix.length - 2) + 1);
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
const newServerName = parentPrefix + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
cutResources: (names: string[]) => {
const { currentPath, 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: null, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath, 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: null, sourcePath: currentPath } });
},
pasteResources: async () => {
const { client, currentPath, clipboard, refresh } = get();
if (!client || !clipboard) return;
const prefix = getPathPrefix(currentPath);
const entries: UndoAction['entries'] = [];
for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i];
const displayName = clipboard.names[i];
const oldServerName = clipboard.serverNames?.[i];
if (clipboard.mode === 'cut') {
const newServerName = prefix + displayName;
await client.updateFileNode(id, { name: newServerName });
entries.push({ id, from: { name: oldServerName }, to: { name: newServerName } });
} else {
const fullName = prefix + displayName;
await client.copyFileNode(id, fullName, null);
}
}
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.listFileNodes(null);
const prefix = getPathPrefix(path);
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} catch {
return [];
}
},
listByParentId: async (parentId: string | null) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
if (parentId === null) {
// Root level: nodes with simple names (no "/")
const rootNodes = allNodes.filter(n => !n.name.includes('/'));
return rootNodes.map(n => nodeToResource(n));
}
// Find the folder node to get its server name
const folder = allNodes.find(n => n.id === parentId);
if (!folder) return [];
const prefix = folder.name + PATH_SEP;
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} 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();
},
}));