@aprovan/patchwork 0.1.0 → 0.1.1

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 (71) hide show
  1. package/.github/workflows/publish.yml +1 -1
  2. package/.vscode/launch.json +19 -0
  3. package/README.md +24 -0
  4. package/apps/chat/package.json +4 -4
  5. package/apps/chat/vite.config.ts +8 -8
  6. package/docs/specs/directory-sync.md +822 -0
  7. package/docs/specs/patchwork-vscode.md +625 -0
  8. package/package.json +2 -2
  9. package/packages/compiler/package.json +3 -2
  10. package/packages/compiler/src/index.ts +13 -14
  11. package/packages/compiler/src/vfs/backends/http.ts +139 -0
  12. package/packages/compiler/src/vfs/backends/indexeddb.ts +185 -24
  13. package/packages/compiler/src/vfs/backends/memory.ts +166 -0
  14. package/packages/compiler/src/vfs/core/index.ts +26 -0
  15. package/packages/compiler/src/vfs/core/types.ts +93 -0
  16. package/packages/compiler/src/vfs/core/utils.ts +42 -0
  17. package/packages/compiler/src/vfs/core/virtual-fs.ts +120 -0
  18. package/packages/compiler/src/vfs/index.ts +37 -5
  19. package/packages/compiler/src/vfs/project.ts +16 -16
  20. package/packages/compiler/src/vfs/store.ts +183 -19
  21. package/packages/compiler/src/vfs/sync/differ.ts +47 -0
  22. package/packages/compiler/src/vfs/sync/engine.ts +398 -0
  23. package/packages/compiler/src/vfs/sync/index.ts +3 -0
  24. package/packages/compiler/src/vfs/sync/resolver.ts +46 -0
  25. package/packages/compiler/src/vfs/types.ts +1 -8
  26. package/packages/compiler/tsup.config.ts +5 -5
  27. package/packages/editor/package.json +1 -1
  28. package/packages/editor/src/components/CodeBlockExtension.tsx +1 -1
  29. package/packages/editor/src/components/CodePreview.tsx +59 -1
  30. package/packages/editor/src/components/edit/CodeBlockView.tsx +72 -0
  31. package/packages/editor/src/components/edit/EditModal.tsx +169 -28
  32. package/packages/editor/src/components/edit/FileTree.tsx +67 -13
  33. package/packages/editor/src/components/edit/MediaPreview.tsx +106 -0
  34. package/packages/editor/src/components/edit/SaveConfirmDialog.tsx +60 -0
  35. package/packages/editor/src/components/edit/fileTypes.ts +125 -0
  36. package/packages/editor/src/components/edit/index.ts +4 -0
  37. package/packages/editor/src/components/edit/types.ts +3 -0
  38. package/packages/editor/src/components/edit/useEditSession.ts +22 -4
  39. package/packages/editor/src/index.ts +17 -0
  40. package/packages/editor/src/lib/diff.ts +2 -1
  41. package/packages/editor/src/lib/vfs.ts +28 -10
  42. package/packages/editor/tsup.config.ts +10 -5
  43. package/packages/stitchery/package.json +5 -3
  44. package/packages/stitchery/src/server/index.ts +57 -57
  45. package/packages/stitchery/src/server/vfs-routes.ts +246 -56
  46. package/packages/stitchery/tsup.config.ts +5 -5
  47. package/packages/utcp/package.json +3 -2
  48. package/packages/utcp/tsconfig.json +6 -2
  49. package/packages/utcp/tsup.config.ts +6 -6
  50. package/packages/vscode/README.md +31 -0
  51. package/packages/vscode/media/outline.png +0 -0
  52. package/packages/vscode/media/outline.svg +70 -0
  53. package/packages/vscode/media/patchwork.png +0 -0
  54. package/packages/vscode/media/patchwork.svg +72 -0
  55. package/packages/vscode/node_modules/.bin/jiti +17 -0
  56. package/packages/vscode/node_modules/.bin/tsc +17 -0
  57. package/packages/vscode/node_modules/.bin/tsserver +17 -0
  58. package/packages/vscode/node_modules/.bin/tsup +17 -0
  59. package/packages/vscode/node_modules/.bin/tsup-node +17 -0
  60. package/packages/vscode/node_modules/.bin/tsx +17 -0
  61. package/packages/vscode/package.json +136 -0
  62. package/packages/vscode/src/extension.ts +612 -0
  63. package/packages/vscode/src/providers/PatchworkFileSystemProvider.ts +205 -0
  64. package/packages/vscode/src/providers/PatchworkTreeProvider.ts +177 -0
  65. package/packages/vscode/src/providers/PreviewPanelProvider.ts +536 -0
  66. package/packages/vscode/src/services/EditService.ts +24 -0
  67. package/packages/vscode/src/services/EmbeddedStitchery.ts +82 -0
  68. package/packages/vscode/tsconfig.json +13 -0
  69. package/packages/vscode/tsup.config.ts +11 -0
  70. package/packages/compiler/src/vfs/backends/local-fs.ts +0 -41
  71. package/packages/compiler/src/vfs/backends/s3.ts +0 -60
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  // Core compiler
27
- export { createCompiler } from './compiler.js';
27
+ export { createCompiler } from "./compiler.js";
28
28
 
29
29
  // Schemas (Zod validation)
30
30
  export {
@@ -45,7 +45,7 @@ export {
45
45
  // Defaults
46
46
  DEFAULT_IMAGE_CONFIG,
47
47
  DEFAULT_CLI_IMAGE_CONFIG,
48
- } from './schemas.js';
48
+ } from "./schemas.js";
49
49
 
50
50
  // Types
51
51
  export type {
@@ -72,7 +72,7 @@ export type {
72
72
  BridgeMessageType,
73
73
  ServiceCallPayload,
74
74
  ServiceResultPayload,
75
- } from './types.js';
75
+ } from "./types.js";
76
76
 
77
77
  // Images
78
78
  export {
@@ -86,18 +86,18 @@ export {
86
86
  fetchPackageJson,
87
87
  setCdnBaseUrl,
88
88
  getCdnBaseUrl,
89
- } from './images/index.js';
89
+ } from "./images/index.js";
90
90
 
91
91
  // Transforms
92
92
  export {
93
93
  cdnTransformPlugin,
94
94
  generateImportMap,
95
95
  vfsPlugin,
96
- } from './transforms/index.js';
96
+ } from "./transforms/index.js";
97
97
  export type {
98
98
  CdnTransformOptions,
99
99
  VFSPluginOptions,
100
- } from './transforms/index.js';
100
+ } from "./transforms/index.js";
101
101
 
102
102
  // VFS
103
103
  export {
@@ -107,16 +107,15 @@ export {
107
107
  resolveEntry,
108
108
  detectMainFile,
109
109
  IndexedDBBackend,
110
- LocalFSBackend,
111
- S3Backend,
112
- } from './vfs/index.js';
110
+ HttpBackend,
111
+ } from "./vfs/index.js";
113
112
  export type {
114
113
  VirtualFile,
115
114
  VirtualProject,
116
- StorageBackend,
117
- LocalFSConfig,
118
- S3Config,
119
- } from './vfs/index.js';
115
+ ChangeRecord,
116
+ HttpBackendConfig,
117
+ VFSStoreOptions,
118
+ } from "./vfs/index.js";
120
119
 
121
120
  // Mount utilities
122
121
  export {
@@ -138,4 +137,4 @@ export {
138
137
  ParentBridge,
139
138
  createIframeServiceProxy,
140
139
  generateIframeBridgeScript,
141
- } from './mount/index.js';
140
+ } from "./mount/index.js";
@@ -0,0 +1,139 @@
1
+ import type {
2
+ DirEntry,
3
+ FileStats,
4
+ FSProvider,
5
+ WatchCallback,
6
+ WatchEventType,
7
+ } from "../core/types.js";
8
+ import { createDirEntry, createFileStats } from "../core/utils.js";
9
+
10
+ export interface HttpBackendConfig {
11
+ baseUrl: string;
12
+ }
13
+
14
+ interface StatResponse {
15
+ size: number;
16
+ mtime: string;
17
+ isFile: boolean;
18
+ isDirectory: boolean;
19
+ }
20
+
21
+ interface WatchEvent {
22
+ type: WatchEventType;
23
+ path: string;
24
+ mtime: string;
25
+ }
26
+
27
+ /**
28
+ * HTTP-based FSProvider for connecting to remote servers (e.g., stitchery)
29
+ */
30
+ export class HttpBackend implements FSProvider {
31
+ constructor(private config: HttpBackendConfig) {}
32
+
33
+ async readFile(path: string): Promise<string> {
34
+ const res = await fetch(this.url(path));
35
+ if (!res.ok) throw new Error(`ENOENT: ${path}`);
36
+ return res.text();
37
+ }
38
+
39
+ async writeFile(path: string, content: string): Promise<void> {
40
+ const res = await fetch(this.url(path), {
41
+ method: "PUT",
42
+ body: content,
43
+ headers: { "Content-Type": "text/plain" },
44
+ });
45
+ if (!res.ok) throw new Error(`Failed to write: ${path}`);
46
+ }
47
+
48
+ async unlink(path: string): Promise<void> {
49
+ const res = await fetch(this.url(path), { method: "DELETE" });
50
+ if (!res.ok) throw new Error(`Failed to delete: ${path}`);
51
+ }
52
+
53
+ async stat(path: string): Promise<FileStats> {
54
+ const res = await fetch(this.url(path, { stat: "true" }));
55
+ if (!res.ok) throw new Error(`ENOENT: ${path}`);
56
+ const data: StatResponse = await res.json();
57
+ return createFileStats(data.size, new Date(data.mtime), data.isDirectory);
58
+ }
59
+
60
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
61
+ const params: Record<string, string> = { mkdir: "true" };
62
+ if (options?.recursive) params.recursive = "true";
63
+ const res = await fetch(this.url(path, params), { method: "POST" });
64
+ if (!res.ok) throw new Error(`Failed to mkdir: ${path}`);
65
+ }
66
+
67
+ async readdir(path: string): Promise<DirEntry[]> {
68
+ const res = await fetch(this.url(path, { readdir: "true" }));
69
+ if (!res.ok) throw new Error(`ENOENT: ${path}`);
70
+ const entries: Array<{ name: string; isDirectory: boolean }> =
71
+ await res.json();
72
+ return entries.map((e) => createDirEntry(e.name, e.isDirectory));
73
+ }
74
+
75
+ async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
76
+ const params: Record<string, string> = {};
77
+ if (options?.recursive) params.recursive = "true";
78
+ const res = await fetch(this.url(path, params), { method: "DELETE" });
79
+ if (!res.ok) throw new Error(`Failed to rmdir: ${path}`);
80
+ }
81
+
82
+ async exists(path: string): Promise<boolean> {
83
+ const res = await fetch(this.url(path), { method: "HEAD" });
84
+ return res.ok;
85
+ }
86
+
87
+ watch(path: string, callback: WatchCallback): () => void {
88
+ const controller = new AbortController();
89
+ this.startWatch(path, callback, controller.signal);
90
+ return () => controller.abort();
91
+ }
92
+
93
+ private async startWatch(
94
+ path: string,
95
+ callback: WatchCallback,
96
+ signal: AbortSignal,
97
+ ): Promise<void> {
98
+ try {
99
+ const res = await fetch(this.url("", { watch: path }), { signal });
100
+ if (!res.ok) return;
101
+ const reader = res.body?.getReader();
102
+ if (!reader) return;
103
+
104
+ const decoder = new TextDecoder();
105
+ let buffer = "";
106
+
107
+ while (!signal.aborted) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+
111
+ buffer += decoder.decode(value, { stream: true });
112
+ const lines = buffer.split("\n");
113
+ buffer = lines.pop() ?? "";
114
+
115
+ for (const line of lines) {
116
+ if (line.startsWith("data: ")) {
117
+ try {
118
+ const event: WatchEvent = JSON.parse(line.slice(6));
119
+ callback(event.type, event.path);
120
+ } catch {
121
+ // Ignore parse errors
122
+ }
123
+ }
124
+ }
125
+ }
126
+ } catch {
127
+ // Connection closed or aborted
128
+ }
129
+ }
130
+
131
+ private url(path: string, params?: Record<string, string>): string {
132
+ const baseUrl = this.config.baseUrl.replace(/\/+$/, "");
133
+ const cleanPath = path.replace(/^\/+/, "");
134
+ const base = cleanPath ? `${baseUrl}/${cleanPath}` : baseUrl;
135
+ if (!params) return base;
136
+ const query = new URLSearchParams(params).toString();
137
+ return `${base}?${query}`;
138
+ }
139
+ }
@@ -1,28 +1,49 @@
1
- import type { StorageBackend } from '../types.js';
1
+ import type { DirEntry, FileStats, FSProvider } from '../core/types.js';
2
+ import {
3
+ basename,
4
+ createDirEntry,
5
+ createFileStats,
6
+ dirname,
7
+ normalizePath,
8
+ } from '../core/utils.js';
2
9
 
3
10
  const DB_NAME = 'patchwork-vfs';
4
- const STORE_NAME = 'files';
11
+ const DB_VERSION = 2;
12
+ const FILES_STORE = 'files';
13
+ const DIRS_STORE = 'dirs';
14
+
15
+ interface FileRecord {
16
+ content: string;
17
+ mtime: number;
18
+ }
5
19
 
6
20
  function openDB(): Promise<IDBDatabase> {
7
21
  return new Promise((resolve, reject) => {
8
- const request = indexedDB.open(DB_NAME, 1);
22
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
9
23
  request.onerror = () => reject(request.error);
10
24
  request.onsuccess = () => resolve(request.result);
11
- request.onupgradeneeded = () => {
12
- request.result.createObjectStore(STORE_NAME);
25
+ request.onupgradeneeded = (event) => {
26
+ const db = request.result;
27
+ if (!db.objectStoreNames.contains(FILES_STORE)) {
28
+ db.createObjectStore(FILES_STORE);
29
+ }
30
+ if (!db.objectStoreNames.contains(DIRS_STORE)) {
31
+ db.createObjectStore(DIRS_STORE);
32
+ }
13
33
  };
14
34
  });
15
35
  }
16
36
 
17
37
  function withStore<T>(
38
+ storeName: string,
18
39
  mode: IDBTransactionMode,
19
40
  fn: (store: IDBObjectStore) => IDBRequest<T>,
20
41
  ): Promise<T> {
21
42
  return openDB().then(
22
43
  (db) =>
23
44
  new Promise((resolve, reject) => {
24
- const tx = db.transaction(STORE_NAME, mode);
25
- const store = tx.objectStore(STORE_NAME);
45
+ const tx = db.transaction(storeName, mode);
46
+ const store = tx.objectStore(storeName);
26
47
  const request = fn(store);
27
48
  request.onerror = () => reject(request.error);
28
49
  request.onsuccess = () => resolve(request.result);
@@ -30,37 +51,177 @@ function withStore<T>(
30
51
  );
31
52
  }
32
53
 
33
- export class IndexedDBBackend implements StorageBackend {
54
+ export class IndexedDBBackend implements FSProvider {
34
55
  constructor(private prefix = 'vfs') {}
35
56
 
36
57
  private key(path: string): string {
37
- return `${this.prefix}:${path}`;
58
+ return `${this.prefix}:${normalizePath(path)}`;
59
+ }
60
+
61
+ async readFile(path: string): Promise<string> {
62
+ const record = await withStore<FileRecord | undefined>(
63
+ FILES_STORE,
64
+ 'readonly',
65
+ (store) => store.get(this.key(path)),
66
+ );
67
+ if (!record) throw new Error(`ENOENT: ${path}`);
68
+ return record.content;
38
69
  }
39
70
 
40
- async get(path: string): Promise<string | null> {
41
- const result = await withStore('readonly', (store) =>
42
- store.get(this.key(path)),
71
+ async writeFile(path: string, content: string): Promise<void> {
72
+ const dir = dirname(normalizePath(path));
73
+ if (dir && !(await this.dirExists(dir))) {
74
+ throw new Error(`ENOENT: ${dir}`);
75
+ }
76
+ const record: FileRecord = { content, mtime: Date.now() };
77
+ await withStore(FILES_STORE, 'readwrite', (store) =>
78
+ store.put(record, this.key(path)),
43
79
  );
44
- return result ?? null;
45
80
  }
46
81
 
47
- async put(path: string, content: string): Promise<void> {
48
- await withStore('readwrite', (store) => store.put(content, this.key(path)));
82
+ async unlink(path: string): Promise<void> {
83
+ await withStore(FILES_STORE, 'readwrite', (store) =>
84
+ store.delete(this.key(path)),
85
+ );
49
86
  }
50
87
 
51
- async delete(path: string): Promise<void> {
52
- await withStore('readwrite', (store) => store.delete(this.key(path)));
88
+ async stat(path: string): Promise<FileStats> {
89
+ const normalized = normalizePath(path);
90
+ const record = await withStore<FileRecord | undefined>(
91
+ FILES_STORE,
92
+ 'readonly',
93
+ (store) => store.get(this.key(normalized)),
94
+ );
95
+ if (record) {
96
+ return createFileStats(
97
+ record.content.length,
98
+ new Date(record.mtime),
99
+ false,
100
+ );
101
+ }
102
+ if (await this.dirExists(normalized)) {
103
+ return createFileStats(0, new Date(), true);
104
+ }
105
+ throw new Error(`ENOENT: ${path}`);
106
+ }
107
+
108
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
109
+ const normalized = normalizePath(path);
110
+ if (await this.dirExists(normalized)) return;
111
+
112
+ const parent = dirname(normalized);
113
+ if (parent && !(await this.dirExists(parent))) {
114
+ if (options?.recursive) {
115
+ await this.mkdir(parent, options);
116
+ } else {
117
+ throw new Error(`ENOENT: ${parent}`);
118
+ }
119
+ }
120
+
121
+ await withStore(DIRS_STORE, 'readwrite', (store) =>
122
+ store.put(Date.now(), this.key(normalized)),
123
+ );
124
+ }
125
+
126
+ async readdir(path: string): Promise<DirEntry[]> {
127
+ const normalized = normalizePath(path);
128
+ if (normalized && !(await this.dirExists(normalized))) {
129
+ throw new Error(`ENOENT: ${path}`);
130
+ }
131
+
132
+ const prefix = normalized ? `${this.key(normalized)}/` : `${this.prefix}:`;
133
+ const entries = new Map<string, boolean>();
134
+
135
+ const fileKeys = await withStore<IDBValidKey[]>(
136
+ FILES_STORE,
137
+ 'readonly',
138
+ (store) => store.getAllKeys(),
139
+ );
140
+ for (const key of fileKeys as string[]) {
141
+ if (key.startsWith(prefix)) {
142
+ const rest = key.slice(prefix.length);
143
+ const name = rest.split('/')[0];
144
+ if (name && !rest.includes('/')) entries.set(name, false);
145
+ }
146
+ }
147
+
148
+ const dirKeys = await withStore<IDBValidKey[]>(
149
+ DIRS_STORE,
150
+ 'readonly',
151
+ (store) => store.getAllKeys(),
152
+ );
153
+ for (const key of dirKeys as string[]) {
154
+ if (key.startsWith(prefix)) {
155
+ const rest = key.slice(prefix.length);
156
+ const name = rest.split('/')[0];
157
+ if (name && !rest.includes('/')) entries.set(name, true);
158
+ }
159
+ }
160
+
161
+ return Array.from(entries).map(([name, isDir]) =>
162
+ createDirEntry(name, isDir),
163
+ );
53
164
  }
54
165
 
55
- async list(prefix?: string): Promise<string[]> {
56
- const keyPrefix = prefix ? this.key(prefix) : this.key('');
57
- const allKeys = await withStore('readonly', (store) => store.getAllKeys());
58
- return (allKeys as string[])
59
- .filter((k) => k.startsWith(keyPrefix))
60
- .map((k) => k.slice(this.prefix.length + 1));
166
+ async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
167
+ const normalized = normalizePath(path);
168
+ if (!(await this.dirExists(normalized))) {
169
+ throw new Error(`ENOENT: ${path}`);
170
+ }
171
+
172
+ const prefix = `${this.key(normalized)}/`;
173
+
174
+ if (options?.recursive) {
175
+ const fileKeys = await withStore<IDBValidKey[]>(
176
+ FILES_STORE,
177
+ 'readonly',
178
+ (store) => store.getAllKeys(),
179
+ );
180
+ for (const key of fileKeys as string[]) {
181
+ if (key.startsWith(prefix)) {
182
+ await withStore(FILES_STORE, 'readwrite', (store) =>
183
+ store.delete(key),
184
+ );
185
+ }
186
+ }
187
+
188
+ const dirKeys = await withStore<IDBValidKey[]>(
189
+ DIRS_STORE,
190
+ 'readonly',
191
+ (store) => store.getAllKeys(),
192
+ );
193
+ for (const key of dirKeys as string[]) {
194
+ if (key.startsWith(prefix)) {
195
+ await withStore(DIRS_STORE, 'readwrite', (store) =>
196
+ store.delete(key),
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ await withStore(DIRS_STORE, 'readwrite', (store) =>
203
+ store.delete(this.key(normalized)),
204
+ );
61
205
  }
62
206
 
63
207
  async exists(path: string): Promise<boolean> {
64
- return (await this.get(path)) !== null;
208
+ const normalized = normalizePath(path);
209
+ const record = await withStore<FileRecord | undefined>(
210
+ FILES_STORE,
211
+ 'readonly',
212
+ (store) => store.get(this.key(normalized)),
213
+ );
214
+ if (record) return true;
215
+ return this.dirExists(normalized);
216
+ }
217
+
218
+ private async dirExists(path: string): Promise<boolean> {
219
+ if (!path) return true; // Root always exists
220
+ const result = await withStore<number | undefined>(
221
+ DIRS_STORE,
222
+ 'readonly',
223
+ (store) => store.get(this.key(path)),
224
+ );
225
+ return result !== undefined;
65
226
  }
66
227
  }
@@ -0,0 +1,166 @@
1
+ import type { DirEntry, FileStats, FSProvider, WatchCallback } from '../core/types.js';
2
+ import {
3
+ basename,
4
+ createDirEntry,
5
+ createFileStats,
6
+ dirname,
7
+ normalizePath,
8
+ } from '../core/utils.js';
9
+
10
+ interface FileEntry {
11
+ content: string;
12
+ mtime: Date;
13
+ }
14
+
15
+ /**
16
+ * In-memory FSProvider implementation.
17
+ * Useful for tests and ephemeral file systems.
18
+ */
19
+ export class MemoryBackend implements FSProvider {
20
+ private files = new Map<string, FileEntry>();
21
+ private dirs = new Set<string>(['']);
22
+ private watchers = new Map<string, Set<WatchCallback>>();
23
+
24
+ async readFile(path: string): Promise<string> {
25
+ const entry = this.files.get(normalizePath(path));
26
+ if (!entry) throw new Error(`ENOENT: ${path}`);
27
+ return entry.content;
28
+ }
29
+
30
+ async writeFile(path: string, content: string): Promise<void> {
31
+ const normalized = normalizePath(path);
32
+ const dir = dirname(normalized);
33
+ if (dir && !this.dirs.has(dir)) {
34
+ throw new Error(`ENOENT: ${dir}`);
35
+ }
36
+ const isNew = !this.files.has(normalized);
37
+ this.files.set(normalized, { content, mtime: new Date() });
38
+ this.emit(isNew ? 'create' : 'update', normalized);
39
+ }
40
+
41
+ async unlink(path: string): Promise<void> {
42
+ const normalized = normalizePath(path);
43
+ if (!this.files.delete(normalized)) {
44
+ throw new Error(`ENOENT: ${path}`);
45
+ }
46
+ this.emit('delete', normalized);
47
+ }
48
+
49
+ async stat(path: string): Promise<FileStats> {
50
+ const normalized = normalizePath(path);
51
+ const entry = this.files.get(normalized);
52
+ if (entry) {
53
+ return createFileStats(entry.content.length, entry.mtime, false);
54
+ }
55
+ if (this.dirs.has(normalized)) {
56
+ return createFileStats(0, new Date(), true);
57
+ }
58
+ throw new Error(`ENOENT: ${path}`);
59
+ }
60
+
61
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
62
+ const normalized = normalizePath(path);
63
+ if (this.dirs.has(normalized)) return;
64
+
65
+ const parent = dirname(normalized);
66
+ if (parent && !this.dirs.has(parent)) {
67
+ if (options?.recursive) {
68
+ await this.mkdir(parent, options);
69
+ } else {
70
+ throw new Error(`ENOENT: ${parent}`);
71
+ }
72
+ }
73
+ this.dirs.add(normalized);
74
+ }
75
+
76
+ async readdir(path: string): Promise<DirEntry[]> {
77
+ const normalized = normalizePath(path);
78
+ if (!this.dirs.has(normalized)) {
79
+ throw new Error(`ENOENT: ${path}`);
80
+ }
81
+
82
+ const prefix = normalized ? `${normalized}/` : '';
83
+ const entries = new Map<string, boolean>();
84
+
85
+ for (const filePath of this.files.keys()) {
86
+ if (filePath.startsWith(prefix)) {
87
+ const rest = filePath.slice(prefix.length);
88
+ const name = rest.split('/')[0];
89
+ if (name) entries.set(name, false);
90
+ }
91
+ }
92
+
93
+ for (const dirPath of this.dirs) {
94
+ if (dirPath.startsWith(prefix) && dirPath !== normalized) {
95
+ const rest = dirPath.slice(prefix.length);
96
+ const name = rest.split('/')[0];
97
+ if (name) entries.set(name, true);
98
+ }
99
+ }
100
+
101
+ return Array.from(entries).map(([name, isDir]) =>
102
+ createDirEntry(name, isDir),
103
+ );
104
+ }
105
+
106
+ async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
107
+ const normalized = normalizePath(path);
108
+ if (!this.dirs.has(normalized)) {
109
+ throw new Error(`ENOENT: ${path}`);
110
+ }
111
+
112
+ const prefix = `${normalized}/`;
113
+ const hasChildren =
114
+ [...this.files.keys()].some((p) => p.startsWith(prefix)) ||
115
+ [...this.dirs].some((d) => d.startsWith(prefix));
116
+
117
+ if (hasChildren && !options?.recursive) {
118
+ throw new Error(`ENOTEMPTY: ${path}`);
119
+ }
120
+
121
+ if (options?.recursive) {
122
+ for (const filePath of this.files.keys()) {
123
+ if (filePath.startsWith(prefix)) {
124
+ this.files.delete(filePath);
125
+ this.emit('delete', filePath);
126
+ }
127
+ }
128
+ for (const dirPath of this.dirs) {
129
+ if (dirPath.startsWith(prefix)) {
130
+ this.dirs.delete(dirPath);
131
+ }
132
+ }
133
+ }
134
+
135
+ this.dirs.delete(normalized);
136
+ }
137
+
138
+ async exists(path: string): Promise<boolean> {
139
+ const normalized = normalizePath(path);
140
+ return this.files.has(normalized) || this.dirs.has(normalized);
141
+ }
142
+
143
+ watch(path: string, callback: WatchCallback): () => void {
144
+ const normalized = normalizePath(path);
145
+ let callbacks = this.watchers.get(normalized);
146
+ if (!callbacks) {
147
+ callbacks = new Set();
148
+ this.watchers.set(normalized, callbacks);
149
+ }
150
+ callbacks.add(callback);
151
+ return () => callbacks!.delete(callback);
152
+ }
153
+
154
+ private emit(event: 'create' | 'update' | 'delete', path: string): void {
155
+ // Notify watchers for this path and all parent paths
156
+ let current = path;
157
+ while (true) {
158
+ const callbacks = this.watchers.get(current);
159
+ if (callbacks) {
160
+ for (const cb of callbacks) cb(event, path);
161
+ }
162
+ if (!current) break;
163
+ current = dirname(current);
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,26 @@
1
+ export type {
2
+ ChangeRecord,
3
+ ConflictRecord,
4
+ ConflictStrategy,
5
+ DirEntry,
6
+ FileStats,
7
+ FSProvider,
8
+ SyncEngine,
9
+ SyncEventCallback,
10
+ SyncEventType,
11
+ SyncResult,
12
+ SyncStatus,
13
+ WatchCallback,
14
+ WatchEventType,
15
+ } from './types.js';
16
+
17
+ export {
18
+ basename,
19
+ createDirEntry,
20
+ createFileStats,
21
+ dirname,
22
+ join,
23
+ normalizePath,
24
+ } from './utils.js';
25
+
26
+ export { VirtualFS } from './virtual-fs.js';