File Manager
The UI layer of a file manager — for cloud drives, media libraries, document managers and IDE sidebars. It owns navigation, selection, keyboard, menus, dialogs, drag and drop and the state of running operations. Your application owns everything else: it handles the events, talks to its storage, and updates items.
Installation
npx raya-ui@latest add file-managerHow it works
The file manager emits intent, your application performs it, and the file manager renders the new items. It never calls an API, reads a disk, stores data or mutates what you pass in. Listening to an event is what enables its action; handlers may return a promise, which the file manager tracks as an operation with progress, errors, retry, cancellation and undo — all of which you control.
user action ─▶ FileManager ─▶ @paste(event, context) ─▶ your API
◀─ progress · error · { undo, select } ─┘
your API ─▶ items ─▶ FileManager renders the result
File Structure
Usage
Basic usage
Pass a nested tree to items. Every item needs a stable, unique id, a name and a type; folders hold children. Browsing, selection and keyboard navigation work on their own. Give the file manager a height and it fills it.
<script setup lang="ts">
import { FileManager, type FileManagerItem } from '@/components/ui/file-manager'
const files: FileManagerItem[] = [
{
id: 'src',
name: 'src',
type: 'folder',
children: [
{ id: 'src/App.vue', name: 'App.vue', type: 'file', size: 734 },
{ id: 'src/main.ts', name: 'main.ts', type: 'file', size: 298 },
],
},
{ id: 'package.json', name: 'package.json', type: 'file', size: 1087 },
]
</script>
<template>
<FileManager :items="files" class="h-[480px]" />
</template>Events decide what exists
The file manager emits intent; your application performs it and updates items. Listening to an event is what enables its action — the toolbar, the context menu, the shortcuts and the status bar all come from one action registry, so an action you do not handle never appears, and one the selection, permissions or read-only state forbid is disabled.
<template>
<FileManager
:items="files"
@upload="upload"
@create-folder="createFolder"
@create-file="createFile"
@rename="rename"
@move="move"
@paste="paste"
@duplicate="duplicate"
@download="download"
@preview="preview"
@open="open"
@trash="trash"
@restore="restore"
@delete-permanently="purge"
@empty-trash="emptyTrash"
@share="share"
@copy-link="copyLink"
@favorite="star"
@unfavorite="unstar"
@properties="showProperties"
@refresh="refresh"
/>
</template>Async handlers and operation states
Return a promise from any handler and the file manager tracks it: a progress bar in the operations panel and on the affected items, aria-busy, and a spinner on the refresh button. The last argument is a context with progress(percent) and an AbortSignal. Instant actions (rename, star, preview) stay silent unless they fail.
<script setup lang="ts">
import type { FileManagerItem, FileManagerOperationContext } from '@/components/ui/file-manager'
async function download({ items }: { items: FileManagerItem[] }, context: FileManagerOperationContext) {
const response = await fetch('/api/archive', {
method: 'POST',
body: JSON.stringify(items.map(item => item.id)),
signal: context.signal, // Cancel aborts the request
})
const reader = response.body!.getReader()
const total = Number(response.headers.get('content-length'))
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
received += value.length
context.progress((received / total) * 100)
}
// Saving the file is your call — the file manager never downloads anything.
}
</script>
<template>
<FileManager :items="files" @download="download" />
</template>Errors, retry and cancellation
A rejected promise is never swallowed: the operation turns into an error with the message you threw, Retry (which calls your handler again with the same arguments) and Dismiss, and operation-error fires for logging. For partial failures, resolve with failed — Retry then sends only what failed. Cancel aborts context.signal.
<script setup lang="ts">
async function upload(files: File[], folder: FileManagerItem | null, context: FileManagerUploadContext) {
const results = await Promise.allSettled(files.map((file, i) =>
storage.put(folder?.id ?? '', context.relativePaths[i] || file.name, file, { signal: context.signal })))
files.value = await storage.list()
return {
failed: results.flatMap((result, i) =>
result.status === 'rejected' ? [{ source: files[i], error: String(result.reason) }] : []),
}
}
</script>
<template>
<FileManager :items="files" @upload="upload" @operation-error="op => logger.warn(op)" />
</template>Your own operations
When progress lives elsewhere (a resumable upload manager, a job queue), pass operations and listen to cancel-operation, retry-operation and dismiss-operation. Items listed in itemIds show the progress inline.
<script setup lang="ts">
import type { FileManagerOperationState } from '@/components/ui/file-manager'
const operations = computed<FileManagerOperationState[]>(() =>
uploads.value.map(upload => ({
id: upload.id,
type: 'upload',
status: upload.error ? 'error' : upload.done ? 'success' : 'running',
label: `Uploading ${upload.name}`,
progress: upload.percent,
error: upload.error,
itemIds: [upload.placeholderId],
cancelable: true,
retryable: true,
})))
</script>
<template>
<FileManager
:items="files"
:operations="operations"
@cancel-operation="op => uploader.cancel(op.id)"
@retry-operation="op => uploader.retry(op.id)"
@dismiss-operation="op => uploader.forget(op.id)"
/>
</template>Undo and redo
The file manager cannot reverse anything on your server, so it asks you how: return { undo } from a handler and it offers Undo in the panel and on Ctrl/Cmd+Z. Whatever undo returns can carry its own undo, which becomes Redo (Ctrl/Cmd+Shift+Z or Ctrl+Y). message replaces the default success text.
<script setup lang="ts">
async function trash({ items }: FileManagerItemsEvent) {
const ids = items.map(item => item.id)
await api.trash(ids)
files.value = await api.tree()
return {
message: `Moved ${ids.length} items to Trash`,
undo: async () => {
await api.restore(ids)
files.value = await api.tree()
return { undo: () => trash({ items }) } // redo
},
}
}
</script>Copy, cut and paste
Handling @paste enables Copy (Ctrl/Cmd+C), Cut (Ctrl/Cmd+X) and Paste (Ctrl/Cmd+V), from the keyboard, the context menu and on a folder ("paste into"). The clipboard is UI state — bind v-model:clipboard to share it between file managers. Cut items are dimmed until pasted. Paste goes into the open folder; pasting a folder into itself or a read-only folder is refused. copy and cut events tell you what was put on the clipboard.
<script setup lang="ts">
import type { FileManagerClipboard, FileManagerPasteEvent } from '@/components/ui/file-manager'
const clipboard = ref<FileManagerClipboard | null>(null)
async function paste({ items, target, operation, conflicts }: FileManagerPasteEvent, context: FileManagerOperationContext) {
await (operation === 'cut' ? api.move : api.copy)({
ids: items.map(item => item.id),
to: target?.id ?? null,
// e.g. [{ conflict, action: 'keep-both', name: 'report (1).pdf' }]
conflicts: conflicts.map(({ conflict, action, name }) => ({ id: (conflict.source as FileManagerItem).id, action, name })),
}, { signal: context.signal })
files.value = await api.tree()
}
</script>
<template>
<!-- Two panes sharing one clipboard -->
<FileManager v-model:clipboard="clipboard" :items="files" @paste="paste" />
<FileManager v-model:clipboard="clipboard" :items="files" @paste="paste" />
</template>Conflict resolution
Before paste, move and upload, the file manager compares names with the destination and asks: Replace, Keep both (with a free name such as "report (1).pdf") or Skip, with "apply to all" for the rest; Cancel stops everything. Copying next to the original keeps both without asking. Your handler receives the choices in conflicts, and skipped items are left out. When your server finds conflicts the file manager cannot see, call context.resolveConflicts() from the handler — the same dialog answers.
<script setup lang="ts">
async function move(event: FileManagerMoveEvent, context: FileManagerOperationContext) {
const { clashes } = await api.checkMove(event)
if (clashes.length) {
const choices = await context.resolveConflicts(clashes.map(clash => ({
name: clash.name,
source: event.items.find(item => item.id === clash.id)!,
destination: null,
target: event.target,
reason: clash.locked ? 'permission' : 'exists',
})))
if (!choices) return // the user canceled
}
await api.move(event)
}
</script>Download, duplicate, preview and open
Download is a first-class action (toolbar, menu, status bar) for any selection, folders included when your server can archive them. Duplicate (Ctrl/Cmd+D) copies next to the originals. Open and preview are different things: open fires on double-click and Enter; preview is a quick look on Space. The file manager never renders files, runs them or downloads them itself — isPotentiallyUnsafe(item) flags executables so you can warn, and they get no Preview.
<script setup lang="ts">
import { isPotentiallyUnsafe } from '@/components/ui/file-manager'
function open(item: FileManagerItem) {
if (isPotentiallyUnsafe(item) && !confirm(`${item.name} is an application. Open it anyway?`)) return
router.push(`/files/${item.id}`)
}
</script>
<template>
<FileManager
:items="files"
@open="open"
@preview="({ item }) => (quickLook = item)"
@download="({ items }) => api.download(items)"
@duplicate="({ items }) => api.duplicate(items)"
/>
<MyQuickLook v-model:item="quickLook" />
</template>Permissions and read-only
Give items permissions — read, write, delete, rename, move, copy, download, share, all allowed unless set to false. Actions follow: a folder without write refuses uploads, new items, pastes and drops (and is marked while dragged over); a file without rename has no Rename. readonly hides every change at once. This shapes the UI only — enforce permissions on your server too.
const files: FileManagerItem[] = [
{
id: 'shared',
name: 'Shared with me',
type: 'folder',
owner: 'Ada',
permissions: { write: false, delete: false, rename: false, move: false },
children: [
{ id: 'shared/roadmap.xlsx', name: 'roadmap.xlsx', type: 'file', permissions: { download: false } },
],
},
]Trash, restore and permanent delete
With @trash, Delete moves items to the trash without asking (return undo to offer Undo). Items with trashed: true offer Restore and Delete permanently instead, and a listing with trash: true adds Empty Trash; both confirm first. Keep @delete too and Shift+Delete deletes permanently. Without @trash, Delete asks for confirmation and calls @delete.
<template>
<FileManager
v-model:location="location"
:items="files"
:listing="location === 'trash' ? { items: trashed, trash: true } : null"
:locations="[{ locations: [{ id: 'trash', label: 'Trash', icon: Trash2, trash: true }] }]"
@trash="({ items }) => api.trash(items)"
@restore="({ items }) => api.restore(items)"
@delete-permanently="({ items }) => api.purge(items)"
@empty-trash="() => api.emptyTrash()"
/>
</template>Favorites
Pass the starred ids as favorites and listen to favorite / unfavorite. Starred items show a star, and the menu offers Add to or Remove from Starred. Nothing on the item is mutated.
<template>
<FileManager
:items="files"
:favorites="starred"
@favorite="({ items }) => starred.push(...items.map(item => item.id))"
@unfavorite="({ items }) => (starred = starred.filter(id => !items.some(item => item.id === id)))"
/>
</template>Sidebar locations and listings
Sections above the directory tree come from locations. An entry with folder is a shortcut to a folder (null is the root). Any other entry sets v-model:location, and you show its items through listing — Recent, Starred, Shared with me, a search, a storage provider. Listings are flat and may contain items from anywhere; entries with trash accept drops that move items to the trash.
<script setup lang="ts">
import { Clock, House, Star, Trash2 } from 'lucide-vue-next'
const location = ref<string | null>(null)
const locations = [
{ label: 'Quick access', locations: [
{ id: 'home', label: 'Home', icon: House, folder: null },
{ id: 'recent', label: 'Recent', icon: Clock },
{ id: 'starred', label: 'Starred', icon: Star },
{ id: 'trash', label: 'Trash', icon: Trash2, trash: true },
] },
{ label: 'Locations', locations: [
{ id: 'drive', label: 'My Drive', folder: 'drive-root' },
{ id: 'team', label: 'Team Drive', folder: 'team-root' },
] },
]
const { data: listing, pending } = useAsyncData(
() => (location.value ? api.listing(location.value) : Promise.resolve(null)),
{ watch: [location] },
)
</script>
<template>
<FileManager v-model:location="location" :items="files" :locations="locations" :listing="listing" :loading="pending" />
</template>Lazy loading large trees
You never need the whole filesystem in memory. With @load-children, a folder whose children is undefined is loaded when it is opened or expanded in the tree (one call, shared by both). A spinner shows while it loads; a rejected promise shows the error with Retry. Set hasChildren: false on folders known to be empty so they show no chevron.
<script setup lang="ts">
const files = ref<FileManagerItem[]>(await api.list(null)) // top level only
async function loadChildren(folder: FileManagerItem, context: FileManagerOperationContext) {
const children = await api.list(folder.id, { signal: context.signal })
files.value = setChildren(files.value, folder.id, children) // your immutable update
}
</script>
<template>
<FileManager :items="files" @load-children="loadChildren" />
</template>Pagination and infinite loading
For huge folders, load a page and set hasMore and cursor on the folder (or on the listing). The next page loads when the end scrolls into view, or with the Load more button; errors pause loading until Retry. Cards and rows use content-visibility, so long folders stay cheap to render.
<script setup lang="ts">
async function loadMore({ folder, cursor }: FileManagerLoadMoreEvent) {
const page = await api.list(folder?.id ?? null, { cursor })
files.value = appendChildren(files.value, folder?.id ?? null, page.items, {
hasMore: page.next !== null,
cursor: page.next,
})
}
</script>
<template>
<FileManager :items="files" @load-more="loadMore" />
</template>Remote search
By default the search box filters the open folder by name. With remote-search, it calls @search instead (after search-debounce ms, 300 by default; set 0 to debounce yourself) and shows a loading state while your handler runs and an error with Retry if it throws. Show the results through listing; an empty query means the search was cleared.
<script setup lang="ts">
const results = ref<FileManagerListing | null>(null)
async function search({ query, folder }: FileManagerSearchEvent, context: FileManagerOperationContext) {
results.value = query
? { label: `Results for “${query}”`, items: await api.search(query, { within: folder?.id, signal: context.signal }) }
: null
}
</script>
<template>
<FileManager :items="files" :listing="results" remote-search @search="search" />
</template>Details view columns
Choose built-in columns — name, modified, created, accessed, type, size, owner, permissions — or add your own with a value (to sort by) and format (to display); the #cell slot renders custom columns any way you like (its fallback is the formatted value). Columns sort from their header or the toolbar Sort menu. When the file manager is narrow, unpinned columns give way from the end so the name keeps at least 12rem; name, size and pinned columns always stay. Drag a column's edge to resize it and a header to move it — or, from the keyboard, arrows on the edge and Alt+Shift+Arrow on a header. Bind v-model:columns to keep the layout; resizable-columns and reorderable-columns turn this off.
<script setup lang="ts">
// Resizing and reordering update this list. To keep the layout across visits,
// store each column's key and width (functions such as `value` cannot be stored).
const columns = ref<(FileManagerColumnKey | FileManagerColumn<Meta>)[]>([
'name',
'owner',
'modified',
{ key: 'version', label: 'Version', width: '5rem', value: (item: FileManagerItem<Meta>) => item.data?.version },
{ key: 'status', label: 'Sync', width: '6rem', pinned: true },
'size',
])
</script>
<template>
<!-- v-model: resized and reordered columns come back as the new list. -->
<FileManager v-model:columns="columns" :items="files" default-view="list">
<template #cell="{ item, column }">
<SyncBadge v-if="column.key === 'status'" :state="item.data?.sync" />
</template>
</FileManager>
</template>Context menu and action availability
Right-click (or long-press, or the Menu key) opens a built-in menu with every action available for the item, the selection or the empty area. Replace it with the #context-menu slot: the scope carries the resolved actions (render some, add your own), plus rename(), remove() and defer() — the last runs your own entry once the menu has closed, which dialogs need. The exposed getActions(ids) returns the same list, e.g. for your own command palette.
<script setup lang="ts">
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'
</script>
<template>
<FileManager ref="file manager" :items="files" @paste="paste" @download="download">
<template #context-menu="{ item, actions, defer }">
<ContextMenuItem v-for="action in actions" :key="action.id" :disabled="action.disabled" @select="action.run()">
<component :is="action.icon" /> {{ action.label }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem v-if="item" @select="defer(() => openVersionHistory(item))">Version history…</ContextMenuItem>
</template>
</FileManager>
</template>Command palette
With command-palette, Ctrl/Cmd+K (while focus is in the file manager) opens a searchable list of what can be done to the selection — the same actions as the context menu, with their shortcuts — and every loaded folder and sidebar location to go to. Enter runs the highlighted line and focus returns to the list. It is off by default because many apps own Ctrl/Cmd+K; the exposed openCommandPalette() opens it from your own button, and getActions() feeds a palette of your own instead.
<template>
<FileManager ref="file manager" :items="files" command-palette @paste="paste" @rename="rename" />
<Button @click="file manager?.openCommandPalette()">Commands</Button>
</template>Touch
On touch screens a long-press opens the context menu, and holding an item until it lifts and then moving it drags it — onto a folder, the tree, a breadcrumb or the Trash, with the same checks and highlights as a mouse (browsers have no drag and drop for touch; the file manager replays the drag itself). Moving right away still scrolls. Tap opens folders, check circles stay visible for multi-select, and on phone widths the sidebar becomes an overlay and Download moves to the status bar and menus.
<template>
<!-- Nothing to configure: touch works wherever `draggable` and @move do. -->
<FileManager :items="files" draggable @move="move" />
</template>Large folders
Measured on a production build in Chrome with 5,000 files in one folder: opening it takes under a second, an arrow key about 60 ms, Select all about 160 ms, sorting about 160 ms. Items render in two parts so that focus and selection only touch the items that change, cards and rows use content-visibility, and the clock that ages “5m ago” labels re-renders only labels that change. Beyond a few thousand items per folder, load them in pages with hasMore and @load-more, and load subfolders on demand with @load-children, rather than passing everything at once.
// A page at a time: the file manager asks for the next one as the end scrolls into view.
async function loadMore({ folder, cursor }: FileManagerLoadMoreEvent) {
const page = await api.list(folder?.id ?? null, { cursor, limit: 500 })
files.value = appendChildren(files.value, folder?.id ?? null, page.items, { hasMore: page.next !== null, cursor: page.next })
}Uploads
@upload enables the Upload button, the drop tile and dropping files from the desktop; directory-upload adds Upload folder and walks dropped folders, handing you context.relativePaths. accept and max-file-size are enforced for picked and dropped files alike, and rejected files are reported. Name clashes go through the conflict dialog first.
<template>
<FileManager
:items="files"
accept="image/*,.pdf"
:max-file-size="50 * 1024 * 1024"
directory-upload
@upload="(files, folder, context) => storage.upload(files, folder, context)"
/>
</template>Translations and RTL
Every visible string — buttons, menus, dialogs, empty states, errors, operation labels, relative dates (timeAgo), file sizes (fileSize) and list separators — comes from messages; pass the ones you want to change. Strings that depend on counts or names are functions, so plurals stay correct. Set dir="rtl" (or use Reka's ConfigProvider) and arrows, indentation, breadcrumbs, menus and column resizing mirror, while file names, sizes and code previews keep their own direction. Turn on “Arabic (RTL)” in the demo settings for a complete translation.
<script setup lang="ts">
import type { FileManagerMessages } from '@/components/ui/file-manager'
const messages: Partial<FileManagerMessages> = {
newFolder: 'Nouveau dossier',
upload: 'Téléverser',
items: count => `${count} élément${count > 1 ? 's' : ''}`,
deleteTitle: items => `Supprimer ${items.length} élément(s) ?`,
justNow: 'à l’instant',
timeAgo: (value, unit) => `il y a ${value} ${{ minute: 'min', hour: 'h', day: 'j', week: 'sem.', month: 'mois', year: 'an' }[unit]}`,
fileSize: (value, unit) => `${value.replace('.', ',')} ${{ B: 'o', KB: 'Ko', MB: 'Mo', GB: 'Go', TB: 'To' }[unit]}`,
}
</script>
<template>
<FileManager :items="files" :messages="messages" dir="rtl" />
</template>FileTree on its own
The directory tree is exported as FileTree: recursive, WAI-ARIA tree semantics through Reka UI, id-based v-model:selected and v-model:expanded, search that reveals matches, inline rename, confirmed delete, lazy @load-children, a context menu and drag and drop.
<script setup lang="ts">
import { FileTree } from '@/components/ui/file-manager'
</script>
<template>
<FileTree
v-model:selected="selected"
v-model:expanded="expanded"
:items="files"
searchable
@rename="(item, name) => api.rename(item.id, name)"
@load-children="folder => api.loadChildren(folder)"
class="h-80"
/>
</template>Keyboard & accessibility
Items form a single-tab-stop listbox (aria-selected, aria-multiselectable, aria-busy while an operation runs); the directory tree is a WAI-ARIA tree. Operations are announced through a polite live region, errors as alerts, progress as progressbar. Focus lands on the new item after create, on the renamed item after rename, on the neighbour after delete, on the first item after opening a folder, and on the folder you came from after Up or Back. Everything drag and drop does is also available as Cut and Paste. Every state (views, menus, dialogs, rename, errors, trash, right to left, light and dark, phone) is checked automatically against WCAG 2.2 AA with axe; automated checks catch a share of issues, so test with a screen reader in your own product too.
Move (two-dimensional in the grid, mirrored in RTL) and select.
Extend the selection.
Move focus only; Ctrl/⌘+Space toggles.
First / last item.
Open.
Preview (with @preview), else toggle selection.
Properties.
Rename.
Copy, cut, paste.
Duplicate.
Move to trash, or delete after confirmation.
Delete permanently.
Undo. Ctrl+Y or Ctrl/⌘+Shift+Z redo.
Select all.
Up, selecting the folder you left.
Back, forward.
Clear the selection.
Type-ahead.
Command palette (with command-palette).
On a column header: move the column.
On a column edge: resize (Shift for bigger steps); Enter resets.
In the directory tree: copy, cut, paste into the focused folder.
API Reference
Props
itemsFileManagerItem<TData>[][]The tree. Never mutated.
folderstring | nullnullOpen folder id (null is the root). v-model:folder.
selectedstring[][]Selected ids. v-model:selected.
view"grid" | "list""grid"v-model:view.
sortFileManagerSort{ key: "name", direction: "asc" }v-model:sort. Folders always first.
searchstring""v-model:search. Filters the open folder, or runs @search with remote-search.
clipboardFileManagerClipboard | nullnullv-model:clipboard. Share it between file managers.
locationstring | nullnullActive sidebar location without a folder. v-model:location.
listingFileManagerListing | nullnullFlat items shown instead of the open folder: Recent, Starred, Trash, search results.
locationsFileManagerLocationSection[][]Sidebar sections above the directory tree.
operationsFileManagerOperationState[]—Your own operations, shown with the file manager's.
columns(FileManagerColumnKey | FileManagerColumn)[]name, modified, type, sizeDetails view columns. v-model:columns receives resized and moved columns.
resizableColumns · reorderableColumnsbooleantrueResize columns from their edge; move them by their header.
commandPalettebooleanfalseCtrl/Cmd+K palette of actions and places.
defaultFolder · defaultSelected · defaultView · defaultSort——Initial state when the matching v-model is not bound.
favoritesstring[]—Starred ids.
multiplebooleantrueMulti-selection.
draggablebooleanfalseDrag and drop between folders, the tree, breadcrumbs and Trash.
sidebarbooleantrueLocations and directory tree (an overlay on narrow file managers).
readonlybooleanfalseHides every action that changes data.
loadingbooleanfalseSkeletons and aria-busy.
disabledbooleanfalseDisables everything.
acceptstring—Accepted uploads, for picked and dropped files.
maxFileSizenumber—Largest accepted upload, in bytes.
directoryUploadbooleanfalseUpload folder, and dropped folders.
remoteSearchbooleanfalseSearch with @search instead of filtering.
searchDebouncenumber300Delay before @search, in ms.
confirmDeletebooleantrueConfirm deletes in a dialog.
validateName(name, item) => string | undefined—Extra rename checks.
getIconFileManagerIconResolver—Custom icons.
messagesPartial<FileManagerMessages>—Every user-facing string.
dir"ltr" | "rtl"inheritedReading direction.
rootLabel · label · classstring"root" · "Files"Breadcrumb root, list accessible name, root classes.
Item
id · name · typestring · string · "file" | "folder"Required. Ids are stable and unique across the tree.
childrenFileManagerItem[]A folder's contents. With @load-children, undefined means "not loaded".
hasChildren · hasMore · cursorboolean · boolean · unknownLazy loading and pagination.
sizenumberBytes.
createdAt · modifiedAt · accessedAtDate | stringShown relative; used for sorting.
owner · mimeType · extensionstringMetadata for columns, icons and the status bar.
description · preview · thumbnailstringCard subtitle, text preview, image URL.
permissionsFileManagerPermissionsread, write, delete, rename, move, copy, download, share.
trashed · disabledbooleanIn the trash; not interactive.
dataTDataYour metadata, typed everywhere.
Events
Action events are handler props: listening to one enables the action. Each receives a FileManagerOperationContext last and may return (or resolve to) a result.
upload(files, folder, context)Picked or dropped files. context has relativePaths and conflicts.
create-folder · create-file(parent, context)Return { rename: id } to rename the new item.
rename(item, name, context)Inline rename (F2).
move({ items, target, conflicts }, context)Drag and drop.
paste({ items, target, operation, conflicts }, context)Enables Copy, Cut and Paste.
duplicate · download · share · copy-link · properties({ items }, context)Actions on the selection.
preview({ item }, context)Quick look (Space).
openitemDouble-click, Enter, status bar.
delete(items, context)After confirmation. Shift+Delete when @trash is also set.
trash · restore · delete-permanently({ items }, context)Trash workflow.
empty-trash({ folder }, context)From a trash listing, after confirmation.
favorite · unfavorite({ items }, context)Starring.
refresh({ folder }, context)Toolbar refresh.
load-children(folder, context)Lazy folders.
load-more({ folder, cursor }, context)Next page.
search({ query, folder }, context)With remote-search.
copy · cut{ items }Items put on the clipboard.
cancel-operation · retry-operation · dismiss-operationFileManagerOperationStateFor your own operations.
operation-errorFileManagerOperationStateOne of the file manager's operations failed.
update:*folder, selected, view, sort, search, clipboard, location, columnsv-model updates.
Handler results
selectstring[]Select and focus these ids once they appear in items.
renamestringStart renaming this id once it appears.
undo() => FileManagerHandlerResultOffer Undo; its own result may carry undo (Redo).
messagestringSuccess text in the operations panel.
failed{ source, error }[]Partial failure; Retry sends only these.
Slots
#context-menu{ item, actions, rename, remove, defer }Replaces the built-in menu.
#preview{ item }A card's preview area.
#cell{ item, column }Custom details columns.
#empty{ query }Empty folder or no matches.
#delete-description{ items }Delete confirmation text.
#toolbar-actions—Extra toolbar buttons.
#status-actions{ items, actions }Status bar actions.
Exposed
rename(id) · remove(ids)Inline rename; delete through the dialog.
copy(ids) · cut(ids) · paste(folderId?)Clipboard.
undo() · redo() · refresh()History and reload.
resolveConflicts(conflicts)Open the conflict dialog yourself.
getActions(ids?)Available actions, e.g. for your own command palette.
openCommandPalette() · focus()Open the built-in palette; move focus into the file manager.
operationsEvery operation currently shown.
TypeScript
Everything is exported from @/components/ui/file-manager: FileManagerItem, FileManagerPermissions, FileManagerOperationState, FileManagerOperationContext, FileManagerOperationResult, FileManagerPasteEvent, FileManagerMoveEvent, FileManagerItemsEvent (download, duplicate, trash…), FileManagerConflict and its resolution, FileManagerClipboard, FileManagerListing, FileManagerLocation, FileManagerColumn, FileManagerAction, FileManagerCommand, FileManagerMessages (with FileManagerTimeUnit and FileManagerSizeUnit), and the helpers getFileKind, isPotentiallyUnsafe, can, formatBytes, formatRelativeTime, uniqueName, matchesAccept and sortFileItems. Both components are generic over TData.
Migrating from the previous version
- Handlers get a trailing context argument (signal, progress, resolveConflicts). Existing handlers keep working.
- move is now a handler: @move="fn" is unchanged in templates, and the payload gains conflicts. wrapper.emitted("move") no longer records it in tests.
- The status bar's default action reads "Open" (it was "Open File"), and the New Folder button's label is "New Folder" — both configurable through messages.
- FileManagerSort.key also accepts custom column keys.
- A built-in context menu now appears when you do not provide #context-menu. Its scope gained actions and defer.
- The Type column shows the kind of item ("TypeScript", "PNG image"), and the status bar shows the kind instead of the MIME type. item.description is free text for the card subtitle; add a column with value: item => item.description to show it in the details view.
- Menus are no longer modal: like desktop context menus they do not trap focus or hide the page from assistive technology, and clicking elsewhere both closes them and acts.
- The rename input is drawn over the item (in a layer inside the file manager) instead of inside it, so tests that look for it inside an option or tree item should look in the file manager.
- FileManagerConflictReason gained "into-itself", used when a folder is pasted into itself; the conflict dialog explains it.
- Relative dates and file sizes are translatable (justNow, timeAgo, fileSize, listSeparator); formatBytes and formatRelativeTime accept the messages as an optional last argument.
ui-primitives
Folder, 14 items, 1d agoDirectory
Dialog.vue, Button.vue
+12
FileManager.vue
Vue component, 4.2 KB, 10m ago, StarredVue 3 SFC
hero-banner.png
PNG image, 1.4 MB, 3d agoRaster asset

nuxt.config.ts
TypeScript, 1.1 KB, 4d agoCore config
useFileSystem.ts
TypeScript, 2.8 KB, 2h agoComposable hook
FileManager.vue(4.2 KB, Vue component)
Mock server in memory — try uploading a file named “fail.txt”.
<script setup lang="ts">
import { ref } from 'vue'
import {
FileManager,
type FileManagerItem,
type FileManagerOperationContext,
type FileManagerPasteEvent,
} from '@/components/ui/file-manager'
import { api } from '@/lib/api' // your client — the file manager never calls it
const files = ref<FileManagerItem[]>(await api.tree())
const folder = ref<string | null>(null)
const selected = ref<string[]>([])
// Every handler may return a promise: the file manager shows progress,
// errors with Retry, Cancel (through context.signal) and Undo.
async function onPaste(event: FileManagerPasteEvent, context: FileManagerOperationContext) {
await api.paste(event, { signal: context.signal, onProgress: context.progress })
files.value = await api.tree()
}
async function onTrash({ items }: { items: FileManagerItem[] }) {
await api.trash(items.map(item => item.id))
files.value = await api.tree()
return { message: `Moved ${items.length} to Trash`, undo: () => api.restore(items.map(item => item.id)) }
}
</script>
<template>
<FileManager
v-model:folder="folder"
v-model:selected="selected"
:items="files"
draggable
directory-upload
@upload="(files, folder, context) => api.upload(files, folder, context)"
@create-folder="parent => api.mkdir(parent)"
@rename="(item, name) => api.rename(item.id, name)"
@move="event => api.move(event)"
@paste="onPaste"
@trash="onTrash"
@download="({ items }) => api.download(items)"
@refresh="async () => (files.value = await api.tree())"
@open="item => router.push(`/edit/${item.id}`)"
class="h-[560px]"
/>
</template>