import { computed, getCurrentScope, onScopeDispose, ref, shallowRef, toValue, watch, type ComputedRef, type MaybeRefOrGetter, type Ref, } from 'vue' import type { FileBrowserSortKey, FileDirectoryListing, FileEntry, FilePathCrumb, FileSelection, ServerMount, } from '../types/fileBrowserTypes' import { useApi } from './useApi' import { getInjectedPlatformContext } from './platformContextHelpers' import { useRequestSyncState } from './useRequestSyncState' export interface UseFileBrowserOptions { /** Extensions the caller cares about; entries are flagged, never removed. */ typeRules?: MaybeRefOrGetter /** Mount to open first; defaults to the first available one. */ initialMountId?: MaybeRefOrGetter /** Override the platform API base URL (standalone plugin dev). */ apiBaseUrl?: string } export interface UseFileBrowserReturn { /** Every configured mount, offline ones included. */ mounts: Ref /** Mount the current listing belongs to. */ mountId: Ref /** Mount-relative path of the current listing. */ path: Ref /** Parent path, or null at a mount root. */ parent: Ref entries: Ref breadcrumbs: Ref /** Directory size before any search filter. */ totalCount: Ref /** How many entries satisfy the type rules. */ matchCount: Ref /** How many rows this listing carries, after search and any cap. */ shownCount: Ref /** True when the server capped the listing. */ truncated: Ref search: Ref sort: Ref /** Paths the user has picked, keyed by mount and path. */ selected: Ref isLoading: ComputedRef error: Ref /** Load the mount list and open the first available mount. */ init: () => Promise /** Open a directory, or a mount root when path is empty. */ navigate: (location: { mount_id: string; path: string }) => Promise /** Re-read the current directory. */ refresh: () => Promise /** Take the directory currently being viewed as the input. */ selectCurrentFolder: () => void clear: () => void } /** * Browses the platform's `/api/filesystem` routes for a custom file UI. For the * ready-made dialog, use `FilePicker` with `usePlatformFilePickerAdapter()`. * * Requires the `filesystem.browse` permission and a deployment that declares * mounts; with none configured the routes are absent and `mounts` stays empty. */ export function useFileBrowser(options: UseFileBrowserOptions = {}): UseFileBrowserReturn { const injectedContext = getInjectedPlatformContext() const api = useApi({ baseUrl: options.apiBaseUrl ?? injectedContext?.platformApiUrl }) const sync = useRequestSyncState('Could not read the server mounts.') const mounts = ref([]) const mountId = shallowRef('') const path = shallowRef('') const parent = ref(null) const entries = ref([]) const breadcrumbs = ref([]) const totalCount = ref(null) const matchCount = ref(null) const shownCount = ref(null) const truncated = ref(false) const search = shallowRef('') const sort = shallowRef('name') const selected = ref([]) const mountsLoading = shallowRef(false) const listingLoading = shallowRef(false) const listingReady = shallowRef(false) const isLoading = computed(() => mountsLoading.value || listingLoading.value) let listToken = 0 let mountsToken = 0 let disposed = false let listingController: AbortController | undefined let mountsController: AbortController | undefined let searchTimer: ReturnType | undefined function resetListing(): void { listingReady.value = false entries.value = [] breadcrumbs.value = [] parent.value = null totalCount.value = null matchCount.value = null shownCount.value = null truncated.value = false } function typeRuleParams(): string { const rules = toValue(options.typeRules) ?? [] return rules.map((rule) => `&type_rules=${encodeURIComponent(rule)}`).join('') } /** Prefer the API's own `detail` over axios's "Request failed with status N". */ function describe(cause: unknown, fallback: string): string { const detail = (cause as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail return typeof detail === 'string' && detail ? detail : sync.readErrorMessage(cause, fallback) } function fail(cause: unknown, fallback: string): void { sync.setError(new Error(describe(cause, fallback)), fallback) } function statusOf(cause: unknown): number | undefined { return (cause as { response?: { status?: number } })?.response?.status } async function loadMounts(): Promise { const token = ++mountsToken mountsController?.abort() mountsController = new AbortController() mountsLoading.value = true try { const data = await api.get<{ mounts: ServerMount[] }>('/filesystem/mounts', { signal: mountsController.signal, }) if (disposed || token !== mountsToken) return false mounts.value = data.mounts ?? [] sync.clearError() sync.markLoaded() return true } catch (cause) { if (disposed || token !== mountsToken) return false // The platform only registers these routes once mounts are configured, so // a 404 means "none declared" — an empty sidebar, not a failure. if (statusOf(cause) === 404) { mounts.value = [] sync.clearError() sync.markLoaded() return true } mounts.value = [] fail(cause, 'Could not read the server mounts.') return false } finally { if (token === mountsToken) mountsLoading.value = false } } async function navigate(location: { mount_id: string; path: string }, refresh = false): Promise { if (disposed || !location.mount_id) return clearTimeout(searchTimer) const token = ++listToken listingController?.abort() listingController = new AbortController() listingLoading.value = true mountId.value = location.mount_id path.value = location.path resetListing() sync.clearError() try { const query = `mount_id=${encodeURIComponent(location.mount_id)}` + `&path=${encodeURIComponent(location.path)}` + `&sort=${sort.value}` + (refresh ? '&refresh=true' : '') + (search.value ? `&search=${encodeURIComponent(search.value)}` : '') + typeRuleParams() const listing = await api.get(`/filesystem/browse?${query}`, { signal: listingController.signal, }) if (disposed || token !== listToken) return mountId.value = listing.mount_id path.value = listing.path parent.value = listing.parent ?? null entries.value = listing.entries ?? [] breadcrumbs.value = listing.breadcrumbs ?? [] totalCount.value = listing.total_count ?? null matchCount.value = listing.match_count ?? null shownCount.value = listing.shown_count ?? listing.entries?.length ?? null truncated.value = listing.truncated ?? false listingReady.value = true sync.clearError() sync.markLoaded() } catch (cause) { if (disposed || token !== listToken) return entries.value = [] fail(cause, 'Could not read that folder.') } finally { if (token === listToken) listingLoading.value = false } } async function init(): Promise { if (disposed) return const navigationToken = listToken if (!(await loadMounts()) || disposed || navigationToken !== listToken) return const preferred = toValue(options.initialMountId) // An offline mount is not navigable, so a remembered preference for one // falls through to the first reachable mount instead of opening on an error. const reachable = (mount: ServerMount): boolean => mount.available !== false const target = mounts.value.find((mount) => mount.id === preferred && reachable(mount)) ?? mounts.value.find(reachable) if (target) await navigate({ mount_id: target.id, path: '' }) else { mountId.value = '' path.value = '' resetListing() } } async function refresh(): Promise { if (disposed) return if (mountId.value) await navigate({ mount_id: mountId.value, path: path.value }, true) else await init() } function selectCurrentFolder(): void { if (!mountId.value || !listingReady.value || isLoading.value || sync.error.value) return const crumb = breadcrumbs.value[breadcrumbs.value.length - 1] const key = JSON.stringify([mountId.value, path.value]) if (selected.value.some((item) => JSON.stringify([item.mount_id, item.path]) === key)) return selected.value = [ ...selected.value, { mount_id: mountId.value, path: path.value, name: crumb?.label ?? path.value, kind: 'directory', child_count: totalCount.value, }, ] } function clear(): void { selected.value = [] } // Both controls are server-side, so they only take effect on a re-fetch. // Without these watchers the search box and sort button look live but are // inert until the user happens to navigate. watch(search, () => { if (!mountId.value) return clearTimeout(searchTimer) // Debounced: search changes on every keystroke. searchTimer = setTimeout(() => { void navigate({ mount_id: mountId.value, path: path.value }) }, 250) }) watch(sort, () => { if (mountId.value) void navigate({ mount_id: mountId.value, path: path.value }) }) watch( () => toValue(options.typeRules), () => { if (mountId.value) void navigate({ mount_id: mountId.value, path: path.value }) }, { deep: true }, ) if (getCurrentScope()) onScopeDispose(() => { disposed = true ++listToken ++mountsToken clearTimeout(searchTimer) listingController?.abort() mountsController?.abort() }) return { mounts, mountId, path, parent, entries, breadcrumbs, totalCount, matchCount, shownCount, truncated, search, sort, selected, isLoading, error: sync.error, init, navigate, refresh, selectCurrentFolder, clear, } }