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
@@ -17,6 +17,8 @@ import {
17
17
  serializeExcalidrawRoomScene,
18
18
  tryParseExcalidrawSceneJson,
19
19
  } from '../../../domain/excalidraw-room-codec.js';
20
+ import { normalizeWorkspaceEvent } from '../../../domain/workspace-change.js';
21
+ import { WORKSPACE_EVENT_MAX_MESSAGES, WORKSPACE_ROOM_NAME } from '../../../domain/workspace-room.js';
20
22
 
21
23
  function closeSlowClient(ws, clientState, { maxBufferedAmountBytes, name }) {
22
24
  if (!clientState || clientState.backpressureCloseIssued) {
@@ -86,9 +88,45 @@ function isExcalidrawRoom(name) {
86
88
  return typeof name === 'string' && name.endsWith('.excalidraw');
87
89
  }
88
90
 
91
+ function isWorkspaceRoom(name) {
92
+ return name === WORKSPACE_ROOM_NAME;
93
+ }
94
+
95
+ function computeTextReplacement(currentContent, nextContent) {
96
+ const currentText = String(currentContent ?? '');
97
+ const nextText = String(nextContent ?? '');
98
+ if (currentText === nextText) {
99
+ return null;
100
+ }
101
+
102
+ let prefixLength = 0;
103
+ const maxPrefix = Math.min(currentText.length, nextText.length);
104
+ while (prefixLength < maxPrefix && currentText[prefixLength] === nextText[prefixLength]) {
105
+ prefixLength += 1;
106
+ }
107
+
108
+ let currentSuffixLength = currentText.length;
109
+ let nextSuffixLength = nextText.length;
110
+ while (
111
+ currentSuffixLength > prefixLength
112
+ && nextSuffixLength > prefixLength
113
+ && currentText[currentSuffixLength - 1] === nextText[nextSuffixLength - 1]
114
+ ) {
115
+ currentSuffixLength -= 1;
116
+ nextSuffixLength -= 1;
117
+ }
118
+
119
+ return {
120
+ deleteCount: currentSuffixLength - prefixLength,
121
+ insertText: nextText.slice(prefixLength, nextSuffixLength),
122
+ start: prefixLength,
123
+ };
124
+ }
125
+
89
126
  export class CollaborationRoom {
90
127
  constructor({
91
128
  documentStore = null,
129
+ getHydrateDelayMs = null,
92
130
  name,
93
131
  idleGraceMs = 0,
94
132
  maxBufferedAmountBytes,
@@ -104,6 +142,7 @@ export class CollaborationRoom {
104
142
  name,
105
143
  vaultFileStore,
106
144
  });
145
+ this.getHydrateDelayMs = getHydrateDelayMs;
107
146
  this.onEmpty = onEmpty;
108
147
  this.doc = new Y.Doc({ gc: true });
109
148
  this.awareness = new awarenessProtocol.Awareness(this.doc);
@@ -115,6 +154,12 @@ export class CollaborationRoom {
115
154
  this.destroyed = false;
116
155
  this.cachedInitialSyncMessage = null;
117
156
  this.activePersistPromise = null;
157
+ this.debugMetrics = {
158
+ hydrateCount: 0,
159
+ initialSyncCount: 0,
160
+ lastHydrate: null,
161
+ lastInitialSyncAt: 0,
162
+ };
118
163
  this.persistence = new RoomPersistenceController({
119
164
  idleGraceMs: this.idleGraceMs,
120
165
  onDestroy: () => {
@@ -136,64 +181,96 @@ export class CollaborationRoom {
136
181
 
137
182
  if (!this.hydratePromise) {
138
183
  this.hydratePromise = (async () => {
139
- if (this.documentStore?.hasPersistence()) {
140
- const snapshot = await this.documentStore.readSnapshot();
141
- if (snapshot) {
142
- try {
143
- if (isExcalidrawRoom(this.name)) {
144
- const validationDoc = new Y.Doc({ gc: true });
145
- try {
146
- Y.applyUpdate(validationDoc, snapshot, 'hydrate');
147
- if (!this.ensureStructuredExcalidrawState(validationDoc)) {
148
- throw new Error('snapshot did not contain a valid structured Excalidraw scene');
149
- }
184
+ const startedAt = Date.now();
185
+ let hydrateSource = 'empty';
186
+ let snapshotExists = false;
187
+ let snapshotValid = false;
188
+ let commentThreadCount = 0;
189
+ const hydrateDelayMs = Math.max(0, Number(this.getHydrateDelayMs?.() || 0));
190
+
191
+ try {
192
+ if (hydrateDelayMs > 0) {
193
+ await new Promise((resolve) => setTimeout(resolve, hydrateDelayMs));
194
+ }
150
195
 
151
- Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(validationDoc), 'hydrate');
152
- } finally {
153
- validationDoc.destroy();
196
+ if (this.documentStore?.hasPersistence()) {
197
+ const snapshot = await this.documentStore.readSnapshot();
198
+ if (snapshot) {
199
+ snapshotExists = true;
200
+ try {
201
+ if (isExcalidrawRoom(this.name)) {
202
+ const validationDoc = new Y.Doc({ gc: true });
203
+ try {
204
+ Y.applyUpdate(validationDoc, snapshot, 'hydrate');
205
+ if (!this.ensureStructuredExcalidrawState(validationDoc)) {
206
+ throw new Error('snapshot did not contain a valid structured Excalidraw scene');
207
+ }
208
+
209
+ Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(validationDoc), 'hydrate');
210
+ } finally {
211
+ validationDoc.destroy();
212
+ }
213
+ } else {
214
+ Y.applyUpdate(this.doc, snapshot, 'hydrate');
154
215
  }
155
- } else {
156
- Y.applyUpdate(this.doc, snapshot, 'hydrate');
157
- }
158
216
 
159
- this.hydrated = true;
160
- return;
161
- } catch (error) {
162
- console.warn(
163
- `[room:${this.name}] Discarding invalid collaboration snapshot: ${error.message}`,
164
- );
165
- await this.documentStore.deleteSnapshot?.();
217
+ snapshotValid = true;
218
+ hydrateSource = 'snapshot';
219
+ this.hydrated = true;
220
+ return;
221
+ } catch (error) {
222
+ console.warn(
223
+ `[room:${this.name}] Discarding invalid collaboration snapshot: ${error.message}`,
224
+ );
225
+ await this.documentStore.deleteSnapshot?.();
226
+ }
166
227
  }
167
- }
168
228
 
169
- const [content, commentThreads] = await Promise.all([
170
- this.documentStore.readContent(),
171
- this.documentStore.readCommentThreads(),
172
- ]);
173
- if (content !== null || commentThreads.length > 0) {
174
- const ytext = this.doc.getText('codemirror');
175
- const comments = this.doc.getArray('comments');
176
- this.doc.transact(() => {
177
- if (isExcalidrawRoom(this.name)) {
178
- const parsedScene = tryParseExcalidrawSceneJson(content);
179
- if (parsedScene) {
180
- migrateLegacyExcalidrawRoomData(this.doc, parsedScene);
229
+ const [content, commentThreads] = await Promise.all([
230
+ this.documentStore.readContent(),
231
+ this.documentStore.readCommentThreads(),
232
+ ]);
233
+ commentThreadCount = commentThreads.length;
234
+ if (content !== null || commentThreads.length > 0) {
235
+ hydrateSource = 'content';
236
+ const ytext = this.doc.getText('codemirror');
237
+ const comments = this.doc.getArray('comments');
238
+ this.doc.transact(() => {
239
+ if (isExcalidrawRoom(this.name)) {
240
+ const parsedScene = tryParseExcalidrawSceneJson(content);
241
+ if (parsedScene) {
242
+ migrateLegacyExcalidrawRoomData(this.doc, parsedScene);
243
+ } else if (content !== null) {
244
+ ytext.insert(0, content);
245
+ }
181
246
  } else if (content !== null) {
182
247
  ytext.insert(0, content);
183
248
  }
184
- } else if (content !== null) {
185
- ytext.insert(0, content);
186
- }
187
- populateCommentThreads(comments, commentThreads);
188
- }, 'hydrate');
249
+ populateCommentThreads(comments, commentThreads);
250
+ }, 'hydrate');
251
+
252
+ void this.documentStore.writeSnapshot(Y.encodeStateAsUpdate(this.doc)).catch((error) => {
253
+ console.error(`[room:${this.name}] Failed to prime collaboration snapshot: ${error.message}`);
254
+ });
255
+ }
256
+ }
189
257
 
190
- void this.documentStore.writeSnapshot(Y.encodeStateAsUpdate(this.doc)).catch((error) => {
191
- console.error(`[room:${this.name}] Failed to prime collaboration snapshot: ${error.message}`);
192
- });
258
+ this.hydrated = true;
259
+ } finally {
260
+ if (this.hydrated) {
261
+ this.debugMetrics.hydrateCount += 1;
262
+ this.debugMetrics.lastHydrate = {
263
+ commentThreadCount,
264
+ durationMs: Date.now() - startedAt,
265
+ snapshotExists,
266
+ snapshotValid,
267
+ source: hydrateSource,
268
+ };
269
+ console.info(
270
+ `[perf][room:${this.name}] hydrate durationMs=${this.debugMetrics.lastHydrate.durationMs} source=${this.debugMetrics.lastHydrate.source} snapshotExists=${this.debugMetrics.lastHydrate.snapshotExists} snapshotValid=${this.debugMetrics.lastHydrate.snapshotValid} commentThreads=${this.debugMetrics.lastHydrate.commentThreadCount}`,
271
+ );
193
272
  }
194
273
  }
195
-
196
- this.hydrated = true;
197
274
  })().catch((error) => {
198
275
  this.hydratePromise = null;
199
276
  console.error(`[room:${this.name}] Failed to hydrate from disk: ${error.message}`);
@@ -332,36 +409,75 @@ export class CollaborationRoom {
332
409
  return false;
333
410
  }
334
411
 
335
- const ytext = this.doc.getText('codemirror');
336
- const comments = this.doc.getArray('comments');
337
- const parsedExcalidrawScene = isExcalidrawRoom(this.name)
338
- ? tryParseExcalidrawSceneJson(content)
339
- : null;
340
- if (isExcalidrawRoom(this.name) && !parsedExcalidrawScene) {
341
- return false;
412
+ return this.applyExternalContent(content, {
413
+ commentThreads,
414
+ replaceCommentThreads: true,
415
+ });
416
+ }
417
+
418
+ async applyExternalContent(content, {
419
+ commentThreads = [],
420
+ replaceCommentThreads = false,
421
+ } = {}) {
422
+ if (this.deleted || this.destroyed) {
423
+ return { ok: false, reason: 'room-unavailable' };
342
424
  }
343
425
 
344
- this.doc.transact(() => {
345
- if (isExcalidrawRoom(this.name)) {
346
- if (ytext.length > 0) {
347
- ytext.delete(0, ytext.length);
348
- }
426
+ const comments = this.doc.getArray('comments');
427
+ if (isExcalidrawRoom(this.name)) {
428
+ const parsedExcalidrawScene = tryParseExcalidrawSceneJson(content);
429
+ if (!parsedExcalidrawScene) {
430
+ return { ok: false, reason: 'invalid-excalidraw' };
431
+ }
432
+
433
+ this.doc.transact(() => {
349
434
  replaceExcalidrawRoomScene(this.doc, parsedExcalidrawScene);
350
- } else {
351
- if (ytext.length > 0) {
352
- ytext.delete(0, ytext.length);
353
- }
354
- if (content) {
355
- ytext.insert(0, content);
435
+ if (replaceCommentThreads) {
436
+ if (comments.length > 0) {
437
+ comments.delete(0, comments.length);
438
+ }
439
+ populateCommentThreads(comments, commentThreads);
356
440
  }
441
+ }, 'workspace-reconcile');
442
+
443
+ return { ok: true, highlightRange: null };
444
+ }
445
+
446
+ const ytext = this.doc.getText('codemirror');
447
+ const replacement = computeTextReplacement(ytext.toString(), content);
448
+ if (!replacement && !replaceCommentThreads) {
449
+ return { highlightRange: null, ok: true, skipped: true };
450
+ }
451
+
452
+ this.doc.transact(() => {
453
+ if (replacement?.deleteCount) {
454
+ ytext.delete(replacement.start, replacement.deleteCount);
357
455
  }
358
- if (comments.length > 0) {
359
- comments.delete(0, comments.length);
456
+ if (replacement?.insertText) {
457
+ ytext.insert(replacement.start, replacement.insertText);
458
+ }
459
+ if (replaceCommentThreads) {
460
+ if (comments.length > 0) {
461
+ comments.delete(0, comments.length);
462
+ }
463
+ populateCommentThreads(comments, commentThreads);
360
464
  }
361
- populateCommentThreads(comments, commentThreads);
362
465
  }, 'workspace-reconcile');
363
466
 
364
- return true;
467
+ return {
468
+ highlightRange: replacement
469
+ ? {
470
+ from: replacement.start,
471
+ to: replacement.start + replacement.insertText.length,
472
+ }
473
+ : null,
474
+ ok: true,
475
+ };
476
+ }
477
+
478
+ applyExternalDeletion() {
479
+ this.markDeleted();
480
+ return this.destroy();
365
481
  }
366
482
 
367
483
  rename(nextName) {
@@ -403,6 +519,9 @@ export class CollaborationRoom {
403
519
  }
404
520
 
405
521
  sendInitialSync(ws) {
522
+ this.debugMetrics.initialSyncCount += 1;
523
+ this.debugMetrics.lastInitialSyncAt = Date.now();
524
+ console.info(`[perf][room:${this.name}] send-initial-sync count=${this.debugMetrics.initialSyncCount}`);
406
525
  return sendMessage.call(this, ws, this.getInitialSyncMessage(), this);
407
526
  }
408
527
 
@@ -537,6 +656,10 @@ export class CollaborationRoom {
537
656
  }
538
657
 
539
658
  getPersistedContent() {
659
+ if (isWorkspaceRoom(this.name)) {
660
+ return null;
661
+ }
662
+
540
663
  if (!isExcalidrawRoom(this.name)) {
541
664
  return this.doc.getText('codemirror').toString();
542
665
  }
@@ -600,4 +723,93 @@ export class CollaborationRoom {
600
723
  console.error(`[room:${this.name}] Failed to handle message:`, error.message);
601
724
  }
602
725
  }
726
+
727
+ replaceWorkspaceEntries(entries = new Map(), {
728
+ generatedAt = Date.now(),
729
+ } = {}) {
730
+ if (!isWorkspaceRoom(this.name)) {
731
+ return false;
732
+ }
733
+
734
+ const normalizedEntries = entries instanceof Map ? entries : new Map(entries);
735
+ const entriesMap = this.doc.getMap('entries');
736
+ const metaMap = this.doc.getMap('meta');
737
+
738
+ this.doc.transact(() => {
739
+ Array.from(entriesMap.keys()).forEach((key) => {
740
+ if (!normalizedEntries.has(key)) {
741
+ entriesMap.delete(key);
742
+ }
743
+ });
744
+
745
+ normalizedEntries.forEach((entry, pathValue) => {
746
+ entriesMap.set(pathValue, entry);
747
+ });
748
+
749
+ metaMap.set('lastSnapshotAt', generatedAt);
750
+ metaMap.set('revision', Number(metaMap.get('revision') || 0) + 1);
751
+ }, 'workspace-room-entries');
752
+
753
+ return true;
754
+ }
755
+
756
+ applyWorkspaceEntryPatch({
757
+ deletes = [],
758
+ upserts = new Map(),
759
+ } = {}, {
760
+ generatedAt = Date.now(),
761
+ } = {}) {
762
+ if (!isWorkspaceRoom(this.name)) {
763
+ return false;
764
+ }
765
+
766
+ const normalizedUpserts = upserts instanceof Map ? upserts : new Map(upserts);
767
+ const normalizedDeletes = Array.from(new Set((deletes ?? []).filter(Boolean)));
768
+ if (normalizedUpserts.size === 0 && normalizedDeletes.length === 0) {
769
+ return false;
770
+ }
771
+
772
+ const entriesMap = this.doc.getMap('entries');
773
+ const metaMap = this.doc.getMap('meta');
774
+
775
+ this.doc.transact(() => {
776
+ normalizedDeletes.forEach((pathValue) => {
777
+ entriesMap.delete(pathValue);
778
+ });
779
+
780
+ normalizedUpserts.forEach((entry, pathValue) => {
781
+ entriesMap.set(pathValue, entry);
782
+ });
783
+
784
+ metaMap.set('lastSnapshotAt', generatedAt);
785
+ metaMap.set('revision', Number(metaMap.get('revision') || 0) + 1);
786
+ }, 'workspace-room-entry-patch');
787
+
788
+ return true;
789
+ }
790
+
791
+ publishWorkspaceEvent(event) {
792
+ if (!isWorkspaceRoom(this.name)) {
793
+ return null;
794
+ }
795
+
796
+ const normalizedEvent = normalizeWorkspaceEvent(event);
797
+ if (!normalizedEvent) {
798
+ return null;
799
+ }
800
+
801
+ const events = this.doc.getArray('events');
802
+ const metaMap = this.doc.getMap('meta');
803
+ this.doc.transact(() => {
804
+ events.push([normalizedEvent]);
805
+ const overflow = events.length - WORKSPACE_EVENT_MAX_MESSAGES;
806
+ if (overflow > 0) {
807
+ events.delete(0, overflow);
808
+ }
809
+ metaMap.set('lastEventAt', normalizedEvent.createdAt);
810
+ metaMap.set('revision', Number(metaMap.get('revision') || 0) + 1);
811
+ }, 'workspace-room-event');
812
+
813
+ return normalizedEvent;
814
+ }
603
815
  }
@@ -57,27 +57,67 @@ export class RoomRegistry {
57
57
  this.rooms.clear();
58
58
  }
59
59
 
60
+ getRooms() {
61
+ return Array.from(this.rooms.entries());
62
+ }
63
+
64
+ async reloadAllFromDisk() {
65
+ await Promise.allSettled(
66
+ Array.from(this.rooms.values(), (room) => room.reloadFromDisk?.()),
67
+ );
68
+ }
69
+
60
70
  async reconcileWorkspaceChange(workspaceChange = {}) {
61
71
  const deletedPaths = new Set(workspaceChange.deletedPaths ?? []);
62
72
  const renamedPaths = Array.isArray(workspaceChange.renamedPaths) ? workspaceChange.renamedPaths : [];
63
- const renamedOldPaths = new Set(renamedPaths.map((entry) => entry?.oldPath).filter(Boolean));
73
+ const pendingDeletes = [];
74
+ const highlightRanges = [];
75
+ const reloadRequiredPaths = [];
64
76
 
65
- await Promise.allSettled(
66
- [...deletedPaths, ...renamedOldPaths].map(async (pathValue) => {
67
- const room = this.rooms.get(pathValue);
68
- if (!room) {
69
- return;
70
- }
77
+ renamedPaths.forEach((entry) => {
78
+ if (!entry?.oldPath || !entry?.newPath) {
79
+ return;
80
+ }
71
81
 
82
+ if (this.rename(entry.oldPath, entry.newPath)) {
83
+ return;
84
+ }
85
+
86
+ const room = this.rooms.get(entry.oldPath);
87
+ if (room) {
88
+ pendingDeletes.push([entry.oldPath, room]);
89
+ }
90
+ });
91
+
92
+ deletedPaths.forEach((pathValue) => {
93
+ if (!pathValue) {
94
+ return;
95
+ }
96
+
97
+ const room = this.rooms.get(pathValue);
98
+ if (room) {
99
+ pendingDeletes.push([pathValue, room]);
100
+ }
101
+ });
102
+
103
+ await Promise.allSettled(
104
+ pendingDeletes.map(async ([pathValue, room]) => {
72
105
  room.markDeleted?.();
73
- await room.destroy?.();
106
+ if (typeof room.applyExternalDeletion === 'function') {
107
+ await room.applyExternalDeletion();
108
+ } else {
109
+ await room.destroy?.();
110
+ }
74
111
  if (this.rooms.get(pathValue) === room) {
75
112
  this.rooms.delete(pathValue);
76
113
  }
77
114
  }),
78
115
  );
79
116
 
80
- const blockedPaths = new Set([...deletedPaths, ...renamedOldPaths]);
117
+ const blockedPaths = new Set([
118
+ ...deletedPaths,
119
+ ...renamedPaths.flatMap((entry) => [entry?.oldPath, entry?.newPath]),
120
+ ]);
81
121
  await Promise.allSettled(
82
122
  Array.from(new Set(workspaceChange.changedPaths ?? []))
83
123
  .filter((pathValue) => pathValue && !blockedPaths.has(pathValue))
@@ -87,8 +127,22 @@ export class RoomRegistry {
87
127
  return;
88
128
  }
89
129
 
90
- await room.reloadFromDisk?.();
130
+ const result = await room.reloadFromDisk?.();
131
+ if (result && result.ok === false && result.reason === 'invalid-excalidraw') {
132
+ reloadRequiredPaths.push(pathValue);
133
+ } else if (result?.highlightRange) {
134
+ highlightRanges.push({
135
+ from: result.highlightRange.from,
136
+ path: pathValue,
137
+ to: result.highlightRange.to,
138
+ });
139
+ }
91
140
  }),
92
141
  );
142
+
143
+ return {
144
+ highlightRanges,
145
+ reloadRequiredPaths,
146
+ };
93
147
  }
94
148
  }
@@ -60,7 +60,9 @@ server.listen().then(({ host, port, wsPath }) => {
60
60
  if (server.config.auth.strategy === 'password') {
61
61
  console.log(` auth: password (${server.config.auth.password})`);
62
62
  } else if (server.config.auth.strategy === 'oidc') {
63
- console.log(' auth: oidc (not implemented yet)');
63
+ console.log(` auth: oidc (${server.config.auth.oidc.provider})`);
64
+ console.log(` public: ${server.config.auth.oidc.publicBaseUrl}`);
65
+ console.log(` callback: ${server.config.auth.oidc.callbackUrl}`);
64
66
  } else {
65
67
  console.log(' auth: none');
66
68
  }
@@ -31,13 +31,14 @@ export class GitCommandRunner {
31
31
  }
32
32
  }
33
33
 
34
- async execGit(args) {
34
+ async execGit(args, { env = null } = {}) {
35
35
  const result = await this.execFileImpl('git', ['-c', 'core.quotepath=false', ...args], {
36
36
  cwd: this.vaultDir,
37
37
  encoding: 'utf8',
38
38
  env: {
39
39
  ...process.env,
40
40
  ...(this.commandEnv ?? {}),
41
+ ...(env ?? {}),
41
42
  },
42
43
  maxBuffer: 5 * 1024 * 1024,
43
44
  timeout: 10_000,
@@ -2,12 +2,29 @@ import { createGitRequestError } from './errors.js';
2
2
  import { normalizeRelativeGitPath } from './path-utils.js';
3
3
  import { GitCommandRunner } from './command-runner.js';
4
4
  import { GitDiffService } from './diff-service.js';
5
+ import { GitHistoryService } from './history-service.js';
5
6
  import { parseNameStatusOutput } from './parsers.js';
6
7
  import { createEmptyWorkspaceChange, createWorkspaceChange } from './responses.js';
7
8
  import { GitStatusService } from './status-service.js';
8
9
  import { GitUntrackedFileService } from './untracked-files.js';
9
10
  import { PullBackupStore } from '../persistence/pull-backup-store.js';
10
11
 
12
+ function buildAuthorEnv(author = null) {
13
+ const name = String(author?.name ?? '').trim();
14
+ const email = String(author?.email ?? '').trim();
15
+
16
+ if (!name || !email) {
17
+ return null;
18
+ }
19
+
20
+ return {
21
+ GIT_AUTHOR_EMAIL: email,
22
+ GIT_AUTHOR_NAME: name,
23
+ GIT_COMMITTER_EMAIL: email,
24
+ GIT_COMMITTER_NAME: name,
25
+ };
26
+ }
27
+
11
28
  export class GitService {
12
29
  constructor({
13
30
  commandEnv = null,
@@ -37,6 +54,11 @@ export class GitService {
37
54
  statusService: this.statusService,
38
55
  untrackedFileService: this.untrackedFileService,
39
56
  });
57
+ this.historyService = new GitHistoryService({
58
+ commandRunner: this.commandRunner,
59
+ maxInitialPatchBytes,
60
+ maxInitialPatchLines,
61
+ });
40
62
  this.pullBackupStore = new PullBackupStore({ vaultDir });
41
63
  }
42
64
 
@@ -51,6 +73,7 @@ export class GitService {
51
73
  invalidateStatusCache() {
52
74
  this.statusService.invalidate();
53
75
  this.diffService.invalidate();
76
+ this.historyService.invalidate();
54
77
  }
55
78
 
56
79
  async getStatus(options = {}) {
@@ -87,7 +110,7 @@ export class GitService {
87
110
  };
88
111
  }
89
112
 
90
- async commitStaged({ message } = {}) {
113
+ async commitStaged({ author = null, message } = {}) {
91
114
  const normalizedMessage = String(message ?? '').trim();
92
115
  if (!normalizedMessage) {
93
116
  throw createGitRequestError(400, 'Missing commit message');
@@ -98,7 +121,9 @@ export class GitService {
98
121
  throw createGitRequestError(409, 'No staged changes to commit');
99
122
  }
100
123
 
101
- await this.commandRunner.execGit(['commit', '-m', normalizedMessage]);
124
+ await this.commandRunner.execGit(['commit', '-m', normalizedMessage], {
125
+ env: buildAuthorEnv(author),
126
+ });
102
127
  const hash = (await this.commandRunner.execGit(['rev-parse', 'HEAD'])).trim();
103
128
  const shortHash = (await this.commandRunner.execGit(['rev-parse', '--short', 'HEAD'])).trim();
104
129
  this.invalidateStatusCache();
@@ -233,6 +258,27 @@ export class GitService {
233
258
  });
234
259
  }
235
260
 
261
+ async getHistory({ limit = 30, offset = 0 } = {}) {
262
+ return this.historyService.listHistory({ limit, offset });
263
+ }
264
+
265
+ async getFileHistory({ limit = 30, offset = 0, path } = {}) {
266
+ return this.historyService.listFileHistory({ limit, offset, path });
267
+ }
268
+
269
+ async getCommit({ allowLargePatch = false, hash, metaOnly = false, path = null } = {}) {
270
+ return this.historyService.getCommit({
271
+ allowLargePatch,
272
+ hash,
273
+ metaOnly,
274
+ path,
275
+ });
276
+ }
277
+
278
+ async getFileSnapshot({ hash, path } = {}) {
279
+ return this.historyService.getFileSnapshot({ hash, path });
280
+ }
281
+
236
282
  async getHeadRef() {
237
283
  try {
238
284
  const output = await this.commandRunner.execGit(['rev-parse', 'HEAD']);