Files
webmail/stores/file-store.ts
T

1465 lines
59 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, FileNodeRights } from '@/lib/jmap/types';
import { imageBlobUrl } from '@/lib/file-preview';
import { fileHooks } from '@/lib/plugin-hooks';
import { fileToView } from '@/lib/plugin-projection';
import type { FileResourceView } from '@/lib/plugin-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<string, FileNodeRights> | 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;
/** The cut or copied items as plugins see them, taken when they were picked. */
files: FileResourceView[];
}
/** How a batch upload ended: files that made it, and whether the user cancelled. */
export interface UploadResult {
uploaded: number;
canceled: boolean;
}
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;
/** Top-level FileNodes shared with the user by other principals ("Shared with me"). */
sharedRoots: FileResource[];
/**
* Every folder known from the last listing, for the folder tree. Each
* listing already fetches the whole drive, so the tree is derived from it
* instead of fetching again, and is current after every change.
*/
allFolders: FileResource[];
/** False until the first listing for the attached account has finished. */
hasListed: boolean;
// 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<UploadResult>;
uploadFolder: (files: File[]) => Promise<UploadResult>;
cancelUpload: () => void;
// Deletes, renames and moves resolve to false when a plugin stopped them
// (onBeforeFileDelete / Rename / Move); nothing changed then.
deleteResource: (name: string) => Promise<boolean>;
deleteResources: (names: string[]) => Promise<boolean>;
renameResource: (oldName: string, newName: string) => Promise<boolean>;
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<boolean>;
moveToParent: (names: string[]) => Promise<boolean>;
/** Move listed items into any folder by id (null = the root), e.g. a tree or breadcrumb drop. */
moveToFolderId: (names: string[], targetId: string | null) => Promise<boolean>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<boolean>;
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;
/** Open the folder holding a node and return the node's name, or null when it is gone. */
revealNode: (id: string) => Promise<string | null>;
/** Rename any node by id, e.g. a folder in the tree that is not listed. */
renameNode: (id: string, newName: string) => Promise<boolean>;
/** Delete any node by id; leaves the folder on screen if it was inside it. */
deleteNode: (id: string) => Promise<boolean>;
/** Download a file by id, wherever it lives. */
downloadNode: (id: string) => Promise<void>;
removeRecentFile: (id: string) => void;
undoLastAction: () => Promise<void>;
/** Add, update (rights), or remove (rights=null) a principal's share on a node. */
shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
/** Load the top-level nodes shared with the user by other principals. */
loadSharedRoots: () => 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;
}
// 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<FileNode, 'blobId'>): 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(':');
}
/**
* The account and bare server id behind a listed resource, for callers that
* address the node on the server themselves (the WOPI editor, #1094). A node
* of a shared account is listed as "accountId:nodeId"; `accountId` is only
* known for nodes listed across accounts.
*/
export function resourceServerRef(
resource: Pick<FileResource, 'id' | 'ownerAccountId'>,
): { accountId?: string; id: string } {
const owner = resource.ownerAccountId;
if (owner && resource.id.startsWith(`${owner}:`)) {
return { accountId: owner, id: resource.id.slice(owner.length + 1) };
}
return { accountId: owner, id: resource.id };
}
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>): 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}`;
}
// Display path of a folder ("/A/B") from the folder list, or null if unknown.
function folderPath(folders: FileResource[], id: string): string | null {
const byId = new Map(folders.map(f => [f.id, f]));
const names: string[] = [];
for (let cur = byId.get(id); cur; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
names.unshift(cur.name);
if (names.length > 256) return null;
}
return names.length > 0 ? '/' + names.join('/') : null;
}
function isUnder(path: string, base: string): boolean {
return path === base || path.startsWith(base + '/');
}
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
if (stack.length <= 1) return '/';
return '/' + stack.slice(1).map(s => s.name).join('/');
}
function joinPath(base: string, name: string): string {
if (!name) return base;
return base === '/' || base === '' ? `/${name}` : `${base}/${name}`;
}
/** A listed item as plugins see it; `path` is the folder that holds it. */
export function resourceToView(r: FileResource, path: string): FileResourceView {
return fileToView({
id: r.id,
parentId: r.parentId,
name: r.name,
type: r.isDirectory ? 'd' : r.contentType,
blobId: r.isDirectory ? null : r.blobId,
size: r.contentLength,
created: r.lastModified,
modified: r.lastModified,
}, path);
}
/** Path of the folder holding a node, from the folder list ('' when unknown). */
function parentPath(folders: FileResource[], parentId: string | null | undefined): string {
if (parentId == null) return '/';
return folderPath(folders, parentId) ?? '';
}
/** Views of the items whose ids the server reports as destroyed. */
function destroyedViews(views: FileResourceView[], result: { destroyed?: string[] } | undefined): FileResourceView[] {
if (!result?.destroyed) return views;
const gone = new Set(result.destroyed);
return views.filter(v => gone.has(v.id));
}
/** A starred folder as plugins see it; favourites are kept by path. */
function favoriteFolderView(folders: FileResource[], path: string): FileResourceView {
const slash = path.lastIndexOf('/');
const parent = slash > 0 ? path.slice(0, slash) : '/';
const folder = folders.find(f => folderPath(folders, f.id) === path);
if (folder) return resourceToView(folder, parent);
return { id: '', name: path.slice(slash + 1), type: 'directory', size: 0, mimeType: '', path: parent, modified: '' };
}
function sameNameSet(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const n of a) if (!b.has(n)) return false;
return true;
}
/** A node that is not in any listing; plugins get at least its id. */
function unknownNodeView(id: string): FileResourceView {
return { id, name: '', type: 'file', size: 0, mimeType: '', path: '', modified: '' };
}
/** Ask plugins once about moving these items from `fromPath` to `toPath`. */
async function approveMove(items: FileResource[], fromPath: string, toPath: string): Promise<boolean> {
if (items.length === 0) return true;
const files = items.map(r => resourceToView(r, fromPath));
return fileHooks.onBeforeFileMove.intercept({ files, toPath });
}
/**
* Move each item with `moveOne`, then tell plugins which ones moved. Items
* moved before a failure are still reported; the error is passed on.
*/
async function reportMoves(items: FileResource[], toPath: string, moveOne: (r: FileResource) => Promise<void>): Promise<void> {
const moved: FileResourceView[] = [];
try {
for (const resource of items) {
await moveOne(resource);
moved.push(resourceToView(resource, toPath));
}
} finally {
if (moved.length > 0) void fileHooks.onFileMove.emit({ files: moved, toPath });
}
}
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,
sharedRoots: [],
allFolders: [],
hasListed: false,
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: [],
allFolders: [],
hasListed: false,
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;
}
// 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<string, FileNode>();
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<string, { name: string; parentId: string | null }> = {};
const dirIdByPath = new Map<string, string | null>();
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<string | null> => {
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<string, { name: string; parentId: string | null }> = {};
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, currentParentId: previousParentId, hasListed } = get();
if (!client) return;
// A refresh lists the same folder again; plugins only hear about a new one.
const opensFolder = !hasListed || parentId !== previousParentId;
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));
const allFolders = allNodes.filter(isFolder).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, allFolders, recentFiles: prunedRecent, isLoading: false, hasListed: true });
} else {
set({ resources, allFolders, isLoading: false, hasListed: true });
}
if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to list directory',
hasListed: true,
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, currentPath, refresh } = get();
if (!client) return;
const created = await client.createFileDirectory(name, currentParentId);
void fileHooks.onDirectoryCreate.emit(fileToView(created, currentPath));
await refresh();
},
uploadFile: async (file: File) => {
const { client, currentParentId, currentPath } = get();
if (!client) return;
if (!(await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path: currentPath }))) 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 } });
const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath));
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (allFiles: File[]) => {
const { client, currentParentId, currentPath, resources } = get();
if (!client) return { uploaded: 0, canceled: false };
let uploaded = 0;
// Plugins may hold back single files; the rest go up.
const files: File[] = [];
for (const file of allFiles) {
if (await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path: currentPath })) {
files.push(file);
}
}
if (files.length === 0) return { uploaded: 0, canceled: false };
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 } });
const node = await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
uploaded++;
void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath));
} 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();
return { uploaded, canceled: abortController.signal.aborted };
},
cancelUpload: () => {
const { uploadAbortController, uploadProgress, currentPath } = get();
if (uploadAbortController) {
uploadAbortController.abort();
set({ uploadProgress: null, uploadAbortController: null });
void fileHooks.onFileUploadCancel.emit({ name: uploadProgress?.name ?? '', path: currentPath });
}
},
uploadFolder: async (allFiles: File[]) => {
const { client, currentParentId, currentPath } = get();
if (!client || allFiles.length === 0) return { uploaded: 0, canceled: false };
let uploaded = 0;
const relativeOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const dirOf = (relativePath: string) => {
const slash = relativePath.lastIndexOf('/');
return slash >= 0 ? relativePath.slice(0, slash) : '';
};
// Ask plugins about every file before anything is created, so a folder
// that only held refused files is not created either.
const files: File[] = [];
for (const file of allFiles) {
const path = joinPath(currentPath, dirOf(relativeOf(file)));
if (await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path })) {
files.push(file);
}
}
if (files.length === 0) return { uploaded: 0, canceled: false };
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);
void fileHooks.onDirectoryCreate.emit(fileToView(created, joinPath(currentPath, parentPath)));
} 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 = relativeOf(file);
const dirPath = dirOf(relativePath);
const parentId = dirIds.get(dirPath) ?? currentParentId;
set({ uploadProgress: { name: relativePath, 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: relativePath, loaded, total, current: idx + 1, totalFiles } });
},
});
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId);
uploaded++;
// A file whose folder could not be created lands in the closest known one.
const landedIn = dirIds.has(dirPath) ? joinPath(currentPath, dirPath) : currentPath;
void fileHooks.onAfterFileUpload.emit(fileToView(node, landedIn));
} 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();
return { uploaded, canceled: abortController.signal.aborted };
},
deleteResource: async (name: string) => {
const { client, resources, recentFiles, currentPath, refresh } = get();
if (!client) return false;
const resource = resources.find(r => r.name === name);
if (!resource) return false;
const files = [resourceToView(resource, currentPath)];
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false;
// The server removes descendant nodes (onDestroyRemoveChildren).
const result = await client.destroyFileNodes([resource.id]);
const deleted = destroyedViews(files, result);
if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted });
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();
return true;
},
deleteResources: async (names: string[]) => {
const { client, resources, recentFiles, currentPath, refresh } = get();
if (!client) return false;
const idsToDelete: string[] = [];
const files: FileResourceView[] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (resource) {
idsToDelete.push(resource.id);
files.push(resourceToView(resource, currentPath));
}
}
if (idsToDelete.length === 0) return false;
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false;
// The server removes descendant nodes (onDestroyRemoveChildren).
const result = await client.destroyFileNodes(idsToDelete);
const deleted = destroyedViews(files, result);
if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted });
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();
return true;
},
renameResource: async (oldName: string, newName: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return false;
const resource = resources.find(r => r.name === oldName);
if (!resource) return false;
const file = resourceToView(resource, currentPath);
if (!(await fileHooks.onBeforeFileRename.intercept({ file, newName }))) return false;
await client.updateFileNode(resource.id, { name: newName });
void fileHooks.onFileRename.emit({ file: { ...file, name: newName }, previousName: oldName });
set({
lastAction: {
type: 'rename',
entries: [{ id: resource.id, from: { name: oldName }, to: { name: newName } }],
sourceParentId: null,
},
});
await refresh();
return true;
},
downloadResource: async (name: string) => {
const { client, resources, currentPath } = 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);
void fileHooks.onFileDownload.emit(resourceToView(resource, currentPath));
},
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 imageBlobUrl(await client.fetchBlob(resource.blobId, resource.name, resource.contentType), 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, currentPath, 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)`;
const copy = await client.copyFileNode(resource.id, copyName, currentParentId);
void fileHooks.onFileDuplicate.emit(fileToView(copy, currentPath));
await refresh();
},
moveToFolder: async (names: string[], targetFolder: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return false;
const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory);
if (!targetResource) return false;
const toMove = names
.map(name => resources.find(r => r.name === name))
.filter((r): r is FileResource => !!r && r.id !== targetResource.id);
const toPath = joinPath(currentPath, targetFolder);
if (!(await approveMove(toMove, currentPath, toPath))) return false;
const entries: UndoAction['entries'] = [];
await reportMoves(toMove, toPath, async (resource) => {
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();
return true;
},
moveToParent: async (names: string[]) => {
const { client, resources, pathStack, currentPath, refresh } = get();
if (!client || pathStack.length <= 1) return false;
// 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 toMove = names
.map(name => resources.find(r => r.name === name))
.filter((r): r is FileResource => !!r);
const toPath = buildPathFromStack(pathStack.slice(0, -1));
if (!(await approveMove(toMove, currentPath, toPath))) return false;
const entries: UndoAction['entries'] = [];
await reportMoves(toMove, toPath, async (resource) => {
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();
return true;
},
moveToFolderId: async (names: string[], targetId: string | null) => {
const { client, resources, currentParentId, currentPath, allFolders, refresh } = get();
if (!client || targetId === currentParentId) return false;
// A folder must not move into itself or anything below it.
const parentOf = new Map(allFolders.map(f => [f.id, f.parentId ?? null]));
const isInside = (folderId: string | null, ancestorId: string) => {
for (let id = folderId; id != null; id = parentOf.get(id) ?? null) {
if (id === ancestorId) return true;
}
return false;
};
const toMove = names
.map(name => resources.find(r => r.name === name))
.filter((r): r is FileResource => !!r && !(r.isDirectory && isInside(targetId, r.id)));
const toPath = parentPath(allFolders, targetId);
if (!(await approveMove(toMove, currentPath, toPath))) return false;
const entries: UndoAction['entries'] = [];
await reportMoves(toMove, toPath, async (resource) => {
await client.updateFileNode(resource.id, { parentId: targetId });
entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetId } });
});
set({
selectedResources: new Set(),
lastAction: entries.length > 0 ? { type: 'move', entries, sourceParentId: null } : get().lastAction,
});
await refresh();
return true;
},
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[];
const files = resources.filter(r => names.includes(r.name)).map(r => resourceToView(r, currentPath));
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath, files } });
},
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[];
const files = resources.filter(r => names.includes(r.name)).map(r => resourceToView(r, currentPath));
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath, files } });
},
pasteResources: async () => {
const { client, currentParentId, currentPath, clipboard, refresh } = get();
if (!client || !clipboard) return false;
const viewOf = (id: string) => clipboard.files?.find(f => f.id === id);
if (clipboard.mode === 'cut') {
const files = clipboard.ids.map(viewOf).filter((f): f is FileResourceView => !!f);
if (!(await fileHooks.onBeforeFileMove.intercept({ files, toPath: currentPath }))) return false;
}
const entries: UndoAction['entries'] = [];
const done: FileResourceView[] = [];
try {
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 } });
const view = viewOf(id);
if (view) done.push({ ...view, path: currentPath });
} else {
const copy = await client.copyFileNode(id, displayName, currentParentId);
done.push(fileToView(copy, currentPath));
}
}
} finally {
// Report what really happened, also when a later item failed.
if (done.length > 0) {
if (clipboard.mode === 'cut') void fileHooks.onFileMove.emit({ files: done, toPath: currentPath });
else void fileHooks.onFileCopy.emit({ files: done, toPath: currentPath });
}
}
if (clipboard.mode === 'cut') {
set({
clipboard: null,
lastAction: { type: 'move', entries, sourceParentId: null },
});
}
await refresh();
return true;
},
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, allFolders } = get();
const favorite = !favorites.includes(path);
const next = favorite ? [...favorites, path] : favorites.filter(f => f !== path);
set({ favorites: next });
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
if (fileHooks.onFileFavoriteToggle.size > 0) {
void fileHooks.onFileFavoriteToggle.emit({ file: favoriteFolderView(allFolders, path), favorite });
}
},
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 */ }
},
revealNode: async (id: string) => {
const { client, navigate } = get();
if (!client) return null;
let nodes: FileNode[];
try {
nodes = isCrossAccountId(id)
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
} catch {
return null;
}
const byId = new Map(nodes.map(n => [n.id, n]));
const node = byId.get(id);
if (!node) return null;
// Rebuild the breadcrumb trail so navigate() lands on the right path.
const stack: { id: string | null; name: string }[] = [];
for (let p = node.parentId ?? null; p != null; p = byId.get(p)?.parentId ?? null) {
const parent = byId.get(p);
if (!parent) break;
stack.unshift({ id: parent.id, name: parent.name });
}
stack.unshift({ id: null, name: '' });
set({ pathStack: stack });
const parentEntry = stack[stack.length - 1];
await navigate(parentEntry.id, parentEntry.name);
set({ selectedResources: new Set([node.name]) });
return node.name;
},
renameNode: async (id: string, newName: string) => {
const { client, allFolders, resources, favorites, refresh } = get();
if (!client) return false;
const node = allFolders.find(f => f.id === id);
const oldPath = folderPath(allFolders, id);
const known = node ?? resources.find(r => r.id === id);
const file = known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id);
if (!(await fileHooks.onBeforeFileRename.intercept({ file, newName }))) return false;
await client.updateFileNode(id, { name: newName });
void fileHooks.onFileRename.emit({ file: { ...file, name: newName }, previousName: file.name });
// Starred folders are kept by path: follow the rename.
if (oldPath) {
const newPath = oldPath.slice(0, oldPath.lastIndexOf('/') + 1) + newName;
const next = favorites.map(f => (isUnder(f, oldPath) ? newPath + f.slice(oldPath.length) : f));
if (next.some((f, i) => f !== favorites[i])) {
set({ favorites: next });
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
}
}
set({
lastAction: node
? { type: 'rename', entries: [{ id, from: { name: node.name }, to: { name: newName } }], sourceParentId: null }
: get().lastAction,
});
// A renamed ancestor changes the path on screen; rebuild it from the ids.
const { pathStack } = get();
if (pathStack.some(s => s.id === id)) {
set({ pathStack: pathStack.map(s => (s.id === id ? { ...s, name: newName } : s)) });
set({ currentPath: buildPathFromStack(get().pathStack) });
}
await refresh();
return true;
},
deleteNode: async (id: string) => {
const { client, allFolders, resources, favorites, recentFiles, pathStack, navigate, refresh } = get();
if (!client) return false;
const path = folderPath(allFolders, id);
const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id);
const files = [known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id)];
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false;
const result = await client.destroyFileNodes([id]);
const deleted = destroyedViews(files, result);
if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted });
const nextFavorites = path ? favorites.filter(f => !isUnder(f, path)) : favorites;
// Recent files only know their own id; drop the one deleted, the rest are
// pruned by the next listing.
const nextRecent = recentFiles.filter(r => r.id !== id);
set({ favorites: nextFavorites, recentFiles: nextRecent });
try {
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
} catch { /* ignore */ }
// Standing in the deleted folder or below it: step out to its parent.
const idx = pathStack.findIndex(s => s.id === id);
if (idx > 0) {
const parent = pathStack[idx - 1];
set({ pathStack: pathStack.slice(0, idx) });
await navigate(parent.id, parent.name);
return true;
}
await refresh();
return true;
},
downloadNode: async (id: string) => {
const { client } = get();
if (!client) return;
const nodes = isCrossAccountId(id)
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
const node = nodes.find(n => n.id === id);
if (!node?.blobId) throw new Error('File not found');
await client.downloadBlob(node.blobId, node.name, node.type);
const folders = nodes.filter(isFolder).map(nodeToResource);
void fileHooks.onFileDownload.emit(fileToView(node, parentPath(folders, node.parentId)));
},
removeRecentFile: (id: string) => {
const next = get().recentFiles.filter(r => r.id !== id);
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 });
void fileHooks.onFileUndo.emit({
action: {
type: lastAction.type,
entries: lastAction.entries.map(e => ({ id: e.id, from: { ...e.from }, to: { ...e.to } })),
},
});
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: [] });
}
},
}));
// Tell plugins when the selected files change, whichever action changed it
// (click, toggle, select all, clear, navigating away, after a delete or move).
// Selecting the same files again is not a change.
useFileStore.subscribe((state, prev) => {
if (state.selectedResources === prev.selectedResources) return;
if (fileHooks.onFileSelectionChange.size === 0) return;
const next = state.selectedResources ?? new Set<string>();
if (sameNameSet(next, prev.selectedResources ?? new Set<string>())) return;
const files = state.resources.filter(r => next.has(r.name)).map(r => resourceToView(r, state.currentPath));
void fileHooks.onFileSelectionChange.emit({ files });
});