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
@@ -0,0 +1,488 @@
1
+ import { watch } from 'fs';
2
+ import { readdir, stat } from 'fs/promises';
3
+ import { basename, dirname, join } from 'path';
4
+
5
+ import { createWorkspaceChange } from '../../../domain/workspace-change.js';
6
+ import { getVaultTreeNodeType, isVaultFilePath } from '../../../domain/file-kind.js';
7
+ import {
8
+ isIgnoredVaultEntry,
9
+ sanitizeVaultPath,
10
+ } from '../persistence/path-utils.js';
11
+
12
+ const MAX_INCREMENTAL_PENDING_PATHS = 16;
13
+
14
+ function entrySignature(entry = {}) {
15
+ return `${entry.type}:${entry.inode}:${entry.size}:${entry.mtimeMs}`;
16
+ }
17
+
18
+ function sortByPath(values = []) {
19
+ return [...values].sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
20
+ }
21
+
22
+ function normalizeWatchedPath(filename) {
23
+ if (typeof filename !== 'string' && !Buffer.isBuffer(filename)) {
24
+ return '';
25
+ }
26
+
27
+ const normalized = String(filename).trim().replace(/\\/g, '/').replace(/^\.\/+/, '');
28
+ if (!normalized || normalized === '.') {
29
+ return '';
30
+ }
31
+
32
+ return normalized
33
+ .split('/')
34
+ .filter(Boolean)
35
+ .join('/');
36
+ }
37
+
38
+ function isIgnoredWatchedPath(pathValue) {
39
+ if (!pathValue) {
40
+ return true;
41
+ }
42
+
43
+ const segments = String(pathValue).split('/').filter(Boolean);
44
+ if (segments.length === 0) {
45
+ return true;
46
+ }
47
+
48
+ if (segments.some((segment) => isIgnoredVaultEntry(segment))) {
49
+ return true;
50
+ }
51
+
52
+ return basename(pathValue).includes('.collabmd-');
53
+ }
54
+
55
+ function normalizeWorkspacePath(pathValue = '') {
56
+ return String(pathValue ?? '').replace(/\\/g, '/').trim();
57
+ }
58
+
59
+ function getParentDirectoryPath(pathValue = '') {
60
+ const parentPath = dirname(normalizeWorkspacePath(pathValue)).replace(/\\/g, '/');
61
+ return parentPath === '.' ? '' : parentPath;
62
+ }
63
+
64
+ function createWorkspaceEntry(pathValue, nodeType) {
65
+ const normalizedPath = normalizeWorkspacePath(pathValue);
66
+ return {
67
+ fileKind: nodeType === 'directory' ? null : getVaultTreeNodeType(normalizedPath),
68
+ name: basename(normalizedPath),
69
+ nodeType,
70
+ parentPath: getParentDirectoryPath(normalizedPath),
71
+ path: normalizedPath,
72
+ type: nodeType === 'directory' ? 'directory' : getVaultTreeNodeType(normalizedPath),
73
+ };
74
+ }
75
+
76
+ function createWorkspaceMetadata(pathValue, type, info) {
77
+ return {
78
+ inode: Number(info.ino || 0),
79
+ mtimeMs: Number(info.mtimeMs || 0),
80
+ path: pathValue,
81
+ size: type === 'directory' ? 0 : Number(info.size || 0),
82
+ type,
83
+ };
84
+ }
85
+
86
+ function isPathWithinPrefix(pathValue, prefix) {
87
+ return pathValue === prefix || pathValue.startsWith(`${prefix}/`);
88
+ }
89
+
90
+ function buildPrefixRenameEntries(previousState, nextState, oldPrefix, newPrefix) {
91
+ const renames = [];
92
+ previousState.metadata.forEach((entry, pathValue) => {
93
+ if (entry.type !== 'file' || !pathValue.startsWith(`${oldPrefix}/`)) {
94
+ return;
95
+ }
96
+
97
+ const suffix = pathValue.slice(oldPrefix.length + 1);
98
+ const nextPath = `${newPrefix}/${suffix}`;
99
+ if (nextState.metadata.get(nextPath)?.type === 'file') {
100
+ renames.push({ oldPath: pathValue, newPath: nextPath });
101
+ }
102
+ });
103
+ return renames;
104
+ }
105
+
106
+ function detectWorkspaceChange(previousState, nextState) {
107
+ const previousMetadata = previousState?.metadata ?? new Map();
108
+ const nextMetadata = nextState?.metadata ?? new Map();
109
+ const changedPaths = new Set();
110
+ const deletedPaths = new Set();
111
+ const addedPaths = new Set();
112
+ const renamedPaths = [];
113
+
114
+ previousMetadata.forEach((previousEntry, pathValue) => {
115
+ const nextEntry = nextMetadata.get(pathValue);
116
+ if (!nextEntry) {
117
+ if (previousEntry.type === 'file') {
118
+ deletedPaths.add(pathValue);
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (entrySignature(previousEntry) !== entrySignature(nextEntry) && nextEntry.type === 'file') {
124
+ changedPaths.add(pathValue);
125
+ }
126
+ });
127
+
128
+ nextMetadata.forEach((nextEntry, pathValue) => {
129
+ if (!previousMetadata.has(pathValue) && nextEntry.type === 'file') {
130
+ addedPaths.add(pathValue);
131
+ }
132
+ });
133
+
134
+ const deletedBySignature = new Map();
135
+ deletedPaths.forEach((pathValue) => {
136
+ const metadata = previousMetadata.get(pathValue);
137
+ const signature = entrySignature(metadata);
138
+ const bucket = deletedBySignature.get(signature) ?? [];
139
+ bucket.push(pathValue);
140
+ deletedBySignature.set(signature, bucket);
141
+ });
142
+
143
+ const addedBySignature = new Map();
144
+ addedPaths.forEach((pathValue) => {
145
+ const metadata = nextMetadata.get(pathValue);
146
+ const signature = entrySignature(metadata);
147
+ const bucket = addedBySignature.get(signature) ?? [];
148
+ bucket.push(pathValue);
149
+ addedBySignature.set(signature, bucket);
150
+ });
151
+
152
+ Array.from(deletedBySignature.keys()).forEach((signature) => {
153
+ const removed = deletedBySignature.get(signature) ?? [];
154
+ const added = addedBySignature.get(signature) ?? [];
155
+ if (removed.length === 1 && added.length === 1) {
156
+ renamedPaths.push({ oldPath: removed[0], newPath: added[0] });
157
+ deletedPaths.delete(removed[0]);
158
+ addedPaths.delete(added[0]);
159
+ }
160
+ });
161
+
162
+ const previousDirectories = Array.from(previousMetadata.entries())
163
+ .filter(([, entry]) => entry.type === 'directory' && !nextMetadata.has(entry.path));
164
+ const nextDirectories = Array.from(nextMetadata.entries())
165
+ .filter(([, entry]) => entry.type === 'directory' && !previousMetadata.has(entry.path));
166
+
167
+ previousDirectories.forEach(([oldPath, oldEntry]) => {
168
+ const match = nextDirectories.find(([, nextEntry]) => entrySignature(oldEntry) === entrySignature(nextEntry));
169
+ if (!match) {
170
+ return;
171
+ }
172
+
173
+ const [newPath] = match;
174
+ buildPrefixRenameEntries(previousState, nextState, oldPath, newPath).forEach((entry) => {
175
+ renamedPaths.push(entry);
176
+ deletedPaths.delete(entry.oldPath);
177
+ addedPaths.delete(entry.newPath);
178
+ });
179
+ });
180
+
181
+ addedPaths.forEach((pathValue) => {
182
+ changedPaths.add(pathValue);
183
+ });
184
+
185
+ return createWorkspaceChange({
186
+ changedPaths: sortByPath(Array.from(changedPaths)),
187
+ deletedPaths: sortByPath(Array.from(deletedPaths)),
188
+ renamedPaths,
189
+ refreshExplorer: true,
190
+ });
191
+ }
192
+
193
+ export class FileSystemSyncService {
194
+ constructor({
195
+ debounceMs = 180,
196
+ mutationCoordinator,
197
+ vaultFileStore,
198
+ }) {
199
+ this.debounceMs = debounceMs;
200
+ this.mutationCoordinator = mutationCoordinator;
201
+ this.vaultFileStore = vaultFileStore;
202
+ this.watcher = null;
203
+ this.debounceTimer = null;
204
+ this.runningFlush = null;
205
+ this.lastState = null;
206
+ this.pendingEventTypesByPath = new Map();
207
+ this.forceFullScan = false;
208
+ this.suspendWatchEventsUntil = 0;
209
+ }
210
+
211
+ async start() {
212
+ this.lastState = this.mutationCoordinator.workspaceState ?? await this.vaultFileStore.scanWorkspaceState();
213
+ this.watcher = watch(this.vaultFileStore.vaultDir, { recursive: true }, (eventType, filename) => {
214
+ this.handleWatchEvent(eventType, filename);
215
+ });
216
+ }
217
+
218
+ handleWatchEvent(eventType, filename) {
219
+ if (Date.now() <= this.suspendWatchEventsUntil) {
220
+ return;
221
+ }
222
+
223
+ const normalizedPath = normalizeWatchedPath(filename);
224
+ if (!normalizedPath) {
225
+ this.forceFullScan = true;
226
+ this.scheduleFlush();
227
+ return;
228
+ }
229
+
230
+ if (isIgnoredWatchedPath(normalizedPath)) {
231
+ return;
232
+ }
233
+
234
+ const bucket = this.pendingEventTypesByPath.get(normalizedPath) ?? new Set();
235
+ bucket.add(String(eventType || 'change'));
236
+ this.pendingEventTypesByPath.set(normalizedPath, bucket);
237
+
238
+ if (this.pendingEventTypesByPath.size > MAX_INCREMENTAL_PENDING_PATHS) {
239
+ this.forceFullScan = true;
240
+ }
241
+
242
+ this.scheduleFlush();
243
+ }
244
+
245
+ scheduleFlush() {
246
+ clearTimeout(this.debounceTimer);
247
+ this.debounceTimer = setTimeout(() => {
248
+ this.debounceTimer = null;
249
+ this.runningFlush = this.flush().finally(() => {
250
+ this.runningFlush = null;
251
+ });
252
+ }, this.debounceMs);
253
+ this.debounceTimer.unref?.();
254
+ }
255
+
256
+ async resetForExternalStateChange() {
257
+ this.suspendWatchEventsUntil = Math.max(this.suspendWatchEventsUntil, Date.now() + 750);
258
+ clearTimeout(this.debounceTimer);
259
+ this.debounceTimer = null;
260
+ this.pendingEventTypesByPath.clear();
261
+ this.forceFullScan = false;
262
+
263
+ try {
264
+ await this.runningFlush;
265
+ } catch {
266
+ // Ignore stale flush failures while rebaselining around an external reset.
267
+ }
268
+
269
+ this.pendingEventTypesByPath.clear();
270
+ this.forceFullScan = false;
271
+ this.lastState = this.mutationCoordinator.workspaceState ?? await this.vaultFileStore.scanWorkspaceState();
272
+ }
273
+
274
+ async readWorkspacePathSnapshot(pathValue) {
275
+ const normalizedPath = normalizeWorkspacePath(pathValue);
276
+ if (!normalizedPath) {
277
+ return null;
278
+ }
279
+
280
+ const absolutePath = sanitizeVaultPath(this.vaultFileStore.vaultDir, normalizedPath);
281
+ if (!absolutePath) {
282
+ return null;
283
+ }
284
+
285
+ try {
286
+ const info = await stat(absolutePath);
287
+ const entries = new Map();
288
+ const metadata = new Map();
289
+
290
+ if (info.isDirectory()) {
291
+ const visitDirectory = async (directoryPath, relativePath) => {
292
+ entries.set(relativePath, createWorkspaceEntry(relativePath, 'directory'));
293
+ metadata.set(relativePath, createWorkspaceMetadata(relativePath, 'directory', await stat(directoryPath)));
294
+
295
+ let dirEntries;
296
+ try {
297
+ dirEntries = await readdir(directoryPath, { withFileTypes: true });
298
+ } catch {
299
+ return;
300
+ }
301
+
302
+ for (const entry of dirEntries) {
303
+ if (isIgnoredVaultEntry(entry.name)) {
304
+ continue;
305
+ }
306
+
307
+ const childAbsolutePath = join(directoryPath, entry.name);
308
+ const childRelativePath = `${relativePath}/${entry.name}`.replace(/\\/g, '/');
309
+
310
+ if (entry.isDirectory()) {
311
+ await visitDirectory(childAbsolutePath, childRelativePath);
312
+ continue;
313
+ }
314
+
315
+ if (!isVaultFilePath(childRelativePath)) {
316
+ continue;
317
+ }
318
+
319
+ try {
320
+ const childInfo = await stat(childAbsolutePath);
321
+ if (!childInfo.isFile()) {
322
+ continue;
323
+ }
324
+
325
+ entries.set(childRelativePath, createWorkspaceEntry(childRelativePath, 'file'));
326
+ metadata.set(childRelativePath, createWorkspaceMetadata(childRelativePath, 'file', childInfo));
327
+ } catch (error) {
328
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') {
329
+ throw error;
330
+ }
331
+ }
332
+ }
333
+ };
334
+
335
+ await visitDirectory(absolutePath, normalizedPath);
336
+ return { entries, metadata };
337
+ }
338
+
339
+ if (!info.isFile() || !isVaultFilePath(normalizedPath)) {
340
+ return { entries, metadata };
341
+ }
342
+
343
+ entries.set(normalizedPath, createWorkspaceEntry(normalizedPath, 'file'));
344
+ metadata.set(normalizedPath, createWorkspaceMetadata(normalizedPath, 'file', info));
345
+ return { entries, metadata };
346
+ } catch (error) {
347
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
348
+ return {
349
+ entries: new Map(),
350
+ metadata: new Map(),
351
+ };
352
+ }
353
+
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ async ensureAncestorDirectories(nextEntries, nextMetadata, pathValue) {
359
+ const parentPath = getParentDirectoryPath(pathValue);
360
+ if (!parentPath) {
361
+ return true;
362
+ }
363
+
364
+ const segments = parentPath.split('/').filter(Boolean);
365
+ let currentPath = '';
366
+ for (const segment of segments) {
367
+ currentPath = currentPath ? `${currentPath}/${segment}` : segment;
368
+ if (nextEntries.has(currentPath) && nextMetadata.has(currentPath)) {
369
+ continue;
370
+ }
371
+
372
+ const directorySnapshot = await this.readWorkspacePathSnapshot(currentPath);
373
+ if (!directorySnapshot?.entries?.has(currentPath) || !directorySnapshot.metadata?.has(currentPath)) {
374
+ return false;
375
+ }
376
+
377
+ nextEntries.set(currentPath, directorySnapshot.entries.get(currentPath));
378
+ nextMetadata.set(currentPath, directorySnapshot.metadata.get(currentPath));
379
+ }
380
+
381
+ return true;
382
+ }
383
+
384
+ async buildIncrementalResult() {
385
+ if (this.forceFullScan || this.pendingEventTypesByPath.size === 0 || !this.lastState) {
386
+ return null;
387
+ }
388
+
389
+ const pendingPaths = Array.from(this.pendingEventTypesByPath.keys())
390
+ .filter(Boolean)
391
+ .sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
392
+ if (pendingPaths.length === 0 || pendingPaths.length > MAX_INCREMENTAL_PENDING_PATHS) {
393
+ return null;
394
+ }
395
+
396
+ const nextEntries = new Map(this.lastState.entries);
397
+ const nextMetadata = new Map(this.lastState.metadata);
398
+ const previousState = this.lastState;
399
+
400
+ for (const pathValue of pendingPaths) {
401
+ Array.from(previousState.entries.keys())
402
+ .filter((candidatePath) => isPathWithinPrefix(candidatePath, pathValue))
403
+ .forEach((candidatePath) => {
404
+ nextEntries.delete(candidatePath);
405
+ });
406
+ Array.from(previousState.metadata.keys())
407
+ .filter((candidatePath) => isPathWithinPrefix(candidatePath, pathValue))
408
+ .forEach((candidatePath) => {
409
+ nextMetadata.delete(candidatePath);
410
+ });
411
+
412
+ const snapshot = await this.readWorkspacePathSnapshot(pathValue);
413
+ if (!snapshot) {
414
+ return null;
415
+ }
416
+
417
+ if (snapshot.entries.size > 0) {
418
+ if (!(await this.ensureAncestorDirectories(nextEntries, nextMetadata, pathValue))) {
419
+ return null;
420
+ }
421
+ }
422
+
423
+ snapshot.entries.forEach((entry, entryPath) => {
424
+ nextEntries.set(entryPath, entry);
425
+ });
426
+ snapshot.metadata.forEach((metadata, metadataPath) => {
427
+ nextMetadata.set(metadataPath, metadata);
428
+ });
429
+ }
430
+
431
+ const nextState = {
432
+ entries: nextEntries,
433
+ metadata: nextMetadata,
434
+ scannedAt: Date.now(),
435
+ };
436
+ const workspaceChange = detectWorkspaceChange(this.lastState, nextState);
437
+
438
+ return {
439
+ nextState,
440
+ workspaceChange,
441
+ };
442
+ }
443
+
444
+ consumePendingEvents() {
445
+ const hadPendingEvents = this.pendingEventTypesByPath.size > 0 || this.forceFullScan;
446
+ this.pendingEventTypesByPath.clear();
447
+ this.forceFullScan = false;
448
+ return hadPendingEvents;
449
+ }
450
+
451
+ async flush() {
452
+ const incrementalResult = await this.buildIncrementalResult();
453
+ this.consumePendingEvents();
454
+
455
+ const previousWorkspaceState = this.mutationCoordinator.workspaceState;
456
+ const nextState = incrementalResult?.nextState ?? await this.vaultFileStore.scanWorkspaceState();
457
+ const workspaceChange = incrementalResult?.workspaceChange ?? detectWorkspaceChange(this.lastState, nextState);
458
+ this.lastState = nextState;
459
+
460
+ const filteredChange = this.mutationCoordinator.filterManagedWorkspaceChange(workspaceChange);
461
+ if (!filteredChange) {
462
+ this.mutationCoordinator.syncWorkspaceEntries(nextState, {
463
+ previousState: previousWorkspaceState,
464
+ });
465
+ this.mutationCoordinator.replaceWorkspaceState(nextState);
466
+ return;
467
+ }
468
+
469
+ await this.mutationCoordinator.apply({
470
+ action: 'filesystem-sync',
471
+ origin: 'filesystem',
472
+ nextState,
473
+ workspaceChange: filteredChange,
474
+ });
475
+ }
476
+
477
+ async close() {
478
+ clearTimeout(this.debounceTimer);
479
+ this.debounceTimer = null;
480
+ this.pendingEventTypesByPath.clear();
481
+ this.forceFullScan = false;
482
+ if (this.watcher) {
483
+ this.watcher.close();
484
+ this.watcher = null;
485
+ }
486
+ await this.runningFlush;
487
+ }
488
+ }