collabmd 0.1.20 → 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 (43) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/public/assets/css/style.css +1 -1
  4. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  5. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  6. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  7. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  8. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  9. package/public/assets/js/excalidraw-editor.js +1 -1
  10. package/public/assets/js/main.js +68 -68
  11. package/public/assets/js/preview-render-worker.js +15 -15
  12. package/src/client/application/app-shell/git-feature.js +57 -18
  13. package/src/client/application/app-shell/ui-feature.js +4 -0
  14. package/src/client/bootstrap/collabmd-app-shell.js +12 -0
  15. package/src/client/domain/vault-utils.js +2 -2
  16. package/src/client/excalidraw-editor.js +2 -1
  17. package/src/client/infrastructure/editor-session.js +4 -0
  18. package/src/client/infrastructure/editor-view-adapter.js +181 -1
  19. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  20. package/src/client/presentation/comment-ui-controller.js +192 -5
  21. package/src/client/presentation/file-explorer-controller.js +15 -3
  22. package/src/client/presentation/file-explorer-view.js +152 -11
  23. package/src/client/styles/style.css +46 -2
  24. package/src/domain/wiki-link-resolver.js +16 -5
  25. package/src/domain/workspace-change.js +68 -0
  26. package/src/domain/workspace-room.js +3 -0
  27. package/src/server/create-app-server.js +41 -10
  28. package/src/server/domain/backlink-index.js +94 -1
  29. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  30. package/src/server/domain/collaboration/room-registry.js +64 -10
  31. package/src/server/infrastructure/git/responses.js +12 -19
  32. package/src/server/infrastructure/http/create-git-api-command-handler.js +53 -43
  33. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  34. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  35. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  36. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  37. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  38. package/src/server/infrastructure/http/http-response.js +65 -28
  39. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  40. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  41. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  42. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  43. 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,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,3 +1,8 @@
1
+ import {
2
+ createEmptyWorkspaceChange as createSharedEmptyWorkspaceChange,
3
+ createWorkspaceChange as createSharedWorkspaceChange,
4
+ } from '../../../domain/workspace-change.js';
5
+
1
6
  const STATUS_MAP = {
2
7
  A: { code: 'A', label: 'added', status: 'added' },
3
8
  C: { code: 'C', label: 'copied', status: 'copied' },
@@ -43,12 +48,7 @@ export function createEmptyBranchStatus() {
43
48
  }
44
49
 
45
50
  export function createEmptyWorkspaceChange() {
46
- return {
47
- changedPaths: [],
48
- deletedPaths: [],
49
- refreshExplorer: true,
50
- renamedPaths: [],
51
- };
51
+ return createSharedEmptyWorkspaceChange();
52
52
  }
53
53
 
54
54
  export function createEmptyStatusResponse() {
@@ -92,17 +92,10 @@ export function createWorkspaceChange({
92
92
  refreshExplorer = true,
93
93
  renamedPaths = [],
94
94
  } = {}) {
95
- return {
96
- changedPaths: Array.from(new Set((changedPaths ?? []).filter(Boolean))),
97
- deletedPaths: Array.from(new Set((deletedPaths ?? []).filter(Boolean))),
98
- refreshExplorer: Boolean(refreshExplorer),
99
- renamedPaths: Array.from(new Map(
100
- (renamedPaths ?? [])
101
- .filter((entry) => entry?.oldPath && entry?.newPath && entry.oldPath !== entry.newPath)
102
- .map((entry) => [`${entry.oldPath}:${entry.newPath}`, {
103
- newPath: entry.newPath,
104
- oldPath: entry.oldPath,
105
- }]),
106
- ).values()),
107
- };
95
+ return createSharedWorkspaceChange({
96
+ changedPaths,
97
+ deletedPaths,
98
+ refreshExplorer,
99
+ renamedPaths,
100
+ });
108
101
  }
@@ -1,7 +1,7 @@
1
1
  import { getRequestErrorStatusCode } from './http-errors.js';
2
2
  import { jsonResponse } from './http-response.js';
3
3
  import { parseJsonBody } from './request-body.js';
4
- import { createEmptyWorkspaceChange } from '../git/responses.js';
4
+ import { createEmptyWorkspaceChange, hasWorkspaceMutation } from '../../../domain/workspace-change.js';
5
5
 
6
6
  async function parseRequiredBody(req, res, fieldName) {
7
7
  const body = await parseJsonBody(req);
@@ -29,19 +29,16 @@ function handleGitError(req, res, error, logMessage, fallbackMessage) {
29
29
  return true;
30
30
  }
31
31
 
32
- function hasWorkspaceMutation(workspaceChange = {}) {
33
- return Boolean(
34
- (workspaceChange.changedPaths?.length ?? 0) > 0
35
- || (workspaceChange.deletedPaths?.length ?? 0) > 0
36
- || (workspaceChange.renamedPaths?.length ?? 0) > 0,
37
- );
32
+ function readRequestId(req) {
33
+ const value = String(req.headers['x-collabmd-request-id'] || '').trim();
34
+ return value ? value.slice(0, 120) : null;
38
35
  }
39
36
 
40
37
  async function applyWorkspaceMutationEffects({
41
- backlinkIndex,
38
+ action,
39
+ req,
42
40
  responsePayload,
43
- roomRegistry,
44
- vaultFileStore,
41
+ workspaceMutationCoordinator,
45
42
  }) {
46
43
  const workspaceChange = responsePayload?.workspaceChange ?? createEmptyWorkspaceChange();
47
44
  responsePayload.workspaceChange = workspaceChange;
@@ -50,18 +47,19 @@ async function applyWorkspaceMutationEffects({
50
47
  return responsePayload;
51
48
  }
52
49
 
53
- await vaultFileStore?.reconcileSidecars?.(workspaceChange);
54
- await vaultFileStore?.reconcileCollaborationSnapshots?.(workspaceChange);
55
- await backlinkIndex?.build?.();
56
- await roomRegistry?.reconcileWorkspaceChange?.(workspaceChange);
50
+ await workspaceMutationCoordinator?.apply?.({
51
+ action,
52
+ origin: 'git',
53
+ requestId: readRequestId(req),
54
+ sourceRef: responsePayload?.sourceRef ?? null,
55
+ workspaceChange,
56
+ });
57
57
  return responsePayload;
58
58
  }
59
59
 
60
60
  export function createGitApiCommandHandler({
61
- backlinkIndex = null,
62
61
  gitService,
63
- roomRegistry = null,
64
- vaultFileStore = null,
62
+ workspaceMutationCoordinator = null,
65
63
  }) {
66
64
  return async function handleGitApiCommand(req, res, requestUrl) {
67
65
  if (requestUrl.pathname === '/api/git/stage' && req.method === 'POST') {
@@ -72,10 +70,12 @@ export function createGitApiCommandHandler({
72
70
  }
73
71
 
74
72
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
75
- backlinkIndex,
76
- responsePayload: await gitService.stageFile(body.path),
77
- roomRegistry,
78
- vaultFileStore,
73
+ action: 'stage',
74
+ req,
75
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
76
+ () => gitService.stageFile(body.path),
77
+ ),
78
+ workspaceMutationCoordinator,
79
79
  }));
80
80
  } catch (error) {
81
81
  handleGitError(req, res, error, '[api] Failed to stage git file:', 'Failed to stage git file');
@@ -91,10 +91,12 @@ export function createGitApiCommandHandler({
91
91
  }
92
92
 
93
93
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
94
- backlinkIndex,
95
- responsePayload: await gitService.unstageFile(body.path),
96
- roomRegistry,
97
- vaultFileStore,
94
+ action: 'unstage',
95
+ req,
96
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
97
+ () => gitService.unstageFile(body.path),
98
+ ),
99
+ workspaceMutationCoordinator,
98
100
  }));
99
101
  } catch (error) {
100
102
  handleGitError(req, res, error, '[api] Failed to unstage git file:', 'Failed to unstage git file');
@@ -110,12 +112,14 @@ export function createGitApiCommandHandler({
110
112
  }
111
113
 
112
114
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
113
- backlinkIndex,
114
- responsePayload: await gitService.commitStaged({
115
- message: body.message,
116
- }),
117
- roomRegistry,
118
- vaultFileStore,
115
+ action: 'commit',
116
+ req,
117
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
118
+ () => gitService.commitStaged({
119
+ message: body.message,
120
+ }),
121
+ ),
122
+ workspaceMutationCoordinator,
119
123
  }));
120
124
  } catch (error) {
121
125
  handleGitError(req, res, error, '[api] Failed to commit staged changes:', 'Failed to commit staged changes');
@@ -126,10 +130,12 @@ export function createGitApiCommandHandler({
126
130
  if (requestUrl.pathname === '/api/git/push' && req.method === 'POST') {
127
131
  try {
128
132
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
129
- backlinkIndex,
130
- responsePayload: await gitService.pushBranch(),
131
- roomRegistry,
132
- vaultFileStore,
133
+ action: 'push',
134
+ req,
135
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
136
+ () => gitService.pushBranch(),
137
+ ),
138
+ workspaceMutationCoordinator,
133
139
  }));
134
140
  } catch (error) {
135
141
  handleGitError(req, res, error, '[api] Failed to push git branch:', 'Failed to push git branch');
@@ -140,10 +146,12 @@ export function createGitApiCommandHandler({
140
146
  if (requestUrl.pathname === '/api/git/pull' && req.method === 'POST') {
141
147
  try {
142
148
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
143
- backlinkIndex,
144
- responsePayload: await gitService.pullBranch(),
145
- roomRegistry,
146
- vaultFileStore,
149
+ action: 'pull',
150
+ req,
151
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
152
+ () => gitService.pullBranch(),
153
+ ),
154
+ workspaceMutationCoordinator,
147
155
  }));
148
156
  } catch (error) {
149
157
  handleGitError(req, res, error, '[api] Failed to pull git branch:', 'Failed to pull git branch');
@@ -159,10 +167,12 @@ export function createGitApiCommandHandler({
159
167
  }
160
168
 
161
169
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
162
- backlinkIndex,
163
- responsePayload: await gitService.resetFileToHead(body.path),
164
- roomRegistry,
165
- vaultFileStore,
170
+ action: 'reset-file',
171
+ req,
172
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
173
+ () => gitService.resetFileToHead(body.path),
174
+ ),
175
+ workspaceMutationCoordinator,
166
176
  }));
167
177
  } catch (error) {
168
178
  handleGitError(req, res, error, '[api] Failed to reset git file:', 'Failed to reset git file');
@@ -7,6 +7,7 @@ export function createGitApiHandler({
7
7
  gitService = null,
8
8
  roomRegistry = null,
9
9
  vaultFileStore = null,
10
+ workspaceMutationCoordinator = null,
10
11
  }) {
11
12
  const handleGitApiQuery = createGitApiQueryHandler({ gitService });
12
13
  const handleGitApiCommand = createGitApiCommandHandler({
@@ -14,6 +15,7 @@ export function createGitApiHandler({
14
15
  gitService,
15
16
  roomRegistry,
16
17
  vaultFileStore,
18
+ workspaceMutationCoordinator,
17
19
  });
18
20
 
19
21
  return async function handleGitApi(req, res, requestUrl) {
@@ -42,6 +42,8 @@ export function createRequestHandler(
42
42
  roomRegistry = null,
43
43
  plantUmlRenderer = null,
44
44
  gitService = null,
45
+ workspaceMutationCoordinator = null,
46
+ fileSystemSyncService = null,
45
47
  ) {
46
48
  const handleEsmProxy = createEsmProxyHandler();
47
49
  const handleStaticRequest = createStaticHandler(config, authService);
@@ -51,12 +53,14 @@ export function createRequestHandler(
51
53
  gitService,
52
54
  roomRegistry,
53
55
  vaultFileStore,
56
+ workspaceMutationCoordinator,
54
57
  });
55
58
  const handleVaultApi = createVaultApiHandler({
56
59
  backlinkIndex,
57
60
  plantUmlRenderer,
58
61
  roomRegistry,
59
62
  vaultFileStore,
63
+ workspaceMutationCoordinator,
60
64
  });
61
65
 
62
66
  return async function handleRequest(req, res) {
@@ -103,8 +107,11 @@ export function createRequestHandler(
103
107
  }
104
108
 
105
109
  if (config.nodeEnv === 'test' && requestUrl.pathname === '/api/test/reset-state' && req.method === 'POST') {
110
+ await fileSystemSyncService?.resetForExternalStateChange?.();
106
111
  await roomRegistry?.reset?.();
107
112
  await backlinkIndex?.build?.();
113
+ await workspaceMutationCoordinator?.initialize?.();
114
+ await fileSystemSyncService?.resetForExternalStateChange?.();
108
115
  jsonResponse(req, res, 200, { ok: true });
109
116
  return;
110
117
  }