fix: remove flatname workaround, store Files as real FileNode #379

This commit is contained in:
Linus Rath
2026-06-03 20:53:03 +02:00
parent 44781f002c
commit 568abb0e33
5 changed files with 330 additions and 164 deletions
+108 -164
View File
@@ -98,44 +98,21 @@ interface FileState {
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
// Stalwart rejects "/" in file names, so we use Unicode DIVISION SLASH as the
// path separator when encoding folder hierarchy into flat file names.
const PATH_SEP = '\u2215'; //
function isDirectoryType(type: string | undefined): boolean {
if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory');
}
// Convert currentPath to a server-side name prefix for filtering
// "/" -> "", "/test" -> "test", "/test/sub" -> "testsub"
function getPathPrefix(currentPath: string): string {
if (currentPath === '/') return '';
return currentPath.slice(1).replace(/\//g, PATH_SEP) + PATH_SEP;
// 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);
}
// Filter nodes to only direct children of a path prefix
function filterNodesByPrefix(nodes: FileNode[], prefix: string): FileNode[] {
if (prefix === '') {
// Root: nodes whose names have no PATH_SEP
return nodes.filter(n => !n.name.includes(PATH_SEP));
}
// Subfolder: nodes starting with prefix, with no additional PATH_SEP after the prefix
return nodes.filter(n => {
if (!n.name.startsWith(prefix)) return false;
const remaining = n.name.slice(prefix.length);
return remaining.length > 0 && !remaining.includes(PATH_SEP);
});
}
function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
const displayName = pathPrefix && node.name.startsWith(pathPrefix)
? node.name.slice(pathPrefix.length)
: node.name;
function nodeToResource(node: FileNode): FileResource {
const isDir = isDirectoryType(node.type);
return {
id: node.id,
name: displayName,
name: node.name,
serverName: node.name,
isDirectory: isDir,
contentType: isDir ? '' : node.type,
@@ -146,6 +123,29 @@ function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
};
}
function sortResources(resources: FileResource[]): FileResource[] {
// Directories first, then alphabetically.
return resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
// Resolve a display path (e.g. "/Documents/Notes") to a FileNode id by walking
// the hierarchy from the root. Returns null for the root, or undefined if any
// segment can't be found.
function resolvePathToId(nodes: FileNode[], path: string): string | null | undefined {
if (path === '/' || path === '') return null;
const segments = path.split('/').filter(Boolean);
let parentId: string | null = null;
for (const segment of segments) {
const match: FileNode | undefined = childrenOf(nodes, parentId).find(n => n.name === segment && isDirectoryType(n.type));
if (!match) return undefined;
parentId = match.id;
}
return parentId;
}
function getUniqueName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.');
@@ -246,16 +246,11 @@ export const useFileStore = create<FileState>((set, get) => ({
try { localStorage.setItem('files-path-stack', JSON.stringify(newStack)); } catch { /* ignore */ }
try {
// Always fetch all nodes from root - Stalwart doesn't support parentId nesting
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(newPath);
const filteredNodes = filterNodesByPrefix(allNodes, prefix);
const resources = filteredNodes.map(n => nodeToResource(n, prefix));
// Sort: directories first, then alphabetically
resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
// Fetch the whole tree once and select the current parent's direct
// children locally. Hierarchy is derived from parentId links, exactly as
// the JMAP FileNode spec intends (issue #379).
const allNodes = await client.listAllFileNodes();
const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
// Prune recent files whose backing node no longer exists on the server
const { recentFiles } = get();
@@ -295,7 +290,18 @@ export const useFileStore = create<FileState>((set, get) => ({
return;
}
}
// Fallback: if we can't resolve, stay at current location
// 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 () => {
@@ -312,21 +318,17 @@ export const useFileStore = create<FileState>((set, get) => ({
},
createDirectory: async (name: string) => {
const { client, currentPath, refresh } = get();
const { client, currentParentId, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
await client.createFileDirectory(fullName, null);
await client.createFileDirectory(name, currentParentId);
await refresh();
},
uploadFile: async (file: File) => {
const { client, currentPath } = get();
const { client, currentParentId } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + file.name;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
@@ -341,17 +343,16 @@ export const useFileStore = create<FileState>((set, get) => ({
});
if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
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, currentPath, resources } = get();
const { client, currentParentId, resources } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
@@ -362,7 +363,6 @@ export const useFileStore = create<FileState>((set, get) => ({
const file = files[i];
const uniqueName = getUniqueName(file.name, existingNames);
existingNames.add(uniqueName);
const fullName = prefix + uniqueName;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
@@ -375,7 +375,7 @@ export const useFileStore = create<FileState>((set, get) => ({
});
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
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 });
@@ -395,15 +395,15 @@ export const useFileStore = create<FileState>((set, get) => ({
},
uploadFolder: async (files: File[]) => {
const { client, currentPath } = get();
const { client, currentParentId } = get();
if (!client || files.length === 0) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect unique directory paths from the uploaded folder structure
// 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;
@@ -413,25 +413,35 @@ export const useFileStore = create<FileState>((set, get) => ({
}
}
// Create directories as flat entries with prefixed names (no parentId nesting)
// Convert "/" separators from webkitRelativePath to PATH_SEP () for server names
// 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 fullDirName = prefix + dir.replace(/\//g, PATH_SEP);
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 {
await client.createFileDirectory(fullDirName, null);
const created = await client.createFileDirectory(dirName, parentId);
dirIds.set(dir, created.id);
} catch {
// Directory may already exist - ignore
// Directory may already exist - leave it unmapped; files fall back to
// the closest known parent below.
}
}
// Upload files with full prefixed paths
// 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 fullName = prefix + relativePath.replace(/\//g, PATH_SEP);
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 } });
@@ -439,7 +449,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
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 });
@@ -457,22 +467,9 @@ export const useFileStore = create<FileState>((set, get) => ({
const resource = resources.find(r => r.name === name);
if (!resource) return;
const idsToDelete = [resource.id];
// If deleting a folder, also delete all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
await client.destroyFileNodes(idsToDelete);
const deletedIdSet = new Set(idsToDelete);
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id));
// 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();
@@ -483,26 +480,14 @@ export const useFileStore = create<FileState>((set, get) => ({
if (!client) return;
const idsToDelete: string[] = [];
let allNodes: FileNode[] | null = null;
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
idsToDelete.push(resource.id);
if (resource.isDirectory) {
if (!allNodes) allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
if (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));
@@ -513,35 +498,18 @@ export const useFileStore = create<FileState>((set, get) => ({
},
renameResource: async (oldName: string, newName: string) => {
const { client, resources, currentPath, refresh } = get();
const { client, resources, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === oldName);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const oldServerName = resource.serverName;
const newServerName = prefix + newName;
await client.updateFileNode(resource.id, { name: newServerName });
// If renaming a folder, also rename all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(oldFolderPrefix)) {
const newNodeName = newFolderPrefix + node.name.slice(oldFolderPrefix.length);
await client.updateFileNode(node.id, { name: newNodeName });
}
}
}
await client.updateFileNode(resource.id, { name: newName });
set({
lastAction: {
type: 'rename',
entries: [{ id: resource.id, from: { name: oldServerName }, to: { name: newServerName } }],
entries: [{ id: resource.id, from: { name: oldName }, to: { name: newName } }],
sourceParentId: null,
},
});
@@ -592,32 +560,28 @@ export const useFileStore = create<FileState>((set, get) => ({
},
createTextFile: async (name: string) => {
const { client, currentPath, refresh } = get();
const { client, currentParentId, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob);
await client.createFileNode(fullName, blobId, 'text/plain', 0, null);
await client.createFileNode(name, blobId, 'text/plain', 0, currentParentId);
await refresh();
},
duplicateResource: async (name: string) => {
const { client, resources, currentPath, refresh } = get();
const { client, resources, currentParentId, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
const fullCopyName = prefix + copyName;
await client.copyFileNode(resource.id, fullCopyName, null);
await client.copyFileNode(resource.id, copyName, currentParentId);
await refresh();
},
@@ -631,10 +595,9 @@ export const useFileStore = create<FileState>((set, get) => ({
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
const newServerName = targetResource.serverName + PATH_SEP + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
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(),
@@ -644,21 +607,19 @@ export const useFileStore = create<FileState>((set, get) => ({
},
moveToParent: async (names: string[]) => {
const { client, resources, currentPath, refresh } = get();
if (!client || currentPath === '/') return;
const { client, resources, pathStack, refresh } = get();
if (!client || pathStack.length <= 1) return;
const prefix = getPathPrefix(currentPath);
// Parent prefix: strip the last segment from the current prefix
// e.g. "foldersub" → "folder", "folder" → ""
const parentPrefix = prefix.slice(0, prefix.lastIndexOf(PATH_SEP, prefix.length - 2) + 1);
// 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;
const newServerName = parentPrefix + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
await client.updateFileNode(resource.id, { parentId: newParentId });
entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: newParentId } });
}
set({
selectedResources: new Set(),
@@ -668,38 +629,34 @@ export const useFileStore = create<FileState>((set, get) => ({
},
cutResources: (names: string[]) => {
const { currentPath, resources } = get();
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: null, sourcePath: currentPath } });
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath, resources } = get();
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: null, sourcePath: currentPath } });
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
},
pasteResources: async () => {
const { client, currentPath, clipboard, refresh } = get();
const { client, currentParentId, clipboard, refresh } = get();
if (!client || !clipboard) return;
const prefix = getPathPrefix(currentPath);
const entries: UndoAction['entries'] = [];
for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i];
const displayName = clipboard.names[i];
const oldServerName = clipboard.serverNames?.[i];
if (clipboard.mode === 'cut') {
const newServerName = prefix + displayName;
await client.updateFileNode(id, { name: newServerName });
entries.push({ id, from: { name: oldServerName }, to: { name: newServerName } });
await client.updateFileNode(id, { parentId: currentParentId });
entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } });
} else {
const fullName = prefix + displayName;
await client.copyFileNode(id, fullName, null);
await client.copyFileNode(id, displayName, currentParentId);
}
}
@@ -745,10 +702,10 @@ export const useFileStore = create<FileState>((set, get) => ({
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(path);
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
const allNodes = await client.listAllFileNodes();
const parentId = resolvePathToId(allNodes, path);
if (parentId === undefined) return [];
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
} catch {
return [];
}
@@ -758,21 +715,8 @@ export const useFileStore = create<FileState>((set, get) => ({
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
if (parentId === null) {
// Root level: nodes with simple names (no "/")
const rootNodes = allNodes.filter(n => !n.name.includes('/'));
return rootNodes.map(n => nodeToResource(n));
}
// Find the folder node to get its server name
const folder = allNodes.find(n => n.id === parentId);
if (!folder) return [];
const prefix = folder.name + PATH_SEP;
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
const allNodes = await client.listAllFileNodes();
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
} catch {
return [];
}