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
+4
View File
@@ -902,6 +902,10 @@ export class DemoJMAPClient implements IJMAPClient {
return this.data.fileNodes.filter(n => n.parentId === parentId); return this.data.fileNodes.filter(n => n.parentId === parentId);
} }
async listAllFileNodes(): Promise<FileNode[]> {
return [...this.data.fileNodes];
}
async getFileNodes(ids: string[] | null): Promise<FileNode[]> { async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
if (ids === null) return [...this.data.fileNodes]; if (ids === null) return [...this.data.fileNodes];
return this.data.fileNodes.filter(n => ids.includes(n.id)); return this.data.fileNodes.filter(n => ids.includes(n.id));
+1
View File
@@ -306,6 +306,7 @@ export interface IJMAPClient {
getFilesAccountId(): string; getFilesAccountId(): string;
probeFileNodeSupport(): Promise<boolean>; probeFileNodeSupport(): Promise<boolean>;
listFileNodes(parentId: string | null): Promise<FileNode[]>; listFileNodes(parentId: string | null): Promise<FileNode[]>;
listAllFileNodes(): Promise<FileNode[]>;
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>; getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>; createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>; createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
+30
View File
@@ -5066,6 +5066,36 @@ export class JMAPClient implements IJMAPClient {
return nodes; return nodes;
} }
/**
* Fetch every FileNode in the account in a single query+get round-trip.
* Used to build the folder hierarchy client-side from parentId links,
* so we never depend on server-side `parentId` filtering being available.
*/
async listAllFileNodes(): Promise<FileNode[]> {
const accountId = this.getFilesAccountId();
const response = await this.request(
[
["FileNode/query", { accountId, filter: {} }, "fnq0"],
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
],
this.fileUsing(),
);
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0"));
if (queryResult && queryResult[0] === "error") {
console.error('[Files] FileNode/query error:', queryResult[1]);
throw new Error(queryResult[1]?.description || "FileNode/query failed");
}
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0"));
if (!getResult || getResult[0] === "error") {
console.error('[Files] FileNode/get error:', getResult?.[1]);
throw new Error(getResult?.[1]?.description || "FileNode list failed");
}
return (getResult[1].list || []) as FileNode[];
}
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> { async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId(); const accountId = this.getFilesAccountId();
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFileStore } from '../file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
// Minimal in-memory FileNode backend that models the JMAP hierarchy via
// parentId links (the behaviour issue #379 asks for).
function makeMockClient(initial: FileNode[] = []) {
let nodes: FileNode[] = initial.map(n => ({ ...n }));
let seq = 0;
const now = () => new Date().toISOString();
const client = {
getCapabilities: () => ({}),
probeFileNodeSupport: async () => true,
getFilesAccountId: () => 'acct',
async listAllFileNodes() {
return nodes.map(n => ({ ...n }));
},
async listFileNodes(parentId: string | null) {
return nodes.filter(n => (n.parentId ?? null) === parentId).map(n => ({ ...n }));
},
async getFileNodes(ids: string[] | null) {
return (ids === null ? nodes : nodes.filter(n => ids.includes(n.id))).map(n => ({ ...n }));
},
async createFileDirectory(name: string, parentId: string | null) {
const node: FileNode = { id: `n${++seq}`, parentId, name, type: 'd', blobId: null, size: 0, created: now(), updated: now() };
nodes.push(node);
return { ...node };
},
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) {
const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: now(), updated: now() };
nodes.push(node);
return { ...node };
},
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>) {
const node = nodes.find(n => n.id === id);
if (node) Object.assign(node, updates);
},
async destroyFileNodes(ids: string[]) {
// Emulate Stalwart's onDestroyRemoveChildren: removing a directory also
// removes its whole subtree.
const toRemove = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && toRemove.has(n.parentId) && !toRemove.has(n.id)) {
toRemove.add(n.id);
grew = true;
}
}
}
nodes = nodes.filter(n => !toRemove.has(n.id));
return { destroyed: [...toRemove], notDestroyed: [] };
},
async copyFileNode(id: string, newName: string, parentId: string | null) {
const original = nodes.find(n => n.id === id);
if (!original) throw new Error('not found');
return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId);
},
async uploadBlob(file: File) {
return { blobId: `blob${++seq}`, type: file.type };
},
// test helper
_nodes: () => nodes,
};
return client as unknown as IJMAPClient & { _nodes: () => FileNode[] };
}
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'd', blobId: null, size: 0, created: '', updated: '',
});
const file = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', updated: '',
});
describe('file-store hierarchy (issue #379)', () => {
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
selectedResources: new Set(),
clipboard: null,
lastAction: null,
});
});
it('lists only root-level nodes at the root, not the whole tree flattened', async () => {
// Stuff > Nonsense > Notes.md / Other.md (the exact shape from the issue)
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', 'Nonsense', 'stuff'),
file('notes', 'Notes.md', 'nonsense'),
file('other', 'Other.md', 'nonsense'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
const names = useFileStore.getState().resources.map(r => r.name);
expect(names).toEqual(['Stuff']);
});
it('navigates into a folder via parentId and shows its direct children', async () => {
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', 'Nonsense', 'stuff'),
file('notes', 'Notes.md', 'nonsense'),
file('other', 'Other.md', 'nonsense'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('stuff', 'Stuff');
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['Nonsense']);
await useFileStore.getState().navigate('nonsense', 'Nonsense');
expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Other.md']);
});
it('creates a directory as a child node with a plain name (no path encoding)', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('stuff', 'Stuff');
await useFileStore.getState().createDirectory('Nonsense');
const created = client._nodes().find(n => n.name === 'Nonsense');
expect(created).toBeDefined();
expect(created!.parentId).toBe('stuff');
expect(created!.name).toBe('Nonsense'); // not "Stuff/Nonsense"
});
it('uploads a file into the current folder as a child node', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('stuff', 'Stuff');
await useFileStore.getState().uploadFile(new File(['hi'], 'Notes.md', { type: 'text/markdown' }));
const uploaded = client._nodes().find(n => n.name === 'Notes.md');
expect(uploaded!.parentId).toBe('stuff');
});
it('moves a node into a folder by reparenting (parentId), not by renaming', async () => {
const client = makeMockClient([
dir('b', 'B', null),
file('f', 'f.txt', null),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
// Drag f.txt onto the B folder visible in the current (root) view.
await useFileStore.getState().moveToFolder(['f.txt'], 'B');
const moved = client._nodes().find(n => n.id === 'f')!;
expect(moved.parentId).toBe('b');
expect(moved.name).toBe('f.txt'); // name untouched
});
it('renames a node by changing only its name', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
await useFileStore.getState().renameResource('Stuff', 'Things');
const node = client._nodes().find(n => n.id === 'stuff')!;
expect(node.name).toBe('Things');
expect(node.parentId).toBeNull();
});
it('deletes a folder and the server removes its subtree', async () => {
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', 'Nonsense', 'stuff'),
file('notes', 'Notes.md', 'nonsense'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
await useFileStore.getState().deleteResource('Stuff');
expect(client._nodes()).toHaveLength(0);
});
});
+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']); 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 { function isDirectoryType(type: string | undefined): boolean {
if (!type) return false; if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory'); return DIRECTORY_TYPES.has(type) || type.includes('directory');
} }
// Convert currentPath to a server-side name prefix for filtering // Direct children of a given parent in the FileNode hierarchy.
// "/" -> "", "/test" -> "test", "/test/sub" -> "testsub" function childrenOf(nodes: FileNode[], parentId: string | null): FileNode[] {
function getPathPrefix(currentPath: string): string { return nodes.filter(n => (n.parentId ?? null) === parentId);
if (currentPath === '/') return '';
return currentPath.slice(1).replace(/\//g, PATH_SEP) + PATH_SEP;
} }
// Filter nodes to only direct children of a path prefix function nodeToResource(node: FileNode): FileResource {
function filterNodesByPrefix(nodes: FileNode[], prefix: string): FileNode[] {
if (prefix === '') {
// Root: nodes whose names have no PATH_SEP
return nodes.filter(n => !n.name.includes(PATH_SEP));
}
// Subfolder: nodes starting with prefix, with no additional PATH_SEP after the prefix
return nodes.filter(n => {
if (!n.name.startsWith(prefix)) return false;
const remaining = n.name.slice(prefix.length);
return remaining.length > 0 && !remaining.includes(PATH_SEP);
});
}
function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
const displayName = pathPrefix && node.name.startsWith(pathPrefix)
? node.name.slice(pathPrefix.length)
: node.name;
const isDir = isDirectoryType(node.type); const isDir = isDirectoryType(node.type);
return { return {
id: node.id, id: node.id,
name: displayName, name: node.name,
serverName: node.name, serverName: node.name,
isDirectory: isDir, isDirectory: isDir,
contentType: isDir ? '' : node.type, 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 { function getUniqueName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) return name; if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.'); 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 { localStorage.setItem('files-path-stack', JSON.stringify(newStack)); } catch { /* ignore */ }
try { try {
// Always fetch all nodes from root - Stalwart doesn't support parentId nesting // Fetch the whole tree once and select the current parent's direct
const allNodes = await client.listFileNodes(null); // children locally. Hierarchy is derived from parentId links, exactly as
const prefix = getPathPrefix(newPath); // the JMAP FileNode spec intends (issue #379).
const filteredNodes = filterNodesByPrefix(allNodes, prefix); const allNodes = await client.listAllFileNodes();
const resources = filteredNodes.map(n => nodeToResource(n, prefix)); const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
// 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);
});
// Prune recent files whose backing node no longer exists on the server // Prune recent files whose backing node no longer exists on the server
const { recentFiles } = get(); const { recentFiles } = get();
@@ -295,7 +290,18 @@ export const useFileStore = create<FileState>((set, get) => ({
return; 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 () => { navigateUp: async () => {
@@ -312,21 +318,17 @@ export const useFileStore = create<FileState>((set, get) => ({
}, },
createDirectory: async (name: string) => { createDirectory: async (name: string) => {
const { client, currentPath, refresh } = get(); const { client, currentParentId, refresh } = get();
if (!client) return; if (!client) return;
const prefix = getPathPrefix(currentPath); await client.createFileDirectory(name, currentParentId);
const fullName = prefix + name;
await client.createFileDirectory(fullName, null);
await refresh(); await refresh();
}, },
uploadFile: async (file: File) => { uploadFile: async (file: File) => {
const { client, currentPath } = get(); const { client, currentParentId } = get();
if (!client) return; if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + file.name;
const abortController = new AbortController(); const abortController = new AbortController();
set({ uploadAbortController: abortController }); set({ uploadAbortController: abortController });
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } }); 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; if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } }); 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 { } finally {
set({ uploadProgress: null, uploadAbortController: null }); set({ uploadProgress: null, uploadAbortController: null });
} }
}, },
uploadFiles: async (files: File[]) => { uploadFiles: async (files: File[]) => {
const { client, currentPath, resources } = get(); const { client, currentParentId, resources } = get();
if (!client) return; if (!client) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController(); const abortController = new AbortController();
set({ uploadAbortController: abortController }); set({ uploadAbortController: abortController });
const totalFiles = files.length; const totalFiles = files.length;
@@ -362,7 +363,6 @@ export const useFileStore = create<FileState>((set, get) => ({
const file = files[i]; const file = files[i];
const uniqueName = getUniqueName(file.name, existingNames); const uniqueName = getUniqueName(file.name, existingNames);
existingNames.add(uniqueName); existingNames.add(uniqueName);
const fullName = prefix + uniqueName;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } }); set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try { try {
@@ -375,7 +375,7 @@ export const useFileStore = create<FileState>((set, get) => ({
}); });
if (abortController.signal.aborted) break; if (abortController.signal.aborted) break;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); 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) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break; if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null }); set({ uploadProgress: null, uploadAbortController: null });
@@ -395,15 +395,15 @@ export const useFileStore = create<FileState>((set, get) => ({
}, },
uploadFolder: async (files: File[]) => { uploadFolder: async (files: File[]) => {
const { client, currentPath } = get(); const { client, currentParentId } = get();
if (!client || files.length === 0) return; if (!client || files.length === 0) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController(); const abortController = new AbortController();
set({ uploadAbortController: abortController }); set({ uploadAbortController: abortController });
const totalFiles = files.length; 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>(); const dirs = new Set<string>();
for (const file of files) { for (const file of files) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; 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) // Map a directory path to its created node id. Root ('') maps to the
// Convert "/" separators from webkitRelativePath to PATH_SEP () for server names // 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); const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) { for (const dir of sortedDirs) {
if (abortController.signal.aborted) break; 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 { try {
await client.createFileDirectory(fullDirName, null); const created = await client.createFileDirectory(dirName, parentId);
dirIds.set(dir, created.id);
} catch { } 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++) { for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break; if (abortController.signal.aborted) break;
const file = files[i]; const file = files[i];
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; 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 } }); 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); const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break; if (abortController.signal.aborted) break;
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); 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) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break; if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null }); set({ uploadProgress: null, uploadAbortController: null });
@@ -457,22 +467,9 @@ export const useFileStore = create<FileState>((set, get) => ({
const resource = resources.find(r => r.name === name); const resource = resources.find(r => r.name === name);
if (!resource) return; if (!resource) return;
const idsToDelete = [resource.id]; // The server removes descendant nodes (onDestroyRemoveChildren).
await client.destroyFileNodes([resource.id]);
// If deleting a folder, also delete all files inside it const nextRecentFiles = recentFiles.filter(r => r.id !== resource.id);
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));
set({ recentFiles: nextRecentFiles }); set({ recentFiles: nextRecentFiles });
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ } try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
await refresh(); await refresh();
@@ -483,26 +480,14 @@ export const useFileStore = create<FileState>((set, get) => ({
if (!client) return; if (!client) return;
const idsToDelete: string[] = []; const idsToDelete: string[] = [];
let allNodes: FileNode[] | null = null;
for (const name of names) { for (const name of names) {
const resource = resources.find(r => r.name === name); const resource = resources.find(r => r.name === name);
if (!resource) continue; if (resource) idsToDelete.push(resource.id);
idsToDelete.push(resource.id);
if (resource.isDirectory) {
if (!allNodes) allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
} }
if (idsToDelete.length === 0) return; if (idsToDelete.length === 0) return;
// The server removes descendant nodes (onDestroyRemoveChildren).
await client.destroyFileNodes(idsToDelete); await client.destroyFileNodes(idsToDelete);
const deletedIdSet = new Set(idsToDelete); const deletedIdSet = new Set(idsToDelete);
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id)); 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) => { renameResource: async (oldName: string, newName: string) => {
const { client, resources, currentPath, refresh } = get(); const { client, resources, refresh } = get();
if (!client) return; if (!client) return;
const resource = resources.find(r => r.name === oldName); const resource = resources.find(r => r.name === oldName);
if (!resource) return; if (!resource) return;
const prefix = getPathPrefix(currentPath); await client.updateFileNode(resource.id, { name: newName });
const oldServerName = resource.serverName;
const newServerName = prefix + newName;
await client.updateFileNode(resource.id, { name: newServerName });
// If renaming a folder, also rename all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(oldFolderPrefix)) {
const newNodeName = newFolderPrefix + node.name.slice(oldFolderPrefix.length);
await client.updateFileNode(node.id, { name: newNodeName });
}
}
}
set({ set({
lastAction: { lastAction: {
type: 'rename', type: 'rename',
entries: [{ id: resource.id, from: { name: oldServerName }, to: { name: newServerName } }], entries: [{ id: resource.id, from: { name: oldName }, to: { name: newName } }],
sourceParentId: null, sourceParentId: null,
}, },
}); });
@@ -592,32 +560,28 @@ export const useFileStore = create<FileState>((set, get) => ({
}, },
createTextFile: async (name: string) => { createTextFile: async (name: string) => {
const { client, currentPath, refresh } = get(); const { client, currentParentId, refresh } = get();
if (!client) return; if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
const emptyBlob = new File([''], name, { type: 'text/plain' }); const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob); 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(); await refresh();
}, },
duplicateResource: async (name: string) => { duplicateResource: async (name: string) => {
const { client, resources, currentPath, refresh } = get(); const { client, resources, currentParentId, refresh } = get();
if (!client) return; if (!client) return;
const resource = resources.find(r => r.name === name); const resource = resources.find(r => r.name === name);
if (!resource) return; if (!resource) return;
const prefix = getPathPrefix(currentPath);
const dotIdx = name.lastIndexOf('.'); const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0 const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}` ? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`; : `${name} (copy)`;
const fullCopyName = prefix + copyName;
await client.copyFileNode(resource.id, fullCopyName, null); await client.copyFileNode(resource.id, copyName, currentParentId);
await refresh(); await refresh();
}, },
@@ -631,10 +595,9 @@ export const useFileStore = create<FileState>((set, get) => ({
const entries: UndoAction['entries'] = []; const entries: UndoAction['entries'] = [];
for (const name of names) { for (const name of names) {
const resource = resources.find(r => r.name === name); const resource = resources.find(r => r.name === name);
if (!resource) continue; if (!resource || resource.id === targetResource.id) continue;
const newServerName = targetResource.serverName + PATH_SEP + resource.name; await client.updateFileNode(resource.id, { parentId: targetResource.id });
await client.updateFileNode(resource.id, { name: newServerName }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetResource.id } });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
} }
set({ set({
selectedResources: new Set(), selectedResources: new Set(),
@@ -644,21 +607,19 @@ export const useFileStore = create<FileState>((set, get) => ({
}, },
moveToParent: async (names: string[]) => { moveToParent: async (names: string[]) => {
const { client, resources, currentPath, refresh } = get(); const { client, resources, pathStack, refresh } = get();
if (!client || currentPath === '/') return; if (!client || pathStack.length <= 1) return;
const prefix = getPathPrefix(currentPath); // Move into the grandparent of the current folder's contents, i.e. the
// Parent prefix: strip the last segment from the current prefix // entry one level up in the breadcrumb stack.
// e.g. "foldersub" → "folder", "folder" → "" const newParentId = pathStack[pathStack.length - 2].id;
const parentPrefix = prefix.slice(0, prefix.lastIndexOf(PATH_SEP, prefix.length - 2) + 1);
const entries: UndoAction['entries'] = []; const entries: UndoAction['entries'] = [];
for (const name of names) { for (const name of names) {
const resource = resources.find(r => r.name === name); const resource = resources.find(r => r.name === name);
if (!resource) continue; if (!resource) continue;
const newServerName = parentPrefix + resource.name; await client.updateFileNode(resource.id, { parentId: newParentId });
await client.updateFileNode(resource.id, { name: newServerName }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: newParentId } });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
} }
set({ set({
selectedResources: new Set(), selectedResources: new Set(),
@@ -668,38 +629,34 @@ export const useFileStore = create<FileState>((set, get) => ({
}, },
cutResources: (names: string[]) => { 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 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 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[]) => { 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 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 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 () => { pasteResources: async () => {
const { client, currentPath, clipboard, refresh } = get(); const { client, currentParentId, clipboard, refresh } = get();
if (!client || !clipboard) return; if (!client || !clipboard) return;
const prefix = getPathPrefix(currentPath);
const entries: UndoAction['entries'] = []; const entries: UndoAction['entries'] = [];
for (let i = 0; i < clipboard.ids.length; i++) { for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i]; const id = clipboard.ids[i];
const displayName = clipboard.names[i]; const displayName = clipboard.names[i];
const oldServerName = clipboard.serverNames?.[i];
if (clipboard.mode === 'cut') { if (clipboard.mode === 'cut') {
const newServerName = prefix + displayName; await client.updateFileNode(id, { parentId: currentParentId });
await client.updateFileNode(id, { name: newServerName }); entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } });
entries.push({ id, from: { name: oldServerName }, to: { name: newServerName } });
} else { } else {
const fullName = prefix + displayName; await client.copyFileNode(id, displayName, currentParentId);
await client.copyFileNode(id, fullName, null);
} }
} }
@@ -745,10 +702,10 @@ export const useFileStore = create<FileState>((set, get) => ({
if (!client) return []; if (!client) return [];
try { try {
const allNodes = await client.listFileNodes(null); const allNodes = await client.listAllFileNodes();
const prefix = getPathPrefix(path); const parentId = resolvePathToId(allNodes, path);
const filtered = filterNodesByPrefix(allNodes, prefix); if (parentId === undefined) return [];
return filtered.map(n => nodeToResource(n, prefix)); return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
} catch { } catch {
return []; return [];
} }
@@ -758,21 +715,8 @@ export const useFileStore = create<FileState>((set, get) => ({
const { client } = get(); const { client } = get();
if (!client) return []; if (!client) return [];
try { try {
const allNodes = await client.listFileNodes(null); const allNodes = await client.listAllFileNodes();
return sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
if (parentId === null) {
// Root level: nodes with simple names (no "/")
const rootNodes = allNodes.filter(n => !n.name.includes('/'));
return rootNodes.map(n => nodeToResource(n));
}
// Find the folder node to get its server name
const folder = allNodes.find(n => n.id === parentId);
if (!folder) return [];
const prefix = folder.name + PATH_SEP;
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} catch { } catch {
return []; return [];
} }