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
@@ -12,6 +12,29 @@ function getPathLeaf(path) {
12
12
  .pop() || '';
13
13
  }
14
14
 
15
+ function getParentPath(pathValue) {
16
+ const normalized = String(pathValue ?? '').replace(/\/+$/u, '');
17
+ const separatorIndex = normalized.lastIndexOf('/');
18
+ return separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : '';
19
+ }
20
+
21
+ function findNodeByPath(nodes = [], pathValue = '') {
22
+ for (const node of nodes) {
23
+ if (node.path === pathValue) {
24
+ return node;
25
+ }
26
+
27
+ if (node.type === 'directory' && Array.isArray(node.children)) {
28
+ const nested = findNodeByPath(node.children, pathValue);
29
+ if (nested) {
30
+ return nested;
31
+ }
32
+ }
33
+ }
34
+
35
+ return null;
36
+ }
37
+
15
38
  export class FileExplorerView {
16
39
  constructor({
17
40
  onDirectoryToggle,
@@ -27,6 +50,9 @@ export class FileExplorerView {
27
50
  this.onTreeContextMenu = onTreeContextMenu;
28
51
  this.treeContainer = document.getElementById('fileTree');
29
52
  this.searchInput = document.getElementById('fileSearchInput');
53
+ this.renderedDirectoryWrappers = new Map();
54
+ this.renderedChildContainers = new Map();
55
+ this.lastRenderMode = 'tree';
30
56
  }
31
57
 
32
58
  initialize() {
@@ -44,30 +70,62 @@ export class FileExplorerView {
44
70
  });
45
71
  }
46
72
 
47
- render({ activeFilePath, expandedDirs, searchMatches, searchQuery, tree }) {
73
+ render({ activeFilePath, changedPaths = null, expandedDirs, reset = false, searchMatches, searchQuery, tree }) {
48
74
  if (!this.treeContainer) {
49
75
  return;
50
76
  }
51
77
 
52
78
  if (searchQuery) {
79
+ this.lastRenderMode = 'search';
53
80
  this.renderSearchResults(searchMatches, activeFilePath);
54
81
  return;
55
82
  }
56
83
 
57
- this.treeContainer.innerHTML = '';
84
+ if (
85
+ reset
86
+ || this.lastRenderMode !== 'tree'
87
+ || !Array.isArray(changedPaths)
88
+ || changedPaths.length === 0
89
+ ) {
90
+ this.renderFullTree(tree, {
91
+ activeFilePath,
92
+ expandedDirs,
93
+ });
94
+ this.lastRenderMode = 'tree';
95
+ return;
96
+ }
58
97
 
59
- if (tree.length === 0) {
60
- this.treeContainer.innerHTML = '<div class="file-tree-empty">No vault files found</div>';
98
+ const affectedParentPaths = Array.from(new Set(
99
+ changedPaths.map((pathValue) => getParentPath(pathValue)),
100
+ ))
101
+ .sort((left, right) => left.split('/').length - right.split('/').length)
102
+ .filter((pathValue, index, values) => (
103
+ !values.slice(0, index).some((ancestorPath) => ancestorPath && pathValue.startsWith(`${ancestorPath}/`))
104
+ ));
105
+ if (affectedParentPaths.includes('')) {
106
+ this.renderFullTree(tree, {
107
+ activeFilePath,
108
+ expandedDirs,
109
+ });
110
+ this.lastRenderMode = 'tree';
61
111
  return;
62
112
  }
63
113
 
64
- const fragment = document.createDocumentFragment();
65
- this.renderNodes(tree, fragment, {
66
- activeFilePath,
67
- depth: 0,
68
- expandedDirs,
69
- });
70
- this.treeContainer.appendChild(fragment);
114
+ for (const parentPath of affectedParentPaths) {
115
+ if (!this.rerenderDirectoryBranch(parentPath, tree, {
116
+ activeFilePath,
117
+ expandedDirs,
118
+ })) {
119
+ this.renderFullTree(tree, {
120
+ activeFilePath,
121
+ expandedDirs,
122
+ });
123
+ this.lastRenderMode = 'tree';
124
+ return;
125
+ }
126
+ }
127
+
128
+ this.lastRenderMode = 'tree';
71
129
  }
72
130
 
73
131
  renderSearchResults(matches, activeFilePath) {
@@ -75,6 +133,7 @@ export class FileExplorerView {
75
133
  return;
76
134
  }
77
135
 
136
+ this.resetTreeIndexes();
78
137
  this.treeContainer.innerHTML = '';
79
138
 
80
139
  if (matches.length === 0) {
@@ -95,6 +154,29 @@ export class FileExplorerView {
95
154
  this.treeContainer.appendChild(fragment);
96
155
  }
97
156
 
157
+ renderFullTree(tree, { activeFilePath, expandedDirs }) {
158
+ this.resetTreeIndexes();
159
+ this.treeContainer.innerHTML = '';
160
+
161
+ if (tree.length === 0) {
162
+ this.treeContainer.innerHTML = '<div class="file-tree-empty">No vault files found</div>';
163
+ return;
164
+ }
165
+
166
+ const fragment = document.createDocumentFragment();
167
+ this.renderNodes(tree, fragment, {
168
+ activeFilePath,
169
+ depth: 0,
170
+ expandedDirs,
171
+ });
172
+ this.treeContainer.appendChild(fragment);
173
+ }
174
+
175
+ resetTreeIndexes() {
176
+ this.renderedDirectoryWrappers.clear();
177
+ this.renderedChildContainers.clear();
178
+ }
179
+
98
180
  renderNodes(nodes, container, { activeFilePath, depth, expandedDirs }) {
99
181
  for (const node of nodes) {
100
182
  if (node.type === 'directory') {
@@ -142,21 +224,80 @@ export class FileExplorerView {
142
224
  });
143
225
 
144
226
  wrapper.appendChild(button);
227
+ this.renderedDirectoryWrappers.set(node.path, wrapper);
145
228
 
146
229
  if (isExpanded && Array.isArray(node.children)) {
147
230
  const childContainer = document.createElement('div');
148
231
  childContainer.className = 'file-tree-children';
232
+ this.renderedChildContainers.set(node.path, childContainer);
149
233
  this.renderNodes(node.children, childContainer, {
150
234
  activeFilePath,
151
235
  depth: depth + 1,
152
236
  expandedDirs,
153
237
  });
154
238
  wrapper.appendChild(childContainer);
239
+ } else {
240
+ this.renderedChildContainers.delete(node.path);
155
241
  }
156
242
 
157
243
  return wrapper;
158
244
  }
159
245
 
246
+ rerenderDirectoryBranch(parentPath, tree, { activeFilePath, expandedDirs }) {
247
+ const wrapper = this.renderedDirectoryWrappers.get(parentPath);
248
+ const parentNode = findNodeByPath(tree, parentPath);
249
+ if (!wrapper || parentNode?.type !== 'directory') {
250
+ return false;
251
+ }
252
+
253
+ const button = wrapper.querySelector('.file-tree-dir');
254
+ const isExpanded = expandedDirs.has(parentPath);
255
+ button?.setAttribute('aria-expanded', String(isExpanded));
256
+ button?.querySelector('.file-tree-chevron')?.classList.toggle('expanded', isExpanded);
257
+
258
+ const depth = Number(button?.dataset.depth ?? 0);
259
+ let childContainer = this.renderedChildContainers.get(parentPath) ?? wrapper.querySelector('.file-tree-children');
260
+
261
+ this.clearRenderedDescendants(parentPath);
262
+
263
+ if (!isExpanded) {
264
+ childContainer?.remove();
265
+ this.renderedChildContainers.delete(parentPath);
266
+ return true;
267
+ }
268
+
269
+ if (!childContainer) {
270
+ childContainer = document.createElement('div');
271
+ childContainer.className = 'file-tree-children';
272
+ wrapper.appendChild(childContainer);
273
+ } else {
274
+ childContainer.innerHTML = '';
275
+ }
276
+ this.renderedChildContainers.set(parentPath, childContainer);
277
+
278
+ this.renderNodes(parentNode.children ?? [], childContainer, {
279
+ activeFilePath,
280
+ depth: depth + 1,
281
+ expandedDirs,
282
+ });
283
+
284
+ return true;
285
+ }
286
+
287
+ clearRenderedDescendants(parentPath) {
288
+ const prefix = `${parentPath}/`;
289
+ Array.from(this.renderedDirectoryWrappers.keys()).forEach((pathValue) => {
290
+ if (pathValue.startsWith(prefix)) {
291
+ this.renderedDirectoryWrappers.delete(pathValue);
292
+ }
293
+ });
294
+ Array.from(this.renderedChildContainers.keys()).forEach((pathValue) => {
295
+ if (pathValue.startsWith(prefix)) {
296
+ this.renderedChildContainers.delete(pathValue);
297
+ }
298
+ });
299
+ }
300
+
160
301
  createFileItem({ activeFilePath, depth, filePath, fileType = 'file', name }) {
161
302
  const button = document.createElement('button');
162
303
  button.className = 'file-tree-item file-tree-file';
@@ -2269,6 +2269,8 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2269
2269
  --preview-floating-comments-width: min(300px, 34vw);
2270
2270
  --preview-floating-outline-width: 220px;
2271
2271
  --preview-content-max-width: 720px;
2272
+ --preview-comment-rail-width: 0px;
2273
+ --preview-comment-rail-inset: 10px;
2272
2274
  display: flex;
2273
2275
  flex: 1;
2274
2276
  overflow: hidden;
@@ -2287,7 +2289,7 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2287
2289
  }
2288
2290
 
2289
2291
  .preview-content {
2290
- padding: var(--space-6) var(--space-6) var(--space-12);
2292
+ padding: var(--space-6) calc(var(--space-6) + var(--preview-comment-rail-width)) var(--space-12) var(--space-6);
2291
2293
  max-width: 720px;
2292
2294
  margin: 0 auto;
2293
2295
  min-width: 0;
@@ -2303,6 +2305,10 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2303
2305
  }
2304
2306
 
2305
2307
  @media (min-width: 769px) {
2308
+ .preview-body {
2309
+ --preview-comment-rail-width: 52px;
2310
+ }
2311
+
2306
2312
  .preview-content {
2307
2313
  padding-bottom: calc(var(--space-12) + 96px);
2308
2314
  }
@@ -3190,7 +3196,7 @@ body.plantuml-maximized-open::before {
3190
3196
  font-size: 10px;
3191
3197
  font-weight: 700;
3192
3198
  pointer-events: auto;
3193
- transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease;
3199
+ transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease;
3194
3200
  }
3195
3201
 
3196
3202
  .comment-editor-badge.is-active,
@@ -3200,6 +3206,26 @@ body.plantuml-maximized-open::before {
3200
3206
  color: var(--color-text-inverse);
3201
3207
  }
3202
3208
 
3209
+ .comment-preview-badge {
3210
+ right: var(--preview-comment-rail-inset);
3211
+ }
3212
+
3213
+ .comment-editor-badge.is-passive,
3214
+ .comment-preview-badge.is-passive {
3215
+ opacity: 0.48;
3216
+ background: color-mix(in srgb, var(--color-surface) 76%, var(--color-comment) 24%);
3217
+ border-color: oklch(from var(--color-comment) l c h / 0.16);
3218
+ box-shadow: none;
3219
+ }
3220
+
3221
+ .comment-editor-badge.is-hovered,
3222
+ .comment-preview-badge.is-hovered {
3223
+ opacity: 1;
3224
+ background: color-mix(in srgb, var(--color-surface) 56%, var(--color-comment) 44%);
3225
+ border-color: oklch(from var(--color-comment) l c h / 0.28);
3226
+ color: var(--color-text-inverse);
3227
+ }
3228
+
3203
3229
  .comment-marker-icon {
3204
3230
  display: inline-flex;
3205
3231
  width: 12px;
@@ -3246,6 +3272,24 @@ body.plantuml-maximized-open::before {
3246
3272
  border-radius: 6px;
3247
3273
  background: var(--color-comment-soft);
3248
3274
  border: 1px solid oklch(from var(--color-comment) l c h / 0.16);
3275
+ opacity: 0.56;
3276
+ transition: opacity 140ms ease, background-color 140ms ease, border-color 140ms ease;
3277
+ }
3278
+
3279
+ .comment-preview-highlight.is-passive {
3280
+ opacity: 0.32;
3281
+ }
3282
+
3283
+ .comment-preview-highlight.is-hovered {
3284
+ opacity: 0.72;
3285
+ background: color-mix(in srgb, var(--color-comment-soft) 74%, var(--color-comment) 26%);
3286
+ border-color: oklch(from var(--color-comment) l c h / 0.24);
3287
+ }
3288
+
3289
+ .comment-preview-highlight.is-active {
3290
+ opacity: 0.9;
3291
+ background: color-mix(in srgb, var(--color-comment-soft) 62%, var(--color-comment) 38%);
3292
+ border-color: oklch(from var(--color-comment) l c h / 0.3);
3249
3293
  }
3250
3294
 
3251
3295
  .comment-card-root {
@@ -66,10 +66,21 @@ export function resolveWikiTargetPath(target, files) {
66
66
  }
67
67
 
68
68
  const rawTarget = String(target ?? '').trim();
69
+ let fallbackSuffixMatch = null;
69
70
 
70
- return files.find((filePath) => (
71
- filePath === normalizedTarget
72
- || filePath.endsWith(`/${normalizedTarget}`)
73
- || filePath.replace(/\.md$/i, '') === rawTarget
74
- )) ?? null;
71
+ for (const filePath of files) {
72
+ if (filePath === normalizedTarget) {
73
+ return filePath;
74
+ }
75
+
76
+ if (filePath.replace(/\.md$/i, '') === rawTarget) {
77
+ return filePath;
78
+ }
79
+
80
+ if (!fallbackSuffixMatch && filePath.endsWith(`/${normalizedTarget}`)) {
81
+ fallbackSuffixMatch = filePath;
82
+ }
83
+ }
84
+
85
+ return fallbackSuffixMatch;
75
86
  }
@@ -0,0 +1,68 @@
1
+ function uniquePaths(values = []) {
2
+ return Array.from(new Set((values ?? []).filter(Boolean)));
3
+ }
4
+
5
+ function uniqueRenames(values = []) {
6
+ return Array.from(new Map(
7
+ (values ?? [])
8
+ .filter((entry) => entry?.oldPath && entry?.newPath && entry.oldPath !== entry.newPath)
9
+ .map((entry) => [`${entry.oldPath}:${entry.newPath}`, {
10
+ newPath: entry.newPath,
11
+ oldPath: entry.oldPath,
12
+ }]),
13
+ ).values());
14
+ }
15
+
16
+ function normalizeHighlightRanges(values = []) {
17
+ return (values ?? [])
18
+ .filter((entry) => entry?.path && Number.isFinite(entry?.from) && Number.isFinite(entry?.to))
19
+ .map((entry) => ({
20
+ from: Math.max(0, Math.round(entry.from)),
21
+ path: entry.path,
22
+ to: Math.max(0, Math.round(entry.to)),
23
+ }));
24
+ }
25
+
26
+ export function createWorkspaceChange({
27
+ changedPaths = [],
28
+ deletedPaths = [],
29
+ refreshExplorer = true,
30
+ renamedPaths = [],
31
+ } = {}) {
32
+ return {
33
+ changedPaths: uniquePaths(changedPaths),
34
+ deletedPaths: uniquePaths(deletedPaths),
35
+ refreshExplorer: refreshExplorer !== false,
36
+ renamedPaths: uniqueRenames(renamedPaths),
37
+ };
38
+ }
39
+
40
+ export function createEmptyWorkspaceChange() {
41
+ return createWorkspaceChange();
42
+ }
43
+
44
+ export function hasWorkspaceMutation(workspaceChange = {}) {
45
+ return Boolean(
46
+ (workspaceChange.changedPaths?.length ?? 0) > 0
47
+ || (workspaceChange.deletedPaths?.length ?? 0) > 0
48
+ || (workspaceChange.renamedPaths?.length ?? 0) > 0,
49
+ );
50
+ }
51
+
52
+ export function normalizeWorkspaceEvent(event) {
53
+ if (!event || typeof event !== 'object' || typeof event.id !== 'string') {
54
+ return null;
55
+ }
56
+
57
+ return {
58
+ action: typeof event.action === 'string' ? event.action : 'workspace',
59
+ createdAt: Number.isFinite(event.createdAt) ? event.createdAt : Date.now(),
60
+ highlightRanges: normalizeHighlightRanges(event.highlightRanges),
61
+ id: event.id,
62
+ origin: typeof event.origin === 'string' ? event.origin : 'api',
63
+ reloadRequiredPaths: uniquePaths(event.reloadRequiredPaths),
64
+ requestId: typeof event.requestId === 'string' ? event.requestId : null,
65
+ sourceRef: typeof event.sourceRef === 'string' ? event.sourceRef : null,
66
+ workspaceChange: createWorkspaceChange(event.workspaceChange),
67
+ };
68
+ }
@@ -0,0 +1,3 @@
1
+ export const WORKSPACE_ROOM_NAME = '__workspace__';
2
+ export const WORKSPACE_EVENT_MAX_MESSAGES = 40;
3
+
@@ -11,6 +11,9 @@ import { RoomRegistry } from './domain/collaboration/room-registry.js';
11
11
  import { createRequestHandler } from './infrastructure/http/create-request-handler.js';
12
12
  import { VaultFileStore } from './infrastructure/persistence/vault-file-store.js';
13
13
  import { attachCollaborationGateway } from './infrastructure/websocket/attach-collaboration-gateway.js';
14
+ import { WORKSPACE_ROOM_NAME } from '../domain/workspace-room.js';
15
+ import { FileSystemSyncService } from './infrastructure/workspace/file-system-sync-service.js';
16
+ import { WorkspaceMutationCoordinator } from './infrastructure/workspace/workspace-mutation-coordinator.js';
14
17
 
15
18
  function getDisplayHost(host) {
16
19
  return host === '127.0.0.1' ? 'localhost' : host;
@@ -45,18 +48,39 @@ export function createAppServer(config = loadConfig()) {
45
48
  enabled: config.gitEnabled,
46
49
  vaultDir: config.vaultDir,
47
50
  });
51
+ let workspaceMutationCoordinator = null;
48
52
  const roomRegistry = new RoomRegistry({
49
- createRoom: ({ name, onEmpty }) => new CollaborationRoom({
50
- documentStore: new CollaborationDocumentStore({
51
- backlinkIndex: name === '__lobby__' ? null : backlinkIndex,
53
+ createRoom: ({ name, onEmpty }) => {
54
+ const room = new CollaborationRoom({
55
+ documentStore: new CollaborationDocumentStore({
56
+ backlinkIndex: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : backlinkIndex,
57
+ name,
58
+ vaultFileStore: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : vaultFileStore,
59
+ }),
60
+ idleGraceMs: config.wsRoomIdleGraceMs,
61
+ maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
52
62
  name,
53
- vaultFileStore: name === '__lobby__' ? null : vaultFileStore,
54
- }),
55
- idleGraceMs: config.wsRoomIdleGraceMs,
56
- maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
57
- name,
58
- onEmpty,
59
- }),
63
+ onEmpty,
64
+ });
65
+
66
+ if (name === WORKSPACE_ROOM_NAME && workspaceMutationCoordinator?.workspaceState) {
67
+ room.replaceWorkspaceEntries(workspaceMutationCoordinator.workspaceState.entries, {
68
+ generatedAt: workspaceMutationCoordinator.workspaceState.scannedAt,
69
+ });
70
+ }
71
+
72
+ return room;
73
+ },
74
+ });
75
+ workspaceMutationCoordinator = new WorkspaceMutationCoordinator({
76
+ backlinkIndex,
77
+ roomRegistry,
78
+ vaultFileStore,
79
+ });
80
+ vaultFileStore.setManagedWriteTracker(workspaceMutationCoordinator);
81
+ const fileSystemSyncService = new FileSystemSyncService({
82
+ mutationCoordinator: workspaceMutationCoordinator,
83
+ vaultFileStore,
60
84
  });
61
85
  const requestHandler = createRequestHandler(
62
86
  config,
@@ -66,6 +90,8 @@ export function createAppServer(config = loadConfig()) {
66
90
  roomRegistry,
67
91
  plantUmlRenderer,
68
92
  gitService,
93
+ workspaceMutationCoordinator,
94
+ fileSystemSyncService,
69
95
  );
70
96
  const httpServer = createServer((req, res) => {
71
97
  requestHandler(req, res).catch((error) => {
@@ -95,6 +121,8 @@ export function createAppServer(config = loadConfig()) {
95
121
  async function listen() {
96
122
  vaultFileCount = await vaultFileStore.countVaultFiles();
97
123
  await backlinkIndex.build();
124
+ await workspaceMutationCoordinator.initialize();
125
+ await fileSystemSyncService.start();
98
126
 
99
127
  return new Promise((resolve, reject) => {
100
128
  httpServer.once('error', reject);
@@ -118,6 +146,7 @@ export function createAppServer(config = loadConfig()) {
118
146
 
119
147
  shutdownPromise = (async () => {
120
148
  await collaborationGateway.close();
149
+ await fileSystemSyncService.close();
121
150
  await roomRegistry.reset();
122
151
  await Promise.all([
123
152
  closeHttpServer(httpServer),
@@ -135,7 +164,9 @@ export function createAppServer(config = loadConfig()) {
135
164
  httpServer,
136
165
  listen,
137
166
  roomRegistry,
167
+ workspaceMutationCoordinator,
138
168
  authService,
169
+ fileSystemSyncService,
139
170
  gitService,
140
171
  vaultFileStore,
141
172
  get vaultFileCount() { return vaultFileCount; },
@@ -14,9 +14,27 @@ import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../domain/
14
14
 
15
15
  const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
16
16
 
17
+ function createDeferred() {
18
+ let resolve;
19
+ let reject;
20
+ const promise = new Promise((nextResolve, nextReject) => {
21
+ resolve = nextResolve;
22
+ reject = nextReject;
23
+ });
24
+ return { promise, reject, resolve };
25
+ }
26
+
17
27
  export class BacklinkIndex {
18
- constructor({ vaultFileStore }) {
28
+ constructor({
29
+ rebuildDelayMs = 150,
30
+ setTimeoutFn = setTimeout,
31
+ clearTimeoutFn = clearTimeout,
32
+ vaultFileStore,
33
+ }) {
19
34
  this.vaultFileStore = vaultFileStore;
35
+ this.rebuildDelayMs = rebuildDelayMs;
36
+ this.setTimeoutFn = setTimeoutFn;
37
+ this.clearTimeoutFn = clearTimeoutFn;
20
38
  /** @type {Map<string, Set<string>>} sourcePath → set of resolved target paths */
21
39
  this.forward = new Map();
22
40
  /** @type {Map<string, Set<string>>} targetPath → set of source paths */
@@ -29,6 +47,11 @@ export class BacklinkIndex {
29
47
  this._fileSet = new Set();
30
48
  this._wikiTargetIndex = createWikiTargetIndex(this._fileList);
31
49
  this._built = false;
50
+ this._requestedBuildVersion = 0;
51
+ this._completedBuildVersion = 0;
52
+ this._buildPromise = null;
53
+ this._scheduledBuildTimer = null;
54
+ this._scheduledBuildDeferred = null;
32
55
  }
33
56
 
34
57
  /**
@@ -36,6 +59,74 @@ export class BacklinkIndex {
36
59
  * Called once at server startup.
37
60
  */
38
61
  async build() {
62
+ this._requestedBuildVersion += 1;
63
+ return this.flushScheduledBuild();
64
+ }
65
+
66
+ scheduleBuild({ delayMs = this.rebuildDelayMs } = {}) {
67
+ this._requestedBuildVersion += 1;
68
+ if (!this._scheduledBuildDeferred) {
69
+ this._scheduledBuildDeferred = createDeferred();
70
+ }
71
+
72
+ if (this._scheduledBuildTimer) {
73
+ this.clearTimeoutFn(this._scheduledBuildTimer);
74
+ }
75
+
76
+ this._scheduledBuildTimer = this.setTimeoutFn(() => {
77
+ this._scheduledBuildTimer = null;
78
+ const deferred = this._scheduledBuildDeferred;
79
+ this._scheduledBuildDeferred = null;
80
+ this._ensureBuiltToRequestedVersion()
81
+ .then(() => deferred?.resolve())
82
+ .catch((error) => deferred?.reject(error));
83
+ }, delayMs);
84
+ this._scheduledBuildTimer.unref?.();
85
+
86
+ return this._scheduledBuildDeferred.promise;
87
+ }
88
+
89
+ async flushScheduledBuild() {
90
+ if (this._scheduledBuildTimer) {
91
+ this.clearTimeoutFn(this._scheduledBuildTimer);
92
+ this._scheduledBuildTimer = null;
93
+ }
94
+
95
+ const deferred = this._scheduledBuildDeferred;
96
+ this._scheduledBuildDeferred = null;
97
+
98
+ try {
99
+ await this._ensureBuiltToRequestedVersion();
100
+ deferred?.resolve();
101
+ } catch (error) {
102
+ deferred?.reject(error);
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ async _ensureBuiltToRequestedVersion() {
108
+ while (this._completedBuildVersion < this._requestedBuildVersion) {
109
+ await this._runSingleBuild();
110
+ }
111
+ }
112
+
113
+ async _runSingleBuild() {
114
+ if (this._buildPromise) {
115
+ return this._buildPromise;
116
+ }
117
+
118
+ const targetVersion = this._requestedBuildVersion;
119
+ this._buildPromise = (async () => {
120
+ await this._performBuild();
121
+ this._completedBuildVersion = Math.max(this._completedBuildVersion, targetVersion);
122
+ })().finally(() => {
123
+ this._buildPromise = null;
124
+ });
125
+
126
+ return this._buildPromise;
127
+ }
128
+
129
+ async _performBuild() {
39
130
  this.forward.clear();
40
131
  this.reverse.clear();
41
132
  this.contextsBySource.clear();
@@ -169,6 +260,8 @@ export class BacklinkIndex {
169
260
  * Returns: [{ file: string, contexts: string[] }]
170
261
  */
171
262
  async getBacklinks(filePath) {
263
+ await this.flushScheduledBuild();
264
+
172
265
  const sources = this.reverse.get(filePath);
173
266
  if (!sources || sources.size === 0) {
174
267
  return [];