collabmd 0.1.19 → 0.1.21

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 (53) hide show
  1. package/README.md +13 -11
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  6. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  7. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  8. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  9. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  10. package/public/assets/js/excalidraw-editor.js +1 -1
  11. package/public/assets/js/main.js +102 -75
  12. package/public/assets/js/preview-render-worker.js +15 -15
  13. package/public/index.html +14 -4
  14. package/src/client/application/app-shell/git-feature.js +86 -19
  15. package/src/client/application/app-shell/ui-feature.js +4 -0
  16. package/src/client/application/app-shell-elements.js +1 -0
  17. package/src/client/bootstrap/collabmd-app-shell.js +16 -0
  18. package/src/client/domain/vault-utils.js +2 -2
  19. package/src/client/excalidraw-editor.js +2 -1
  20. package/src/client/infrastructure/editor-session.js +4 -0
  21. package/src/client/infrastructure/editor-view-adapter.js +245 -7
  22. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  23. package/src/client/presentation/backlinks-panel.js +157 -101
  24. package/src/client/presentation/comment-ui-controller.js +233 -10
  25. package/src/client/presentation/file-explorer-controller.js +15 -3
  26. package/src/client/presentation/file-explorer-view.js +152 -11
  27. package/src/client/presentation/git-panel-controller.js +73 -0
  28. package/src/client/presentation/outline-controller.js +25 -0
  29. package/src/client/styles/style.css +184 -9
  30. package/src/domain/wiki-link-resolver.js +16 -5
  31. package/src/domain/workspace-change.js +68 -0
  32. package/src/domain/workspace-room.js +3 -0
  33. package/src/server/create-app-server.js +41 -10
  34. package/src/server/domain/backlink-index.js +94 -1
  35. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  36. package/src/server/domain/collaboration/room-registry.js +64 -10
  37. package/src/server/infrastructure/git/errors.js +4 -1
  38. package/src/server/infrastructure/git/git-service.js +215 -1
  39. package/src/server/infrastructure/git/responses.js +12 -19
  40. package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
  41. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  42. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  43. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  44. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  45. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  46. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  47. package/src/server/infrastructure/http/http-response.js +65 -28
  48. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  49. package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
  50. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  51. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  52. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  53. package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +0 -22
@@ -9,6 +9,9 @@ const COMMENT_CARD_WIDTH = 520;
9
9
  const COMMENT_SELECTION_REVEAL_DELAY_MS = 150;
10
10
  const COMMENT_SELECTION_CHIP_GAP = 12;
11
11
  const COMMENT_CONTROL_SLOT_HEIGHT = 36;
12
+ const COMMENT_PREVIEW_RAIL_SLOT_HEIGHT = 30;
13
+ const COMMENT_PREVIEW_RAIL_MIN_WIDTH = 400;
14
+ const COMMENT_PREVIEW_RAIL_BREAKPOINT = 769;
12
15
  const COMMENT_REACTION_PRESET_EMOJIS = Object.freeze(['👍', '❤️', '🎉', '👀', '🚀']);
13
16
  const COMMENT_REACTION_MORE_EMOJIS = Object.freeze(['😂', '🔥', '✅', '🙏', '💡', '🤔', '👏', '😄', '🎯', '🙌']);
14
17
 
@@ -248,6 +251,25 @@ function createRectFromRects(rects = []) {
248
251
  };
249
252
  }
250
253
 
254
+ function pointIntersectsRect(x, y, rect) {
255
+ if (!rect) {
256
+ return false;
257
+ }
258
+
259
+ return x >= rect.left
260
+ && x <= rect.right
261
+ && y >= rect.top
262
+ && y <= rect.bottom;
263
+ }
264
+
265
+ function normalizeGroupKeys(keys = []) {
266
+ return [...new Set(keys.filter(Boolean))].sort();
267
+ }
268
+
269
+ function serializeGroupKeys(keys = []) {
270
+ return normalizeGroupKeys(keys).join(' ');
271
+ }
272
+
251
273
  function createCommentMarkerContent(count) {
252
274
  const fragment = document.createDocumentFragment();
253
275
 
@@ -279,6 +301,7 @@ export class CommentUiController {
279
301
  commentsDrawerList,
280
302
  commentsToggleButton,
281
303
  editorContainer,
304
+ onWillOpenDrawer,
282
305
  onCreateThread,
283
306
  onNavigateToLine,
284
307
  onReplyToThread,
@@ -293,6 +316,7 @@ export class CommentUiController {
293
316
  this.commentsDrawerList = commentsDrawerList;
294
317
  this.commentsToggleButton = commentsToggleButton;
295
318
  this.editorContainer = editorContainer;
319
+ this.onWillOpenDrawer = onWillOpenDrawer;
296
320
  this.onCreateThread = onCreateThread;
297
321
  this.onNavigateToLine = onNavigateToLine;
298
322
  this.onReplyToThread = onReplyToThread;
@@ -313,6 +337,12 @@ export class CommentUiController {
313
337
  this.pointerSelecting = false;
314
338
  this.session = null;
315
339
  this.activeCard = null;
340
+ this.hoveredEditorGroupKeys = [];
341
+ this.hoveredEditorGroupKeysSignature = '';
342
+ this.hoveredPreviewGroupKeys = [];
343
+ this.hoveredPreviewGroupKeysSignature = '';
344
+ this.previewHoverRegions = [];
345
+ this.lastPreviewPointerPosition = null;
316
346
  this.editorLayer = null;
317
347
  this.previewLayer = null;
318
348
  this.previewHighlightLayer = null;
@@ -329,6 +359,20 @@ export class CommentUiController {
329
359
  this.handleEditorScroll = () => this.scheduleLayoutRefresh();
330
360
  this.handlePreviewScroll = () => this.scheduleLayoutRefresh();
331
361
  this.handleWindowResize = () => this.scheduleLayoutRefresh();
362
+ this.handlePreviewPointerMove = (event) => {
363
+ this.lastPreviewPointerPosition = { x: event.clientX, y: event.clientY };
364
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysAtPoint(event.clientX, event.clientY));
365
+ };
366
+ this.handlePreviewPointerLeave = () => {
367
+ this.lastPreviewPointerPosition = null;
368
+ this.updateHoveredPreviewGroups([]);
369
+ };
370
+ this.handlePreviewFocusIn = (event) => {
371
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(event.target));
372
+ };
373
+ this.handlePreviewFocusOut = (event) => {
374
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(event.relatedTarget));
375
+ };
332
376
  this.handleCommentSelectionButtonPointerDown = (event) => {
333
377
  event.preventDefault();
334
378
  };
@@ -384,13 +428,30 @@ export class CommentUiController {
384
428
  this.scheduleLayoutRefresh();
385
429
  };
386
430
  this.handleDocumentPointerDown = (event) => {
387
- if (!this.activeCard || !this.cardRoot) {
431
+ const target = event.target;
432
+
433
+ if (this.activeCard && this.cardRoot) {
434
+ if (this.cardRoot.contains(target)) {
435
+ return;
436
+ }
437
+ this.closeCard();
438
+ }
439
+
440
+ if (!this.drawerOpen) {
388
441
  return;
389
442
  }
390
- if (this.cardRoot.contains(event.target)) {
443
+
444
+ if (
445
+ target instanceof Node
446
+ && (
447
+ this.commentsDrawer?.contains(target)
448
+ || this.commentsToggleButton?.contains(target)
449
+ )
450
+ ) {
391
451
  return;
392
452
  }
393
- this.closeCard();
453
+
454
+ this.closeDrawer();
394
455
  };
395
456
  this.handleDocumentKeyDown = (event) => {
396
457
  if (event.key === 'Escape' && this.reactionPicker) {
@@ -414,10 +475,13 @@ export class CommentUiController {
414
475
  this.openComposerForSelection('toolbar');
415
476
  });
416
477
  this.commentsToggleButton?.addEventListener('click', () => {
417
- this.drawerOpen = !this.drawerOpen;
418
- this.render();
478
+ this.setDrawerOpen(!this.drawerOpen);
419
479
  });
420
480
  this.previewContainer?.addEventListener('scroll', this.handlePreviewScroll, { passive: true });
481
+ this.previewElement?.addEventListener('pointermove', this.handlePreviewPointerMove, { passive: true });
482
+ this.previewElement?.addEventListener('pointerleave', this.handlePreviewPointerLeave);
483
+ this.previewElement?.addEventListener('focusin', this.handlePreviewFocusIn);
484
+ this.previewElement?.addEventListener('focusout', this.handlePreviewFocusOut);
421
485
  this.editorContainer?.addEventListener('pointerdown', this.handleEditorPointerDown);
422
486
  this.editorContainer?.addEventListener('focusout', this.handleEditorFocusOut);
423
487
  window.addEventListener('resize', this.handleWindowResize);
@@ -434,6 +498,10 @@ export class CommentUiController {
434
498
  }
435
499
  this.attachSession(null);
436
500
  this.previewContainer?.removeEventListener('scroll', this.handlePreviewScroll);
501
+ this.previewElement?.removeEventListener('pointermove', this.handlePreviewPointerMove);
502
+ this.previewElement?.removeEventListener('pointerleave', this.handlePreviewPointerLeave);
503
+ this.previewElement?.removeEventListener('focusin', this.handlePreviewFocusIn);
504
+ this.previewElement?.removeEventListener('focusout', this.handlePreviewFocusOut);
437
505
  this.commentSelectionButton?.removeEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
438
506
  this.editorContainer?.removeEventListener('pointerdown', this.handleEditorPointerDown);
439
507
  this.editorContainer?.removeEventListener('focusout', this.handleEditorFocusOut);
@@ -442,6 +510,7 @@ export class CommentUiController {
442
510
  document.removeEventListener('pointercancel', this.handleDocumentPointerUp);
443
511
  document.removeEventListener('pointerdown', this.handleDocumentPointerDown);
444
512
  document.removeEventListener('keydown', this.handleDocumentKeyDown);
513
+ this.previewHoverRegions = [];
445
514
  this.cardRoot?.remove();
446
515
  this.editorLayer?.remove();
447
516
  this.previewLayer?.remove();
@@ -475,6 +544,10 @@ export class CommentUiController {
475
544
  this.clearSelectionRevealTimer();
476
545
  this.pointerSelecting = false;
477
546
  this.activeCard = null;
547
+ this.updateHoveredEditorGroups([]);
548
+ this.updateHoveredPreviewGroups([]);
549
+ this.previewHoverRegions = [];
550
+ this.lastPreviewPointerPosition = null;
478
551
  this.reactionPicker = null;
479
552
  }
480
553
  if (!this.supported) {
@@ -486,6 +559,10 @@ export class CommentUiController {
486
559
  this.committedSelectionAnchor = null;
487
560
  this.clearSelectionRevealTimer();
488
561
  this.pointerSelecting = false;
562
+ this.updateHoveredEditorGroups([]);
563
+ this.updateHoveredPreviewGroups([]);
564
+ this.previewHoverRegions = [];
565
+ this.lastPreviewPointerPosition = null;
489
566
  this.reactionPicker = null;
490
567
  }
491
568
  this.render();
@@ -569,6 +646,24 @@ export class CommentUiController {
569
646
  this.render();
570
647
  }
571
648
 
649
+ setDrawerOpen(nextState) {
650
+ const normalizedState = Boolean(nextState);
651
+ if (normalizedState === this.drawerOpen) {
652
+ return;
653
+ }
654
+
655
+ if (normalizedState) {
656
+ this.onWillOpenDrawer?.();
657
+ }
658
+
659
+ this.drawerOpen = normalizedState;
660
+ this.render();
661
+ }
662
+
663
+ closeDrawer() {
664
+ this.setDrawerOpen(false);
665
+ }
666
+
572
667
  refreshLayout() {
573
668
  this.renderEditorLayer();
574
669
  this.renderPreviewLayer();
@@ -721,11 +816,19 @@ export class CommentUiController {
721
816
  }
722
817
 
723
818
  const relativeRect = toRelativeRect(rect, containerRect);
819
+ if (relativeRect.bottom < 0 || relativeRect.top > containerRect.height) {
820
+ return;
821
+ }
822
+
724
823
  const button = document.createElement('button');
725
824
  button.type = 'button';
726
825
  button.className = 'comment-editor-badge';
727
826
  button.dataset.count = String(group.threads.length);
728
- button.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
827
+ const isActive = this.activeCard?.groupKey === group.key;
828
+ const isHovered = this.hoveredEditorGroupKeys.includes(group.key);
829
+ button.classList.toggle('is-active', isActive);
830
+ button.classList.toggle('is-hovered', isHovered);
831
+ button.classList.toggle('is-passive', !isActive && !isHovered);
729
832
  button.setAttribute('aria-label', `${group.threads.length} comment thread${group.threads.length === 1 ? '' : 's'}`);
730
833
  button.appendChild(createCommentMarkerContent(group.threads.length));
731
834
  const top = Math.max(relativeRect.top, 8);
@@ -735,6 +838,18 @@ export class CommentUiController {
735
838
  button.addEventListener('pointerdown', (event) => {
736
839
  event.preventDefault();
737
840
  });
841
+ button.addEventListener('pointerenter', () => {
842
+ this.updateHoveredEditorGroups([group.key]);
843
+ });
844
+ button.addEventListener('pointerleave', () => {
845
+ this.updateHoveredEditorGroups([]);
846
+ });
847
+ button.addEventListener('focusin', () => {
848
+ this.updateHoveredEditorGroups([group.key]);
849
+ });
850
+ button.addEventListener('focusout', () => {
851
+ this.updateHoveredEditorGroups([]);
852
+ });
738
853
  button.addEventListener('click', () => {
739
854
  this.openThreadGroup(group, {
740
855
  anchor: group.anchor,
@@ -804,20 +919,36 @@ export class CommentUiController {
804
919
  this.previewHighlightLayer?.replaceChildren();
805
920
 
806
921
  if (!this.supported || !this.previewElement) {
922
+ this.previewHoverRegions = [];
807
923
  return;
808
924
  }
809
925
 
810
926
  const previewRect = this.previewElement.getBoundingClientRect();
811
927
  const groups = this.getThreadGroups();
928
+ const occupiedTops = [];
929
+ const hoverRegions = [];
930
+ const showPassiveMarkers = this.shouldRenderPassivePreviewMarkers();
812
931
  groups.forEach((group) => {
813
932
  const target = this.resolvePreviewTarget(group.anchor);
814
933
  if (!target?.bubbleRect) {
815
934
  return;
816
935
  }
817
936
 
937
+ hoverRegions.push({
938
+ key: group.key,
939
+ rects: target.hoverRects?.length > 0 ? target.hoverRects : [target.bubbleRect],
940
+ });
941
+
942
+ const isActive = this.activeCard?.groupKey === group.key;
943
+ const isHovered = this.hoveredPreviewGroupKeys.includes(group.key);
944
+ const isEmphasized = isActive || isHovered;
945
+
818
946
  target.highlightRects?.forEach((rect) => {
819
947
  const highlight = document.createElement('div');
820
948
  highlight.className = 'comment-preview-highlight';
949
+ highlight.classList.toggle('is-active', isActive);
950
+ highlight.classList.toggle('is-hovered', isHovered);
951
+ highlight.classList.toggle('is-passive', !isEmphasized);
821
952
  highlight.style.left = `${rect.left - previewRect.left}px`;
822
953
  highlight.style.top = `${rect.top - previewRect.top}px`;
823
954
  highlight.style.width = `${rect.width}px`;
@@ -825,14 +956,32 @@ export class CommentUiController {
825
956
  this.previewHighlightLayer?.appendChild(highlight);
826
957
  });
827
958
 
959
+ if (!showPassiveMarkers && !isEmphasized) {
960
+ return;
961
+ }
962
+
828
963
  const bubble = document.createElement('button');
829
964
  bubble.type = 'button';
830
965
  bubble.className = 'comment-preview-badge';
831
- bubble.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
966
+ bubble.dataset.commentPreviewGroupKeys = group.key;
967
+ bubble.classList.toggle('is-active', isActive);
968
+ bubble.classList.toggle('is-hovered', isHovered);
969
+ bubble.classList.toggle('is-passive', !isEmphasized);
832
970
  bubble.setAttribute('aria-label', `${group.threads.length} comment thread${group.threads.length === 1 ? '' : 's'}`);
833
971
  bubble.appendChild(createCommentMarkerContent(group.threads.length));
834
- bubble.style.left = `${clamp(target.bubbleRect.right - previewRect.left + 6, 6, this.previewElement.clientWidth - 34)}px`;
835
- bubble.style.top = `${Math.max(target.bubbleRect.top - previewRect.top, 6)}px`;
972
+ let bubbleTop = clamp(
973
+ target.bubbleRect.top - previewRect.top,
974
+ 6,
975
+ Math.max(this.previewElement.clientHeight - COMMENT_PREVIEW_RAIL_SLOT_HEIGHT, 6),
976
+ );
977
+ while (occupiedTops.some((top) => Math.abs(top - bubbleTop) < (COMMENT_PREVIEW_RAIL_SLOT_HEIGHT - 4))) {
978
+ bubbleTop = clamp(
979
+ bubbleTop + COMMENT_PREVIEW_RAIL_SLOT_HEIGHT,
980
+ 6,
981
+ Math.max(this.previewElement.clientHeight - COMMENT_PREVIEW_RAIL_SLOT_HEIGHT, 6),
982
+ );
983
+ }
984
+ bubble.style.top = `${bubbleTop}px`;
836
985
  bubble.title = `${group.threads.length} comment${group.threads.length === 1 ? '' : 's'}`;
837
986
  bubble.addEventListener('pointerdown', (event) => {
838
987
  event.preventDefault();
@@ -841,11 +990,19 @@ export class CommentUiController {
841
990
  this.openThreadGroup(group, {
842
991
  anchor: group.anchor,
843
992
  origin: 'preview',
844
- sourceRect: target.bubbleRect,
993
+ sourceRect: bubble.getBoundingClientRect(),
845
994
  });
846
995
  });
847
996
  this.previewLayer?.appendChild(bubble);
997
+ occupiedTops.push(bubbleTop);
848
998
  });
999
+
1000
+ this.previewHoverRegions = hoverRegions;
1001
+ if (this.lastPreviewPointerPosition) {
1002
+ this.updateHoveredPreviewGroups(
1003
+ this.getPreviewGroupKeysAtPoint(this.lastPreviewPointerPosition.x, this.lastPreviewPointerPosition.y),
1004
+ );
1005
+ }
849
1006
  }
850
1007
 
851
1008
  resolvePreviewTarget(anchor) {
@@ -859,6 +1016,7 @@ export class CommentUiController {
859
1016
  return {
860
1017
  bubbleRect: diagramShell.getBoundingClientRect(),
861
1018
  highlightRects: [],
1019
+ hoverRects: [diagramShell.getBoundingClientRect()],
862
1020
  };
863
1021
  }
864
1022
 
@@ -875,6 +1033,7 @@ export class CommentUiController {
875
1033
  return {
876
1034
  bubbleRect,
877
1035
  highlightRects: rects,
1036
+ hoverRects: rects,
878
1037
  };
879
1038
  }
880
1039
  }
@@ -887,6 +1046,7 @@ export class CommentUiController {
887
1046
  return {
888
1047
  bubbleRect: fallback.getBoundingClientRect(),
889
1048
  highlightRects: [],
1049
+ hoverRects: [fallback.getBoundingClientRect()],
890
1050
  };
891
1051
  }
892
1052
 
@@ -1079,6 +1239,69 @@ export class CommentUiController {
1079
1239
  this.scheduleLayoutRefresh();
1080
1240
  }
1081
1241
 
1242
+ getPreviewGroupKeysForTarget(target) {
1243
+ if (!(target instanceof Node)) {
1244
+ return [];
1245
+ }
1246
+
1247
+ const keyCarrier = target.closest?.('[data-comment-preview-group-keys]');
1248
+ return serializeGroupKeys(
1249
+ String(keyCarrier?.dataset?.commentPreviewGroupKeys ?? '')
1250
+ .split(/\s+/)
1251
+ .filter(Boolean),
1252
+ ).split(' ').filter(Boolean);
1253
+ }
1254
+
1255
+ getPreviewGroupKeysAtPoint(x, y) {
1256
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1257
+ return [];
1258
+ }
1259
+
1260
+ const targetAtPoint = document.elementFromPoint(x, y);
1261
+ const targetKeys = this.getPreviewGroupKeysForTarget(targetAtPoint);
1262
+ if (targetKeys.length > 0) {
1263
+ return targetKeys;
1264
+ }
1265
+
1266
+ const matchingKeys = this.previewHoverRegions
1267
+ .filter((region) => region.rects.some((rect) => pointIntersectsRect(x, y, rect)))
1268
+ .map((region) => region.key);
1269
+ return normalizeGroupKeys(matchingKeys);
1270
+ }
1271
+
1272
+ updateHoveredPreviewGroups(nextKeys = []) {
1273
+ const normalizedKeys = normalizeGroupKeys(nextKeys);
1274
+ const signature = normalizedKeys.join(' ');
1275
+ if (signature === this.hoveredPreviewGroupKeysSignature) {
1276
+ return;
1277
+ }
1278
+
1279
+ this.hoveredPreviewGroupKeys = normalizedKeys;
1280
+ this.hoveredPreviewGroupKeysSignature = signature;
1281
+ this.scheduleLayoutRefresh();
1282
+ }
1283
+
1284
+ updateHoveredEditorGroups(nextKeys = []) {
1285
+ const normalizedKeys = normalizeGroupKeys(nextKeys);
1286
+ const signature = normalizedKeys.join(' ');
1287
+ if (signature === this.hoveredEditorGroupKeysSignature) {
1288
+ return;
1289
+ }
1290
+
1291
+ this.hoveredEditorGroupKeys = normalizedKeys;
1292
+ this.hoveredEditorGroupKeysSignature = signature;
1293
+ this.scheduleLayoutRefresh();
1294
+ }
1295
+
1296
+ syncHoveredPreviewGroupsFromTarget(target) {
1297
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(target));
1298
+ }
1299
+
1300
+ shouldRenderPassivePreviewMarkers() {
1301
+ const previewWidth = this.previewContainer?.clientWidth ?? this.previewElement?.clientWidth ?? 0;
1302
+ return window.innerWidth >= COMMENT_PREVIEW_RAIL_BREAKPOINT && previewWidth >= COMMENT_PREVIEW_RAIL_MIN_WIDTH;
1303
+ }
1304
+
1082
1305
  updateReactionPickerPosition(card) {
1083
1306
  const bounds = getReactionPickerBounds(card);
1084
1307
  if (!bounds) {
@@ -54,13 +54,20 @@ export class FileExplorerController {
54
54
  async refresh() {
55
55
  try {
56
56
  const data = await this.vaultClient.readTree();
57
- this.state.setTree(data.tree || []);
58
- this.renderTree();
57
+ this.setTree(data.tree || []);
59
58
  } catch (error) {
60
59
  console.error('[explorer] Failed to load file tree:', error.message);
61
60
  }
62
61
  }
63
62
 
63
+ setTree(tree, {
64
+ changedPaths = null,
65
+ reset = false,
66
+ } = {}) {
67
+ this.state.setTree(tree);
68
+ this.renderTree({ changedPaths, reset });
69
+ }
70
+
64
71
  setActiveFile(filePath) {
65
72
  this.state.setActiveFile(filePath);
66
73
  this.renderTree();
@@ -74,10 +81,15 @@ export class FileExplorerController {
74
81
  return this.state.flatFiles.filter((path) => !isImageAttachmentFilePath(path));
75
82
  }
76
83
 
77
- renderTree() {
84
+ renderTree({
85
+ changedPaths = null,
86
+ reset = false,
87
+ } = {}) {
78
88
  this.view.render({
79
89
  activeFilePath: this.state.activeFilePath,
90
+ changedPaths,
80
91
  expandedDirs: this.state.expandedDirs,
92
+ reset,
81
93
  searchMatches: this.state.getSearchMatches(),
82
94
  searchQuery: this.state.searchQuery,
83
95
  tree: this.state.tree,
@@ -12,6 +12,29 @@ function getPathLeaf(path) {
12
12
  .pop() || '';
13
13
  }
14
14
 
15
+ function getParentPath(pathValue) {
16
+ const normalized = String(pathValue ?? '').replace(/\/+$/u, '');
17
+ const separatorIndex = normalized.lastIndexOf('/');
18
+ return separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : '';
19
+ }
20
+
21
+ function findNodeByPath(nodes = [], pathValue = '') {
22
+ for (const node of nodes) {
23
+ if (node.path === pathValue) {
24
+ return node;
25
+ }
26
+
27
+ if (node.type === 'directory' && Array.isArray(node.children)) {
28
+ const nested = findNodeByPath(node.children, pathValue);
29
+ if (nested) {
30
+ return nested;
31
+ }
32
+ }
33
+ }
34
+
35
+ return null;
36
+ }
37
+
15
38
  export class FileExplorerView {
16
39
  constructor({
17
40
  onDirectoryToggle,
@@ -27,6 +50,9 @@ export class FileExplorerView {
27
50
  this.onTreeContextMenu = onTreeContextMenu;
28
51
  this.treeContainer = document.getElementById('fileTree');
29
52
  this.searchInput = document.getElementById('fileSearchInput');
53
+ this.renderedDirectoryWrappers = new Map();
54
+ this.renderedChildContainers = new Map();
55
+ this.lastRenderMode = 'tree';
30
56
  }
31
57
 
32
58
  initialize() {
@@ -44,30 +70,62 @@ export class FileExplorerView {
44
70
  });
45
71
  }
46
72
 
47
- render({ activeFilePath, expandedDirs, searchMatches, searchQuery, tree }) {
73
+ render({ activeFilePath, changedPaths = null, expandedDirs, reset = false, searchMatches, searchQuery, tree }) {
48
74
  if (!this.treeContainer) {
49
75
  return;
50
76
  }
51
77
 
52
78
  if (searchQuery) {
79
+ this.lastRenderMode = 'search';
53
80
  this.renderSearchResults(searchMatches, activeFilePath);
54
81
  return;
55
82
  }
56
83
 
57
- this.treeContainer.innerHTML = '';
84
+ if (
85
+ reset
86
+ || this.lastRenderMode !== 'tree'
87
+ || !Array.isArray(changedPaths)
88
+ || changedPaths.length === 0
89
+ ) {
90
+ this.renderFullTree(tree, {
91
+ activeFilePath,
92
+ expandedDirs,
93
+ });
94
+ this.lastRenderMode = 'tree';
95
+ return;
96
+ }
58
97
 
59
- if (tree.length === 0) {
60
- this.treeContainer.innerHTML = '<div class="file-tree-empty">No vault files found</div>';
98
+ const affectedParentPaths = Array.from(new Set(
99
+ changedPaths.map((pathValue) => getParentPath(pathValue)),
100
+ ))
101
+ .sort((left, right) => left.split('/').length - right.split('/').length)
102
+ .filter((pathValue, index, values) => (
103
+ !values.slice(0, index).some((ancestorPath) => ancestorPath && pathValue.startsWith(`${ancestorPath}/`))
104
+ ));
105
+ if (affectedParentPaths.includes('')) {
106
+ this.renderFullTree(tree, {
107
+ activeFilePath,
108
+ expandedDirs,
109
+ });
110
+ this.lastRenderMode = 'tree';
61
111
  return;
62
112
  }
63
113
 
64
- const fragment = document.createDocumentFragment();
65
- this.renderNodes(tree, fragment, {
66
- activeFilePath,
67
- depth: 0,
68
- expandedDirs,
69
- });
70
- this.treeContainer.appendChild(fragment);
114
+ for (const parentPath of affectedParentPaths) {
115
+ if (!this.rerenderDirectoryBranch(parentPath, tree, {
116
+ activeFilePath,
117
+ expandedDirs,
118
+ })) {
119
+ this.renderFullTree(tree, {
120
+ activeFilePath,
121
+ expandedDirs,
122
+ });
123
+ this.lastRenderMode = 'tree';
124
+ return;
125
+ }
126
+ }
127
+
128
+ this.lastRenderMode = 'tree';
71
129
  }
72
130
 
73
131
  renderSearchResults(matches, activeFilePath) {
@@ -75,6 +133,7 @@ export class FileExplorerView {
75
133
  return;
76
134
  }
77
135
 
136
+ this.resetTreeIndexes();
78
137
  this.treeContainer.innerHTML = '';
79
138
 
80
139
  if (matches.length === 0) {
@@ -95,6 +154,29 @@ export class FileExplorerView {
95
154
  this.treeContainer.appendChild(fragment);
96
155
  }
97
156
 
157
+ renderFullTree(tree, { activeFilePath, expandedDirs }) {
158
+ this.resetTreeIndexes();
159
+ this.treeContainer.innerHTML = '';
160
+
161
+ if (tree.length === 0) {
162
+ this.treeContainer.innerHTML = '<div class="file-tree-empty">No vault files found</div>';
163
+ return;
164
+ }
165
+
166
+ const fragment = document.createDocumentFragment();
167
+ this.renderNodes(tree, fragment, {
168
+ activeFilePath,
169
+ depth: 0,
170
+ expandedDirs,
171
+ });
172
+ this.treeContainer.appendChild(fragment);
173
+ }
174
+
175
+ resetTreeIndexes() {
176
+ this.renderedDirectoryWrappers.clear();
177
+ this.renderedChildContainers.clear();
178
+ }
179
+
98
180
  renderNodes(nodes, container, { activeFilePath, depth, expandedDirs }) {
99
181
  for (const node of nodes) {
100
182
  if (node.type === 'directory') {
@@ -142,21 +224,80 @@ export class FileExplorerView {
142
224
  });
143
225
 
144
226
  wrapper.appendChild(button);
227
+ this.renderedDirectoryWrappers.set(node.path, wrapper);
145
228
 
146
229
  if (isExpanded && Array.isArray(node.children)) {
147
230
  const childContainer = document.createElement('div');
148
231
  childContainer.className = 'file-tree-children';
232
+ this.renderedChildContainers.set(node.path, childContainer);
149
233
  this.renderNodes(node.children, childContainer, {
150
234
  activeFilePath,
151
235
  depth: depth + 1,
152
236
  expandedDirs,
153
237
  });
154
238
  wrapper.appendChild(childContainer);
239
+ } else {
240
+ this.renderedChildContainers.delete(node.path);
155
241
  }
156
242
 
157
243
  return wrapper;
158
244
  }
159
245
 
246
+ rerenderDirectoryBranch(parentPath, tree, { activeFilePath, expandedDirs }) {
247
+ const wrapper = this.renderedDirectoryWrappers.get(parentPath);
248
+ const parentNode = findNodeByPath(tree, parentPath);
249
+ if (!wrapper || parentNode?.type !== 'directory') {
250
+ return false;
251
+ }
252
+
253
+ const button = wrapper.querySelector('.file-tree-dir');
254
+ const isExpanded = expandedDirs.has(parentPath);
255
+ button?.setAttribute('aria-expanded', String(isExpanded));
256
+ button?.querySelector('.file-tree-chevron')?.classList.toggle('expanded', isExpanded);
257
+
258
+ const depth = Number(button?.dataset.depth ?? 0);
259
+ let childContainer = this.renderedChildContainers.get(parentPath) ?? wrapper.querySelector('.file-tree-children');
260
+
261
+ this.clearRenderedDescendants(parentPath);
262
+
263
+ if (!isExpanded) {
264
+ childContainer?.remove();
265
+ this.renderedChildContainers.delete(parentPath);
266
+ return true;
267
+ }
268
+
269
+ if (!childContainer) {
270
+ childContainer = document.createElement('div');
271
+ childContainer.className = 'file-tree-children';
272
+ wrapper.appendChild(childContainer);
273
+ } else {
274
+ childContainer.innerHTML = '';
275
+ }
276
+ this.renderedChildContainers.set(parentPath, childContainer);
277
+
278
+ this.renderNodes(parentNode.children ?? [], childContainer, {
279
+ activeFilePath,
280
+ depth: depth + 1,
281
+ expandedDirs,
282
+ });
283
+
284
+ return true;
285
+ }
286
+
287
+ clearRenderedDescendants(parentPath) {
288
+ const prefix = `${parentPath}/`;
289
+ Array.from(this.renderedDirectoryWrappers.keys()).forEach((pathValue) => {
290
+ if (pathValue.startsWith(prefix)) {
291
+ this.renderedDirectoryWrappers.delete(pathValue);
292
+ }
293
+ });
294
+ Array.from(this.renderedChildContainers.keys()).forEach((pathValue) => {
295
+ if (pathValue.startsWith(prefix)) {
296
+ this.renderedChildContainers.delete(pathValue);
297
+ }
298
+ });
299
+ }
300
+
160
301
  createFileItem({ activeFilePath, depth, filePath, fileType = 'file', name }) {
161
302
  const button = document.createElement('button');
162
303
  button.className = 'file-tree-item file-tree-file';