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
@@ -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' });
@@ -1,4 +1,8 @@
1
- import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib';
1
+ import {
2
+ brotliCompress,
3
+ constants as zlibConstants,
4
+ gzip,
5
+ } from 'node:zlib';
2
6
 
3
7
  const COMPRESSIBLE_CONTENT_TYPE_PATTERN = /^(?:text\/|application\/(?:javascript|json|xml)|image\/svg\+xml)/i;
4
8
  const MIN_COMPRESSIBLE_BYTES = 1024;
@@ -41,9 +45,9 @@ function resolveCompressionEncoding(acceptEncodingHeader) {
41
45
  return null;
42
46
  }
43
47
 
44
- function maybeCompressBody(req, body, contentType) {
48
+ function prepareBody(req, body, contentType) {
45
49
  if (body === undefined || body === null) {
46
- return { body: null, compressed: false, encoding: null };
50
+ return { body: null, encoding: null };
47
51
  }
48
52
 
49
53
  const bodyBuffer = Buffer.isBuffer(body)
@@ -54,27 +58,48 @@ function maybeCompressBody(req, body, contentType) {
54
58
  bodyBuffer.byteLength < MIN_COMPRESSIBLE_BYTES
55
59
  || !COMPRESSIBLE_CONTENT_TYPE_PATTERN.test(String(contentType || ''))
56
60
  ) {
57
- return { body: bodyBuffer, compressed: false, encoding: null };
61
+ return { body: bodyBuffer, encoding: null };
58
62
  }
59
63
 
60
64
  const encoding = resolveCompressionEncoding(req.headers['accept-encoding']);
61
65
  if (!encoding) {
62
- return { body: bodyBuffer, compressed: false, encoding: null };
66
+ return { body: bodyBuffer, encoding: null };
63
67
  }
64
68
 
65
- const compressedBody = encoding === 'br'
66
- ? brotliCompressSync(bodyBuffer, {
69
+ return { body: bodyBuffer, encoding };
70
+ }
71
+
72
+ function compressBody(bodyBuffer, encoding, callback) {
73
+ if (encoding === 'br') {
74
+ brotliCompress(bodyBuffer, {
67
75
  params: {
68
76
  [zlibConstants.BROTLI_PARAM_QUALITY]: 5,
69
77
  },
70
- })
71
- : gzipSync(bodyBuffer, { level: 6 });
78
+ }, callback);
79
+ return;
80
+ }
81
+
82
+ gzip(bodyBuffer, { level: 6 }, callback);
83
+ }
84
+
85
+ function writeResponseHead(res, statusCode, headers, bodyBuffer) {
86
+ const responseHeaders = { ...headers };
87
+ if (bodyBuffer) {
88
+ responseHeaders['Content-Length'] = String(bodyBuffer.byteLength);
89
+ }
72
90
 
73
- if (compressedBody.byteLength >= bodyBuffer.byteLength) {
74
- return { body: bodyBuffer, compressed: false, encoding: null };
91
+ res.writeHead(statusCode, responseHeaders);
92
+ }
93
+
94
+ function writePreparedBody(req, res, statusCode, headers, bodyBuffer) {
95
+ writeResponseHead(res, statusCode, headers, bodyBuffer);
96
+
97
+ if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
98
+ res.end();
99
+ return;
75
100
  }
76
101
 
77
- return { body: compressedBody, compressed: true, encoding };
102
+ res.end(bodyBuffer ?? undefined);
78
103
  }
79
104
 
80
105
  export function setHeaders(res, headers) {
@@ -116,30 +141,42 @@ export function sendResponse(req, res, {
116
141
  statusCode = 200,
117
142
  } = {}) {
118
143
  const contentType = headers['Content-Type'] || headers['content-type'] || '';
119
- const prepared = maybeCompressBody(req, body, contentType);
144
+ const prepared = prepareBody(req, body, contentType);
120
145
 
121
146
  if (body !== null) {
122
147
  appendVaryHeader(res, 'Accept-Encoding');
123
148
  }
124
149
 
125
- const responseHeaders = { ...headers };
126
-
127
- if (prepared.compressed && prepared.encoding) {
128
- responseHeaders['Content-Encoding'] = prepared.encoding;
129
- }
130
-
131
- if (prepared.body) {
132
- responseHeaders['Content-Length'] = String(prepared.body.byteLength);
133
- }
134
-
135
- res.writeHead(statusCode, responseHeaders);
136
-
137
- if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
138
- res.end();
150
+ if (!prepared.body || !prepared.encoding || req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
151
+ writePreparedBody(req, res, statusCode, headers, prepared.body);
139
152
  return;
140
153
  }
141
154
 
142
- res.end(prepared.body ?? undefined);
155
+ compressBody(prepared.body, prepared.encoding, (error, compressedBody) => {
156
+ if (res.writableEnded || res.destroyed) {
157
+ return;
158
+ }
159
+
160
+ if (error) {
161
+ console.error('[http] Failed to compress response body:', error.message);
162
+ if (!res.headersSent) {
163
+ writePreparedBody(req, res, statusCode, headers, prepared.body);
164
+ return;
165
+ }
166
+ res.destroy(error);
167
+ return;
168
+ }
169
+
170
+ if (!compressedBody || compressedBody.byteLength >= prepared.body.byteLength) {
171
+ writePreparedBody(req, res, statusCode, headers, prepared.body);
172
+ return;
173
+ }
174
+
175
+ writePreparedBody(req, res, statusCode, {
176
+ ...headers,
177
+ 'Content-Encoding': prepared.encoding,
178
+ }, compressedBody);
179
+ });
143
180
  }
144
181
 
145
182
  export function jsonResponse(req, res, statusCode, data) {
@@ -60,6 +60,19 @@ async function pathExists(pathValue) {
60
60
  }
61
61
  }
62
62
 
63
+ function createWorkspaceEntry(relativePath, type) {
64
+ return {
65
+ fileKind: type === 'directory' ? null : getVaultTreeNodeType(relativePath),
66
+ name: basename(relativePath),
67
+ nodeType: type,
68
+ parentPath: dirname(relativePath).replace(/\\/g, '/') === '.'
69
+ ? ''
70
+ : dirname(relativePath).replace(/\\/g, '/'),
71
+ path: relativePath,
72
+ type: type === 'directory' ? 'directory' : getVaultTreeNodeType(relativePath),
73
+ };
74
+ }
75
+
63
76
  async function cleanupPaths(paths = []) {
64
77
  await Promise.allSettled(paths.filter(Boolean).map((pathValue) => rm(pathValue, { force: true })));
65
78
  }
@@ -150,6 +163,19 @@ export class VaultFileStore {
150
163
  constructor({ vaultDir }) {
151
164
  this.vaultDir = resolve(vaultDir);
152
165
  this.sidecarStore = new SidecarStore({ vaultDir: this.vaultDir });
166
+ this.managedWriteTracker = null;
167
+ }
168
+
169
+ setManagedWriteTracker(tracker) {
170
+ this.managedWriteTracker = tracker ?? null;
171
+ }
172
+
173
+ async runManagedWrite(paths, operation) {
174
+ if (!this.managedWriteTracker?.runManagedWrite) {
175
+ return operation();
176
+ }
177
+
178
+ return this.managedWriteTracker.runManagedWrite(paths, operation);
153
179
  }
154
180
 
155
181
  async tree() {
@@ -251,11 +277,13 @@ export class VaultFileStore {
251
277
  }
252
278
 
253
279
  try {
254
- await mkdir(dirname(resolved.absolute), { recursive: true });
255
- await writeFile(resolved.absolute, content, 'utf-8');
256
- if (invalidateCollaborationSnapshot) {
257
- await this.deleteCollaborationSnapshot(filePath);
258
- }
280
+ await this.runManagedWrite([filePath], async () => {
281
+ await mkdir(dirname(resolved.absolute), { recursive: true });
282
+ await writeFile(resolved.absolute, content, 'utf-8');
283
+ if (invalidateCollaborationSnapshot) {
284
+ await this.deleteCollaborationSnapshot(filePath);
285
+ }
286
+ });
259
287
  return { ok: true };
260
288
  } catch (error) {
261
289
  return { ok: false, error: error.message };
@@ -356,8 +384,10 @@ export class VaultFileStore {
356
384
  }
357
385
 
358
386
  try {
359
- await mkdir(dirname(absolutePath), { recursive: true });
360
- await writeFile(absolutePath, content);
387
+ await this.runManagedWrite([storedPath], async () => {
388
+ await mkdir(dirname(absolutePath), { recursive: true });
389
+ await writeFile(absolutePath, content);
390
+ });
361
391
  } catch (error) {
362
392
  return { ok: false, error: error.message };
363
393
  }
@@ -412,49 +442,51 @@ export class VaultFileStore {
412
442
  const committedOperations = [];
413
443
 
414
444
  try {
415
- for (const operation of operations) {
416
- if (operation.kind !== 'write') {
417
- continue;
418
- }
445
+ await this.runManagedWrite([filePath], async () => {
446
+ for (const operation of operations) {
447
+ if (operation.kind !== 'write') {
448
+ continue;
449
+ }
419
450
 
420
- const tempPath = createTransactionalPath(operation.targetPath, 'tmp');
421
- await mkdir(dirname(operation.targetPath), { recursive: true });
422
- await writeFile(tempPath, operation.value, operation.writeOptions);
423
- operation.tempPath = tempPath;
424
- stagedWrites.push(tempPath);
425
- }
451
+ const tempPath = createTransactionalPath(operation.targetPath, 'tmp');
452
+ await mkdir(dirname(operation.targetPath), { recursive: true });
453
+ await writeFile(tempPath, operation.value, operation.writeOptions);
454
+ operation.tempPath = tempPath;
455
+ stagedWrites.push(tempPath);
456
+ }
426
457
 
427
- for (const operation of operations) {
428
- const hadExistingTarget = await pathExists(operation.targetPath);
429
- const backupPath = hadExistingTarget
430
- ? createTransactionalPath(operation.targetPath, 'bak')
431
- : null;
458
+ for (const operation of operations) {
459
+ const hadExistingTarget = await pathExists(operation.targetPath);
460
+ const backupPath = hadExistingTarget
461
+ ? createTransactionalPath(operation.targetPath, 'bak')
462
+ : null;
432
463
 
433
- if (backupPath) {
434
- await rename(operation.targetPath, backupPath);
435
- }
464
+ if (backupPath) {
465
+ await rename(operation.targetPath, backupPath);
466
+ }
436
467
 
437
- try {
438
- if (operation.kind === 'write') {
439
- await rename(operation.tempPath, operation.targetPath);
440
- const stagedIndex = stagedWrites.indexOf(operation.tempPath);
441
- if (stagedIndex >= 0) {
442
- stagedWrites.splice(stagedIndex, 1);
468
+ try {
469
+ if (operation.kind === 'write') {
470
+ await rename(operation.tempPath, operation.targetPath);
471
+ const stagedIndex = stagedWrites.indexOf(operation.tempPath);
472
+ if (stagedIndex >= 0) {
473
+ stagedWrites.splice(stagedIndex, 1);
474
+ }
443
475
  }
476
+ } catch (error) {
477
+ if (backupPath) {
478
+ await rename(backupPath, operation.targetPath);
479
+ }
480
+ throw error;
444
481
  }
445
- } catch (error) {
446
- if (backupPath) {
447
- await rename(backupPath, operation.targetPath);
448
- }
449
- throw error;
450
- }
451
482
 
452
- committedOperations.push({
453
- backupPath,
454
- kind: operation.kind,
455
- targetPath: operation.targetPath,
456
- });
457
- }
483
+ committedOperations.push({
484
+ backupPath,
485
+ kind: operation.kind,
486
+ targetPath: operation.targetPath,
487
+ });
488
+ }
489
+ });
458
490
 
459
491
  await cleanupPaths(committedOperations.map((operation) => operation.backupPath));
460
492
  return { ok: true };
@@ -510,9 +542,11 @@ export class VaultFileStore {
510
542
  }
511
543
  }
512
544
 
513
- await mkdir(dirname(absolute), { recursive: true });
514
- await writeFile(absolute, content, 'utf-8');
515
- await this.deleteCollaborationSnapshot(filePath);
545
+ await this.runManagedWrite([filePath], async () => {
546
+ await mkdir(dirname(absolute), { recursive: true });
547
+ await writeFile(absolute, content, 'utf-8');
548
+ await this.deleteCollaborationSnapshot(filePath);
549
+ });
516
550
  return { ok: true };
517
551
  }
518
552
 
@@ -523,8 +557,10 @@ export class VaultFileStore {
523
557
  }
524
558
 
525
559
  try {
526
- await rm(absolute, { force: true });
527
- await this.sidecarStore.deleteAllForFile(filePath);
560
+ await this.runManagedWrite([filePath], async () => {
561
+ await rm(absolute, { force: true });
562
+ await this.sidecarStore.deleteAllForFile(filePath);
563
+ });
528
564
  return { ok: true };
529
565
  } catch (error) {
530
566
  return { ok: false, error: error.message };
@@ -538,9 +574,11 @@ export class VaultFileStore {
538
574
  }
539
575
 
540
576
  try {
541
- await mkdir(dirname(absoluteNew), { recursive: true });
542
- await rename(absoluteOld, absoluteNew);
543
- await this.sidecarStore.renameAllForFile(oldPath, newPath);
577
+ await this.runManagedWrite([oldPath, newPath], async () => {
578
+ await mkdir(dirname(absoluteNew), { recursive: true });
579
+ await rename(absoluteOld, absoluteNew);
580
+ await this.sidecarStore.renameAllForFile(oldPath, newPath);
581
+ });
544
582
  return { ok: true };
545
583
  } catch (error) {
546
584
  return { ok: false, error: error.message };
@@ -554,7 +592,7 @@ export class VaultFileStore {
554
592
  }
555
593
 
556
594
  try {
557
- await mkdir(absolute, { recursive: true });
595
+ await this.runManagedWrite([dirPath], () => mkdir(absolute, { recursive: true }));
558
596
  return { ok: true };
559
597
  } catch (error) {
560
598
  return { ok: false, error: error.message };
@@ -633,4 +671,77 @@ export class VaultFileStore {
633
671
 
634
672
  return toVaultRelativePath(this.vaultDir, absolute);
635
673
  }
674
+
675
+ async scanWorkspaceState() {
676
+ const entries = new Map();
677
+ const metadata = new Map();
678
+
679
+ const visitDirectory = async (dirPath) => {
680
+ let dirEntries;
681
+ try {
682
+ dirEntries = await readdir(dirPath, { withFileTypes: true });
683
+ } catch {
684
+ return;
685
+ }
686
+
687
+ const sorted = dirEntries.sort((left, right) => {
688
+ if (left.isDirectory() && !right.isDirectory()) return -1;
689
+ if (!left.isDirectory() && right.isDirectory()) return 1;
690
+ return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
691
+ });
692
+
693
+ for (const entry of sorted) {
694
+ if (isIgnoredVaultEntry(entry.name)) {
695
+ continue;
696
+ }
697
+
698
+ const fullPath = join(dirPath, entry.name);
699
+ const relativePath = toVaultRelativePath(this.vaultDir, fullPath).replace(/\\/g, '/');
700
+
701
+ if (entry.isDirectory()) {
702
+ entries.set(relativePath, createWorkspaceEntry(relativePath, 'directory'));
703
+ try {
704
+ const info = await stat(fullPath);
705
+ metadata.set(relativePath, {
706
+ inode: Number(info.ino || 0),
707
+ mtimeMs: Number(info.mtimeMs || 0),
708
+ path: relativePath,
709
+ size: 0,
710
+ type: 'directory',
711
+ });
712
+ } catch {
713
+ // Ignore transient directories that disappear during scans.
714
+ }
715
+ await visitDirectory(fullPath);
716
+ continue;
717
+ }
718
+
719
+ if (!isVaultFilePath(entry.name)) {
720
+ continue;
721
+ }
722
+
723
+ entries.set(relativePath, createWorkspaceEntry(relativePath, 'file'));
724
+ try {
725
+ const info = await stat(fullPath);
726
+ metadata.set(relativePath, {
727
+ inode: Number(info.ino || 0),
728
+ mtimeMs: Number(info.mtimeMs || 0),
729
+ path: relativePath,
730
+ size: Number(info.size || 0),
731
+ type: 'file',
732
+ });
733
+ } catch {
734
+ // Ignore transient files that disappear during scans.
735
+ }
736
+ }
737
+ };
738
+
739
+ await visitDirectory(this.vaultDir);
740
+
741
+ return {
742
+ entries,
743
+ metadata,
744
+ scannedAt: Date.now(),
745
+ };
746
+ }
636
747
  }