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/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/hashing.js"
30
- import { info, warn, error, success, debug } from "./utils/logging.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"
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("info", `Remote confirmed sync: ${event.fileName}`), {
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("info", "Disconnected, persisting state")
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("info", "Plugin requested file list"), {
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("info", `Received file list: ${event.files.length} files`)
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 already did auto-resolution:
335
+ // detectConflicts returns:
314
336
  // - safeWrites = files we can apply (remote-only or local unchanged)
315
- // - conflicts = files that need manual resolution (both sides changed)
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(log("info", `Applying ${safeWrites.length} safe writes`), {
321
- type: "WRITE_FILES",
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("info", `Uploading ${localOnly.length} local-only files`)
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 potential conflicts remain, request remote version data before surfacing to user
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
- "warn",
348
- `${conflicts.length} conflicts require version verification`
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
- effects.push(log("info", "Initial sync complete, entering watch mode"), {
369
- type: "PERSIST_STATE",
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("info", `Applying remote change: ${event.file.name}`), {
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("info", `Remote delete applied: ${event.fileName}`),
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("info", `Delete confirmed: ${event.fileName}`),
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("info", `Delete cancelled: ${event.fileName}`))
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 remoteFiles = state.pendingConflicts.map((c) => ({
490
- name: c.fileName,
491
- content: c.remoteContent,
492
- modifiedAt: c.remoteModifiedAt,
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("info", `Applying ${remoteFiles.length} remote versions`),
497
- { type: "WRITE_FILES", files: remoteFiles }
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
- "info",
504
- `Applying ${state.pendingConflicts.length} local versions`
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(log("info", "All conflicts resolved, entering watch mode"), {
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("info", `Local delete detected: ${relativePath}`), {
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
- "info",
599
- `[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`
659
+ "debug",
660
+ `Auto-resolved ${autoResolvedLocal.length} local changes`
600
661
  )
601
662
  )
602
663
  for (const conflict of autoResolvedLocal) {
603
- effects.push({
604
- type: "SEND_LOCAL_CHANGE",
605
- fileName: conflict.fileName,
606
- content: conflict.localContent,
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
- "info",
615
- `[AUTO-RESOLVE] Applying ${autoResolvedRemote.length} remote changes`
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
- `[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`
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
- effects.push(log("info", "[AUTO-RESOLVE] All conflicts auto-resolved!"), {
647
- type: "PERSIST_STATE",
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
- info(`Files directory: ${config.filesDir}`)
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
- info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
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
- info(
828
- `[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`
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
- console.warn(
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
- info("🚀 Starting Code Link")
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
- info(
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
- info(
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
- warn(
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
- info("Received handshake from plugin")
1030
- info(`Project: ${message.projectName} (${message.projectId})`)
1156
+ debug(`Received handshake: ${message.projectName} (${message.projectId})`)
1031
1157
 
1032
- // Validate project hash
1033
- if (message.projectId !== config.projectHash) {
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 ${config.projectHash}, got ${message.projectId}`
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
- success("Handshake successful - connection established")
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
- info(
1084
- `[FILE_LIST] Received ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB total)`
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
- warn("Plugin disconnected")
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
- info("\nShutting down gracefully...")
1357
+ console.log() // newline after ^C
1358
+ status("Shutting down...")
1223
1359
  if (watcher) {
1224
1360
  await watcher.close()
1225
1361
  }