import { computed, ref, shallowRef, watch } from 'vue' import type { PickerCapability, PickerExcluded, PickerFile, PickerNode, PickerSelection, PickerSelectionMode, } from '../../types/filePicker' import type { usePickerNavigation } from './usePickerNavigation' import { pickerExclusion } from './validation' type Navigation = ReturnType export function usePickerSelection( nav: Navigation, mode: () => PickerSelectionMode, capability: () => PickerCapability | undefined, skipBlanks: () => boolean, ) { const keys = ref(new Set()) const dropped = ref(new Set()) const focusPath = shallowRef('') const resolving = shallowRef(false) const resolutionError = shallowRef('') let anchor = '' let generation = 0 function ancestorSelected(node: PickerNode, candidates = keys.value): boolean { const seen = new Set() let parent = node.parentPath while (parent && !seen.has(parent)) { if (candidates.has(parent)) return true seen.add(parent) parent = nav.nodes.value.get(parent)?.parentPath } return false } function exclusion(node: PickerNode) { if (!nav.isAllowed(node)) return { path: node.path, name: node.name, reason: 'forbidden' as const } const own = pickerExclusion(node, capability(), skipBlanks()) if (own) return own const seen = new Set() let parent = node.parentPath while (parent && !seen.has(parent)) { seen.add(parent) const folder = nav.nodes.value.get(parent) if (!folder) break const inherited = pickerExclusion(folder, capability(), skipBlanks()) if (inherited) return { ...inherited, path: node.path, name: node.name } parent = folder.parentPath } return null } function selected(node: PickerNode): boolean { return ( !exclusion(node) && !dropped.value.has(node.path) && !ancestorSelected(node, dropped.value) && (keys.value.has(node.path) || (mode() === 'folder+files' && ancestorSelected(node))) ) } function selectable(node: PickerNode): boolean { if (exclusion(node)) return false return node.kind === 'folder' ? mode() === 'folder' || mode() === 'folder+files' : mode() !== 'folder' } function select( node: PickerNode, modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; toggle?: boolean } = {}, ) { if (exclusion(node)) return focusPath.value = node.path if (!selectable(node)) return const multi = mode() === 'multi-file' || mode() === 'folder+files' // Multi modes behave like a checklist: a plain click (or Space) adds or // removes one row, Shift extends a range, Ctrl/Cmd is accepted for habit. const additive = multi && (modifiers.metaKey || modifiers.ctrlKey || modifiers.toggle) if (multi && modifiers.shiftKey && anchor) { const rows = nav.rows.value.map((row) => row.node) const from = rows.findIndex((item) => item.path === anchor) const to = rows.findIndex((item) => item.path === node.path) if (from >= 0 && to >= 0) { keys.value = new Set([ ...(additive ? keys.value : []), ...rows .slice(Math.min(from, to), Math.max(from, to) + 1) .filter(selectable) .map((item) => item.path), ]) dropped.value.clear() return } } if (additive || (modifiers.toggle && (selected(node) || dropped.value.has(node.path)))) { if (mode() === 'folder+files' && ancestorSelected(node)) { if (dropped.value.has(node.path)) dropped.value.delete(node.path) else dropped.value.add(node.path) } else if (keys.value.has(node.path)) keys.value.delete(node.path) else { keys.value.add(node.path) dropped.value.delete(node.path) } } else { keys.value = new Set([node.path]) dropped.value.clear() } anchor = node.path } function reset(selection?: PickerSelection | null) { ++generation keys.value.clear() dropped.value.clear() resolving.value = false resolutionError.value = '' focusPath.value = '' if (selection?.source === 'server') { const files = mode() === 'folder' ? [] : mode() === 'single-file' ? selection.files.slice(0, 1) : selection.files const folders = mode() === 'folder' ? selection.folders.slice(0, 1) : mode() === 'folder+files' ? selection.folders : [] nav.register(files) folders.forEach((path) => { if (!nav.nodes.value.has(path)) nav.register([ { kind: 'folder', path, name: 'Selected folder', parentName: 'Selected location' }, ]) keys.value.add(path) }) files.forEach((file) => keys.value.add(file.path)) selection.excluded .filter((item) => item.reason === 'deselected') .forEach((item) => dropped.value.add(item.path)) } } const roots = computed(() => [...keys.value] .map((key) => nav.nodes.value.get(key)) .filter((node): node is PickerNode => !!node && !ancestorSelected(node) && nav.isAllowed(node)), ) const chosen = computed(() => { const files: PickerFile[] = [] const excluded: PickerExcluded[] = [] for (const node of nav.nodes.value.values()) { if (!nav.isAllowed(node)) continue if (!keys.value.has(node.path) && !ancestorSelected(node)) continue const reason = exclusion(node) ?? (dropped.value.has(node.path) ? { path: node.path, name: node.name, reason: 'deselected' as const } : null) // A dropped folder excludes its descendants as well. const droppedByParent = ancestorSelected(node, dropped.value) if (reason || droppedByParent) { if (node.kind === 'file' || reason?.reason === 'unreadable') excluded.push(reason ?? { path: node.path, name: node.name, reason: 'deselected' }) } else if (node.kind === 'file') files.push(node) } return { files, excluded, bytes: files.reduce((total, node) => total + (node.bytes ?? 0), 0) } }) async function resolve() { const token = ++generation resolving.value = true resolutionError.value = '' let count = 0 const seen = new Set() async function walk(node: PickerNode, ancestors: string[] = []): Promise { if (token !== generation) throw new DOMException('Cancelled', 'AbortError') if (!nav.isAllowed(node)) return [] if (node.kind === 'file') return [node] if (node.unavailableReason) return [] if (ancestors.includes(node.path)) throw new Error('The adapter returned a circular folder tree.') if (seen.has(node.path)) return [] seen.add(node.path) if (++count > 500 || ancestors.length > 30) throw new Error('This selection is too large to validate. Choose a smaller folder.') const children = await nav.loadChildren(node.path, ancestors) const files: PickerFile[] = [] for (const child of children) files.push(...(await walk(child, [...ancestors, node.path]))) if (token !== generation) throw new DOMException('Cancelled', 'AbortError') const formats: Record = {} files.forEach((file) => { formats[file.format] = (formats[file.format] ?? 0) + 1 }) nav.register([ { ...node, fileCount: files.length, bytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0), formats, }, ]) return files } try { for (const node of roots.value) await walk(node) if (token === generation) { // Resolved descendants inherit their folder, including restored selections. for (const key of [...keys.value]) { const node = nav.nodes.value.get(key) if (node && ancestorSelected(node)) keys.value.delete(key) } } } catch (cause) { if (token === generation) resolutionError.value = cause instanceof Error ? cause.message : 'Could not validate the selection.' } finally { if (token === generation) resolving.value = false } } const focused = computed(() => { const node = nav.nodes.value.get(focusPath.value) return node && nav.isAllowed(node) ? node : roots.value[roots.value.length - 1] ?? null }) watch( () => new Map([...nav.nodes.value].map(([path, node]) => [path, nav.isAllowed(node)])), (allowed, previous) => { if (keys.value.size && [...allowed].some(([path, value]) => previous.has(path) && value !== previous.get(path), )) void resolve() }, ) const selectedFileCounts = computed(() => { const counts = new Map() for (const file of chosen.value.files) { const seen = new Set() let parent = file.parentPath while (parent && !seen.has(parent)) { seen.add(parent) counts.set(parent, (counts.get(parent) ?? 0) + 1) parent = nav.nodes.value.get(parent)?.parentPath } } return counts }) function folderProgress(node: PickerNode): string { if (node.kind !== 'folder' || node.fileCount == null) return '' const allowed = selected(node) ? (selectedFileCounts.value.get(node.path) ?? 0) : 0 return `${allowed} / ${node.fileCount}` } return { keys, dropped, focusPath, resolving, resolutionError, roots, chosen, focused, select, selected, selectable, exclusion, reset, resolve, folderProgress, ancestorSelected, } }