collabmd 0.1.45 → 0.1.46

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 (190) hide show
  1. package/README.md +39 -19
  2. package/bin/collabmd.js +27 -1
  3. package/dist/client/assets/CodeMirrorEditor-UCMRAMCV-CYM5nxUi.js +1 -0
  4. package/dist/client/assets/browser-utils--H_DkyEF.js +1 -0
  5. package/dist/client/assets/chunk-ERSWUTIN-hHdvnhaY.js +41 -0
  6. package/dist/client/assets/comment-thread-store-CdcwrObc.js +1 -0
  7. package/dist/client/assets/comment-threads-9J6Em-_y.js +4 -0
  8. package/dist/client/assets/comment-ui-shared-BC4pt4w8.js +7 -0
  9. package/dist/client/assets/dist-B5HTH2BO.js +1 -0
  10. package/dist/client/assets/{dist-DhLvC8DS.js → dist-B5Jf1SLZ.js} +2 -2
  11. package/dist/client/assets/{dist-CnL7Y2sM.js → dist-BDt3rz7l.js} +1 -1
  12. package/dist/client/assets/dist-BEwK4csK.js +1 -0
  13. package/dist/client/assets/dist-BKW4PALF.js +1 -0
  14. package/dist/client/assets/dist-BWcHHOx-.js +1 -0
  15. package/dist/client/assets/{dist-CYZmbokb.js → dist-BalScOwZ.js} +9 -9
  16. package/dist/client/assets/dist-Bf8f4HmM.js +6 -0
  17. package/dist/client/assets/dist-BjHJDtyU.js +1 -0
  18. package/dist/client/assets/dist-Bm6pf9Bl.js +1 -0
  19. package/dist/client/assets/dist-BoVBdSjk.js +1 -0
  20. package/dist/client/assets/dist-CC5IrQMG.js +1 -0
  21. package/dist/client/assets/{dist-C11hHqv_.js → dist-CCP_AKPG.js} +12 -12
  22. package/dist/client/assets/dist-CZgOmxK6.js +1 -0
  23. package/dist/client/assets/{dist-D2AQ-76g.js → dist-Cqhv5LAk.js} +1 -1
  24. package/dist/client/assets/{dist-MvcV4190.js → dist-D-Z6FLem.js} +1 -1
  25. package/dist/client/assets/dist-DLMiIsNC.js +11 -0
  26. package/dist/client/assets/{dist-3bgCp7iY.js → dist-DZKu9Cs8.js} +1 -1
  27. package/dist/client/assets/dist-DaYdhcVs.js +1 -0
  28. package/dist/client/assets/dist-FWJsgGYI.js +1 -0
  29. package/dist/client/assets/dist-Kh2COra_.js +1 -0
  30. package/dist/client/assets/{dist-JYEDRrPT.js → dist-PDZ3bSqm.js} +1 -1
  31. package/dist/client/assets/{dist-Drp8uu5J.js → dist-TjMh8n1u.js} +1 -1
  32. package/dist/client/assets/{dist-DGb27Dl6.js → dist-dtAdgL39.js} +1 -1
  33. package/dist/client/assets/dist-jv4WdPnK.js +9 -0
  34. package/dist/client/assets/{dist-DK5Gs2wa.js → dist-rGBmqxC7.js} +1 -1
  35. package/dist/client/assets/{dist-DCzuX1AF.js → dist-shrl6K_0.js} +1 -1
  36. package/dist/client/assets/dist-tOaegDYS2.js +1 -0
  37. package/dist/client/assets/dist-ule2n6ak.js +1 -0
  38. package/dist/client/assets/dist-xElaxbia.js +1 -0
  39. package/dist/client/assets/{drawio-editor-ClE8jpoG.js → drawio-editor-FzY1cVw8.js} +1 -1
  40. package/dist/client/assets/drawioEditor-BjiKJqlc.js +2 -0
  41. package/dist/client/assets/editor-session-Cm9ekeNo.js +54 -0
  42. package/dist/client/assets/embedded-editor-base-CwTkk911.css +1 -0
  43. package/dist/client/assets/{preview-render-profile-BZzGSg6K.js → es-DXGV3RHd.js} +13 -13
  44. package/dist/client/assets/excalidraw-editor-B46zsTFI.js +9 -0
  45. package/dist/client/assets/{excalidraw-editor-CyGwOhLz.css → excalidraw-editor-Bt7gMSoh.css} +1 -1
  46. package/dist/client/assets/excalidraw-scene-BOben2gr.js +1 -0
  47. package/dist/client/assets/excalidrawEditor-CT2pHq7e.js +2 -0
  48. package/dist/client/assets/{export-page-CUfws2T-.js → export-page-A7NHawJY.js} +3 -3
  49. package/dist/client/assets/exportDocument-B2L8fe34.css +1 -0
  50. package/dist/client/assets/exportDocument-BaqcOAAW.js +2 -0
  51. package/dist/client/assets/file-icon-svg-CQAjlWjK.js +1 -0
  52. package/dist/client/assets/file-kind-BbqgGzpc.js +1 -0
  53. package/dist/client/assets/image-QXRYHUNP-SWEV8H0J.js +1 -0
  54. package/dist/client/assets/index-ClbjMBVt.js +2 -0
  55. package/dist/client/assets/index-Pum8ET-5.css +1 -0
  56. package/dist/client/assets/main-B3uPyqic.js +893 -0
  57. package/dist/client/assets/pdf-BL9Pr2D5.js +54 -0
  58. package/dist/client/assets/pdf.worker-CLesOks4.mjs +64857 -0
  59. package/dist/client/assets/pdf_viewer-FVkkThnz.js +551 -0
  60. package/dist/client/assets/{preview-render-compiler-BSaQO_p3.js → preview-render-compiler-BD-XhkS2.js} +2 -2
  61. package/dist/client/assets/preview-render-compiler-BKedaMxx.js +1 -0
  62. package/dist/client/assets/preview-render-profile-CE-5rVTC.js +1 -0
  63. package/dist/client/assets/{preview-render-worker-B42LWvrD.js → preview-render-worker-CiPqc9n6.js} +20 -20
  64. package/dist/client/assets/prod-Dy20X7eT.js +1 -0
  65. package/dist/client/assets/prod-hnwh7wRM.js +177 -0
  66. package/dist/client/assets/quick-switcher-controller-fqOYR7qR.js +12 -0
  67. package/dist/client/assets/runtime-config-BLrEKVO1.js +1 -0
  68. package/dist/client/assets/vault-api-client-_LpV1aIn.js +6 -0
  69. package/dist/client/drawio-editor.html +1 -1
  70. package/dist/client/excalidraw-editor.html +1 -1
  71. package/dist/client/export-document.html +2 -2
  72. package/dist/client/index.html +15 -3
  73. package/docker-compose.yml +42 -0
  74. package/package.json +17 -4
  75. package/scripts/local-structurizr-compose.mjs +101 -0
  76. package/src/client/app/index.html +13 -1
  77. package/src/client/application/app-shell/comments-feature.js +19 -10
  78. package/src/client/application/app-shell/git-feature.js +12 -0
  79. package/src/client/application/app-shell/presence-feature.js +2 -1
  80. package/src/client/application/app-shell/ui-feature-shell.js +45 -2
  81. package/src/client/application/app-shell/ui-feature-toolbar.js +2 -52
  82. package/src/client/application/app-shell/workspace-feature.js +5 -1
  83. package/src/client/application/app-shell-elements.js +2 -0
  84. package/src/client/application/pdf-preview-controller.js +992 -0
  85. package/src/client/application/structurizr-preview-controller.js +395 -0
  86. package/src/client/application/workspace-coordinator.js +25 -3
  87. package/src/client/application/workspace-preview-controller.js +91 -2
  88. package/src/client/bootstrap/collabmd-app-shell.js +31 -5
  89. package/src/client/browser-utils.js +44 -0
  90. package/src/client/domain/runtime-paths.js +26 -5
  91. package/src/client/domain/structurizr-language.js +99 -0
  92. package/src/client/domain/vault-paths.js +48 -0
  93. package/src/client/excalidraw-editor.js +833 -51
  94. package/src/client/infrastructure/browser-preferences-port.js +10 -0
  95. package/src/client/infrastructure/comment-thread-store.js +34 -13
  96. package/src/client/infrastructure/editor-session.js +10 -0
  97. package/src/client/infrastructure/editor-view-adapter.js +46 -2
  98. package/src/client/infrastructure/excalidraw-room-client.js +103 -14
  99. package/src/client/infrastructure/git-api-client.js +5 -0
  100. package/src/client/infrastructure/vault-api-client.js +26 -0
  101. package/src/client/presentation/comment-ui/comment-ui-shared.js +4 -0
  102. package/src/client/presentation/drawio-embed-controller.js +9 -102
  103. package/src/client/presentation/drawio-viewer.js +118 -0
  104. package/src/client/presentation/excalidraw-embed-controller.js +74 -8
  105. package/src/client/presentation/file-action-controller.js +102 -0
  106. package/src/client/presentation/file-explorer-view.js +10 -29
  107. package/src/client/presentation/file-icon-svg.js +40 -0
  108. package/src/client/presentation/file-tree-state.js +2 -0
  109. package/src/client/presentation/git-diff-view-controller.js +731 -3
  110. package/src/client/presentation/outline-controller.js +86 -0
  111. package/src/client/presentation/quick-switcher-controller.js +25 -13
  112. package/src/client/presentation/quick-switcher-text-search.js +1 -1
  113. package/src/client/styles/components/navigation.css +11 -1
  114. package/src/client/styles/features/diagram-preview.css +76 -0
  115. package/src/client/styles/features/diff-special.css +309 -0
  116. package/src/client/styles/foundation/themes.css +6 -0
  117. package/src/client/styles/layout/shell.css +358 -1
  118. package/src/client/styles/layout/view-modes.css +1 -0
  119. package/src/client/styles/style.css +1 -0
  120. package/src/client/styles/surfaces/excalidraw-editor.css +294 -0
  121. package/src/domain/comment-threads.js +63 -5
  122. package/src/domain/excalidraw-room-codec.js +6 -4
  123. package/src/domain/file-kind.js +35 -3
  124. package/src/server/config/env.js +79 -0
  125. package/src/server/create-app-server.js +12 -0
  126. package/src/server/domain/bases/base-definition.js +1 -1
  127. package/src/server/domain/bases/base-expression-runtime.js +1 -4
  128. package/src/server/domain/bases/base-index-snapshot-store.js +1 -4
  129. package/src/server/domain/bases/base-transform.js +1 -4
  130. package/src/server/domain/collaboration/collaboration-room.js +55 -21
  131. package/src/server/domain/comment-overview.js +15 -5
  132. package/src/server/domain/ripgrep-search-service.js +4 -0
  133. package/src/server/infrastructure/git/diff-service.js +5 -0
  134. package/src/server/infrastructure/git/git-service.js +21 -0
  135. package/src/server/infrastructure/git/history-service.js +54 -0
  136. package/src/server/infrastructure/git/image-mime.js +14 -0
  137. package/src/server/infrastructure/git/untracked-files.js +28 -1
  138. package/src/server/infrastructure/http/create-git-api-query-handler.js +31 -1
  139. package/src/server/infrastructure/http/create-request-handler.js +16 -23
  140. package/src/server/infrastructure/http/create-static-handler.js +5 -2
  141. package/src/server/infrastructure/http/create-structurizr-api-handler.js +145 -0
  142. package/src/server/infrastructure/http/create-vault-api-command-handler.js +66 -4
  143. package/src/server/infrastructure/http/create-vault-api-query-handler.js +51 -3
  144. package/src/server/infrastructure/http/http-request-helpers.js +22 -0
  145. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  146. package/src/server/infrastructure/persistence/vault-file-store.js +3 -0
  147. package/src/server/infrastructure/structurizr/structurizr-embed.css +43 -0
  148. package/src/server/infrastructure/structurizr/structurizr-workspace-service.js +374 -0
  149. package/src/server/infrastructure/websocket/attach-collaboration-gateway.js +4 -22
  150. package/dist/client/assets/CodeMirrorEditor-UCMRAMCV-CbSISC1w.js +0 -1
  151. package/dist/client/assets/browser-utils-CKtI_rZl.js +0 -1
  152. package/dist/client/assets/chunk-ERSWUTIN-ME9RVNel.js +0 -41
  153. package/dist/client/assets/comment-threads-DDegmaFO.js +0 -4
  154. package/dist/client/assets/dist-BH87JoHx.js +0 -11
  155. package/dist/client/assets/dist-BIkPRZO7.js +0 -6
  156. package/dist/client/assets/dist-BqCsFpx4.js +0 -1
  157. package/dist/client/assets/dist-C2JHCt8U.js +0 -1
  158. package/dist/client/assets/dist-CZjTtmTu.js +0 -1
  159. package/dist/client/assets/dist-Cig4Ov_X.js +0 -1
  160. package/dist/client/assets/dist-CqQEC8gR.js +0 -1
  161. package/dist/client/assets/dist-D4Rzqod1.js +0 -1
  162. package/dist/client/assets/dist-D5sn-mwk.js +0 -9
  163. package/dist/client/assets/dist-D7G-n87v.js +0 -1
  164. package/dist/client/assets/dist-DRqOrNgN.js +0 -1
  165. package/dist/client/assets/dist-DetkMS_4.js +0 -1
  166. package/dist/client/assets/dist-Dp6FVkQ1.js +0 -1
  167. package/dist/client/assets/dist-DtbjNB28.js +0 -1
  168. package/dist/client/assets/dist-DyrYcvpo2.js +0 -1
  169. package/dist/client/assets/dist-GrygD2zE.js +0 -1
  170. package/dist/client/assets/dist-HmEpWUow.js +0 -1
  171. package/dist/client/assets/dist-RTa81Km7.js +0 -1
  172. package/dist/client/assets/drawioEditor-CXaTM9Ds.js +0 -2
  173. package/dist/client/assets/editor-session-GTwZS6Df.js +0 -16
  174. package/dist/client/assets/embedded-editor-base-CiNEc8vy.css +0 -1
  175. package/dist/client/assets/excalidraw-editor-BZVM81BO.js +0 -9
  176. package/dist/client/assets/excalidraw-scene-C8n9CHsK.js +0 -176
  177. package/dist/client/assets/excalidrawEditor-DxJanCze.js +0 -2
  178. package/dist/client/assets/exportDocument-BCuxqXDY.js +0 -2
  179. package/dist/client/assets/exportDocument-DV-OX74E.css +0 -1
  180. package/dist/client/assets/file-kind-BKPOIRPE.js +0 -1
  181. package/dist/client/assets/image-QXRYHUNP-lfTHE3bb.js +0 -1
  182. package/dist/client/assets/index-C3YnUgMD.css +0 -1
  183. package/dist/client/assets/index-D2wXraJ1.js +0 -2
  184. package/dist/client/assets/main-DtiGz7uI.js +0 -795
  185. package/dist/client/assets/preview-render-compiler-csGM5ZFz.js +0 -1
  186. package/dist/client/assets/quick-switcher-controller-shW_6vPY.js +0 -12
  187. package/dist/client/assets/room-CQLoqtMo.js +0 -1
  188. package/dist/client/assets/runtime-config-DSGmGE9V.js +0 -1
  189. package/dist/client/assets/vault-api-client-C6bcGwy-.js +0 -5
  190. package/src/server/domain/collaboration/room-persistence-controller.js +0 -80
@@ -5,7 +5,7 @@ export const COMMENT_EXCERPT_MAX_LENGTH = 160;
5
5
  export const COMMENT_ANCHOR_QUOTE_MAX_LENGTH = 280;
6
6
  export const COMMENT_REACTION_EMOJI_MAX_LENGTH = 16;
7
7
 
8
- const COMMENT_ANCHOR_KINDS = new Set(['line', 'text']);
8
+ const COMMENT_ANCHOR_KINDS = new Set(['diagram-element', 'line', 'text']);
9
9
 
10
10
  function asFiniteNumber(value) {
11
11
  return Number.isFinite(value) ? value : null;
@@ -55,6 +55,38 @@ function normalizeAnchorKind(value) {
55
55
  return COMMENT_ANCHOR_KINDS.has(value) ? value : null;
56
56
  }
57
57
 
58
+ function normalizeDiagramElementId(value) {
59
+ const normalized = asString(value).trim().slice(0, 240);
60
+ return normalized || null;
61
+ }
62
+
63
+ function normalizeDiagramAnchorPoint(value) {
64
+ const point = asObject(value);
65
+ const x = asFiniteNumber(point?.x);
66
+ const y = asFiniteNumber(point?.y);
67
+ return x === null || y === null ? null : { x, y };
68
+ }
69
+
70
+ function normalizeDiagramAnchorSnapshot(value) {
71
+ const snapshot = asObject(value);
72
+ const x = asFiniteNumber(snapshot?.x);
73
+ const y = asFiniteNumber(snapshot?.y);
74
+ const width = asFiniteNumber(snapshot?.width);
75
+ const height = asFiniteNumber(snapshot?.height);
76
+ if (x === null || y === null || width === null || height === null) {
77
+ return null;
78
+ }
79
+
80
+ return {
81
+ height,
82
+ text: normalizeCommentQuote(snapshot?.text),
83
+ type: asString(snapshot?.type).trim() || 'element',
84
+ width,
85
+ x,
86
+ y,
87
+ };
88
+ }
89
+
58
90
  export function normalizeCommentBody(value) {
59
91
  const normalized = String(value ?? '')
60
92
  .replace(/\r\n?/g, '\n')
@@ -196,6 +228,23 @@ export function createCommentId(prefix = 'comment') {
196
228
 
197
229
  export function normalizeCommentAnchor(record = {}) {
198
230
  const anchorKind = normalizeAnchorKind(record.anchorKind);
231
+ if (anchorKind === 'diagram-element') {
232
+ const anchorPoint = normalizeDiagramAnchorPoint(record.anchorPoint);
233
+ const anchorSnapshot = normalizeDiagramAnchorSnapshot(record.anchorSnapshot);
234
+ const elementId = normalizeDiagramElementId(record.elementId);
235
+ if (!anchorPoint || !anchorSnapshot || !elementId) {
236
+ return null;
237
+ }
238
+
239
+ return {
240
+ anchorKind,
241
+ anchorPoint,
242
+ anchorQuote: normalizeCommentQuote(record.anchorQuote || anchorSnapshot.text),
243
+ anchorSnapshot,
244
+ elementId,
245
+ };
246
+ }
247
+
199
248
  const anchorStart = asObject(record.anchorStart);
200
249
  const anchorEnd = asObject(record.anchorEnd);
201
250
  const anchorStartLine = asFiniteNumber(record.anchorStartLine);
@@ -235,12 +284,18 @@ export function createCommentThreadSharedType(record = {}) {
235
284
  messages.push(normalizedMessages.length > 0 ? normalizedMessages : [initialMessage]);
236
285
 
237
286
  const thread = new Y.Map();
238
- thread.set('anchorEnd', anchor.anchorEnd);
239
- thread.set('anchorEndLine', anchor.anchorEndLine);
240
287
  thread.set('anchorKind', anchor.anchorKind);
241
288
  thread.set('anchorQuote', anchor.anchorQuote);
242
- thread.set('anchorStart', anchor.anchorStart);
243
- thread.set('anchorStartLine', anchor.anchorStartLine);
289
+ if (anchor.anchorKind === 'diagram-element') {
290
+ thread.set('anchorPoint', anchor.anchorPoint);
291
+ thread.set('anchorSnapshot', anchor.anchorSnapshot);
292
+ thread.set('elementId', anchor.elementId);
293
+ } else {
294
+ thread.set('anchorEnd', anchor.anchorEnd);
295
+ thread.set('anchorEndLine', anchor.anchorEndLine);
296
+ thread.set('anchorStart', anchor.anchorStart);
297
+ thread.set('anchorStartLine', anchor.anchorStartLine);
298
+ }
244
299
  thread.set('createdAt', asFiniteNumber(record.createdAt) ?? Date.now());
245
300
  thread.set('createdByColor', asString(record.createdByColor));
246
301
  thread.set('createdByName', asString(record.createdByName) || initialMessage.userName);
@@ -266,6 +321,9 @@ export function serializeCommentThread(thread) {
266
321
  anchorQuote: readThreadValue(thread, 'anchorQuote'),
267
322
  anchorStart: readThreadValue(thread, 'anchorStart'),
268
323
  anchorStartLine: readThreadValue(thread, 'anchorStartLine'),
324
+ anchorPoint: readThreadValue(thread, 'anchorPoint'),
325
+ anchorSnapshot: readThreadValue(thread, 'anchorSnapshot'),
326
+ elementId: readThreadValue(thread, 'elementId'),
269
327
  });
270
328
  const messages = serializeMessages(readThreadValue(thread, 'messages'));
271
329
 
@@ -98,13 +98,15 @@ function compareElementVersions(left, right) {
98
98
  }
99
99
 
100
100
  function compareElementIndex(left, right) {
101
- const leftIndex = left?.index ?? '';
102
- const rightIndex = right?.index ?? '';
101
+ const leftIndex = String(left?.index ?? '');
102
+ const rightIndex = String(right?.index ?? '');
103
103
  if (leftIndex !== rightIndex) {
104
- return String(leftIndex).localeCompare(String(rightIndex));
104
+ return leftIndex < rightIndex ? -1 : 1;
105
105
  }
106
106
 
107
- return String(left?.id || '').localeCompare(String(right?.id || ''));
107
+ const leftId = String(left?.id || '');
108
+ const rightId = String(right?.id || '');
109
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
108
110
  }
109
111
 
110
112
  function getNestedMapValue(map, key, { create = false } = {}) {
@@ -4,20 +4,25 @@ const EXCALIDRAW_FILE_EXTENSION = '.excalidraw';
4
4
  const DRAWIO_FILE_EXTENSION = '.drawio';
5
5
  const MERMAID_FILE_EXTENSIONS = Object.freeze(['.mmd', '.mermaid']);
6
6
  const PLANTUML_FILE_EXTENSIONS = Object.freeze(['.puml', '.plantuml']);
7
+ const STRUCTURIZR_FILE_EXTENSION = '.dsl';
8
+ const STRUCTURIZR_FILE_EXTENSIONS = Object.freeze([STRUCTURIZR_FILE_EXTENSION]);
9
+ const PDF_FILE_EXTENSION = '.pdf';
7
10
  const IMAGE_ATTACHMENT_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
8
11
  const DIAGRAM_FILE_EXTENSIONS = Object.freeze([
9
12
  EXCALIDRAW_FILE_EXTENSION,
10
13
  DRAWIO_FILE_EXTENSION,
11
14
  ...MERMAID_FILE_EXTENSIONS,
12
15
  ...PLANTUML_FILE_EXTENSIONS,
16
+ ...STRUCTURIZR_FILE_EXTENSIONS,
13
17
  ]);
14
18
  const VAULT_FILE_EXTENSIONS = Object.freeze([
15
19
  ...MARKDOWN_FILE_EXTENSIONS,
16
20
  BASE_FILE_EXTENSION,
17
21
  ...DIAGRAM_FILE_EXTENSIONS,
22
+ PDF_FILE_EXTENSION,
18
23
  ...IMAGE_ATTACHMENT_EXTENSIONS,
19
24
  ]);
20
- const STRIP_VAULT_EXTENSION_PATTERN = /\.(?:md|markdown|mdx|base|excalidraw|drawio|mmd|mermaid|puml|plantuml|png|jpe?g|webp|gif|svg)$/i;
25
+ const STRIP_VAULT_EXTENSION_PATTERN = /\.(?:md|markdown|mdx|base|excalidraw|drawio|mmd|mermaid|puml|plantuml|dsl|pdf|png|jpe?g|webp|gif|svg)$/i;
21
26
 
22
27
  function normalizeFilePath(filePath) {
23
28
  return String(filePath ?? '').trim().toLowerCase();
@@ -36,7 +41,10 @@ export {
36
41
  IMAGE_ATTACHMENT_EXTENSIONS,
37
42
  MARKDOWN_FILE_EXTENSIONS,
38
43
  MERMAID_FILE_EXTENSIONS,
44
+ PDF_FILE_EXTENSION,
39
45
  PLANTUML_FILE_EXTENSIONS,
46
+ STRUCTURIZR_FILE_EXTENSION,
47
+ STRUCTURIZR_FILE_EXTENSIONS,
40
48
  VAULT_FILE_EXTENSIONS,
41
49
  };
42
50
 
@@ -65,6 +73,14 @@ export function getVaultFileKind(filePath) {
65
73
  return 'plantuml';
66
74
  }
67
75
 
76
+ if (hasFileExtension(filePath, STRUCTURIZR_FILE_EXTENSIONS)) {
77
+ return 'structurizr';
78
+ }
79
+
80
+ if (hasFileExtension(filePath, [PDF_FILE_EXTENSION])) {
81
+ return 'pdf';
82
+ }
83
+
68
84
  if (hasFileExtension(filePath, IMAGE_ATTACHMENT_EXTENSIONS)) {
69
85
  return 'image';
70
86
  }
@@ -118,13 +134,25 @@ export function isPlantUmlFilePath(filePath) {
118
134
  return getVaultFileKind(filePath) === 'plantuml';
119
135
  }
120
136
 
137
+ export function isStructurizrFilePath(filePath) {
138
+ return getVaultFileKind(filePath) === 'structurizr';
139
+ }
140
+
141
+ export function isPdfFilePath(filePath) {
142
+ return getVaultFileKind(filePath) === 'pdf';
143
+ }
144
+
121
145
  export function isImageAttachmentFilePath(filePath) {
122
146
  return getVaultFileKind(filePath) === 'image';
123
147
  }
124
148
 
125
149
  export function isDiagramFilePath(filePath) {
126
150
  const kind = getVaultFileKind(filePath);
127
- return kind === 'excalidraw' || kind === 'drawio' || kind === 'mermaid' || kind === 'plantuml';
151
+ return kind === 'excalidraw'
152
+ || kind === 'drawio'
153
+ || kind === 'mermaid'
154
+ || kind === 'plantuml'
155
+ || kind === 'structurizr';
128
156
  }
129
157
 
130
158
  export function isVaultFilePath(filePath) {
@@ -133,7 +161,11 @@ export function isVaultFilePath(filePath) {
133
161
 
134
162
  export function supportsCommentsForFilePath(filePath) {
135
163
  const kind = getVaultFileKind(filePath);
136
- return kind === 'markdown' || kind === 'mermaid' || kind === 'plantuml';
164
+ return kind === 'markdown'
165
+ || kind === 'mermaid'
166
+ || kind === 'plantuml'
167
+ || kind === 'structurizr'
168
+ || kind === 'excalidraw';
137
169
  }
138
170
 
139
171
  export function supportsBacklinksForFilePath(filePath) {
@@ -13,11 +13,28 @@ import {
13
13
  import { loadBuildInfo } from './build-info.js';
14
14
  import { isPerfLoggingEnabled } from './perf-logging.js';
15
15
 
16
+ const DEFAULT_SEARCH_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
17
+ const DEFAULT_SEARCH_MAX_FILE_SIZE = '1M';
18
+ const DEFAULT_PDF_UPLOAD_BYTES = 50 * 1024 * 1024;
19
+
16
20
  function parsePositiveInt(rawValue, fallbackValue) {
17
21
  const parsed = Number.parseInt(rawValue ?? '', 10);
18
22
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallbackValue;
19
23
  }
20
24
 
25
+ function normalizeSearchMaxFileSize(rawValue, fallbackValue = DEFAULT_SEARCH_MAX_FILE_SIZE) {
26
+ const normalized = String(rawValue ?? '').trim();
27
+ if (!normalized) {
28
+ return fallbackValue;
29
+ }
30
+
31
+ if (!/^[1-9]\d*(?:[KMG])?$/iu.test(normalized)) {
32
+ throw new Error('COLLABMD_SEARCH_MAX_FILE_SIZE must be a positive byte count with an optional K, M, or G suffix.');
33
+ }
34
+
35
+ return normalized.toUpperCase();
36
+ }
37
+
21
38
  function parseOptionalPositiveInt(rawValue, {
22
39
  fallbackValue = null,
23
40
  variableName = 'value',
@@ -56,6 +73,30 @@ function resolveDrawioBaseUrl(value) {
56
73
  return normalizeOptionalString(value) || 'https://embed.diagrams.net';
57
74
  }
58
75
 
76
+ function resolveStructurizrServerUrl(value) {
77
+ const normalized = normalizeOptionalString(value);
78
+ if (!normalized) {
79
+ return '';
80
+ }
81
+
82
+ let parsedUrl;
83
+ try {
84
+ parsedUrl = new URL(normalized);
85
+ } catch {
86
+ throw new Error('STRUCTURIZR_SERVER_URL must be an absolute HTTP or HTTPS URL.');
87
+ }
88
+
89
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
90
+ throw new Error('STRUCTURIZR_SERVER_URL must use HTTP or HTTPS.');
91
+ }
92
+
93
+ if (parsedUrl.search || parsedUrl.hash) {
94
+ throw new Error('STRUCTURIZR_SERVER_URL must not include a query string or hash.');
95
+ }
96
+
97
+ return normalized.replace(/\/+$/u, '');
98
+ }
99
+
59
100
  function normalizeAppBasePath(basePath) {
60
101
  const normalized = normalizeOptionalString(basePath);
61
102
  if (!normalized || normalized === '/') {
@@ -278,6 +319,31 @@ function loadOidcConfig(overrides = {}, { basePath = '' } = {}) {
278
319
  };
279
320
  }
280
321
 
322
+ function loadStructurizrConfig(overrides = {}, { vaultDir } = {}) {
323
+ const serverUrl = resolveStructurizrServerUrl(
324
+ overrides.serverUrl
325
+ ?? process.env.COLLABMD_STRUCTURIZR_SERVER_URL
326
+ ?? process.env.STRUCTURIZR_SERVER_URL,
327
+ );
328
+ const mirrorDir = resolveOptionalPath(
329
+ overrides.mirrorDir
330
+ ?? process.env.COLLABMD_STRUCTURIZR_MIRROR_DIR
331
+ ?? process.env.STRUCTURIZR_MIRROR_DIR,
332
+ ) || resolve(vaultDir, '.collabmd/structurizr');
333
+
334
+ return {
335
+ enabled: Boolean(serverUrl),
336
+ mirrorDir,
337
+ serverUrl,
338
+ trustedExecutableDsl: overrides.trustedExecutableDsl
339
+ ?? parseBooleanFlag(
340
+ process.env.COLLABMD_STRUCTURIZR_TRUSTED_EXECUTABLE_DSL
341
+ ?? process.env.STRUCTURIZR_TRUSTED_EXECUTABLE_DSL,
342
+ false,
343
+ ),
344
+ };
345
+ }
346
+
281
347
  function loadHostedConfig(overrides = {}, { authStrategy, vaultDir } = {}) {
282
348
  const enabled = overrides.enabled ?? parseBooleanFlag(process.env.COLLABMD_HOSTED_ENABLED, false);
283
349
  const metadataDbPath = resolveOptionalPath(
@@ -361,6 +427,7 @@ export function loadConfig(overrides = {}) {
361
427
  : null;
362
428
  const git = loadGitConfig(overrides.git);
363
429
  const hosted = loadHostedConfig(overrides.hosted, { authStrategy, vaultDir });
430
+ const structurizr = loadStructurizrConfig(overrides.structurizr, { vaultDir });
364
431
  const build = loadBuildInfo({
365
432
  explicitBuildId: process.env.COLLABMD_BUILD_ID,
366
433
  projectRoot,
@@ -396,6 +463,18 @@ export function loadConfig(overrides = {}) {
396
463
  maxBaseQueryRows: parsePositiveInt(process.env.COLLABMD_MAX_BASE_QUERY_ROWS, 5_000),
397
464
  maxDownloadFileBytes: parsePositiveInt(process.env.COLLABMD_MAX_DOWNLOAD_FILE_BYTES, 268_435_456),
398
465
  maxInitialSyncBytes: parsePositiveInt(process.env.COLLABMD_MAX_INITIAL_SYNC_BYTES, 16_777_216),
466
+ maxPdfUploadBytes: parsePositiveInt(
467
+ overrides.maxPdfUploadBytes ?? process.env.COLLABMD_MAX_PDF_UPLOAD_BYTES,
468
+ DEFAULT_PDF_UPLOAD_BYTES,
469
+ ),
470
+ searchMaxBufferBytes: parsePositiveInt(
471
+ overrides.searchMaxBufferBytes ?? process.env.COLLABMD_SEARCH_MAX_BUFFER_BYTES,
472
+ DEFAULT_SEARCH_MAX_BUFFER_BYTES,
473
+ ),
474
+ searchMaxFileSize: normalizeSearchMaxFileSize(
475
+ overrides.searchMaxFileSize ?? process.env.COLLABMD_SEARCH_MAX_FILE_SIZE,
476
+ ),
477
+ structurizr,
399
478
  perfLoggingEnabled: overrides.perfLoggingEnabled ?? isPerfLoggingEnabled(process.env.COLLABMD_PERF_LOGGING),
400
479
  port: parsePositiveInt(process.env.PORT, 1234),
401
480
  nodeEnv,
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'http';
2
+ import { resolve } from 'node:path';
2
3
 
3
4
  import { loadConfig } from './config/env.js';
4
5
  import { createAuthService } from './auth/create-auth-service.js';
@@ -13,6 +14,7 @@ import { GitHubAppClient } from './infrastructure/github/github-app-client.js';
13
14
  import { GitHubSetupFlow } from './infrastructure/github/github-setup-flow.js';
14
15
  import { HostedWorkspaceService } from './domain/hosted-workspace.js';
15
16
  import { PlantUmlRenderer } from './infrastructure/plantuml/plantuml-renderer.js';
17
+ import { StructurizrWorkspaceService } from './infrastructure/structurizr/structurizr-workspace-service.js';
16
18
  import { RoomRegistry } from './domain/collaboration/room-registry.js';
17
19
  import { RipgrepSearchService } from './domain/ripgrep-search-service.js';
18
20
  import { createRequestHandler } from './infrastructure/http/create-request-handler.js';
@@ -83,12 +85,20 @@ export function createAppServer(config = loadConfig()) {
83
85
  const plantUmlRenderer = new PlantUmlRenderer({
84
86
  serverUrl: config.plantumlServerUrl,
85
87
  });
88
+ const structurizrWorkspaceService = new StructurizrWorkspaceService({
89
+ mirrorDir: config.structurizr?.mirrorDir || resolve(config.vaultDir, '.collabmd/structurizr'),
90
+ serverUrl: config.structurizr?.serverUrl || '',
91
+ trustedExecutableDsl: config.structurizr?.trustedExecutableDsl,
92
+ vaultDir: config.vaultDir,
93
+ });
86
94
  const gitService = new GitService({
87
95
  commandEnv: config.git?.commandEnv,
88
96
  enabled: config.gitEnabled,
89
97
  vaultDir: config.vaultDir,
90
98
  });
91
99
  const searchService = new RipgrepSearchService({
100
+ maxBufferBytes: config.searchMaxBufferBytes,
101
+ maxFileSize: config.searchMaxFileSize,
92
102
  perfLoggingEnabled: config.perfLoggingEnabled,
93
103
  vaultDir: config.vaultDir,
94
104
  });
@@ -153,6 +163,7 @@ export function createAppServer(config = loadConfig()) {
153
163
  fileSystemSyncService,
154
164
  hostedWorkspaceService,
155
165
  githubSetupFlow,
166
+ structurizrWorkspaceService,
156
167
  );
157
168
  const httpServer = createServer((req, res) => {
158
169
  requestHandler(req, res).catch((error) => {
@@ -315,6 +326,7 @@ export function createAppServer(config = loadConfig()) {
315
326
  gitService,
316
327
  hostedWorkspaceService,
317
328
  searchService,
329
+ structurizrWorkspaceService,
318
330
  setTestHydrateDelayMs(delayMs = 0) {
319
331
  testControls.wsRoomHydrateDelayMs = Math.max(0, Number(delayMs) || 0);
320
332
  },
@@ -2,7 +2,7 @@ import { dump as dumpYaml, loadAll as loadAllYaml } from 'js-yaml';
2
2
 
3
3
  const SUPPORTED_VIEW_TYPES = new Set(['cards', 'list', 'table']);
4
4
 
5
- function isPlainObject(value) {
5
+ export function isPlainObject(value) {
6
6
  return Object.prototype.toString.call(value) === '[object Object]';
7
7
  }
8
8
 
@@ -1,9 +1,6 @@
1
1
  import { isImageAttachmentFilePath } from '../../../domain/file-kind.js';
2
2
  import { resolveVaultRelativePath } from '../../../domain/vault-paths.js';
3
-
4
- function isPlainObject(value) {
5
- return Object.prototype.toString.call(value) === '[object Object]';
6
- }
3
+ import { isPlainObject } from './base-definition.js';
7
4
 
8
5
  export function createLinkValue(target, {
9
6
  display = '',
@@ -12,6 +12,7 @@ import {
12
12
  createReferenceTargetAliasMap,
13
13
  } from '../markdown-reference-extractor.js';
14
14
  import { mapWithConcurrency } from '../../shared/async-utils.js';
15
+ import { isPlainObject } from './base-definition.js';
15
16
  import { createLinkValue, dedupeLinkValues } from './base-expression-runtime.js';
16
17
 
17
18
  const INLINE_TAG_RE = /(^|[\s(])#([A-Za-z0-9/_-]+)/g;
@@ -39,10 +40,6 @@ function createRowCacheKey(filePath, workspaceState, fileSetSignature) {
39
40
  ].join('\0');
40
41
  }
41
42
 
42
- function isPlainObject(value) {
43
- return Object.prototype.toString.call(value) === '[object Object]';
44
- }
45
-
46
43
  function normalizeTags(value) {
47
44
  if (Array.isArray(value)) {
48
45
  return value
@@ -2,15 +2,12 @@ import { dump as dumpYaml } from 'js-yaml';
2
2
 
3
3
  import {
4
4
  findView,
5
+ isPlainObject,
5
6
  normalizeBaseDefinition,
6
7
  normalizeRawDefinitionForWrite,
7
8
  parseBaseSource,
8
9
  } from './base-definition.js';
9
10
 
10
- function isPlainObject(value) {
11
- return Object.prototype.toString.call(value) === '[object Object]';
12
- }
13
-
14
11
  function cloneRawSource(source = '') {
15
12
  const parsed = parseBaseSource(source);
16
13
  return isPlainObject(parsed) ? parsed : {};
@@ -6,9 +6,9 @@ import * as decoding from 'lib0/decoding';
6
6
 
7
7
  import { MSG_AWARENESS, MSG_SYNC } from './protocol.js';
8
8
  import { CollaborationDocumentStore } from './collaboration-document-store.js';
9
- import { RoomPersistenceController } from './room-persistence-controller.js';
10
9
  import { logPerfEvent } from '../../config/perf-logging.js';
11
10
  import { populateCommentThreads, serializeCommentThreads } from '../../../domain/comment-threads.js';
11
+ import { getVaultFileKind } from '../../../domain/file-kind.js';
12
12
  import {
13
13
  EXCALIDRAW_APP_STATE_KEY,
14
14
  EXCALIDRAW_ELEMENTS_KEY,
@@ -123,7 +123,7 @@ function readAwarenessEntries(update) {
123
123
  }
124
124
 
125
125
  function isExcalidrawRoom(name) {
126
- return typeof name === 'string' && name.endsWith('.excalidraw');
126
+ return getVaultFileKind(name) === 'excalidraw';
127
127
  }
128
128
 
129
129
  function isWorkspaceRoom(name) {
@@ -214,15 +214,10 @@ export class CollaborationRoom {
214
214
  lastHydrate: null,
215
215
  lastInitialSyncAt: 0,
216
216
  };
217
- this.persistence = new RoomPersistenceController({
218
- idleGraceMs: this.idleGraceMs,
219
- onDestroy: () => {
220
- this.awareness.destroy();
221
- this.doc.destroy();
222
- this.onEmpty?.(this.name);
223
- },
224
- onPersist: () => this.persist(),
225
- });
217
+ this.persistTimer = null;
218
+ this.destroyTimer = null;
219
+ this.finalizePromise = null;
220
+ this.shutdownGeneration = 0;
226
221
 
227
222
  this.awareness.setLocalState(null);
228
223
  this.registerDocListeners();
@@ -475,11 +470,12 @@ export class CollaborationRoom {
475
470
  return;
476
471
  }
477
472
 
478
- this.persistence.schedulePersist(async () => {
473
+ clearTimeout(this.persistTimer);
474
+ this.persistTimer = setTimeout(() => {
479
475
  this.persist().catch((error) => {
480
476
  console.error(`[room:${this.name}] Failed to persist document:`, error.message);
481
477
  });
482
- });
478
+ }, 500);
483
479
  }
484
480
 
485
481
  async persist() {
@@ -620,9 +616,17 @@ export class CollaborationRoom {
620
616
  this.documentStore?.rename(nextName);
621
617
  }
622
618
 
619
+ cancelPersistenceTimers() {
620
+ this.shutdownGeneration += 1;
621
+ clearTimeout(this.persistTimer);
622
+ clearTimeout(this.destroyTimer);
623
+ this.persistTimer = null;
624
+ this.destroyTimer = null;
625
+ }
626
+
623
627
  markDeleted() {
624
628
  this.deleted = true;
625
- this.persistence.cancelAll();
629
+ this.cancelPersistenceTimers();
626
630
  if (this.clients.size === 0) {
627
631
  this.finalizeIfIdle();
628
632
  }
@@ -669,7 +673,9 @@ export class CollaborationRoom {
669
673
  }
670
674
 
671
675
  async addClient(ws, { sendInitialSync: shouldSendInitialSync = true } = {}) {
672
- this.persistence.markActivity();
676
+ this.shutdownGeneration += 1;
677
+ clearTimeout(this.destroyTimer);
678
+ this.destroyTimer = null;
673
679
  await this.hydrate();
674
680
 
675
681
  this.ensureClientState(ws);
@@ -721,9 +727,9 @@ export class CollaborationRoom {
721
727
  }
722
728
 
723
729
  this.destroyed = true;
724
- this.persistence.cancelAll();
730
+ this.cancelPersistenceTimers();
725
731
  await Promise.allSettled([
726
- this.persistence.finalizePromise,
732
+ this.finalizePromise,
727
733
  this.activePersistPromise,
728
734
  ]);
729
735
 
@@ -745,12 +751,40 @@ export class CollaborationRoom {
745
751
  }
746
752
 
747
753
  finalizeIfIdle() {
748
- return this.persistence.finalizeIfIdle({
749
- isIdle: () => this.clients.size === 0,
750
- onPersistError: (error) => {
754
+ if (this.clients.size > 0 || this.finalizePromise) {
755
+ return;
756
+ }
757
+
758
+ const generation = ++this.shutdownGeneration;
759
+ this.finalizePromise = (async () => {
760
+ clearTimeout(this.persistTimer);
761
+ this.persistTimer = null;
762
+
763
+ try {
764
+ await this.persist();
765
+ } catch (error) {
751
766
  console.error(`[room:${this.name}] Failed to persist final room state:`, error.message);
752
- },
767
+ }
768
+
769
+ if (this.clients.size > 0 || generation !== this.shutdownGeneration) {
770
+ return;
771
+ }
772
+
773
+ this.destroyTimer = setTimeout(() => {
774
+ if (this.clients.size > 0 || generation !== this.shutdownGeneration) {
775
+ return;
776
+ }
777
+
778
+ this.awareness.destroy();
779
+ this.doc.destroy();
780
+ this.onEmpty?.(this.name);
781
+ }, this.idleGraceMs);
782
+ this.destroyTimer.unref?.();
783
+ })().finally(() => {
784
+ this.finalizePromise = null;
753
785
  });
786
+
787
+ return this.finalizePromise;
754
788
  }
755
789
 
756
790
  clearEphemeralExcalidrawHistory() {
@@ -39,10 +39,16 @@ function createThreadSummary(thread) {
39
39
  }
40
40
 
41
41
  const id = asString(thread.id);
42
+ const anchorKind = asString(thread.anchorKind) || 'line';
43
+ const isDiagramElementAnchor = anchorKind === 'diagram-element';
42
44
  const anchorStartLine = asFiniteNumber(thread.anchorStartLine);
43
45
  const anchorEndLine = asFiniteNumber(thread.anchorEndLine);
44
46
  const messages = asArray(thread.messages);
45
- if (!id || anchorStartLine === null || anchorEndLine === null || messages.length === 0) {
47
+ if (
48
+ !id
49
+ || messages.length === 0
50
+ || (!isDiagramElementAnchor && (anchorStartLine === null || anchorEndLine === null))
51
+ ) {
46
52
  return null;
47
53
  }
48
54
 
@@ -53,10 +59,14 @@ function createThreadSummary(thread) {
53
59
 
54
60
  return {
55
61
  anchor: {
56
- endLine: Math.max(anchorEndLine, anchorStartLine),
57
- kind: asString(thread.anchorKind) || 'line',
58
- quote: asString(thread.anchorQuote),
59
- startLine: Math.max(anchorStartLine, 1),
62
+ ...(isDiagramElementAnchor
63
+ ? {}
64
+ : {
65
+ endLine: Math.max(anchorEndLine, anchorStartLine),
66
+ startLine: Math.max(anchorStartLine, 1),
67
+ }),
68
+ kind: anchorKind,
69
+ quote: asString(thread.anchorQuote) || (isDiagramElementAnchor ? 'Diagram element' : ''),
60
70
  },
61
71
  createdAt: asFiniteNumber(thread.createdAt) ?? latestActivityAt,
62
72
  createdByColor: asString(thread.createdByColor),
@@ -22,7 +22,9 @@ const TEXT_SEARCH_GLOBS = Object.freeze([
22
22
  '*.mermaid',
23
23
  '*.puml',
24
24
  '*.plantuml',
25
+ '*.dsl',
25
26
  '*.drawio',
27
+ '*.excalidraw',
26
28
  ]);
27
29
 
28
30
  function normalizePositiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
@@ -278,6 +280,7 @@ export class RipgrepSearchService {
278
280
  async search({
279
281
  limit = DEFAULT_MAX_FILES,
280
282
  query = '',
283
+ signal = null,
281
284
  } = {}) {
282
285
  const normalizedQuery = String(query ?? '').trim();
283
286
  const maxFiles = normalizePositiveInt(limit, this.maxFiles, {
@@ -310,6 +313,7 @@ export class RipgrepSearchService {
310
313
  cwd: this.vaultDir,
311
314
  encoding: 'utf8',
312
315
  maxBuffer: this.maxBufferBytes,
316
+ ...(signal ? { signal } : {}),
313
317
  timeout: this.timeoutMs,
314
318
  });
315
319
  const parsed = parseRipgrepJson(result.stdout, {
@@ -1,5 +1,6 @@
1
1
  import { countPatchLines, parseNumstatOutput, parseUnifiedDiff } from './parsers.js';
2
2
  import { createEmptyStats } from './responses.js';
3
+ import { getVaultFileKind } from '../../../domain/file-kind.js';
3
4
 
4
5
  function createSectionMap(sections = []) {
5
6
  return new Map(sections.map((section) => [section.key, section]));
@@ -33,6 +34,7 @@ function mergeScopedFile(existingFile, nextFile, scope) {
33
34
  return {
34
35
  ...nextFile,
35
36
  ...nextFlags,
37
+ fileKind: nextFile.fileKind || getVaultFileKind(nextFile.path),
36
38
  scope,
37
39
  stats: createEmptyStats(),
38
40
  };
@@ -44,6 +46,7 @@ function mergeScopedFile(existingFile, nextFile, scope) {
44
46
  return {
45
47
  ...existingFile,
46
48
  ...nextFile,
49
+ fileKind: existingFile.fileKind || getVaultFileKind(nextFile.path),
47
50
  hasStagedChanges: existingFile.hasStagedChanges || nextFlags.hasStagedChanges,
48
51
  hasUntrackedChanges: existingFile.hasUntrackedChanges || nextFlags.hasUntrackedChanges,
49
52
  hasWorkingTreeChanges: existingFile.hasWorkingTreeChanges || nextFlags.hasWorkingTreeChanges,
@@ -54,6 +57,7 @@ function mergeScopedFile(existingFile, nextFile, scope) {
54
57
 
55
58
  return {
56
59
  ...existingFile,
60
+ fileKind: existingFile.fileKind || getVaultFileKind(nextFile.path),
57
61
  hasStagedChanges: existingFile.hasStagedChanges || nextFlags.hasStagedChanges,
58
62
  hasUntrackedChanges: existingFile.hasUntrackedChanges || nextFlags.hasUntrackedChanges,
59
63
  hasWorkingTreeChanges: existingFile.hasWorkingTreeChanges || nextFlags.hasWorkingTreeChanges,
@@ -372,6 +376,7 @@ export class GitDiffService {
372
376
  return {
373
377
  ...file,
374
378
  ...detail,
379
+ fileKind: file.fileKind || detail.fileKind || getVaultFileKind(file.path),
375
380
  canLoadFullPatch: false,
376
381
  patchLineCount,
377
382
  tooLarge: false,