collabmd 0.1.31 → 0.1.33

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.
Files changed (99) hide show
  1. package/dist/client/assets/{drawio-editor-B4sHHwWJ.js → drawio-editor-BMTwP5mD.js} +1 -1
  2. package/dist/client/assets/drawioEditor-D6clB5OH.js +2 -0
  3. package/dist/client/assets/{editor-session-BKHEYkbY.js → editor-session-Dzy_85Wp.js} +2 -2
  4. package/dist/client/assets/embedded-editor-base-CgEkjNYn.css +1 -0
  5. package/dist/client/assets/{excalidraw-editor-CHbeNZu6.js → excalidraw-editor-CAP9F8CB.js} +3 -3
  6. package/dist/client/assets/{excalidrawEditor-BhaTX2Ko.js → excalidrawEditor-DD4AvzYP.js} +2 -2
  7. package/dist/client/assets/exportDocument-BbkN85c4.css +1 -0
  8. package/dist/client/assets/index-BIt5EZXz.css +1 -0
  9. package/dist/client/assets/{index-kPgEYvEX.js → index-Czg_KL-i.js} +2 -2
  10. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  18. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  19. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  20. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  21. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  22. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  23. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  24. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  25. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  26. package/dist/client/assets/main-DeowJOSp.js +1368 -0
  27. package/dist/client/assets/vault-api-client-D7Ck0CvR.css +1 -0
  28. package/dist/client/assets/vault-api-client-p7h7cXKM.js +1 -0
  29. package/dist/client/drawio-editor.html +1 -1
  30. package/dist/client/excalidraw-editor.html +1 -1
  31. package/dist/client/export-document.html +2 -2
  32. package/dist/client/index.html +2 -2
  33. package/package.json +8 -5
  34. package/src/client/app/main-entry.js +2 -0
  35. package/src/client/application/app-shell/git-feature.js +28 -36
  36. package/src/client/application/app-shell/presence-feature.js +3 -1
  37. package/src/client/application/diagram-preview-hydrator.js +24 -15
  38. package/src/client/application/mermaid-preview-hydrator.js +121 -4
  39. package/src/client/application/plantuml-preview-hydrator.js +6 -16
  40. package/src/client/application/preview-renderer.js +11 -2
  41. package/src/client/bootstrap/collabmd-app-shell.js +182 -50
  42. package/src/client/domain/excalidraw-document-switch.js +3 -0
  43. package/src/client/excalidraw-editor.js +187 -84
  44. package/src/client/infrastructure/auth-client.js +4 -4
  45. package/src/client/infrastructure/backlinks-api-client.js +18 -0
  46. package/src/client/infrastructure/comment-thread-store.js +4 -0
  47. package/src/client/infrastructure/editor-session.js +6 -0
  48. package/src/client/infrastructure/editor-view-adapter.js +23 -0
  49. package/src/client/infrastructure/git-api-client.js +134 -0
  50. package/src/client/infrastructure/plantuml-api-client.js +21 -0
  51. package/src/client/infrastructure/vault-api-client.js +115 -0
  52. package/src/client/presentation/backlinks-panel.js +6 -9
  53. package/src/client/presentation/bases-preview-controller.js +1413 -34
  54. package/src/client/presentation/comments-panel.js +50 -5
  55. package/src/client/presentation/excalidraw-embed-controller.js +251 -15
  56. package/src/client/presentation/excalidraw-embed-reconciler.js +0 -1
  57. package/src/client/presentation/file-action-controller.js +97 -0
  58. package/src/client/presentation/file-explorer-controller.js +2 -0
  59. package/src/client/presentation/file-explorer-view.js +368 -0
  60. package/src/client/presentation/file-history-view-controller.js +41 -40
  61. package/src/client/presentation/git-diff-view-controller.js +70 -53
  62. package/src/client/presentation/git-panel-controller.js +12 -30
  63. package/src/client/styles/components/scrollbars.css +5 -0
  64. package/src/client/styles/export-document.css +45 -2
  65. package/src/client/styles/features/auth-gate.css +13 -24
  66. package/src/client/styles/features/comment-card.css +6 -0
  67. package/src/client/styles/features/comments-drawer.css +38 -18
  68. package/src/client/styles/features/diagram-preview.css +33 -0
  69. package/src/client/styles/features/embedded-preview.css +37 -11
  70. package/src/client/styles/features/preview-markdown.css +559 -39
  71. package/src/client/styles/foundation/themes.css +4 -0
  72. package/src/client/styles/foundation/tokens.css +4 -3
  73. package/src/client/styles/layout/sidebar.css +72 -0
  74. package/src/server/auth/auth-common.js +224 -0
  75. package/src/server/auth/auth-constants.js +30 -0
  76. package/src/server/auth/auth-strategies.js +320 -0
  77. package/src/server/auth/create-auth-service.js +24 -545
  78. package/src/server/domain/backlink-index.js +177 -5
  79. package/src/server/domain/bases/base-definition.js +382 -0
  80. package/src/server/domain/bases/base-expression-runtime.js +1196 -0
  81. package/src/server/domain/bases/base-index-snapshot-store.js +565 -0
  82. package/src/server/domain/bases/base-query-metadata.js +242 -0
  83. package/src/server/domain/bases/base-query-results.js +215 -0
  84. package/src/server/domain/bases/base-query-service.js +406 -1992
  85. package/src/server/domain/bases/base-transform.js +116 -0
  86. package/src/server/domain/collaboration/collaboration-room.js +36 -1
  87. package/src/server/domain/collaboration/room-client-state-store.js +1 -0
  88. package/src/server/domain/collaboration/room-persistence-controller.js +1 -1
  89. package/src/server/infrastructure/http/create-vault-api-query-handler.js +161 -1
  90. package/src/server/infrastructure/http/http-response.js +56 -0
  91. package/src/server/infrastructure/persistence/vault-file-store.js +110 -0
  92. package/dist/client/assets/drawioEditor-DGpo2hcx.js +0 -2
  93. package/dist/client/assets/embedded-editor-base-DgmRcsR8.css +0 -1
  94. package/dist/client/assets/exportDocument-DmV7Gngb.css +0 -1
  95. package/dist/client/assets/index-D69HOZHr.css +0 -1
  96. package/dist/client/assets/main-DSxNTtD-.js +0 -1243
  97. package/dist/client/assets/vault-api-client-BzHQseMN.js +0 -1
  98. package/dist/client/assets/vault-api-client-De7kMvrE.css +0 -1
  99. /package/dist/client/assets/{exportDocument-CtZKWE2c.js → exportDocument-Zfwq1XVx.js} +0 -0
@@ -37,6 +37,47 @@ function sortThreads(threads = []) {
37
37
  ));
38
38
  }
39
39
 
40
+ export function countThreadsForSourceBlocks(blocks = [], threads = []) {
41
+ const normalizedBlocks = blocks
42
+ .map((block, index) => {
43
+ const startLine = Number(block?.startLine);
44
+ const endLine = Number(block?.endLine ?? startLine);
45
+ if (!Number.isFinite(startLine) || startLine <= 0) {
46
+ return null;
47
+ }
48
+
49
+ return {
50
+ endExclusive: Math.max(endLine, startLine + 1),
51
+ index,
52
+ startLine,
53
+ };
54
+ })
55
+ .filter(Boolean)
56
+ .sort((left, right) => (
57
+ left.startLine - right.startLine
58
+ || left.endExclusive - right.endExclusive
59
+ || left.index - right.index
60
+ ));
61
+ const counts = new Array(blocks.length).fill(0);
62
+ let threadIndex = 0;
63
+
64
+ normalizedBlocks.forEach((block) => {
65
+ while (threadIndex < threads.length && (threads[threadIndex]?.anchor?.startLine ?? 0) < block.startLine) {
66
+ threadIndex += 1;
67
+ }
68
+
69
+ let countIndex = threadIndex;
70
+ while (countIndex < threads.length && (threads[countIndex]?.anchor?.startLine ?? 0) < block.endExclusive) {
71
+ countIndex += 1;
72
+ }
73
+
74
+ counts[block.index] = countIndex - threadIndex;
75
+ threadIndex = countIndex;
76
+ });
77
+
78
+ return counts;
79
+ }
80
+
40
81
  export class CommentsPanel {
41
82
  constructor({
42
83
  panelElement,
@@ -236,18 +277,22 @@ export class CommentsPanel {
236
277
 
237
278
  const blocks = Array.from(this.previewElement.querySelectorAll('[data-source-line]'))
238
279
  .filter((element) => isLeafSourceBlock(element) && isCommentablePreviewBlock(element));
280
+ const threadCounts = countThreadsForSourceBlocks(
281
+ blocks.map((block) => ({
282
+ endLine: parseLineNumber(block.getAttribute('data-source-line-end')) ?? parseLineNumber(block.getAttribute('data-source-line')) ?? 1,
283
+ startLine: parseLineNumber(block.getAttribute('data-source-line')),
284
+ })),
285
+ this.threads,
286
+ );
239
287
 
240
- blocks.forEach((block) => {
288
+ blocks.forEach((block, index) => {
241
289
  const startLine = parseLineNumber(block.getAttribute('data-source-line'));
242
290
  const endLine = parseLineNumber(block.getAttribute('data-source-line-end')) ?? (startLine ?? 1);
243
291
  if (!startLine) {
244
292
  return;
245
293
  }
246
294
 
247
- const matchingThreads = this.threads.filter((thread) => (
248
- thread.anchor?.startLine >= startLine && thread.anchor?.startLine < Math.max(endLine, startLine + 1)
249
- ));
250
- const totalCount = matchingThreads.length;
295
+ const totalCount = threadCounts[index] ?? 0;
251
296
  const button = document.createElement('button');
252
297
  button.type = 'button';
253
298
  button.className = 'comment-anchor-btn';
@@ -75,6 +75,8 @@ export class ExcalidrawEmbedController {
75
75
  this.maximizedEmbed = null;
76
76
  this.overlayRoot = null;
77
77
  this.maximizedRoot = null;
78
+ this.warmCacheRoot = null;
79
+ this.warmEntry = null;
78
80
  this.placeholderObserver = null;
79
81
  this.followedPeerIdsByFilePath = new Map();
80
82
  this.pendingDisconnectRequests = new Map();
@@ -106,6 +108,11 @@ export class ExcalidrawEmbedController {
106
108
  entry.placeholder = null;
107
109
  });
108
110
  this.embedEntries.clear();
111
+ if (this.warmEntry) {
112
+ this._clearEntryBootTimeout(this.warmEntry);
113
+ this.warmEntry.wrapper?.remove();
114
+ this.warmEntry = null;
115
+ }
109
116
  this.pendingDisconnectRequests.forEach((request) => {
110
117
  request.resolve(false);
111
118
  });
@@ -115,6 +122,8 @@ export class ExcalidrawEmbedController {
115
122
  this.overlayRoot = null;
116
123
  this.maximizedRoot?.remove();
117
124
  this.maximizedRoot = null;
125
+ this.warmCacheRoot?.remove();
126
+ this.warmCacheRoot = null;
118
127
  }
119
128
 
120
129
  async setFollowedUser(filePath, peerId = null) {
@@ -186,6 +195,7 @@ export class ExcalidrawEmbedController {
186
195
  }));
187
196
 
188
197
  const { nextEntries, removedEntries } = reconcileEmbedEntries(this.embedEntries, descriptors);
198
+ this._captureWarmEntry(removedEntries);
189
199
  this._adoptReusableEntries(nextEntries, removedEntries);
190
200
  removedEntries.forEach((entry) => this._destroyEntry(entry));
191
201
  this.embedEntries = nextEntries;
@@ -196,8 +206,7 @@ export class ExcalidrawEmbedController {
196
206
  entry.queued = false;
197
207
  entry.followedPeerId = this.followedPeerIdsByFilePath.get(entry.filePath);
198
208
  if (entry.wrapper) {
199
- this._updateEmbedLabel(entry);
200
- this._attachWrapper(entry);
209
+ void this._hydrateEntry(entry);
201
210
  return;
202
211
  }
203
212
 
@@ -233,7 +242,7 @@ export class ExcalidrawEmbedController {
233
242
  }
234
243
 
235
244
  updateTheme(theme) {
236
- this.embedEntries.forEach((entry) => {
245
+ this._forEachManagedEntry((entry) => {
237
246
  this._postMessageToEntry(entry, {
238
247
  source: 'collabmd-host',
239
248
  type: 'set-theme',
@@ -243,7 +252,7 @@ export class ExcalidrawEmbedController {
243
252
  }
244
253
 
245
254
  updateLocalUser(user) {
246
- this.embedEntries.forEach((entry) => {
255
+ this._forEachManagedEntry((entry) => {
247
256
  this._syncEntryUser(entry, user);
248
257
  });
249
258
  }
@@ -390,19 +399,31 @@ export class ExcalidrawEmbedController {
390
399
  return;
391
400
  }
392
401
 
402
+ entry.isParked = false;
403
+
393
404
  if (!entry.wrapper) {
394
405
  const mount = this._createEmbedContainer(entry);
406
+ entry.header = mount.header;
395
407
  entry.wrapper = mount.wrapper;
396
408
  entry.iframe = mount.iframe;
397
409
  entry.labelElement = mount.labelElement;
410
+ entry.maxButton = mount.maxButton;
411
+ entry.openButton = mount.openButton;
398
412
  entry.instanceId = mount.instanceId;
399
413
  entry.isReady = false;
414
+ entry.bootFilePath = entry.filePath;
415
+ entry.bootMode = this._getEntryMode(entry);
416
+ entry.loadedFilePath = entry.bootFilePath;
417
+ entry.loadedMode = entry.bootMode;
400
418
  entry.bootAttempts = (entry.bootAttempts ?? 0) + 1;
401
419
  this._armEntryBootTimeout(entry);
402
420
  }
403
421
 
404
422
  entry.followedPeerId = this.followedPeerIdsByFilePath.get(entry.filePath);
405
423
  this._updateEmbedLabel(entry);
424
+ if (this._entryNeedsHardReload(entry)) {
425
+ this._reloadEntry(entry);
426
+ }
406
427
  this._attachWrapper(entry);
407
428
  }
408
429
 
@@ -444,22 +465,16 @@ export class ExcalidrawEmbedController {
444
465
  return;
445
466
  }
446
467
 
447
- const removedIndex = removedEntries.findIndex((entry) => (
448
- entry.filePath === nextEntry.filePath
449
- && this._getEntryMode(entry) === this._getEntryMode(nextEntry)
450
- && (entry.wrapper || entry.iframe)
451
- ));
452
- if (removedIndex < 0) {
468
+ const reusedEntry = this._claimReusableEntry(nextEntry, removedEntries);
469
+ if (!reusedEntry) {
453
470
  return;
454
471
  }
455
472
 
456
- const [reusedEntry] = removedEntries.splice(removedIndex, 1);
457
473
  reusedEntry.filePath = nextEntry.filePath;
458
474
  reusedEntry.key = nextEntry.key;
459
475
  reusedEntry.label = nextEntry.label;
460
476
  reusedEntry.placeholder = nextEntry.placeholder;
461
477
  reusedEntry.queued = false;
462
- reusedEntry.reusedFromEmbed = this._isFilePreviewEntry(nextEntry);
463
478
  nextEntries.set(key, reusedEntry);
464
479
  });
465
480
  }
@@ -477,16 +492,20 @@ export class ExcalidrawEmbedController {
477
492
  }
478
493
 
479
494
  this._clearEntryBootTimeout(entry);
495
+ if (this.warmEntry === entry) {
496
+ this.warmEntry = null;
497
+ }
480
498
  this._resetPlaceholderLayout(entry);
499
+ this._setEntryLoadingState(entry, false);
481
500
  entry.wrapper?.remove();
482
501
  entry.placeholder = null;
483
502
  }
484
503
 
485
- _armEntryBootTimeout(entry) {
504
+ _armEntryBootTimeout(entry, delayMs = IFRAME_BOOT_TIMEOUT_MS) {
486
505
  this._clearEntryBootTimeout(entry);
487
506
  entry.bootTimerId = window.setTimeout(() => {
488
507
  void this._handleEntryBootTimeout(entry);
489
- }, IFRAME_BOOT_TIMEOUT_MS);
508
+ }, delayMs);
490
509
  }
491
510
 
492
511
  _clearEntryBootTimeout(entry) {
@@ -519,6 +538,7 @@ export class ExcalidrawEmbedController {
519
538
 
520
539
  this._clearEntryBootTimeout(entry);
521
540
  this._resetPlaceholderLayout(entry);
541
+ this._setEntryLoadingState(entry, false);
522
542
 
523
543
  if (this.maximizedEmbed?.wrapper === entry.wrapper) {
524
544
  this._exitMaximizedEmbed();
@@ -527,9 +547,17 @@ export class ExcalidrawEmbedController {
527
547
  entry.wrapper?.remove();
528
548
  entry.wrapper = null;
529
549
  entry.iframe = null;
550
+ entry.header = null;
530
551
  entry.labelElement = null;
552
+ entry.maxButton = null;
553
+ entry.openButton = null;
531
554
  entry.instanceId = null;
532
555
  entry.isReady = false;
556
+ entry.isParked = false;
557
+ entry.bootFilePath = null;
558
+ entry.bootMode = null;
559
+ entry.loadedFilePath = null;
560
+ entry.loadedMode = null;
533
561
 
534
562
  await this._hydrateEntry(entry);
535
563
  }
@@ -545,6 +573,10 @@ export class ExcalidrawEmbedController {
545
573
  }
546
574
  }
547
575
 
576
+ if (this.warmEntry?.iframe?.contentWindow === contentWindow) {
577
+ return this.warmEntry;
578
+ }
579
+
548
580
  return null;
549
581
  }
550
582
 
@@ -559,6 +591,10 @@ export class ExcalidrawEmbedController {
559
591
  }
560
592
  }
561
593
 
594
+ if (this.warmEntry?.filePath === filePath) {
595
+ return this.warmEntry;
596
+ }
597
+
562
598
  return null;
563
599
  }
564
600
 
@@ -572,11 +608,20 @@ export class ExcalidrawEmbedController {
572
608
  if (entry.wrapper) {
573
609
  entry.wrapper.dataset.embedKey = entry.key;
574
610
  entry.wrapper.dataset.file = entry.filePath;
611
+ entry.wrapper.classList.toggle('is-direct-file', this._isFilePreviewEntry(entry));
575
612
  }
576
613
 
577
614
  if (entry.iframe) {
578
615
  entry.iframe.title = `Excalidraw: ${entry.filePath}`;
579
616
  }
617
+
618
+ if (entry.openButton) {
619
+ if (this._isFilePreviewEntry(entry)) {
620
+ entry.openButton.remove();
621
+ } else if (!entry.openButton.isConnected && entry.header) {
622
+ entry.header.insertBefore(entry.openButton, entry.maxButton || null);
623
+ }
624
+ }
580
625
  }
581
626
 
582
627
  _buildIframeUrl(entry) {
@@ -724,13 +769,33 @@ export class ExcalidrawEmbedController {
724
769
  syncMaximizeButtonState();
725
770
 
726
771
  return {
772
+ header,
727
773
  iframe,
728
774
  instanceId: iframe.dataset.instanceId,
729
775
  labelElement: label,
776
+ maxButton: maxBtn,
777
+ openButton: openBtn,
730
778
  wrapper,
731
779
  };
732
780
  }
733
781
 
782
+ _ensureWarmCacheRoot() {
783
+ if (this.warmCacheRoot?.isConnected && this.warmCacheRoot.parentElement === document.body) {
784
+ return this.warmCacheRoot;
785
+ }
786
+
787
+ let warmCacheRoot = document.body.querySelector('[data-excalidraw-warm-cache-root="true"]');
788
+ if (!warmCacheRoot) {
789
+ warmCacheRoot = document.createElement('div');
790
+ warmCacheRoot.dataset.excalidrawWarmCacheRoot = 'true';
791
+ warmCacheRoot.className = 'excalidraw-warm-cache-root';
792
+ document.body.appendChild(warmCacheRoot);
793
+ }
794
+
795
+ this.warmCacheRoot = warmCacheRoot;
796
+ return warmCacheRoot;
797
+ }
798
+
734
799
  _setupResizer(resizer, iframe, onResizeEnd = null) {
735
800
  let startY = 0;
736
801
  let startHeight = 0;
@@ -801,7 +866,164 @@ export class ExcalidrawEmbedController {
801
866
  }
802
867
 
803
868
  _shouldInlineFilePreview(entry) {
804
- return this._isFilePreviewEntry(entry) && !entry?.reusedFromEmbed;
869
+ return this._isFilePreviewEntry(entry);
870
+ }
871
+
872
+ _entryNeedsHardReload(entry) {
873
+ const desiredMode = this._getEntryMode(entry);
874
+ return Boolean(
875
+ entry?.wrapper
876
+ && entry.iframe
877
+ && (
878
+ entry.isReady
879
+ ? (
880
+ entry.loadedFilePath !== entry.filePath
881
+ || entry.loadedMode !== desiredMode
882
+ )
883
+ : (
884
+ entry.bootFilePath !== entry.filePath
885
+ || entry.bootMode !== desiredMode
886
+ )
887
+ )
888
+ );
889
+ }
890
+
891
+ _setEntryLoadingState(entry, isLoading) {
892
+ if (!entry?.wrapper) {
893
+ return;
894
+ }
895
+
896
+ entry.wrapper.classList.toggle('is-loading', Boolean(isLoading));
897
+ entry.wrapper.setAttribute('aria-busy', isLoading ? 'true' : 'false');
898
+ }
899
+
900
+ _forEachManagedEntry(callback) {
901
+ const seen = new Set();
902
+ this.embedEntries.forEach((entry) => {
903
+ if (!entry || seen.has(entry)) {
904
+ return;
905
+ }
906
+
907
+ seen.add(entry);
908
+ callback(entry);
909
+ });
910
+
911
+ if (this.warmEntry && !seen.has(this.warmEntry)) {
912
+ callback(this.warmEntry);
913
+ }
914
+ }
915
+
916
+ _claimEntryFromRemovedEntries(removedEntries, predicate) {
917
+ const removedIndex = removedEntries.findIndex((entry) => (
918
+ (entry.wrapper || entry.iframe) && predicate(entry)
919
+ ));
920
+ if (removedIndex < 0) {
921
+ return null;
922
+ }
923
+
924
+ const [reusedEntry] = removedEntries.splice(removedIndex, 1);
925
+ return reusedEntry;
926
+ }
927
+
928
+ _claimReusableEntry(nextEntry, removedEntries) {
929
+ const desiredMode = this._getEntryMode(nextEntry);
930
+ const exactMatch = this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
931
+ entry.filePath === nextEntry.filePath
932
+ && this._getEntryMode(entry) === desiredMode
933
+ ));
934
+ if (exactMatch) {
935
+ return exactMatch;
936
+ }
937
+
938
+ if (this._isFilePreviewEntry(nextEntry)) {
939
+ const promotedEntry = this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
940
+ entry.filePath === nextEntry.filePath
941
+ ));
942
+ if (promotedEntry) {
943
+ return promotedEntry;
944
+ }
945
+
946
+ const warmEntry = this._claimWarmEntry();
947
+ if (warmEntry) {
948
+ return warmEntry;
949
+ }
950
+
951
+ return this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
952
+ this._isFilePreviewEntry(entry)
953
+ ));
954
+ }
955
+
956
+ return null;
957
+ }
958
+
959
+ _claimWarmEntry() {
960
+ const warmEntry = this.warmEntry;
961
+ if (!warmEntry) {
962
+ return null;
963
+ }
964
+
965
+ this.warmEntry = null;
966
+ return warmEntry;
967
+ }
968
+
969
+ _captureWarmEntry(removedEntries) {
970
+ const warmCandidate = this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
971
+ this._isFilePreviewEntry(entry)
972
+ ));
973
+ if (!warmCandidate) {
974
+ return;
975
+ }
976
+
977
+ if (this.warmEntry && this.warmEntry !== warmCandidate) {
978
+ this._destroyEntry(this.warmEntry);
979
+ }
980
+
981
+ this.warmEntry = warmCandidate;
982
+ this._parkWarmEntry(warmCandidate);
983
+ }
984
+
985
+ _parkWarmEntry(entry) {
986
+ if (!entry?.wrapper) {
987
+ return;
988
+ }
989
+
990
+ if (this.maximizedEmbed?.wrapper === entry.wrapper) {
991
+ this._exitMaximizedEmbed();
992
+ }
993
+
994
+ const warmCacheRoot = this._ensureWarmCacheRoot();
995
+ this._setEntryLoadingState(entry, false);
996
+ entry.isParked = true;
997
+ entry.isReady = false;
998
+ entry.bootFilePath = null;
999
+ entry.bootMode = null;
1000
+ entry.loadedFilePath = null;
1001
+ entry.loadedMode = null;
1002
+ entry.placeholder = null;
1003
+ if (entry.wrapper.parentElement !== warmCacheRoot) {
1004
+ warmCacheRoot.appendChild(entry.wrapper);
1005
+ }
1006
+ this._postMessageToEntry(entry, {
1007
+ source: 'collabmd-host',
1008
+ type: 'park-room',
1009
+ });
1010
+ }
1011
+
1012
+ _reloadEntry(entry) {
1013
+ if (!entry?.iframe) {
1014
+ return;
1015
+ }
1016
+
1017
+ this._clearEntryBootTimeout(entry);
1018
+ this._setEntryLoadingState(entry, true);
1019
+ entry.isReady = false;
1020
+ entry.bootAttempts = (entry.bootAttempts ?? 0) + 1;
1021
+ entry.bootFilePath = entry.filePath;
1022
+ entry.bootMode = this._getEntryMode(entry);
1023
+ entry.loadedFilePath = null;
1024
+ entry.loadedMode = null;
1025
+ entry.iframe.src = this._buildIframeUrl(entry).toString();
1026
+ this._armEntryBootTimeout(entry);
805
1027
  }
806
1028
 
807
1029
  _resetPlaceholderLayout(entry) {
@@ -950,8 +1172,15 @@ export class ExcalidrawEmbedController {
950
1172
  }
951
1173
 
952
1174
  if (msg.type === 'ready') {
1175
+ if (entry.isParked) {
1176
+ return;
1177
+ }
1178
+
953
1179
  entry.isReady = true;
954
1180
  this._clearEntryBootTimeout(entry);
1181
+ entry.loadedFilePath = entry.bootFilePath || entry.filePath;
1182
+ entry.loadedMode = entry.bootMode || this._getEntryMode(entry);
1183
+ this._setEntryLoadingState(entry, false);
955
1184
  this._syncEntryUser(entry);
956
1185
  this._postMessageToEntry(entry, {
957
1186
  source: 'collabmd-host',
@@ -959,10 +1188,17 @@ export class ExcalidrawEmbedController {
959
1188
  theme: this.getTheme?.() || 'dark',
960
1189
  });
961
1190
  this._syncEntryFollowState(entry);
1191
+ if (this._entryNeedsHardReload(entry)) {
1192
+ void this._hydrateEntry(entry);
1193
+ }
962
1194
  return;
963
1195
  }
964
1196
 
965
1197
  if (msg.type === 'error') {
1198
+ if (entry.isParked) {
1199
+ return;
1200
+ }
1201
+
966
1202
  entry.isReady = false;
967
1203
  void this._handleEntryBootTimeout(entry);
968
1204
  return;
@@ -11,7 +11,6 @@ export function reconcileEmbedEntries(currentEntries, placeholderDescriptors) {
11
11
  label: descriptor.label,
12
12
  placeholder: descriptor.placeholder,
13
13
  queued: false,
14
- reusedFromEmbed: false,
15
14
  wrapper: null,
16
15
  };
17
16
 
@@ -23,6 +23,12 @@ function getPathLeaf(path) {
23
23
  .pop() || '';
24
24
  }
25
25
 
26
+ function getParentPath(pathValue) {
27
+ const normalized = String(pathValue ?? '').replace(/\/+$/u, '');
28
+ const separatorIndex = normalized.lastIndexOf('/');
29
+ return separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : '';
30
+ }
31
+
26
32
  function replacePathPrefix(pathValue, oldPrefix, newPrefix) {
27
33
  if (pathValue === oldPrefix) {
28
34
  return newPrefix;
@@ -201,6 +207,10 @@ export class FileActionController {
201
207
  label: 'Rename / move',
202
208
  onSelect: () => this.handleRenameDirectory(directoryPath),
203
209
  },
210
+ {
211
+ label: 'Download',
212
+ onSelect: () => this.handleDownloadDirectory(directoryPath),
213
+ },
204
214
  {
205
215
  label: 'Delete',
206
216
  danger: true,
@@ -215,6 +225,10 @@ export class FileActionController {
215
225
  label: 'Rename / move',
216
226
  onSelect: () => this.handleRenameFile(filePath),
217
227
  },
228
+ {
229
+ label: 'Download',
230
+ onSelect: () => this.handleDownloadFile(filePath),
231
+ },
218
232
  {
219
233
  label: 'Delete',
220
234
  danger: true,
@@ -627,6 +641,81 @@ export class FileActionController {
627
641
  }
628
642
  }
629
643
 
644
+ planMoveEntryByDrop({ destinationDirectory = '', sourcePath = '', sourceType = 'file' } = {}) {
645
+ const normalizedSourcePath = normalizeVaultPathInput(sourcePath);
646
+ const normalizedDestinationDirectory = normalizeVaultPathInput(destinationDirectory);
647
+ const currentParentPath = getParentPath(normalizedSourcePath);
648
+ const entryName = getPathLeaf(normalizedSourcePath);
649
+
650
+ if (!normalizedSourcePath || !entryName) {
651
+ return { ok: false, reason: 'Invalid item to move' };
652
+ }
653
+
654
+ if (currentParentPath === normalizedDestinationDirectory) {
655
+ return { ok: false, reason: 'Item is already in that folder' };
656
+ }
657
+
658
+ if (sourceType === 'directory') {
659
+ if (normalizedDestinationDirectory === normalizedSourcePath) {
660
+ return { ok: false, reason: 'A folder cannot be moved into itself' };
661
+ }
662
+
663
+ if (normalizedDestinationDirectory.startsWith(`${normalizedSourcePath}/`)) {
664
+ return { ok: false, reason: 'A folder cannot be moved into one of its descendants' };
665
+ }
666
+
667
+ return {
668
+ nextPath: composeVaultChildPath(normalizedDestinationDirectory, entryName),
669
+ ok: true,
670
+ };
671
+ }
672
+
673
+ const extension = getVaultFileExtension(normalizedSourcePath) || '.md';
674
+ return {
675
+ extension,
676
+ nextPath: composeVaultChildPath(normalizedDestinationDirectory, entryName),
677
+ ok: true,
678
+ };
679
+ }
680
+
681
+ canMoveEntryByDrop(payload = {}) {
682
+ return this.planMoveEntryByDrop(payload).ok;
683
+ }
684
+
685
+ async moveEntryByDrop(payload = {}) {
686
+ const plan = this.planMoveEntryByDrop(payload);
687
+ if (!plan.ok) {
688
+ this.showToast(plan.reason);
689
+ return false;
690
+ }
691
+
692
+ if (payload.sourceType === 'directory') {
693
+ return this.renameDirectory(payload.sourcePath, plan.nextPath);
694
+ }
695
+
696
+ return this.renameVaultFile(payload.sourcePath, plan.nextPath, plan.extension);
697
+ }
698
+
699
+ async downloadFileEntry(filePath) {
700
+ try {
701
+ await this.vaultClient.downloadFile(filePath);
702
+ return true;
703
+ } catch (error) {
704
+ this.showToast(error?.message || 'Failed to download file');
705
+ return false;
706
+ }
707
+ }
708
+
709
+ async downloadDirectoryEntry(directoryPath) {
710
+ try {
711
+ await this.vaultClient.downloadDirectory(directoryPath);
712
+ return true;
713
+ } catch (error) {
714
+ this.showToast(error?.message || 'Failed to download folder');
715
+ return false;
716
+ }
717
+ }
718
+
630
719
  getCreateContext(parentDir = '') {
631
720
  const normalizedParentDir = normalizeVaultPathInput(parentDir);
632
721
 
@@ -908,4 +997,12 @@ export class FileActionController {
908
997
  onSubmit: () => this.deleteDirectory(directoryPath, { recursive: hasDescendants }),
909
998
  });
910
999
  }
1000
+
1001
+ handleDownloadFile(filePath) {
1002
+ void this.downloadFileEntry(filePath);
1003
+ }
1004
+
1005
+ handleDownloadDirectory(directoryPath) {
1006
+ void this.downloadDirectoryEntry(directoryPath);
1007
+ }
911
1008
  }
@@ -29,6 +29,7 @@ export class FileExplorerController {
29
29
  this.state.toggleDirectory(pathValue);
30
30
  this.renderTree();
31
31
  },
32
+ onEntryDrop: (payload) => this.actionController.moveEntryByDrop(payload),
32
33
  onFileContextMenu: (event, payload) => {
33
34
  if (payload.type === 'directory') {
34
35
  this.view.showContextMenu(event, this.actionController.getDirectoryContextMenuItems(payload.directoryPath));
@@ -40,6 +41,7 @@ export class FileExplorerController {
40
41
  onFileSelect: (filePath) => {
41
42
  this.onFileSelect?.(filePath);
42
43
  },
44
+ onValidateDrop: (payload) => this.actionController.canMoveEntryByDrop(payload),
43
45
  onSearchChange: (value) => {
44
46
  this.state.setSearchQuery(value);
45
47
  this.renderTree();