framer-code-link 0.1.4 → 0.2.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/README.md +1 -1
- package/dist/{index.js → index.mjs} +181 -145
- package/package.json +9 -6
- package/dist/project-DhpsFg77.js +0 -53
- package/src/controller.test.ts +0 -851
- package/src/controller.ts +0 -1368
- package/src/helpers/connection.ts +0 -180
- package/src/helpers/files.test.ts +0 -117
- package/src/helpers/files.ts +0 -440
- package/src/helpers/installer.ts +0 -534
- package/src/helpers/sync-validator.ts +0 -87
- package/src/helpers/user-actions.ts +0 -158
- package/src/helpers/watcher.ts +0 -110
- package/src/index.ts +0 -111
- package/src/types.ts +0 -109
- package/src/utils/file-metadata-cache.ts +0 -121
- package/src/utils/hash-tracker.ts +0 -79
- package/src/utils/imports.ts +0 -62
- package/src/utils/logging.ts +0 -232
- package/src/utils/paths.ts +0 -76
- package/src/utils/project.ts +0 -120
- package/src/utils/state-persistence.ts +0 -138
- package/tsconfig.json +0 -14
- package/vitest.config.ts +0 -8
package/src/controller.ts
DELETED
|
@@ -1,1368 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Controller
|
|
3
|
-
* Single source of truth for all runtime state and orchestrates the sync lifecycle.
|
|
4
|
-
* Helpers are functions that provide data - they never hold control or callbacks.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import fs from "fs/promises"
|
|
8
|
-
import type { WebSocket } from "ws"
|
|
9
|
-
import type {
|
|
10
|
-
Config,
|
|
11
|
-
IncomingMessage,
|
|
12
|
-
OutgoingMessage,
|
|
13
|
-
FileInfo,
|
|
14
|
-
Conflict,
|
|
15
|
-
WatcherEvent,
|
|
16
|
-
ConflictVersionData,
|
|
17
|
-
} from "./types.js"
|
|
18
|
-
import { initConnection, sendMessage } from "./helpers/connection.js"
|
|
19
|
-
import { initWatcher } from "./helpers/watcher.js"
|
|
20
|
-
import {
|
|
21
|
-
listFiles,
|
|
22
|
-
detectConflicts,
|
|
23
|
-
writeRemoteFiles,
|
|
24
|
-
deleteLocalFile,
|
|
25
|
-
readFileSafe,
|
|
26
|
-
autoResolveConflicts,
|
|
27
|
-
} from "./helpers/files.js"
|
|
28
|
-
import { Installer } from "./helpers/installer.js"
|
|
29
|
-
import { createHashTracker } from "./utils/hash-tracker.js"
|
|
30
|
-
import {
|
|
31
|
-
info,
|
|
32
|
-
warn,
|
|
33
|
-
error,
|
|
34
|
-
success,
|
|
35
|
-
debug,
|
|
36
|
-
status,
|
|
37
|
-
fileDown,
|
|
38
|
-
fileUp,
|
|
39
|
-
fileDelete,
|
|
40
|
-
scheduleDisconnectMessage,
|
|
41
|
-
cancelDisconnectMessage,
|
|
42
|
-
didShowDisconnect,
|
|
43
|
-
wasRecentlyDisconnected,
|
|
44
|
-
resetDisconnectState,
|
|
45
|
-
} from "./utils/logging.js"
|
|
46
|
-
import { hashFileContent } from "./utils/state-persistence.js"
|
|
47
|
-
import {
|
|
48
|
-
FileMetadataCache,
|
|
49
|
-
type FileSyncMetadata,
|
|
50
|
-
} from "./utils/file-metadata-cache.js"
|
|
51
|
-
import { UserActionCoordinator } from "./helpers/user-actions.js"
|
|
52
|
-
import { validateIncomingChange } from "./helpers/sync-validator.js"
|
|
53
|
-
import { findOrCreateProjectDir } from "./utils/project.js"
|
|
54
|
-
import { pluralize, shortProjectHash } from "@code-link/shared"
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Explicit sync lifecycle modes
|
|
58
|
-
*/
|
|
59
|
-
export type SyncMode =
|
|
60
|
-
| "disconnected"
|
|
61
|
-
| "handshaking"
|
|
62
|
-
| "snapshot_processing"
|
|
63
|
-
| "conflict_resolution"
|
|
64
|
-
| "watching"
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Pending operation for echo suppression and replay
|
|
68
|
-
*/
|
|
69
|
-
type PendingOperation =
|
|
70
|
-
| { id: string; type: "write"; file: string; hash: string }
|
|
71
|
-
| { id: string; type: "delete"; file: string; previousHash?: string }
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Shared state that persists across all lifecycle modes
|
|
75
|
-
*/
|
|
76
|
-
interface SyncStateBase {
|
|
77
|
-
queuedDiffs: FileInfo[]
|
|
78
|
-
pendingOperations: Map<string, PendingOperation>
|
|
79
|
-
nextOperationId: number
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
type DisconnectedState = SyncStateBase & {
|
|
83
|
-
mode: "disconnected"
|
|
84
|
-
socket: null
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
type HandshakingState = SyncStateBase & {
|
|
88
|
-
mode: "handshaking"
|
|
89
|
-
socket: WebSocket
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
type SnapshotProcessingState = SyncStateBase & {
|
|
93
|
-
mode: "snapshot_processing"
|
|
94
|
-
socket: WebSocket
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
type ConflictResolutionState = SyncStateBase & {
|
|
98
|
-
mode: "conflict_resolution"
|
|
99
|
-
socket: WebSocket
|
|
100
|
-
pendingConflicts: Conflict[]
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
type WatchingState = SyncStateBase & {
|
|
104
|
-
mode: "watching"
|
|
105
|
-
socket: WebSocket
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export type SyncState =
|
|
109
|
-
| DisconnectedState
|
|
110
|
-
| HandshakingState
|
|
111
|
-
| SnapshotProcessingState
|
|
112
|
-
| ConflictResolutionState
|
|
113
|
-
| WatchingState
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Events that drive state transitions
|
|
117
|
-
*/
|
|
118
|
-
type SyncEvent =
|
|
119
|
-
| {
|
|
120
|
-
type: "HANDSHAKE"
|
|
121
|
-
socket: WebSocket
|
|
122
|
-
projectInfo: { projectId: string; projectName: string }
|
|
123
|
-
}
|
|
124
|
-
| { type: "REQUEST_FILES" }
|
|
125
|
-
| { type: "FILE_LIST"; files: FileInfo[] }
|
|
126
|
-
| {
|
|
127
|
-
type: "CONFLICTS_DETECTED"
|
|
128
|
-
conflicts: Conflict[]
|
|
129
|
-
safeWrites: FileInfo[]
|
|
130
|
-
localOnly: FileInfo[]
|
|
131
|
-
}
|
|
132
|
-
| { type: "FILE_CHANGE"; file: FileInfo; fileMeta?: FileSyncMetadata }
|
|
133
|
-
| { type: "REMOTE_FILE_DELETE"; fileName: string }
|
|
134
|
-
| { type: "REMOTE_DELETE_CONFIRMED"; fileName: string }
|
|
135
|
-
| { type: "REMOTE_DELETE_CANCELLED"; fileName: string; content: string }
|
|
136
|
-
| {
|
|
137
|
-
type: "CONFLICTS_RESOLVED"
|
|
138
|
-
resolution: "local" | "remote"
|
|
139
|
-
}
|
|
140
|
-
| {
|
|
141
|
-
type: "FILE_SYNCED"
|
|
142
|
-
fileName: string
|
|
143
|
-
remoteModifiedAt: number
|
|
144
|
-
}
|
|
145
|
-
| { type: "DISCONNECT" }
|
|
146
|
-
| { type: "WATCHER_EVENT"; event: WatcherEvent }
|
|
147
|
-
| {
|
|
148
|
-
type: "CONFLICT_VERSION_RESPONSE"
|
|
149
|
-
versions: ConflictVersionData[]
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Side effects emitted by transitions
|
|
154
|
-
*/
|
|
155
|
-
type Effect =
|
|
156
|
-
| {
|
|
157
|
-
type: "INIT_WORKSPACE"
|
|
158
|
-
projectInfo: { projectId: string; projectName: string }
|
|
159
|
-
}
|
|
160
|
-
| { type: "LOAD_PERSISTED_STATE" }
|
|
161
|
-
| { type: "SEND_MESSAGE"; payload: OutgoingMessage }
|
|
162
|
-
| { type: "LIST_LOCAL_FILES" }
|
|
163
|
-
| { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
|
|
164
|
-
| { type: "WRITE_FILES"; files: FileInfo[]; silent?: boolean }
|
|
165
|
-
| { type: "DELETE_LOCAL_FILES"; names: string[] }
|
|
166
|
-
| { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
|
|
167
|
-
| { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
|
|
168
|
-
| {
|
|
169
|
-
type: "REQUEST_DELETE_CONFIRMATION"
|
|
170
|
-
fileName: string
|
|
171
|
-
requireConfirmation: boolean
|
|
172
|
-
}
|
|
173
|
-
| {
|
|
174
|
-
type: "UPDATE_FILE_METADATA"
|
|
175
|
-
fileName: string
|
|
176
|
-
remoteModifiedAt: number
|
|
177
|
-
}
|
|
178
|
-
| {
|
|
179
|
-
type: "SEND_LOCAL_CHANGE"
|
|
180
|
-
fileName: string
|
|
181
|
-
content: string
|
|
182
|
-
}
|
|
183
|
-
| {
|
|
184
|
-
type: "REQUEST_LOCAL_DELETE_DECISION"
|
|
185
|
-
fileName: string
|
|
186
|
-
requireConfirmation: boolean
|
|
187
|
-
}
|
|
188
|
-
| { type: "PERSIST_STATE" }
|
|
189
|
-
| {
|
|
190
|
-
type: "SYNC_COMPLETE"
|
|
191
|
-
totalCount: number
|
|
192
|
-
updatedCount: number
|
|
193
|
-
unchangedCount: number
|
|
194
|
-
}
|
|
195
|
-
| { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
|
|
196
|
-
|
|
197
|
-
/** Log helper */
|
|
198
|
-
function log(level: "info" | "debug" | "warn", message: string): Effect {
|
|
199
|
-
return { type: "LOG", level, message }
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Pure state transition function
|
|
204
|
-
* Takes current state + event, returns new state + effects to execute
|
|
205
|
-
*/
|
|
206
|
-
function transition(
|
|
207
|
-
state: SyncState,
|
|
208
|
-
event: SyncEvent
|
|
209
|
-
): { state: SyncState; effects: Effect[] } {
|
|
210
|
-
const effects: Effect[] = []
|
|
211
|
-
|
|
212
|
-
switch (event.type) {
|
|
213
|
-
case "HANDSHAKE": {
|
|
214
|
-
if (state.mode !== "disconnected") {
|
|
215
|
-
effects.push(
|
|
216
|
-
log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`)
|
|
217
|
-
)
|
|
218
|
-
return { state, effects }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
effects.push(
|
|
222
|
-
{ type: "INIT_WORKSPACE", projectInfo: event.projectInfo },
|
|
223
|
-
{ type: "LOAD_PERSISTED_STATE" },
|
|
224
|
-
{ type: "SEND_MESSAGE", payload: { type: "request-files" } }
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
state: {
|
|
229
|
-
...state,
|
|
230
|
-
mode: "handshaking",
|
|
231
|
-
socket: event.socket,
|
|
232
|
-
},
|
|
233
|
-
effects,
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
case "FILE_SYNCED": {
|
|
238
|
-
// Remote confirms they received our local change
|
|
239
|
-
effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
|
|
240
|
-
type: "UPDATE_FILE_METADATA",
|
|
241
|
-
fileName: event.fileName,
|
|
242
|
-
remoteModifiedAt: event.remoteModifiedAt,
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
return { state, effects }
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
case "DISCONNECT": {
|
|
249
|
-
effects.push(
|
|
250
|
-
{ type: "PERSIST_STATE" },
|
|
251
|
-
log("debug", "Disconnected, persisting state")
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
if (state.mode === "conflict_resolution") {
|
|
255
|
-
const { pendingConflicts: _discarded, ...rest } = state
|
|
256
|
-
return {
|
|
257
|
-
state: {
|
|
258
|
-
...rest,
|
|
259
|
-
mode: "disconnected",
|
|
260
|
-
socket: null,
|
|
261
|
-
},
|
|
262
|
-
effects,
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
state: {
|
|
268
|
-
...state,
|
|
269
|
-
mode: "disconnected",
|
|
270
|
-
socket: null,
|
|
271
|
-
},
|
|
272
|
-
effects,
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
case "REQUEST_FILES": {
|
|
277
|
-
// Plugin is asking for our local file list
|
|
278
|
-
// Valid in any mode except disconnected
|
|
279
|
-
if (state.mode === "disconnected") {
|
|
280
|
-
effects.push(
|
|
281
|
-
log("warn", "Received REQUEST_FILES while disconnected, ignoring")
|
|
282
|
-
)
|
|
283
|
-
return { state, effects }
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
effects.push(log("debug", "Plugin requested file list"), {
|
|
287
|
-
type: "LIST_LOCAL_FILES",
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
return { state, effects }
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
case "FILE_LIST": {
|
|
294
|
-
if (state.mode !== "handshaking") {
|
|
295
|
-
effects.push(
|
|
296
|
-
log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`)
|
|
297
|
-
)
|
|
298
|
-
return { state, effects }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
effects.push(
|
|
302
|
-
log("debug", `Received file list: ${event.files.length} files`)
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
// Detect conflicts between remote snapshot and local files
|
|
306
|
-
effects.push({
|
|
307
|
-
type: "DETECT_CONFLICTS",
|
|
308
|
-
remoteFiles: event.files,
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
// Transition to snapshot_processing - conflict detection effect will determine next mode
|
|
312
|
-
return {
|
|
313
|
-
state: {
|
|
314
|
-
...state,
|
|
315
|
-
mode: "snapshot_processing",
|
|
316
|
-
queuedDiffs: event.files,
|
|
317
|
-
},
|
|
318
|
-
effects,
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
case "CONFLICTS_DETECTED": {
|
|
323
|
-
if (state.mode !== "snapshot_processing") {
|
|
324
|
-
effects.push(
|
|
325
|
-
log(
|
|
326
|
-
"warn",
|
|
327
|
-
`Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring`
|
|
328
|
-
)
|
|
329
|
-
)
|
|
330
|
-
return { state, effects }
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const { conflicts, safeWrites, localOnly } = event
|
|
334
|
-
|
|
335
|
-
// detectConflicts returns:
|
|
336
|
-
// - safeWrites = files we can apply (remote-only or local unchanged)
|
|
337
|
-
// - conflicts = files that need manual resolution (content or deletion conflicts)
|
|
338
|
-
// - localOnly = files to upload
|
|
339
|
-
|
|
340
|
-
// Apply safe writes
|
|
341
|
-
if (safeWrites.length > 0) {
|
|
342
|
-
effects.push(
|
|
343
|
-
log("debug", `Applying ${safeWrites.length} safe writes`),
|
|
344
|
-
{ type: "WRITE_FILES", files: safeWrites, silent: true }
|
|
345
|
-
)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Upload local-only files
|
|
349
|
-
if (localOnly.length > 0) {
|
|
350
|
-
effects.push(
|
|
351
|
-
log("debug", `Uploading ${localOnly.length} local-only files`)
|
|
352
|
-
)
|
|
353
|
-
for (const file of localOnly) {
|
|
354
|
-
effects.push({
|
|
355
|
-
type: "SEND_MESSAGE",
|
|
356
|
-
payload: {
|
|
357
|
-
type: "file-change",
|
|
358
|
-
fileName: file.name,
|
|
359
|
-
content: file.content,
|
|
360
|
-
},
|
|
361
|
-
})
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// If conflicts remain, request remote version data before surfacing to user
|
|
366
|
-
if (conflicts.length > 0) {
|
|
367
|
-
effects.push(
|
|
368
|
-
log(
|
|
369
|
-
"debug",
|
|
370
|
-
`${pluralize(conflicts.length, "conflict")} require version check`
|
|
371
|
-
),
|
|
372
|
-
{ type: "REQUEST_CONFLICT_VERSIONS", conflicts }
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
return {
|
|
376
|
-
state: {
|
|
377
|
-
...state,
|
|
378
|
-
mode: "conflict_resolution",
|
|
379
|
-
pendingConflicts: conflicts,
|
|
380
|
-
},
|
|
381
|
-
effects,
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// No conflicts - transition to watching
|
|
386
|
-
const totalSynced = safeWrites.length + localOnly.length
|
|
387
|
-
const remoteTotal = state.queuedDiffs.length
|
|
388
|
-
const totalCount = remoteTotal + localOnly.length
|
|
389
|
-
const updatedCount = safeWrites.length + localOnly.length
|
|
390
|
-
const unchangedCount = Math.max(0, remoteTotal - safeWrites.length)
|
|
391
|
-
effects.push(
|
|
392
|
-
{ type: "PERSIST_STATE" },
|
|
393
|
-
{
|
|
394
|
-
type: "SYNC_COMPLETE",
|
|
395
|
-
totalCount,
|
|
396
|
-
updatedCount,
|
|
397
|
-
unchangedCount,
|
|
398
|
-
}
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
return {
|
|
402
|
-
state: {
|
|
403
|
-
...state,
|
|
404
|
-
mode: "watching",
|
|
405
|
-
queuedDiffs: [],
|
|
406
|
-
},
|
|
407
|
-
effects,
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
case "FILE_CHANGE": {
|
|
412
|
-
// Use helper to validate the incoming change
|
|
413
|
-
const validation = validateIncomingChange(
|
|
414
|
-
event.file,
|
|
415
|
-
event.fileMeta,
|
|
416
|
-
state.mode
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
if (validation.action === "queue") {
|
|
420
|
-
effects.push(
|
|
421
|
-
log(
|
|
422
|
-
"debug",
|
|
423
|
-
`Queueing file change: ${event.file.name} (${validation.reason})`
|
|
424
|
-
)
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
state: {
|
|
429
|
-
...state,
|
|
430
|
-
queuedDiffs: [...state.queuedDiffs, event.file],
|
|
431
|
-
},
|
|
432
|
-
effects,
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (validation.action === "reject") {
|
|
437
|
-
effects.push(
|
|
438
|
-
log(
|
|
439
|
-
"warn",
|
|
440
|
-
`Rejected file change: ${event.file.name} (${validation.reason})`
|
|
441
|
-
)
|
|
442
|
-
)
|
|
443
|
-
return { state, effects }
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Apply the change
|
|
447
|
-
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
448
|
-
type: "WRITE_FILES",
|
|
449
|
-
files: [event.file],
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
return { state, effects }
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
case "REMOTE_FILE_DELETE": {
|
|
456
|
-
// Reject if not connected
|
|
457
|
-
if (state.mode === "disconnected") {
|
|
458
|
-
effects.push(
|
|
459
|
-
log("warn", `Rejected delete while disconnected: ${event.fileName}`)
|
|
460
|
-
)
|
|
461
|
-
return { state, effects }
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Remote deletes should always be applied immediately
|
|
465
|
-
// (the file is already gone from Framer)
|
|
466
|
-
effects.push(
|
|
467
|
-
log("debug", `Remote delete applied: ${event.fileName}`),
|
|
468
|
-
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
469
|
-
{ type: "PERSIST_STATE" }
|
|
470
|
-
)
|
|
471
|
-
|
|
472
|
-
return { state, effects }
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
case "REMOTE_DELETE_CONFIRMED": {
|
|
476
|
-
// User confirmed the delete - apply it
|
|
477
|
-
effects.push(
|
|
478
|
-
log("debug", `Delete confirmed: ${event.fileName}`),
|
|
479
|
-
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
480
|
-
{ type: "PERSIST_STATE" }
|
|
481
|
-
)
|
|
482
|
-
|
|
483
|
-
return { state, effects }
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
case "REMOTE_DELETE_CANCELLED": {
|
|
487
|
-
// User cancelled - restore the file
|
|
488
|
-
effects.push(log("debug", `Delete cancelled: ${event.fileName}`))
|
|
489
|
-
effects.push({
|
|
490
|
-
type: "WRITE_FILES",
|
|
491
|
-
files: [
|
|
492
|
-
{
|
|
493
|
-
name: event.fileName,
|
|
494
|
-
content: event.content,
|
|
495
|
-
modifiedAt: Date.now(),
|
|
496
|
-
},
|
|
497
|
-
],
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
return { state, effects }
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
case "CONFLICTS_RESOLVED": {
|
|
504
|
-
// Only valid in conflict_resolution mode
|
|
505
|
-
if (state.mode !== "conflict_resolution") {
|
|
506
|
-
effects.push(
|
|
507
|
-
log(
|
|
508
|
-
"warn",
|
|
509
|
-
`Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`
|
|
510
|
-
)
|
|
511
|
-
)
|
|
512
|
-
return { state, effects }
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// User picked one resolution for ALL conflicts
|
|
516
|
-
if (event.resolution === "remote") {
|
|
517
|
-
// Apply all remote versions (or delete locally if remote is null)
|
|
518
|
-
for (const conflict of state.pendingConflicts) {
|
|
519
|
-
if (conflict.remoteContent === null) {
|
|
520
|
-
// Remote deleted this file - delete locally
|
|
521
|
-
effects.push({
|
|
522
|
-
type: "DELETE_LOCAL_FILES",
|
|
523
|
-
names: [conflict.fileName],
|
|
524
|
-
})
|
|
525
|
-
} else {
|
|
526
|
-
effects.push({
|
|
527
|
-
type: "WRITE_FILES",
|
|
528
|
-
files: [
|
|
529
|
-
{
|
|
530
|
-
name: conflict.fileName,
|
|
531
|
-
content: conflict.remoteContent,
|
|
532
|
-
modifiedAt: conflict.remoteModifiedAt,
|
|
533
|
-
},
|
|
534
|
-
],
|
|
535
|
-
})
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
effects.push(
|
|
539
|
-
log(
|
|
540
|
-
"debug",
|
|
541
|
-
`Applied ${state.pendingConflicts.length} remote versions`
|
|
542
|
-
)
|
|
543
|
-
)
|
|
544
|
-
} else {
|
|
545
|
-
// Send all local versions (or delete from Framer if local is null)
|
|
546
|
-
for (const conflict of state.pendingConflicts) {
|
|
547
|
-
if (conflict.localContent === null) {
|
|
548
|
-
// Local deleted this file - delete from Framer
|
|
549
|
-
effects.push({
|
|
550
|
-
type: "SEND_MESSAGE",
|
|
551
|
-
payload: {
|
|
552
|
-
type: "file-delete",
|
|
553
|
-
fileNames: [conflict.fileName],
|
|
554
|
-
},
|
|
555
|
-
})
|
|
556
|
-
} else {
|
|
557
|
-
effects.push({
|
|
558
|
-
type: "SEND_MESSAGE",
|
|
559
|
-
payload: {
|
|
560
|
-
type: "file-change",
|
|
561
|
-
fileName: conflict.fileName,
|
|
562
|
-
content: conflict.localContent,
|
|
563
|
-
},
|
|
564
|
-
})
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
effects.push(
|
|
568
|
-
log(
|
|
569
|
-
"debug",
|
|
570
|
-
`Applied ${state.pendingConflicts.length} local versions`
|
|
571
|
-
)
|
|
572
|
-
)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// All conflicts resolved - transition to watching
|
|
576
|
-
effects.push(
|
|
577
|
-
{ type: "PERSIST_STATE" },
|
|
578
|
-
{
|
|
579
|
-
type: "SYNC_COMPLETE",
|
|
580
|
-
totalCount: state.pendingConflicts.length,
|
|
581
|
-
updatedCount: state.pendingConflicts.length,
|
|
582
|
-
unchangedCount: 0,
|
|
583
|
-
}
|
|
584
|
-
)
|
|
585
|
-
|
|
586
|
-
const { pendingConflicts: _discarded, ...rest } = state
|
|
587
|
-
return {
|
|
588
|
-
state: {
|
|
589
|
-
...rest,
|
|
590
|
-
mode: "watching",
|
|
591
|
-
},
|
|
592
|
-
effects,
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
case "WATCHER_EVENT": {
|
|
597
|
-
// Local file system change detected
|
|
598
|
-
const { kind, relativePath, content } = event.event
|
|
599
|
-
|
|
600
|
-
// Only process changes in watching mode
|
|
601
|
-
if (state.mode !== "watching") {
|
|
602
|
-
effects.push(
|
|
603
|
-
log(
|
|
604
|
-
"debug",
|
|
605
|
-
`Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`
|
|
606
|
-
)
|
|
607
|
-
)
|
|
608
|
-
return { state, effects }
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
switch (kind) {
|
|
612
|
-
case "add":
|
|
613
|
-
case "change": {
|
|
614
|
-
if (content === undefined) {
|
|
615
|
-
effects.push(
|
|
616
|
-
log("warn", `Watcher event missing content: ${relativePath}`)
|
|
617
|
-
)
|
|
618
|
-
return { state, effects }
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
effects.push({
|
|
622
|
-
type: "SEND_LOCAL_CHANGE",
|
|
623
|
-
fileName: relativePath,
|
|
624
|
-
content,
|
|
625
|
-
})
|
|
626
|
-
break
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
case "delete": {
|
|
630
|
-
effects.push(log("debug", `Local delete detected: ${relativePath}`), {
|
|
631
|
-
type: "REQUEST_LOCAL_DELETE_DECISION",
|
|
632
|
-
fileName: relativePath,
|
|
633
|
-
requireConfirmation: true, // Will be overridden by config in effect
|
|
634
|
-
})
|
|
635
|
-
break
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
return { state, effects }
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
case "CONFLICT_VERSION_RESPONSE": {
|
|
643
|
-
if (state.mode !== "conflict_resolution") {
|
|
644
|
-
effects.push(
|
|
645
|
-
log(
|
|
646
|
-
"warn",
|
|
647
|
-
`Received CONFLICT_VERSION_RESPONSE in mode ${state.mode}, ignoring`
|
|
648
|
-
)
|
|
649
|
-
)
|
|
650
|
-
return { state, effects }
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } =
|
|
654
|
-
autoResolveConflicts(state.pendingConflicts, event.versions)
|
|
655
|
-
|
|
656
|
-
if (autoResolvedLocal.length > 0) {
|
|
657
|
-
effects.push(
|
|
658
|
-
log(
|
|
659
|
-
"debug",
|
|
660
|
-
`Auto-resolved ${autoResolvedLocal.length} local changes`
|
|
661
|
-
)
|
|
662
|
-
)
|
|
663
|
-
for (const conflict of autoResolvedLocal) {
|
|
664
|
-
if (conflict.localContent === null) {
|
|
665
|
-
// Local deleted - delete from Framer
|
|
666
|
-
effects.push({
|
|
667
|
-
type: "SEND_MESSAGE",
|
|
668
|
-
payload: {
|
|
669
|
-
type: "file-delete",
|
|
670
|
-
fileNames: [conflict.fileName],
|
|
671
|
-
},
|
|
672
|
-
})
|
|
673
|
-
} else {
|
|
674
|
-
effects.push({
|
|
675
|
-
type: "SEND_LOCAL_CHANGE",
|
|
676
|
-
fileName: conflict.fileName,
|
|
677
|
-
content: conflict.localContent,
|
|
678
|
-
})
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
if (autoResolvedRemote.length > 0) {
|
|
684
|
-
effects.push(
|
|
685
|
-
log(
|
|
686
|
-
"debug",
|
|
687
|
-
`Auto-resolved ${autoResolvedRemote.length} remote changes`
|
|
688
|
-
)
|
|
689
|
-
)
|
|
690
|
-
for (const conflict of autoResolvedRemote) {
|
|
691
|
-
if (conflict.remoteContent === null) {
|
|
692
|
-
// Remote deleted - delete locally
|
|
693
|
-
effects.push({
|
|
694
|
-
type: "DELETE_LOCAL_FILES",
|
|
695
|
-
names: [conflict.fileName],
|
|
696
|
-
})
|
|
697
|
-
} else {
|
|
698
|
-
effects.push({
|
|
699
|
-
type: "WRITE_FILES",
|
|
700
|
-
files: [
|
|
701
|
-
{
|
|
702
|
-
name: conflict.fileName,
|
|
703
|
-
content: conflict.remoteContent,
|
|
704
|
-
modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
|
|
705
|
-
},
|
|
706
|
-
],
|
|
707
|
-
})
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
if (remainingConflicts.length > 0) {
|
|
713
|
-
effects.push(
|
|
714
|
-
log(
|
|
715
|
-
"warn",
|
|
716
|
-
`${pluralize(remainingConflicts.length, "conflict")} require resolution`
|
|
717
|
-
),
|
|
718
|
-
{ type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts }
|
|
719
|
-
)
|
|
720
|
-
|
|
721
|
-
return {
|
|
722
|
-
state: {
|
|
723
|
-
...state,
|
|
724
|
-
pendingConflicts: remainingConflicts,
|
|
725
|
-
},
|
|
726
|
-
effects,
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length
|
|
731
|
-
effects.push(
|
|
732
|
-
{ type: "PERSIST_STATE" },
|
|
733
|
-
{
|
|
734
|
-
type: "SYNC_COMPLETE",
|
|
735
|
-
totalCount: resolvedCount,
|
|
736
|
-
updatedCount: resolvedCount,
|
|
737
|
-
unchangedCount: 0,
|
|
738
|
-
}
|
|
739
|
-
)
|
|
740
|
-
|
|
741
|
-
const { pendingConflicts: _discarded, ...rest } = state
|
|
742
|
-
return {
|
|
743
|
-
state: {
|
|
744
|
-
...rest,
|
|
745
|
-
mode: "watching",
|
|
746
|
-
queuedDiffs: [],
|
|
747
|
-
},
|
|
748
|
-
effects,
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
default: {
|
|
753
|
-
effects.push(log("warn", `Unhandled event type in transition`))
|
|
754
|
-
return { state, effects }
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* Effect executor - interprets effects and calls helpers
|
|
761
|
-
* Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS)
|
|
762
|
-
*/
|
|
763
|
-
async function executeEffect(
|
|
764
|
-
effect: Effect,
|
|
765
|
-
context: {
|
|
766
|
-
config: Config
|
|
767
|
-
hashTracker: ReturnType<typeof createHashTracker>
|
|
768
|
-
installer: Installer | null
|
|
769
|
-
fileMetadataCache: FileMetadataCache
|
|
770
|
-
userActions: UserActionCoordinator
|
|
771
|
-
syncState: SyncState
|
|
772
|
-
}
|
|
773
|
-
): Promise<SyncEvent[]> {
|
|
774
|
-
const {
|
|
775
|
-
config,
|
|
776
|
-
hashTracker,
|
|
777
|
-
installer,
|
|
778
|
-
fileMetadataCache,
|
|
779
|
-
userActions,
|
|
780
|
-
syncState,
|
|
781
|
-
} = context
|
|
782
|
-
|
|
783
|
-
switch (effect.type) {
|
|
784
|
-
case "INIT_WORKSPACE": {
|
|
785
|
-
// Initialize project directory if not already set
|
|
786
|
-
if (!config.projectDir) {
|
|
787
|
-
const projectName =
|
|
788
|
-
config.explicitName ?? effect.projectInfo.projectName
|
|
789
|
-
config.projectDir = await findOrCreateProjectDir(
|
|
790
|
-
config.projectHash,
|
|
791
|
-
projectName,
|
|
792
|
-
config.explicitDir
|
|
793
|
-
)
|
|
794
|
-
config.filesDir = `${config.projectDir}/files`
|
|
795
|
-
debug(`Files directory: ${config.filesDir}`)
|
|
796
|
-
|
|
797
|
-
// Create files directory
|
|
798
|
-
await fs.mkdir(config.filesDir, { recursive: true })
|
|
799
|
-
}
|
|
800
|
-
return []
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
case "LOAD_PERSISTED_STATE": {
|
|
804
|
-
if (config.projectDir) {
|
|
805
|
-
await fileMetadataCache.initialize(config.projectDir)
|
|
806
|
-
debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
|
|
807
|
-
}
|
|
808
|
-
return []
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
case "LIST_LOCAL_FILES": {
|
|
812
|
-
if (!config.filesDir) {
|
|
813
|
-
return []
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// List all local files and send to plugin
|
|
817
|
-
const files = await listFiles(config.filesDir)
|
|
818
|
-
|
|
819
|
-
if (syncState.socket) {
|
|
820
|
-
await sendMessage(syncState.socket, {
|
|
821
|
-
type: "file-list",
|
|
822
|
-
files,
|
|
823
|
-
})
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
return []
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
case "DETECT_CONFLICTS": {
|
|
830
|
-
if (!config.filesDir) {
|
|
831
|
-
return []
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// Use existing helper to detect conflicts
|
|
835
|
-
const { conflicts, writes, localOnly } = await detectConflicts(
|
|
836
|
-
effect.remoteFiles,
|
|
837
|
-
config.filesDir,
|
|
838
|
-
{ persistedState: fileMetadataCache.getPersistedState() }
|
|
839
|
-
)
|
|
840
|
-
|
|
841
|
-
// Return CONFLICTS_DETECTED event to continue the flow
|
|
842
|
-
return [
|
|
843
|
-
{
|
|
844
|
-
type: "CONFLICTS_DETECTED",
|
|
845
|
-
conflicts,
|
|
846
|
-
safeWrites: writes,
|
|
847
|
-
localOnly,
|
|
848
|
-
},
|
|
849
|
-
]
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
case "SEND_MESSAGE": {
|
|
853
|
-
if (syncState.socket) {
|
|
854
|
-
const sent = await sendMessage(syncState.socket, effect.payload)
|
|
855
|
-
if (!sent) {
|
|
856
|
-
warn(`Failed to send message: ${effect.payload.type}`)
|
|
857
|
-
}
|
|
858
|
-
} else {
|
|
859
|
-
warn(`No socket available to send: ${effect.payload.type}`)
|
|
860
|
-
}
|
|
861
|
-
return []
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
case "WRITE_FILES": {
|
|
865
|
-
if (config.filesDir) {
|
|
866
|
-
await writeRemoteFiles(
|
|
867
|
-
effect.files,
|
|
868
|
-
config.filesDir,
|
|
869
|
-
hashTracker,
|
|
870
|
-
installer ?? undefined
|
|
871
|
-
)
|
|
872
|
-
for (const file of effect.files) {
|
|
873
|
-
if (!effect.silent) {
|
|
874
|
-
fileDown(file.name)
|
|
875
|
-
}
|
|
876
|
-
const remoteTimestamp = file.modifiedAt ?? Date.now()
|
|
877
|
-
fileMetadataCache.recordRemoteWrite(
|
|
878
|
-
file.name,
|
|
879
|
-
file.content,
|
|
880
|
-
remoteTimestamp
|
|
881
|
-
)
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
return []
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
case "DELETE_LOCAL_FILES": {
|
|
888
|
-
if (config.filesDir) {
|
|
889
|
-
for (const fileName of effect.names) {
|
|
890
|
-
await deleteLocalFile(fileName, config.filesDir, hashTracker)
|
|
891
|
-
fileDelete(fileName)
|
|
892
|
-
fileMetadataCache.recordDelete(fileName)
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
return []
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
case "REQUEST_CONFLICT_DECISIONS": {
|
|
899
|
-
await userActions.requestConflictDecisions(
|
|
900
|
-
syncState.socket,
|
|
901
|
-
effect.conflicts
|
|
902
|
-
)
|
|
903
|
-
|
|
904
|
-
return []
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
case "REQUEST_CONFLICT_VERSIONS": {
|
|
908
|
-
if (!syncState.socket) {
|
|
909
|
-
warn("Cannot request conflict versions without active socket")
|
|
910
|
-
return []
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const persistedState = fileMetadataCache.getPersistedState()
|
|
914
|
-
const versionRequests = effect.conflicts.map((conflict) => {
|
|
915
|
-
const persisted = persistedState.get(conflict.fileName)
|
|
916
|
-
return {
|
|
917
|
-
fileName: conflict.fileName,
|
|
918
|
-
lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp,
|
|
919
|
-
}
|
|
920
|
-
})
|
|
921
|
-
|
|
922
|
-
debug(
|
|
923
|
-
`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`
|
|
924
|
-
)
|
|
925
|
-
|
|
926
|
-
await sendMessage(syncState.socket, {
|
|
927
|
-
type: "conflict-version-request",
|
|
928
|
-
conflicts: versionRequests,
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
return []
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
case "REQUEST_DELETE_CONFIRMATION": {
|
|
935
|
-
if (syncState.socket) {
|
|
936
|
-
// Send delete request to plugin
|
|
937
|
-
await sendMessage(syncState.socket, {
|
|
938
|
-
type: "file-delete",
|
|
939
|
-
fileNames: [effect.fileName],
|
|
940
|
-
requireConfirmation: effect.requireConfirmation,
|
|
941
|
-
})
|
|
942
|
-
}
|
|
943
|
-
// Response will come via delete-confirmed or delete-cancelled message
|
|
944
|
-
return []
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
case "UPDATE_FILE_METADATA": {
|
|
948
|
-
if (!config.filesDir || !config.projectDir) {
|
|
949
|
-
return []
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Read current file content to compute hash
|
|
953
|
-
const currentContent = await readFileSafe(
|
|
954
|
-
effect.fileName,
|
|
955
|
-
config.filesDir
|
|
956
|
-
)
|
|
957
|
-
|
|
958
|
-
if (currentContent !== null) {
|
|
959
|
-
const contentHash = hashFileContent(currentContent)
|
|
960
|
-
fileMetadataCache.recordSyncedSnapshot(
|
|
961
|
-
effect.fileName,
|
|
962
|
-
contentHash,
|
|
963
|
-
effect.remoteModifiedAt
|
|
964
|
-
)
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
return []
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
case "SEND_LOCAL_CHANGE": {
|
|
971
|
-
const contentHash = hashFileContent(effect.content)
|
|
972
|
-
const metadata = fileMetadataCache.get(effect.fileName)
|
|
973
|
-
|
|
974
|
-
// Skip if file matches last confirmed remote content
|
|
975
|
-
if (metadata?.lastSyncedHash === contentHash) {
|
|
976
|
-
debug(
|
|
977
|
-
`Skipping local change for ${effect.fileName}: matches last synced content`
|
|
978
|
-
)
|
|
979
|
-
return []
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Echo prevention: skip if we just wrote this exact content
|
|
983
|
-
if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
|
|
984
|
-
return []
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
debug(`Local change detected: ${effect.fileName}`)
|
|
988
|
-
|
|
989
|
-
try {
|
|
990
|
-
// Send change to plugin
|
|
991
|
-
if (syncState.socket) {
|
|
992
|
-
await sendMessage(syncState.socket, {
|
|
993
|
-
type: "file-change",
|
|
994
|
-
fileName: effect.fileName,
|
|
995
|
-
content: effect.content,
|
|
996
|
-
})
|
|
997
|
-
fileUp(effect.fileName)
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
// Only remember hash after successful send (prevents re-sending on failure)
|
|
1001
|
-
hashTracker.remember(effect.fileName, effect.content)
|
|
1002
|
-
|
|
1003
|
-
// Trigger type installer
|
|
1004
|
-
if (installer) {
|
|
1005
|
-
installer.process(effect.fileName, effect.content)
|
|
1006
|
-
}
|
|
1007
|
-
} catch (err) {
|
|
1008
|
-
warn(`Failed to push ${effect.fileName}`)
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
return []
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
case "REQUEST_LOCAL_DELETE_DECISION": {
|
|
1015
|
-
// Echo prevention: skip if this is a remote-initiated delete
|
|
1016
|
-
const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName)
|
|
1017
|
-
|
|
1018
|
-
if (shouldSkip) {
|
|
1019
|
-
// Clear the delete marker now that we've caught the echo
|
|
1020
|
-
hashTracker.clearDelete(effect.fileName)
|
|
1021
|
-
return []
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
try {
|
|
1025
|
-
const shouldDelete = await userActions.requestDeleteDecision(
|
|
1026
|
-
syncState.socket,
|
|
1027
|
-
{
|
|
1028
|
-
fileName: effect.fileName,
|
|
1029
|
-
requireConfirmation: !config.dangerouslyAutoDelete,
|
|
1030
|
-
}
|
|
1031
|
-
)
|
|
1032
|
-
|
|
1033
|
-
if (shouldDelete) {
|
|
1034
|
-
hashTracker.forget(effect.fileName)
|
|
1035
|
-
fileMetadataCache.recordDelete(effect.fileName)
|
|
1036
|
-
|
|
1037
|
-
if (syncState.socket) {
|
|
1038
|
-
await sendMessage(syncState.socket, {
|
|
1039
|
-
type: "file-delete",
|
|
1040
|
-
fileNames: [effect.fileName],
|
|
1041
|
-
})
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
} catch (err) {
|
|
1045
|
-
console.warn(`Failed to handle deletion for ${effect.fileName}:`, err)
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
return []
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
case "PERSIST_STATE": {
|
|
1052
|
-
await fileMetadataCache.flush()
|
|
1053
|
-
return []
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
case "SYNC_COMPLETE": {
|
|
1057
|
-
const wasDisconnected = wasRecentlyDisconnected()
|
|
1058
|
-
|
|
1059
|
-
if (wasDisconnected) {
|
|
1060
|
-
// Only show reconnect message if we actually showed the disconnect notice
|
|
1061
|
-
if (didShowDisconnect()) {
|
|
1062
|
-
success(
|
|
1063
|
-
`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
|
|
1064
|
-
)
|
|
1065
|
-
status("Watching for changes...")
|
|
1066
|
-
}
|
|
1067
|
-
resetDisconnectState()
|
|
1068
|
-
return []
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
success(
|
|
1072
|
-
`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
|
|
1073
|
-
)
|
|
1074
|
-
status("Watching for changes...")
|
|
1075
|
-
return []
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
case "LOG": {
|
|
1079
|
-
const logFn =
|
|
1080
|
-
effect.level === "info" ? info : effect.level === "warn" ? warn : debug
|
|
1081
|
-
logFn(effect.message)
|
|
1082
|
-
return []
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
/**
|
|
1088
|
-
* Starts the sync controller with the given configuration
|
|
1089
|
-
*/
|
|
1090
|
-
export async function start(config: Config): Promise<void> {
|
|
1091
|
-
status("Waiting for Plugin connection...")
|
|
1092
|
-
|
|
1093
|
-
const hashTracker = createHashTracker()
|
|
1094
|
-
const fileMetadataCache = new FileMetadataCache()
|
|
1095
|
-
let installer: Installer | null = null
|
|
1096
|
-
|
|
1097
|
-
// State machine state
|
|
1098
|
-
let syncState: SyncState = {
|
|
1099
|
-
mode: "disconnected",
|
|
1100
|
-
socket: null,
|
|
1101
|
-
queuedDiffs: [],
|
|
1102
|
-
pendingOperations: new Map(),
|
|
1103
|
-
nextOperationId: 1,
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const userActions = new UserActionCoordinator()
|
|
1107
|
-
|
|
1108
|
-
// State Machine Execution Helper
|
|
1109
|
-
// Process events through state machine and execute effects recursively
|
|
1110
|
-
async function processEvent(event: SyncEvent) {
|
|
1111
|
-
const socketState = syncState.socket?.readyState
|
|
1112
|
-
debug(
|
|
1113
|
-
`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`
|
|
1114
|
-
)
|
|
1115
|
-
|
|
1116
|
-
const result = transition(syncState, event)
|
|
1117
|
-
syncState = result.state
|
|
1118
|
-
|
|
1119
|
-
if (result.effects.length > 0) {
|
|
1120
|
-
debug(
|
|
1121
|
-
`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`
|
|
1122
|
-
)
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// Execute all effects and process any follow-up events
|
|
1126
|
-
for (const effect of result.effects) {
|
|
1127
|
-
// Check socket state before each effect
|
|
1128
|
-
const currentSocketState = syncState.socket?.readyState
|
|
1129
|
-
if (currentSocketState !== undefined && currentSocketState !== 1) {
|
|
1130
|
-
debug(
|
|
1131
|
-
`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`
|
|
1132
|
-
)
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
const followUpEvents = await executeEffect(effect, {
|
|
1136
|
-
config,
|
|
1137
|
-
hashTracker,
|
|
1138
|
-
installer,
|
|
1139
|
-
fileMetadataCache,
|
|
1140
|
-
userActions,
|
|
1141
|
-
syncState,
|
|
1142
|
-
})
|
|
1143
|
-
|
|
1144
|
-
// Recursively process follow-up events
|
|
1145
|
-
for (const followUpEvent of followUpEvents) {
|
|
1146
|
-
await processEvent(followUpEvent)
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
// WebSocket Connection
|
|
1152
|
-
const connection = await initConnection(config.port)
|
|
1153
|
-
|
|
1154
|
-
// Handle initial handshake
|
|
1155
|
-
connection.on("handshake", async (client: WebSocket, message) => {
|
|
1156
|
-
debug(`Received handshake: ${message.projectName} (${message.projectId})`)
|
|
1157
|
-
|
|
1158
|
-
// Validate project hash (normalize both to short hash for comparison)
|
|
1159
|
-
const expectedShort = shortProjectHash(config.projectHash)
|
|
1160
|
-
const receivedShort = shortProjectHash(message.projectId)
|
|
1161
|
-
if (receivedShort !== expectedShort) {
|
|
1162
|
-
warn(
|
|
1163
|
-
`Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`
|
|
1164
|
-
)
|
|
1165
|
-
client.close()
|
|
1166
|
-
return
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// Process handshake through state machine
|
|
1170
|
-
await processEvent({
|
|
1171
|
-
type: "HANDSHAKE",
|
|
1172
|
-
socket: client,
|
|
1173
|
-
projectInfo: {
|
|
1174
|
-
projectId: message.projectId,
|
|
1175
|
-
projectName: message.projectName,
|
|
1176
|
-
},
|
|
1177
|
-
})
|
|
1178
|
-
|
|
1179
|
-
// Initialize installer if needed
|
|
1180
|
-
if (config.projectDir && !installer) {
|
|
1181
|
-
installer = new Installer({ projectDir: config.projectDir })
|
|
1182
|
-
await installer.initialize()
|
|
1183
|
-
// Start file watcher now that we have a directory
|
|
1184
|
-
startWatcher()
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
// Cancel any pending disconnect message (fast reconnect)
|
|
1188
|
-
cancelDisconnectMessage()
|
|
1189
|
-
|
|
1190
|
-
// Only show "Connected" on initial connection, not reconnects
|
|
1191
|
-
// Reconnect confirmation happens in SYNC_COMPLETE
|
|
1192
|
-
const wasDisconnected = wasRecentlyDisconnected()
|
|
1193
|
-
if (!wasDisconnected && !didShowDisconnect()) {
|
|
1194
|
-
success(`Connected to ${message.projectName}`)
|
|
1195
|
-
}
|
|
1196
|
-
})
|
|
1197
|
-
|
|
1198
|
-
// Message Handler
|
|
1199
|
-
async function handleMessage(message: IncomingMessage) {
|
|
1200
|
-
// Ensure project is initialized before handling messages
|
|
1201
|
-
if (!config.projectDir || !installer) {
|
|
1202
|
-
warn("Received message before handshake completed - ignoring")
|
|
1203
|
-
return
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
let event: SyncEvent | null = null
|
|
1207
|
-
|
|
1208
|
-
// Map incoming messages to state machine events
|
|
1209
|
-
switch (message.type) {
|
|
1210
|
-
case "request-files":
|
|
1211
|
-
event = { type: "REQUEST_FILES" }
|
|
1212
|
-
break
|
|
1213
|
-
|
|
1214
|
-
case "file-list": {
|
|
1215
|
-
const totalSize = message.files.reduce(
|
|
1216
|
-
(sum, f) => sum + (f.content?.length ?? 0),
|
|
1217
|
-
0
|
|
1218
|
-
)
|
|
1219
|
-
debug(
|
|
1220
|
-
`Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`
|
|
1221
|
-
)
|
|
1222
|
-
event = { type: "FILE_LIST", files: message.files }
|
|
1223
|
-
break
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
case "file-change":
|
|
1227
|
-
event = {
|
|
1228
|
-
type: "FILE_CHANGE",
|
|
1229
|
-
file: {
|
|
1230
|
-
name: message.fileName,
|
|
1231
|
-
content: message.content,
|
|
1232
|
-
// Remote modifiedAt is expensive to compute (requires getVerions API call), so we
|
|
1233
|
-
// use local receipt time. Conflict detection uses content hashes, not timestamps.
|
|
1234
|
-
modifiedAt: Date.now(),
|
|
1235
|
-
},
|
|
1236
|
-
fileMeta: fileMetadataCache.get(message.fileName),
|
|
1237
|
-
}
|
|
1238
|
-
break
|
|
1239
|
-
|
|
1240
|
-
case "file-delete": {
|
|
1241
|
-
// Remote deletes are always applied immediately (file is already gone from Framer)
|
|
1242
|
-
for (const fileName of message.fileNames) {
|
|
1243
|
-
await processEvent({
|
|
1244
|
-
type: "REMOTE_FILE_DELETE",
|
|
1245
|
-
fileName,
|
|
1246
|
-
})
|
|
1247
|
-
}
|
|
1248
|
-
return
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
case "delete-confirmed": {
|
|
1252
|
-
const unmatched: string[] = []
|
|
1253
|
-
|
|
1254
|
-
for (const fileName of message.fileNames) {
|
|
1255
|
-
const handled = userActions.handleConfirmation(
|
|
1256
|
-
`delete:${fileName}`,
|
|
1257
|
-
true
|
|
1258
|
-
)
|
|
1259
|
-
|
|
1260
|
-
if (!handled) {
|
|
1261
|
-
unmatched.push(fileName)
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
for (const fileName of unmatched) {
|
|
1266
|
-
await processEvent({ type: "REMOTE_DELETE_CONFIRMED", fileName })
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
return
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
case "delete-cancelled": {
|
|
1273
|
-
for (const file of message.files) {
|
|
1274
|
-
userActions.handleConfirmation(`delete:${file.fileName}`, false)
|
|
1275
|
-
|
|
1276
|
-
await processEvent({
|
|
1277
|
-
type: "REMOTE_DELETE_CANCELLED",
|
|
1278
|
-
fileName: file.fileName,
|
|
1279
|
-
content: file.content ?? "",
|
|
1280
|
-
})
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
return
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
case "file-synced":
|
|
1287
|
-
event = {
|
|
1288
|
-
type: "FILE_SYNCED",
|
|
1289
|
-
fileName: message.fileName,
|
|
1290
|
-
remoteModifiedAt: message.remoteModifiedAt,
|
|
1291
|
-
}
|
|
1292
|
-
break
|
|
1293
|
-
|
|
1294
|
-
case "conflicts-resolved":
|
|
1295
|
-
event = {
|
|
1296
|
-
type: "CONFLICTS_RESOLVED",
|
|
1297
|
-
resolution: message.resolution,
|
|
1298
|
-
}
|
|
1299
|
-
break
|
|
1300
|
-
|
|
1301
|
-
case "conflict-version-response":
|
|
1302
|
-
event = {
|
|
1303
|
-
type: "CONFLICT_VERSION_RESPONSE",
|
|
1304
|
-
versions: message.versions,
|
|
1305
|
-
}
|
|
1306
|
-
break
|
|
1307
|
-
|
|
1308
|
-
default:
|
|
1309
|
-
warn(`Unhandled message type: ${message.type}`)
|
|
1310
|
-
return
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
if (event) {
|
|
1314
|
-
await processEvent(event)
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
connection.on("message", async (message: IncomingMessage) => {
|
|
1319
|
-
try {
|
|
1320
|
-
await handleMessage(message)
|
|
1321
|
-
} catch (err) {
|
|
1322
|
-
error("Error handling message:", err)
|
|
1323
|
-
}
|
|
1324
|
-
})
|
|
1325
|
-
|
|
1326
|
-
connection.on("disconnect", async () => {
|
|
1327
|
-
// Schedule disconnect message with delay - if reconnect happens quickly, we skip it
|
|
1328
|
-
scheduleDisconnectMessage(() => {
|
|
1329
|
-
status("Disconnected, waiting to reconnect...")
|
|
1330
|
-
})
|
|
1331
|
-
await processEvent({ type: "DISCONNECT" })
|
|
1332
|
-
userActions.cleanup()
|
|
1333
|
-
})
|
|
1334
|
-
|
|
1335
|
-
connection.on("error", (err) => {
|
|
1336
|
-
error("Error on WebSocket connection:", err)
|
|
1337
|
-
})
|
|
1338
|
-
|
|
1339
|
-
// File Watcher Setup
|
|
1340
|
-
// Note: Watcher will be initialized after handshake when filesDir is set
|
|
1341
|
-
|
|
1342
|
-
let watcher: ReturnType<typeof initWatcher> | null = null
|
|
1343
|
-
|
|
1344
|
-
const startWatcher = () => {
|
|
1345
|
-
if (!config.filesDir || watcher) return
|
|
1346
|
-
watcher = initWatcher(config.filesDir)
|
|
1347
|
-
|
|
1348
|
-
watcher.on("change", async (event) => {
|
|
1349
|
-
await processEvent({ type: "WATCHER_EVENT", event })
|
|
1350
|
-
})
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// Lifecycle - no additional startup messages needed (banner shown in index.ts)
|
|
1354
|
-
|
|
1355
|
-
// Graceful shutdown
|
|
1356
|
-
process.on("SIGINT", async () => {
|
|
1357
|
-
console.log() // newline after ^C
|
|
1358
|
-
status("Shutting down...")
|
|
1359
|
-
if (watcher) {
|
|
1360
|
-
await watcher.close()
|
|
1361
|
-
}
|
|
1362
|
-
connection.close()
|
|
1363
|
-
process.exit(0)
|
|
1364
|
-
})
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
// Export for testing
|
|
1368
|
-
export { transition }
|