@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.
- package/.github/workflows/publish.yml +1 -1
- package/.vscode/launch.json +19 -0
- package/README.md +24 -0
- package/apps/chat/package.json +4 -4
- package/apps/chat/vite.config.ts +8 -8
- package/docs/specs/directory-sync.md +822 -0
- package/docs/specs/patchwork-vscode.md +625 -0
- package/package.json +2 -2
- package/packages/compiler/package.json +3 -2
- package/packages/compiler/src/index.ts +13 -14
- package/packages/compiler/src/vfs/backends/http.ts +139 -0
- package/packages/compiler/src/vfs/backends/indexeddb.ts +185 -24
- package/packages/compiler/src/vfs/backends/memory.ts +166 -0
- package/packages/compiler/src/vfs/core/index.ts +26 -0
- package/packages/compiler/src/vfs/core/types.ts +93 -0
- package/packages/compiler/src/vfs/core/utils.ts +42 -0
- package/packages/compiler/src/vfs/core/virtual-fs.ts +120 -0
- package/packages/compiler/src/vfs/index.ts +37 -5
- package/packages/compiler/src/vfs/project.ts +16 -16
- package/packages/compiler/src/vfs/store.ts +183 -19
- package/packages/compiler/src/vfs/sync/differ.ts +47 -0
- package/packages/compiler/src/vfs/sync/engine.ts +398 -0
- package/packages/compiler/src/vfs/sync/index.ts +3 -0
- package/packages/compiler/src/vfs/sync/resolver.ts +46 -0
- package/packages/compiler/src/vfs/types.ts +1 -8
- package/packages/compiler/tsup.config.ts +5 -5
- package/packages/editor/package.json +1 -1
- package/packages/editor/src/components/CodeBlockExtension.tsx +1 -1
- package/packages/editor/src/components/CodePreview.tsx +59 -1
- package/packages/editor/src/components/edit/CodeBlockView.tsx +72 -0
- package/packages/editor/src/components/edit/EditModal.tsx +169 -28
- package/packages/editor/src/components/edit/FileTree.tsx +67 -13
- package/packages/editor/src/components/edit/MediaPreview.tsx +106 -0
- package/packages/editor/src/components/edit/SaveConfirmDialog.tsx +60 -0
- package/packages/editor/src/components/edit/fileTypes.ts +125 -0
- package/packages/editor/src/components/edit/index.ts +4 -0
- package/packages/editor/src/components/edit/types.ts +3 -0
- package/packages/editor/src/components/edit/useEditSession.ts +22 -4
- package/packages/editor/src/index.ts +17 -0
- package/packages/editor/src/lib/diff.ts +2 -1
- package/packages/editor/src/lib/vfs.ts +28 -10
- package/packages/editor/tsup.config.ts +10 -5
- package/packages/stitchery/package.json +5 -3
- package/packages/stitchery/src/server/index.ts +57 -57
- package/packages/stitchery/src/server/vfs-routes.ts +246 -56
- package/packages/stitchery/tsup.config.ts +5 -5
- package/packages/utcp/package.json +3 -2
- package/packages/utcp/tsconfig.json +6 -2
- package/packages/utcp/tsup.config.ts +6 -6
- package/packages/vscode/README.md +31 -0
- package/packages/vscode/media/outline.png +0 -0
- package/packages/vscode/media/outline.svg +70 -0
- package/packages/vscode/media/patchwork.png +0 -0
- package/packages/vscode/media/patchwork.svg +72 -0
- package/packages/vscode/node_modules/.bin/jiti +17 -0
- package/packages/vscode/node_modules/.bin/tsc +17 -0
- package/packages/vscode/node_modules/.bin/tsserver +17 -0
- package/packages/vscode/node_modules/.bin/tsup +17 -0
- package/packages/vscode/node_modules/.bin/tsup-node +17 -0
- package/packages/vscode/node_modules/.bin/tsx +17 -0
- package/packages/vscode/package.json +136 -0
- package/packages/vscode/src/extension.ts +612 -0
- package/packages/vscode/src/providers/PatchworkFileSystemProvider.ts +205 -0
- package/packages/vscode/src/providers/PatchworkTreeProvider.ts +177 -0
- package/packages/vscode/src/providers/PreviewPanelProvider.ts +536 -0
- package/packages/vscode/src/services/EditService.ts +24 -0
- package/packages/vscode/src/services/EmbeddedStitchery.ts +82 -0
- package/packages/vscode/tsconfig.json +13 -0
- package/packages/vscode/tsup.config.ts +11 -0
- package/packages/compiler/src/vfs/backends/local-fs.ts +0 -41
- 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
|
-
|
|
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
|
|
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] ||
|
|
26
|
+
return paths[0] || "main.tsx";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function detectMainFile(language?: string): string {
|
|
30
30
|
switch (language) {
|
|
31
|
-
case
|
|
32
|
-
case
|
|
33
|
-
return
|
|
34
|
-
case
|
|
35
|
-
case
|
|
36
|
-
return
|
|
37
|
-
case
|
|
38
|
-
return
|
|
39
|
-
case
|
|
40
|
-
return
|
|
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
|
|
42
|
+
return "main.tsx";
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function createSingleFileProject(
|
|
47
47
|
content: string,
|
|
48
|
-
entry =
|
|
49
|
-
id =
|
|
48
|
+
entry = "main.tsx",
|
|
49
|
+
id = "inline",
|
|
50
50
|
): VirtualProject {
|
|
51
51
|
return {
|
|
52
52
|
id,
|
|
@@ -1,31 +1,126 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
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
|
|
39
|
-
|
|
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.
|
|
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
|
+
}
|