collabmd 0.1.20 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -9
- package/bin/collabmd.js +3 -2
- package/docker-compose.yml +13 -1
- package/package.json +2 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
- package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
- package/public/assets/js/chunks/editor-session-S5AT7BMV.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-5YWMGLKF.js} +145 -62
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +503 -182
- package/public/assets/js/preview-render-worker.js +15 -15
- package/public/index.html +104 -84
- package/src/client/application/app-shell/git-feature.js +304 -27
- package/src/client/application/app-shell/ui-feature.js +69 -7
- package/src/client/application/app-shell/workspace-feature.js +46 -0
- package/src/client/application/app-shell-elements.js +2 -0
- package/src/client/application/mermaid-preview-hydrator.js +1 -1
- package/src/client/application/plantuml-preview-hydrator.js +1 -1
- package/src/client/application/workspace-coordinator.js +147 -18
- package/src/client/application/workspace-preview-controller.js +34 -0
- package/src/client/application/workspace-route-controller.js +104 -0
- package/src/client/bootstrap/collabmd-app-shell.js +107 -0
- package/src/client/domain/runtime-paths.js +5 -0
- package/src/client/domain/vault-utils.js +2 -2
- package/src/client/excalidraw-editor.js +52 -3
- package/src/client/infrastructure/auth-client.js +219 -52
- package/src/client/infrastructure/browser-navigation-port.js +20 -0
- package/src/client/infrastructure/editor-collaboration-client.js +4 -0
- package/src/client/infrastructure/editor-session.js +51 -7
- package/src/client/infrastructure/editor-view-adapter.js +297 -53
- package/src/client/infrastructure/runtime-config.js +66 -0
- package/src/client/infrastructure/workspace-sync-client.js +324 -0
- package/src/client/presentation/comment-ui-controller.js +274 -31
- package/src/client/presentation/comments-panel.js +2 -2
- package/src/client/presentation/excalidraw-embed-controller.js +60 -2
- package/src/client/presentation/file-explorer-controller.js +15 -3
- package/src/client/presentation/file-explorer-view.js +152 -11
- package/src/client/presentation/file-history-view-controller.js +806 -0
- package/src/client/presentation/git-diff-view-controller.js +718 -121
- package/src/client/presentation/git-panel-controller.js +458 -66
- package/src/client/presentation/layout-controller.js +1 -1
- package/src/client/styles/components/actions.css +89 -0
- package/src/client/styles/components/feedback.css +43 -0
- package/src/client/styles/components/navigation.css +68 -0
- package/src/client/styles/components/scrollbars.css +104 -0
- package/src/client/styles/components/states.css +289 -0
- package/src/client/styles/components/surfaces.css +155 -0
- package/src/client/styles/features/backlinks.css +213 -0
- package/src/client/styles/features/collaboration-chat.css +228 -0
- package/src/client/styles/features/collaboration-dialogs.css +154 -0
- package/src/client/styles/features/collaboration-presence.css +103 -0
- package/src/client/styles/features/collaboration-ui.css +3 -0
- package/src/client/styles/features/comment-card.css +236 -0
- package/src/client/styles/features/comment-markdown.css +127 -0
- package/src/client/styles/features/comment-overlays.css +60 -0
- package/src/client/styles/features/comments-drawer.css +120 -0
- package/src/client/styles/features/comments.css +4 -0
- package/src/client/styles/features/diagram-preview.css +227 -0
- package/src/client/styles/features/diff-commit.css +268 -0
- package/src/client/styles/features/diff-lines.css +160 -0
- package/src/client/styles/features/diff-toolbar.css +98 -0
- package/src/client/styles/features/diff.css +3 -0
- package/src/client/styles/features/embedded-preview.css +218 -0
- package/src/client/styles/features/git-branch.css +155 -0
- package/src/client/styles/features/git-files.css +105 -0
- package/src/client/styles/features/git-history.css +339 -0
- package/src/client/styles/features/git.css +3 -0
- package/src/client/styles/features/media-lightbox.css +157 -0
- package/src/client/styles/features/outline.css +115 -0
- package/src/client/styles/features/preview-content.css +3 -0
- package/src/client/styles/features/preview-markdown.css +201 -0
- package/src/client/styles/features/preview-sidebars.css +2 -0
- package/src/client/styles/features/quick-switcher.css +167 -0
- package/src/client/styles/foundation/themes.css +347 -0
- package/src/client/styles/foundation/tokens.css +66 -0
- package/src/client/styles/foundation/utilities.css +36 -0
- package/src/client/styles/layout/editor-page.css +203 -0
- package/src/client/styles/layout/overlays.css +132 -0
- package/src/client/styles/layout/responsive.css +251 -0
- package/src/client/styles/layout/shell.css +314 -0
- package/src/client/styles/layout/sidebar.css +176 -0
- package/src/client/styles/layout/view-modes.css +92 -0
- package/src/client/styles/overrides/codemirror.css +53 -0
- package/src/client/styles/overrides/highlightjs.css +7 -0
- package/src/client/styles/overrides/vendor.css +2 -0
- package/src/client/styles/primitives/controls.css +276 -0
- package/src/client/styles/style.css +25 -4901
- 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/auth/create-auth-service.js +373 -15
- package/src/server/auth/session-cookie.js +30 -4
- package/src/server/config/env.js +121 -5
- package/src/server/create-app-server.js +49 -10
- package/src/server/domain/backlink-index.js +94 -1
- package/src/server/domain/collaboration/collaboration-room.js +281 -69
- package/src/server/domain/collaboration/room-registry.js +64 -10
- package/src/server/index.js +3 -1
- package/src/server/infrastructure/git/command-runner.js +2 -1
- package/src/server/infrastructure/git/git-service.js +48 -2
- package/src/server/infrastructure/git/history-service.js +683 -0
- package/src/server/infrastructure/git/parsers.js +30 -0
- package/src/server/infrastructure/git/responses.js +85 -19
- package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
- package/src/server/infrastructure/http/create-git-api-command-handler.js +55 -43
- package/src/server/infrastructure/http/create-git-api-handler.js +4 -0
- package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
- package/src/server/infrastructure/http/create-request-handler.js +22 -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
package/src/server/config/env.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'url';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
AUTH_STRATEGY_NONE,
|
|
6
|
+
AUTH_STRATEGY_OIDC,
|
|
6
7
|
AUTH_STRATEGY_PASSWORD,
|
|
7
8
|
SUPPORTED_AUTH_STRATEGIES,
|
|
8
9
|
createRandomAuthPassword,
|
|
@@ -54,6 +55,30 @@ function normalizeWsBasePath(basePath) {
|
|
|
54
55
|
return normalizeRoutePath(basePath, '/ws');
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function normalizePublicBaseUrl(rawValue) {
|
|
59
|
+
const normalized = normalizeOptionalString(rawValue);
|
|
60
|
+
if (!normalized) {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let parsedUrl;
|
|
65
|
+
try {
|
|
66
|
+
parsedUrl = new URL(normalized);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error('PUBLIC_BASE_URL must be an absolute URL.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (parsedUrl.protocol !== 'https:' && parsedUrl.hostname !== 'localhost' && parsedUrl.hostname !== '127.0.0.1') {
|
|
72
|
+
throw new Error('PUBLIC_BASE_URL must use https unless it points to localhost.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if ((parsedUrl.pathname && parsedUrl.pathname !== '/') || parsedUrl.search || parsedUrl.hash) {
|
|
76
|
+
throw new Error('PUBLIC_BASE_URL must not include a path, query string, or hash.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return parsedUrl.origin;
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
function normalizeAuthStrategy(rawStrategy) {
|
|
58
83
|
const normalized = String(rawStrategy ?? AUTH_STRATEGY_NONE).trim().toLowerCase();
|
|
59
84
|
if (!SUPPORTED_AUTH_STRATEGIES.has(normalized)) {
|
|
@@ -65,8 +90,42 @@ function normalizeAuthStrategy(rawStrategy) {
|
|
|
65
90
|
return normalized;
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
function normalizeCsvList(value) {
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
return value
|
|
96
|
+
.map((entry) => String(entry ?? '').trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return String(value ?? '')
|
|
101
|
+
.split(',')
|
|
102
|
+
.map((entry) => entry.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeEmailAllowlist(value) {
|
|
107
|
+
return normalizeCsvList(value)
|
|
108
|
+
.map((entry) => entry.toLowerCase());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeDomainAllowlist(value) {
|
|
112
|
+
return normalizeCsvList(value)
|
|
113
|
+
.map((entry) => entry.replace(/^@+/, '').toLowerCase())
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
68
117
|
const projectRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url)));
|
|
69
118
|
|
|
119
|
+
export function resolveConfiguredVaultDir(overrides = {}, env = process.env) {
|
|
120
|
+
return overrides.vaultDir
|
|
121
|
+
|| env.COLLABMD_VAULT_DIR
|
|
122
|
+
|| resolve(projectRoot, 'data/vault');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveCliVaultDir(positionals = [], env = process.env) {
|
|
126
|
+
return resolve(positionals[0] || env.COLLABMD_VAULT_DIR || '.');
|
|
127
|
+
}
|
|
128
|
+
|
|
70
129
|
function getDefaultHost(nodeEnv) {
|
|
71
130
|
return nodeEnv === 'production' ? '0.0.0.0' : '127.0.0.1';
|
|
72
131
|
}
|
|
@@ -134,11 +193,63 @@ function loadGitConfig(overrides = {}) {
|
|
|
134
193
|
};
|
|
135
194
|
}
|
|
136
195
|
|
|
196
|
+
function loadOidcConfig(overrides = {}, { basePath = '' } = {}) {
|
|
197
|
+
const clientId = normalizeOptionalString(
|
|
198
|
+
overrides.clientId
|
|
199
|
+
?? process.env.AUTH_OIDC_CLIENT_ID,
|
|
200
|
+
);
|
|
201
|
+
const clientSecret = normalizeOptionalString(
|
|
202
|
+
overrides.clientSecret
|
|
203
|
+
?? process.env.AUTH_OIDC_CLIENT_SECRET,
|
|
204
|
+
);
|
|
205
|
+
const publicBaseUrl = normalizePublicBaseUrl(
|
|
206
|
+
overrides.publicBaseUrl
|
|
207
|
+
?? process.env.PUBLIC_BASE_URL,
|
|
208
|
+
);
|
|
209
|
+
const issuer = normalizeOptionalString(
|
|
210
|
+
overrides.issuer
|
|
211
|
+
?? process.env.AUTH_OIDC_ISSUER_URL,
|
|
212
|
+
) || 'https://accounts.google.com';
|
|
213
|
+
const flowCookieName = normalizeOptionalString(
|
|
214
|
+
overrides.flowCookieName
|
|
215
|
+
?? process.env.AUTH_OIDC_FLOW_COOKIE_NAME,
|
|
216
|
+
) || 'collabmd_auth_flow';
|
|
217
|
+
const allowedEmails = normalizeEmailAllowlist(
|
|
218
|
+
overrides.allowedEmails
|
|
219
|
+
?? process.env.AUTH_OIDC_ALLOWED_EMAILS,
|
|
220
|
+
);
|
|
221
|
+
const allowedDomains = normalizeDomainAllowlist(
|
|
222
|
+
overrides.allowedDomains
|
|
223
|
+
?? process.env.AUTH_OIDC_ALLOWED_DOMAINS,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (!publicBaseUrl) {
|
|
227
|
+
throw new Error('OIDC auth requires PUBLIC_BASE_URL.');
|
|
228
|
+
}
|
|
229
|
+
if (!clientId) {
|
|
230
|
+
throw new Error('OIDC auth requires AUTH_OIDC_CLIENT_ID.');
|
|
231
|
+
}
|
|
232
|
+
if (!clientSecret) {
|
|
233
|
+
throw new Error('OIDC auth requires AUTH_OIDC_CLIENT_SECRET.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
allowedDomains,
|
|
238
|
+
allowedEmails,
|
|
239
|
+
callbackUrl: `${publicBaseUrl}${basePath}/api/auth/oidc/callback`,
|
|
240
|
+
clientId,
|
|
241
|
+
clientSecret,
|
|
242
|
+
flowCookieName,
|
|
243
|
+
issuer,
|
|
244
|
+
provider: 'google',
|
|
245
|
+
publicBaseUrl,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
137
249
|
export function loadConfig(overrides = {}) {
|
|
138
250
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
139
|
-
const vaultDir = overrides
|
|
140
|
-
|
|
141
|
-
|| resolve(projectRoot, 'data/vault');
|
|
251
|
+
const vaultDir = resolveConfiguredVaultDir(overrides);
|
|
252
|
+
const basePath = normalizeAppBasePath(process.env.BASE_PATH || '');
|
|
142
253
|
const authOverrides = overrides.auth ?? {};
|
|
143
254
|
const authStrategy = normalizeAuthStrategy(
|
|
144
255
|
authOverrides.strategy
|
|
@@ -150,18 +261,22 @@ export function loadConfig(overrides = {}) {
|
|
|
150
261
|
const password = authStrategy === AUTH_STRATEGY_PASSWORD
|
|
151
262
|
? (authOverrides.password || process.env.AUTH_PASSWORD || createRandomAuthPassword())
|
|
152
263
|
: '';
|
|
264
|
+
const oidc = authStrategy === AUTH_STRATEGY_OIDC
|
|
265
|
+
? loadOidcConfig(authOverrides.oidc, { basePath })
|
|
266
|
+
: null;
|
|
153
267
|
const git = loadGitConfig(overrides.git);
|
|
154
268
|
|
|
155
269
|
return {
|
|
156
270
|
auth: {
|
|
157
271
|
generatedPassword: passwordWasGenerated ? password : '',
|
|
272
|
+
oidc,
|
|
158
273
|
password,
|
|
159
274
|
passwordWasGenerated,
|
|
160
275
|
sessionCookieName: authOverrides.sessionCookieName || process.env.AUTH_SESSION_COOKIE_NAME || 'collabmd_auth',
|
|
161
276
|
sessionSecret: authOverrides.sessionSecret || process.env.AUTH_SESSION_SECRET || createRandomSessionSecret(),
|
|
162
277
|
strategy: authStrategy,
|
|
163
278
|
},
|
|
164
|
-
basePath
|
|
279
|
+
basePath,
|
|
165
280
|
host: process.env.HOST || getDefaultHost(nodeEnv),
|
|
166
281
|
httpHeadersTimeoutMs: parsePositiveInt(process.env.HTTP_HEADERS_TIMEOUT_MS, 60_000),
|
|
167
282
|
httpKeepAliveTimeoutMs: parsePositiveInt(process.env.HTTP_KEEP_ALIVE_TIMEOUT_MS, 5_000),
|
|
@@ -174,8 +289,9 @@ export function loadConfig(overrides = {}) {
|
|
|
174
289
|
publicDir: resolve(projectRoot, 'public'),
|
|
175
290
|
vaultDir,
|
|
176
291
|
publicWsBaseUrl: process.env.PUBLIC_WS_BASE_URL || '',
|
|
292
|
+
testWsRoomHydrateDelayMs: parsePositiveInt(process.env.TEST_WS_ROOM_HYDRATE_DELAY_MS, 0),
|
|
177
293
|
wsHeartbeatIntervalMs: parsePositiveInt(process.env.WS_HEARTBEAT_INTERVAL_MS, 30_000),
|
|
178
|
-
wsRoomIdleGraceMs: parsePositiveInt(process.env.WS_ROOM_IDLE_GRACE_MS,
|
|
294
|
+
wsRoomIdleGraceMs: parsePositiveInt(process.env.WS_ROOM_IDLE_GRACE_MS, 60_000),
|
|
179
295
|
wsBasePath: normalizeWsBasePath(process.env.WS_BASE_PATH || '/ws'),
|
|
180
296
|
wsMaxBufferedAmountBytes: parsePositiveInt(
|
|
181
297
|
process.env.WS_MAX_BUFFERED_AMOUNT_BYTES,
|
|
@@ -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,43 @@ export function createAppServer(config = loadConfig()) {
|
|
|
45
48
|
enabled: config.gitEnabled,
|
|
46
49
|
vaultDir: config.vaultDir,
|
|
47
50
|
});
|
|
51
|
+
const testControls = {
|
|
52
|
+
wsRoomHydrateDelayMs: Math.max(0, Number(config.testWsRoomHydrateDelayMs || 0)),
|
|
53
|
+
};
|
|
54
|
+
let workspaceMutationCoordinator = null;
|
|
48
55
|
const roomRegistry = new RoomRegistry({
|
|
49
|
-
createRoom: ({ name, onEmpty }) =>
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
createRoom: ({ name, onEmpty }) => {
|
|
57
|
+
const room = new CollaborationRoom({
|
|
58
|
+
documentStore: new CollaborationDocumentStore({
|
|
59
|
+
backlinkIndex: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : backlinkIndex,
|
|
60
|
+
name,
|
|
61
|
+
vaultFileStore: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : vaultFileStore,
|
|
62
|
+
}),
|
|
63
|
+
getHydrateDelayMs: () => testControls.wsRoomHydrateDelayMs,
|
|
64
|
+
idleGraceMs: config.wsRoomIdleGraceMs,
|
|
65
|
+
maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
|
|
52
66
|
name,
|
|
53
|
-
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
onEmpty,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (name === WORKSPACE_ROOM_NAME && workspaceMutationCoordinator?.workspaceState) {
|
|
71
|
+
room.replaceWorkspaceEntries(workspaceMutationCoordinator.workspaceState.entries, {
|
|
72
|
+
generatedAt: workspaceMutationCoordinator.workspaceState.scannedAt,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return room;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
workspaceMutationCoordinator = new WorkspaceMutationCoordinator({
|
|
80
|
+
backlinkIndex,
|
|
81
|
+
roomRegistry,
|
|
82
|
+
vaultFileStore,
|
|
83
|
+
});
|
|
84
|
+
vaultFileStore.setManagedWriteTracker(workspaceMutationCoordinator);
|
|
85
|
+
const fileSystemSyncService = new FileSystemSyncService({
|
|
86
|
+
mutationCoordinator: workspaceMutationCoordinator,
|
|
87
|
+
vaultFileStore,
|
|
60
88
|
});
|
|
61
89
|
const requestHandler = createRequestHandler(
|
|
62
90
|
config,
|
|
@@ -66,6 +94,9 @@ export function createAppServer(config = loadConfig()) {
|
|
|
66
94
|
roomRegistry,
|
|
67
95
|
plantUmlRenderer,
|
|
68
96
|
gitService,
|
|
97
|
+
testControls,
|
|
98
|
+
workspaceMutationCoordinator,
|
|
99
|
+
fileSystemSyncService,
|
|
69
100
|
);
|
|
70
101
|
const httpServer = createServer((req, res) => {
|
|
71
102
|
requestHandler(req, res).catch((error) => {
|
|
@@ -95,6 +126,8 @@ export function createAppServer(config = loadConfig()) {
|
|
|
95
126
|
async function listen() {
|
|
96
127
|
vaultFileCount = await vaultFileStore.countVaultFiles();
|
|
97
128
|
await backlinkIndex.build();
|
|
129
|
+
await workspaceMutationCoordinator.initialize();
|
|
130
|
+
await fileSystemSyncService.start();
|
|
98
131
|
|
|
99
132
|
return new Promise((resolve, reject) => {
|
|
100
133
|
httpServer.once('error', reject);
|
|
@@ -118,6 +151,7 @@ export function createAppServer(config = loadConfig()) {
|
|
|
118
151
|
|
|
119
152
|
shutdownPromise = (async () => {
|
|
120
153
|
await collaborationGateway.close();
|
|
154
|
+
await fileSystemSyncService.close();
|
|
121
155
|
await roomRegistry.reset();
|
|
122
156
|
await Promise.all([
|
|
123
157
|
closeHttpServer(httpServer),
|
|
@@ -135,8 +169,13 @@ export function createAppServer(config = loadConfig()) {
|
|
|
135
169
|
httpServer,
|
|
136
170
|
listen,
|
|
137
171
|
roomRegistry,
|
|
172
|
+
workspaceMutationCoordinator,
|
|
138
173
|
authService,
|
|
174
|
+
fileSystemSyncService,
|
|
139
175
|
gitService,
|
|
176
|
+
setTestHydrateDelayMs(delayMs = 0) {
|
|
177
|
+
testControls.wsRoomHydrateDelayMs = Math.max(0, Number(delayMs) || 0);
|
|
178
|
+
},
|
|
140
179
|
vaultFileStore,
|
|
141
180
|
get vaultFileCount() { return vaultFileCount; },
|
|
142
181
|
};
|
|
@@ -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 [];
|