@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,398 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeRecord,
|
|
3
|
+
ConflictRecord,
|
|
4
|
+
ConflictStrategy,
|
|
5
|
+
DirEntry,
|
|
6
|
+
FSProvider,
|
|
7
|
+
SyncEventCallback,
|
|
8
|
+
SyncEventType,
|
|
9
|
+
SyncResult,
|
|
10
|
+
SyncStatus,
|
|
11
|
+
WatchEventType,
|
|
12
|
+
} from "../core/types.js";
|
|
13
|
+
import type { VirtualFS } from "../core/virtual-fs.js";
|
|
14
|
+
import { join, normalizePath } from "../core/utils.js";
|
|
15
|
+
import { readChecksums } from "./differ.js";
|
|
16
|
+
import { resolveConflict } from "./resolver.js";
|
|
17
|
+
|
|
18
|
+
export interface SyncEngineConfig {
|
|
19
|
+
conflictStrategy?: ConflictStrategy;
|
|
20
|
+
basePath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type EventMap = {
|
|
24
|
+
change: ChangeRecord;
|
|
25
|
+
conflict: ConflictRecord;
|
|
26
|
+
error: Error;
|
|
27
|
+
status: SyncStatus;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bidirectional sync engine between local VirtualFS and remote FSProvider
|
|
32
|
+
*/
|
|
33
|
+
export class SyncEngineImpl {
|
|
34
|
+
status: SyncStatus = "idle";
|
|
35
|
+
private intervalId?: ReturnType<typeof setInterval>;
|
|
36
|
+
private listeners = new Map<SyncEventType, Set<SyncEventCallback<unknown>>>();
|
|
37
|
+
private conflictStrategy: ConflictStrategy;
|
|
38
|
+
private basePath: string;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private local: VirtualFS,
|
|
42
|
+
private remote: FSProvider,
|
|
43
|
+
config: SyncEngineConfig = {},
|
|
44
|
+
) {
|
|
45
|
+
this.conflictStrategy = config.conflictStrategy ?? "local-wins";
|
|
46
|
+
this.basePath = config.basePath ?? "";
|
|
47
|
+
this.startRemoteWatch();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async sync(): Promise<SyncResult> {
|
|
51
|
+
if (this.status === "syncing") {
|
|
52
|
+
return { pushed: 0, pulled: 0, conflicts: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.setStatus("syncing");
|
|
56
|
+
const result: SyncResult = { pushed: 0, pulled: 0, conflicts: [] };
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const localChanges = this.local.getChanges();
|
|
60
|
+
const localChangeMap = new Map(
|
|
61
|
+
localChanges.map((change) => [change.path, change]),
|
|
62
|
+
);
|
|
63
|
+
const syncedPaths: string[] = [];
|
|
64
|
+
|
|
65
|
+
const remoteFiles = await this.listFiles(this.remote, this.basePath);
|
|
66
|
+
const localFiles = await this.listFiles(this.local, "");
|
|
67
|
+
const remoteLocalPaths = new Set(
|
|
68
|
+
remoteFiles.map((path) => this.localPath(path)),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
for (const remotePath of remoteFiles) {
|
|
72
|
+
const localPath = this.localPath(remotePath);
|
|
73
|
+
const localChange = localChangeMap.get(localPath);
|
|
74
|
+
|
|
75
|
+
if (localChange) {
|
|
76
|
+
const conflict = await this.checkConflict(localChange, remotePath);
|
|
77
|
+
if (conflict) {
|
|
78
|
+
result.conflicts.push(conflict);
|
|
79
|
+
this.emit("conflict", conflict);
|
|
80
|
+
if (conflict.resolved === "remote") {
|
|
81
|
+
if (await this.pullRemoteFile(localPath, remotePath)) {
|
|
82
|
+
result.pulled++;
|
|
83
|
+
this.emit("change", {
|
|
84
|
+
path: localPath,
|
|
85
|
+
type: "update",
|
|
86
|
+
mtime: new Date(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
syncedPaths.push(localPath);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (await this.pullRemoteFile(localPath, remotePath)) {
|
|
96
|
+
result.pulled++;
|
|
97
|
+
this.emit("change", {
|
|
98
|
+
path: localPath,
|
|
99
|
+
type: "update",
|
|
100
|
+
mtime: new Date(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const localPath of localFiles) {
|
|
106
|
+
if (remoteLocalPaths.has(localPath)) continue;
|
|
107
|
+
if (localChangeMap.has(localPath)) continue;
|
|
108
|
+
await this.local.applyRemoteDelete(localPath);
|
|
109
|
+
result.pulled++;
|
|
110
|
+
this.emit("change", {
|
|
111
|
+
path: localPath,
|
|
112
|
+
type: "delete",
|
|
113
|
+
mtime: new Date(),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const change of localChanges) {
|
|
118
|
+
if (syncedPaths.includes(change.path)) continue;
|
|
119
|
+
const remotePath = this.remotePath(change.path);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const conflict = await this.checkConflict(change, remotePath);
|
|
123
|
+
if (conflict) {
|
|
124
|
+
result.conflicts.push(conflict);
|
|
125
|
+
this.emit("conflict", conflict);
|
|
126
|
+
if (conflict.resolved === "remote") {
|
|
127
|
+
if (await this.pullRemoteFile(change.path, remotePath)) {
|
|
128
|
+
result.pulled++;
|
|
129
|
+
this.emit("change", {
|
|
130
|
+
path: change.path,
|
|
131
|
+
type: "update",
|
|
132
|
+
mtime: new Date(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
syncedPaths.push(change.path);
|
|
136
|
+
}
|
|
137
|
+
if (conflict.resolved !== "local") continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (change.type === "delete") {
|
|
141
|
+
if (await this.remote.exists(remotePath)) {
|
|
142
|
+
await this.remote.unlink(remotePath);
|
|
143
|
+
}
|
|
144
|
+
result.pushed++;
|
|
145
|
+
syncedPaths.push(change.path);
|
|
146
|
+
this.emit("change", change);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const content = await this.local.readFile(change.path);
|
|
151
|
+
await this.remote.writeFile(remotePath, content);
|
|
152
|
+
result.pushed++;
|
|
153
|
+
syncedPaths.push(change.path);
|
|
154
|
+
this.emit("change", change);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this.emit(
|
|
157
|
+
"error",
|
|
158
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (syncedPaths.length > 0) {
|
|
164
|
+
this.local.markSynced(syncedPaths);
|
|
165
|
+
}
|
|
166
|
+
this.setStatus("idle");
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.setStatus("error");
|
|
169
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
startAutoSync(intervalMs: number): void {
|
|
176
|
+
this.stopAutoSync();
|
|
177
|
+
this.intervalId = setInterval(() => this.sync(), intervalMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
stopAutoSync(): void {
|
|
181
|
+
if (this.intervalId) {
|
|
182
|
+
clearInterval(this.intervalId);
|
|
183
|
+
this.intervalId = undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
on<T extends SyncEventType>(
|
|
188
|
+
event: T,
|
|
189
|
+
callback: SyncEventCallback<EventMap[T]>,
|
|
190
|
+
): () => void {
|
|
191
|
+
let set = this.listeners.get(event);
|
|
192
|
+
if (!set) {
|
|
193
|
+
set = new Set();
|
|
194
|
+
this.listeners.set(event, set);
|
|
195
|
+
}
|
|
196
|
+
set.add(callback as SyncEventCallback<unknown>);
|
|
197
|
+
return () => set!.delete(callback as SyncEventCallback<unknown>);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private emit<T extends SyncEventType>(event: T, data: EventMap[T]): void {
|
|
201
|
+
const set = this.listeners.get(event);
|
|
202
|
+
if (set) {
|
|
203
|
+
for (const cb of set) cb(data);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private setStatus(status: SyncStatus): void {
|
|
208
|
+
this.status = status;
|
|
209
|
+
this.emit("status", status);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private remotePath(localPath: string): string {
|
|
213
|
+
return this.basePath ? join(this.basePath, localPath) : localPath;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private localPath(remotePath: string): string {
|
|
217
|
+
if (!this.basePath) return normalizePath(remotePath);
|
|
218
|
+
const normalized = normalizePath(remotePath);
|
|
219
|
+
const base = normalizePath(this.basePath);
|
|
220
|
+
if (normalized === base) return "";
|
|
221
|
+
if (normalized.startsWith(`${base}/`)) {
|
|
222
|
+
return normalized.slice(base.length + 1);
|
|
223
|
+
}
|
|
224
|
+
return normalized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async listFiles(
|
|
228
|
+
provider: FSProvider,
|
|
229
|
+
basePath: string,
|
|
230
|
+
): Promise<string[]> {
|
|
231
|
+
const normalized = normalizePath(basePath);
|
|
232
|
+
let entries: DirEntry[] = [];
|
|
233
|
+
try {
|
|
234
|
+
entries = await provider.readdir(normalized);
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const results: string[] = [];
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
const entryPath = normalized ? `${normalized}/${entry.name}` : entry.name;
|
|
242
|
+
if (entry.isDirectory()) {
|
|
243
|
+
results.push(...(await this.listFiles(provider, entryPath)));
|
|
244
|
+
} else {
|
|
245
|
+
results.push(entryPath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return results;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async pullRemoteFile(
|
|
253
|
+
localPath: string,
|
|
254
|
+
remotePath: string,
|
|
255
|
+
): Promise<boolean> {
|
|
256
|
+
let localContent: string | null = null;
|
|
257
|
+
try {
|
|
258
|
+
if (await this.local.exists(localPath)) {
|
|
259
|
+
localContent = await this.local.readFile(localPath);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
localContent = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const remoteContent = await this.remote.readFile(remotePath);
|
|
266
|
+
if (localContent === remoteContent) return false;
|
|
267
|
+
await this.local.applyRemoteFile(localPath, remoteContent);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private startRemoteWatch(): void {
|
|
272
|
+
if (!this.remote.watch) return;
|
|
273
|
+
this.remote.watch(this.basePath, (event, path) => {
|
|
274
|
+
void this.handleRemoteEvent(event, path);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async handleRemoteEvent(
|
|
279
|
+
event: WatchEventType,
|
|
280
|
+
remotePath: string,
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
const localPath = this.localPath(remotePath);
|
|
283
|
+
const localChange = this.local
|
|
284
|
+
.getChanges()
|
|
285
|
+
.find((change) => change.path === localPath);
|
|
286
|
+
|
|
287
|
+
if (localChange) {
|
|
288
|
+
const conflict = await this.checkRemoteEventConflict(
|
|
289
|
+
localChange,
|
|
290
|
+
remotePath,
|
|
291
|
+
event,
|
|
292
|
+
);
|
|
293
|
+
if (conflict) {
|
|
294
|
+
this.emit("conflict", conflict);
|
|
295
|
+
if (conflict.resolved === "remote") {
|
|
296
|
+
await this.applyRemoteEvent(event, localPath, remotePath);
|
|
297
|
+
this.local.markSynced([localPath]);
|
|
298
|
+
this.emit("change", {
|
|
299
|
+
path: localPath,
|
|
300
|
+
type: event,
|
|
301
|
+
mtime: new Date(),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
await this.applyRemoteEvent(event, localPath, remotePath);
|
|
312
|
+
this.emit("change", {
|
|
313
|
+
path: localPath,
|
|
314
|
+
type: event,
|
|
315
|
+
mtime: new Date(),
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async checkConflict(
|
|
323
|
+
change: ChangeRecord,
|
|
324
|
+
remotePath: string,
|
|
325
|
+
): Promise<ConflictRecord | null> {
|
|
326
|
+
try {
|
|
327
|
+
const remoteStat = await this.remote.stat(remotePath);
|
|
328
|
+
if (remoteStat.mtime <= change.mtime) return null;
|
|
329
|
+
const checksums = await readChecksums(
|
|
330
|
+
this.local,
|
|
331
|
+
change.path,
|
|
332
|
+
this.remote,
|
|
333
|
+
remotePath,
|
|
334
|
+
);
|
|
335
|
+
return resolveConflict({
|
|
336
|
+
path: change.path,
|
|
337
|
+
changeMtime: change.mtime,
|
|
338
|
+
remoteMtime: remoteStat.mtime,
|
|
339
|
+
localChecksum: checksums.local,
|
|
340
|
+
remoteChecksum: checksums.remote,
|
|
341
|
+
strategy: this.conflictStrategy,
|
|
342
|
+
});
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async checkRemoteEventConflict(
|
|
349
|
+
change: ChangeRecord,
|
|
350
|
+
remotePath: string,
|
|
351
|
+
event: WatchEventType,
|
|
352
|
+
): Promise<ConflictRecord | null> {
|
|
353
|
+
if (event === "delete") {
|
|
354
|
+
if (change.type === "delete") return null;
|
|
355
|
+
return resolveConflict({
|
|
356
|
+
path: change.path,
|
|
357
|
+
changeMtime: change.mtime,
|
|
358
|
+
remoteMtime: new Date(),
|
|
359
|
+
strategy: this.conflictStrategy,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const remoteStat = await this.remote.stat(remotePath);
|
|
365
|
+
if (remoteStat.mtime <= change.mtime) return null;
|
|
366
|
+
const checksums = await readChecksums(
|
|
367
|
+
this.local,
|
|
368
|
+
change.path,
|
|
369
|
+
this.remote,
|
|
370
|
+
remotePath,
|
|
371
|
+
);
|
|
372
|
+
return resolveConflict({
|
|
373
|
+
path: change.path,
|
|
374
|
+
changeMtime: change.mtime,
|
|
375
|
+
remoteMtime: remoteStat.mtime,
|
|
376
|
+
localChecksum: checksums.local,
|
|
377
|
+
remoteChecksum: checksums.remote,
|
|
378
|
+
strategy: this.conflictStrategy,
|
|
379
|
+
});
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async applyRemoteEvent(
|
|
386
|
+
event: WatchEventType,
|
|
387
|
+
localPath: string,
|
|
388
|
+
remotePath: string,
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
if (event === "delete") {
|
|
391
|
+
await this.local.applyRemoteDelete(localPath);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const content = await this.remote.readFile(remotePath);
|
|
396
|
+
await this.local.applyRemoteFile(localPath, content);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ConflictRecord, ConflictStrategy } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export interface ConflictResolutionInput {
|
|
4
|
+
path: string;
|
|
5
|
+
changeMtime: Date;
|
|
6
|
+
remoteMtime: Date;
|
|
7
|
+
localChecksum?: string;
|
|
8
|
+
remoteChecksum?: string;
|
|
9
|
+
strategy: ConflictStrategy;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveConflict(
|
|
13
|
+
input: ConflictResolutionInput,
|
|
14
|
+
): ConflictRecord | null {
|
|
15
|
+
if (input.remoteMtime <= input.changeMtime) return null;
|
|
16
|
+
if (
|
|
17
|
+
input.localChecksum &&
|
|
18
|
+
input.remoteChecksum &&
|
|
19
|
+
input.localChecksum === input.remoteChecksum
|
|
20
|
+
) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const conflict: ConflictRecord = {
|
|
25
|
+
path: input.path,
|
|
26
|
+
localMtime: input.changeMtime,
|
|
27
|
+
remoteMtime: input.remoteMtime,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
switch (input.strategy) {
|
|
31
|
+
case "local-wins":
|
|
32
|
+
conflict.resolved = "local";
|
|
33
|
+
break;
|
|
34
|
+
case "remote-wins":
|
|
35
|
+
conflict.resolved = "remote";
|
|
36
|
+
break;
|
|
37
|
+
case "newest-wins":
|
|
38
|
+
conflict.resolved =
|
|
39
|
+
input.remoteMtime > input.changeMtime ? "remote" : "local";
|
|
40
|
+
break;
|
|
41
|
+
case "manual":
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return conflict;
|
|
46
|
+
}
|
|
@@ -3,6 +3,7 @@ export interface VirtualFile {
|
|
|
3
3
|
content: string;
|
|
4
4
|
language?: string;
|
|
5
5
|
note?: string;
|
|
6
|
+
encoding?: 'utf8' | 'base64';
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export interface VirtualProject {
|
|
@@ -10,11 +11,3 @@ export interface VirtualProject {
|
|
|
10
11
|
entry: string;
|
|
11
12
|
files: Map<string, VirtualFile>;
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
-
export interface StorageBackend {
|
|
15
|
-
get(path: string): Promise<string | null>;
|
|
16
|
-
put(path: string, content: string): Promise<void>;
|
|
17
|
-
delete(path: string): Promise<void>;
|
|
18
|
-
list(prefix?: string): Promise<string[]>;
|
|
19
|
-
exists(path: string): Promise<boolean>;
|
|
20
|
-
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { defineConfig } from
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
-
entry: [
|
|
5
|
-
format: [
|
|
6
|
-
target:
|
|
4
|
+
entry: ["src/index.ts"],
|
|
5
|
+
format: ["esm", "cjs"],
|
|
6
|
+
target: "node20",
|
|
7
7
|
clean: true,
|
|
8
8
|
dts: true,
|
|
9
9
|
splitting: false,
|
|
10
10
|
sourcemap: true,
|
|
11
11
|
shims: true,
|
|
12
|
-
external: [
|
|
12
|
+
external: ["react", "react-dom", "ink"],
|
|
13
13
|
skipNodeModulesBundle: true,
|
|
14
14
|
});
|
|
@@ -115,7 +115,7 @@ export const CodeBlockExtension = Node.create({
|
|
|
115
115
|
default: null,
|
|
116
116
|
parseHTML: (element: HTMLElement) => {
|
|
117
117
|
const { languageClassPrefix } = this.options;
|
|
118
|
-
const classNames =
|
|
118
|
+
const classNames = Array.from(element.firstElementChild?.classList || []);
|
|
119
119
|
const languages = classNames
|
|
120
120
|
.filter((className) => className.startsWith(languageClassPrefix))
|
|
121
121
|
.map((className) => className.replace(languageClassPrefix, ''));
|
|
@@ -3,7 +3,7 @@ import { Code, Eye, AlertCircle, Loader2, Pencil, RotateCcw, MessageSquare, Clou
|
|
|
3
3
|
import type { Compiler, MountedWidget, Manifest } from '@aprovan/patchwork-compiler';
|
|
4
4
|
import { createSingleFileProject } from '@aprovan/patchwork-compiler';
|
|
5
5
|
import { EditModal, type CompileFn } from './edit';
|
|
6
|
-
import { saveProject, getVFSConfig } from '../lib/vfs';
|
|
6
|
+
import { saveProject, getVFSConfig, loadFile, subscribeToChanges } from '../lib/vfs';
|
|
7
7
|
|
|
8
8
|
type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'error';
|
|
9
9
|
|
|
@@ -96,6 +96,11 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
|
|
|
96
96
|
const [currentCode, setCurrentCode] = useState(originalCode);
|
|
97
97
|
const [editCount, setEditCount] = useState(0);
|
|
98
98
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('unsaved');
|
|
99
|
+
const [lastSavedCode, setLastSavedCode] = useState(originalCode);
|
|
100
|
+
const [vfsPath, setVfsPath] = useState<string | null>(null);
|
|
101
|
+
const currentCodeRef = useRef(currentCode);
|
|
102
|
+
const lastSavedRef = useRef(lastSavedCode);
|
|
103
|
+
const isEditingRef = useRef(isEditing);
|
|
99
104
|
|
|
100
105
|
// Stable project ID for this widget instance (fallback when not using paths)
|
|
101
106
|
const fallbackId = useMemo(() => crypto.randomUUID(), []);
|
|
@@ -126,6 +131,58 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
|
|
|
126
131
|
return 'main.tsx';
|
|
127
132
|
}, [filePath]);
|
|
128
133
|
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
currentCodeRef.current = currentCode;
|
|
136
|
+
}, [currentCode]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
lastSavedRef.current = lastSavedCode;
|
|
140
|
+
}, [lastSavedCode]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
isEditingRef.current = isEditing;
|
|
144
|
+
}, [isEditing]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
let active = true;
|
|
148
|
+
void (async () => {
|
|
149
|
+
const projectId = await getProjectId();
|
|
150
|
+
const entryFile = getEntryFile();
|
|
151
|
+
if (!active) return;
|
|
152
|
+
setVfsPath(`${projectId}/${entryFile}`);
|
|
153
|
+
})();
|
|
154
|
+
return () => {
|
|
155
|
+
active = false;
|
|
156
|
+
};
|
|
157
|
+
}, [getProjectId, getEntryFile]);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!vfsPath) return;
|
|
161
|
+
const unsubscribe = subscribeToChanges(async (record) => {
|
|
162
|
+
if (record.path !== vfsPath) return;
|
|
163
|
+
if (record.type === 'delete') {
|
|
164
|
+
setSaveStatus('unsaved');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (isEditingRef.current) return;
|
|
168
|
+
try {
|
|
169
|
+
const remote = await loadFile(vfsPath);
|
|
170
|
+
if (currentCodeRef.current !== lastSavedRef.current) {
|
|
171
|
+
setSaveStatus('unsaved');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (remote !== currentCodeRef.current) {
|
|
175
|
+
setCurrentCode(remote);
|
|
176
|
+
setLastSavedCode(remote);
|
|
177
|
+
setSaveStatus('saved');
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
setSaveStatus('error');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return () => unsubscribe();
|
|
184
|
+
}, [vfsPath]);
|
|
185
|
+
|
|
129
186
|
// Manual save handler
|
|
130
187
|
const handleSave = useCallback(async () => {
|
|
131
188
|
setSaveStatus('saving');
|
|
@@ -134,6 +191,7 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
|
|
|
134
191
|
const entryFile = getEntryFile();
|
|
135
192
|
const project = createSingleFileProject(currentCode, entryFile, projectId);
|
|
136
193
|
await saveProject(project);
|
|
194
|
+
setLastSavedCode(currentCode);
|
|
137
195
|
setSaveStatus('saved');
|
|
138
196
|
} catch (err) {
|
|
139
197
|
console.warn('[VFS] Failed to save project:', err);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface CodeBlockViewProps {
|
|
4
|
+
content: string;
|
|
5
|
+
language: string | null;
|
|
6
|
+
editable?: boolean;
|
|
7
|
+
onChange?: (content: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CodeBlockView({ content, language, editable = false, onChange }: CodeBlockViewProps) {
|
|
11
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (textareaRef.current) {
|
|
15
|
+
textareaRef.current.style.height = 'auto';
|
|
16
|
+
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
17
|
+
}
|
|
18
|
+
}, [content]);
|
|
19
|
+
|
|
20
|
+
const handleChange = useCallback(
|
|
21
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
22
|
+
onChange?.(e.target.value);
|
|
23
|
+
},
|
|
24
|
+
[onChange]
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const handleKeyDown = useCallback(
|
|
28
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
29
|
+
if (e.key === 'Tab') {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
const target = e.target as HTMLTextAreaElement;
|
|
32
|
+
const start = target.selectionStart;
|
|
33
|
+
const end = target.selectionEnd;
|
|
34
|
+
const value = target.value;
|
|
35
|
+
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
|
36
|
+
onChange?.(newValue);
|
|
37
|
+
requestAnimationFrame(() => {
|
|
38
|
+
target.selectionStart = target.selectionEnd = start + 2;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[onChange]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const langLabel = language || 'text';
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="h-full flex flex-col bg-muted/10">
|
|
49
|
+
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b text-xs">
|
|
50
|
+
<span className="font-mono text-muted-foreground">{langLabel}</span>
|
|
51
|
+
</div>
|
|
52
|
+
{editable ? (
|
|
53
|
+
<textarea
|
|
54
|
+
ref={textareaRef}
|
|
55
|
+
value={content}
|
|
56
|
+
onChange={handleChange}
|
|
57
|
+
onKeyDown={handleKeyDown}
|
|
58
|
+
className="w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none"
|
|
59
|
+
spellCheck={false}
|
|
60
|
+
style={{
|
|
61
|
+
tabSize: 2,
|
|
62
|
+
WebkitTextFillColor: 'inherit',
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
) : (
|
|
66
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed">
|
|
67
|
+
<code>{content}</code>
|
|
68
|
+
</pre>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|