fix: remove flatname workaround, store Files as real FileNode #379
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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" -> "test∕sub∕"
|
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. "folder∕sub∕" → "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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user