framer-code-link 0.1.3 → 0.2.0
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} +703 -260
- package/package.json +12 -12
- package/src/controller.test.ts +63 -138
- package/src/controller.ts +296 -109
- package/src/helpers/connection.ts +18 -11
- package/src/helpers/files.ts +125 -40
- package/src/helpers/installer.ts +12 -16
- package/src/helpers/user-actions.ts +7 -11
- package/src/helpers/watcher.test.ts +74 -0
- package/src/helpers/watcher.ts +4 -9
- package/src/index.ts +7 -4
- package/src/types.ts +8 -3
- package/src/utils/{hashing.ts → hash-tracker.ts} +1 -17
- package/src/utils/logging.ts +194 -6
- package/src/utils/project.ts +15 -8
- package/dist/project-DhpsFg77.js +0 -53
package/src/controller.ts
CHANGED
|
@@ -26,8 +26,23 @@ import {
|
|
|
26
26
|
autoResolveConflicts,
|
|
27
27
|
} from "./helpers/files.js"
|
|
28
28
|
import { Installer } from "./helpers/installer.js"
|
|
29
|
-
import { createHashTracker } from "./utils/
|
|
30
|
-
import {
|
|
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"
|
|
31
46
|
import { hashFileContent } from "./utils/state-persistence.js"
|
|
32
47
|
import {
|
|
33
48
|
FileMetadataCache,
|
|
@@ -36,6 +51,7 @@ import {
|
|
|
36
51
|
import { UserActionCoordinator } from "./helpers/user-actions.js"
|
|
37
52
|
import { validateIncomingChange } from "./helpers/sync-validator.js"
|
|
38
53
|
import { findOrCreateProjectDir } from "./utils/project.js"
|
|
54
|
+
import { pluralize, shortProjectHash } from "@code-link/shared"
|
|
39
55
|
|
|
40
56
|
/**
|
|
41
57
|
* Explicit sync lifecycle modes
|
|
@@ -145,7 +161,12 @@ type Effect =
|
|
|
145
161
|
| { type: "SEND_MESSAGE"; payload: OutgoingMessage }
|
|
146
162
|
| { type: "LIST_LOCAL_FILES" }
|
|
147
163
|
| { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
|
|
148
|
-
| {
|
|
164
|
+
| {
|
|
165
|
+
type: "WRITE_FILES"
|
|
166
|
+
files: FileInfo[]
|
|
167
|
+
silent?: boolean
|
|
168
|
+
skipEcho?: boolean // when true, skip writes that match hashTracker (inbound echo)
|
|
169
|
+
}
|
|
149
170
|
| { type: "DELETE_LOCAL_FILES"; names: string[] }
|
|
150
171
|
| { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
|
|
151
172
|
| { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
|
|
@@ -170,6 +191,12 @@ type Effect =
|
|
|
170
191
|
requireConfirmation: boolean
|
|
171
192
|
}
|
|
172
193
|
| { type: "PERSIST_STATE" }
|
|
194
|
+
| {
|
|
195
|
+
type: "SYNC_COMPLETE"
|
|
196
|
+
totalCount: number
|
|
197
|
+
updatedCount: number
|
|
198
|
+
unchangedCount: number
|
|
199
|
+
}
|
|
173
200
|
| { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
|
|
174
201
|
|
|
175
202
|
/** Log helper */
|
|
@@ -177,6 +204,20 @@ function log(level: "info" | "debug" | "warn", message: string): Effect {
|
|
|
177
204
|
return { type: "LOG", level, message }
|
|
178
205
|
}
|
|
179
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Filter out files whose content matches the last remembered hash.
|
|
209
|
+
* Used to skip inbound echoes of our own local sends.
|
|
210
|
+
*/
|
|
211
|
+
export function filterEchoedFiles(
|
|
212
|
+
files: FileInfo[],
|
|
213
|
+
hashTracker: ReturnType<typeof createHashTracker>
|
|
214
|
+
): FileInfo[] {
|
|
215
|
+
return files.filter((file) => {
|
|
216
|
+
if (file.content === undefined) return true
|
|
217
|
+
return !hashTracker.shouldSkip(file.name, file.content)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
180
221
|
/**
|
|
181
222
|
* Pure state transition function
|
|
182
223
|
* Takes current state + event, returns new state + effects to execute
|
|
@@ -214,7 +255,7 @@ function transition(
|
|
|
214
255
|
|
|
215
256
|
case "FILE_SYNCED": {
|
|
216
257
|
// Remote confirms they received our local change
|
|
217
|
-
effects.push(log("
|
|
258
|
+
effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
|
|
218
259
|
type: "UPDATE_FILE_METADATA",
|
|
219
260
|
fileName: event.fileName,
|
|
220
261
|
remoteModifiedAt: event.remoteModifiedAt,
|
|
@@ -226,7 +267,7 @@ function transition(
|
|
|
226
267
|
case "DISCONNECT": {
|
|
227
268
|
effects.push(
|
|
228
269
|
{ type: "PERSIST_STATE" },
|
|
229
|
-
log("
|
|
270
|
+
log("debug", "Disconnected, persisting state")
|
|
230
271
|
)
|
|
231
272
|
|
|
232
273
|
if (state.mode === "conflict_resolution") {
|
|
@@ -261,7 +302,7 @@ function transition(
|
|
|
261
302
|
return { state, effects }
|
|
262
303
|
}
|
|
263
304
|
|
|
264
|
-
effects.push(log("
|
|
305
|
+
effects.push(log("debug", "Plugin requested file list"), {
|
|
265
306
|
type: "LIST_LOCAL_FILES",
|
|
266
307
|
})
|
|
267
308
|
|
|
@@ -277,7 +318,7 @@ function transition(
|
|
|
277
318
|
}
|
|
278
319
|
|
|
279
320
|
effects.push(
|
|
280
|
-
log("
|
|
321
|
+
log("debug", `Received file list: ${event.files.length} files`)
|
|
281
322
|
)
|
|
282
323
|
|
|
283
324
|
// Detect conflicts between remote snapshot and local files
|
|
@@ -310,23 +351,24 @@ function transition(
|
|
|
310
351
|
|
|
311
352
|
const { conflicts, safeWrites, localOnly } = event
|
|
312
353
|
|
|
313
|
-
// detectConflicts
|
|
354
|
+
// detectConflicts returns:
|
|
314
355
|
// - safeWrites = files we can apply (remote-only or local unchanged)
|
|
315
|
-
// - conflicts = files that need manual resolution (
|
|
356
|
+
// - conflicts = files that need manual resolution (content or deletion conflicts)
|
|
316
357
|
// - localOnly = files to upload
|
|
358
|
+
// (unchanged files have metadata recorded in DETECT_CONFLICTS executor)
|
|
317
359
|
|
|
318
360
|
// Apply safe writes
|
|
319
361
|
if (safeWrites.length > 0) {
|
|
320
|
-
effects.push(
|
|
321
|
-
|
|
322
|
-
files: safeWrites,
|
|
323
|
-
|
|
362
|
+
effects.push(
|
|
363
|
+
log("debug", `Applying ${safeWrites.length} safe writes`),
|
|
364
|
+
{ type: "WRITE_FILES", files: safeWrites, silent: true }
|
|
365
|
+
)
|
|
324
366
|
}
|
|
325
367
|
|
|
326
368
|
// Upload local-only files
|
|
327
369
|
if (localOnly.length > 0) {
|
|
328
370
|
effects.push(
|
|
329
|
-
log("
|
|
371
|
+
log("debug", `Uploading ${localOnly.length} local-only files`)
|
|
330
372
|
)
|
|
331
373
|
for (const file of localOnly) {
|
|
332
374
|
effects.push({
|
|
@@ -340,16 +382,12 @@ function transition(
|
|
|
340
382
|
}
|
|
341
383
|
}
|
|
342
384
|
|
|
343
|
-
// If
|
|
385
|
+
// If conflicts remain, request remote version data before surfacing to user
|
|
344
386
|
if (conflicts.length > 0) {
|
|
345
387
|
effects.push(
|
|
346
388
|
log(
|
|
347
|
-
"
|
|
348
|
-
`${conflicts.length}
|
|
349
|
-
),
|
|
350
|
-
log(
|
|
351
|
-
"info",
|
|
352
|
-
"[CONFLICTS] Requesting remote version data from plugin..."
|
|
389
|
+
"debug",
|
|
390
|
+
`${pluralize(conflicts.length, "conflict")} require version check`
|
|
353
391
|
),
|
|
354
392
|
{ type: "REQUEST_CONFLICT_VERSIONS", conflicts }
|
|
355
393
|
)
|
|
@@ -365,9 +403,19 @@ function transition(
|
|
|
365
403
|
}
|
|
366
404
|
|
|
367
405
|
// No conflicts - transition to watching
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
406
|
+
const remoteTotal = state.queuedDiffs.length
|
|
407
|
+
const totalCount = remoteTotal + localOnly.length
|
|
408
|
+
const updatedCount = safeWrites.length + localOnly.length
|
|
409
|
+
const unchangedCount = Math.max(0, remoteTotal - safeWrites.length)
|
|
410
|
+
effects.push(
|
|
411
|
+
{ type: "PERSIST_STATE" },
|
|
412
|
+
{
|
|
413
|
+
type: "SYNC_COMPLETE",
|
|
414
|
+
totalCount,
|
|
415
|
+
updatedCount,
|
|
416
|
+
unchangedCount,
|
|
417
|
+
}
|
|
418
|
+
)
|
|
371
419
|
|
|
372
420
|
return {
|
|
373
421
|
state: {
|
|
@@ -415,9 +463,10 @@ function transition(
|
|
|
415
463
|
}
|
|
416
464
|
|
|
417
465
|
// Apply the change
|
|
418
|
-
effects.push(log("
|
|
466
|
+
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
419
467
|
type: "WRITE_FILES",
|
|
420
468
|
files: [event.file],
|
|
469
|
+
skipEcho: true,
|
|
421
470
|
})
|
|
422
471
|
|
|
423
472
|
return { state, effects }
|
|
@@ -435,7 +484,7 @@ function transition(
|
|
|
435
484
|
// Remote deletes should always be applied immediately
|
|
436
485
|
// (the file is already gone from Framer)
|
|
437
486
|
effects.push(
|
|
438
|
-
log("
|
|
487
|
+
log("debug", `Remote delete applied: ${event.fileName}`),
|
|
439
488
|
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
440
489
|
{ type: "PERSIST_STATE" }
|
|
441
490
|
)
|
|
@@ -446,7 +495,7 @@ function transition(
|
|
|
446
495
|
case "REMOTE_DELETE_CONFIRMED": {
|
|
447
496
|
// User confirmed the delete - apply it
|
|
448
497
|
effects.push(
|
|
449
|
-
log("
|
|
498
|
+
log("debug", `Delete confirmed: ${event.fileName}`),
|
|
450
499
|
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
451
500
|
{ type: "PERSIST_STATE" }
|
|
452
501
|
)
|
|
@@ -456,7 +505,7 @@ function transition(
|
|
|
456
505
|
|
|
457
506
|
case "REMOTE_DELETE_CANCELLED": {
|
|
458
507
|
// User cancelled - restore the file
|
|
459
|
-
effects.push(log("
|
|
508
|
+
effects.push(log("debug", `Delete cancelled: ${event.fileName}`))
|
|
460
509
|
effects.push({
|
|
461
510
|
type: "WRITE_FILES",
|
|
462
511
|
files: [
|
|
@@ -485,41 +534,74 @@ function transition(
|
|
|
485
534
|
|
|
486
535
|
// User picked one resolution for ALL conflicts
|
|
487
536
|
if (event.resolution === "remote") {
|
|
488
|
-
// Apply all remote versions
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
537
|
+
// Apply all remote versions (or delete locally if remote is null)
|
|
538
|
+
for (const conflict of state.pendingConflicts) {
|
|
539
|
+
if (conflict.remoteContent === null) {
|
|
540
|
+
// Remote deleted this file - delete locally
|
|
541
|
+
effects.push({
|
|
542
|
+
type: "DELETE_LOCAL_FILES",
|
|
543
|
+
names: [conflict.fileName],
|
|
544
|
+
})
|
|
545
|
+
} else {
|
|
546
|
+
effects.push({
|
|
547
|
+
type: "WRITE_FILES",
|
|
548
|
+
files: [
|
|
549
|
+
{
|
|
550
|
+
name: conflict.fileName,
|
|
551
|
+
content: conflict.remoteContent,
|
|
552
|
+
modifiedAt: conflict.remoteModifiedAt,
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
}
|
|
495
558
|
effects.push(
|
|
496
|
-
log(
|
|
497
|
-
|
|
559
|
+
log(
|
|
560
|
+
"debug",
|
|
561
|
+
`Applied ${state.pendingConflicts.length} remote versions`
|
|
562
|
+
)
|
|
498
563
|
)
|
|
499
564
|
} else {
|
|
500
|
-
// Send all local versions
|
|
565
|
+
// Send all local versions (or delete from Framer if local is null)
|
|
566
|
+
for (const conflict of state.pendingConflicts) {
|
|
567
|
+
if (conflict.localContent === null) {
|
|
568
|
+
// Local deleted this file - delete from Framer
|
|
569
|
+
effects.push({
|
|
570
|
+
type: "SEND_MESSAGE",
|
|
571
|
+
payload: {
|
|
572
|
+
type: "file-delete",
|
|
573
|
+
fileNames: [conflict.fileName],
|
|
574
|
+
},
|
|
575
|
+
})
|
|
576
|
+
} else {
|
|
577
|
+
effects.push({
|
|
578
|
+
type: "SEND_MESSAGE",
|
|
579
|
+
payload: {
|
|
580
|
+
type: "file-change",
|
|
581
|
+
fileName: conflict.fileName,
|
|
582
|
+
content: conflict.localContent,
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
}
|
|
501
587
|
effects.push(
|
|
502
588
|
log(
|
|
503
|
-
"
|
|
504
|
-
`
|
|
589
|
+
"debug",
|
|
590
|
+
`Applied ${state.pendingConflicts.length} local versions`
|
|
505
591
|
)
|
|
506
592
|
)
|
|
507
|
-
for (const conflict of state.pendingConflicts) {
|
|
508
|
-
effects.push({
|
|
509
|
-
type: "SEND_MESSAGE",
|
|
510
|
-
payload: {
|
|
511
|
-
type: "file-change",
|
|
512
|
-
fileName: conflict.fileName,
|
|
513
|
-
content: conflict.localContent,
|
|
514
|
-
},
|
|
515
|
-
})
|
|
516
|
-
}
|
|
517
593
|
}
|
|
518
594
|
|
|
519
595
|
// All conflicts resolved - transition to watching
|
|
520
|
-
effects.push(
|
|
521
|
-
type: "PERSIST_STATE",
|
|
522
|
-
|
|
596
|
+
effects.push(
|
|
597
|
+
{ type: "PERSIST_STATE" },
|
|
598
|
+
{
|
|
599
|
+
type: "SYNC_COMPLETE",
|
|
600
|
+
totalCount: state.pendingConflicts.length,
|
|
601
|
+
updatedCount: state.pendingConflicts.length,
|
|
602
|
+
unchangedCount: 0,
|
|
603
|
+
}
|
|
604
|
+
)
|
|
523
605
|
|
|
524
606
|
const { pendingConflicts: _discarded, ...rest } = state
|
|
525
607
|
return {
|
|
@@ -556,7 +638,6 @@ function transition(
|
|
|
556
638
|
return { state, effects }
|
|
557
639
|
}
|
|
558
640
|
|
|
559
|
-
effects.push(log("info", `Local change detected: ${relativePath}`))
|
|
560
641
|
effects.push({
|
|
561
642
|
type: "SEND_LOCAL_CHANGE",
|
|
562
643
|
fileName: relativePath,
|
|
@@ -566,7 +647,7 @@ function transition(
|
|
|
566
647
|
}
|
|
567
648
|
|
|
568
649
|
case "delete": {
|
|
569
|
-
effects.push(log("
|
|
650
|
+
effects.push(log("debug", `Local delete detected: ${relativePath}`), {
|
|
570
651
|
type: "REQUEST_LOCAL_DELETE_DECISION",
|
|
571
652
|
fileName: relativePath,
|
|
572
653
|
requireConfirmation: true, // Will be overridden by config in effect
|
|
@@ -595,41 +676,64 @@ function transition(
|
|
|
595
676
|
if (autoResolvedLocal.length > 0) {
|
|
596
677
|
effects.push(
|
|
597
678
|
log(
|
|
598
|
-
"
|
|
599
|
-
`
|
|
679
|
+
"debug",
|
|
680
|
+
`Auto-resolved ${autoResolvedLocal.length} local changes`
|
|
600
681
|
)
|
|
601
682
|
)
|
|
602
683
|
for (const conflict of autoResolvedLocal) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
684
|
+
if (conflict.localContent === null) {
|
|
685
|
+
// Local deleted - delete from Framer
|
|
686
|
+
effects.push({
|
|
687
|
+
type: "SEND_MESSAGE",
|
|
688
|
+
payload: {
|
|
689
|
+
type: "file-delete",
|
|
690
|
+
fileNames: [conflict.fileName],
|
|
691
|
+
},
|
|
692
|
+
})
|
|
693
|
+
} else {
|
|
694
|
+
effects.push({
|
|
695
|
+
type: "SEND_LOCAL_CHANGE",
|
|
696
|
+
fileName: conflict.fileName,
|
|
697
|
+
content: conflict.localContent,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
608
700
|
}
|
|
609
701
|
}
|
|
610
702
|
|
|
611
703
|
if (autoResolvedRemote.length > 0) {
|
|
612
704
|
effects.push(
|
|
613
705
|
log(
|
|
614
|
-
"
|
|
615
|
-
`
|
|
616
|
-
)
|
|
617
|
-
{
|
|
618
|
-
type: "WRITE_FILES",
|
|
619
|
-
files: autoResolvedRemote.map((conflict) => ({
|
|
620
|
-
name: conflict.fileName,
|
|
621
|
-
content: conflict.remoteContent,
|
|
622
|
-
modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
|
|
623
|
-
})),
|
|
624
|
-
}
|
|
706
|
+
"debug",
|
|
707
|
+
`Auto-resolved ${autoResolvedRemote.length} remote changes`
|
|
708
|
+
)
|
|
625
709
|
)
|
|
710
|
+
for (const conflict of autoResolvedRemote) {
|
|
711
|
+
if (conflict.remoteContent === null) {
|
|
712
|
+
// Remote deleted - delete locally
|
|
713
|
+
effects.push({
|
|
714
|
+
type: "DELETE_LOCAL_FILES",
|
|
715
|
+
names: [conflict.fileName],
|
|
716
|
+
})
|
|
717
|
+
} else {
|
|
718
|
+
effects.push({
|
|
719
|
+
type: "WRITE_FILES",
|
|
720
|
+
files: [
|
|
721
|
+
{
|
|
722
|
+
name: conflict.fileName,
|
|
723
|
+
content: conflict.remoteContent,
|
|
724
|
+
modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
}
|
|
626
730
|
}
|
|
627
731
|
|
|
628
732
|
if (remainingConflicts.length > 0) {
|
|
629
733
|
effects.push(
|
|
630
734
|
log(
|
|
631
735
|
"warn",
|
|
632
|
-
|
|
736
|
+
`${pluralize(remainingConflicts.length, "conflict")} require resolution`
|
|
633
737
|
),
|
|
634
738
|
{ type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts }
|
|
635
739
|
)
|
|
@@ -643,9 +747,16 @@ function transition(
|
|
|
643
747
|
}
|
|
644
748
|
}
|
|
645
749
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
750
|
+
const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length
|
|
751
|
+
effects.push(
|
|
752
|
+
{ type: "PERSIST_STATE" },
|
|
753
|
+
{
|
|
754
|
+
type: "SYNC_COMPLETE",
|
|
755
|
+
totalCount: resolvedCount,
|
|
756
|
+
updatedCount: resolvedCount,
|
|
757
|
+
unchangedCount: 0,
|
|
758
|
+
}
|
|
759
|
+
)
|
|
649
760
|
|
|
650
761
|
const { pendingConflicts: _discarded, ...rest } = state
|
|
651
762
|
return {
|
|
@@ -701,7 +812,7 @@ async function executeEffect(
|
|
|
701
812
|
config.explicitDir
|
|
702
813
|
)
|
|
703
814
|
config.filesDir = `${config.projectDir}/files`
|
|
704
|
-
|
|
815
|
+
debug(`Files directory: ${config.filesDir}`)
|
|
705
816
|
|
|
706
817
|
// Create files directory
|
|
707
818
|
await fs.mkdir(config.filesDir, { recursive: true })
|
|
@@ -712,7 +823,7 @@ async function executeEffect(
|
|
|
712
823
|
case "LOAD_PERSISTED_STATE": {
|
|
713
824
|
if (config.projectDir) {
|
|
714
825
|
await fileMetadataCache.initialize(config.projectDir)
|
|
715
|
-
|
|
826
|
+
debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
|
|
716
827
|
}
|
|
717
828
|
return []
|
|
718
829
|
}
|
|
@@ -741,12 +852,22 @@ async function executeEffect(
|
|
|
741
852
|
}
|
|
742
853
|
|
|
743
854
|
// Use existing helper to detect conflicts
|
|
744
|
-
const { conflicts, writes, localOnly } = await detectConflicts(
|
|
855
|
+
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(
|
|
745
856
|
effect.remoteFiles,
|
|
746
857
|
config.filesDir,
|
|
747
858
|
{ persistedState: fileMetadataCache.getPersistedState() }
|
|
748
859
|
)
|
|
749
860
|
|
|
861
|
+
// Record metadata for unchanged files so watcher add events get skipped
|
|
862
|
+
// (chokidar ignoreInitial=false fires late adds that would otherwise re-upload)
|
|
863
|
+
for (const file of unchanged) {
|
|
864
|
+
fileMetadataCache.recordRemoteWrite(
|
|
865
|
+
file.name,
|
|
866
|
+
file.content,
|
|
867
|
+
file.modifiedAt ?? Date.now()
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
|
|
750
871
|
// Return CONFLICTS_DETECTED event to continue the flow
|
|
751
872
|
return [
|
|
752
873
|
{
|
|
@@ -772,13 +893,32 @@ async function executeEffect(
|
|
|
772
893
|
|
|
773
894
|
case "WRITE_FILES": {
|
|
774
895
|
if (config.filesDir) {
|
|
896
|
+
// skipEcho is opt-in: some callers still need side-effects (metadata/logs)
|
|
897
|
+
// even when content matches the last hash tracked in-memory.
|
|
898
|
+
const filesToWrite =
|
|
899
|
+
effect.skipEcho === true
|
|
900
|
+
? filterEchoedFiles(effect.files, hashTracker)
|
|
901
|
+
: effect.files
|
|
902
|
+
|
|
903
|
+
if (effect.skipEcho && filesToWrite.length !== effect.files.length) {
|
|
904
|
+
const skipped = effect.files.length - filesToWrite.length
|
|
905
|
+
debug(`Skipped ${pluralize(skipped, "echoed change")}`)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (filesToWrite.length === 0) {
|
|
909
|
+
return []
|
|
910
|
+
}
|
|
911
|
+
|
|
775
912
|
await writeRemoteFiles(
|
|
776
|
-
|
|
913
|
+
filesToWrite,
|
|
777
914
|
config.filesDir,
|
|
778
915
|
hashTracker,
|
|
779
916
|
installer ?? undefined
|
|
780
917
|
)
|
|
781
|
-
for (const file of
|
|
918
|
+
for (const file of filesToWrite) {
|
|
919
|
+
if (!effect.silent) {
|
|
920
|
+
fileDown(file.name)
|
|
921
|
+
}
|
|
782
922
|
const remoteTimestamp = file.modifiedAt ?? Date.now()
|
|
783
923
|
fileMetadataCache.recordRemoteWrite(
|
|
784
924
|
file.name,
|
|
@@ -794,6 +934,7 @@ async function executeEffect(
|
|
|
794
934
|
if (config.filesDir) {
|
|
795
935
|
for (const fileName of effect.names) {
|
|
796
936
|
await deleteLocalFile(fileName, config.filesDir, hashTracker)
|
|
937
|
+
fileDelete(fileName)
|
|
797
938
|
fileMetadataCache.recordDelete(fileName)
|
|
798
939
|
}
|
|
799
940
|
}
|
|
@@ -824,8 +965,8 @@ async function executeEffect(
|
|
|
824
965
|
}
|
|
825
966
|
})
|
|
826
967
|
|
|
827
|
-
|
|
828
|
-
`
|
|
968
|
+
debug(
|
|
969
|
+
`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`
|
|
829
970
|
)
|
|
830
971
|
|
|
831
972
|
await sendMessage(syncState.socket, {
|
|
@@ -873,10 +1014,24 @@ async function executeEffect(
|
|
|
873
1014
|
}
|
|
874
1015
|
|
|
875
1016
|
case "SEND_LOCAL_CHANGE": {
|
|
1017
|
+
const contentHash = hashFileContent(effect.content)
|
|
1018
|
+
const metadata = fileMetadataCache.get(effect.fileName)
|
|
1019
|
+
|
|
1020
|
+
// Skip if file matches last confirmed remote content
|
|
1021
|
+
if (metadata?.lastSyncedHash === contentHash) {
|
|
1022
|
+
debug(
|
|
1023
|
+
`Skipping local change for ${effect.fileName}: matches last synced content`
|
|
1024
|
+
)
|
|
1025
|
+
return []
|
|
1026
|
+
}
|
|
1027
|
+
|
|
876
1028
|
// Echo prevention: skip if we just wrote this exact content
|
|
877
1029
|
if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
|
|
878
1030
|
return []
|
|
879
1031
|
}
|
|
1032
|
+
|
|
1033
|
+
debug(`Local change detected: ${effect.fileName}`)
|
|
1034
|
+
|
|
880
1035
|
try {
|
|
881
1036
|
// Send change to plugin
|
|
882
1037
|
if (syncState.socket) {
|
|
@@ -885,6 +1040,7 @@ async function executeEffect(
|
|
|
885
1040
|
fileName: effect.fileName,
|
|
886
1041
|
content: effect.content,
|
|
887
1042
|
})
|
|
1043
|
+
fileUp(effect.fileName)
|
|
888
1044
|
}
|
|
889
1045
|
|
|
890
1046
|
// Only remember hash after successful send (prevents re-sending on failure)
|
|
@@ -895,10 +1051,7 @@ async function executeEffect(
|
|
|
895
1051
|
installer.process(effect.fileName, effect.content)
|
|
896
1052
|
}
|
|
897
1053
|
} catch (err) {
|
|
898
|
-
|
|
899
|
-
`Failed to push change for ${effect.fileName}, will re-sync on next diff:`,
|
|
900
|
-
err
|
|
901
|
-
)
|
|
1054
|
+
warn(`Failed to push ${effect.fileName}`)
|
|
902
1055
|
}
|
|
903
1056
|
|
|
904
1057
|
return []
|
|
@@ -946,6 +1099,33 @@ async function executeEffect(
|
|
|
946
1099
|
return []
|
|
947
1100
|
}
|
|
948
1101
|
|
|
1102
|
+
case "SYNC_COMPLETE": {
|
|
1103
|
+
const wasDisconnected = wasRecentlyDisconnected()
|
|
1104
|
+
|
|
1105
|
+
// Notify plugin that sync is complete
|
|
1106
|
+
if (syncState.socket) {
|
|
1107
|
+
await sendMessage(syncState.socket, { type: "sync-complete" })
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (wasDisconnected) {
|
|
1111
|
+
// Only show reconnect message if we actually showed the disconnect notice
|
|
1112
|
+
if (didShowDisconnect()) {
|
|
1113
|
+
success(
|
|
1114
|
+
`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
|
|
1115
|
+
)
|
|
1116
|
+
status("Watching for changes...")
|
|
1117
|
+
}
|
|
1118
|
+
resetDisconnectState()
|
|
1119
|
+
return []
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
success(
|
|
1123
|
+
`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
|
|
1124
|
+
)
|
|
1125
|
+
status("Watching for changes...")
|
|
1126
|
+
return []
|
|
1127
|
+
}
|
|
1128
|
+
|
|
949
1129
|
case "LOG": {
|
|
950
1130
|
const logFn =
|
|
951
1131
|
effect.level === "info" ? info : effect.level === "warn" ? warn : debug
|
|
@@ -959,9 +1139,7 @@ async function executeEffect(
|
|
|
959
1139
|
* Starts the sync controller with the given configuration
|
|
960
1140
|
*/
|
|
961
1141
|
export async function start(config: Config): Promise<void> {
|
|
962
|
-
|
|
963
|
-
info(`Project: ${config.projectHash}`)
|
|
964
|
-
info(`Port: ${config.port} (auto-selected from project hash)`)
|
|
1142
|
+
status("Waiting for Plugin connection...")
|
|
965
1143
|
|
|
966
1144
|
const hashTracker = createHashTracker()
|
|
967
1145
|
const fileMetadataCache = new FileMetadataCache()
|
|
@@ -982,7 +1160,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
982
1160
|
// Process events through state machine and execute effects recursively
|
|
983
1161
|
async function processEvent(event: SyncEvent) {
|
|
984
1162
|
const socketState = syncState.socket?.readyState
|
|
985
|
-
|
|
1163
|
+
debug(
|
|
986
1164
|
`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`
|
|
987
1165
|
)
|
|
988
1166
|
|
|
@@ -990,7 +1168,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
990
1168
|
syncState = result.state
|
|
991
1169
|
|
|
992
1170
|
if (result.effects.length > 0) {
|
|
993
|
-
|
|
1171
|
+
debug(
|
|
994
1172
|
`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`
|
|
995
1173
|
)
|
|
996
1174
|
}
|
|
@@ -1000,7 +1178,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
1000
1178
|
// Check socket state before each effect
|
|
1001
1179
|
const currentSocketState = syncState.socket?.readyState
|
|
1002
1180
|
if (currentSocketState !== undefined && currentSocketState !== 1) {
|
|
1003
|
-
|
|
1181
|
+
debug(
|
|
1004
1182
|
`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`
|
|
1005
1183
|
)
|
|
1006
1184
|
}
|
|
@@ -1026,13 +1204,14 @@ export async function start(config: Config): Promise<void> {
|
|
|
1026
1204
|
|
|
1027
1205
|
// Handle initial handshake
|
|
1028
1206
|
connection.on("handshake", async (client: WebSocket, message) => {
|
|
1029
|
-
|
|
1030
|
-
info(`Project: ${message.projectName} (${message.projectId})`)
|
|
1207
|
+
debug(`Received handshake: ${message.projectName} (${message.projectId})`)
|
|
1031
1208
|
|
|
1032
|
-
// Validate project hash
|
|
1033
|
-
|
|
1209
|
+
// Validate project hash (normalize both to short hash for comparison)
|
|
1210
|
+
const expectedShort = shortProjectHash(config.projectHash)
|
|
1211
|
+
const receivedShort = shortProjectHash(message.projectId)
|
|
1212
|
+
if (receivedShort !== expectedShort) {
|
|
1034
1213
|
warn(
|
|
1035
|
-
`Project ID mismatch: expected ${
|
|
1214
|
+
`Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`
|
|
1036
1215
|
)
|
|
1037
1216
|
client.close()
|
|
1038
1217
|
return
|
|
@@ -1056,7 +1235,15 @@ export async function start(config: Config): Promise<void> {
|
|
|
1056
1235
|
startWatcher()
|
|
1057
1236
|
}
|
|
1058
1237
|
|
|
1059
|
-
|
|
1238
|
+
// Cancel any pending disconnect message (fast reconnect)
|
|
1239
|
+
cancelDisconnectMessage()
|
|
1240
|
+
|
|
1241
|
+
// Only show "Connected" on initial connection, not reconnects
|
|
1242
|
+
// Reconnect confirmation happens in SYNC_COMPLETE
|
|
1243
|
+
const wasDisconnected = wasRecentlyDisconnected()
|
|
1244
|
+
if (!wasDisconnected && !didShowDisconnect()) {
|
|
1245
|
+
success(`Connected to ${message.projectName}`)
|
|
1246
|
+
}
|
|
1060
1247
|
})
|
|
1061
1248
|
|
|
1062
1249
|
// Message Handler
|
|
@@ -1080,8 +1267,8 @@ export async function start(config: Config): Promise<void> {
|
|
|
1080
1267
|
(sum, f) => sum + (f.content?.length ?? 0),
|
|
1081
1268
|
0
|
|
1082
1269
|
)
|
|
1083
|
-
|
|
1084
|
-
`
|
|
1270
|
+
debug(
|
|
1271
|
+
`Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`
|
|
1085
1272
|
)
|
|
1086
1273
|
event = { type: "FILE_LIST", files: message.files }
|
|
1087
1274
|
break
|
|
@@ -1188,10 +1375,12 @@ export async function start(config: Config): Promise<void> {
|
|
|
1188
1375
|
})
|
|
1189
1376
|
|
|
1190
1377
|
connection.on("disconnect", async () => {
|
|
1191
|
-
|
|
1378
|
+
// Schedule disconnect message with delay - if reconnect happens quickly, we skip it
|
|
1379
|
+
scheduleDisconnectMessage(() => {
|
|
1380
|
+
status("Disconnected, waiting to reconnect...")
|
|
1381
|
+
})
|
|
1192
1382
|
await processEvent({ type: "DISCONNECT" })
|
|
1193
1383
|
userActions.cleanup()
|
|
1194
|
-
info("Will perform full diff on reconnect")
|
|
1195
1384
|
})
|
|
1196
1385
|
|
|
1197
1386
|
connection.on("error", (err) => {
|
|
@@ -1212,14 +1401,12 @@ export async function start(config: Config): Promise<void> {
|
|
|
1212
1401
|
})
|
|
1213
1402
|
}
|
|
1214
1403
|
|
|
1215
|
-
// Lifecycle
|
|
1216
|
-
|
|
1217
|
-
info("✓ Controller initialized and ready")
|
|
1218
|
-
info(`Waiting for plugin connection on port ${config.port}...`)
|
|
1404
|
+
// Lifecycle - no additional startup messages needed (banner shown in index.ts)
|
|
1219
1405
|
|
|
1220
1406
|
// Graceful shutdown
|
|
1221
1407
|
process.on("SIGINT", async () => {
|
|
1222
|
-
|
|
1408
|
+
console.log() // newline after ^C
|
|
1409
|
+
status("Shutting down...")
|
|
1223
1410
|
if (watcher) {
|
|
1224
1411
|
await watcher.close()
|
|
1225
1412
|
}
|