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
@@ -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() {
@@ -86,23 +86,89 @@ export function createDiffResponse({
86
86
  };
87
87
  }
88
88
 
89
+ export function createHistoryResponse({
90
+ commits = [],
91
+ hasMore = false,
92
+ isGitRepo = true,
93
+ limit = 30,
94
+ offset = 0,
95
+ } = {}) {
96
+ return {
97
+ commits,
98
+ hasMore,
99
+ isGitRepo,
100
+ limit,
101
+ offset,
102
+ };
103
+ }
104
+
105
+ export function createFileHistoryResponse({
106
+ commits = [],
107
+ hasMore = false,
108
+ isGitRepo = true,
109
+ limit = 30,
110
+ offset = 0,
111
+ path = null,
112
+ } = {}) {
113
+ return {
114
+ commits,
115
+ hasMore,
116
+ isGitRepo,
117
+ limit,
118
+ offset,
119
+ path,
120
+ };
121
+ }
122
+
123
+ export function createCommitDiffResponse({
124
+ commit = null,
125
+ files = [],
126
+ isGitRepo = true,
127
+ metaOnly = false,
128
+ path = null,
129
+ summary = {
130
+ additions: 0,
131
+ deletions: 0,
132
+ filesChanged: 0,
133
+ },
134
+ } = {}) {
135
+ return {
136
+ commit,
137
+ files,
138
+ isGitRepo,
139
+ metaOnly,
140
+ path,
141
+ source: 'commit',
142
+ summary,
143
+ };
144
+ }
145
+
146
+ export function createFileSnapshotResponse({
147
+ content = '',
148
+ fileKind = null,
149
+ hash = null,
150
+ isGitRepo = true,
151
+ path = null,
152
+ } = {}) {
153
+ return {
154
+ content,
155
+ fileKind,
156
+ hash,
157
+ isGitRepo,
158
+ path,
159
+ };
160
+ }
161
+
89
162
  export function createWorkspaceChange({
90
163
  changedPaths = [],
91
164
  deletedPaths = [],
92
165
  refreshExplorer = true,
93
166
  renamedPaths = [],
94
167
  } = {}) {
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
- };
168
+ return createSharedWorkspaceChange({
169
+ changedPaths,
170
+ deletedPaths,
171
+ refreshExplorer,
172
+ renamedPaths,
173
+ });
108
174
  }
@@ -7,6 +7,14 @@ function applyAuthResponse(req, res, result) {
7
7
  res.setHeader('Set-Cookie', result.setCookie);
8
8
  }
9
9
 
10
+ if (result?.redirectTo) {
11
+ res.writeHead(result?.statusCode ?? 302, {
12
+ Location: result.redirectTo,
13
+ });
14
+ res.end();
15
+ return true;
16
+ }
17
+
10
18
  jsonResponse(req, res, result?.statusCode ?? 200, result?.body ?? { ok: true });
11
19
  return true;
12
20
  }
@@ -21,10 +29,30 @@ export function createAuthApiHandler({ authService }) {
21
29
  return applyAuthResponse(req, res, authService.getStatus(req));
22
30
  }
23
31
 
32
+ if (requestUrl.pathname === '/api/auth/oidc/login' && req.method === 'GET') {
33
+ try {
34
+ return applyAuthResponse(req, res, await authService.beginOidcLogin(req, requestUrl));
35
+ } catch (error) {
36
+ console.error('[api] Failed to start OIDC login:', error.message);
37
+ jsonResponse(req, res, 500, { error: 'Failed to start OIDC login' });
38
+ return true;
39
+ }
40
+ }
41
+
42
+ if (requestUrl.pathname === '/api/auth/oidc/callback' && req.method === 'GET') {
43
+ try {
44
+ return applyAuthResponse(req, res, await authService.completeOidcLogin(req, requestUrl));
45
+ } catch (error) {
46
+ console.error('[api] Failed to complete OIDC login:', error.message);
47
+ jsonResponse(req, res, 500, { error: 'Failed to complete OIDC login' });
48
+ return true;
49
+ }
50
+ }
51
+
24
52
  if (requestUrl.pathname === '/api/auth/session' && req.method === 'POST') {
25
53
  try {
26
54
  const body = await parseJsonBody(req);
27
- return applyAuthResponse(req, res, authService.createSession(req, body));
55
+ return applyAuthResponse(req, res, await authService.createSession(req, body));
28
56
  } catch (error) {
29
57
  const statusCode = getRequestErrorStatusCode(error);
30
58
  if (statusCode) {
@@ -39,7 +67,7 @@ export function createAuthApiHandler({ authService }) {
39
67
  }
40
68
 
41
69
  if (requestUrl.pathname === '/api/auth/session' && req.method === 'DELETE') {
42
- return applyAuthResponse(req, res, authService.clearSession(req));
70
+ return applyAuthResponse(req, res, await authService.clearSession(req));
43
71
  }
44
72
 
45
73
  jsonResponse(req, res, 404, { error: 'Auth endpoint not found' });
@@ -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,20 @@ 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,
61
+ authService = null,
62
62
  gitService,
63
- roomRegistry = null,
64
- vaultFileStore = null,
63
+ workspaceMutationCoordinator = null,
65
64
  }) {
66
65
  return async function handleGitApiCommand(req, res, requestUrl) {
67
66
  if (requestUrl.pathname === '/api/git/stage' && req.method === 'POST') {
@@ -72,10 +71,12 @@ export function createGitApiCommandHandler({
72
71
  }
73
72
 
74
73
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
75
- backlinkIndex,
76
- responsePayload: await gitService.stageFile(body.path),
77
- roomRegistry,
78
- vaultFileStore,
74
+ action: 'stage',
75
+ req,
76
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
77
+ () => gitService.stageFile(body.path),
78
+ ),
79
+ workspaceMutationCoordinator,
79
80
  }));
80
81
  } catch (error) {
81
82
  handleGitError(req, res, error, '[api] Failed to stage git file:', 'Failed to stage git file');
@@ -91,10 +92,12 @@ export function createGitApiCommandHandler({
91
92
  }
92
93
 
93
94
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
94
- backlinkIndex,
95
- responsePayload: await gitService.unstageFile(body.path),
96
- roomRegistry,
97
- vaultFileStore,
95
+ action: 'unstage',
96
+ req,
97
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
98
+ () => gitService.unstageFile(body.path),
99
+ ),
100
+ workspaceMutationCoordinator,
98
101
  }));
99
102
  } catch (error) {
100
103
  handleGitError(req, res, error, '[api] Failed to unstage git file:', 'Failed to unstage git file');
@@ -110,12 +113,15 @@ export function createGitApiCommandHandler({
110
113
  }
111
114
 
112
115
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
113
- backlinkIndex,
114
- responsePayload: await gitService.commitStaged({
115
- message: body.message,
116
- }),
117
- roomRegistry,
118
- vaultFileStore,
116
+ action: 'commit',
117
+ req,
118
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
119
+ () => gitService.commitStaged({
120
+ author: authService?.getAuthenticatedUser?.(req) ?? null,
121
+ message: body.message,
122
+ }),
123
+ ),
124
+ workspaceMutationCoordinator,
119
125
  }));
120
126
  } catch (error) {
121
127
  handleGitError(req, res, error, '[api] Failed to commit staged changes:', 'Failed to commit staged changes');
@@ -126,10 +132,12 @@ export function createGitApiCommandHandler({
126
132
  if (requestUrl.pathname === '/api/git/push' && req.method === 'POST') {
127
133
  try {
128
134
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
129
- backlinkIndex,
130
- responsePayload: await gitService.pushBranch(),
131
- roomRegistry,
132
- vaultFileStore,
135
+ action: 'push',
136
+ req,
137
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
138
+ () => gitService.pushBranch(),
139
+ ),
140
+ workspaceMutationCoordinator,
133
141
  }));
134
142
  } catch (error) {
135
143
  handleGitError(req, res, error, '[api] Failed to push git branch:', 'Failed to push git branch');
@@ -140,10 +148,12 @@ export function createGitApiCommandHandler({
140
148
  if (requestUrl.pathname === '/api/git/pull' && req.method === 'POST') {
141
149
  try {
142
150
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
143
- backlinkIndex,
144
- responsePayload: await gitService.pullBranch(),
145
- roomRegistry,
146
- vaultFileStore,
151
+ action: 'pull',
152
+ req,
153
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
154
+ () => gitService.pullBranch(),
155
+ ),
156
+ workspaceMutationCoordinator,
147
157
  }));
148
158
  } catch (error) {
149
159
  handleGitError(req, res, error, '[api] Failed to pull git branch:', 'Failed to pull git branch');
@@ -159,10 +169,12 @@ export function createGitApiCommandHandler({
159
169
  }
160
170
 
161
171
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
162
- backlinkIndex,
163
- responsePayload: await gitService.resetFileToHead(body.path),
164
- roomRegistry,
165
- vaultFileStore,
172
+ action: 'reset-file',
173
+ req,
174
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
175
+ () => gitService.resetFileToHead(body.path),
176
+ ),
177
+ workspaceMutationCoordinator,
166
178
  }));
167
179
  } catch (error) {
168
180
  handleGitError(req, res, error, '[api] Failed to reset git file:', 'Failed to reset git file');
@@ -3,17 +3,21 @@ import { createGitApiCommandHandler } from './create-git-api-command-handler.js'
3
3
  import { createGitApiQueryHandler } from './create-git-api-query-handler.js';
4
4
 
5
5
  export function createGitApiHandler({
6
+ authService = null,
6
7
  backlinkIndex = null,
7
8
  gitService = null,
8
9
  roomRegistry = null,
9
10
  vaultFileStore = null,
11
+ workspaceMutationCoordinator = null,
10
12
  }) {
11
13
  const handleGitApiQuery = createGitApiQueryHandler({ gitService });
12
14
  const handleGitApiCommand = createGitApiCommandHandler({
15
+ authService,
13
16
  backlinkIndex,
14
17
  gitService,
15
18
  roomRegistry,
16
19
  vaultFileStore,
20
+ workspaceMutationCoordinator,
17
21
  });
18
22
 
19
23
  return async function handleGitApi(req, res, requestUrl) {
@@ -18,6 +18,16 @@ function isTruthyParam(value) {
18
18
  return value === '1' || value === 'true';
19
19
  }
20
20
 
21
+ function readHistoryLimit(value) {
22
+ const parsed = Number.parseInt(String(value ?? ''), 10);
23
+ return Number.isFinite(parsed) ? parsed : 30;
24
+ }
25
+
26
+ function readHistoryOffset(value) {
27
+ const parsed = Number.parseInt(String(value ?? ''), 10);
28
+ return Number.isFinite(parsed) ? parsed : 0;
29
+ }
30
+
21
31
  function handleGitError(req, res, error, message, fallback) {
22
32
  const statusCode = getRequestErrorStatusCode(error);
23
33
  if (statusCode) {
@@ -61,6 +71,57 @@ export function createGitApiQueryHandler({ gitService }) {
61
71
  return true;
62
72
  }
63
73
 
74
+ if (requestUrl.pathname === '/api/git/history' && req.method === 'GET') {
75
+ try {
76
+ jsonResponse(req, res, 200, await gitService.getHistory({
77
+ limit: readHistoryLimit(requestUrl.searchParams.get('limit')),
78
+ offset: readHistoryOffset(requestUrl.searchParams.get('offset')),
79
+ }));
80
+ } catch (error) {
81
+ handleGitError(req, res, error, '[api] Failed to read git history:', 'Failed to read git history');
82
+ }
83
+ return true;
84
+ }
85
+
86
+ if (requestUrl.pathname === '/api/git/file-history' && req.method === 'GET') {
87
+ try {
88
+ jsonResponse(req, res, 200, await gitService.getFileHistory({
89
+ limit: readHistoryLimit(requestUrl.searchParams.get('limit')),
90
+ offset: readHistoryOffset(requestUrl.searchParams.get('offset')),
91
+ path: requestUrl.searchParams.get('path'),
92
+ }));
93
+ } catch (error) {
94
+ handleGitError(req, res, error, '[api] Failed to read git file history:', 'Failed to read git file history');
95
+ }
96
+ return true;
97
+ }
98
+
99
+ if (requestUrl.pathname === '/api/git/commit' && req.method === 'GET') {
100
+ try {
101
+ jsonResponse(req, res, 200, await gitService.getCommit({
102
+ allowLargePatch: isTruthyParam(requestUrl.searchParams.get('allowLargePatch')),
103
+ hash: requestUrl.searchParams.get('hash'),
104
+ metaOnly: isTruthyParam(requestUrl.searchParams.get('metaOnly')),
105
+ path: requestUrl.searchParams.get('path'),
106
+ }));
107
+ } catch (error) {
108
+ handleGitError(req, res, error, '[api] Failed to read git commit:', 'Failed to read git commit');
109
+ }
110
+ return true;
111
+ }
112
+
113
+ if (requestUrl.pathname === '/api/git/file-snapshot' && req.method === 'GET') {
114
+ try {
115
+ jsonResponse(req, res, 200, await gitService.getFileSnapshot({
116
+ hash: requestUrl.searchParams.get('hash'),
117
+ path: requestUrl.searchParams.get('path'),
118
+ }));
119
+ } catch (error) {
120
+ handleGitError(req, res, error, '[api] Failed to read git file snapshot:', 'Failed to read git file snapshot');
121
+ }
122
+ return true;
123
+ }
124
+
64
125
  if (requestUrl.pathname === '/api/git/pull-backups' && req.method === 'GET') {
65
126
  try {
66
127
  jsonResponse(req, res, 200, {
@@ -3,6 +3,7 @@ import { createGitApiHandler } from './create-git-api-handler.js';
3
3
  import { createEsmProxyHandler } from './create-esm-proxy-handler.js';
4
4
  import { createStaticHandler } from './create-static-handler.js';
5
5
  import { createVaultApiHandler } from './create-vault-api-handler.js';
6
+ import { parseJsonBody } from './request-body.js';
6
7
  import {
7
8
  applyCorsHeaders,
8
9
  jsonResponse,
@@ -42,21 +43,27 @@ export function createRequestHandler(
42
43
  roomRegistry = null,
43
44
  plantUmlRenderer = null,
44
45
  gitService = null,
46
+ testControls = { wsRoomHydrateDelayMs: 0 },
47
+ workspaceMutationCoordinator = null,
48
+ fileSystemSyncService = null,
45
49
  ) {
46
50
  const handleEsmProxy = createEsmProxyHandler();
47
51
  const handleStaticRequest = createStaticHandler(config, authService);
48
52
  const handleAuthApi = createAuthApiHandler({ authService });
49
53
  const handleGitApi = createGitApiHandler({
54
+ authService,
50
55
  backlinkIndex,
51
56
  gitService,
52
57
  roomRegistry,
53
58
  vaultFileStore,
59
+ workspaceMutationCoordinator,
54
60
  });
55
61
  const handleVaultApi = createVaultApiHandler({
56
62
  backlinkIndex,
57
63
  plantUmlRenderer,
58
64
  roomRegistry,
59
65
  vaultFileStore,
66
+ workspaceMutationCoordinator,
60
67
  });
61
68
 
62
69
  return async function handleRequest(req, res) {
@@ -103,12 +110,27 @@ export function createRequestHandler(
103
110
  }
104
111
 
105
112
  if (config.nodeEnv === 'test' && requestUrl.pathname === '/api/test/reset-state' && req.method === 'POST') {
113
+ await fileSystemSyncService?.resetForExternalStateChange?.();
106
114
  await roomRegistry?.reset?.();
107
115
  await backlinkIndex?.build?.();
116
+ await workspaceMutationCoordinator?.initialize?.();
117
+ await fileSystemSyncService?.resetForExternalStateChange?.();
108
118
  jsonResponse(req, res, 200, { ok: true });
109
119
  return;
110
120
  }
111
121
 
122
+ if (config.nodeEnv === 'test' && requestUrl.pathname === '/api/test/hydrate-delay' && req.method === 'POST') {
123
+ const body = await parseJsonBody(req).catch(() => ({}));
124
+ testControls.wsRoomHydrateDelayMs = Math.max(0, Number(body?.delayMs) || 0);
125
+ await fileSystemSyncService?.resetForExternalStateChange?.();
126
+ await roomRegistry?.reset?.();
127
+ await backlinkIndex?.build?.();
128
+ await workspaceMutationCoordinator?.initialize?.();
129
+ await fileSystemSyncService?.resetForExternalStateChange?.();
130
+ jsonResponse(req, res, 200, { delayMs: testControls.wsRoomHydrateDelayMs, ok: true });
131
+ return;
132
+ }
133
+
112
134
  if (await handleAuthApi(req, res, requestUrl)) {
113
135
  return;
114
136
  }
@@ -3,6 +3,7 @@ import {
3
3
  isMermaidFilePath,
4
4
  isPlantUmlFilePath,
5
5
  } from '../../../domain/file-kind.js';
6
+ import { createWorkspaceChange } from '../../../domain/workspace-change.js';
6
7
  import { createRequestError, getRequestErrorStatusCode } from './http-errors.js';
7
8
  import { jsonResponse } from './http-response.js';
8
9
  import { parseJsonBody, readBinaryRequestBody } from './request-body.js';
@@ -36,6 +37,11 @@ function selectWriteOperation(vaultFileStore, filePath, content) {
36
37
  return vaultFileStore.writeMarkdownFile(filePath, content);
37
38
  }
38
39
 
40
+ function readRequestId(req) {
41
+ const value = String(req.headers['x-collabmd-request-id'] || '').trim();
42
+ return value ? value.slice(0, 120) : null;
43
+ }
44
+
39
45
  function handleVaultError(req, res, error, logMessage, fallbackMessage) {
40
46
  const statusCode = getRequestErrorStatusCode(error);
41
47
  if (statusCode) {
@@ -49,9 +55,8 @@ function handleVaultError(req, res, error, logMessage, fallbackMessage) {
49
55
  }
50
56
 
51
57
  export function createVaultApiCommandHandler({
52
- backlinkIndex,
53
- roomRegistry = null,
54
58
  vaultFileStore,
59
+ workspaceMutationCoordinator = null,
55
60
  }) {
56
61
  return async function handleVaultApiCommand(req, res, requestUrl) {
57
62
  if (requestUrl.pathname === '/api/file' && req.method === 'PUT') {
@@ -68,6 +73,15 @@ export function createVaultApiCommandHandler({
68
73
  return true;
69
74
  }
70
75
 
76
+ await workspaceMutationCoordinator?.apply?.({
77
+ action: 'write-file',
78
+ origin: 'api',
79
+ requestId: readRequestId(req),
80
+ workspaceChange: createWorkspaceChange({
81
+ changedPaths: [body.path],
82
+ }),
83
+ });
84
+
71
85
  jsonResponse(req, res, 200, { ok: true });
72
86
  } catch (error) {
73
87
  handleVaultError(req, res, error, '[api] Failed to write file:', 'Failed to write file');
@@ -97,6 +111,15 @@ export function createVaultApiCommandHandler({
97
111
  return true;
98
112
  }
99
113
 
114
+ await workspaceMutationCoordinator?.apply?.({
115
+ action: 'upload-attachment',
116
+ origin: 'api',
117
+ requestId: readRequestId(req),
118
+ workspaceChange: createWorkspaceChange({
119
+ changedPaths: [result.path],
120
+ }),
121
+ });
122
+
100
123
  jsonResponse(req, res, 201, {
101
124
  markdown: result.markdownSnippet,
102
125
  ok: true,
@@ -121,8 +144,14 @@ export function createVaultApiCommandHandler({
121
144
  jsonResponse(req, res, 409, { error: result.error });
122
145
  return true;
123
146
  }
124
-
125
- backlinkIndex?.onFileCreated(body.path, body.content || '');
147
+ await workspaceMutationCoordinator?.apply?.({
148
+ action: 'create-file',
149
+ origin: 'api',
150
+ requestId: readRequestId(req),
151
+ workspaceChange: createWorkspaceChange({
152
+ changedPaths: [body.path],
153
+ }),
154
+ });
126
155
  jsonResponse(req, res, 201, { ok: true, path: body.path });
127
156
  } catch (error) {
128
157
  handleVaultError(req, res, error, '[api] Failed to create file:', 'Failed to create file');
@@ -137,21 +166,22 @@ export function createVaultApiCommandHandler({
137
166
  return true;
138
167
  }
139
168
 
140
- const activeRoom = roomRegistry?.get(filePath);
141
169
  try {
142
- activeRoom?.markDeleted?.();
143
170
  const result = await vaultFileStore.deleteFile(filePath);
144
171
  if (!result.ok) {
145
- activeRoom?.unmarkDeleted?.();
146
172
  jsonResponse(req, res, 400, { error: result.error });
147
173
  return true;
148
174
  }
149
-
150
- roomRegistry?.delete?.(filePath);
151
- backlinkIndex?.onFileDeleted(filePath);
175
+ await workspaceMutationCoordinator?.apply?.({
176
+ action: 'delete-file',
177
+ origin: 'api',
178
+ requestId: readRequestId(req),
179
+ workspaceChange: createWorkspaceChange({
180
+ deletedPaths: [filePath],
181
+ }),
182
+ });
152
183
  jsonResponse(req, res, 200, { ok: true });
153
184
  } catch (error) {
154
- activeRoom?.unmarkDeleted?.();
155
185
  console.error('[api] Failed to delete file:', error.message);
156
186
  jsonResponse(req, res, 500, { error: 'Failed to delete file' });
157
187
  }
@@ -171,9 +201,14 @@ export function createVaultApiCommandHandler({
171
201
  jsonResponse(req, res, 400, { error: result.error });
172
202
  return true;
173
203
  }
174
-
175
- roomRegistry?.rename(body.oldPath, body.newPath);
176
- backlinkIndex?.onFileRenamed(body.oldPath, body.newPath);
204
+ await workspaceMutationCoordinator?.apply?.({
205
+ action: 'rename-file',
206
+ origin: 'api',
207
+ requestId: readRequestId(req),
208
+ workspaceChange: createWorkspaceChange({
209
+ renamedPaths: [{ oldPath: body.oldPath, newPath: body.newPath }],
210
+ }),
211
+ });
177
212
  jsonResponse(req, res, 200, { ok: true, path: body.newPath });
178
213
  } catch (error) {
179
214
  handleVaultError(req, res, error, '[api] Failed to rename file:', 'Failed to rename file');
@@ -195,6 +230,15 @@ export function createVaultApiCommandHandler({
195
230
  return true;
196
231
  }
197
232
 
233
+ await workspaceMutationCoordinator?.apply?.({
234
+ action: 'create-directory',
235
+ origin: 'api',
236
+ requestId: readRequestId(req),
237
+ workspaceChange: createWorkspaceChange({
238
+ changedPaths: [body.path],
239
+ }),
240
+ });
241
+
198
242
  jsonResponse(req, res, 201, { ok: true });
199
243
  } catch (error) {
200
244
  handleVaultError(req, res, error, '[api] Failed to create directory:', 'Failed to create directory');
@@ -7,15 +7,18 @@ export function createVaultApiHandler({
7
7
  plantUmlRenderer = null,
8
8
  roomRegistry = null,
9
9
  vaultFileStore,
10
+ workspaceMutationCoordinator = null,
10
11
  }) {
11
12
  const handleVaultApiQuery = createVaultApiQueryHandler({
12
13
  backlinkIndex,
13
14
  vaultFileStore,
15
+ workspaceMutationCoordinator,
14
16
  });
15
17
  const handleVaultApiCommand = createVaultApiCommandHandler({
16
18
  backlinkIndex,
17
19
  roomRegistry,
18
20
  vaultFileStore,
21
+ workspaceMutationCoordinator,
19
22
  });
20
23
  const handlePlantUmlApi = createPlantUmlApiHandler({
21
24
  plantUmlRenderer,
@@ -59,11 +59,13 @@ function selectReadOperation(vaultFileStore, filePath) {
59
59
  export function createVaultApiQueryHandler({
60
60
  backlinkIndex,
61
61
  vaultFileStore,
62
+ workspaceMutationCoordinator = null,
62
63
  }) {
63
64
  return async function handleVaultApiQuery(req, res, requestUrl) {
64
65
  if (requestUrl.pathname === '/api/files' && req.method === 'GET') {
65
66
  try {
66
- jsonResponse(req, res, 200, { tree: await vaultFileStore.tree() });
67
+ const tree = workspaceMutationCoordinator?.getWorkspaceTree?.() ?? await vaultFileStore.tree();
68
+ jsonResponse(req, res, 200, { tree });
67
69
  } catch (error) {
68
70
  console.error('[api] Failed to read file tree:', error.message);
69
71
  jsonResponse(req, res, 500, { error: 'Failed to read file tree' });