import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } 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; // Sharing (RFC 9670). `shareWith` has entries when this node is shared out by // the current user; `myRights` describes what the viewer may do; `isShared` // marks nodes owned by another principal and surfaced under "Shared with me". myRights?: FileNodeRights; shareWith?: Record | null; isShared?: boolean; ownerAccountId?: string; ownerName?: string; } 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>; to: Partial> }[]; 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; 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; /** Top-level FileNodes shared with the user by other principals ("Shared with me"). */ sharedRoots: FileResource[]; // 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; /** * 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; navigate: (parentId: string | null, name?: string) => Promise; navigateByPath: (path: string) => Promise; navigateUp: () => Promise; refresh: () => Promise; createDirectory: (name: string) => Promise; uploadFile: (file: File) => Promise; uploadFiles: (files: File[]) => Promise; uploadFolder: (files: File[]) => Promise; cancelUpload: () => void; deleteResource: (name: string) => Promise; deleteResources: (names: string[]) => Promise; renameResource: (oldName: string, newName: string) => Promise; downloadResource: (name: string) => Promise; downloadResources: (names: string[]) => Promise; getImageUrl: (name: string) => Promise; getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>; createTextFile: (name: string) => Promise; duplicateResource: (name: string) => Promise; moveToFolder: (names: string[], targetFolder: string) => Promise; moveToParent: (names: string[]) => Promise; cutResources: (names: string[]) => void; copyResources: (names: string[]) => void; pasteResources: () => Promise; selectResource: (name: string | null) => void; toggleSelect: (name: string) => void; selectAll: () => void; clearSelection: () => void; setSelection: (names: Set) => void; listPath: (path: string) => Promise; listByParentId: (parentId: string | null) => Promise; toggleFavorite: (path: string) => void; addRecentFile: (name: string, id: string) => void; undoLastAction: () => Promise; /** Add, update (rights), or remove (rights=null) a principal's share on a node. */ shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; /** Load the top-level nodes shared with the user by other principals. */ loadSharedRoots: () => Promise; } 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; } // Whether an old build's `type` marks a node as a directory. Only meaningful for // detecting legacy "folder" nodes that were really stored as 0-byte files; it is // NOT how a real folder is identified (see isFolder). function isDirectoryType(type: string | undefined): boolean { if (!type) return false; return DIRECTORY_TYPES.has(type) || type.includes('directory'); } // A FileNode is a folder iff it has no content blob. This is the authoritative // signal in the JMAP FileNode spec and in Stalwart (a node is a container when // its `file`/`blobId` is null); a `type` of "d" is not — older builds created // "folders" as blob-backed files, which can't hold children (#379). function isFolder(node: Pick): boolean { return node.blobId == null; } // 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); } // Nodes from a non-primary (shared) account are namespaced "accountId:nodeId" // by listAllFileNodesAcrossAccounts(). JMAP ids never contain ':', so the // separator unambiguously marks a node we're browsing inside a shared account. function isCrossAccountId(id: string | null): boolean { return id != null && id.includes(':'); } function nodeToResource(node: FileNode): FileResource { const isDir = isFolder(node); return { id: node.id, name: node.name, serverName: node.name, isDirectory: isDir, contentType: isDir ? '' : node.type, contentLength: node.size, lastModified: node.modified || node.created, blobId: node.blobId, parentId: node.parentId, myRights: node.myRights, shareWith: node.shareWith, isShared: node.isShared, ownerAccountId: node.accountId, ownerName: node.accountName, }; } 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 && isFolder(n)); if (!match) return undefined; parentId = match.id; } return parentId; } function getUniqueName(name: string, existingNames: Set): 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((set, get) => ({ currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], resources: [], isLoading: false, error: null, supportsFiles: null, selectedResources: new Set(), uploadProgress: null, migrationProgress: null, client: null, currentAccountId: null, clipboard: null, uploadAbortController: null, lastAction: null, sharedRoots: [], 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 = { 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(), 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; } // Split an encoded name into its real path segments, accepting any of the // legacy separators and dropping empty segments (leading / trailing / dup // separators). A non-legacy name yields a single segment. const splitSegments = (name: string): string[] => { let parts = [name]; for (const sep of LEGACY_PATH_SEPS) parts = parts.flatMap(p => p.split(sep)); return parts.filter(Boolean); }; // A legacy "folder marker": an old build stored folders as 0-byte files with // a directory-ish `type` and a blob. The server treats these as files, so // nothing can be parented under them - they must be replaced by real folders. const isLegacyDirMarker = (n: FileNode) => !isFolder(n) && isDirectoryType(n.type); const legacy = allNodes.filter(n => lastLegacySepIndex(n.name) >= 0); const markers = allNodes.filter(isLegacyDirMarker); if (legacy.length === 0 && markers.length === 0) return false; // Real folders that already exist, indexed by the canonical path they // represent, so we reuse them instead of creating duplicates. Keying on the // JSON-encoded segment array avoids separator-collisions between levels. const pathKey = (segs: string[]) => JSON.stringify(segs); const existingDirByPath = new Map(); for (const n of allNodes) { if (!isFolder(n)) continue; const segs = splitSegments(n.name); if (segs.length > 0) existingDirByPath.set(pathKey(segs), n); } // Per-node rename+reparent operations to apply. Crucially, every parentId // here points at a node we have already ensured is a real folder, so the // server's "parent must be a folder" check can't reject them (#379). const updates: Record = {}; const dirIdByPath = new Map(); dirIdByPath.set('', null); // root let skipped = 0; let createdDirs = 0; let creationBroken = false; // Ensure a real folder exists for the given path, returning its id. Reuses an // existing folder at that path (scheduling it for rename/reparent into the // real hierarchy) or creates a fresh one. Sequential because a create hits // the server and deeper levels depend on its id. const ensureDir = async (segs: string[]): Promise => { if (segs.length === 0) return null; const key = pathKey(segs); if (dirIdByPath.has(key)) return dirIdByPath.get(key)!; const parentId = await ensureDir(segs.slice(0, -1)); const leaf = segs[segs.length - 1]; const existing = existingDirByPath.get(key); if (existing) { // Reuse it; only rewrite if its name/parent isn't already correct. if (existing.name !== leaf || (existing.parentId ?? null) !== parentId) { updates[existing.id] = { name: leaf, parentId }; } dirIdByPath.set(key, existing.id); return existing.id; } const created = await client.createFileDirectory(leaf, parentId); // Safety net: a real folder has no content blob. If the server (or a stale // build of createFileDirectory) hands back a blob-backed node, it is NOT a // folder - abort before we delete anything irreversible (see below). if (created.blobId != null) { creationBroken = true; throw new Error('createFileDirectory returned a non-folder (has a blobId)'); } createdDirs++; dirIdByPath.set(key, created.id); return created.id; }; const placeDir = async (segs: string[], label: string) => { try { await ensureDir(segs); } catch (err) { skipped++; if (!creationBroken) console.warn('[Files] migration: could not create folder', JSON.stringify(label), '→', err instanceof Error ? err.message : String(err)); } }; // Move the legacy marker files out of the way (a reversible rename) so their // names are free for the real folders created in their place. They are only // DELETED at the very end, once the real hierarchy is safely in place - so a // failure can never leave a folder both gone and not recreated. const renamedMarkers: { id: string; name: string }[] = []; for (const m of markers) { try { await client.updateFileNode(m.id, { name: `__bulwark_migrating__.${m.id}` }); renamedMarkers.push({ id: m.id, name: m.name }); } catch (err) { console.warn('[Files] migration: could not set aside marker', JSON.stringify(m.name), '→', err instanceof Error ? err.message : String(err)); } } // Recreate folders that existed only as markers, reparent any real folders // that still carry an encoded name, then reparent the content files. for (const m of markers) { const segs = splitSegments(m.name); if (segs.length > 0) await placeDir(segs, m.name); } for (const node of legacy) { if (!isFolder(node) || isLegacyDirMarker(node)) continue; const segs = splitSegments(node.name); if (segs.length > 0) await placeDir(segs, node.name); } for (const node of legacy) { if (isFolder(node) || isLegacyDirMarker(node)) continue; const segs = splitSegments(node.name); if (segs.length === 0) { skipped++; continue; } try { const parentId = await ensureDir(segs.slice(0, -1)); updates[node.id] = { name: segs[segs.length - 1], parentId }; } catch (err) { skipped++; if (!creationBroken) console.warn('[Files] migration: could not place', JSON.stringify(node.name), '→', err instanceof Error ? err.message : String(err)); } } // If folder creation is fundamentally broken, restore the markers we set // aside and bail out without deleting or reparenting anything. No data lost. if (creationBroken) { console.error('[Files] migration aborted: the server did not return real folders ' + '(createFileDirectory produced blob-backed nodes). Restoring markers; nothing was deleted.'); for (const m of renamedMarkers) { try { await client.updateFileNode(m.id, { name: m.name }); } catch { /* best effort */ } } set({ migrationProgress: null }); return false; } const updateIds = Object.keys(updates); set({ migrationProgress: { current: 0, total: updateIds.length } }); let migrated = 0; let firstError: string | null = null; const CHUNK = 100; try { for (let i = 0; i < updateIds.length; i += CHUNK) { const slice = updateIds.slice(i, i + CHUNK); const batch: Record = {}; for (const id of slice) batch[id] = updates[id]; try { const { updated, notUpdated } = await client.updateFileNodes(batch); 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, updateIds.length), total: updateIds.length } }); } } finally { set({ migrationProgress: null }); } // The real hierarchy is now in place, so the set-aside marker files are empty // and safe to delete. Done last, on purpose: until here nothing irreversible // has happened. let removedMarkers = 0; if (renamedMarkers.length > 0) { try { const { destroyed } = await client.destroyFileNodes(renamedMarkers.map(m => m.id)); removedMarkers = destroyed.length; } catch (err) { console.warn('[Files] migration: could not remove emptied markers →', err instanceof Error ? err.message : String(err)); } } const didWork = migrated > 0 || createdDirs > 0 || removedMarkers > 0; if (!didWork && (legacy.length > 0 || markers.length > 0)) { console.error(`[Files] migration found ${legacy.length} legacy node(s) but changed nothing ` + `(skipped ${skipped}, first error: ${firstError ?? 'none'}).`); } else if (didWork) { console.info(`[Files] migration reparented ${migrated} file(s), created ${createdDirs} folder(s), ` + `removed ${removedMarkers} legacy marker(s) (skipped ${skipped}).`); } return didWork; }, 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). When browsing inside a // folder shared by another principal (namespaced id), pull the tree // across all accessible accounts so the shared subtree is visible. const allNodes = isCrossAccountId(parentId) ? await client.listAllFileNodesAcrossAccounts() : 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(); 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(); 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) => { 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(); }, shareResource: async (id: string, principalId: string, rights: FileNodeRights | null) => { const { client, refresh } = get(); if (!client) return; // currentAccountId is the webmail's connected-account key ("user@host"), // not a JMAP account id - Stalwart parses accountId strictly while // deserializing the request, so sending it rejects the whole call with // notRequest. The client is already the per-account client; let // setFileNodeShare resolve its own files account. await client.setFileNodeShare(id, principalId, rights); await refresh(); }, loadSharedRoots: async () => { const { client } = get(); if (!client) { set({ sharedRoots: [] }); return; } try { const all = await client.listAllFileNodesAcrossAccounts(); const idSet = new Set(all.map(n => n.id)); // A shared node is a "root" of the shared-with-me tree when its parent is // not among the nodes the user can see (either null, or owned by a // principal whose ancestor folders weren't shared). const roots = all.filter(n => n.isShared && (n.parentId == null || !idSet.has(n.parentId)), ); set({ sharedRoots: sortResources(roots.map(nodeToResource)) }); } catch (error) { console.error('[Files] Failed to load shared roots:', error); set({ sharedRoots: [] }); } }, })); import { registerPushHandler } from '@/lib/push-event-bus'; registerPushHandler('FileNode', async (_client) => { const store = useFileStore.getState(); const currentParentId = store.currentParentId; store.navigate(currentParentId).catch((err) => { console.error('Failed to refresh files on push:', err); }); });