import { computed, onScopeDispose, ref, shallowRef } from 'vue' import type { PickerAdapter, PickerNode, PickerRequest, PickerSort, PickerTreeRow, } from '../../types/filePicker' const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare /** Folders first; undated rows go last in either time order. */ function ordered(items: PickerNode[], sort: PickerSort | undefined): PickerNode[] { if (!sort) return items // Parse each timestamp once rather than on every comparison. const times = new Map(items.map((node) => [node, node.modifiedAt ? Date.parse(node.modifiedAt) : NaN])) const time = (node: PickerNode) => times.get(node)! return [...items].sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1 if (sort === 'name') return byName(a.name, b.name) if (sort === 'name-desc') return byName(b.name, a.name) const [ta, tb] = [time(a), time(b)] if (Number.isNaN(ta) || Number.isNaN(tb)) return Number.isNaN(ta) === Number.isNaN(tb) ? byName(a.name, b.name) : Number.isNaN(ta) ? 1 : -1 return (sort === 'newest' ? tb - ta : ta - tb) || byName(a.name, b.name) }) } export function usePickerNavigation( adapter: () => PickerAdapter, request: () => PickerRequest, systemFilter: (node: PickerNode) => boolean = () => true, sort: () => PickerSort | undefined = () => undefined, ) { const path = shallowRef('') const trail = ref<{ path: string; label: string }[]>([]) const entries = shallowRef([]) const nodes = ref(new Map()) const children = shallowRef(new Map()) const expanded = ref(new Set()) const expanding = ref(new Set()) const expansionErrors = ref(new Map()) const loading = shallowRef(false) const error = shallowRef('') /** True when the current search results stopped at an adapter limit. */ const truncated = shallowRef(false) let epoch = 0 let navigation = 0 let controller: AbortController | undefined const pending = new Map>() const childControllers = new Set() let prefetchTimer: ReturnType | undefined let prefetchQueue: PickerNode[] = [] let prefetchActive = 0 function stopPrefetch() { clearTimeout(prefetchTimer) prefetchQueue = [] } function drainPrefetch() { while (prefetchActive < 2 && prefetchQueue.length) { const node = prefetchQueue.shift()! if (!isAllowed(node) || children.value.has(node.path) || pending.has(node.path)) continue prefetchActive++ const token = epoch void loadChildren(node.path).catch(() => { // Speculative failures are retried and reported only when the folder is opened. }).finally(() => { if (token !== epoch) return prefetchActive-- drainPrefetch() }) } } function prefetch(items: PickerNode[]) { stopPrefetch() // ponytail: warm at most 20 immediate folders, two at a time; use a server index for full-tree search. const candidates = items.filter((node) => node.kind === 'folder' && !node.unavailableReason && node.hasChildren !== false && isAllowed(node)).slice(0, 20) prefetchTimer = setTimeout(() => { prefetchQueue = candidates drainPrefetch() }, 100) } function isAllowed(node: PickerNode): boolean { const seen = new Set() let current: PickerNode | undefined = nodes.value.get(node.path) ?? node while (current && !seen.has(current.path)) { if (!systemFilter(current)) return false seen.add(current.path) current = current.parentPath ? nodes.value.get(current.parentPath) : undefined } return true } function register(items: PickerNode[], parent?: string) { for (const item of items) nodes.value.set(item.path, { ...item, parentPath: item.parentPath !== undefined ? item.parentPath : parent !== undefined ? parent || null : (nodes.value.get(item.path)?.parentPath ?? null), }) } function invalidate() { adapter().invalidate?.() stopPrefetch() prefetchActive = 0 ++epoch ++navigation controller?.abort() childControllers.forEach((item) => item.abort()) childControllers.clear() pending.clear() children.value = new Map() expanding.value.clear() expansionErrors.value.clear() } function clear() { invalidate() nodes.value.clear() expanded.value.clear() expanding.value.clear() expansionErrors.value.clear() entries.value = [] loading.value = false error.value = '' } async function loadChildren( folder: string, ancestors: string[] = [], refresh = false, ): Promise { if (ancestors.includes(folder)) throw new Error('The adapter returned a circular folder tree.') const cached = children.value.get(folder) if (cached) return cached const existing = pending.get(folder) if (existing) return existing const token = epoch const abort = new AbortController() childControllers.add(abort) const task = adapter() .listChildren(folder, { ...request(), ...(refresh && { refresh }), signal: abort.signal }) .then((items) => { if (token !== epoch) throw new DOMException('Cancelled', 'AbortError') const retained = new Set(items.map((item) => item.path)) function remove(path: string, seen = new Set()) { if (seen.has(path) || path === folder) return seen.add(path) for (const node of nodes.value.values()) if (node.parentPath === path) remove(node.path, seen) nodes.value.delete(path) children.value.delete(path) } for (const node of nodes.value.values()) if (node.parentPath === folder && !retained.has(node.path)) remove(node.path) register(items, folder) children.value = new Map(children.value).set(folder, items) return items }) .finally(() => { childControllers.delete(abort) if (token === epoch) pending.delete(folder) }) pending.set(folder, task) return task } async function load(query = '', recursive = false, refresh = false) { stopPrefetch() const token = ++navigation controller?.abort() controller = new AbortController() const options = { ...request(), ...(refresh && { refresh }), signal: controller.signal } loading.value = true error.value = '' truncated.value = false entries.value = [] try { const found = query.trim() ? await adapter().search(query.trim(), { path: path.value, recursive }, options) : path.value ? await loadChildren(path.value, [], refresh) : await adapter().listRoot(options) if (token !== navigation) return const items = Array.isArray(found) ? found : found.nodes truncated.value = !Array.isArray(found) && found.truncated register(items, query.trim() ? undefined : path.value) entries.value = items if (!query.trim()) prefetch(items) } catch (cause) { if (token === navigation) error.value = cause instanceof Error ? cause.message : 'Could not read this location.' } finally { if (token === navigation) loading.value = false } } async function expand(node: PickerNode) { if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return if (expanded.value.has(node.path)) { expanded.value.delete(node.path) return } expanded.value.add(node.path) expanding.value.add(node.path) expansionErrors.value.delete(node.path) const token = epoch const view = navigation try { const items = await loadChildren(node.path) if (token === epoch && view === navigation && expanded.value.has(node.path)) prefetch(items) } catch (cause) { if (token === epoch) expansionErrors.value.set( node.path, cause instanceof Error ? cause.message : 'Could not read this folder.', ) } finally { if (token === epoch) expanding.value.delete(node.path) } } function enter(node: PickerNode) { if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return const index = trail.value.findIndex((crumb) => crumb.path === node.path) trail.value = index >= 0 ? trail.value.slice(0, index + 1) : [...trail.value, { path: node.path, label: node.name }] path.value = node.path } function up(target?: string) { const index = target !== undefined ? trail.value.findIndex((crumb) => crumb.path === target) : trail.value.length - 2 trail.value = index < 0 ? [] : trail.value.slice(0, index + 1) path.value = trail.value[trail.value.length - 1]?.path ?? '' } const rows = computed(() => { const result: PickerTreeRow[] = [] const order = sort() function visit(items: PickerNode[], depth: number, ancestors: Set) { for (const item of ordered(items, order)) { if (ancestors.has(item.path) || depth > 30) continue const node = nodes.value.get(item.path) ?? item if (!isAllowed(node)) continue result.push({ node, depth, expanded: expanded.value.has(item.path), loading: expanding.value.has(item.path), error: expansionErrors.value.get(item.path), }) if (expanded.value.has(item.path)) visit(children.value.get(item.path) ?? [], depth + 1, new Set([...ancestors, item.path])) } } visit(entries.value, 0, new Set()) return result }) onScopeDispose(clear) return { path, trail, entries, nodes, children, expanded, expanding, expansionErrors, loading, error, truncated, rows, isAllowed, clear, invalidate, register, loadChildren, load, expand, enter, up, } }