framer-code-link 0.1.3 → 0.1.4
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/dist/index.js +551 -144
- package/package.json +12 -12
- package/src/controller.test.ts +22 -137
- package/src/controller.ts +242 -106
- package/src/helpers/connection.ts +10 -10
- package/src/helpers/files.ts +99 -37
- package/src/helpers/installer.ts +11 -11
- package/src/helpers/user-actions.ts +7 -11
- package/src/helpers/watcher.ts +4 -9
- package/src/index.ts +7 -4
- package/src/types.ts +4 -2
- package/src/utils/{hashing.ts → hash-tracker.ts} +1 -17
- package/src/utils/logging.ts +191 -6
- package/src/utils/project.ts +15 -8
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,7 @@ type Effect =
|
|
|
145
161
|
| { type: "SEND_MESSAGE"; payload: OutgoingMessage }
|
|
146
162
|
| { type: "LIST_LOCAL_FILES" }
|
|
147
163
|
| { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
|
|
148
|
-
| { type: "WRITE_FILES"; files: FileInfo[] }
|
|
164
|
+
| { type: "WRITE_FILES"; files: FileInfo[]; silent?: boolean }
|
|
149
165
|
| { type: "DELETE_LOCAL_FILES"; names: string[] }
|
|
150
166
|
| { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
|
|
151
167
|
| { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
|
|
@@ -170,6 +186,12 @@ type Effect =
|
|
|
170
186
|
requireConfirmation: boolean
|
|
171
187
|
}
|
|
172
188
|
| { type: "PERSIST_STATE" }
|
|
189
|
+
| {
|
|
190
|
+
type: "SYNC_COMPLETE"
|
|
191
|
+
totalCount: number
|
|
192
|
+
updatedCount: number
|
|
193
|
+
unchangedCount: number
|
|
194
|
+
}
|
|
173
195
|
| { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
|
|
174
196
|
|
|
175
197
|
/** Log helper */
|
|
@@ -214,7 +236,7 @@ function transition(
|
|
|
214
236
|
|
|
215
237
|
case "FILE_SYNCED": {
|
|
216
238
|
// Remote confirms they received our local change
|
|
217
|
-
effects.push(log("
|
|
239
|
+
effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
|
|
218
240
|
type: "UPDATE_FILE_METADATA",
|
|
219
241
|
fileName: event.fileName,
|
|
220
242
|
remoteModifiedAt: event.remoteModifiedAt,
|
|
@@ -226,7 +248,7 @@ function transition(
|
|
|
226
248
|
case "DISCONNECT": {
|
|
227
249
|
effects.push(
|
|
228
250
|
{ type: "PERSIST_STATE" },
|
|
229
|
-
log("
|
|
251
|
+
log("debug", "Disconnected, persisting state")
|
|
230
252
|
)
|
|
231
253
|
|
|
232
254
|
if (state.mode === "conflict_resolution") {
|
|
@@ -261,7 +283,7 @@ function transition(
|
|
|
261
283
|
return { state, effects }
|
|
262
284
|
}
|
|
263
285
|
|
|
264
|
-
effects.push(log("
|
|
286
|
+
effects.push(log("debug", "Plugin requested file list"), {
|
|
265
287
|
type: "LIST_LOCAL_FILES",
|
|
266
288
|
})
|
|
267
289
|
|
|
@@ -277,7 +299,7 @@ function transition(
|
|
|
277
299
|
}
|
|
278
300
|
|
|
279
301
|
effects.push(
|
|
280
|
-
log("
|
|
302
|
+
log("debug", `Received file list: ${event.files.length} files`)
|
|
281
303
|
)
|
|
282
304
|
|
|
283
305
|
// Detect conflicts between remote snapshot and local files
|
|
@@ -310,23 +332,23 @@ function transition(
|
|
|
310
332
|
|
|
311
333
|
const { conflicts, safeWrites, localOnly } = event
|
|
312
334
|
|
|
313
|
-
// detectConflicts
|
|
335
|
+
// detectConflicts returns:
|
|
314
336
|
// - safeWrites = files we can apply (remote-only or local unchanged)
|
|
315
|
-
// - conflicts = files that need manual resolution (
|
|
337
|
+
// - conflicts = files that need manual resolution (content or deletion conflicts)
|
|
316
338
|
// - localOnly = files to upload
|
|
317
339
|
|
|
318
340
|
// Apply safe writes
|
|
319
341
|
if (safeWrites.length > 0) {
|
|
320
|
-
effects.push(
|
|
321
|
-
|
|
322
|
-
files: safeWrites,
|
|
323
|
-
|
|
342
|
+
effects.push(
|
|
343
|
+
log("debug", `Applying ${safeWrites.length} safe writes`),
|
|
344
|
+
{ type: "WRITE_FILES", files: safeWrites, silent: true }
|
|
345
|
+
)
|
|
324
346
|
}
|
|
325
347
|
|
|
326
348
|
// Upload local-only files
|
|
327
349
|
if (localOnly.length > 0) {
|
|
328
350
|
effects.push(
|
|
329
|
-
log("
|
|
351
|
+
log("debug", `Uploading ${localOnly.length} local-only files`)
|
|
330
352
|
)
|
|
331
353
|
for (const file of localOnly) {
|
|
332
354
|
effects.push({
|
|
@@ -340,16 +362,12 @@ function transition(
|
|
|
340
362
|
}
|
|
341
363
|
}
|
|
342
364
|
|
|
343
|
-
// If
|
|
365
|
+
// If conflicts remain, request remote version data before surfacing to user
|
|
344
366
|
if (conflicts.length > 0) {
|
|
345
367
|
effects.push(
|
|
346
368
|
log(
|
|
347
|
-
"
|
|
348
|
-
`${conflicts.length}
|
|
349
|
-
),
|
|
350
|
-
log(
|
|
351
|
-
"info",
|
|
352
|
-
"[CONFLICTS] Requesting remote version data from plugin..."
|
|
369
|
+
"debug",
|
|
370
|
+
`${pluralize(conflicts.length, "conflict")} require version check`
|
|
353
371
|
),
|
|
354
372
|
{ type: "REQUEST_CONFLICT_VERSIONS", conflicts }
|
|
355
373
|
)
|
|
@@ -365,9 +383,20 @@ function transition(
|
|
|
365
383
|
}
|
|
366
384
|
|
|
367
385
|
// No conflicts - transition to watching
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
)
|
|
371
400
|
|
|
372
401
|
return {
|
|
373
402
|
state: {
|
|
@@ -415,7 +444,7 @@ function transition(
|
|
|
415
444
|
}
|
|
416
445
|
|
|
417
446
|
// Apply the change
|
|
418
|
-
effects.push(log("
|
|
447
|
+
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
419
448
|
type: "WRITE_FILES",
|
|
420
449
|
files: [event.file],
|
|
421
450
|
})
|
|
@@ -435,7 +464,7 @@ function transition(
|
|
|
435
464
|
// Remote deletes should always be applied immediately
|
|
436
465
|
// (the file is already gone from Framer)
|
|
437
466
|
effects.push(
|
|
438
|
-
log("
|
|
467
|
+
log("debug", `Remote delete applied: ${event.fileName}`),
|
|
439
468
|
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
440
469
|
{ type: "PERSIST_STATE" }
|
|
441
470
|
)
|
|
@@ -446,7 +475,7 @@ function transition(
|
|
|
446
475
|
case "REMOTE_DELETE_CONFIRMED": {
|
|
447
476
|
// User confirmed the delete - apply it
|
|
448
477
|
effects.push(
|
|
449
|
-
log("
|
|
478
|
+
log("debug", `Delete confirmed: ${event.fileName}`),
|
|
450
479
|
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
451
480
|
{ type: "PERSIST_STATE" }
|
|
452
481
|
)
|
|
@@ -456,7 +485,7 @@ function transition(
|
|
|
456
485
|
|
|
457
486
|
case "REMOTE_DELETE_CANCELLED": {
|
|
458
487
|
// User cancelled - restore the file
|
|
459
|
-
effects.push(log("
|
|
488
|
+
effects.push(log("debug", `Delete cancelled: ${event.fileName}`))
|
|
460
489
|
effects.push({
|
|
461
490
|
type: "WRITE_FILES",
|
|
462
491
|
files: [
|
|
@@ -485,41 +514,74 @@ function transition(
|
|
|
485
514
|
|
|
486
515
|
// User picked one resolution for ALL conflicts
|
|
487
516
|
if (event.resolution === "remote") {
|
|
488
|
-
// Apply all remote versions
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
495
538
|
effects.push(
|
|
496
|
-
log(
|
|
497
|
-
|
|
539
|
+
log(
|
|
540
|
+
"debug",
|
|
541
|
+
`Applied ${state.pendingConflicts.length} remote versions`
|
|
542
|
+
)
|
|
498
543
|
)
|
|
499
544
|
} else {
|
|
500
|
-
// Send all local versions
|
|
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
|
+
}
|
|
501
567
|
effects.push(
|
|
502
568
|
log(
|
|
503
|
-
"
|
|
504
|
-
`
|
|
569
|
+
"debug",
|
|
570
|
+
`Applied ${state.pendingConflicts.length} local versions`
|
|
505
571
|
)
|
|
506
572
|
)
|
|
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
573
|
}
|
|
518
574
|
|
|
519
575
|
// All conflicts resolved - transition to watching
|
|
520
|
-
effects.push(
|
|
521
|
-
type: "PERSIST_STATE",
|
|
522
|
-
|
|
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
|
+
)
|
|
523
585
|
|
|
524
586
|
const { pendingConflicts: _discarded, ...rest } = state
|
|
525
587
|
return {
|
|
@@ -556,7 +618,6 @@ function transition(
|
|
|
556
618
|
return { state, effects }
|
|
557
619
|
}
|
|
558
620
|
|
|
559
|
-
effects.push(log("info", `Local change detected: ${relativePath}`))
|
|
560
621
|
effects.push({
|
|
561
622
|
type: "SEND_LOCAL_CHANGE",
|
|
562
623
|
fileName: relativePath,
|
|
@@ -566,7 +627,7 @@ function transition(
|
|
|
566
627
|
}
|
|
567
628
|
|
|
568
629
|
case "delete": {
|
|
569
|
-
effects.push(log("
|
|
630
|
+
effects.push(log("debug", `Local delete detected: ${relativePath}`), {
|
|
570
631
|
type: "REQUEST_LOCAL_DELETE_DECISION",
|
|
571
632
|
fileName: relativePath,
|
|
572
633
|
requireConfirmation: true, // Will be overridden by config in effect
|
|
@@ -595,41 +656,64 @@ function transition(
|
|
|
595
656
|
if (autoResolvedLocal.length > 0) {
|
|
596
657
|
effects.push(
|
|
597
658
|
log(
|
|
598
|
-
"
|
|
599
|
-
`
|
|
659
|
+
"debug",
|
|
660
|
+
`Auto-resolved ${autoResolvedLocal.length} local changes`
|
|
600
661
|
)
|
|
601
662
|
)
|
|
602
663
|
for (const conflict of autoResolvedLocal) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
+
}
|
|
608
680
|
}
|
|
609
681
|
}
|
|
610
682
|
|
|
611
683
|
if (autoResolvedRemote.length > 0) {
|
|
612
684
|
effects.push(
|
|
613
685
|
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
|
-
}
|
|
686
|
+
"debug",
|
|
687
|
+
`Auto-resolved ${autoResolvedRemote.length} remote changes`
|
|
688
|
+
)
|
|
625
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
|
+
}
|
|
626
710
|
}
|
|
627
711
|
|
|
628
712
|
if (remainingConflicts.length > 0) {
|
|
629
713
|
effects.push(
|
|
630
714
|
log(
|
|
631
715
|
"warn",
|
|
632
|
-
|
|
716
|
+
`${pluralize(remainingConflicts.length, "conflict")} require resolution`
|
|
633
717
|
),
|
|
634
718
|
{ type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts }
|
|
635
719
|
)
|
|
@@ -643,9 +727,16 @@ function transition(
|
|
|
643
727
|
}
|
|
644
728
|
}
|
|
645
729
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
+
)
|
|
649
740
|
|
|
650
741
|
const { pendingConflicts: _discarded, ...rest } = state
|
|
651
742
|
return {
|
|
@@ -701,7 +792,7 @@ async function executeEffect(
|
|
|
701
792
|
config.explicitDir
|
|
702
793
|
)
|
|
703
794
|
config.filesDir = `${config.projectDir}/files`
|
|
704
|
-
|
|
795
|
+
debug(`Files directory: ${config.filesDir}`)
|
|
705
796
|
|
|
706
797
|
// Create files directory
|
|
707
798
|
await fs.mkdir(config.filesDir, { recursive: true })
|
|
@@ -712,7 +803,7 @@ async function executeEffect(
|
|
|
712
803
|
case "LOAD_PERSISTED_STATE": {
|
|
713
804
|
if (config.projectDir) {
|
|
714
805
|
await fileMetadataCache.initialize(config.projectDir)
|
|
715
|
-
|
|
806
|
+
debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
|
|
716
807
|
}
|
|
717
808
|
return []
|
|
718
809
|
}
|
|
@@ -779,6 +870,9 @@ async function executeEffect(
|
|
|
779
870
|
installer ?? undefined
|
|
780
871
|
)
|
|
781
872
|
for (const file of effect.files) {
|
|
873
|
+
if (!effect.silent) {
|
|
874
|
+
fileDown(file.name)
|
|
875
|
+
}
|
|
782
876
|
const remoteTimestamp = file.modifiedAt ?? Date.now()
|
|
783
877
|
fileMetadataCache.recordRemoteWrite(
|
|
784
878
|
file.name,
|
|
@@ -794,6 +888,7 @@ async function executeEffect(
|
|
|
794
888
|
if (config.filesDir) {
|
|
795
889
|
for (const fileName of effect.names) {
|
|
796
890
|
await deleteLocalFile(fileName, config.filesDir, hashTracker)
|
|
891
|
+
fileDelete(fileName)
|
|
797
892
|
fileMetadataCache.recordDelete(fileName)
|
|
798
893
|
}
|
|
799
894
|
}
|
|
@@ -824,8 +919,8 @@ async function executeEffect(
|
|
|
824
919
|
}
|
|
825
920
|
})
|
|
826
921
|
|
|
827
|
-
|
|
828
|
-
`
|
|
922
|
+
debug(
|
|
923
|
+
`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`
|
|
829
924
|
)
|
|
830
925
|
|
|
831
926
|
await sendMessage(syncState.socket, {
|
|
@@ -873,10 +968,24 @@ async function executeEffect(
|
|
|
873
968
|
}
|
|
874
969
|
|
|
875
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
|
+
|
|
876
982
|
// Echo prevention: skip if we just wrote this exact content
|
|
877
983
|
if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
|
|
878
984
|
return []
|
|
879
985
|
}
|
|
986
|
+
|
|
987
|
+
debug(`Local change detected: ${effect.fileName}`)
|
|
988
|
+
|
|
880
989
|
try {
|
|
881
990
|
// Send change to plugin
|
|
882
991
|
if (syncState.socket) {
|
|
@@ -885,6 +994,7 @@ async function executeEffect(
|
|
|
885
994
|
fileName: effect.fileName,
|
|
886
995
|
content: effect.content,
|
|
887
996
|
})
|
|
997
|
+
fileUp(effect.fileName)
|
|
888
998
|
}
|
|
889
999
|
|
|
890
1000
|
// Only remember hash after successful send (prevents re-sending on failure)
|
|
@@ -895,10 +1005,7 @@ async function executeEffect(
|
|
|
895
1005
|
installer.process(effect.fileName, effect.content)
|
|
896
1006
|
}
|
|
897
1007
|
} catch (err) {
|
|
898
|
-
|
|
899
|
-
`Failed to push change for ${effect.fileName}, will re-sync on next diff:`,
|
|
900
|
-
err
|
|
901
|
-
)
|
|
1008
|
+
warn(`Failed to push ${effect.fileName}`)
|
|
902
1009
|
}
|
|
903
1010
|
|
|
904
1011
|
return []
|
|
@@ -946,6 +1053,28 @@ async function executeEffect(
|
|
|
946
1053
|
return []
|
|
947
1054
|
}
|
|
948
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
|
+
|
|
949
1078
|
case "LOG": {
|
|
950
1079
|
const logFn =
|
|
951
1080
|
effect.level === "info" ? info : effect.level === "warn" ? warn : debug
|
|
@@ -959,9 +1088,7 @@ async function executeEffect(
|
|
|
959
1088
|
* Starts the sync controller with the given configuration
|
|
960
1089
|
*/
|
|
961
1090
|
export async function start(config: Config): Promise<void> {
|
|
962
|
-
|
|
963
|
-
info(`Project: ${config.projectHash}`)
|
|
964
|
-
info(`Port: ${config.port} (auto-selected from project hash)`)
|
|
1091
|
+
status("Waiting for Plugin connection...")
|
|
965
1092
|
|
|
966
1093
|
const hashTracker = createHashTracker()
|
|
967
1094
|
const fileMetadataCache = new FileMetadataCache()
|
|
@@ -982,7 +1109,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
982
1109
|
// Process events through state machine and execute effects recursively
|
|
983
1110
|
async function processEvent(event: SyncEvent) {
|
|
984
1111
|
const socketState = syncState.socket?.readyState
|
|
985
|
-
|
|
1112
|
+
debug(
|
|
986
1113
|
`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`
|
|
987
1114
|
)
|
|
988
1115
|
|
|
@@ -990,7 +1117,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
990
1117
|
syncState = result.state
|
|
991
1118
|
|
|
992
1119
|
if (result.effects.length > 0) {
|
|
993
|
-
|
|
1120
|
+
debug(
|
|
994
1121
|
`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`
|
|
995
1122
|
)
|
|
996
1123
|
}
|
|
@@ -1000,7 +1127,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
1000
1127
|
// Check socket state before each effect
|
|
1001
1128
|
const currentSocketState = syncState.socket?.readyState
|
|
1002
1129
|
if (currentSocketState !== undefined && currentSocketState !== 1) {
|
|
1003
|
-
|
|
1130
|
+
debug(
|
|
1004
1131
|
`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`
|
|
1005
1132
|
)
|
|
1006
1133
|
}
|
|
@@ -1026,13 +1153,14 @@ export async function start(config: Config): Promise<void> {
|
|
|
1026
1153
|
|
|
1027
1154
|
// Handle initial handshake
|
|
1028
1155
|
connection.on("handshake", async (client: WebSocket, message) => {
|
|
1029
|
-
|
|
1030
|
-
info(`Project: ${message.projectName} (${message.projectId})`)
|
|
1156
|
+
debug(`Received handshake: ${message.projectName} (${message.projectId})`)
|
|
1031
1157
|
|
|
1032
|
-
// Validate project hash
|
|
1033
|
-
|
|
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) {
|
|
1034
1162
|
warn(
|
|
1035
|
-
`Project ID mismatch: expected ${
|
|
1163
|
+
`Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`
|
|
1036
1164
|
)
|
|
1037
1165
|
client.close()
|
|
1038
1166
|
return
|
|
@@ -1056,7 +1184,15 @@ export async function start(config: Config): Promise<void> {
|
|
|
1056
1184
|
startWatcher()
|
|
1057
1185
|
}
|
|
1058
1186
|
|
|
1059
|
-
|
|
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
|
+
}
|
|
1060
1196
|
})
|
|
1061
1197
|
|
|
1062
1198
|
// Message Handler
|
|
@@ -1080,8 +1216,8 @@ export async function start(config: Config): Promise<void> {
|
|
|
1080
1216
|
(sum, f) => sum + (f.content?.length ?? 0),
|
|
1081
1217
|
0
|
|
1082
1218
|
)
|
|
1083
|
-
|
|
1084
|
-
`
|
|
1219
|
+
debug(
|
|
1220
|
+
`Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`
|
|
1085
1221
|
)
|
|
1086
1222
|
event = { type: "FILE_LIST", files: message.files }
|
|
1087
1223
|
break
|
|
@@ -1188,10 +1324,12 @@ export async function start(config: Config): Promise<void> {
|
|
|
1188
1324
|
})
|
|
1189
1325
|
|
|
1190
1326
|
connection.on("disconnect", async () => {
|
|
1191
|
-
|
|
1327
|
+
// Schedule disconnect message with delay - if reconnect happens quickly, we skip it
|
|
1328
|
+
scheduleDisconnectMessage(() => {
|
|
1329
|
+
status("Disconnected, waiting to reconnect...")
|
|
1330
|
+
})
|
|
1192
1331
|
await processEvent({ type: "DISCONNECT" })
|
|
1193
1332
|
userActions.cleanup()
|
|
1194
|
-
info("Will perform full diff on reconnect")
|
|
1195
1333
|
})
|
|
1196
1334
|
|
|
1197
1335
|
connection.on("error", (err) => {
|
|
@@ -1212,14 +1350,12 @@ export async function start(config: Config): Promise<void> {
|
|
|
1212
1350
|
})
|
|
1213
1351
|
}
|
|
1214
1352
|
|
|
1215
|
-
// Lifecycle
|
|
1216
|
-
|
|
1217
|
-
info("✓ Controller initialized and ready")
|
|
1218
|
-
info(`Waiting for plugin connection on port ${config.port}...`)
|
|
1353
|
+
// Lifecycle - no additional startup messages needed (banner shown in index.ts)
|
|
1219
1354
|
|
|
1220
1355
|
// Graceful shutdown
|
|
1221
1356
|
process.on("SIGINT", async () => {
|
|
1222
|
-
|
|
1357
|
+
console.log() // newline after ^C
|
|
1358
|
+
status("Shutting down...")
|
|
1223
1359
|
if (watcher) {
|
|
1224
1360
|
await watcher.close()
|
|
1225
1361
|
}
|