collabmd 0.1.21 → 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.
Files changed (99) hide show
  1. package/README.md +94 -7
  2. package/bin/collabmd.js +3 -2
  3. package/docker-compose.yml +13 -1
  4. package/package.json +2 -1
  5. package/public/assets/css/style.css +1 -1
  6. package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
  7. package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
  8. package/public/assets/js/{excalidraw-editor-TCIH6GJL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
  9. package/public/assets/js/excalidraw-editor.js +1 -1
  10. package/public/assets/js/main.js +503 -182
  11. package/public/index.html +104 -84
  12. package/src/client/application/app-shell/git-feature.js +247 -9
  13. package/src/client/application/app-shell/ui-feature.js +65 -7
  14. package/src/client/application/app-shell/workspace-feature.js +46 -0
  15. package/src/client/application/app-shell-elements.js +2 -0
  16. package/src/client/application/mermaid-preview-hydrator.js +1 -1
  17. package/src/client/application/plantuml-preview-hydrator.js +1 -1
  18. package/src/client/application/workspace-coordinator.js +147 -18
  19. package/src/client/application/workspace-preview-controller.js +34 -0
  20. package/src/client/application/workspace-route-controller.js +104 -0
  21. package/src/client/bootstrap/collabmd-app-shell.js +95 -0
  22. package/src/client/domain/runtime-paths.js +5 -0
  23. package/src/client/excalidraw-editor.js +50 -2
  24. package/src/client/infrastructure/auth-client.js +219 -52
  25. package/src/client/infrastructure/browser-navigation-port.js +20 -0
  26. package/src/client/infrastructure/editor-collaboration-client.js +4 -0
  27. package/src/client/infrastructure/editor-session.js +47 -7
  28. package/src/client/infrastructure/editor-view-adapter.js +122 -58
  29. package/src/client/infrastructure/runtime-config.js +66 -0
  30. package/src/client/presentation/comment-ui-controller.js +82 -26
  31. package/src/client/presentation/comments-panel.js +2 -2
  32. package/src/client/presentation/excalidraw-embed-controller.js +60 -2
  33. package/src/client/presentation/file-history-view-controller.js +806 -0
  34. package/src/client/presentation/git-diff-view-controller.js +718 -121
  35. package/src/client/presentation/git-panel-controller.js +458 -66
  36. package/src/client/presentation/layout-controller.js +1 -1
  37. package/src/client/styles/components/actions.css +89 -0
  38. package/src/client/styles/components/feedback.css +43 -0
  39. package/src/client/styles/components/navigation.css +68 -0
  40. package/src/client/styles/components/scrollbars.css +104 -0
  41. package/src/client/styles/components/states.css +289 -0
  42. package/src/client/styles/components/surfaces.css +155 -0
  43. package/src/client/styles/features/backlinks.css +213 -0
  44. package/src/client/styles/features/collaboration-chat.css +228 -0
  45. package/src/client/styles/features/collaboration-dialogs.css +154 -0
  46. package/src/client/styles/features/collaboration-presence.css +103 -0
  47. package/src/client/styles/features/collaboration-ui.css +3 -0
  48. package/src/client/styles/features/comment-card.css +236 -0
  49. package/src/client/styles/features/comment-markdown.css +127 -0
  50. package/src/client/styles/features/comment-overlays.css +60 -0
  51. package/src/client/styles/features/comments-drawer.css +120 -0
  52. package/src/client/styles/features/comments.css +4 -0
  53. package/src/client/styles/features/diagram-preview.css +227 -0
  54. package/src/client/styles/features/diff-commit.css +268 -0
  55. package/src/client/styles/features/diff-lines.css +160 -0
  56. package/src/client/styles/features/diff-toolbar.css +98 -0
  57. package/src/client/styles/features/diff.css +3 -0
  58. package/src/client/styles/features/embedded-preview.css +218 -0
  59. package/src/client/styles/features/git-branch.css +155 -0
  60. package/src/client/styles/features/git-files.css +105 -0
  61. package/src/client/styles/features/git-history.css +339 -0
  62. package/src/client/styles/features/git.css +3 -0
  63. package/src/client/styles/features/media-lightbox.css +157 -0
  64. package/src/client/styles/features/outline.css +115 -0
  65. package/src/client/styles/features/preview-content.css +3 -0
  66. package/src/client/styles/features/preview-markdown.css +201 -0
  67. package/src/client/styles/features/preview-sidebars.css +2 -0
  68. package/src/client/styles/features/quick-switcher.css +167 -0
  69. package/src/client/styles/foundation/themes.css +347 -0
  70. package/src/client/styles/foundation/tokens.css +66 -0
  71. package/src/client/styles/foundation/utilities.css +36 -0
  72. package/src/client/styles/layout/editor-page.css +203 -0
  73. package/src/client/styles/layout/overlays.css +132 -0
  74. package/src/client/styles/layout/responsive.css +251 -0
  75. package/src/client/styles/layout/shell.css +314 -0
  76. package/src/client/styles/layout/sidebar.css +176 -0
  77. package/src/client/styles/layout/view-modes.css +92 -0
  78. package/src/client/styles/overrides/codemirror.css +53 -0
  79. package/src/client/styles/overrides/highlightjs.css +7 -0
  80. package/src/client/styles/overrides/vendor.css +2 -0
  81. package/src/client/styles/primitives/controls.css +276 -0
  82. package/src/client/styles/style.css +25 -4945
  83. package/src/server/auth/create-auth-service.js +373 -15
  84. package/src/server/auth/session-cookie.js +30 -4
  85. package/src/server/config/env.js +121 -5
  86. package/src/server/create-app-server.js +8 -0
  87. package/src/server/domain/collaboration/collaboration-room.js +90 -47
  88. package/src/server/index.js +3 -1
  89. package/src/server/infrastructure/git/command-runner.js +2 -1
  90. package/src/server/infrastructure/git/git-service.js +48 -2
  91. package/src/server/infrastructure/git/history-service.js +683 -0
  92. package/src/server/infrastructure/git/parsers.js +30 -0
  93. package/src/server/infrastructure/git/responses.js +73 -0
  94. package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
  95. package/src/server/infrastructure/http/create-git-api-command-handler.js +2 -0
  96. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  97. package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
  98. package/src/server/infrastructure/http/create-request-handler.js +15 -0
  99. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +0 -22
@@ -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.vaultDir
140
- || process.env.COLLABMD_VAULT_DIR
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: normalizeAppBasePath(process.env.BASE_PATH || ''),
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, 15_000),
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,
@@ -48,6 +48,9 @@ export function createAppServer(config = loadConfig()) {
48
48
  enabled: config.gitEnabled,
49
49
  vaultDir: config.vaultDir,
50
50
  });
51
+ const testControls = {
52
+ wsRoomHydrateDelayMs: Math.max(0, Number(config.testWsRoomHydrateDelayMs || 0)),
53
+ };
51
54
  let workspaceMutationCoordinator = null;
52
55
  const roomRegistry = new RoomRegistry({
53
56
  createRoom: ({ name, onEmpty }) => {
@@ -57,6 +60,7 @@ export function createAppServer(config = loadConfig()) {
57
60
  name,
58
61
  vaultFileStore: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : vaultFileStore,
59
62
  }),
63
+ getHydrateDelayMs: () => testControls.wsRoomHydrateDelayMs,
60
64
  idleGraceMs: config.wsRoomIdleGraceMs,
61
65
  maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
62
66
  name,
@@ -90,6 +94,7 @@ export function createAppServer(config = loadConfig()) {
90
94
  roomRegistry,
91
95
  plantUmlRenderer,
92
96
  gitService,
97
+ testControls,
93
98
  workspaceMutationCoordinator,
94
99
  fileSystemSyncService,
95
100
  );
@@ -168,6 +173,9 @@ export function createAppServer(config = loadConfig()) {
168
173
  authService,
169
174
  fileSystemSyncService,
170
175
  gitService,
176
+ setTestHydrateDelayMs(delayMs = 0) {
177
+ testControls.wsRoomHydrateDelayMs = Math.max(0, Number(delayMs) || 0);
178
+ },
171
179
  vaultFileStore,
172
180
  get vaultFileCount() { return vaultFileCount; },
173
181
  };
@@ -126,6 +126,7 @@ function computeTextReplacement(currentContent, nextContent) {
126
126
  export class CollaborationRoom {
127
127
  constructor({
128
128
  documentStore = null,
129
+ getHydrateDelayMs = null,
129
130
  name,
130
131
  idleGraceMs = 0,
131
132
  maxBufferedAmountBytes,
@@ -141,6 +142,7 @@ export class CollaborationRoom {
141
142
  name,
142
143
  vaultFileStore,
143
144
  });
145
+ this.getHydrateDelayMs = getHydrateDelayMs;
144
146
  this.onEmpty = onEmpty;
145
147
  this.doc = new Y.Doc({ gc: true });
146
148
  this.awareness = new awarenessProtocol.Awareness(this.doc);
@@ -152,6 +154,12 @@ export class CollaborationRoom {
152
154
  this.destroyed = false;
153
155
  this.cachedInitialSyncMessage = null;
154
156
  this.activePersistPromise = null;
157
+ this.debugMetrics = {
158
+ hydrateCount: 0,
159
+ initialSyncCount: 0,
160
+ lastHydrate: null,
161
+ lastInitialSyncAt: 0,
162
+ };
155
163
  this.persistence = new RoomPersistenceController({
156
164
  idleGraceMs: this.idleGraceMs,
157
165
  onDestroy: () => {
@@ -173,64 +181,96 @@ export class CollaborationRoom {
173
181
 
174
182
  if (!this.hydratePromise) {
175
183
  this.hydratePromise = (async () => {
176
- if (this.documentStore?.hasPersistence()) {
177
- const snapshot = await this.documentStore.readSnapshot();
178
- if (snapshot) {
179
- try {
180
- if (isExcalidrawRoom(this.name)) {
181
- const validationDoc = new Y.Doc({ gc: true });
182
- try {
183
- Y.applyUpdate(validationDoc, snapshot, 'hydrate');
184
- if (!this.ensureStructuredExcalidrawState(validationDoc)) {
185
- throw new Error('snapshot did not contain a valid structured Excalidraw scene');
186
- }
184
+ const startedAt = Date.now();
185
+ let hydrateSource = 'empty';
186
+ let snapshotExists = false;
187
+ let snapshotValid = false;
188
+ let commentThreadCount = 0;
189
+ const hydrateDelayMs = Math.max(0, Number(this.getHydrateDelayMs?.() || 0));
190
+
191
+ try {
192
+ if (hydrateDelayMs > 0) {
193
+ await new Promise((resolve) => setTimeout(resolve, hydrateDelayMs));
194
+ }
187
195
 
188
- Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(validationDoc), 'hydrate');
189
- } finally {
190
- validationDoc.destroy();
196
+ if (this.documentStore?.hasPersistence()) {
197
+ const snapshot = await this.documentStore.readSnapshot();
198
+ if (snapshot) {
199
+ snapshotExists = true;
200
+ try {
201
+ if (isExcalidrawRoom(this.name)) {
202
+ const validationDoc = new Y.Doc({ gc: true });
203
+ try {
204
+ Y.applyUpdate(validationDoc, snapshot, 'hydrate');
205
+ if (!this.ensureStructuredExcalidrawState(validationDoc)) {
206
+ throw new Error('snapshot did not contain a valid structured Excalidraw scene');
207
+ }
208
+
209
+ Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(validationDoc), 'hydrate');
210
+ } finally {
211
+ validationDoc.destroy();
212
+ }
213
+ } else {
214
+ Y.applyUpdate(this.doc, snapshot, 'hydrate');
191
215
  }
192
- } else {
193
- Y.applyUpdate(this.doc, snapshot, 'hydrate');
194
- }
195
216
 
196
- this.hydrated = true;
197
- return;
198
- } catch (error) {
199
- console.warn(
200
- `[room:${this.name}] Discarding invalid collaboration snapshot: ${error.message}`,
201
- );
202
- await this.documentStore.deleteSnapshot?.();
217
+ snapshotValid = true;
218
+ hydrateSource = 'snapshot';
219
+ this.hydrated = true;
220
+ return;
221
+ } catch (error) {
222
+ console.warn(
223
+ `[room:${this.name}] Discarding invalid collaboration snapshot: ${error.message}`,
224
+ );
225
+ await this.documentStore.deleteSnapshot?.();
226
+ }
203
227
  }
204
- }
205
228
 
206
- const [content, commentThreads] = await Promise.all([
207
- this.documentStore.readContent(),
208
- this.documentStore.readCommentThreads(),
209
- ]);
210
- if (content !== null || commentThreads.length > 0) {
211
- const ytext = this.doc.getText('codemirror');
212
- const comments = this.doc.getArray('comments');
213
- this.doc.transact(() => {
214
- if (isExcalidrawRoom(this.name)) {
215
- const parsedScene = tryParseExcalidrawSceneJson(content);
216
- if (parsedScene) {
217
- migrateLegacyExcalidrawRoomData(this.doc, parsedScene);
229
+ const [content, commentThreads] = await Promise.all([
230
+ this.documentStore.readContent(),
231
+ this.documentStore.readCommentThreads(),
232
+ ]);
233
+ commentThreadCount = commentThreads.length;
234
+ if (content !== null || commentThreads.length > 0) {
235
+ hydrateSource = 'content';
236
+ const ytext = this.doc.getText('codemirror');
237
+ const comments = this.doc.getArray('comments');
238
+ this.doc.transact(() => {
239
+ if (isExcalidrawRoom(this.name)) {
240
+ const parsedScene = tryParseExcalidrawSceneJson(content);
241
+ if (parsedScene) {
242
+ migrateLegacyExcalidrawRoomData(this.doc, parsedScene);
243
+ } else if (content !== null) {
244
+ ytext.insert(0, content);
245
+ }
218
246
  } else if (content !== null) {
219
247
  ytext.insert(0, content);
220
248
  }
221
- } else if (content !== null) {
222
- ytext.insert(0, content);
223
- }
224
- populateCommentThreads(comments, commentThreads);
225
- }, 'hydrate');
249
+ populateCommentThreads(comments, commentThreads);
250
+ }, 'hydrate');
226
251
 
227
- void this.documentStore.writeSnapshot(Y.encodeStateAsUpdate(this.doc)).catch((error) => {
228
- console.error(`[room:${this.name}] Failed to prime collaboration snapshot: ${error.message}`);
229
- });
252
+ void this.documentStore.writeSnapshot(Y.encodeStateAsUpdate(this.doc)).catch((error) => {
253
+ console.error(`[room:${this.name}] Failed to prime collaboration snapshot: ${error.message}`);
254
+ });
255
+ }
230
256
  }
231
- }
232
257
 
233
- this.hydrated = true;
258
+ this.hydrated = true;
259
+ } finally {
260
+ if (this.hydrated) {
261
+ this.debugMetrics.hydrateCount += 1;
262
+ this.debugMetrics.lastHydrate = {
263
+ commentThreadCount,
264
+ durationMs: Date.now() - startedAt,
265
+ snapshotExists,
266
+ snapshotValid,
267
+ source: hydrateSource,
268
+ };
269
+ console.info(
270
+ `[perf][room:${this.name}] hydrate durationMs=${this.debugMetrics.lastHydrate.durationMs} source=${this.debugMetrics.lastHydrate.source} snapshotExists=${this.debugMetrics.lastHydrate.snapshotExists} snapshotValid=${this.debugMetrics.lastHydrate.snapshotValid} commentThreads=${this.debugMetrics.lastHydrate.commentThreadCount}`,
271
+ );
272
+ }
273
+ }
234
274
  })().catch((error) => {
235
275
  this.hydratePromise = null;
236
276
  console.error(`[room:${this.name}] Failed to hydrate from disk: ${error.message}`);
@@ -479,6 +519,9 @@ export class CollaborationRoom {
479
519
  }
480
520
 
481
521
  sendInitialSync(ws) {
522
+ this.debugMetrics.initialSyncCount += 1;
523
+ this.debugMetrics.lastInitialSyncAt = Date.now();
524
+ console.info(`[perf][room:${this.name}] send-initial-sync count=${this.debugMetrics.initialSyncCount}`);
482
525
  return sendMessage.call(this, ws, this.getInitialSyncMessage(), this);
483
526
  }
484
527
 
@@ -60,7 +60,9 @@ server.listen().then(({ host, port, wsPath }) => {
60
60
  if (server.config.auth.strategy === 'password') {
61
61
  console.log(` auth: password (${server.config.auth.password})`);
62
62
  } else if (server.config.auth.strategy === 'oidc') {
63
- console.log(' auth: oidc (not implemented yet)');
63
+ console.log(` auth: oidc (${server.config.auth.oidc.provider})`);
64
+ console.log(` public: ${server.config.auth.oidc.publicBaseUrl}`);
65
+ console.log(` callback: ${server.config.auth.oidc.callbackUrl}`);
64
66
  } else {
65
67
  console.log(' auth: none');
66
68
  }
@@ -31,13 +31,14 @@ export class GitCommandRunner {
31
31
  }
32
32
  }
33
33
 
34
- async execGit(args) {
34
+ async execGit(args, { env = null } = {}) {
35
35
  const result = await this.execFileImpl('git', ['-c', 'core.quotepath=false', ...args], {
36
36
  cwd: this.vaultDir,
37
37
  encoding: 'utf8',
38
38
  env: {
39
39
  ...process.env,
40
40
  ...(this.commandEnv ?? {}),
41
+ ...(env ?? {}),
41
42
  },
42
43
  maxBuffer: 5 * 1024 * 1024,
43
44
  timeout: 10_000,
@@ -2,12 +2,29 @@ import { createGitRequestError } from './errors.js';
2
2
  import { normalizeRelativeGitPath } from './path-utils.js';
3
3
  import { GitCommandRunner } from './command-runner.js';
4
4
  import { GitDiffService } from './diff-service.js';
5
+ import { GitHistoryService } from './history-service.js';
5
6
  import { parseNameStatusOutput } from './parsers.js';
6
7
  import { createEmptyWorkspaceChange, createWorkspaceChange } from './responses.js';
7
8
  import { GitStatusService } from './status-service.js';
8
9
  import { GitUntrackedFileService } from './untracked-files.js';
9
10
  import { PullBackupStore } from '../persistence/pull-backup-store.js';
10
11
 
12
+ function buildAuthorEnv(author = null) {
13
+ const name = String(author?.name ?? '').trim();
14
+ const email = String(author?.email ?? '').trim();
15
+
16
+ if (!name || !email) {
17
+ return null;
18
+ }
19
+
20
+ return {
21
+ GIT_AUTHOR_EMAIL: email,
22
+ GIT_AUTHOR_NAME: name,
23
+ GIT_COMMITTER_EMAIL: email,
24
+ GIT_COMMITTER_NAME: name,
25
+ };
26
+ }
27
+
11
28
  export class GitService {
12
29
  constructor({
13
30
  commandEnv = null,
@@ -37,6 +54,11 @@ export class GitService {
37
54
  statusService: this.statusService,
38
55
  untrackedFileService: this.untrackedFileService,
39
56
  });
57
+ this.historyService = new GitHistoryService({
58
+ commandRunner: this.commandRunner,
59
+ maxInitialPatchBytes,
60
+ maxInitialPatchLines,
61
+ });
40
62
  this.pullBackupStore = new PullBackupStore({ vaultDir });
41
63
  }
42
64
 
@@ -51,6 +73,7 @@ export class GitService {
51
73
  invalidateStatusCache() {
52
74
  this.statusService.invalidate();
53
75
  this.diffService.invalidate();
76
+ this.historyService.invalidate();
54
77
  }
55
78
 
56
79
  async getStatus(options = {}) {
@@ -87,7 +110,7 @@ export class GitService {
87
110
  };
88
111
  }
89
112
 
90
- async commitStaged({ message } = {}) {
113
+ async commitStaged({ author = null, message } = {}) {
91
114
  const normalizedMessage = String(message ?? '').trim();
92
115
  if (!normalizedMessage) {
93
116
  throw createGitRequestError(400, 'Missing commit message');
@@ -98,7 +121,9 @@ export class GitService {
98
121
  throw createGitRequestError(409, 'No staged changes to commit');
99
122
  }
100
123
 
101
- await this.commandRunner.execGit(['commit', '-m', normalizedMessage]);
124
+ await this.commandRunner.execGit(['commit', '-m', normalizedMessage], {
125
+ env: buildAuthorEnv(author),
126
+ });
102
127
  const hash = (await this.commandRunner.execGit(['rev-parse', 'HEAD'])).trim();
103
128
  const shortHash = (await this.commandRunner.execGit(['rev-parse', '--short', 'HEAD'])).trim();
104
129
  this.invalidateStatusCache();
@@ -233,6 +258,27 @@ export class GitService {
233
258
  });
234
259
  }
235
260
 
261
+ async getHistory({ limit = 30, offset = 0 } = {}) {
262
+ return this.historyService.listHistory({ limit, offset });
263
+ }
264
+
265
+ async getFileHistory({ limit = 30, offset = 0, path } = {}) {
266
+ return this.historyService.listFileHistory({ limit, offset, path });
267
+ }
268
+
269
+ async getCommit({ allowLargePatch = false, hash, metaOnly = false, path = null } = {}) {
270
+ return this.historyService.getCommit({
271
+ allowLargePatch,
272
+ hash,
273
+ metaOnly,
274
+ path,
275
+ });
276
+ }
277
+
278
+ async getFileSnapshot({ hash, path } = {}) {
279
+ return this.historyService.getFileSnapshot({ hash, path });
280
+ }
281
+
236
282
  async getHeadRef() {
237
283
  try {
238
284
  const output = await this.commandRunner.execGit(['rev-parse', 'HEAD']);