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
@@ -14,9 +14,27 @@ import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../domain/
14
14
 
15
15
  const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
16
16
 
17
+ function createDeferred() {
18
+ let resolve;
19
+ let reject;
20
+ const promise = new Promise((nextResolve, nextReject) => {
21
+ resolve = nextResolve;
22
+ reject = nextReject;
23
+ });
24
+ return { promise, reject, resolve };
25
+ }
26
+
17
27
  export class BacklinkIndex {
18
- constructor({ vaultFileStore }) {
28
+ constructor({
29
+ rebuildDelayMs = 150,
30
+ setTimeoutFn = setTimeout,
31
+ clearTimeoutFn = clearTimeout,
32
+ vaultFileStore,
33
+ }) {
19
34
  this.vaultFileStore = vaultFileStore;
35
+ this.rebuildDelayMs = rebuildDelayMs;
36
+ this.setTimeoutFn = setTimeoutFn;
37
+ this.clearTimeoutFn = clearTimeoutFn;
20
38
  /** @type {Map<string, Set<string>>} sourcePath → set of resolved target paths */
21
39
  this.forward = new Map();
22
40
  /** @type {Map<string, Set<string>>} targetPath → set of source paths */
@@ -29,6 +47,11 @@ export class BacklinkIndex {
29
47
  this._fileSet = new Set();
30
48
  this._wikiTargetIndex = createWikiTargetIndex(this._fileList);
31
49
  this._built = false;
50
+ this._requestedBuildVersion = 0;
51
+ this._completedBuildVersion = 0;
52
+ this._buildPromise = null;
53
+ this._scheduledBuildTimer = null;
54
+ this._scheduledBuildDeferred = null;
32
55
  }
33
56
 
34
57
  /**
@@ -36,6 +59,74 @@ export class BacklinkIndex {
36
59
  * Called once at server startup.
37
60
  */
38
61
  async build() {
62
+ this._requestedBuildVersion += 1;
63
+ return this.flushScheduledBuild();
64
+ }
65
+
66
+ scheduleBuild({ delayMs = this.rebuildDelayMs } = {}) {
67
+ this._requestedBuildVersion += 1;
68
+ if (!this._scheduledBuildDeferred) {
69
+ this._scheduledBuildDeferred = createDeferred();
70
+ }
71
+
72
+ if (this._scheduledBuildTimer) {
73
+ this.clearTimeoutFn(this._scheduledBuildTimer);
74
+ }
75
+
76
+ this._scheduledBuildTimer = this.setTimeoutFn(() => {
77
+ this._scheduledBuildTimer = null;
78
+ const deferred = this._scheduledBuildDeferred;
79
+ this._scheduledBuildDeferred = null;
80
+ this._ensureBuiltToRequestedVersion()
81
+ .then(() => deferred?.resolve())
82
+ .catch((error) => deferred?.reject(error));
83
+ }, delayMs);
84
+ this._scheduledBuildTimer.unref?.();
85
+
86
+ return this._scheduledBuildDeferred.promise;
87
+ }
88
+
89
+ async flushScheduledBuild() {
90
+ if (this._scheduledBuildTimer) {
91
+ this.clearTimeoutFn(this._scheduledBuildTimer);
92
+ this._scheduledBuildTimer = null;
93
+ }
94
+
95
+ const deferred = this._scheduledBuildDeferred;
96
+ this._scheduledBuildDeferred = null;
97
+
98
+ try {
99
+ await this._ensureBuiltToRequestedVersion();
100
+ deferred?.resolve();
101
+ } catch (error) {
102
+ deferred?.reject(error);
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ async _ensureBuiltToRequestedVersion() {
108
+ while (this._completedBuildVersion < this._requestedBuildVersion) {
109
+ await this._runSingleBuild();
110
+ }
111
+ }
112
+
113
+ async _runSingleBuild() {
114
+ if (this._buildPromise) {
115
+ return this._buildPromise;
116
+ }
117
+
118
+ const targetVersion = this._requestedBuildVersion;
119
+ this._buildPromise = (async () => {
120
+ await this._performBuild();
121
+ this._completedBuildVersion = Math.max(this._completedBuildVersion, targetVersion);
122
+ })().finally(() => {
123
+ this._buildPromise = null;
124
+ });
125
+
126
+ return this._buildPromise;
127
+ }
128
+
129
+ async _performBuild() {
39
130
  this.forward.clear();
40
131
  this.reverse.clear();
41
132
  this.contextsBySource.clear();
@@ -169,6 +260,8 @@ export class BacklinkIndex {
169
260
  * Returns: [{ file: string, contexts: string[] }]
170
261
  */
171
262
  async getBacklinks(filePath) {
263
+ await this.flushScheduledBuild();
264
+
172
265
  const sources = this.reverse.get(filePath);
173
266
  if (!sources || sources.size === 0) {
174
267
  return [];
@@ -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,6 +88,41 @@ 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,
@@ -332,36 +369,75 @@ export class CollaborationRoom {
332
369
  return false;
333
370
  }
334
371
 
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;
372
+ return this.applyExternalContent(content, {
373
+ commentThreads,
374
+ replaceCommentThreads: true,
375
+ });
376
+ }
377
+
378
+ async applyExternalContent(content, {
379
+ commentThreads = [],
380
+ replaceCommentThreads = false,
381
+ } = {}) {
382
+ if (this.deleted || this.destroyed) {
383
+ return { ok: false, reason: 'room-unavailable' };
342
384
  }
343
385
 
344
- this.doc.transact(() => {
345
- if (isExcalidrawRoom(this.name)) {
346
- if (ytext.length > 0) {
347
- ytext.delete(0, ytext.length);
348
- }
386
+ const comments = this.doc.getArray('comments');
387
+ if (isExcalidrawRoom(this.name)) {
388
+ const parsedExcalidrawScene = tryParseExcalidrawSceneJson(content);
389
+ if (!parsedExcalidrawScene) {
390
+ return { ok: false, reason: 'invalid-excalidraw' };
391
+ }
392
+
393
+ this.doc.transact(() => {
349
394
  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);
395
+ if (replaceCommentThreads) {
396
+ if (comments.length > 0) {
397
+ comments.delete(0, comments.length);
398
+ }
399
+ populateCommentThreads(comments, commentThreads);
356
400
  }
401
+ }, 'workspace-reconcile');
402
+
403
+ return { ok: true, highlightRange: null };
404
+ }
405
+
406
+ const ytext = this.doc.getText('codemirror');
407
+ const replacement = computeTextReplacement(ytext.toString(), content);
408
+ if (!replacement && !replaceCommentThreads) {
409
+ return { highlightRange: null, ok: true, skipped: true };
410
+ }
411
+
412
+ this.doc.transact(() => {
413
+ if (replacement?.deleteCount) {
414
+ ytext.delete(replacement.start, replacement.deleteCount);
357
415
  }
358
- if (comments.length > 0) {
359
- comments.delete(0, comments.length);
416
+ if (replacement?.insertText) {
417
+ ytext.insert(replacement.start, replacement.insertText);
418
+ }
419
+ if (replaceCommentThreads) {
420
+ if (comments.length > 0) {
421
+ comments.delete(0, comments.length);
422
+ }
423
+ populateCommentThreads(comments, commentThreads);
360
424
  }
361
- populateCommentThreads(comments, commentThreads);
362
425
  }, 'workspace-reconcile');
363
426
 
364
- return true;
427
+ return {
428
+ highlightRange: replacement
429
+ ? {
430
+ from: replacement.start,
431
+ to: replacement.start + replacement.insertText.length,
432
+ }
433
+ : null,
434
+ ok: true,
435
+ };
436
+ }
437
+
438
+ applyExternalDeletion() {
439
+ this.markDeleted();
440
+ return this.destroy();
365
441
  }
366
442
 
367
443
  rename(nextName) {
@@ -537,6 +613,10 @@ export class CollaborationRoom {
537
613
  }
538
614
 
539
615
  getPersistedContent() {
616
+ if (isWorkspaceRoom(this.name)) {
617
+ return null;
618
+ }
619
+
540
620
  if (!isExcalidrawRoom(this.name)) {
541
621
  return this.doc.getText('codemirror').toString();
542
622
  }
@@ -600,4 +680,93 @@ export class CollaborationRoom {
600
680
  console.error(`[room:${this.name}] Failed to handle message:`, error.message);
601
681
  }
602
682
  }
683
+
684
+ replaceWorkspaceEntries(entries = new Map(), {
685
+ generatedAt = Date.now(),
686
+ } = {}) {
687
+ if (!isWorkspaceRoom(this.name)) {
688
+ return false;
689
+ }
690
+
691
+ const normalizedEntries = entries instanceof Map ? entries : new Map(entries);
692
+ const entriesMap = this.doc.getMap('entries');
693
+ const metaMap = this.doc.getMap('meta');
694
+
695
+ this.doc.transact(() => {
696
+ Array.from(entriesMap.keys()).forEach((key) => {
697
+ if (!normalizedEntries.has(key)) {
698
+ entriesMap.delete(key);
699
+ }
700
+ });
701
+
702
+ normalizedEntries.forEach((entry, pathValue) => {
703
+ entriesMap.set(pathValue, entry);
704
+ });
705
+
706
+ metaMap.set('lastSnapshotAt', generatedAt);
707
+ metaMap.set('revision', Number(metaMap.get('revision') || 0) + 1);
708
+ }, 'workspace-room-entries');
709
+
710
+ return true;
711
+ }
712
+
713
+ applyWorkspaceEntryPatch({
714
+ deletes = [],
715
+ upserts = new Map(),
716
+ } = {}, {
717
+ generatedAt = Date.now(),
718
+ } = {}) {
719
+ if (!isWorkspaceRoom(this.name)) {
720
+ return false;
721
+ }
722
+
723
+ const normalizedUpserts = upserts instanceof Map ? upserts : new Map(upserts);
724
+ const normalizedDeletes = Array.from(new Set((deletes ?? []).filter(Boolean)));
725
+ if (normalizedUpserts.size === 0 && normalizedDeletes.length === 0) {
726
+ return false;
727
+ }
728
+
729
+ const entriesMap = this.doc.getMap('entries');
730
+ const metaMap = this.doc.getMap('meta');
731
+
732
+ this.doc.transact(() => {
733
+ normalizedDeletes.forEach((pathValue) => {
734
+ entriesMap.delete(pathValue);
735
+ });
736
+
737
+ normalizedUpserts.forEach((entry, pathValue) => {
738
+ entriesMap.set(pathValue, entry);
739
+ });
740
+
741
+ metaMap.set('lastSnapshotAt', generatedAt);
742
+ metaMap.set('revision', Number(metaMap.get('revision') || 0) + 1);
743
+ }, 'workspace-room-entry-patch');
744
+
745
+ return true;
746
+ }
747
+
748
+ publishWorkspaceEvent(event) {
749
+ if (!isWorkspaceRoom(this.name)) {
750
+ return null;
751
+ }
752
+
753
+ const normalizedEvent = normalizeWorkspaceEvent(event);
754
+ if (!normalizedEvent) {
755
+ return null;
756
+ }
757
+
758
+ const events = this.doc.getArray('events');
759
+ const metaMap = this.doc.getMap('meta');
760
+ this.doc.transact(() => {
761
+ events.push([normalizedEvent]);
762
+ const overflow = events.length - WORKSPACE_EVENT_MAX_MESSAGES;
763
+ if (overflow > 0) {
764
+ events.delete(0, overflow);
765
+ }
766
+ metaMap.set('lastEventAt', normalizedEvent.createdAt);
767
+ metaMap.set('revision', Number(metaMap.get('revision') || 0) + 1);
768
+ }, 'workspace-room-event');
769
+
770
+ return normalizedEvent;
771
+ }
603
772
  }
@@ -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
  }
@@ -1,5 +1,8 @@
1
- export function createGitRequestError(statusCode, message) {
1
+ export function createGitRequestError(statusCode, message, requestCode = null) {
2
2
  const error = new Error(message);
3
3
  error.statusCode = statusCode;
4
+ if (requestCode) {
5
+ error.requestCode = requestCode;
6
+ }
4
7
  return error;
5
8
  }