vfs-kit
v1.0.4
Published
A headless-first Virtual File System for React. Complete with drag-and-drop hierarchy, tab management and pluggable storage adapters.
Downloads
550
Maintainers
Readme
vfs-kit
A headless virtual filesystem engine for React. Provides a fully typed adapter model, a caching engine, and a suite of hooks and renderless components for building file explorers, editors, and asset managers — with no opinions on styling.
Table of Contents
Installation
npm install vfs-kitPeer dependencies: react, react-dom, @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities.
For IndexedDBAdapter: idb.
Quick Start
import { VfsProvider } from 'vfs-kit';
import { InMemoryAdapter } from 'vfs-kit/adapters';
import { FileTree } from 'vfs-kit/components';
import { useVfsEngine } from 'vfs-kit/hooks';
const adapter = new InMemoryAdapter();
export default function App() {
return (
<VfsProvider
workspaces={[{ id: 'main', name: 'My Files', adapter }]}
config={{ sessionId: 'user-1' }}
>
<Explorer />
</VfsProvider>
);
}
function Explorer() {
const { fs } = useVfsEngine('main');
return (
<FileTree
workspaceId="main"
renderNode={({ flatNode, onClick, dragHandleProps, style }) => (
<div {...dragHandleProps} onClick={onClick} style={style}>
{flatNode.node.name}
</div>
)}
/>
);
}Adapters
Adapters implement the VfsAdapter abstract class and provide the storage backend. Three are included; you can write your own.
InMemoryAdapter
Stores everything in Maps. No setup required. Ideal for prototyping, testing, and ephemeral state.
import { InMemoryAdapter } from 'vfs-kit/adapters';
const adapter = new InMemoryAdapter();Supports history (supportsHistory = true). Data is lost on page refresh.
You can pre-seed the adapter directly before passing it to VfsProvider:
const root = await adapter.createFolder({ parentId: null, name: 'src' });
const file = await adapter.createFile({ parentId: root.id, name: 'index.ts', mimeType: 'text/typescript' });
await adapter.writeFile(file.id, new TextEncoder().encode('export {}'));IndexedDBAdapter
Persists to the browser's IndexedDB via idb. Survives page refresh. Supports optional cross-tab sync via BroadcastChannel.
import { IndexedDBAdapter } from 'vfs-kit/adapters';
const adapter = new IndexedDBAdapter({
dbName: 'my-app-fs',
dbVersion: 1, // optional, default 1
isolation: 'shared', // 'private' (default) | 'shared' — enables BroadcastChannel sync
});
await adapter.open(); // must be called before passing to VfsProviderCall adapter.close() on unmount if managing lifecycle manually.
Supports history (supportsHistory = true). Enforces a 50-snapshot cap per file, evicting the oldest automatically.
RestAdapter
Delegates all operations to a remote HTTP API. Connects to a server-sent events (SSE) endpoint for real-time change propagation, with an optional fallback for non-SSE environments.
import { RestAdapter } from 'vfs-kit/adapters';
const adapter = new RestAdapter({
baseUrl: 'https://api.example.com/fs',
supportsHistory: true, // default true
fetch: customFetch, // optional — defaults to globalThis.fetch
endpoints: { ... }, // optional — override individual endpoint URLs
realtimeFallback: (onMessage, onError) => {
// e.g. set up a WebSocket and return a cleanup function
return () => { /* disconnect */ };
},
});
await adapter.open();Default endpoint conventions (all relative to baseUrl):
| Operation | Method | Path |
|---|---|---|
| Get node | GET | /nodes/:id |
| Get children | GET | /nodes/:parentId/children |
| Create file | POST | /nodes/file |
| Create folder | POST | /nodes/folder |
| Write content | PUT | /content/:id |
| Move (batch) | POST | /nodes/batch/move |
| Get snapshots | GET | /snapshots/:fileId |
| Save snapshot | POST | /snapshots/:fileId |
| Restore snapshot | POST | /snapshots/:fileId/:index/restore |
| Delete snapshot | DELETE | /snapshots/:fileId/:index |
| SSE stream | GET | /events |
Any endpoint can be overridden individually via the endpoints option.
Writing a Custom Adapter
Extend VfsAdapter and implement all abstract methods. Set supportsHistory = true and implement the four history methods (getSnapshots, saveSnapshot, restoreSnapshot, deleteSnapshot) if your backend supports it.
import { VfsAdapter } from 'vfs-kit/core';
export class MyAdapter extends VfsAdapter {
readonly supportsHistory = false;
// implement abstract methods...
}Provider
VfsProvider initialises the engine for each workspace and makes them available via context.
<VfsProvider
workspaces={[
{ id: 'ws-a', name: 'Source', adapter: adapterA },
{ id: 'ws-b', name: 'Target', adapter: adapterB },
]}
config={{
sessionId: 'user-123', // required — used for node locking
allowDuplicateNames: false, // default false
duplicateResolution: 'throw', // 'throw' | 'rename' — default 'throw'
history: {
maxSnapshots: 50, // default 50
autosave: {
enabled: false, // default false
intervalMs: 30_000,
},
},
}}
cacheStrategy="hybrid" // 'eager' | 'lazy' | 'hybrid' — default 'hybrid'
tabPersistence={{ strategy: 'session' }} // 'session' | 'none' — default 'none'
fallback={<div>Loading…</div>}
>
{children}
</VfsProvider>Cache strategies:
eager— loads all nodes on initlazy— loads nodes on demandhybrid— loads root children on init, everything else on demand
Hooks
useVfs
The all-in-one hook. Combines engine, tabs, selection, and expanded state in a single call. Use this when you want everything wired together; use the individual hooks when you need fine-grained control.
import { useVfs } from 'vfs-kit/hooks';
const {
fs, // VfsFsApi — write operations
tree, // VfsTreeApi — read operations
status, // VfsStatusApi
tabs, // VfsTabsApi
selection, // VfsSelectionApi
expanded, // VfsExpandedApi
} = useVfs({
workspaceId: 'ws-a', // optional — defaults to active workspace
tabs: { workspaceIds: ['ws-a'] },
selection: { multiSelect: true, rangeSelect: true },
expanded: { persistKey: 'sidebar-expanded' },
});useVfsEngine
Returns only the fs / tree / status surfaces for a workspace. The most commonly used hook when you don't need tabs or selection.
const { fs, tree, status } = useVfsEngine('ws-a');fs — write operations:
fs.createFile(parentId, name, { mimeType?, meta? })
fs.createFolder(parentId, name, { meta? })
fs.rename(id, newName)
fs.delete(ids, permanent?) // soft-delete by default
fs.restore(ids)
fs.purge(ids) // permanent delete
fs.move(ids, newParentId)
fs.write(id, content) // Uint8Array
fs.lock(ids)
fs.unlock(ids)
fs.reorder(parentId, orderedIds)
fs.snapshot(fileId, label?)
fs.execute(command) // raw VfsCommand escape hatchtree — read operations:
tree.getNode(id) // Promise<VfsNode | null>
tree.getChildren(parentId, opts?) // Promise<VfsNode[]>
tree.getPath(id) // Promise<string> e.g. '/src/index.ts'
tree.readFile(id) // Promise<Uint8Array>
tree.readJSON<T>(id) // Promise<T>
tree.writeJSON<T>(id, data) // Promise<void>
tree.search(query, opts?)
tree.getTrashed()
tree.getSnapshots(fileId) // Promise<VfsFileSnapshot[]>status:
status.pending // boolean — true while a command is in flight
status.error // Error | null — last command error
status.version // number — increments on every change, useful as a cache keyuseVfsTabs
Manages open file tabs. Supports dirty tracking, locking, drag-to-reorder, and optional session persistence.
import { useVfsTabs } from 'vfs-kit/hooks';
const tabs = useVfsTabs({
workspaceIds: ['ws-a'], // optional
dirtyChecker: ({ savedContent, currentContent }) => ..., // optional custom checker
});tabs.tabs // VfsTab[]
tabs.activeTabId // string | null
tabs.activeTab // VfsTab | null
tabs.open(nodeId, workspaceId) // opens or focuses a tab
tabs.close(tabId)
tabs.closeOthers(tabId)
tabs.closeAll(workspaceId?)
tabs.setActive(tabId)
tabs.reorder(activeId, overId)
tabs.lock(tabId)
tabs.unlock(tabId)
tabs.markDirty(tabId, currentContent)
tabs.markSaved(tabId)VfsTab shape:
interface VfsTab {
id: string;
nodeId: string;
workspaceId: string;
title: string;
isDirty: boolean;
isLocked: boolean;
savedContent: Uint8Array | null;
currentContent: Uint8Array | null;
lastSavedAt: number | null;
}Important: when using a custom tab UI, use
TabListBaseand pass youruseVfsTabsinstance into it directly. UsingTabListcreates its own internaluseVfsTabsinstance which will be disconnected from yours.
useVfsHistory
Per-file snapshot manager. Loads snapshots for the active file and keeps the list up to date as snapshots are taken. Scoped to the current render — history is discarded when the component unmounts.
import { useVfsHistory } from 'vfs-kit/hooks';
const { snapshots, takeSnapshot, restore, remove, loading } = useVfsHistory({
workspaceId: 'ws-a',
nodeId: 'file-id', // null clears the list
onRestore: (bytes) => { // called after a restore so you can sync editor state
setContent(new TextDecoder().decode(bytes));
},
});takeSnapshot(label?) // saves current file content as a new snapshot
restore(index) // writes snapshot content back to the file and calls onRestore
remove(index) // removes a snapshot
loading // true while the initial snapshot list is loading
snapshots // VfsFileSnapshot[] — most-recent firstVfsFileSnapshot shape:
interface VfsFileSnapshot {
id: string;
fileId: string;
index: number;
content: Uint8Array;
createdAt: number;
label?: string;
}Only available when the adapter has supportsHistory = true (InMemoryAdapter and IndexedDBAdapter both do; RestAdapter depends on your server).
useVfsExpanded
Manages folder expanded state in a file tree. Persists to sessionStorage when a persistKey is provided. Automatically collapses deleted folders.
const expanded = useVfsExpanded({
workspaceIds: ['ws-a'], // optional
defaultExpanded: ['folder-id'], // optional
persistKey: 'sidebar-expanded', // optional — enables sessionStorage persistence
});expanded.expandedIds // Set<string>
expanded.isExpanded(id) // boolean
expanded.expand(id)
expanded.collapse(id)
expanded.toggle(id)
expanded.expandAll(ids)
expanded.collapseAll()
expanded.expandToNode(id, workspaceId?) // expands all ancestors of a nodeuseVfsSelection
Manages selected node state. Supports single select, multi-select, and shift-range select.
const selection = useVfsSelection({
multiSelect: true, // default true
rangeSelect: true, // default true
});selection.selection // string[]
selection.lastSelectedId // string | null
selection.isSelected(id) // boolean
selection.select(id) // replaces selection
selection.toggle(id) // adds/removes from selection
selection.selectRange(orderedIds, anchorId, targetId)
selection.deselect(id)
selection.deselectAll()
selection.selectAll(ids)useSortableTab
A thin wrapper around @dnd-kit/sortable's useSortable for tab drag-to-reorder. Returns ref, style, and dragHandleProps ready to spread onto a tab element.
import { useSortableTab } from 'vfs-kit/hooks';
function MyTab({ tab }) {
const { ref, style, dragHandleProps } = useSortableTab(tab.id);
return (
<div ref={ref} style={style} {...dragHandleProps}>
{tab.title}
</div>
);
}Used internally by TabListBase when sortable is true. Use it directly when building a fully custom tab strip.
Components
All components are headless by default — they manage behaviour and delegate rendering to your own render props.
FileTree
Renders a flat virtualised list of nodes for a workspace. Handles expand/collapse, drag-and-drop reordering, inline creation, and drop-zone overlays.
import { FileTree } from 'vfs-kit/components';
<FileTree
workspaceId="ws-a"
draggable // enables drag-and-drop
renderNode={({ flatNode, isSelected, isActive, isDragging, isDragSession,
isRenaming, setRenaming, dragHandleProps, style, onClick }) => (
<div {...dragHandleProps} onClick={onClick} style={style}>
{flatNode.node.name}
</div>
)}
inlineCreate={{ kind: 'file' }} // pass null to cancel
onCancelCreate={() => {}}
folderOverlay={{
mode: 'full', // 'full' | 'none'
renderOverlay: ({ isBlocked, isEmpty }) => <div />,
}}
/>NodeRenderProps:
interface NodeRenderProps<TMeta> {
flatNode: FlatNode<TMeta>; // { node, level, isOpen }
isSelected: boolean;
isActive: boolean;
isDragging: boolean;
isDragSession: boolean;
isRenaming: boolean;
setRenaming: (v: boolean) => void;
dragHandleProps: Record<string, unknown>;
style: React.CSSProperties;
onClick: (e: React.MouseEvent) => void;
}When a file node is clicked,
FileTree's internalonClickhandles selection and expand — it does not open tabs. Wiretabs.open(node.id, workspaceId)inside yourrenderNodeclick handler if you want tab behaviour.
FileRenderer
Loads and renders the content of the active file. Re-runs when activeNodeId changes, handles loading and error states, and calls onLoad once content is available.
import { FileRenderer } from 'vfs-kit/components';
<FileRenderer
workspaceId="ws-a"
activeNodeId={tabs.activeTab?.nodeId ?? null}
onLoad={(node, bytes) => setContent(new TextDecoder().decode(bytes))}
onError={(node, error) => console.error(error)}
fallback={<div>No file open</div>}
>
{({ node, content, loading, error }) => (
<textarea value={new TextDecoder().decode(content ?? new Uint8Array())} />
)}
</FileRenderer>Key the child component on
node.idif it owns its own content state — this causes React to fully remount on tab switch, cleanly resetting state without auseEffect.
TabList / TabListBase
TabList is the higher-level component: it creates its own useVfsTabs instance internally.
TabListBase is the lower-level component: it accepts tab state as props, so you can drive it from a useVfsTabs instance you control elsewhere. Use TabListBase when you are also calling tabs.open() from your own code — otherwise the tab strip and your open calls will be in two disconnected states.
import { TabListBase } from 'vfs-kit/components';
// tabs = useVfsTabs({ workspaceIds: ['ws-a'] })
<TabListBase
tabs={tabs.tabs}
activeTabId={tabs.activeTabId}
onReorder={tabs.reorder}
onClose={tabs.close}
onSetActive={tabs.setActive}
onLock={tabs.lock}
onUnlock={tabs.unlock}
sortable // enables drag-to-reorder via @dnd-kit
>
{({ tabs: tabList, activeTabId, onClose, onSetActive, onLock, onUnlock, dragHandleProps }) => (
<div style={{ display: 'flex' }}>
{tabList.map(tab => (
<MyTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
dragHandleProps={dragHandleProps(tab.id)}
onSetActive={onSetActive}
onClose={onClose}
onLock={onLock}
onUnlock={onUnlock}
/>
))}
</div>
)}
</TabListBase>InlineInput
A controlled text input designed for inline rename flows. Commits on Enter or blur, cancels on Escape.
import { InlineInput } from 'vfs-kit/components';
<InlineInput
initialValue={node.name}
onSubmit={(newName) => fs.rename(node.id, newName)}
onCancel={() => setRenaming(false)}
className="my-input-class"
/>Core Types
// vfs-kit/core
interface VfsNode<TMeta> {
id: string;
name: string;
kind: 'file' | 'folder';
parentId: string | null;
sortIndex: number | null;
lockedBy: string | null;
createdAt: number;
updatedAt: number;
deletedAt: number | null;
meta: TMeta;
}
interface VfsFileNode<TMeta> extends VfsNode<TMeta> {
kind: 'file';
mimeType: string;
size: number;
}
interface VfsFolderNode<TMeta> extends VfsNode<TMeta> {
kind: 'folder';
}
interface VfsFileSnapshot {
id: string;
fileId: string;
index: number;
content: Uint8Array;
createdAt: number;
label?: string;
}Roadmap
deleteSnapshotas a formalVfsCommand— currentlyremove()inuseVfsHistoryis local-state only; adding{ op: 'deleteSnapshot', fileId, index }toVfsEngineandfs.deleteSnapshot()toVfsFsApiwill make it durable- Autosave snapshots —
VfsEngineConfig.history.autosaveis wired in the config but not yet connected; asetIntervalinuseVfsHistorygated onautosave.enabledis the planned hookup point - Snapshot diff view — compare a snapshot's content against the current file before restoring
