useFileExplorer
useFileExplorer<T>({store, ...}) is the composable. It wires a configured AssetStore<T> into reactive tree + tab state, owns the async / dirty / blob-URL bookkeeping, and returns every ref and op a file-explorer shell needs.
import {
useFileExplorer,
type UseFileExplorerOptions,
type UseFileExplorerReturn,
type OpenTab,
} from '@cocoar/vue-file-explorer';Options
interface UseFileExplorerOptions<T = unknown> {
store: AssetStore<T>;
onError?: (op: AssetOp, err: unknown, ctx: AssetOpContext) => void;
getFileMeta?: (asset: Asset<T>) => FileMeta | null;
confirm?: (message: string) => boolean;
initialExpandedIds?: readonly string[];
sortMode?: MaybeRefOrGetter<SortMode<T>>;
}| Option | Default | Notes |
|---|---|---|
store | required | Anything implementing AssetStore<T>. The single seam between view and backend. |
onError | no-op | Single funnel for every store rejection. The composable has already rolled back by the time this fires. |
getFileMeta | none | Stage-2 of the file-meta fallback chain. Returns null to fall through to the extension heuristic. |
confirm | window.confirm | Used by closeTab / closeOthers / closeToRight / closeAll to confirm discarding dirty tabs. Override for custom dialogs. |
initialExpandedIds | top-level folders (eager) / [] (lazy) | Folder ids to seed expanded with. Defaults to root folders in eager mode so the user sees content immediately; defaults to empty in lazy mode so the canonical click-to-expand UX appears. |
sortMode | 'folders-first' | Sibling ordering strategy. MaybeRefOrGetter so a toolbar can flip it live. See sort modes. |
Return surface
The return is organized into four groups: tree state, tab state, ops, navigation.
Tree state
readonly assets: Readonly<Ref<readonly Asset<T>[]>>;
readonly rootNodes: Readonly<Ref<readonly Asset<T>[]>>;
selectedId: Ref<string | null>;
expanded: Ref<Set<string>>;
readonly loading: Readonly<Ref<boolean>>;
readonly loadingNodes: Readonly<Ref<ReadonlySet<string>>>;
readonly savingNodes: Readonly<Ref<ReadonlySet<string>>>;
readonly reorderable: Readonly<Ref<boolean>>;| Ref | Notes |
|---|---|
assets | Flat reactive list — the store's underlying projection. Read-only from the consumer's perspective. |
rootNodes | Already filtered + sorted children of null. Pass directly to <CoarTree :nodes>. |
selectedId | Two-way. Single-click on a row sets this; a watcher then opens the file as a preview tab. |
expanded | Two-way Set<string> of expanded folder ids. In lazy mode, newly-added ids trigger store.loadChildren(). |
loading | true during the initial store.loadTree() call only. Per-folder lazy loads + per-file content loads live on loadingNodes. Stays false for stores that surface their own reactive _assets. |
loadingNodes | Per-id Set: files being loadContent-fetched + folders being lazy-loaded. Bind to row-icon → spinner swap. |
savingNodes | Per-id Set: any in-flight save / rename / delete / move. Same spinner channel as loadingNodes. |
reorderable | true when sortMode === 'manual'. Reactive — read it in your CoarTree wiring to gate drop-between-siblings. |
CoarTree wiring helpers
getId: (a: Asset<T>) => string;
getChildren: (a: Asset<T>) => readonly Asset<T>[] | undefined;
getLabel: (a: Asset<T>) => string;
isExpandable: (a: Asset<T>) => boolean;These mirror <CoarTree>'s prop signatures so you can pass them straight through:
<CoarTree
:nodes="fe.rootNodes.value"
:get-id="fe.getId"
:get-children="fe.getChildren"
:get-label="fe.getLabel"
:is-expandable="fe.isExpandable"
v-model:expanded="fe.expanded.value"
v-model:selected="fe.selectedId.value"
draggable
renamable
@activate="fe.activateNode"
@rename="({ node, newName }) => fe.rename(node.id, newName)"
@node-move="fe.moveNode"
/>isExpandable returns false for folders the store reports as hasChildren: false — keeps the chevron off known-empty folders in lazy mode.
Tab state
readonly openTabs: Readonly<Ref<readonly OpenTab[]>>;
activeId: Ref<string | null>;
readonly activeTab: Readonly<Ref<OpenTab | null>>;
readonly anyDirty: Readonly<Ref<boolean>>;
isDirty: (tab: OpenTab) => boolean;
setContent: (id: string, content: string) => void;
interface OpenTab {
id: string;
name: string;
editor: FileEditor;
language?: CoarScriptEditorLanguage;
content: string; // current editor buffer
savedContent: string; // last persisted — content !== savedContent ⇒ dirty
pinned: boolean; // false = preview (italic, replaced on next preview)
}The tab state machine implements the VSCode pattern:
| Trigger | Result |
|---|---|
| Single-click on a file row | Push a preview tab (italic title). At most one preview exists at a time — opening another preview replaces it. |
| Double-click / Enter / "Open" menu | Open as pinned. Existing previews are upgraded in place. |
User edits → setContent differs from savedContent | Auto-pin the tab. Eliminates the impossible "italic + dirty" state. |
closeTab while dirty | Calls options.confirm ("Discard unsaved changes to '{name}'?"). |
closeTab while savingNodes.has(id) | Bails — closing would orphan the in-flight save. |
Imperative ops
// CRUD — optimistic; resolve when the backend confirms
addFolder(parentId: string | null, name: string): Promise<Asset<T> | null>;
addFiles(parentId: string | null, files: FileList | readonly File[]): Promise<void>;
deleteNode(asset: Asset<T>): Promise<void>;
moveNode(e: CoarTreeNodeMoveEvent<Asset<T>>): Promise<void>;
rename(id: string, newName: string): Promise<void>;
refresh(folderId?: string | null): Promise<void>;
// Tab ops
openFile(asset: Asset<T>, opts?: { pinned: boolean }): Promise<void>;
activateNode(asset: Asset<T>): void; // dblclick / Enter — opens pinned
saveTab(id: string): Promise<boolean>;
saveActive(): Promise<void>;
closeTab(id: string): void;
closeOthers(keepId: string): void;
closeToRight(anchorId: string): void;
closeAll(): void;
pinTab(id: string): void;
unpinTab(id: string): void;
reorderTab(sourceId: string, targetId: string, position: 'before' | 'after'): void;Notes on the trickier ones:
addFilesis the OS-drop entry point. The composable derives content per file (text orURL.createObjectURLfor PDF / image), callsstore.uploadFile(), thenstore.save(id, content). Blob URLs are tracked and revoked on delete + unmount.moveNodeconsumes<CoarTree>'sCoarTreeNodeMoveEvent. Forposition: 'inside', it auto-expands the target folder. For'before' / 'after', it forwardsposition?only whenreorderable.value.openFileis the placeholder-then-fill flow. The placeholder tab is pushed + activated immediately; onloadContentrejection the placeholder rolls back so the user isn't stranded.activateNodeis meant for<CoarTree @activate>. Files open pinned; folders are a no-op (CoarTree itself toggles expansion).reorderTabis for drag-to-reorder. No-op on self-drop or unknown ids; pinned status is preserved on the moved tab.refresh()re-runsstore.loadTree()(orstore.loadChildren(folderId)in lazy mode when given a folder id). Use it when upstream state can change out-of-band — a SignalR push from the backend, another tab uploading a file, a server-side retention sweep. No-op for stores that surface a reactive_assetsdirectly: those are already live.
Navigation
revealInTree(id: string, focusNode?: (id: string) => void): void;
readonly breadcrumbPath: Readonly<Ref<readonly string[]>>;
pathOf(id: string): string[];
fileMeta(asset: Asset<T>): FileMeta | null;| Helper | Notes |
|---|---|
revealInTree | Walks the parentId chain, expands every ancestor in one batch, sets selectedId, then calls focusNode?.(id) after Vue flushes. Pass the tree's api.focusNode if you have it. |
breadcrumbPath | Name-path of the active tab. Drives the editor-area breadcrumb. |
pathOf(id) | Name-path of any asset. Used for quick-open / recent-files matchers. |
fileMeta(asset) | Runs the 3-stage fallback. Returns null for unrecognised binary types — caller skips with a warning. |
Persistent viewer config across file swaps
When the consumer's shell mounts different editors via v-if based on activeTab.editor, each editor component is freshly mounted on every editor-type swap (e.g. .md → .ts → .md unmounts + remounts Monaco the second time). Any internal editor state — Monaco's scroll position, CoarDocumentViewer's sidebar open / closed, Milkdown's toolbar collapse — resets on remount unless the consumer's shell holds that state as refs and passes them in.
The cleanest pattern: hold whatever you want to persist as refs outside the v-if branch, bind them via v-model or pass them via props.
<script setup lang="ts">
import { ref } from 'vue';
import { CoarDocumentViewer, type CoarDocumentViewerTool } from '@cocoar/vue-document-viewer';
// These live in the shell, not inside the v-if branch — they survive editor swaps.
const viewerSidebarOpen = ref(false);
const viewerAnnotationsPanelOpen = ref(false);
const viewerTools: CoarDocumentViewerTool[] = [
'sidebar-toggle', 'annotations-panel', 'separator',
'zoom-out', 'zoom-reset', 'zoom-in', 'separator',
'fit-width', 'fit-page',
];
</script>
<template>
<CoarDocumentViewer
v-if="fe.activeTab.value?.editor === 'image' && imageSrc"
:source="imageSrc"
:tools="viewerTools"
:show-thumbnails="true"
:show-annotations-panel="true"
v-model:sidebar-open="viewerSidebarOpen"
v-model:annotations-panel-open="viewerAnnotationsPanelOpen"
/>
</template>This is shell-level state, not composable-level
The composable deliberately knows nothing about editors — useFileExplorer doesn't render them or hold their config. That keeps it independent of any specific editor package, and lets every consumer's shell define its own dispatch logic (which editor for which file type, fall-through to plain text, custom editors for proprietary asset payload types, …). The trade-off: editor persistence is a shell concern. The pattern above is the canonical way to handle it.
This works for every editor: hold a Monaco view-state ref outside the v-if and restore it in onMounted; hold a Milkdown collapsed-toolbar boolean outside and pass it via prop; hold the position memory ref outside and bind it to v-model:position. The composable doesn't care — its job is the tree + tab state, not the editor's internals.
Lifecycle
- Mount — In lazy mode, the composable watches
expandedwithimmediate: trueso any seeded ids ininitialExpandedIdspre-load their children. - Eager open — Single-click
selectedIdchange is watched; file selections fireopenFile(file, { pinned: false }). - Unmount —
onScopeDisposeremoves thebeforeunloadlistener and revokes every blob URL the composable owns. Consumer doesn't need to clean up. beforeunload— Active whileanyDirty.valueis true. Browser shows its native "leave site?" prompt.
Example consumer shell
A minimal shell — tree + breadcrumb + a single-tab editor area — to show what the composable's surface looks like in practice:
<script setup lang="ts">
import {
createInMemoryAssetStore,
useFileExplorer,
type Asset,
} from '@cocoar/vue-file-explorer';
import { CoarTree, CoarTreeNodeLabel, CoarBreadcrumb, CoarBreadcrumbItem } from '@cocoar/vue-ui';
const seed: Asset[] = [
{ id: 's', name: 'src', kind: 'folder', parentId: null },
{ id: 'u', name: 'utils.ts', kind: 'file', parentId: 's' },
];
const store = createInMemoryAssetStore({
initialTree: seed,
initialContent: { u: 'export const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));' },
});
const fe = useFileExplorer({
store,
onError: (op, err) => console.warn(`[file-explorer] ${op}:`, err),
});
</script>
<template>
<div class="shell">
<aside>
<CoarTree
:nodes="fe.rootNodes.value"
:get-id="fe.getId"
:get-children="fe.getChildren"
:get-label="fe.getLabel"
:is-expandable="fe.isExpandable"
v-model:expanded="fe.expanded.value"
v-model:selected="fe.selectedId.value"
renamable
@activate="fe.activateNode"
@rename="({ node, newName }) => fe.rename(node.id, newName)"
>
<template #default="{ node }">
<CoarTreeNodeLabel :label="node.name" />
</template>
</CoarTree>
</aside>
<main>
<CoarBreadcrumb v-if="fe.activeTab.value">
<CoarBreadcrumbItem v-for="seg in fe.breadcrumbPath.value" :key="seg">
{{ seg }}
</CoarBreadcrumbItem>
</CoarBreadcrumb>
<textarea
v-if="fe.activeTab.value"
:value="fe.activeTab.value.content"
@input="e => fe.setContent(fe.activeTab.value!.id, (e.target as HTMLTextAreaElement).value)"
/>
</main>
</div>
</template>The full POC (apps/playground/src/views/FileExplorerPocView.vue — ~1280 LoC) wires every feature: tab bar with drag-to-reorder, context menus, Ctrl+P quick-open, simulator panel for latency / failure / sort / conflict, editor dispatch across Monaco / Milkdown / CoarDocumentViewer, OS file drop, reveal-in-tree. Treat it as the worked reference.