import { computed, nextTick, onScopeDispose, shallowRef, toRaw, watch, type Ref } from 'vue' import type { PickerAdapter, PickerCapabilities, PickerNode, PickerSelection, PickerSelectionMode, PickerSort, PickerSource, } from '../types/filePicker' import type { SegmentedOption } from '../types' import { usePickerNavigation } from './filePicker/usePickerNavigation' import { usePickerSelection } from './filePicker/usePickerSelection' import { localFilesAdapter } from './filePicker/localFiles' interface PickerStateProps { open: boolean adapter: PickerAdapter selectionMode: PickerSelectionMode sources: PickerSource[] capabilities: PickerCapabilities initialPath: string initialSelection: PickerSelection | null defaultSort: PickerSort systemFilter?: (node: PickerNode, source: PickerSource) => boolean } type PickerEmit = { (event: 'update:open', value: boolean): void (event: 'select', value: PickerSelection): void (event: 'cancel'): void } /** Internal orchestration; public consumers use FilePicker's props and events. */ export function useFilePicker( props: PickerStateProps, emit: PickerEmit, searchArea: Ref, ) { const sourceNames: Record = { server: 'Server', localFile: 'Local files', } const allowedSources = computed(() => [...new Set(props.sources)]) // Segment values are namespaced so an adapter's mount path can never collide with a source name. const SOURCE = 'source:' const MOUNT = 'mount:' const mounts = shallowRef([]) const source = shallowRef( allowedSources.value.includes(props.initialSelection?.source as PickerSource) ? props.initialSelection!.source : (allowedSources.value[0] ?? 'server'), ) const query = shallowRef('') const recursive = shallowRef(false) const sort = shallowRef(props.defaultSort) const skipBlanks = shallowRef(false) const reviewing = shallowRef(false) const localFiles = shallowRef([]) const localPaths = new WeakMap() const localAdapter = computed(() => localFilesAdapter(localFiles.value, localFiles.value.map((file) => localPaths.get(toRaw(file)))), ) const nav = usePickerNavigation( () => (source.value === 'localFile' ? localAdapter.value : props.adapter), () => ({ source: source.value }), (node) => props.systemFilter?.(node, source.value) !== false, () => sort.value, ) const capability = computed(() => props.capabilities[source.value]) const selection = usePickerSelection( nav, () => props.selectionMode, () => capability.value, () => skipBlanks.value, ) const { path, trail, loading, error, rows } = nav /** A mount-rooted adapter lists its mounts as header sources; the listing starts inside one. */ const mountView = computed( () => source.value === 'server' && !!props.adapter.mounts && mounts.value.length > 0, ) const segments = computed(() => allowedSources.value.flatMap((item) => item === 'server' && props.adapter.mounts && mounts.value.length ? mounts.value.map((mount) => ({ value: MOUNT + mount.path, label: mount.name, disabled: !!mount.unavailableReason, })) : [{ value: SOURCE + item, label: sourceNames[item] }], ), ) const activeSegment = computed(() => mountView.value ? MOUNT + (trail.value[0]?.path ?? '') : SOURCE + source.value, ) const { roots, chosen, focused, resolving, resolutionError } = selection const previewNode = computed(() => { const node = focused.value if (!node || node.kind !== 'folder' || !selection.selected(node) || resolving.value) return node const files = chosen.value.files.filter((file) => selection.ancestorSelected(file, new Set([node.path])), ) const formats: Record = {} files.forEach((file) => { formats[file.format] = (formats[file.format] ?? 0) + 1 }) return { ...node, bytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0), fileCount: files.length, formats, } }) const confirmLabel = computed( () => ({ 'single-file': 'Use file', 'multi-file': 'Use files', folder: 'Use folder', 'folder+files': 'Use selection', })[props.selectionMode], ) const folderCount = computed(() => roots.value.filter((node) => node.kind === 'folder').length) const canConfirm = computed( () => allowedSources.value.includes(source.value) && roots.value.length > 0 && chosen.value.files.length > 0 && !resolving.value && !loading.value && !error.value && !resolutionError.value, ) let lastConfirmed: PickerSelection | null = null let initialized = false let timer: ReturnType | undefined let openGeneration = 0 const remembered = new Map< PickerSource, { path: string; trail: { path: string; label: string }[] } >() const knownFolders = new Map() function rememberFolders() { knownFolders.set( source.value, [...nav.nodes.value.values()].filter((node) => node.kind === 'folder'), ) } function currentSeed() { return props.initialSelection ?? lastConfirmed } async function restore() { const token = ++openGeneration const seed = currentSeed() nav.register(knownFolders.get(source.value) ?? []) selection.reset(seed?.source === source.value ? seed : null) reviewing.value = false if (!allowedSources.value.includes(source.value)) return if (seed?.source === 'localFile' && source.value === 'localFile') { seed.files.forEach((file, index) => { if (seed.relativePaths?.[index]) localPaths.set(toRaw(file), seed.relativePaths[index]!) }) localFiles.value = seed.files } if (source.value === 'server' && props.adapter.mounts) { await loadMounts() if (token !== openGeneration || !props.open) return const first = mounts.value.find((mount) => !mount.unavailableReason) if (!path.value && first) openMount(first) // An initial folder inside a mount gets that mount as its first crumb. const holder = mounts.value.find((mount) => mount.path === props.adapter.mountOf?.(path.value)) if (holder && trail.value[0]?.path !== holder.path) trail.value = [{ path: holder.path, label: holder.name }, ...trail.value] } await nav.load(query.value, recursive.value) if (token !== openGeneration || !props.open) return if (seed?.source === 'localFile' && source.value === 'localFile') { // Restored local File objects are already the confirmed subset. await selectLocalFiles() } if (selection.keys.value.size) await selection.resolve() } watch( () => props.open, (open) => { clearTimeout(timer) if (open) { sort.value = props.defaultSort if (!initialized) { path.value = props.initialPath if (path.value) trail.value = [{ path: path.value, label: 'Current folder' }] initialized = true } const seed = currentSeed() if (seed && allowedSources.value.includes(seed.source) && seed.source !== source.value) void switchSource(seed.source) else void restore() } else { ++openGeneration rememberFolders() selection.reset() nav.clear() } }, { immediate: true }, ) watch([query, recursive], () => { clearTimeout(timer) if (props.open) timer = setTimeout(() => { void nav.load(query.value, recursive.value) }, 200) }) watch( () => [...selection.keys.value], () => { if (props.open) void selection.resolve() }, ) watch( () => props.selectionMode, () => { if (props.open) void restore() }, ) watch(allowedSources, (sources) => { if (!sources.includes(source.value) && sources[0]) void switchSource(sources[0]) }) onScopeDispose(() => { ++openGeneration clearTimeout(timer) selection.reset() }) async function loadMounts() { try { mounts.value = (await props.adapter.listRoot({ source: 'server' })).filter( (node) => node.kind === 'folder' && props.systemFilter?.(node, 'server') !== false, ) } catch { // The root listing reports the failure; the header falls back to one Server source. mounts.value = [] } nav.register(mounts.value, '') } function openMount(mount: PickerNode) { trail.value = [{ path: mount.path, label: mount.name }] path.value = mount.path } async function chooseSegment(value: string | number) { const key = String(value) if (key.startsWith(SOURCE)) return switchSource(key.slice(SOURCE.length) as PickerSource) const mount = mounts.value.find((item) => MOUNT + item.path === key) if (!mount || mount.unavailableReason) return if (source.value !== 'server') { remembered.set('server', { path: mount.path, trail: [{ path: mount.path, label: mount.name }] }) return switchSource('server') } clearTimeout(timer) query.value = '' openMount(mount) await nav.load() } function clearSelection() { selection.reset() reviewing.value = false } async function switchSource(next: PickerSource) { if (next === source.value || !allowedSources.value.includes(next)) return rememberFolders() remembered.set(source.value, { path: path.value, trail: [...trail.value] }) clearTimeout(timer) nav.clear() source.value = next query.value = '' path.value = remembered.get(next)?.path ?? '' trail.value = remembered.get(next)?.trail ?? [] await restore() } function selectNode(node: PickerNode, event: MouseEvent | KeyboardEvent) { // The second click of a double-click is ignored, except on a folder+files folder: // there the double-click opens it, and the second click undoes the first one's toggle. const opensFolder = node.kind === 'folder' && props.selectionMode === 'folder+files' if (event.type === 'click' && event.detail > 1 && !opensFolder) return selection.select(node, { ...{ metaKey: event.metaKey, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey }, toggle: event.type === 'click' || (event instanceof KeyboardEvent && event.key === ' '), }) } async function enter(node: PickerNode) { const token = openGeneration if (selection.exclusion(node)) return if (node.kind === 'file') { if (props.selectionMode === 'single-file') { selection.select(node) await nextTick() await selection.resolve() if (token === openGeneration && props.open) confirm() } return } clearTimeout(timer) query.value = '' nav.enter(node) await nav.load() } async function up(target?: string) { // A mount root is the top of its source; the mount list lives in the header. // An initial folder inside a mount the adapter cannot name has no mount crumb, so up leads to the mount list. const atMountRoot = mounts.value.some((mount) => mount.path === trail.value[0]?.path) if ( mountView.value && (target === '' || (target === undefined && trail.value.length <= 1 && atMountRoot)) ) return query.value = '' clearTimeout(timer) nav.up(target) await nav.load() } async function refresh() { nav.invalidate() await nav.load(query.value, recursive.value, true) if (selection.keys.value.size) await selection.resolve() } function selectParent(node: PickerNode) { if (!node.parentPath) return const parent = nav.nodes.value.get(node.parentPath) ?? { kind: 'folder' as const, path: node.parentPath, name: node.parentName ?? 'Containing folder', } nav.register([parent]) selection.select(parent) } async function chooseLocal(files: File[], _rootName?: string, relativePaths?: string[]) { const token = openGeneration if (!files.length || !props.open || source.value !== 'localFile') return files.forEach((file, index) => { if (relativePaths?.[index]) localPaths.set(toRaw(file), relativePaths[index]!) }) localFiles.value = files clearTimeout(timer) nav.clear() selection.reset() path.value = '' trail.value = [] query.value = '' await nav.load() if (token !== openGeneration || !props.open || source.value !== 'localFile') return await selectLocalFiles() } async function selectLocalFiles() { const token = openGeneration const found = props.selectionMode === 'single-file' || props.selectionMode === 'multi-file' ? await localAdapter.value.search('', { path: '', recursive: true }) : nav.entries.value const items = Array.isArray(found) ? found : found.nodes if (token !== openGeneration || !props.open || source.value !== 'localFile') return nav.register(items) const candidates = items.filter((node) => selection.selectable(node)) for (const node of props.selectionMode === 'single-file' || props.selectionMode === 'folder' ? candidates.slice(0, 1) : candidates) selection.select(node, { ctrlKey: true }) } function cancel() { emit('cancel') emit('update:open', false) } function confirm() { if (!canConfirm.value) return const result: PickerSelection = source.value === 'localFile' ? { source: 'localFile', files: chosen.value.files.flatMap((node) => (node.file ? [toRaw(node.file)] : [])), } : { source: 'server', folders: roots.value.filter((node) => node.kind === 'folder').map((node) => node.path), files: [...chosen.value.files], excluded: [...chosen.value.excluded], } if (result.source === 'localFile') { const relativePaths = result.files.map((file) => localPaths.get(file) || file.webkitRelativePath || file.name, ) if (relativePaths.some((path, index) => path !== (result.files[index]!.webkitRelativePath || result.files[index]!.name), )) result.relativePaths = relativePaths } lastConfirmed = result emit('select', result) emit('update:open', false) } function keyboard(event: KeyboardEvent) { if ( event.key !== '/' || event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ) return event.preventDefault() searchArea.value?.querySelector('input')?.focus() } return { previewNode, sourceNames, segments, activeSegment, mounts, mountView, allowedSources, source, query, recursive, sort, skipBlanks, reviewing, nav, selection, capability, path, trail, loading, error, rows, roots, chosen, focused, resolving, resolutionError, confirmLabel, folderCount, canConfirm, chooseSegment, clearSelection, selectNode, enter, up, refresh, selectParent, chooseLocal, cancel, confirm, keyboard, } }