import type { PickerAdapter, PickerNode, PickerRequest, PickerSearchResult } from '../types/filePicker' import type { FileBrowserLocation, FileDirectoryListing, FileDirectoryTree, FileEntry, FileSearchResult, ServerMount, } from '../types/fileBrowserTypes' import { pickerFormat } from './filePicker/validation' export interface FilePickerTransport { listMounts(request?: PickerRequest): Promise browse(location: FileBrowserLocation, request?: PickerRequest): Promise /** `GET …/filesystem/tree`: one request fills the cache for the subfolders too. */ tree?(location: FileBrowserLocation, request?: PickerRequest): Promise /** `GET …/filesystem/search`: replaces the folder-by-folder search walk. */ search?( location: FileBrowserLocation, query: string, recursive: boolean, request?: PickerRequest, ): Promise } export interface FilePickerAdapterOptions { /** Show this directory's contents directly and prevent navigation outside it. */ rootLocation?: FileBrowserLocation } /** Opaque picker identity; decode before passing a selection to a mount-scoped API. */ export function encodePlatformPickerPath(mountId: string, path = ''): string { return JSON.stringify([mountId, path]) } export function decodePlatformPickerPath(path: string): { mountId: string; path: string } { const decoded: unknown = JSON.parse(path) if ( !Array.isArray(decoded) || decoded.length !== 2 || !decoded.every((item) => typeof item === 'string') || !decoded[0] ) throw new Error('Invalid platform picker path.') return { mountId: decoded[0], path: decoded[1] } } function isRelativePath(path: string): boolean { return path === '' || (!/^[a-z]:/i.test(path) && !/[\\\x00-\x1f\x7f]/.test(path) && path.split('/').every((part) => part !== '' && part !== '.' && part !== '..')) } /** Mount browsing and search shared by platform and generated plugin clients. */ export function createFilePickerAdapter( transport: FilePickerTransport, options: FilePickerAdapterOptions = {}, ): PickerAdapter { const root = options.rootLocation && { ...options.rootLocation } const mountLabels = new Map() // Per picker instance: never share permission-sensitive listings across clients. const listings = new Map() let generation = 0 function invalidate(): void { generation++ listings.clear() mountLabels.clear() } function validateLocation(location: FileBrowserLocation) { if (!location.mount_id || !isRelativePath(location.path) || (root && ( location.mount_id !== root.mount_id || (root.path !== '' && location.path !== root.path && !location.path.startsWith(`${root.path}/`)) ))) throw new Error('Invalid file picker location.') } if (root) validateLocation(root) function begin(request?: PickerRequest): number { request?.signal?.throwIfAborted() if (request?.refresh) invalidate() return generation } function check(request: PickerRequest | undefined, token: number) { request?.signal?.throwIfAborted() if (token !== generation) throw new DOMException('Cancelled', 'AbortError') } async function read(path: string, request: PickerRequest | undefined, token: number): Promise { check(request, token) const decoded = path ? decodePlatformPickerPath(path) : undefined const location = decoded ? { mount_id: decoded.mountId, path: decoded.path } : root if (location) validateLocation(location) const key = location ? encodePlatformPickerPath(location.mount_id, location.path) : '' const cached = listings.get(key) if (cached) return cached let nodes: PickerNode[] if (!location) { const mounts = await transport.listMounts(request) check(request, token) nodes = mounts.map((mount) => { mountLabels.set(mount.id, mount.label) return { kind: 'folder', path: encodePlatformPickerPath(mount.id), name: mount.label, parentPath: null, parentName: 'Server mounts', unavailableReason: mount.available === false ? 'This mount is offline.' : undefined, } }) } else if (transport.tree) { const tree = await transport.tree(location, request) check(request, token) for (const listing of tree.listings ?? []) { const at = { mount_id: listing.mount_id, path: listing.path } validateLocation(at) if (at.mount_id !== location.mount_id) throw new Error('Invalid file picker directory entry.') // A capped subfolder is read on its own when opened, and reports the cap then. if (!listing.truncated) listings.set(encodePlatformPickerPath(at.mount_id, at.path), toNodes(at, listing)) } const cached = listings.get(key) if (!cached) throw new Error('This directory exceeds the server listing limit. Choose a smaller data directory.') return cached } else { const listing = await transport.browse(location, request) check(request, token) if (listing.truncated) throw new Error('This directory exceeds the server listing limit. Choose a smaller data directory.') nodes = toNodes(location, listing) } listings.set(key, nodes) return nodes } function toNodes(location: FileBrowserLocation, listing: FileDirectoryListing): PickerNode[] { return (listing.entries ?? []).map((entry) => { if (entry.path !== `${location.path ? `${location.path}/` : ''}${entry.name}`) throw new Error('Invalid file picker directory entry.') return toNode(location.mount_id, entry) }) } /** One entry, parented on the folder its mount-relative path names. */ function toNode(mountId: string, entry: FileEntry): PickerNode { if (!isRelativePath(entry.path) || !entry.name || entry.name.includes('/') || (entry.path !== entry.name && !entry.path.endsWith(`/${entry.name}`))) throw new Error('Invalid file picker directory entry.') const parent = entry.path.slice(0, Math.max(0, entry.path.length - entry.name.length - 1)) const atRoot = root && mountId === root.mount_id && parent === root.path const common = { path: encodePlatformPickerPath(mountId, entry.path), parentPath: atRoot ? null : encodePlatformPickerPath(mountId, parent), parentName: `${mountLabels.get(mountId) ?? mountId}${parent ? ` / ${parent}` : ''}`, name: entry.name, bytes: entry.size_bytes, modifiedAt: entry.modified_at, } return entry.kind === 'directory' // Counts may include only matching formats, not nested directories. ? { ...common, kind: 'folder' } : { ...common, kind: 'file', format: pickerFormat(entry.name) } } async function serverSearch( query: string, scope: { path: string; recursive: boolean }, request: PickerRequest | undefined, ): Promise { const token = begin(request) const decoded = scope.path ? decodePlatformPickerPath(scope.path) : undefined const location = decoded ? { mount_id: decoded.mountId, path: decoded.path } : root! validateLocation(location) const result = await transport.search!(location, query, scope.recursive, request) check(request, token) const prefix = location.path ? `${location.path}/` : '' const nodes = (result.entries ?? []).map((entry) => { // "This folder" searches may return direct children only. if (!entry.path.startsWith(prefix) || (!scope.recursive && entry.path.slice(prefix.length).includes('/'))) throw new Error('Invalid file picker directory entry.') return toNode(location.mount_id, entry) }) return result.truncated ? { nodes, truncated: true } : nodes } return { mounts: !root, mountOf(path) { try { return encodePlatformPickerPath(decodePlatformPickerPath(path).mountId) } catch { return undefined // initialPath is caller input; the listing reports a bad one. } }, invalidate, listRoot: async (request) => read('', request, begin(request)), listChildren: async (path, request) => read(path, request, begin(request)), async search(query, scope, request) { // The mount list itself has no server to search, so it keeps the walk. if (transport.search && (scope.path || root)) return serverSearch(query, scope, request) const token = begin(request) const matches: PickerNode[] = [] const queue = [scope.path] const visited = new Set() const needle = query.toLowerCase() let currentRequest = request while (queue.length) { check(request, token) const path = queue.shift()! if (visited.has(path)) continue visited.add(path) if (visited.size > 500) return { nodes: matches, truncated: true } const nodes = await read(path, currentRequest, token) check(request, token) if (currentRequest?.refresh) { currentRequest = { ...currentRequest } delete currentRequest.refresh } for (const node of nodes) { if (node.name.toLowerCase().includes(needle)) matches.push(node) if (scope.recursive && node.kind === 'folder' && !node.unavailableReason) queue.push(node.path) } if (matches.length > 10000) return { nodes: matches.slice(0, 10000), truncated: true } } return matches }, } }