@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
@@ -0,0 +1,93 @@
1
+ /**
2
+ * File statistics matching Node.js fs.Stats subset
3
+ */
4
+ export interface FileStats {
5
+ size: number;
6
+ mtime: Date;
7
+ isFile(): boolean;
8
+ isDirectory(): boolean;
9
+ }
10
+
11
+ /**
12
+ * Directory entry matching Node.js fs.Dirent
13
+ */
14
+ export interface DirEntry {
15
+ name: string;
16
+ isFile(): boolean;
17
+ isDirectory(): boolean;
18
+ }
19
+
20
+ export type WatchEventType = 'create' | 'update' | 'delete';
21
+ export type WatchCallback = (event: WatchEventType, path: string) => void;
22
+
23
+ /**
24
+ * FSProvider - Node.js fs/promises compatible interface
25
+ * All paths are relative to provider root
26
+ */
27
+ export interface FSProvider {
28
+ readFile(path: string, encoding?: 'utf8' | 'base64'): Promise<string>;
29
+ writeFile(path: string, content: string): Promise<void>;
30
+ unlink(path: string): Promise<void>;
31
+ stat(path: string): Promise<FileStats>;
32
+ mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
33
+ readdir(path: string): Promise<DirEntry[]>;
34
+ rmdir(path: string, options?: { recursive?: boolean }): Promise<void>;
35
+ exists(path: string): Promise<boolean>;
36
+ watch?(path: string, callback: WatchCallback): () => void;
37
+ }
38
+
39
+ /**
40
+ * Change record for sync operations
41
+ */
42
+ export interface ChangeRecord {
43
+ path: string;
44
+ type: WatchEventType;
45
+ mtime: Date;
46
+ checksum?: string;
47
+ }
48
+
49
+ export type ConflictStrategy =
50
+ | 'local-wins'
51
+ | 'remote-wins'
52
+ | 'newest-wins'
53
+ | 'manual';
54
+
55
+ export interface SyncResult {
56
+ pushed: number;
57
+ pulled: number;
58
+ conflicts: ConflictRecord[];
59
+ }
60
+
61
+ export interface ConflictRecord {
62
+ path: string;
63
+ localMtime: Date;
64
+ remoteMtime: Date;
65
+ resolved?: 'local' | 'remote';
66
+ }
67
+
68
+ export type SyncStatus = 'idle' | 'syncing' | 'error';
69
+
70
+ export type SyncEventType = 'change' | 'conflict' | 'error' | 'status';
71
+ export type SyncEventCallback<T = unknown> = (data: T) => void;
72
+
73
+ /**
74
+ * SyncEngine - orchestrates bidirectional sync
75
+ */
76
+ export interface SyncEngine {
77
+ status: SyncStatus;
78
+ sync(): Promise<SyncResult>;
79
+ startAutoSync(intervalMs: number): void;
80
+ stopAutoSync(): void;
81
+ on<T extends SyncEventType>(
82
+ event: T,
83
+ callback: SyncEventCallback<
84
+ T extends 'change'
85
+ ? ChangeRecord
86
+ : T extends 'conflict'
87
+ ? ConflictRecord
88
+ : T extends 'error'
89
+ ? Error
90
+ : SyncStatus
91
+ >,
92
+ ): () => void;
93
+ }
@@ -0,0 +1,42 @@
1
+ import type { DirEntry, FileStats } from './types.js';
2
+
3
+ export function createFileStats(
4
+ size: number,
5
+ mtime: Date,
6
+ isDir = false,
7
+ ): FileStats {
8
+ return {
9
+ size,
10
+ mtime,
11
+ isFile: () => !isDir,
12
+ isDirectory: () => isDir,
13
+ };
14
+ }
15
+
16
+ export function createDirEntry(name: string, isDir: boolean): DirEntry {
17
+ return {
18
+ name,
19
+ isFile: () => !isDir,
20
+ isDirectory: () => isDir,
21
+ };
22
+ }
23
+
24
+ export function normalizePath(path: string): string {
25
+ return path.replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
26
+ }
27
+
28
+ export function dirname(path: string): string {
29
+ const normalized = normalizePath(path);
30
+ const lastSlash = normalized.lastIndexOf('/');
31
+ return lastSlash === -1 ? '' : normalized.slice(0, lastSlash);
32
+ }
33
+
34
+ export function basename(path: string): string {
35
+ const normalized = normalizePath(path);
36
+ const lastSlash = normalized.lastIndexOf('/');
37
+ return lastSlash === -1 ? normalized : normalized.slice(lastSlash + 1);
38
+ }
39
+
40
+ export function join(...parts: string[]): string {
41
+ return normalizePath(parts.filter(Boolean).join('/'));
42
+ }
@@ -0,0 +1,120 @@
1
+ import type {
2
+ ChangeRecord,
3
+ DirEntry,
4
+ FileStats,
5
+ FSProvider,
6
+ WatchCallback,
7
+ WatchEventType,
8
+ } from "./types.js";
9
+ import { MemoryBackend } from "../backends/memory.js";
10
+
11
+ type ChangeListener = (record: ChangeRecord) => void;
12
+
13
+ /**
14
+ * VirtualFS wraps an FSProvider with change tracking.
15
+ * Tracks all local modifications for sync operations.
16
+ */
17
+ export class VirtualFS implements FSProvider {
18
+ private changes = new Map<string, ChangeRecord>();
19
+ private listeners = new Set<ChangeListener>();
20
+ private backend: FSProvider;
21
+
22
+ constructor(backend?: FSProvider) {
23
+ this.backend = backend ?? new MemoryBackend();
24
+ }
25
+
26
+ async readFile(path: string, encoding?: "utf8" | "base64"): Promise<string> {
27
+ return this.backend.readFile(path, encoding);
28
+ }
29
+
30
+ async writeFile(path: string, content: string): Promise<void> {
31
+ const existed = await this.backend.exists(path);
32
+ await this.backend.writeFile(path, content);
33
+ this.recordChange(path, existed ? "update" : "create");
34
+ }
35
+
36
+ async applyRemoteFile(path: string, content: string): Promise<void> {
37
+ await this.backend.writeFile(path, content);
38
+ }
39
+
40
+ async applyRemoteDelete(path: string): Promise<void> {
41
+ try {
42
+ if (await this.backend.exists(path)) {
43
+ await this.backend.unlink(path);
44
+ }
45
+ } catch {
46
+ return;
47
+ }
48
+ }
49
+
50
+ async unlink(path: string): Promise<void> {
51
+ await this.backend.unlink(path);
52
+ this.recordChange(path, "delete");
53
+ }
54
+
55
+ async stat(path: string): Promise<FileStats> {
56
+ return this.backend.stat(path);
57
+ }
58
+
59
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
60
+ return this.backend.mkdir(path, options);
61
+ }
62
+
63
+ async readdir(path: string): Promise<DirEntry[]> {
64
+ return this.backend.readdir(path);
65
+ }
66
+
67
+ async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
68
+ return this.backend.rmdir(path, options);
69
+ }
70
+
71
+ async exists(path: string): Promise<boolean> {
72
+ return this.backend.exists(path);
73
+ }
74
+
75
+ watch(path: string, callback: WatchCallback): () => void {
76
+ if (this.backend.watch) {
77
+ return this.backend.watch(path, callback);
78
+ }
79
+ return () => {};
80
+ }
81
+
82
+ /**
83
+ * Get all pending changes since last sync
84
+ */
85
+ getChanges(): ChangeRecord[] {
86
+ return Array.from(this.changes.values());
87
+ }
88
+
89
+ /**
90
+ * Clear change tracking (after successful sync)
91
+ */
92
+ clearChanges(): void {
93
+ this.changes.clear();
94
+ }
95
+
96
+ /**
97
+ * Mark specific paths as synced
98
+ */
99
+ markSynced(paths: string[]): void {
100
+ for (const path of paths) {
101
+ this.changes.delete(path);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Subscribe to change events
107
+ */
108
+ onChange(listener: ChangeListener): () => void {
109
+ this.listeners.add(listener);
110
+ return () => this.listeners.delete(listener);
111
+ }
112
+
113
+ private recordChange(path: string, type: WatchEventType): void {
114
+ const record: ChangeRecord = { path, type, mtime: new Date() };
115
+ this.changes.set(path, record);
116
+ for (const listener of this.listeners) {
117
+ listener(record);
118
+ }
119
+ }
120
+ }
@@ -1,11 +1,43 @@
1
- export type { VirtualFile, VirtualProject, StorageBackend } from './types.js';
1
+ // Core types and utilities
2
+ export type {
3
+ ChangeRecord,
4
+ ConflictRecord,
5
+ ConflictStrategy,
6
+ DirEntry,
7
+ FileStats,
8
+ FSProvider,
9
+ SyncEngine,
10
+ SyncEventCallback,
11
+ SyncEventType,
12
+ SyncResult,
13
+ SyncStatus,
14
+ WatchCallback,
15
+ WatchEventType,
16
+ } from './core/index.js';
17
+
18
+ export {
19
+ basename,
20
+ createDirEntry,
21
+ createFileStats,
22
+ dirname,
23
+ join,
24
+ normalizePath,
25
+ VirtualFS,
26
+ } from './core/index.js';
27
+
28
+ // Sync engine
29
+ export { SyncEngineImpl, type SyncEngineConfig } from './sync/index.js';
30
+
31
+ // Backends
32
+ export { MemoryBackend } from './backends/memory.js';
33
+ export { IndexedDBBackend } from './backends/indexeddb.js';
34
+ export { HttpBackend, type HttpBackendConfig } from './backends/http.js';
35
+
36
+ export type { VirtualFile, VirtualProject } from './types.js';
2
37
  export {
3
38
  createProjectFromFiles,
4
39
  createSingleFileProject,
5
40
  resolveEntry,
6
41
  detectMainFile,
7
42
  } from './project.js';
8
- export { VFSStore } from './store.js';
9
- export { IndexedDBBackend } from './backends/indexeddb.js';
10
- export { LocalFSBackend, type LocalFSConfig } from './backends/local-fs.js';
11
- export { S3Backend, type S3Config } from './backends/s3.js';
43
+ export { VFSStore, type VFSStoreOptions } from './store.js';
@@ -1,8 +1,8 @@
1
- import type { VirtualFile, VirtualProject } from './types.js';
1
+ import type { VirtualFile, VirtualProject } from "./types.js";
2
2
 
3
3
  export function createProjectFromFiles(
4
4
  files: VirtualFile[],
5
- id = crypto.randomUUID(),
5
+ id: string = crypto.randomUUID(),
6
6
  ): VirtualProject {
7
7
  const fileMap = new Map<string, VirtualFile>();
8
8
  for (const file of files) {
@@ -23,30 +23,30 @@ export function resolveEntry(files: Map<string, VirtualFile>): string {
23
23
  const firstTsx = paths.find((p) => /\.(tsx|jsx)$/.test(p));
24
24
  if (firstTsx) return firstTsx;
25
25
 
26
- return paths[0] || 'main.tsx';
26
+ return paths[0] || "main.tsx";
27
27
  }
28
28
 
29
29
  export function detectMainFile(language?: string): string {
30
30
  switch (language) {
31
- case 'tsx':
32
- case 'typescript':
33
- return 'main.tsx';
34
- case 'jsx':
35
- case 'javascript':
36
- return 'main.jsx';
37
- case 'ts':
38
- return 'main.ts';
39
- case 'js':
40
- return 'main.js';
31
+ case "tsx":
32
+ case "typescript":
33
+ return "main.tsx";
34
+ case "jsx":
35
+ case "javascript":
36
+ return "main.jsx";
37
+ case "ts":
38
+ return "main.ts";
39
+ case "js":
40
+ return "main.js";
41
41
  default:
42
- return 'main.tsx';
42
+ return "main.tsx";
43
43
  }
44
44
  }
45
45
 
46
46
  export function createSingleFileProject(
47
47
  content: string,
48
- entry = 'main.tsx',
49
- id = 'inline',
48
+ entry = "main.tsx",
49
+ id = "inline",
50
50
  ): VirtualProject {
51
51
  return {
52
52
  id,
@@ -1,31 +1,126 @@
1
- import type { StorageBackend, VirtualFile, VirtualProject } from './types.js';
1
+ import type {
2
+ ChangeRecord,
3
+ ConflictRecord,
4
+ ConflictStrategy,
5
+ DirEntry,
6
+ FileStats,
7
+ FSProvider,
8
+ SyncEventCallback,
9
+ SyncEventType,
10
+ SyncResult,
11
+ SyncStatus,
12
+ } from './core/types.js';
13
+ import { join } from './core/utils.js';
14
+ import { VirtualFS } from './core/virtual-fs.js';
15
+ import { SyncEngineImpl } from './sync/index.js';
16
+ import type { VirtualFile, VirtualProject } from './types.js';
2
17
  import { resolveEntry } from './project.js';
3
18
 
19
+ export interface VFSStoreOptions {
20
+ root?: string;
21
+ sync?: boolean;
22
+ conflictStrategy?: ConflictStrategy;
23
+ autoSyncIntervalMs?: number;
24
+ }
25
+
4
26
  export class VFSStore {
5
- constructor(private backend: StorageBackend, private root = '') {}
27
+ private local?: VirtualFS;
28
+ private syncEngine?: SyncEngineImpl;
29
+ private root: string;
30
+
31
+ constructor(private provider: FSProvider, options: VFSStoreOptions = {}) {
32
+ this.root = options.root ?? '';
33
+
34
+ if (options.sync) {
35
+ this.local = new VirtualFS();
36
+ this.syncEngine = new SyncEngineImpl(this.local, this.provider, {
37
+ conflictStrategy: options.conflictStrategy,
38
+ basePath: this.root,
39
+ });
40
+ if (options.autoSyncIntervalMs) {
41
+ this.syncEngine.startAutoSync(options.autoSyncIntervalMs);
42
+ }
43
+ }
44
+ }
45
+
46
+ async readFile(path: string, encoding?: 'utf8' | 'base64'): Promise<string> {
47
+ if (this.local) {
48
+ try {
49
+ return await this.local.readFile(path, encoding);
50
+ } catch {
51
+ const content = await this.provider.readFile(
52
+ this.remotePath(path),
53
+ encoding,
54
+ );
55
+ await this.local.applyRemoteFile(path, content);
56
+ return content;
57
+ }
58
+ }
59
+ return this.provider.readFile(this.remotePath(path), encoding);
60
+ }
61
+
62
+ async writeFile(path: string, content: string): Promise<void> {
63
+ if (this.local) {
64
+ await this.local.writeFile(path, content);
65
+ return;
66
+ }
67
+ await this.provider.writeFile(this.remotePath(path), content);
68
+ }
69
+
70
+ async unlink(path: string): Promise<void> {
71
+ if (this.local) {
72
+ await this.local.unlink(path);
73
+ return;
74
+ }
75
+ await this.provider.unlink(this.remotePath(path));
76
+ }
77
+
78
+ async stat(path: string): Promise<FileStats> {
79
+ if (this.local) {
80
+ try {
81
+ return await this.local.stat(path);
82
+ } catch {
83
+ return this.provider.stat(this.remotePath(path));
84
+ }
85
+ }
86
+ return this.provider.stat(this.remotePath(path));
87
+ }
6
88
 
7
- private key(path: string): string {
8
- return this.root ? `${this.root}/${path}` : path;
89
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
90
+ if (this.local) {
91
+ await this.local.mkdir(path, options);
92
+ }
93
+ await this.provider.mkdir(this.remotePath(path), options);
9
94
  }
10
95
 
11
- async getFile(path: string): Promise<VirtualFile | null> {
12
- const content = await this.backend.get(this.key(path));
13
- if (!content) return null;
14
- return { path, content };
96
+ async readdir(path: string): Promise<DirEntry[]> {
97
+ if (this.local) {
98
+ try {
99
+ return await this.local.readdir(path);
100
+ } catch {
101
+ return this.provider.readdir(this.remotePath(path));
102
+ }
103
+ }
104
+ return this.provider.readdir(this.remotePath(path));
15
105
  }
16
106
 
17
- async putFile(file: VirtualFile): Promise<void> {
18
- await this.backend.put(this.key(file.path), file.content);
107
+ async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
108
+ if (this.local) {
109
+ await this.local.rmdir(path, options);
110
+ }
111
+ await this.provider.rmdir(this.remotePath(path), options);
19
112
  }
20
113
 
21
- async deleteFile(path: string): Promise<void> {
22
- await this.backend.delete(this.key(path));
114
+ async exists(path: string): Promise<boolean> {
115
+ if (this.local) {
116
+ if (await this.local.exists(path)) return true;
117
+ return this.provider.exists(this.remotePath(path));
118
+ }
119
+ return this.provider.exists(this.remotePath(path));
23
120
  }
24
121
 
25
- async listFiles(prefix?: string): Promise<string[]> {
26
- const fullPrefix = prefix ? this.key(prefix) : this.root;
27
- const paths = await this.backend.list(fullPrefix);
28
- return paths.map((p) => (this.root ? p.slice(this.root.length + 1) : p));
122
+ async listFiles(prefix = ''): Promise<string[]> {
123
+ return this.walkFiles(prefix);
29
124
  }
30
125
 
31
126
  async loadProject(id: string): Promise<VirtualProject | null> {
@@ -35,8 +130,12 @@ export class VFSStore {
35
130
  const files = new Map<string, VirtualFile>();
36
131
  await Promise.all(
37
132
  paths.map(async (path) => {
38
- const file = await this.getFile(path);
39
- if (file) files.set(path.slice(id.length + 1), file);
133
+ const content = await this.provider.readFile(this.remotePath(path));
134
+ const relative = path.slice(id.length + 1);
135
+ files.set(relative, { path: relative, content });
136
+ if (this.local) {
137
+ await this.local.applyRemoteFile(path, content);
138
+ }
40
139
  }),
41
140
  );
42
141
 
@@ -44,10 +143,75 @@ export class VFSStore {
44
143
  }
45
144
 
46
145
  async saveProject(project: VirtualProject): Promise<void> {
146
+ if (this.local) {
147
+ await Promise.all(
148
+ Array.from(project.files.values()).map((file) =>
149
+ this.local!.writeFile(`${project.id}/${file.path}`, file.content),
150
+ ),
151
+ );
152
+ await this.sync();
153
+ return;
154
+ }
155
+
47
156
  await Promise.all(
48
157
  Array.from(project.files.values()).map((file) =>
49
- this.putFile({ ...file, path: `${project.id}/${file.path}` }),
158
+ this.provider.writeFile(
159
+ this.remotePath(`${project.id}/${file.path}`),
160
+ file.content,
161
+ ),
50
162
  ),
51
163
  );
52
164
  }
165
+
166
+ async sync(): Promise<SyncResult> {
167
+ if (!this.syncEngine) {
168
+ return { pushed: 0, pulled: 0, conflicts: [] };
169
+ }
170
+ return this.syncEngine.sync();
171
+ }
172
+
173
+ on<T extends SyncEventType>(
174
+ event: T,
175
+ callback: SyncEventCallback<
176
+ T extends 'change'
177
+ ? ChangeRecord
178
+ : T extends 'conflict'
179
+ ? ConflictRecord
180
+ : T extends 'error'
181
+ ? Error
182
+ : SyncStatus
183
+ >,
184
+ ): () => void {
185
+ if (!this.syncEngine) return () => {};
186
+ return this.syncEngine.on(event, callback as SyncEventCallback<unknown>);
187
+ }
188
+
189
+ private remotePath(path: string): string {
190
+ return this.root ? join(this.root, path) : path;
191
+ }
192
+
193
+ private async walkFiles(prefix: string): Promise<string[]> {
194
+ const results: string[] = [];
195
+ const normalized = prefix ? prefix.replace(/^\/+/g, '') : '';
196
+
197
+ let entries: DirEntry[] = [];
198
+ try {
199
+ entries = await this.provider.readdir(this.remotePath(normalized));
200
+ } catch {
201
+ return results;
202
+ }
203
+
204
+ for (const entry of entries) {
205
+ const entryPath = normalized
206
+ ? `${normalized}/${entry.name}`
207
+ : entry.name;
208
+ if (entry.isDirectory()) {
209
+ results.push(...(await this.walkFiles(entryPath)));
210
+ } else {
211
+ results.push(entryPath);
212
+ }
213
+ }
214
+
215
+ return results;
216
+ }
53
217
  }
@@ -0,0 +1,47 @@
1
+ import type { FSProvider } from "../core/types.js";
2
+
3
+ export interface ContentChecksums {
4
+ local?: string;
5
+ remote?: string;
6
+ }
7
+
8
+ export function hashContent(content: string): string {
9
+ let hash = 0x811c9dc5;
10
+ for (let i = 0; i < content.length; i += 1) {
11
+ hash ^= content.charCodeAt(i);
12
+ hash =
13
+ (hash +
14
+ (hash << 1) +
15
+ (hash << 4) +
16
+ (hash << 7) +
17
+ (hash << 8) +
18
+ (hash << 24)) >>>
19
+ 0;
20
+ }
21
+ return hash.toString(16).padStart(8, "0");
22
+ }
23
+
24
+ export async function readChecksum(
25
+ provider: FSProvider,
26
+ path: string,
27
+ ): Promise<string | undefined> {
28
+ try {
29
+ const content = await provider.readFile(path);
30
+ return hashContent(content);
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ export async function readChecksums(
37
+ local: FSProvider,
38
+ localPath: string,
39
+ remote: FSProvider,
40
+ remotePath: string,
41
+ ): Promise<ContentChecksums> {
42
+ const [localChecksum, remoteChecksum] = await Promise.all([
43
+ readChecksum(local, localPath),
44
+ readChecksum(remote, remotePath),
45
+ ]);
46
+ return { local: localChecksum, remote: remoteChecksum };
47
+ }