collabmd 0.1.20 → 0.1.23

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 (120) hide show
  1. package/README.md +98 -9
  2. package/bin/collabmd.js +3 -2
  3. package/docker-compose.yml +13 -1
  4. package/package.json +2 -1
  5. package/public/assets/css/style.css +1 -1
  6. package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
  7. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  8. package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
  9. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  10. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  11. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +503 -182
  14. package/public/assets/js/preview-render-worker.js +15 -15
  15. package/public/index.html +104 -84
  16. package/src/client/application/app-shell/git-feature.js +304 -27
  17. package/src/client/application/app-shell/ui-feature.js +69 -7
  18. package/src/client/application/app-shell/workspace-feature.js +46 -0
  19. package/src/client/application/app-shell-elements.js +2 -0
  20. package/src/client/application/mermaid-preview-hydrator.js +1 -1
  21. package/src/client/application/plantuml-preview-hydrator.js +1 -1
  22. package/src/client/application/workspace-coordinator.js +147 -18
  23. package/src/client/application/workspace-preview-controller.js +34 -0
  24. package/src/client/application/workspace-route-controller.js +104 -0
  25. package/src/client/bootstrap/collabmd-app-shell.js +107 -0
  26. package/src/client/domain/runtime-paths.js +5 -0
  27. package/src/client/domain/vault-utils.js +2 -2
  28. package/src/client/excalidraw-editor.js +52 -3
  29. package/src/client/infrastructure/auth-client.js +219 -52
  30. package/src/client/infrastructure/browser-navigation-port.js +20 -0
  31. package/src/client/infrastructure/editor-collaboration-client.js +4 -0
  32. package/src/client/infrastructure/editor-session.js +51 -7
  33. package/src/client/infrastructure/editor-view-adapter.js +297 -53
  34. package/src/client/infrastructure/runtime-config.js +66 -0
  35. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  36. package/src/client/presentation/comment-ui-controller.js +274 -31
  37. package/src/client/presentation/comments-panel.js +2 -2
  38. package/src/client/presentation/excalidraw-embed-controller.js +60 -2
  39. package/src/client/presentation/file-explorer-controller.js +15 -3
  40. package/src/client/presentation/file-explorer-view.js +152 -11
  41. package/src/client/presentation/file-history-view-controller.js +806 -0
  42. package/src/client/presentation/git-diff-view-controller.js +718 -121
  43. package/src/client/presentation/git-panel-controller.js +458 -66
  44. package/src/client/presentation/layout-controller.js +1 -1
  45. package/src/client/styles/components/actions.css +89 -0
  46. package/src/client/styles/components/feedback.css +43 -0
  47. package/src/client/styles/components/navigation.css +68 -0
  48. package/src/client/styles/components/scrollbars.css +104 -0
  49. package/src/client/styles/components/states.css +289 -0
  50. package/src/client/styles/components/surfaces.css +155 -0
  51. package/src/client/styles/features/backlinks.css +213 -0
  52. package/src/client/styles/features/collaboration-chat.css +228 -0
  53. package/src/client/styles/features/collaboration-dialogs.css +154 -0
  54. package/src/client/styles/features/collaboration-presence.css +103 -0
  55. package/src/client/styles/features/collaboration-ui.css +3 -0
  56. package/src/client/styles/features/comment-card.css +236 -0
  57. package/src/client/styles/features/comment-markdown.css +127 -0
  58. package/src/client/styles/features/comment-overlays.css +60 -0
  59. package/src/client/styles/features/comments-drawer.css +120 -0
  60. package/src/client/styles/features/comments.css +4 -0
  61. package/src/client/styles/features/diagram-preview.css +227 -0
  62. package/src/client/styles/features/diff-commit.css +268 -0
  63. package/src/client/styles/features/diff-lines.css +160 -0
  64. package/src/client/styles/features/diff-toolbar.css +98 -0
  65. package/src/client/styles/features/diff.css +3 -0
  66. package/src/client/styles/features/embedded-preview.css +218 -0
  67. package/src/client/styles/features/git-branch.css +155 -0
  68. package/src/client/styles/features/git-files.css +105 -0
  69. package/src/client/styles/features/git-history.css +339 -0
  70. package/src/client/styles/features/git.css +3 -0
  71. package/src/client/styles/features/media-lightbox.css +157 -0
  72. package/src/client/styles/features/outline.css +115 -0
  73. package/src/client/styles/features/preview-content.css +3 -0
  74. package/src/client/styles/features/preview-markdown.css +201 -0
  75. package/src/client/styles/features/preview-sidebars.css +2 -0
  76. package/src/client/styles/features/quick-switcher.css +167 -0
  77. package/src/client/styles/foundation/themes.css +347 -0
  78. package/src/client/styles/foundation/tokens.css +66 -0
  79. package/src/client/styles/foundation/utilities.css +36 -0
  80. package/src/client/styles/layout/editor-page.css +203 -0
  81. package/src/client/styles/layout/overlays.css +132 -0
  82. package/src/client/styles/layout/responsive.css +251 -0
  83. package/src/client/styles/layout/shell.css +314 -0
  84. package/src/client/styles/layout/sidebar.css +176 -0
  85. package/src/client/styles/layout/view-modes.css +92 -0
  86. package/src/client/styles/overrides/codemirror.css +53 -0
  87. package/src/client/styles/overrides/highlightjs.css +7 -0
  88. package/src/client/styles/overrides/vendor.css +2 -0
  89. package/src/client/styles/primitives/controls.css +276 -0
  90. package/src/client/styles/style.css +25 -4901
  91. package/src/domain/wiki-link-resolver.js +16 -5
  92. package/src/domain/workspace-change.js +68 -0
  93. package/src/domain/workspace-room.js +3 -0
  94. package/src/server/auth/create-auth-service.js +373 -15
  95. package/src/server/auth/session-cookie.js +30 -4
  96. package/src/server/config/env.js +121 -5
  97. package/src/server/create-app-server.js +49 -10
  98. package/src/server/domain/backlink-index.js +94 -1
  99. package/src/server/domain/collaboration/collaboration-room.js +281 -69
  100. package/src/server/domain/collaboration/room-registry.js +64 -10
  101. package/src/server/index.js +3 -1
  102. package/src/server/infrastructure/git/command-runner.js +2 -1
  103. package/src/server/infrastructure/git/git-service.js +48 -2
  104. package/src/server/infrastructure/git/history-service.js +683 -0
  105. package/src/server/infrastructure/git/parsers.js +30 -0
  106. package/src/server/infrastructure/git/responses.js +85 -19
  107. package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
  108. package/src/server/infrastructure/http/create-git-api-command-handler.js +55 -43
  109. package/src/server/infrastructure/http/create-git-api-handler.js +4 -0
  110. package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
  111. package/src/server/infrastructure/http/create-request-handler.js +22 -0
  112. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  113. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  114. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  115. package/src/server/infrastructure/http/http-response.js +65 -28
  116. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  117. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  118. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  119. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  120. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +0 -22
@@ -77,6 +77,8 @@ export class ExcalidrawEmbedController {
77
77
  this.maximizedRoot = null;
78
78
  this.placeholderObserver = null;
79
79
  this.followedPeerIdsByFilePath = new Map();
80
+ this.pendingDisconnectRequests = new Map();
81
+ this.disconnectRequestCounter = 0;
80
82
 
81
83
  this._onMessage = this._onMessage.bind(this);
82
84
  this._onKeyDown = this._onKeyDown.bind(this);
@@ -104,6 +106,10 @@ export class ExcalidrawEmbedController {
104
106
  entry.placeholder = null;
105
107
  });
106
108
  this.embedEntries.clear();
109
+ this.pendingDisconnectRequests.forEach((request) => {
110
+ request.resolve(false);
111
+ });
112
+ this.pendingDisconnectRequests.clear();
107
113
  this.followedPeerIdsByFilePath.clear();
108
114
  this.overlayRoot?.remove();
109
115
  this.overlayRoot = null;
@@ -242,6 +248,43 @@ export class ExcalidrawEmbedController {
242
248
  });
243
249
  }
244
250
 
251
+ prepareFileDisconnect(filePath, { timeoutMs = 250 } = {}) {
252
+ const entry = this._findEntryByFilePath(filePath);
253
+ if (!entry?.iframe?.contentWindow) {
254
+ return Promise.resolve(false);
255
+ }
256
+
257
+ const requestId = `disconnect-${++this.disconnectRequestCounter}`;
258
+ return new Promise((resolve) => {
259
+ const finish = (value) => {
260
+ if (!this.pendingDisconnectRequests.has(requestId)) {
261
+ return;
262
+ }
263
+
264
+ const request = this.pendingDisconnectRequests.get(requestId);
265
+ window.clearTimeout(request.timeoutId);
266
+ this.pendingDisconnectRequests.delete(requestId);
267
+ resolve(value);
268
+ };
269
+
270
+ const timeoutId = window.setTimeout(() => {
271
+ finish(false);
272
+ }, timeoutMs);
273
+
274
+ this.pendingDisconnectRequests.set(requestId, {
275
+ entry,
276
+ resolve: finish,
277
+ timeoutId,
278
+ });
279
+
280
+ this._postMessageToEntry(entry, {
281
+ source: 'collabmd-host',
282
+ type: 'prepare-disconnect',
283
+ requestId,
284
+ });
285
+ });
286
+ }
287
+
245
288
  _ensurePlaceholderObserver() {
246
289
  if (!this.previewContainer) {
247
290
  return;
@@ -422,6 +465,13 @@ export class ExcalidrawEmbedController {
422
465
  }
423
466
 
424
467
  _destroyEntry(entry) {
468
+ this.pendingDisconnectRequests.forEach((request, requestId) => {
469
+ if (request.entry === entry) {
470
+ request.resolve(false);
471
+ this.pendingDisconnectRequests.delete(requestId);
472
+ }
473
+ });
474
+
425
475
  if (this.maximizedEmbed?.wrapper === entry.wrapper) {
426
476
  this._exitMaximizedEmbed();
427
477
  }
@@ -588,14 +638,14 @@ export class ExcalidrawEmbedController {
588
638
 
589
639
  const openBtn = document.createElement('button');
590
640
  openBtn.type = 'button';
591
- openBtn.className = 'excalidraw-embed-btn diagram-preview-action-btn';
641
+ openBtn.className = 'excalidraw-embed-btn ui-preview-action ui-preview-action--icon-only';
592
642
  openBtn.title = 'Edit in Excalidraw';
593
643
  openBtn.setAttribute('aria-label', 'Edit in Excalidraw');
594
644
  setDiagramActionButtonIcon(openBtn, 'edit');
595
645
 
596
646
  const maxBtn = document.createElement('button');
597
647
  maxBtn.type = 'button';
598
- maxBtn.className = 'excalidraw-embed-btn diagram-preview-action-btn';
648
+ maxBtn.className = 'excalidraw-embed-btn ui-preview-action ui-preview-action--icon-only';
599
649
  maxBtn.title = 'Maximize diagram';
600
650
  maxBtn.setAttribute('aria-label', 'Maximize diagram');
601
651
  setDiagramActionButtonIcon(maxBtn, 'maximize');
@@ -915,6 +965,14 @@ export class ExcalidrawEmbedController {
915
965
  if (msg.type === 'error') {
916
966
  entry.isReady = false;
917
967
  void this._handleEntryBootTimeout(entry);
968
+ return;
969
+ }
970
+
971
+ if (msg.type === 'disconnect-ready') {
972
+ const request = this.pendingDisconnectRequests.get(msg.requestId);
973
+ if (request?.entry === entry) {
974
+ request.resolve(true);
975
+ }
918
976
  }
919
977
  }
920
978
 
@@ -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';