{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "file-picker", "title": "File Picker", "description": "Standard open-file, multi-file, directory, and save-target pickers with standalone and WindowManager promise APIs", "registryDependencies": [ "https://ui-components.wener.me/r/file-manager.json", "https://ui-components.wener.me/r/window-manager.json", "https://ui-components.wener.me/r/left-center-right-layout.json" ], "files": [ { "path": "registry/default/blocks/file-picker/file-picker-types.ts", "content": "import type { ReactNode } from 'react';\nimport type { FileManagerFileStat, FileManagerFileSystem, FileManagerProps } from '../file-manager';\n\nexport type FilePickerAcceptType = {\n\taccept: Record;\n\tdescription?: string;\n};\n\nexport type DirectoryPickerResult = {\n\tkind: 'directory';\n\tpath: string;\n};\n\nexport type SaveFilePickerResult = {\n\tdirectory: string;\n\texisting?: FileManagerFileStat;\n\tkind: 'file';\n\tname: string;\n\tpath: string;\n};\n\nexport type FilePickerMessages = {\n\tcancel: string;\n\tconfirmDirectory: string;\n\tconfirmOpen: string;\n\tconfirmSave: string;\n\temptyDirectory: string;\n\temptyFiles: string;\n\tfileName: string;\n\tfileType: string;\n\tnoFile: string;\n\topeningDirectory: string;\n\toverwrite: string;\n\toverwriteDescription: (name: string) => ReactNode;\n\toverwriteTitle: string;\n\tsavingTarget: string;\n};\n\nexport type FilePickerCommonProps = Omit<\n\tFileManagerProps,\n\t| 'capabilities'\n\t| 'emptyState'\n\t| 'filterEntry'\n\t| 'footer'\n\t| 'onActivateEntry'\n\t| 'onOpenFile'\n\t| 'readOnly'\n\t| 'selectionMode'\n\t| 'store'\n\t| 'title'\n> & {\n\tallowCreateDirectory?: boolean;\n\tfileSystem: FileManagerFileSystem;\n\tmessages?: Partial;\n\tonCancel?: () => void;\n\tpreview?: boolean;\n\ttitle?: ReactNode;\n};\n\nexport type SingleFilePickerProps = FilePickerCommonProps & {\n\taccept?: readonly FilePickerAcceptType[];\n\tmultiple?: false;\n\tonConfirm: (result: FileManagerFileStat) => void;\n};\n\nexport type MultipleFilePickerProps = FilePickerCommonProps & {\n\taccept?: readonly FilePickerAcceptType[];\n\tmultiple: true;\n\tonConfirm: (result: FileManagerFileStat[]) => void;\n};\n\nexport type FilePickerProps = MultipleFilePickerProps | SingleFilePickerProps;\n\nexport type DirectoryPickerProps = FilePickerCommonProps & {\n\tonConfirm: (result: DirectoryPickerResult) => void;\n};\n\nexport type SaveFilePickerProps = FilePickerCommonProps & {\n\taccept?: readonly FilePickerAcceptType[];\n\tonConfirm: (result: SaveFilePickerResult) => void;\n\tsuggestedName?: string;\n};\n\nexport type FilePickerMode = 'directory' | 'open' | 'save';\n\nexport type FilePickerCoreProps = FilePickerCommonProps & {\n\taccept?: readonly FilePickerAcceptType[];\n\tmode: FilePickerMode;\n\tmultiple?: boolean;\n\tonConfirm: (\n\t\tresult: DirectoryPickerResult | FileManagerFileStat | FileManagerFileStat[] | SaveFilePickerResult,\n\t) => void;\n\tsuggestedName?: string;\n};\n", "type": "registry:lib", "target": "@components/file-picker/file-picker-types.ts" }, { "path": "registry/default/blocks/file-picker/file-picker-model.ts", "content": "import type { FileManagerFileStat } from '../file-manager';\nimport { joinFileManagerPath, validateFileManagerName } from '../file-manager';\nimport type { FilePickerAcceptType, SaveFilePickerResult } from './file-picker-types';\n\nexport function matchesFilePickerAccept(entry: FileManagerFileStat, accept?: readonly FilePickerAcceptType[]): boolean {\n\tif (entry.kind === 'directory' || !accept?.length) return true;\n\tconst extension = getExtension(entry.name);\n\tconst mime = getEntryMime(entry);\n\treturn accept.some((group) =>\n\t\tObject.entries(group.accept).some(([pattern, extensions]) => {\n\t\t\tif (pattern.trim() === '*/*' || extensions.some((candidate) => candidate.trim() === '*')) return true;\n\t\t\tif (extensions.some((candidate) => normalizeExtension(candidate) === extension)) return true;\n\t\t\treturn mime ? matchesMime(mime, pattern) : false;\n\t\t}),\n\t);\n}\n\nexport function filterFilePickerEntries(\n\tentries: readonly FileManagerFileStat[],\n\toptions: { accept?: readonly FilePickerAcceptType[]; directoriesOnly?: boolean },\n): FileManagerFileStat[] {\n\treturn entries.filter((entry) =>\n\t\toptions.directoriesOnly\n\t\t\t? entry.kind === 'directory'\n\t\t\t: entry.kind === 'directory' || matchesFilePickerAccept(entry, options.accept),\n\t);\n}\n\nexport function getSelectedFilePickerEntries(\n\tentries: readonly FileManagerFileStat[],\n\tselectedPaths: readonly string[],\n\tkind: 'directory' | 'file',\n): FileManagerFileStat[] {\n\tconst selected = new Set(selectedPaths);\n\treturn entries.filter((entry) => entry.kind === kind && selected.has(entry.path));\n}\n\nexport function createSaveFilePickerTarget(\n\tdirectory: string,\n\tname: string,\n\texisting?: FileManagerFileStat,\n): { error?: string; result?: SaveFilePickerResult } {\n\tconst error = validateFileManagerName(name);\n\tif (error) return { error };\n\tconst normalizedName = name.trim();\n\tif (existing?.kind === 'directory') return { error: '不能覆盖同名目录' };\n\treturn {\n\t\tresult: {\n\t\t\tdirectory,\n\t\t\texisting,\n\t\t\tkind: 'file',\n\t\t\tname: normalizedName,\n\t\t\tpath: joinFileManagerPath(directory, normalizedName),\n\t\t},\n\t};\n}\n\nfunction getExtension(name: string): string {\n\tconst dot = name.lastIndexOf('.');\n\treturn dot > 0 ? name.slice(dot).toLowerCase() : '';\n}\n\nfunction normalizeExtension(value: string): string {\n\tconst normalized = value.trim().toLowerCase();\n\tif (!normalized || normalized === '*') return normalized;\n\treturn normalized.startsWith('.') ? normalized : `.${normalized}`;\n}\n\nfunction getEntryMime(entry: FileManagerFileStat): string | undefined {\n\tfor (const key of ['type', 'mimeType', 'mime']) {\n\t\tconst value = entry.meta[key];\n\t\tif (typeof value === 'string' && value.includes('/')) return value.toLowerCase();\n\t}\n\treturn undefined;\n}\n\nfunction matchesMime(mime: string, pattern: string): boolean {\n\tconst normalized = pattern.trim().toLowerCase();\n\tif (normalized === '*/*') return true;\n\tif (normalized.endsWith('/*')) return mime.startsWith(normalized.slice(0, -1));\n\treturn mime === normalized;\n}\n", "type": "registry:lib", "target": "@components/file-picker/file-picker-model.ts" }, { "path": "registry/default/blocks/file-picker/file-picker-store.ts", "content": "import Emittery from 'emittery';\nimport { createStore } from 'zustand/vanilla';\nimport { mutative } from 'zustand-mutative';\nimport type { SaveFilePickerResult } from './file-picker-types';\n\nexport const FilePickerEventType = {\n\tCancel: 'FilePicker:Cancel',\n\tConfirm: 'FilePicker:Confirm',\n\tConfirmOverwrite: 'FilePicker:ConfirmOverwrite',\n} as const;\n\nexport type FilePickerEventData = {\n\t[FilePickerEventType.Cancel]: Record;\n\t[FilePickerEventType.Confirm]: Record;\n\t[FilePickerEventType.ConfirmOverwrite]: Record;\n};\n\nexport type FilePickerStoreState = {\n\tevents: Emittery;\n\tfilter: { acceptIndex: number };\n\tinput: { name: string };\n\tnavigation: { pending: boolean };\n\toverwrite: { target?: SaveFilePickerResult };\n\trequest: { error?: string; id: number; status: 'idle' | 'checking' };\n\tactions: {\n\t\tbeginRequest: () => number;\n\t\tbeginNavigation: () => void;\n\t\tcancel: () => void;\n\t\tclearOverwrite: () => void;\n\t\tconfirm: () => void;\n\t\tconfirmOverwrite: () => void;\n\t\tfailRequest: (id: number, error: string) => void;\n\t\tfinishRequest: (id: number) => void;\n\t\tfinishNavigation: () => void;\n\t\tinvalidate: () => void;\n\t\tsetAcceptIndex: (index: number) => void;\n\t\tsetName: (name: string) => void;\n\t\tshowOverwrite: (id: number, target: SaveFilePickerResult) => void;\n\t};\n};\n\nexport type FilePickerStore = ReturnType;\n\nexport function createFilePickerStore(options: { suggestedName?: string } = {}) {\n\tconst events = new Emittery();\n\treturn createStore(\n\t\tmutative((setState) => ({\n\t\t\tevents,\n\t\t\tfilter: { acceptIndex: 0 },\n\t\t\tinput: { name: options.suggestedName ?? '' },\n\t\t\tnavigation: { pending: false },\n\t\t\toverwrite: {},\n\t\t\trequest: { id: 0, status: 'idle' },\n\t\t\tactions: {\n\t\t\t\tbeginNavigation: () =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tstate.navigation.pending = true;\n\t\t\t\t\t\tstate.request.id += 1;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.request.error = undefined;\n\t\t\t\t\t\tstate.overwrite.target = undefined;\n\t\t\t\t\t}),\n\t\t\t\tbeginRequest: () => {\n\t\t\t\t\tlet id = 0;\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tstate.request.id += 1;\n\t\t\t\t\t\tid = state.request.id;\n\t\t\t\t\t\tstate.request.status = 'checking';\n\t\t\t\t\t\tstate.request.error = undefined;\n\t\t\t\t\t\tstate.overwrite.target = undefined;\n\t\t\t\t\t});\n\t\t\t\t\treturn id;\n\t\t\t\t},\n\t\t\t\tcancel: () => void events.emit(FilePickerEventType.Cancel, {}),\n\t\t\t\tclearOverwrite: () => setState((state) => void (state.overwrite.target = undefined)),\n\t\t\t\tconfirm: () => void events.emit(FilePickerEventType.Confirm, {}),\n\t\t\t\tconfirmOverwrite: () => void events.emit(FilePickerEventType.ConfirmOverwrite, {}),\n\t\t\t\tfailRequest: (id, error) =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tif (state.request.id !== id) return;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.request.error = error;\n\t\t\t\t\t}),\n\t\t\t\tfinishRequest: (id) =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tif (state.request.id !== id) return;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.request.error = undefined;\n\t\t\t\t\t}),\n\t\t\t\tfinishNavigation: () => setState((state) => void (state.navigation.pending = false)),\n\t\t\t\tinvalidate: () =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tstate.request.id += 1;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.request.error = undefined;\n\t\t\t\t\t\tstate.overwrite.target = undefined;\n\t\t\t\t\t\tstate.navigation.pending = false;\n\t\t\t\t\t}),\n\t\t\t\tsetAcceptIndex: (index) =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tstate.filter.acceptIndex = Math.max(0, Math.floor(index));\n\t\t\t\t\t\tstate.request.id += 1;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.request.error = undefined;\n\t\t\t\t\t\tstate.overwrite.target = undefined;\n\t\t\t\t\t}),\n\t\t\t\tsetName: (name) =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tstate.input.name = name;\n\t\t\t\t\t\tstate.request.id += 1;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.request.error = undefined;\n\t\t\t\t\t\tstate.overwrite.target = undefined;\n\t\t\t\t\t}),\n\t\t\t\tshowOverwrite: (id, target) =>\n\t\t\t\t\tsetState((state) => {\n\t\t\t\t\t\tif (state.request.id !== id) return;\n\t\t\t\t\t\tstate.request.status = 'idle';\n\t\t\t\t\t\tstate.overwrite.target = target;\n\t\t\t\t\t}),\n\t\t\t},\n\t\t})),\n\t);\n}\n", "type": "registry:lib", "target": "@components/file-picker/file-picker-store.ts" }, { "path": "registry/default/blocks/file-picker/file-picker-context.tsx", "content": "'use client';\n\nimport { createContext, type PropsWithChildren, useContext, useState } from 'react';\nimport { useStore } from 'zustand';\nimport { createFilePickerStore, type FilePickerStore, type FilePickerStoreState } from './file-picker-store';\n\nconst FilePickerContext = createContext(null);\n\nexport function FilePickerProvider({\n\tchildren,\n\tstore,\n\tsuggestedName,\n}: PropsWithChildren<{ store?: FilePickerStore; suggestedName?: string }>) {\n\tconst [localStore] = useState(() => store ?? createFilePickerStore({ suggestedName }));\n\treturn {children};\n}\n\nexport function useFilePickerStoreContext(): FilePickerStore {\n\tconst store = useContext(FilePickerContext);\n\tif (!store) throw new Error('useFilePickerStore must be used within FilePickerProvider');\n\treturn store;\n}\n\nexport function useFilePickerStore(selector: (state: FilePickerStoreState) => T): T {\n\treturn useStore(useFilePickerStoreContext(), selector);\n}\n\nexport function useFilePickerActions() {\n\treturn useFilePickerStore((state) => state.actions);\n}\n", "type": "registry:component", "target": "@components/file-picker/file-picker-context.tsx" }, { "path": "registry/default/blocks/file-picker/file-picker-runtime.tsx", "content": "'use client';\n\nimport { useEffect, useRef } from 'react';\nimport {\n\ttype FileManagerFileStat,\n\ttype FileManagerFileSystem,\n\ttype FileManagerStore,\n\tsanitizeFileManagerStat,\n} from '../file-manager';\nimport { createSaveFilePickerTarget, getSelectedFilePickerEntries, matchesFilePickerAccept } from './file-picker-model';\nimport { FilePickerEventType, type FilePickerStore } from './file-picker-store';\nimport type {\n\tDirectoryPickerResult,\n\tFilePickerAcceptType,\n\tFilePickerMode,\n\tSaveFilePickerResult,\n} from './file-picker-types';\n\ntype FilePickerRuntimeProps = {\n\taccept?: readonly FilePickerAcceptType[];\n\tfileManagerStore: FileManagerStore;\n\tfileSystem: FileManagerFileSystem;\n\tmode: FilePickerMode;\n\tmessages: { noFile: string };\n\tmultiple: boolean;\n\tonCancel?: () => void;\n\tonConfirm: (\n\t\tresult: DirectoryPickerResult | FileManagerFileStat | FileManagerFileStat[] | SaveFilePickerResult,\n\t) => void;\n\tpickerStore: FilePickerStore;\n};\n\nexport function FilePickerRuntime({\n\taccept,\n\tfileManagerStore,\n\tfileSystem,\n\tmode,\n\tmessages,\n\tmultiple,\n\tonCancel,\n\tonConfirm,\n\tpickerStore,\n}: FilePickerRuntimeProps) {\n\tconst callbacks = useRef({ onCancel, onConfirm });\n\tcallbacks.current = { onCancel, onConfirm };\n\n\tuseEffect(() => {\n\t\tconst picker = pickerStore.getState();\n\t\tconst offCancel = picker.events.on(FilePickerEventType.Cancel, () => {\n\t\t\tpickerStore.getState().actions.invalidate();\n\t\t\tcallbacks.current.onCancel?.();\n\t\t});\n\t\tconst offConfirm = picker.events.on(FilePickerEventType.Confirm, () => void confirm());\n\t\tconst offOverwrite = picker.events.on(FilePickerEventType.ConfirmOverwrite, () => {\n\t\t\tconst target = pickerStore.getState().overwrite.target;\n\t\t\tif (!target) return;\n\t\t\tpickerStore.getState().actions.clearOverwrite();\n\t\t\tcallbacks.current.onConfirm(target);\n\t\t});\n\t\tconst offFileManager = fileManagerStore.getState().events.on('request', ({ data }) => {\n\t\t\tif (data.type === 'load') pickerStore.getState().actions.beginNavigation();\n\t\t});\n\t\tconst offFileManagerEvent = fileManagerStore.getState().events.on('event', ({ data }) => {\n\t\t\tif (data.type === 'navigated' || (data.type === 'error' && data.action === 'navigate')) {\n\t\t\t\tpickerStore.getState().actions.finishNavigation();\n\t\t\t}\n\t\t});\n\n\t\tasync function confirm() {\n\t\t\tconst fileManager = fileManagerStore.getState();\n\t\t\tconst pickerState = pickerStore.getState();\n\t\t\tif (pickerState.navigation.pending || fileManager.listing.status === 'loading') return;\n\t\t\tconst activeAccept = accept?.[pickerState.filter.acceptIndex];\n\t\t\tconst acceptedTypes = activeAccept ? [activeAccept] : accept;\n\t\t\tconst selectedDirectories = getSelectedFilePickerEntries(\n\t\t\t\tfileManager.listing.entries,\n\t\t\t\tfileManager.selection.paths,\n\t\t\t\t'directory',\n\t\t\t);\n\t\t\tif (mode === 'open') {\n\t\t\t\tconst selected = getSelectedFilePickerEntries(\n\t\t\t\t\tfileManager.listing.entries.filter((entry) => matchesFilePickerAccept(entry, acceptedTypes)),\n\t\t\t\t\tfileManager.selection.paths,\n\t\t\t\t\t'file',\n\t\t\t\t);\n\t\t\t\tif (!selected.length) {\n\t\t\t\t\tif (selectedDirectories[0]) {\n\t\t\t\t\t\tpickerState.actions.beginNavigation();\n\t\t\t\t\t\tfileManager.actions.requestNavigate(selectedDirectories[0].path);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconst id = pickerState.actions.beginRequest();\n\t\t\t\t\tpickerState.actions.failRequest(id, messages.noFile);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcallbacks.current.onConfirm(multiple ? selected : selected[0]);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (mode === 'directory') {\n\t\t\t\tcallbacks.current.onConfirm({\n\t\t\t\t\tkind: 'directory',\n\t\t\t\t\tpath: selectedDirectories[0]?.path ?? fileManager.navigation.path,\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (selectedDirectories[0]) {\n\t\t\t\tpickerState.actions.beginNavigation();\n\t\t\t\tfileManager.actions.requestNavigate(selectedDirectories[0].path);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst id = pickerState.actions.beginRequest();\n\t\t\tconst target = createSaveFilePickerTarget(fileManager.navigation.path, pickerStore.getState().input.name);\n\t\t\tif (target.error || !target.result) {\n\t\t\t\tpickerStore.getState().actions.failRequest(id, target.error ?? '文件名无效');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst exists = await fileSystem.exists(target.result.path);\n\t\t\t\tif (!isCurrent(id)) return;\n\t\t\t\tif (!exists) {\n\t\t\t\t\tpickerStore.getState().actions.finishRequest(id);\n\t\t\t\t\tcallbacks.current.onConfirm(target.result);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst existing = sanitizeFileManagerStat(await fileSystem.stat(target.result.path), target.result.path);\n\t\t\t\tif (!isCurrent(id)) return;\n\t\t\t\tconst checked = createSaveFilePickerTarget(fileManager.navigation.path, target.result.name, existing);\n\t\t\t\tif (checked.error || !checked.result) {\n\t\t\t\t\tpickerStore.getState().actions.failRequest(id, checked.error ?? '保存目标无效');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tpickerStore.getState().actions.showOverwrite(id, checked.result);\n\t\t\t} catch (error) {\n\t\t\t\tif (isCurrent(id)) pickerStore.getState().actions.failRequest(id, getErrorMessage(error));\n\t\t\t}\n\t\t}\n\n\t\tfunction isCurrent(id: number) {\n\t\t\treturn pickerStore.getState().request.id === id;\n\t\t}\n\n\t\treturn () => {\n\t\t\tpickerStore.getState().actions.invalidate();\n\t\t\toffCancel();\n\t\t\toffConfirm();\n\t\t\toffOverwrite();\n\t\t\toffFileManager();\n\t\t\toffFileManagerEvent();\n\t\t};\n\t}, [accept, fileManagerStore, fileSystem, messages, mode, multiple, pickerStore]);\n\treturn null;\n}\n\nfunction getErrorMessage(error: unknown): string {\n\treturn error instanceof Error && error.message ? error.message : '无法检查保存目标';\n}\n", "type": "registry:component", "target": "@components/file-picker/file-picker-runtime.tsx" }, { "path": "registry/default/blocks/file-picker/file-picker.tsx", "content": "'use client';\n\nimport { FolderOpen, Save, X } from 'lucide-react';\nimport { type FormEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react';\nimport { useStore } from 'zustand';\nimport { LeftCenterRightLayout } from '@/components/ui/left-center-right-layout';\nimport { createFileManagerStore, FileManager, type FileManagerStore } from '../file-manager';\nimport { FilePickerProvider, useFilePickerActions, useFilePickerStore } from './file-picker-context';\nimport { matchesFilePickerAccept } from './file-picker-model';\nimport { FilePickerRuntime } from './file-picker-runtime';\nimport { createFilePickerStore } from './file-picker-store';\nimport type {\n\tDirectoryPickerProps,\n\tFilePickerAcceptType,\n\tFilePickerCoreProps,\n\tFilePickerMessages,\n\tFilePickerProps,\n\tSaveFilePickerProps,\n} from './file-picker-types';\n\nconst defaultMessages: FilePickerMessages = {\n\tcancel: '取消',\n\tconfirmDirectory: '选择文件夹',\n\tconfirmOpen: '打开',\n\tconfirmSave: '保存',\n\temptyDirectory: '当前目录没有子目录',\n\temptyFiles: '没有符合条件的文件',\n\tfileName: '文件名',\n\tfileType: '文件类型',\n\tnoFile: '请选择文件',\n\topeningDirectory: '正在打开目录…',\n\toverwrite: '替换',\n\toverwriteDescription: (name) => `“${name}”已存在。是否替换现有文件?`,\n\toverwriteTitle: '确认替换文件',\n\tsavingTarget: '正在检查保存目标…',\n};\n\nexport function FilePicker(props: FilePickerProps) {\n\tif (props.multiple) {\n\t\tconst { onConfirm, ...pickerProps } = props;\n\t\treturn (\n\t\t\t onConfirm(result as Parameters[0])}\n\t\t\t/>\n\t\t);\n\t}\n\tconst { onConfirm, ...pickerProps } = props;\n\treturn (\n\t\t onConfirm(result as Parameters[0])}\n\t\t/>\n\t);\n}\n\nexport function DirectoryPicker(props: DirectoryPickerProps) {\n\tconst { onConfirm, ...pickerProps } = props;\n\treturn (\n\t\t onConfirm(result as Parameters[0])}\n\t\t/>\n\t);\n}\n\nexport function SaveFilePicker(props: SaveFilePickerProps) {\n\tconst { onConfirm, ...pickerProps } = props;\n\treturn (\n\t\t onConfirm(result as Parameters[0])}\n\t\t/>\n\t);\n}\n\nfunction FilePickerCore({\n\taccept,\n\tallowCreateDirectory = true,\n\tfileSystem,\n\tmessages: messageOverrides,\n\tmode,\n\tmultiple = false,\n\tonCancel,\n\tonConfirm,\n\tpreview = true,\n\tshowHeader = true,\n\tsuggestedName,\n\ttitle,\n\t...fileManagerProps\n}: FilePickerCoreProps) {\n\tconst pickerCapabilities = createPickerCapabilities(allowCreateDirectory, preview);\n\tconst [managerStore] = useState(() =>\n\t\tcreateFileManagerStore({\n\t\t\tcapabilities: pickerCapabilities,\n\t\t\tfileSystem,\n\t\t\tinitialPath: fileManagerProps.initialPath,\n\t\t\trootPath: fileManagerProps.rootPath,\n\t\t\tviewMode: fileManagerProps.viewMode,\n\t\t}),\n\t);\n\tconst [pickerStore] = useState(() => createFilePickerStore({ suggestedName }));\n\tconst messages = useMemo(() => ({ ...defaultMessages, ...messageOverrides }), [messageOverrides]);\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction FilePickerWorkspace({\n\taccept,\n\tcapabilities,\n\tfileSystem,\n\tmanagerStore,\n\tmessages,\n\tmode,\n\tmultiple = false,\n\tshowHeader,\n\ttitle,\n\t...props\n}: Omit & {\n\tcapabilities: ReturnType;\n\tmanagerStore: FileManagerStore;\n\tmessages: FilePickerMessages;\n}) {\n\tconst actions = useFilePickerActions();\n\tconst acceptIndex = useFilePickerStore((state) => state.filter.acceptIndex);\n\tconst safeAcceptIndex = accept?.length ? Math.min(acceptIndex, accept.length - 1) : 0;\n\tconst activeAccept = accept?.[safeAcceptIndex];\n\tconst acceptedTypes = useMemo(() => (activeAccept ? [activeAccept] : accept), [accept, activeAccept]);\n\tuseEffect(() => {\n\t\tif (acceptIndex !== safeAcceptIndex) actions.setAcceptIndex(safeAcceptIndex);\n\t}, [acceptIndex, actions, safeAcceptIndex]);\n\tconst filterEntry = useMemo(\n\t\t() =>\n\t\t\tmode === 'directory'\n\t\t\t\t? (entry: Parameters[0]) => entry.kind === 'directory'\n\t\t\t\t: (entry: Parameters[0]) => matchesFilePickerAccept(entry, acceptedTypes),\n\t\t[acceptedTypes, mode],\n\t);\n\tconst listingEntries = useStore(managerStore, (state) => state.listing.entries);\n\tconst selectedPaths = useStore(managerStore, (state) => state.selection.paths);\n\tuseEffect(() => {\n\t\tconst allowed = new Set(listingEntries.filter(filterEntry).map((entry) => entry.path));\n\t\tconst next = selectedPaths.filter((path) => allowed.has(path));\n\t\tif (next.length !== selectedPaths.length) managerStore.getState().actions.selectAll(next);\n\t}, [filterEntry, listingEntries, managerStore, selectedPaths]);\n\tconst emptyState = (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{mode === 'directory' ? messages.emptyDirectory : messages.emptyFiles}\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t}\n\t\t\t\tonOpenFile={(entry) => {\n\t\t\t\t\tif (mode === 'save') actions.setName(entry.name);\n\t\t\t\t\tactions.confirm();\n\t\t\t\t}}\n\t\t\t\tonActivateEntry={(entry) => {\n\t\t\t\t\tif (entry.kind === 'directory') actions.beginNavigation();\n\t\t\t\t}}\n\t\t\t\tselectionMode={mode === 'open' && multiple ? 'multiple' : 'single'}\n\t\t\t\tshowHeader={showHeader}\n\t\t\t\tstore={managerStore}\n\t\t\t\ttitle={title ?? getDefaultTitle(mode, multiple)}\n\t\t\t/>\n\t\t\t\n\t\t\n\t);\n}\n\nfunction FilePickerFooter({\n\taccept,\n\tacceptIndex,\n\tmanagerStore,\n\tmessages,\n\tmode,\n\tmultiple,\n}: {\n\taccept?: FilePickerCoreProps['accept'];\n\tacceptIndex: number;\n\tmanagerStore: FileManagerStore;\n\tmessages: FilePickerMessages;\n\tmode: FilePickerCoreProps['mode'];\n\tmultiple: boolean;\n}) {\n\tconst actions = useFilePickerActions();\n\tconst name = useFilePickerStore((state) => state.input.name);\n\tconst request = useFilePickerStore((state) => state.request);\n\tconst navigationPending = useFilePickerStore((state) => state.navigation.pending);\n\tconst fileManager = useStore(managerStore, (state) => state);\n\tconst selectedEntries = fileManager.listing.entries.filter((entry) =>\n\t\tfileManager.selection.paths.includes(entry.path),\n\t);\n\tconst activeAccept = accept?.[acceptIndex];\n\tconst acceptedTypes = activeAccept ? [activeAccept] : accept;\n\tconst acceptEntries = withFilePickerAcceptKeys(accept ?? []);\n\tconst selectedFiles = selectedEntries.filter(\n\t\t(entry) => entry.kind === 'file' && matchesFilePickerAccept(entry, acceptedTypes),\n\t);\n\tconst selectedDirectory = selectedEntries.find((entry) => entry.kind === 'directory');\n\tconst selectedFilePath = selectedFiles[0]?.path;\n\tuseEffect(() => {\n\t\tif (mode === 'save' && selectedFiles[0]) actions.setName(selectedFiles[0].name);\n\t}, [actions, mode, selectedFilePath]);\n\tconst disabled =\n\t\trequest.status === 'checking' ||\n\t\tnavigationPending ||\n\t\tfileManager.listing.status === 'loading' ||\n\t\t(mode === 'open' && selectedFiles.length === 0 && !selectedDirectory) ||\n\t\t(mode === 'save' && !selectedDirectory && !name.trim());\n\tconst selectionSummary =\n\t\tmode === 'directory'\n\t\t\t? (selectedDirectory?.path ?? fileManager.navigation.path)\n\t\t\t: selectedFiles.length\n\t\t\t\t? multiple\n\t\t\t\t\t? `已选择 ${selectedFiles.length} 个文件`\n\t\t\t\t\t: selectedFiles[0].name\n\t\t\t\t: '';\n\tconst fileManagerError = fileManager.operation.error ?? fileManager.listing.error;\n\tconst statusMessage = navigationPending\n\t\t? (fileManager.listing.requestedPath ?? messages.openingDirectory)\n\t\t: request.status === 'checking'\n\t\t\t? messages.savingTarget\n\t\t\t: (request.error ?? fileManagerError ?? fileManager.listing.requestedPath ?? selectionSummary);\n\treturn (\n\t\t
\n\t\t\t{mode === 'save' ? (\n\t\t\t\t\n\t\t\t) : null}\n\t\t\t\n\t\t\t\t\t\t{statusMessage}\n\t\t\t\t\t\n\t\t\t\t}\n\t\t\t\tcenter={\n\t\t\t\t\taccept?.length ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : null\n\t\t\t\t}\n\t\t\t\tcenterClassName={\n\t\t\t\t\taccept?.length\n\t\t\t\t\t\t? 'max-sm:col-span-2 max-sm:col-start-1 max-sm:row-start-1 max-sm:justify-self-stretch'\n\t\t\t\t\t\t: undefined\n\t\t\t\t}\n\t\t\t\tright={\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t}\n\t\t\t\trightClassName={accept?.length ? 'max-sm:col-start-2 max-sm:row-start-2' : undefined}\n\t\t\t/>\n\t\t
\n\t);\n}\n\nfunction filePickerAcceptKey(group: { accept: Record; description?: string }) {\n\tconst entries = Object.entries(group.accept)\n\t\t.sort(([left], [right]) => left.localeCompare(right))\n\t\t.map(([type, extensions]) => `${type}:${[...extensions].sort().join(',')}`)\n\t\t.join('|');\n\treturn `${group.description ?? ''}:${entries}`;\n}\n\nfunction withFilePickerAcceptKeys(groups: readonly FilePickerAcceptType[]) {\n\tconst occurrences = new Map();\n\treturn groups.map((group) => {\n\t\tconst identity = filePickerAcceptKey(group);\n\t\tconst occurrence = occurrences.get(identity) ?? 0;\n\t\toccurrences.set(identity, occurrence + 1);\n\t\treturn { group, key: `${identity}:${occurrence}` };\n\t});\n}\n\nfunction FilePickerOverwriteDialog({ messages }: { messages: FilePickerMessages }) {\n\tconst ref = useRef(null);\n\tconst titleId = useId();\n\tconst target = useFilePickerStore((state) => state.overwrite.target);\n\tconst actions = useFilePickerActions();\n\tuseEffect(() => {\n\t\tconst dialog = ref.current;\n\t\tif (!dialog) return;\n\t\tif (target && !dialog.open) dialog.showModal();\n\t\tif (!target && dialog.open) dialog.close();\n\t}, [target]);\n\tif (!target) return null;\n\treturn (\n\t\t {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tactions.clearOverwrite();\n\t\t\t}}\n\t\t>\n\t\t\t {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tactions.confirmOverwrite();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
{messages.overwriteDescription(target.name)}
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nfunction getDefaultTitle(mode: FilePickerCoreProps['mode'], multiple: boolean): ReactNode {\n\tif (mode === 'directory') return '选择文件夹';\n\tif (mode === 'save') return '保存文件';\n\treturn multiple ? '打开文件' : '打开文件';\n}\n\nfunction createPickerCapabilities(createDirectory: boolean, preview: boolean) {\n\treturn {\n\t\tcopy: false,\n\t\tcreateDirectory,\n\t\tcreateFile: false,\n\t\tdelete: false,\n\t\tdownload: false,\n\t\tedit: false,\n\t\tmove: false,\n\t\tpreview,\n\t\trename: false,\n\t\tupload: false,\n\t};\n}\n", "type": "registry:component", "target": "@components/file-picker/file-picker.tsx" }, { "path": "registry/default/blocks/file-picker/file-picker-window.tsx", "content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '@/lib/utils';\nimport type { FileManagerFileStat } from '../file-manager';\nimport type { WindowManagerCapabilities, WindowManagerOpenOptions, WindowManagerStore } from '../window-manager';\nimport { DirectoryPicker, FilePicker, SaveFilePicker } from './file-picker';\nimport type {\n\tDirectoryPickerProps,\n\tDirectoryPickerResult,\n\tFilePickerMode,\n\tFilePickerProps,\n\tSaveFilePickerProps,\n\tSaveFilePickerResult,\n} from './file-picker-types';\n\nexport const FILE_PICKER_WINDOW_KIND = 'file-picker';\n\ntype PickerWindowOptions = Omit<\n\tWindowManagerOpenOptions,\n\t'capabilities' | 'data' | 'duplicate' | 'id' | 'key' | 'kind' | 'persistence' | 'title'\n> & { capabilities?: Omit, 'close'>; title?: string };\n\ntype ShowFilePickerBaseOptions = Omit & {\n\twindow?: PickerWindowOptions;\n\twindowManager: WindowManagerStore;\n};\n\nexport type ShowFilePickerOptions = ShowFilePickerBaseOptions & { multiple?: false };\nexport type ShowMultipleFilePickerOptions = ShowFilePickerBaseOptions & { multiple: true };\nexport type ShowDirectoryPickerOptions = Omit & {\n\twindow?: PickerWindowOptions;\n\twindowManager: WindowManagerStore;\n};\nexport type ShowSaveFilePickerOptions = Omit & {\n\twindow?: PickerWindowOptions;\n\twindowManager: WindowManagerStore;\n};\n\nexport type FilePickerWindowData =\n\t| { mode: 'directory'; picker: DirectoryPickerProps; type: typeof FILE_PICKER_WINDOW_KIND }\n\t| { mode: 'open'; picker: FilePickerProps; type: typeof FILE_PICKER_WINDOW_KIND }\n\t| { mode: 'save'; picker: SaveFilePickerProps; type: typeof FILE_PICKER_WINDOW_KIND };\n\nexport type FilePickerWindowDescriptor = { data?: unknown; kind: string };\n\nlet pickerWindowSequence = 0;\n\nexport function showFilePicker(options: ShowMultipleFilePickerOptions): Promise;\nexport function showFilePicker(options: ShowFilePickerOptions): Promise;\nexport function showFilePicker(\n\toptions: ShowFilePickerOptions | ShowMultipleFilePickerOptions,\n): Promise {\n\tconst { windowManager, window, ...picker } = options;\n\tconst common = { mode: 'open' as const, picker, title: window?.title ?? '打开文件', window, windowManager };\n\treturn options.multiple\n\t\t? showPickerWindow({ ...common, validateResult: isFileStatArray })\n\t\t: showPickerWindow({ ...common, validateResult: isFileStat });\n}\n\nexport function showDirectoryPicker(options: ShowDirectoryPickerOptions): Promise {\n\tconst { windowManager, window, ...picker } = options;\n\treturn showPickerWindow({\n\t\tmode: 'directory',\n\t\tpicker,\n\t\ttitle: window?.title ?? '选择文件夹',\n\t\twindow,\n\t\twindowManager,\n\t\tvalidateResult: isDirectoryResult,\n\t});\n}\n\nexport function showSaveFilePicker(options: ShowSaveFilePickerOptions): Promise {\n\tconst { windowManager, window, ...picker } = options;\n\treturn showPickerWindow({\n\t\tmode: 'save',\n\t\tpicker,\n\t\ttitle: window?.title ?? '保存文件',\n\t\twindow,\n\t\twindowManager,\n\t\tvalidateResult: isSaveResult,\n\t});\n}\n\nexport function renderFilePickerWindow(window: FilePickerWindowDescriptor): ReactNode {\n\treturn window.kind === FILE_PICKER_WINDOW_KIND && isFilePickerWindowData(window.data) ? (\n\t\t\n\t) : null;\n}\n\nexport function FilePickerWindow({ data }: { data: FilePickerWindowData }) {\n\tconst props = {\n\t\t...data.picker,\n\t\tclassName: cn('size-full min-h-0 rounded-none border-0', data.picker.className),\n\t\tshowHeader: data.picker.showHeader ?? false,\n\t};\n\tif (data.mode === 'directory') return ;\n\tif (data.mode === 'save') return ;\n\treturn ;\n}\n\nfunction showPickerWindow({\n\tmode,\n\tpicker,\n\ttitle,\n\tvalidateResult,\n\twindow,\n\twindowManager,\n}: {\n\tmode: FilePickerMode;\n\tpicker: Omit;\n\ttitle: string;\n\tvalidateResult: (value: unknown) => value is Result;\n\twindow?: PickerWindowOptions;\n\twindowManager: WindowManagerStore;\n}): Promise {\n\tconst id = createPickerWindowId();\n\treturn new Promise((resolve, reject) => {\n\t\tlet activeId = id;\n\t\tlet opened = false;\n\t\tlet settled = false;\n\t\tlet unsubscribeEvents: () => void = () => {};\n\t\tlet unsubscribeState: () => void = () => {};\n\t\tconst settle = (result: Result | undefined) => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tunsubscribeEvents();\n\t\t\tunsubscribeState();\n\t\t\tresolve(result);\n\t\t};\n\t\tunsubscribeEvents = windowManager.subscribeEvents((event) => {\n\t\t\tif (event.type !== 'closed' || event.id !== activeId) return;\n\t\t\tsettle(validateResult(event.result) ? event.result : undefined);\n\t\t});\n\t\tunsubscribeState = windowManager.subscribe((state) => {\n\t\t\tconst exists = Boolean(state.windows[activeId]);\n\t\t\tif (exists) opened = true;\n\t\t\tif (!opened || exists || settled) return;\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tif (!settled && !windowManager.getState().windows[activeId]) settle(undefined);\n\t\t\t});\n\t\t});\n\t\tconst actions = windowManager.getState().actions;\n\t\tconst close = (result?: Result) => actions.close(activeId, result);\n\t\tconst data = {\n\t\t\tmode,\n\t\t\tpicker: {\n\t\t\t\t...picker,\n\t\t\t\tonCancel: () => {\n\t\t\t\t\tclose();\n\t\t\t\t},\n\t\t\t\tonConfirm: (result: Result) => {\n\t\t\t\t\tclose(result);\n\t\t\t\t},\n\t\t\t},\n\t\t\ttype: FILE_PICKER_WINDOW_KIND,\n\t\t} as unknown as FilePickerWindowData;\n\t\ttry {\n\t\t\tactiveId = actions.open({\n\t\t\t\ticon: mode === 'save' ? 'save' : 'folder-open',\n\t\t\t\tshowInDock: true,\n\t\t\t\t...window,\n\t\t\t\tbounds: { width: 920, height: 640, ...window?.bounds },\n\t\t\t\tcapabilities: { ...window?.capabilities, close: true },\n\t\t\t\tdata,\n\t\t\t\tduplicate: 'allow',\n\t\t\t\tid,\n\t\t\t\tkey: id,\n\t\t\t\tkind: FILE_PICKER_WINDOW_KIND,\n\t\t\t\tpersistence: 'none',\n\t\t\t\tsize: { minWidth: 560, minHeight: 440, ...window?.size },\n\t\t\t\ttitle,\n\t\t\t});\n\t\t\topened = Boolean(windowManager.getState().windows[activeId]);\n\t\t} catch (error) {\n\t\t\tif (!settled) {\n\t\t\t\tsettled = true;\n\t\t\t\tunsubscribeEvents();\n\t\t\t\tunsubscribeState();\n\t\t\t\treject(error);\n\t\t\t}\n\t\t}\n\t});\n}\n\nfunction createPickerWindowId(): string {\n\tpickerWindowSequence += 1;\n\treturn `file-picker-${Date.now().toString(36)}-${pickerWindowSequence.toString(36)}`;\n}\n\nfunction isFilePickerWindowData(value: unknown): value is FilePickerWindowData {\n\tif (!isRecord(value) || value.type !== FILE_PICKER_WINDOW_KIND) return false;\n\tif (value.mode !== 'open' && value.mode !== 'directory' && value.mode !== 'save') return false;\n\tif (!isRecord(value.picker) || !isFileSystem(value.picker.fileSystem)) return false;\n\tif (value.picker.accept !== undefined && !isAccept(value.picker.accept)) return false;\n\tif (value.picker.messages !== undefined && !isMessages(value.picker.messages)) return false;\n\tif (value.picker.multiple !== undefined && typeof value.picker.multiple !== 'boolean') return false;\n\tif (value.picker.suggestedName !== undefined && typeof value.picker.suggestedName !== 'string') return false;\n\treturn typeof value.picker.onCancel === 'function' && typeof value.picker.onConfirm === 'function';\n}\n\nfunction isFileSystem(value: unknown): boolean {\n\tif (!isRecord(value)) return false;\n\treturn ['copy', 'exists', 'mkdir', 'readFile', 'readdir', 'rename', 'rm', 'stat', 'writeFile'].every(\n\t\t(method) => typeof value[method] === 'function',\n\t);\n}\n\nfunction isFileStat(value: unknown): value is FileManagerFileStat {\n\treturn (\n\t\tisRecord(value) &&\n\t\tvalue.kind === 'file' &&\n\t\ttypeof value.path === 'string' &&\n\t\ttypeof value.name === 'string' &&\n\t\ttypeof value.directory === 'string' &&\n\t\tisRecord(value.meta) &&\n\t\ttypeof value.mtime === 'number' &&\n\t\tNumber.isFinite(value.mtime) &&\n\t\ttypeof value.size === 'number' &&\n\t\tNumber.isFinite(value.size)\n\t);\n}\n\nfunction isFileStatArray(value: unknown): value is FileManagerFileStat[] {\n\treturn Array.isArray(value) && value.length > 0 && value.every(isFileStat);\n}\n\nfunction isDirectoryResult(value: unknown): value is DirectoryPickerResult {\n\treturn isRecord(value) && value.kind === 'directory' && typeof value.path === 'string';\n}\n\nfunction isSaveResult(value: unknown): value is SaveFilePickerResult {\n\treturn (\n\t\tisRecord(value) &&\n\t\tvalue.kind === 'file' &&\n\t\ttypeof value.path === 'string' &&\n\t\ttypeof value.name === 'string' &&\n\t\ttypeof value.directory === 'string' &&\n\t\t(value.existing === undefined || isFileStat(value.existing))\n\t);\n}\n\nfunction isRecord(value: unknown): value is Record {\n\treturn typeof value === 'object' && value !== null;\n}\n\nfunction isAccept(value: unknown): boolean {\n\treturn (\n\t\tArray.isArray(value) &&\n\t\tvalue.every(\n\t\t\t(group) =>\n\t\t\t\tisRecord(group) &&\n\t\t\t\t(group.description === undefined || typeof group.description === 'string') &&\n\t\t\t\tisRecord(group.accept) &&\n\t\t\t\tObject.entries(group.accept).every(\n\t\t\t\t\t([mime, extensions]) =>\n\t\t\t\t\t\tmime.length > 0 && Array.isArray(extensions) && extensions.every((item) => typeof item === 'string'),\n\t\t\t\t),\n\t\t)\n\t);\n}\n\nfunction isMessages(value: unknown): boolean {\n\tif (!isRecord(value)) return false;\n\treturn Object.entries(value).every(([key, item]) =>\n\t\tkey === 'overwriteDescription' ? typeof item === 'function' : typeof item === 'string',\n\t);\n}\n", "type": "registry:component", "target": "@components/file-picker/file-picker-window.tsx" }, { "path": "registry/default/blocks/file-picker/index.ts", "content": "export * from './file-picker';\nexport * from './file-picker-context';\nexport * from './file-picker-model';\nexport * from './file-picker-runtime';\nexport * from './file-picker-store';\nexport * from './file-picker-types';\nexport * from './file-picker-window';\n", "type": "registry:component", "target": "@components/file-picker/index.ts" } ], "type": "registry:block" }