Phase 3 (security): - P3.1: Feature gate server-side enforcement (403 on disabled features) - P3.2: Unified auth error interceptor (401→logout) - P3.3: Store-level state isolation via StoreSnapshot contract (added message-list-tabs + task stores to snapshot/restore cycle) - P3.4: Push event bus extraction — email-store no longer imports calendar/contact/filter/file stores directly - P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter Phase 4 (polish): - P4.1: Offline write queue — pending operations in localStorage, auto-retry on reconnect, offline-queue-indicator banner - P4.2: Identity spoofing — fromOverrideEmail domain validation - P4.3: WebSocket push for Electron via main-process IPC bridge (ws package with Authorization headers)
1061 lines
40 KiB
TypeScript
1061 lines
40 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
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[];
|
||
|
||
// Actions
|
||
initClient: (client: IJMAPClient, accountId?: string | null) => void;
|
||
/** Detach the current client and reset browse state. Used by the Pro shell to return to the cross-account picker. */
|
||
clearClient: () => void;
|
||
checkSupport: () => Promise<boolean>;
|
||
/**
|
||
* One-time upgrade of files created by older Bulwark builds, which encoded
|
||
* the folder tree into flat node names with a Unicode separator. Reparents
|
||
* those nodes into the real FileNode hierarchy. No-op once migrated.
|
||
* Returns true if any node was migrated.
|
||
*/
|
||
migrateLegacyFlatNodes: () => Promise<boolean>;
|
||
navigate: (parentId: string | null, name?: string) => Promise<void>;
|
||
navigateByPath: (path: string) => Promise<void>;
|
||
navigateUp: () => Promise<void>;
|
||
refresh: () => Promise<void>;
|
||
createDirectory: (name: string) => Promise<void>;
|
||
uploadFile: (file: File) => Promise<void>;
|
||
uploadFiles: (files: File[]) => Promise<void>;
|
||
uploadFolder: (files: File[]) => Promise<void>;
|
||
cancelUpload: () => void;
|
||
deleteResource: (name: string) => Promise<void>;
|
||
deleteResources: (names: string[]) => Promise<void>;
|
||
renameResource: (oldName: string, newName: string) => Promise<void>;
|
||
downloadResource: (name: string) => Promise<void>;
|
||
downloadResources: (names: string[]) => Promise<void>;
|
||
getImageUrl: (name: string) => Promise<string>;
|
||
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
|
||
createTextFile: (name: string) => Promise<void>;
|
||
duplicateResource: (name: string) => Promise<void>;
|
||
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
|
||
moveToParent: (names: string[]) => Promise<void>;
|
||
cutResources: (names: string[]) => void;
|
||
copyResources: (names: string[]) => void;
|
||
pasteResources: () => Promise<void>;
|
||
selectResource: (name: string | null) => void;
|
||
toggleSelect: (name: string) => void;
|
||
selectAll: () => void;
|
||
clearSelection: () => void;
|
||
setSelection: (names: Set<string>) => void;
|
||
listPath: (path: string) => Promise<FileResource[]>;
|
||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||
toggleFavorite: (path: string) => void;
|
||
addRecentFile: (name: string, id: string) => void;
|
||
undoLastAction: () => Promise<void>;
|
||
/** 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(':');
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
|
||
if (stack.length <= 1) return '/';
|
||
return '/' + stack.slice(1).map(s => s.name).join('/');
|
||
}
|
||
|
||
export const useFileStore = create<FileState>((set, get) => ({
|
||
currentParentId: null,
|
||
currentPath: '/',
|
||
pathStack: [{ id: null, name: '' }],
|
||
resources: [],
|
||
isLoading: false,
|
||
error: null,
|
||
supportsFiles: null,
|
||
selectedResources: new Set<string>(),
|
||
uploadProgress: null,
|
||
migrationProgress: null,
|
||
client: null,
|
||
currentAccountId: null,
|
||
clipboard: null,
|
||
uploadAbortController: null,
|
||
lastAction: null,
|
||
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<FileState> = { client };
|
||
if (accountId !== undefined) patch.currentAccountId = accountId;
|
||
set(patch);
|
||
},
|
||
|
||
clearClient: () => {
|
||
set({
|
||
client: null,
|
||
currentAccountId: null,
|
||
supportsFiles: null,
|
||
pathStack: [{ id: null, name: '' }],
|
||
currentPath: '/',
|
||
currentParentId: null,
|
||
resources: [],
|
||
selectedResources: new Set<string>(),
|
||
error: null,
|
||
isLoading: false,
|
||
});
|
||
},
|
||
|
||
checkSupport: async () => {
|
||
const { client } = get();
|
||
if (!client) {
|
||
set({ supportsFiles: false });
|
||
return false;
|
||
}
|
||
// First check capability, then probe with a real request
|
||
const supported = await client.probeFileNodeSupport();
|
||
if (!supported) {
|
||
console.warn('[Files] JMAP FileNode not supported. Available capabilities:', Object.keys(client.getCapabilities()));
|
||
}
|
||
set({ supportsFiles: supported });
|
||
return supported;
|
||
},
|
||
|
||
migrateLegacyFlatNodes: async () => {
|
||
const { client } = get();
|
||
if (!client) return false;
|
||
|
||
let allNodes: FileNode[];
|
||
try {
|
||
allNodes = await client.listAllFileNodes();
|
||
} catch {
|
||
return false;
|
||
}
|
||
|
||
// 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 } = 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<string>();
|
||
for (const file of files) {
|
||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||
const parts = relativePath.split('/');
|
||
for (let i = 1; i < parts.length; i++) {
|
||
dirs.add(parts.slice(0, i).join('/'));
|
||
}
|
||
}
|
||
|
||
// Map a directory path to its created node id. Root ('') maps to the
|
||
// current folder we are uploading into.
|
||
const dirIds = new Map<string, string | null>();
|
||
dirIds.set('', currentParentId);
|
||
|
||
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
|
||
for (const dir of sortedDirs) {
|
||
if (abortController.signal.aborted) break;
|
||
const slash = dir.lastIndexOf('/');
|
||
const parentPath = slash >= 0 ? dir.slice(0, slash) : '';
|
||
const dirName = slash >= 0 ? dir.slice(slash + 1) : dir;
|
||
const parentId = dirIds.get(parentPath) ?? currentParentId;
|
||
try {
|
||
const created = await client.createFileDirectory(dirName, parentId);
|
||
dirIds.set(dir, created.id);
|
||
} catch {
|
||
// Directory may already exist - leave it unmapped; files fall back to
|
||
// the closest known parent below.
|
||
}
|
||
}
|
||
|
||
// Upload files into their containing directory.
|
||
for (let i = 0; i < files.length; i++) {
|
||
if (abortController.signal.aborted) break;
|
||
const file = files[i];
|
||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||
const slash = relativePath.lastIndexOf('/');
|
||
const dirPath = slash >= 0 ? relativePath.slice(0, slash) : '';
|
||
const parentId = dirIds.get(dirPath) ?? currentParentId;
|
||
|
||
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||
|
||
try {
|
||
const { blobId, type } = await client.uploadBlob(file);
|
||
if (abortController.signal.aborted) break;
|
||
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||
await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId);
|
||
} catch (err) {
|
||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||
set({ uploadProgress: null, uploadAbortController: null });
|
||
throw err;
|
||
}
|
||
}
|
||
set({ uploadProgress: null, uploadAbortController: null });
|
||
await get().refresh();
|
||
},
|
||
|
||
deleteResource: async (name: string) => {
|
||
const { client, resources, recentFiles, refresh } = get();
|
||
if (!client) return;
|
||
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource) return;
|
||
|
||
// The server removes descendant nodes (onDestroyRemoveChildren).
|
||
await client.destroyFileNodes([resource.id]);
|
||
const nextRecentFiles = recentFiles.filter(r => r.id !== resource.id);
|
||
set({ recentFiles: nextRecentFiles });
|
||
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
|
||
await refresh();
|
||
},
|
||
|
||
deleteResources: async (names: string[]) => {
|
||
const { client, resources, recentFiles, refresh } = get();
|
||
if (!client) return;
|
||
|
||
const idsToDelete: string[] = [];
|
||
for (const name of names) {
|
||
const resource = resources.find(r => r.name === name);
|
||
if (resource) idsToDelete.push(resource.id);
|
||
}
|
||
|
||
if (idsToDelete.length === 0) return;
|
||
|
||
// The server removes descendant nodes (onDestroyRemoveChildren).
|
||
await client.destroyFileNodes(idsToDelete);
|
||
const deletedIdSet = new Set(idsToDelete);
|
||
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id));
|
||
set({ selectedResources: new Set() });
|
||
set({ recentFiles: nextRecentFiles });
|
||
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
|
||
await refresh();
|
||
},
|
||
|
||
renameResource: async (oldName: string, newName: string) => {
|
||
const { client, resources, refresh } = get();
|
||
if (!client) return;
|
||
|
||
const resource = resources.find(r => r.name === oldName);
|
||
if (!resource) return;
|
||
|
||
await client.updateFileNode(resource.id, { name: newName });
|
||
|
||
set({
|
||
lastAction: {
|
||
type: 'rename',
|
||
entries: [{ id: resource.id, from: { name: oldName }, to: { name: newName } }],
|
||
sourceParentId: null,
|
||
},
|
||
});
|
||
await refresh();
|
||
},
|
||
|
||
downloadResource: async (name: string) => {
|
||
const { client, resources } = get();
|
||
if (!client) return;
|
||
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource?.blobId) return;
|
||
|
||
await client.downloadBlob(resource.blobId, resource.name, resource.contentType);
|
||
},
|
||
|
||
downloadResources: async (names: string[]) => {
|
||
const { downloadResource } = get();
|
||
for (const name of names) {
|
||
await downloadResource(name);
|
||
}
|
||
},
|
||
|
||
getImageUrl: async (name: string) => {
|
||
const { client, resources } = get();
|
||
if (!client) throw new Error('No client');
|
||
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource?.blobId) throw new Error('No blob');
|
||
|
||
return client.fetchBlobAsObjectUrl(resource.blobId, resource.name, resource.contentType);
|
||
},
|
||
|
||
getFileContent: async (name: string) => {
|
||
const { client, resources } = get();
|
||
if (!client) throw new Error('No client');
|
||
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource?.blobId) throw new Error('No blob');
|
||
|
||
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
|
||
const response = await fetch(url, {
|
||
headers: { 'Authorization': client.getAuthHeader() },
|
||
});
|
||
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
|
||
const blob = await response.blob();
|
||
return { blob, contentType: resource.contentType || 'application/octet-stream' };
|
||
},
|
||
|
||
createTextFile: async (name: string) => {
|
||
const { client, currentParentId, refresh } = get();
|
||
if (!client) return;
|
||
|
||
const emptyBlob = new File([''], name, { type: 'text/plain' });
|
||
const { blobId } = await client.uploadBlob(emptyBlob);
|
||
await client.createFileNode(name, blobId, 'text/plain', 0, currentParentId);
|
||
await refresh();
|
||
},
|
||
|
||
duplicateResource: async (name: string) => {
|
||
const { client, resources, currentParentId, refresh } = get();
|
||
if (!client) return;
|
||
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource) return;
|
||
|
||
const dotIdx = name.lastIndexOf('.');
|
||
const copyName = dotIdx > 0
|
||
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
|
||
: `${name} (copy)`;
|
||
|
||
await client.copyFileNode(resource.id, copyName, currentParentId);
|
||
await refresh();
|
||
},
|
||
|
||
moveToFolder: async (names: string[], targetFolder: string) => {
|
||
const { client, resources, refresh } = get();
|
||
if (!client) return;
|
||
|
||
const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory);
|
||
if (!targetResource) return;
|
||
|
||
const entries: UndoAction['entries'] = [];
|
||
for (const name of names) {
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource || resource.id === targetResource.id) continue;
|
||
await client.updateFileNode(resource.id, { parentId: targetResource.id });
|
||
entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetResource.id } });
|
||
}
|
||
set({
|
||
selectedResources: new Set(),
|
||
lastAction: { type: 'move', entries, sourceParentId: null },
|
||
});
|
||
await refresh();
|
||
},
|
||
|
||
moveToParent: async (names: string[]) => {
|
||
const { client, resources, pathStack, refresh } = get();
|
||
if (!client || pathStack.length <= 1) return;
|
||
|
||
// Move into the grandparent of the current folder's contents, i.e. the
|
||
// entry one level up in the breadcrumb stack.
|
||
const newParentId = pathStack[pathStack.length - 2].id;
|
||
|
||
const entries: UndoAction['entries'] = [];
|
||
for (const name of names) {
|
||
const resource = resources.find(r => r.name === name);
|
||
if (!resource) continue;
|
||
await client.updateFileNode(resource.id, { parentId: newParentId });
|
||
entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: newParentId } });
|
||
}
|
||
set({
|
||
selectedResources: new Set(),
|
||
lastAction: { type: 'move', entries, sourceParentId: null },
|
||
});
|
||
await refresh();
|
||
},
|
||
|
||
cutResources: (names: string[]) => {
|
||
const { currentPath, currentParentId, resources } = get();
|
||
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
|
||
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
|
||
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
|
||
},
|
||
|
||
copyResources: (names: string[]) => {
|
||
const { currentPath, currentParentId, resources } = get();
|
||
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
|
||
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
|
||
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
|
||
},
|
||
|
||
pasteResources: async () => {
|
||
const { client, currentParentId, clipboard, refresh } = get();
|
||
if (!client || !clipboard) return;
|
||
|
||
const entries: UndoAction['entries'] = [];
|
||
|
||
for (let i = 0; i < clipboard.ids.length; i++) {
|
||
const id = clipboard.ids[i];
|
||
const displayName = clipboard.names[i];
|
||
|
||
if (clipboard.mode === 'cut') {
|
||
await client.updateFileNode(id, { parentId: currentParentId });
|
||
entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } });
|
||
} else {
|
||
await client.copyFileNode(id, displayName, currentParentId);
|
||
}
|
||
}
|
||
|
||
if (clipboard.mode === 'cut') {
|
||
set({
|
||
clipboard: null,
|
||
lastAction: { type: 'move', entries, sourceParentId: null },
|
||
});
|
||
}
|
||
await refresh();
|
||
},
|
||
|
||
selectResource: (name: string | null) => {
|
||
set({ selectedResources: name ? new Set([name]) : new Set() });
|
||
},
|
||
|
||
toggleSelect: (name: string) => {
|
||
const { selectedResources } = get();
|
||
const next = new Set(selectedResources);
|
||
if (next.has(name)) {
|
||
next.delete(name);
|
||
} else {
|
||
next.add(name);
|
||
}
|
||
set({ selectedResources: next });
|
||
},
|
||
|
||
selectAll: () => {
|
||
const { resources } = get();
|
||
set({ selectedResources: new Set(resources.map(r => r.name)) });
|
||
},
|
||
|
||
clearSelection: () => {
|
||
set({ selectedResources: new Set() });
|
||
},
|
||
|
||
setSelection: (names: Set<string>) => {
|
||
set({ selectedResources: new Set(names) });
|
||
},
|
||
|
||
listPath: async (path: string) => {
|
||
const { client } = get();
|
||
if (!client) return [];
|
||
|
||
try {
|
||
const allNodes = await client.listAllFileNodes();
|
||
const parentId = resolvePathToId(allNodes, path);
|
||
if (parentId === undefined) return [];
|
||
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
|
||
listByParentId: async (parentId: string | null) => {
|
||
const { client } = get();
|
||
if (!client) return [];
|
||
try {
|
||
const allNodes = await client.listAllFileNodes();
|
||
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
|
||
toggleFavorite: (path: string) => {
|
||
const { favorites } = get();
|
||
const next = favorites.includes(path)
|
||
? favorites.filter(f => f !== path)
|
||
: [...favorites, path];
|
||
set({ favorites: next });
|
||
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
|
||
},
|
||
|
||
addRecentFile: (name: string, id: string) => {
|
||
const { recentFiles } = get();
|
||
const entry = { name, id, timestamp: Date.now() };
|
||
const filtered = recentFiles.filter(r => r.id !== id);
|
||
const next = [entry, ...filtered].slice(0, 20);
|
||
set({ recentFiles: next });
|
||
try { localStorage.setItem('files-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
|
||
},
|
||
|
||
undoLastAction: async () => {
|
||
const { client, lastAction, refresh } = get();
|
||
if (!client || !lastAction) return;
|
||
|
||
for (const entry of lastAction.entries) {
|
||
await client.updateFileNode(entry.id, entry.from);
|
||
}
|
||
set({ lastAction: null });
|
||
await refresh();
|
||
},
|
||
|
||
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);
|
||
});
|
||
});
|