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.
- package/README.md +570 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,145 +1,618 @@
|
|
|
1
|
-
# vfs-kit
|
|
1
|
+
# vfs-kit
|
|
2
2
|
|
|
3
|
-
A
|
|
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
|
-
##
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
86
|
+
## Adapters
|
|
20
87
|
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
###
|
|
110
|
+
### IndexedDBAdapter
|
|
35
111
|
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
126
|
+
Call `adapter.close()` on unmount if managing lifecycle manually.
|
|
51
127
|
|
|
52
|
-
|
|
128
|
+
Supports history (`supportsHistory = true`). Enforces a 50-snapshot cap per file, evicting the oldest automatically.
|
|
53
129
|
|
|
54
|
-
|
|
130
|
+
### RestAdapter
|
|
55
131
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
**Default endpoint conventions** (all relative to `baseUrl`):
|
|
63
152
|
|
|
64
|
-
|
|
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
|
-
|
|
167
|
+
Any endpoint can be overridden individually via the `endpoints` option.
|
|
67
168
|
|
|
68
|
-
###
|
|
169
|
+
### Writing a Custom Adapter
|
|
69
170
|
|
|
70
|
-
|
|
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
|
-
|
|
173
|
+
```ts
|
|
174
|
+
import { VfsAdapter } from 'vfs-kit/core';
|
|
73
175
|
|
|
74
|
-
|
|
176
|
+
export class MyAdapter extends VfsAdapter {
|
|
177
|
+
readonly supportsHistory = false;
|
|
178
|
+
// implement abstract methods...
|
|
179
|
+
}
|
|
180
|
+
```
|
|
75
181
|
|
|
76
182
|
---
|
|
77
183
|
|
|
78
|
-
##
|
|
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
|
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
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
|
-
##
|
|
221
|
+
## Hooks
|
|
87
222
|
|
|
88
|
-
|
|
223
|
+
### useVfs
|
|
89
224
|
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
###
|
|
245
|
+
### useVfsEngine
|
|
96
246
|
|
|
97
|
-
|
|
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
|
-
|
|
249
|
+
```ts
|
|
250
|
+
const { fs, tree, status } = useVfsEngine('ws-a');
|
|
251
|
+
```
|
|
100
252
|
|
|
101
|
-
|
|
253
|
+
**`fs` — write operations:**
|
|
102
254
|
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
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",
|