vfs-kit 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +570 -97
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,145 +1,618 @@
1
- # vfs-kit Explorer — React + TypeScript + Vite
1
+ # vfs-kit
2
2
 
3
- A dual-pane file explorer built on top of `vfs-kit`, demonstrating a fully custom UI layer over a virtual filesystem engine. Features tabbed editing, drag-and-drop file tree, and per-file snapshot history.
3
+ 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.
4
4
 
5
5
  ---
6
6
 
7
- ## Features
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [Adapters](#adapters)
12
+ - [InMemoryAdapter](#inmemoryadapter)
13
+ - [IndexedDBAdapter](#indexeddbadapter)
14
+ - [RestAdapter](#restadapter)
15
+ - [Writing a Custom Adapter](#writing-a-custom-adapter)
16
+ - [Provider](#provider)
17
+ - [Hooks](#hooks)
18
+ - [useVfs](#usevfs)
19
+ - [useVfsEngine](#usevfsengine)
20
+ - [useVfsTabs](#usevfstabs)
21
+ - [useVfsHistory](#usevfshistory)
22
+ - [useVfsExpanded](#usevfsexpanded)
23
+ - [useVfsSelection](#usevfsselection)
24
+ - [useSortableTab](#usesoratabletab)
25
+ - [Components](#components)
26
+ - [FileTree](#filetree)
27
+ - [FileRenderer](#filerenderer)
28
+ - [TabList / TabListBase](#tablist--tablistbase)
29
+ - [InlineInput](#inlineinput)
30
+ - [Core Types](#core-types)
31
+ - [Roadmap](#roadmap)
8
32
 
9
- - **Dual-pane layout** — two independent `FilePane` instances (`Source` / `Target`), each with its own workspace, file tree, and tab strip
10
- - **Custom file tree** — built with `FileTree` + a fully custom `renderNode` prop; supports drag-and-drop reordering, inline rename (`InlineInput`), folder expand/collapse, and drop-zone overlays
11
- - **Tabbed editor** — uses `TabListBase` (not `TabList`) so tabs share a single `useVfsTabs` instance with the rest of the pane; tabs support dirty indicators, lock/unlock, drag-to-reorder, and close
12
- - **Per-file snapshot history** — `useFileHistory` hook manages snapshots per open file using only the public `fs` / `tree` surfaces from `useVfsEngine`; the `HistoryPanel` sidebar lets you label, take, restore, and remove snapshots
13
- - **Clean tab switching** — `Editor` is keyed on `node.id` so React fully remounts it on tab change, resetting both content state and history without any manual sync
33
+ ---
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ npm install vfs-kit
39
+ ```
40
+
41
+ Peer dependencies: `react`, `react-dom`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
42
+
43
+ For `IndexedDBAdapter`: `idb`.
14
44
 
15
45
  ---
16
46
 
17
- ## Architecture
47
+ ## Quick Start
48
+
49
+ ```tsx
50
+ import { VfsProvider } from 'vfs-kit';
51
+ import { InMemoryAdapter } from 'vfs-kit/adapters';
52
+ import { FileTree } from 'vfs-kit/components';
53
+ import { useVfsEngine } from 'vfs-kit/hooks';
54
+
55
+ const adapter = new InMemoryAdapter();
56
+
57
+ export default function App() {
58
+ return (
59
+ <VfsProvider
60
+ workspaces={[{ id: 'main', name: 'My Files', adapter }]}
61
+ config={{ sessionId: 'user-1' }}
62
+ >
63
+ <Explorer />
64
+ </VfsProvider>
65
+ );
66
+ }
67
+
68
+ function Explorer() {
69
+ const { fs } = useVfsEngine('main');
70
+
71
+ return (
72
+ <FileTree
73
+ workspaceId="main"
74
+ renderNode={({ flatNode, onClick, dragHandleProps, style }) => (
75
+ <div {...dragHandleProps} onClick={onClick} style={style}>
76
+ {flatNode.node.name}
77
+ </div>
78
+ )}
79
+ />
80
+ );
81
+ }
82
+ ```
83
+
84
+ ---
18
85
 
19
- ### `FilePane`
86
+ ## Adapters
20
87
 
21
- The top-level pane component. Owns the single `useVfsTabs` instance for its workspace and passes it directly into `TabListBase` this is important because `TabList` (the higher-level component) would create its own internal `useVfsTabs` instance, resulting in two disconnected reducer states.
88
+ Adapters implement the `VfsAdapter` abstract class and provide the storage backend. Three are included; you can write your own.
22
89
 
90
+ ### InMemoryAdapter
91
+
92
+ Stores everything in `Map`s. No setup required. Ideal for prototyping, testing, and ephemeral state.
93
+
94
+ ```ts
95
+ import { InMemoryAdapter } from 'vfs-kit/adapters';
96
+
97
+ const adapter = new InMemoryAdapter();
23
98
  ```
24
- FilePane
25
- ├── FileTree (sidebar) ← custom renderNode wired to tabs.open on file click
26
- ├── TabListBase (tab strip) ← driven by the same tabs instance as FileTree
27
- └── FileRenderer (editor area)
28
- └── Editor (keyed on node.id)
29
- ├── textarea
30
- ├── useFileHistory
31
- └── HistoryPanel
99
+
100
+ Supports history (`supportsHistory = true`). Data is lost on page refresh.
101
+
102
+ You can pre-seed the adapter directly before passing it to `VfsProvider`:
103
+
104
+ ```ts
105
+ const root = await adapter.createFolder({ parentId: null, name: 'src' });
106
+ const file = await adapter.createFile({ parentId: root.id, name: 'index.ts', mimeType: 'text/typescript' });
107
+ await adapter.writeFile(file.id, new TextEncoder().encode('export {}'));
32
108
  ```
33
109
 
34
- ### `useFileHistory`
110
+ ### IndexedDBAdapter
35
111
 
36
- Per-tab in-memory snapshot manager. Scoped to the currently open file history is intentionally discarded when the tab closes.
112
+ Persists to the browser's IndexedDB via [`idb`](https://github.com/jakearchibald/idb). Survives page refresh. Supports optional cross-tab sync via `BroadcastChannel`.
37
113
 
38
114
  ```ts
39
- const { snapshots, takeSnapshot, restore, remove, loading } = useFileHistory({
40
- workspaceId: 'ws-a',
41
- nodeId: 'some-file-id',
42
- onRestore: (bytes) => setContent(new TextDecoder().decode(bytes)),
115
+ import { IndexedDBAdapter } from 'vfs-kit/adapters';
116
+
117
+ const adapter = new IndexedDBAdapter({
118
+ dbName: 'my-app-fs',
119
+ dbVersion: 1, // optional, default 1
120
+ isolation: 'shared', // 'private' (default) | 'shared' — enables BroadcastChannel sync
43
121
  });
44
- ```
45
122
 
46
- - `takeSnapshot(label?)` calls `fs.snapshot`, then re-fetches via `tree.getSnapshots` to update the list immediately
47
- - `restore(index)` — fetches snapshot content, writes it back via `fs.write`, then calls `onRestore` so the editor syncs its controlled state
48
- - `remove(index)` — local-state removal only; `deleteSnapshot` is not yet a formal `VfsCommand` (see [Roadmap](#roadmap))
123
+ await adapter.open(); // must be called before passing to VfsProvider
124
+ ```
49
125
 
50
- Uses only the public `fs` / `tree` surfaces no direct `engine` access.
126
+ Call `adapter.close()` on unmount if managing lifecycle manually.
51
127
 
52
- ### `Editor`
128
+ Supports history (`supportsHistory = true`). Enforces a 50-snapshot cap per file, evicting the oldest automatically.
53
129
 
54
- Extracted from `FilePane` and keyed on `node.id`. Owns its own `content` state initialised from `initialContent`, and delegates history entirely to `useFileHistory`. The `setContentRef` pattern ensures `onRestore` never captures a stale setter across re-renders.
130
+ ### RestAdapter
55
131
 
56
- ### `HistoryPanel`
132
+ 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.
57
133
 
58
- Collapsible sidebar rendered inside `Editor`. Shows snapshots most-recent first with timestamp and byte size. Each entry has restore (↺) and delete (🗑) actions. The label input submits on Enter or the `+` button.
134
+ ```ts
135
+ import { RestAdapter } from 'vfs-kit/adapters';
136
+
137
+ const adapter = new RestAdapter({
138
+ baseUrl: 'https://api.example.com/fs',
139
+ supportsHistory: true, // default true
140
+ fetch: customFetch, // optional — defaults to globalThis.fetch
141
+ endpoints: { ... }, // optional — override individual endpoint URLs
142
+ realtimeFallback: (onMessage, onError) => {
143
+ // e.g. set up a WebSocket and return a cleanup function
144
+ return () => { /* disconnect */ };
145
+ },
146
+ });
59
147
 
60
- ---
148
+ await adapter.open();
149
+ ```
61
150
 
62
- ## Key Implementation Notes
151
+ **Default endpoint conventions** (all relative to `baseUrl`):
63
152
 
64
- ### Why `TabListBase` instead of `TabList`
153
+ | Operation | Method | Path |
154
+ |---|---|---|
155
+ | Get node | GET | `/nodes/:id` |
156
+ | Get children | GET | `/nodes/:parentId/children` |
157
+ | Create file | POST | `/nodes/file` |
158
+ | Create folder | POST | `/nodes/folder` |
159
+ | Write content | PUT | `/content/:id` |
160
+ | Move (batch) | POST | `/nodes/batch/move` |
161
+ | Get snapshots | GET | `/snapshots/:fileId` |
162
+ | Save snapshot | POST | `/snapshots/:fileId` |
163
+ | Restore snapshot | POST | `/snapshots/:fileId/:index/restore` |
164
+ | Delete snapshot | DELETE | `/snapshots/:fileId/:index` |
165
+ | SSE stream | GET | `/events` |
65
166
 
66
- `TabList` calls `useVfsTabs` internally, producing a second isolated reducer. `TabListBase` accepts the tab state as props, so the single instance created in `FilePane` is the source of truth for both the tree's `tabs.open()` calls and the tab strip's render.
167
+ Any endpoint can be overridden individually via the `endpoints` option.
67
168
 
68
- ### Why `Editor` is keyed on `node.id`
169
+ ### Writing a Custom Adapter
69
170
 
70
- Without a key, switching tabs leaves the previous file's content in state. Keying causes React to fully unmount and remount `Editor`, which resets `content`, `snapshots`, and `loading` cleanly no `useEffect` sync required.
171
+ 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.
71
172
 
72
- ### Why `tree` is omitted from `useEffect` deps in `useFileHistory`
173
+ ```ts
174
+ import { VfsAdapter } from 'vfs-kit/core';
73
175
 
74
- `useVfsEngine` builds `tree` via an immediately-invoked `useCallback` (`useCallback(...)()`) so a new object reference is produced on every render. Including it in deps would cause an infinite reload loop. `nodeId` is the correct and only trigger.
176
+ export class MyAdapter extends VfsAdapter {
177
+ readonly supportsHistory = false;
178
+ // implement abstract methods...
179
+ }
180
+ ```
75
181
 
76
182
  ---
77
183
 
78
- ## Roadmap
184
+ ## Provider
185
+
186
+ `VfsProvider` initialises the engine for each workspace and makes them available via context.
187
+
188
+ ```tsx
189
+ <VfsProvider
190
+ workspaces={[
191
+ { id: 'ws-a', name: 'Source', adapter: adapterA },
192
+ { id: 'ws-b', name: 'Target', adapter: adapterB },
193
+ ]}
194
+ config={{
195
+ sessionId: 'user-123', // required — used for node locking
196
+ allowDuplicateNames: false, // default false
197
+ duplicateResolution: 'throw', // 'throw' | 'rename' — default 'throw'
198
+ history: {
199
+ maxSnapshots: 50, // default 50
200
+ autosave: {
201
+ enabled: false, // default false
202
+ intervalMs: 30_000,
203
+ },
204
+ },
205
+ }}
206
+ cacheStrategy="hybrid" // 'eager' | 'lazy' | 'hybrid' — default 'hybrid'
207
+ tabPersistence={{ strategy: 'session' }} // 'session' | 'none' — default 'none'
208
+ fallback={<div>Loading…</div>}
209
+ >
210
+ {children}
211
+ </VfsProvider>
212
+ ```
79
213
 
80
- - **`deleteSnapshot` as a formal command** — add `{ op: 'deleteSnapshot', fileId, index }` to `VfsCommand` in `VfsEngine`, wire it through `runSnapshot`, expose it on `VfsFsApi` as `fs.deleteSnapshot(fileId, index)`, then replace the local-only removal in `useFileHistory.remove` with that call
81
- - **Autosave snapshots** — `VfsEngineConfig` already has `history.autosave` hook up a `setInterval` in `useFileHistory` when `autosave.enabled` is true
82
- - **Snapshot diff view** compare snapshot content against current file content before restoring
214
+ **Cache strategies:**
215
+ - `eager` — loads all nodes on init
216
+ - `lazy`loads nodes on demand
217
+ - `hybrid` — loads root children on init, everything else on demand
83
218
 
84
219
  ---
85
220
 
86
- ## Vite / ESLint setup
221
+ ## Hooks
87
222
 
88
- This project was scaffolded from the official React + TypeScript + Vite template.
223
+ ### useVfs
89
224
 
90
- ### Available plugins
225
+ 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.
91
226
 
92
- - [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) — uses Babel (or oxc with rolldown-vite) for Fast Refresh
93
- - [`@vitejs/plugin-react-swc`](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses SWC for Fast Refresh
227
+ ```ts
228
+ import { useVfs } from 'vfs-kit/hooks';
229
+
230
+ const {
231
+ fs, // VfsFsApi — write operations
232
+ tree, // VfsTreeApi — read operations
233
+ status, // VfsStatusApi
234
+ tabs, // VfsTabsApi
235
+ selection, // VfsSelectionApi
236
+ expanded, // VfsExpandedApi
237
+ } = useVfs({
238
+ workspaceId: 'ws-a', // optional — defaults to active workspace
239
+ tabs: { workspaceIds: ['ws-a'] },
240
+ selection: { multiSelect: true, rangeSelect: true },
241
+ expanded: { persistKey: 'sidebar-expanded' },
242
+ });
243
+ ```
94
244
 
95
- ### React Compiler
245
+ ### useVfsEngine
96
246
 
97
- Not enabled by default due to dev/build performance impact. See the [React Compiler installation docs](https://react.dev/learn/react-compiler/installation) to add it.
247
+ Returns only the `fs` / `tree` / `status` surfaces for a workspace. The most commonly used hook when you don't need tabs or selection.
98
248
 
99
- ### Type-aware ESLint rules
249
+ ```ts
250
+ const { fs, tree, status } = useVfsEngine('ws-a');
251
+ ```
100
252
 
101
- For production apps, enable type-aware lint rules:
253
+ **`fs` write operations:**
102
254
 
103
- ```js
104
- export default defineConfig([
105
- globalIgnores(['dist']),
106
- {
107
- files: ['**/*.{ts,tsx}'],
108
- extends: [
109
- tseslint.configs.recommendedTypeChecked,
110
- // or tseslint.configs.strictTypeChecked for stricter rules
111
- tseslint.configs.stylisticTypeChecked,
112
- ],
113
- languageOptions: {
114
- parserOptions: {
115
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
116
- tsconfigRootDir: import.meta.dirname,
117
- },
118
- },
119
- },
120
- ])
121
- ```
122
-
123
- You can also add React-specific lint rules:
124
-
125
- ```js
126
- import reactX from 'eslint-plugin-react-x'
127
- import reactDom from 'eslint-plugin-react-dom'
128
-
129
- export default defineConfig([
130
- globalIgnores(['dist']),
131
- {
132
- files: ['**/*.{ts,tsx}'],
133
- extends: [
134
- reactX.configs['recommended-typescript'],
135
- reactDom.configs.recommended,
136
- ],
137
- languageOptions: {
138
- parserOptions: {
139
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
140
- tsconfigRootDir: import.meta.dirname,
141
- },
255
+ ```ts
256
+ fs.createFile(parentId, name, { mimeType?, meta? })
257
+ fs.createFolder(parentId, name, { meta? })
258
+ fs.rename(id, newName)
259
+ fs.delete(ids, permanent?) // soft-delete by default
260
+ fs.restore(ids)
261
+ fs.purge(ids) // permanent delete
262
+ fs.move(ids, newParentId)
263
+ fs.write(id, content) // Uint8Array
264
+ fs.lock(ids)
265
+ fs.unlock(ids)
266
+ fs.reorder(parentId, orderedIds)
267
+ fs.snapshot(fileId, label?)
268
+ fs.execute(command) // raw VfsCommand escape hatch
269
+ ```
270
+
271
+ **`tree` — read operations:**
272
+
273
+ ```ts
274
+ tree.getNode(id) // Promise<VfsNode | null>
275
+ tree.getChildren(parentId, opts?) // Promise<VfsNode[]>
276
+ tree.getPath(id) // Promise<string> e.g. '/src/index.ts'
277
+ tree.readFile(id) // Promise<Uint8Array>
278
+ tree.readJSON<T>(id) // Promise<T>
279
+ tree.writeJSON<T>(id, data) // Promise<void>
280
+ tree.search(query, opts?)
281
+ tree.getTrashed()
282
+ tree.getSnapshots(fileId) // Promise<VfsFileSnapshot[]>
283
+ ```
284
+
285
+ **`status`:**
286
+
287
+ ```ts
288
+ status.pending // boolean — true while a command is in flight
289
+ status.error // Error | null — last command error
290
+ status.version // number — increments on every change, useful as a cache key
291
+ ```
292
+
293
+ ### useVfsTabs
294
+
295
+ Manages open file tabs. Supports dirty tracking, locking, drag-to-reorder, and optional session persistence.
296
+
297
+ ```ts
298
+ import { useVfsTabs } from 'vfs-kit/hooks';
299
+
300
+ const tabs = useVfsTabs({
301
+ workspaceIds: ['ws-a'], // optional
302
+ dirtyChecker: ({ savedContent, currentContent }) => ..., // optional custom checker
303
+ });
304
+ ```
305
+
306
+ ```ts
307
+ tabs.tabs // VfsTab[]
308
+ tabs.activeTabId // string | null
309
+ tabs.activeTab // VfsTab | null
310
+
311
+ tabs.open(nodeId, workspaceId) // opens or focuses a tab
312
+ tabs.close(tabId)
313
+ tabs.closeOthers(tabId)
314
+ tabs.closeAll(workspaceId?)
315
+ tabs.setActive(tabId)
316
+ tabs.reorder(activeId, overId)
317
+ tabs.lock(tabId)
318
+ tabs.unlock(tabId)
319
+ tabs.markDirty(tabId, currentContent)
320
+ tabs.markSaved(tabId)
321
+ ```
322
+
323
+ **`VfsTab` shape:**
324
+
325
+ ```ts
326
+ interface VfsTab {
327
+ id: string;
328
+ nodeId: string;
329
+ workspaceId: string;
330
+ title: string;
331
+ isDirty: boolean;
332
+ isLocked: boolean;
333
+ savedContent: Uint8Array | null;
334
+ currentContent: Uint8Array | null;
335
+ lastSavedAt: number | null;
336
+ }
337
+ ```
338
+
339
+ > **Important:** when using a custom tab UI, use `TabListBase` and pass your `useVfsTabs` instance into it directly. Using `TabList` creates its own internal `useVfsTabs` instance which will be disconnected from yours.
340
+
341
+ ### useVfsHistory
342
+
343
+ 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.
344
+
345
+ ```ts
346
+ import { useVfsHistory } from 'vfs-kit/hooks';
347
+
348
+ const { snapshots, takeSnapshot, restore, remove, loading } = useVfsHistory({
349
+ workspaceId: 'ws-a',
350
+ nodeId: 'file-id', // null clears the list
351
+ onRestore: (bytes) => { // called after a restore so you can sync editor state
352
+ setContent(new TextDecoder().decode(bytes));
142
353
  },
143
- },
144
- ])
145
- ```
354
+ });
355
+ ```
356
+
357
+ ```ts
358
+ takeSnapshot(label?) // saves current file content as a new snapshot
359
+ restore(index) // writes snapshot content back to the file and calls onRestore
360
+ remove(index) // removes a snapshot
361
+ loading // true while the initial snapshot list is loading
362
+ snapshots // VfsFileSnapshot[] — most-recent first
363
+ ```
364
+
365
+ **`VfsFileSnapshot` shape:**
366
+
367
+ ```ts
368
+ interface VfsFileSnapshot {
369
+ id: string;
370
+ fileId: string;
371
+ index: number;
372
+ content: Uint8Array;
373
+ createdAt: number;
374
+ label?: string;
375
+ }
376
+ ```
377
+
378
+ Only available when the adapter has `supportsHistory = true` (`InMemoryAdapter` and `IndexedDBAdapter` both do; `RestAdapter` depends on your server).
379
+
380
+ ### useVfsExpanded
381
+
382
+ Manages folder expanded state in a file tree. Persists to `sessionStorage` when a `persistKey` is provided. Automatically collapses deleted folders.
383
+
384
+ ```ts
385
+ const expanded = useVfsExpanded({
386
+ workspaceIds: ['ws-a'], // optional
387
+ defaultExpanded: ['folder-id'], // optional
388
+ persistKey: 'sidebar-expanded', // optional — enables sessionStorage persistence
389
+ });
390
+ ```
391
+
392
+ ```ts
393
+ expanded.expandedIds // Set<string>
394
+ expanded.isExpanded(id) // boolean
395
+ expanded.expand(id)
396
+ expanded.collapse(id)
397
+ expanded.toggle(id)
398
+ expanded.expandAll(ids)
399
+ expanded.collapseAll()
400
+ expanded.expandToNode(id, workspaceId?) // expands all ancestors of a node
401
+ ```
402
+
403
+ ### useVfsSelection
404
+
405
+ Manages selected node state. Supports single select, multi-select, and shift-range select.
406
+
407
+ ```ts
408
+ const selection = useVfsSelection({
409
+ multiSelect: true, // default true
410
+ rangeSelect: true, // default true
411
+ });
412
+ ```
413
+
414
+ ```ts
415
+ selection.selection // string[]
416
+ selection.lastSelectedId // string | null
417
+ selection.isSelected(id) // boolean
418
+ selection.select(id) // replaces selection
419
+ selection.toggle(id) // adds/removes from selection
420
+ selection.selectRange(orderedIds, anchorId, targetId)
421
+ selection.deselect(id)
422
+ selection.deselectAll()
423
+ selection.selectAll(ids)
424
+ ```
425
+
426
+ ### useSortableTab
427
+
428
+ 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.
429
+
430
+ ```ts
431
+ import { useSortableTab } from 'vfs-kit/hooks';
432
+
433
+ function MyTab({ tab }) {
434
+ const { ref, style, dragHandleProps } = useSortableTab(tab.id);
435
+ return (
436
+ <div ref={ref} style={style} {...dragHandleProps}>
437
+ {tab.title}
438
+ </div>
439
+ );
440
+ }
441
+ ```
442
+
443
+ Used internally by `TabListBase` when `sortable` is true. Use it directly when building a fully custom tab strip.
444
+
445
+ ---
446
+
447
+ ## Components
448
+
449
+ All components are headless by default — they manage behaviour and delegate rendering to your own render props.
450
+
451
+ ### FileTree
452
+
453
+ Renders a flat virtualised list of nodes for a workspace. Handles expand/collapse, drag-and-drop reordering, inline creation, and drop-zone overlays.
454
+
455
+ ```tsx
456
+ import { FileTree } from 'vfs-kit/components';
457
+
458
+ <FileTree
459
+ workspaceId="ws-a"
460
+ draggable // enables drag-and-drop
461
+ renderNode={({ flatNode, isSelected, isActive, isDragging, isDragSession,
462
+ isRenaming, setRenaming, dragHandleProps, style, onClick }) => (
463
+ <div {...dragHandleProps} onClick={onClick} style={style}>
464
+ {flatNode.node.name}
465
+ </div>
466
+ )}
467
+ inlineCreate={{ kind: 'file' }} // pass null to cancel
468
+ onCancelCreate={() => {}}
469
+ folderOverlay={{
470
+ mode: 'full', // 'full' | 'none'
471
+ renderOverlay: ({ isBlocked, isEmpty }) => <div />,
472
+ }}
473
+ />
474
+ ```
475
+
476
+ **`NodeRenderProps`:**
477
+
478
+ ```ts
479
+ interface NodeRenderProps<TMeta> {
480
+ flatNode: FlatNode<TMeta>; // { node, level, isOpen }
481
+ isSelected: boolean;
482
+ isActive: boolean;
483
+ isDragging: boolean;
484
+ isDragSession: boolean;
485
+ isRenaming: boolean;
486
+ setRenaming: (v: boolean) => void;
487
+ dragHandleProps: Record<string, unknown>;
488
+ style: React.CSSProperties;
489
+ onClick: (e: React.MouseEvent) => void;
490
+ }
491
+ ```
492
+
493
+ > When a file node is clicked, `FileTree`'s internal `onClick` handles selection and expand — it does not open tabs. Wire `tabs.open(node.id, workspaceId)` inside your `renderNode` click handler if you want tab behaviour.
494
+
495
+ ### FileRenderer
496
+
497
+ 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.
498
+
499
+ ```tsx
500
+ import { FileRenderer } from 'vfs-kit/components';
501
+
502
+ <FileRenderer
503
+ workspaceId="ws-a"
504
+ activeNodeId={tabs.activeTab?.nodeId ?? null}
505
+ onLoad={(node, bytes) => setContent(new TextDecoder().decode(bytes))}
506
+ onError={(node, error) => console.error(error)}
507
+ fallback={<div>No file open</div>}
508
+ >
509
+ {({ node, content, loading, error }) => (
510
+ <textarea value={new TextDecoder().decode(content ?? new Uint8Array())} />
511
+ )}
512
+ </FileRenderer>
513
+ ```
514
+
515
+ > Key the child component on `node.id` if it owns its own content state — this causes React to fully remount on tab switch, cleanly resetting state without a `useEffect`.
516
+
517
+ ### TabList / TabListBase
518
+
519
+ `TabList` is the higher-level component: it creates its own `useVfsTabs` instance internally.
520
+
521
+ `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.
522
+
523
+ ```tsx
524
+ import { TabListBase } from 'vfs-kit/components';
525
+
526
+ // tabs = useVfsTabs({ workspaceIds: ['ws-a'] })
527
+
528
+ <TabListBase
529
+ tabs={tabs.tabs}
530
+ activeTabId={tabs.activeTabId}
531
+ onReorder={tabs.reorder}
532
+ onClose={tabs.close}
533
+ onSetActive={tabs.setActive}
534
+ onLock={tabs.lock}
535
+ onUnlock={tabs.unlock}
536
+ sortable // enables drag-to-reorder via @dnd-kit
537
+ >
538
+ {({ tabs: tabList, activeTabId, onClose, onSetActive, onLock, onUnlock, dragHandleProps }) => (
539
+ <div style={{ display: 'flex' }}>
540
+ {tabList.map(tab => (
541
+ <MyTab
542
+ key={tab.id}
543
+ tab={tab}
544
+ isActive={tab.id === activeTabId}
545
+ dragHandleProps={dragHandleProps(tab.id)}
546
+ onSetActive={onSetActive}
547
+ onClose={onClose}
548
+ onLock={onLock}
549
+ onUnlock={onUnlock}
550
+ />
551
+ ))}
552
+ </div>
553
+ )}
554
+ </TabListBase>
555
+ ```
556
+
557
+ ### InlineInput
558
+
559
+ A controlled text input designed for inline rename flows. Commits on Enter or blur, cancels on Escape.
560
+
561
+ ```tsx
562
+ import { InlineInput } from 'vfs-kit/components';
563
+
564
+ <InlineInput
565
+ initialValue={node.name}
566
+ onSubmit={(newName) => fs.rename(node.id, newName)}
567
+ onCancel={() => setRenaming(false)}
568
+ className="my-input-class"
569
+ />
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Core Types
575
+
576
+ ```ts
577
+ // vfs-kit/core
578
+
579
+ interface VfsNode<TMeta> {
580
+ id: string;
581
+ name: string;
582
+ kind: 'file' | 'folder';
583
+ parentId: string | null;
584
+ sortIndex: number | null;
585
+ lockedBy: string | null;
586
+ createdAt: number;
587
+ updatedAt: number;
588
+ deletedAt: number | null;
589
+ meta: TMeta;
590
+ }
591
+
592
+ interface VfsFileNode<TMeta> extends VfsNode<TMeta> {
593
+ kind: 'file';
594
+ mimeType: string;
595
+ size: number;
596
+ }
597
+
598
+ interface VfsFolderNode<TMeta> extends VfsNode<TMeta> {
599
+ kind: 'folder';
600
+ }
601
+
602
+ interface VfsFileSnapshot {
603
+ id: string;
604
+ fileId: string;
605
+ index: number;
606
+ content: Uint8Array;
607
+ createdAt: number;
608
+ label?: string;
609
+ }
610
+ ```
611
+
612
+ ---
613
+
614
+ ## Roadmap
615
+
616
+ - **`deleteSnapshot` as a formal `VfsCommand`** — currently `remove()` in `useVfsHistory` is local-state only; adding `{ op: 'deleteSnapshot', fileId, index }` to `VfsEngine` and `fs.deleteSnapshot()` to `VfsFsApi` will make it durable
617
+ - **Autosave snapshots** — `VfsEngineConfig.history.autosave` is wired in the config but not yet connected; a `setInterval` in `useVfsHistory` gated on `autosave.enabled` is the planned hookup point
618
+ - **Snapshot diff view** — compare a snapshot's content against the current file before restoring
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vfs-kit",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "A headless-first Virtual File System for React. Complete with drag-and-drop hierarchy, tab management and pluggable storage adapters.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",