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.
- package/README.md +4 -2
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
- package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
- package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
- package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +68 -68
- package/public/assets/js/preview-render-worker.js +15 -15
- package/src/client/application/app-shell/git-feature.js +57 -18
- package/src/client/application/app-shell/ui-feature.js +4 -0
- package/src/client/bootstrap/collabmd-app-shell.js +12 -0
- package/src/client/domain/vault-utils.js +2 -2
- package/src/client/excalidraw-editor.js +2 -1
- package/src/client/infrastructure/editor-session.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +181 -1
- package/src/client/infrastructure/workspace-sync-client.js +324 -0
- package/src/client/presentation/comment-ui-controller.js +192 -5
- package/src/client/presentation/file-explorer-controller.js +15 -3
- package/src/client/presentation/file-explorer-view.js +152 -11
- package/src/client/styles/style.css +46 -2
- package/src/domain/wiki-link-resolver.js +16 -5
- package/src/domain/workspace-change.js +68 -0
- package/src/domain/workspace-room.js +3 -0
- package/src/server/create-app-server.js +41 -10
- package/src/server/domain/backlink-index.js +94 -1
- package/src/server/domain/collaboration/collaboration-room.js +191 -22
- package/src/server/domain/collaboration/room-registry.js +64 -10
- package/src/server/infrastructure/git/responses.js +12 -19
- package/src/server/infrastructure/http/create-git-api-command-handler.js +53 -43
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-request-handler.js +7 -0
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
- package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
- package/src/server/infrastructure/http/http-response.js +65 -28
- package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
- package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
- package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
71
|
-
filePath === normalizedTarget
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
@@ -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 }) =>
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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({
|
|
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 [];
|