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
@@ -1,6 +1,14 @@
1
1
  import { FileOpenLifecycle } from './file-open-lifecycle.js';
2
2
  import { WorkspaceChromeController } from './workspace-chrome-controller.js';
3
3
 
4
+ const BOOTSTRAP_RENDER_DELAY_MS = 150;
5
+
6
+ function delay(ms) {
7
+ return new Promise((resolve) => {
8
+ setTimeout(resolve, ms);
9
+ });
10
+ }
11
+
4
12
  export class WorkspaceCoordinator {
5
13
  constructor({
6
14
  attachEditorScroller,
@@ -18,6 +26,7 @@ export class WorkspaceCoordinator {
18
26
  isMermaidFile,
19
27
  isPlantUmlFile,
20
28
  isTabActive,
29
+ loadBootstrapContent = null,
21
30
  loadEditorSessionClass,
22
31
  loadBacklinks,
23
32
  onBeforeFileOpen,
@@ -30,6 +39,7 @@ export class WorkspaceCoordinator {
30
39
  onImagePaste,
31
40
  onSelectionChange,
32
41
  onSessionAssigned = null,
42
+ onFileOpenMetric = null,
33
43
  onRenderExcalidrawPreview,
34
44
  onRenderImagePreview,
35
45
  onSyncWrapToggle,
@@ -58,6 +68,7 @@ export class WorkspaceCoordinator {
58
68
  this.isMermaidFile = isMermaidFile ?? (() => false);
59
69
  this.isPlantUmlFile = isPlantUmlFile ?? (() => false);
60
70
  this.isTabActive = isTabActive;
71
+ this.loadBootstrapContent = loadBootstrapContent;
61
72
  this.loadEditorSessionClassPort = loadEditorSessionClass;
62
73
  this.loadBacklinks = loadBacklinks;
63
74
  this.onBeforeFileOpen = onBeforeFileOpen;
@@ -70,6 +81,7 @@ export class WorkspaceCoordinator {
70
81
  this.onImagePaste = onImagePaste;
71
82
  this.onSelectionChange = onSelectionChange;
72
83
  this.onSessionAssigned = onSessionAssigned;
84
+ this.onFileOpenMetric = onFileOpenMetric;
73
85
  this.onRenderExcalidrawPreview = onRenderExcalidrawPreview;
74
86
  this.onSyncWrapToggle = onSyncWrapToggle;
75
87
  this.onUpdateActiveFile = onUpdateActiveFile;
@@ -122,6 +134,14 @@ export class WorkspaceCoordinator {
122
134
  });
123
135
  }
124
136
 
137
+ reportFileOpenMetric(name, loadToken, data = {}) {
138
+ this.onFileOpenMetric?.(name, {
139
+ filePath: this.stateStore.get('currentFilePath'),
140
+ loadToken,
141
+ ...data,
142
+ });
143
+ }
144
+
125
145
  cleanupSession() {
126
146
  this.session?.destroy();
127
147
  this.session = null;
@@ -146,11 +166,13 @@ export class WorkspaceCoordinator {
146
166
  }
147
167
 
148
168
  const loadToken = this.stateStore.nextSessionLoadToken();
169
+ const openStartedAt = performance.now();
149
170
 
150
171
  this.cleanupSession();
151
172
  const chromeState = this.chromeController.prepareForFileOpen(filePath, {
152
173
  resetConnectionState: !isExcalidraw && !isImage,
153
174
  });
175
+ this.reportFileOpenMetric('open_started', loadToken, { filePath });
154
176
 
155
177
  if (isExcalidraw || isImage) {
156
178
  this.onSessionAssigned?.(null);
@@ -198,37 +220,144 @@ export class WorkspaceCoordinator {
198
220
  this.onSessionAssigned?.(session);
199
221
 
200
222
  try {
201
- await session.initialize(filePath);
223
+ let fileOpenReady = false;
224
+ let fileOpenFinalized = false;
225
+ let liveSyncComplete = false;
202
226
 
203
- if (loadToken !== this.stateStore.get('sessionLoadToken')) {
204
- session.destroy();
205
- return;
206
- }
227
+ const readySession = async (reason) => {
228
+ if (fileOpenReady || loadToken !== this.stateStore.get('sessionLoadToken')) {
229
+ return;
230
+ }
231
+
232
+ fileOpenReady = true;
233
+ this.lifecycle.attachSessionScroller(session);
234
+ session.applyTheme(this.getTheme());
235
+ this.chromeController.markFileOpenReady(session);
236
+ this.reportFileOpenMetric('editor_ready', loadToken, { reason });
237
+ session.requestMeasure();
238
+ await this.waitForNextPaint();
239
+
240
+ if (fileOpenFinalized || loadToken !== this.stateStore.get('sessionLoadToken')) {
241
+ return;
242
+ }
207
243
 
208
- this.lifecycle.attachSessionScroller(session);
209
- session.applyTheme(this.getTheme());
210
- await session.waitForInitialSync();
211
- session.ensureInitialContent?.();
244
+ fileOpenFinalized = true;
245
+ this.chromeController.finalizeFileOpen({
246
+ filePath,
247
+ isExcalidraw,
248
+ session,
249
+ supportsBacklinks: chromeState.supportsBacklinks,
250
+ });
251
+ };
252
+
253
+ const bootstrapPromise = this.loadBootstrapContent
254
+ ? (async () => {
255
+ this.reportFileOpenMetric('bootstrap_fetch_started', loadToken);
256
+ try {
257
+ const content = await this.loadBootstrapContent(filePath);
258
+ this.reportFileOpenMetric('bootstrap_fetch_completed', loadToken, {
259
+ found: content !== null,
260
+ });
261
+ return content;
262
+ } catch (error) {
263
+ this.reportFileOpenMetric('bootstrap_fetch_completed', loadToken, {
264
+ error: error.message,
265
+ found: false,
266
+ });
267
+ return null;
268
+ }
269
+ })()
270
+ : Promise.resolve(null);
271
+
272
+ const initializePromise = session.initialize(filePath);
273
+ const liveSyncPromise = (async () => {
274
+ await initializePromise;
275
+
276
+ if (loadToken !== this.stateStore.get('sessionLoadToken')) {
277
+ return false;
278
+ }
279
+
280
+ await session.waitForInitialSync(null);
281
+ if (loadToken !== this.stateStore.get('sessionLoadToken')) {
282
+ return false;
283
+ }
284
+
285
+ liveSyncComplete = true;
286
+ session.activateCollaborativeView?.();
287
+ this.lifecycle.attachSessionScroller(session);
288
+ session.applyTheme(this.getTheme());
289
+ this.reportFileOpenMetric('initial_sync_complete', loadToken);
290
+ session.ensureInitialContent?.();
291
+ if (!fileOpenReady) {
292
+ await readySession('live-sync');
293
+ } else {
294
+ session.requestMeasure();
295
+ }
296
+ return true;
297
+ })();
298
+
299
+ const bootstrapVisibilityPromise = (async () => {
300
+ const bootstrapContent = await bootstrapPromise;
301
+ if (
302
+ bootstrapContent === null
303
+ || liveSyncComplete
304
+ || fileOpenReady
305
+ || loadToken !== this.stateStore.get('sessionLoadToken')
306
+ ) {
307
+ return false;
308
+ }
309
+
310
+ const elapsedMs = performance.now() - openStartedAt;
311
+ const remainingDelayMs = Math.max(0, BOOTSTRAP_RENDER_DELAY_MS - elapsedMs);
312
+ if (remainingDelayMs > 0) {
313
+ const winner = await Promise.race([
314
+ liveSyncPromise.then((didSync) => (didSync ? 'live-sync' : 'stale')),
315
+ delay(remainingDelayMs).then(() => 'timeout'),
316
+ ]);
317
+ if (winner === 'live-sync') {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ if (
323
+ liveSyncComplete
324
+ || fileOpenReady
325
+ || loadToken !== this.stateStore.get('sessionLoadToken')
326
+ ) {
327
+ return false;
328
+ }
329
+
330
+ const didApplyBootstrap = session.showBootstrapContent({
331
+ content: bootstrapContent,
332
+ filePath,
333
+ });
334
+ if (!didApplyBootstrap && !session.hasBootstrapContent?.()) {
335
+ return false;
336
+ }
337
+
338
+ this.reportFileOpenMetric('bootstrap_shown', loadToken);
339
+ await readySession('bootstrap');
340
+ return true;
341
+ })();
342
+
343
+ await initializePromise;
212
344
 
213
345
  if (loadToken !== this.stateStore.get('sessionLoadToken')) {
214
346
  session.destroy();
215
347
  return;
216
348
  }
217
349
 
218
- this.chromeController.markFileOpenReady(session);
219
- session.requestMeasure();
220
- await this.waitForNextPaint();
350
+ await Promise.all([liveSyncPromise, bootstrapVisibilityPromise]);
221
351
 
222
352
  if (loadToken !== this.stateStore.get('sessionLoadToken')) {
223
353
  session.destroy();
224
354
  return;
225
355
  }
226
- this.chromeController.finalizeFileOpen({
227
- filePath,
228
- isExcalidraw,
229
- session,
230
- supportsBacklinks: chromeState.supportsBacklinks,
231
- });
356
+
357
+ if (!fileOpenReady) {
358
+ session.ensureInitialContent?.();
359
+ await readySession('post-initialize');
360
+ }
232
361
  } catch (error) {
233
362
  console.error('[app] Failed to initialize editor:', error);
234
363
  session.destroy();
@@ -164,6 +164,40 @@ export class WorkspacePreviewController {
164
164
  this.schedulePreviewLayoutSyncCallback({ delayMs: 0 });
165
165
  }
166
166
 
167
+ renderTextFilePreview({ content = '' } = {}) {
168
+ const previewElement = this.elements.previewContent;
169
+ if (!previewElement) {
170
+ return;
171
+ }
172
+
173
+ this.videoEmbed?.detachForCommit();
174
+ this.excalidrawEmbed.detachForCommit();
175
+ this.resetPreviewMode();
176
+ const renderHost = this.previewRenderer.ensureRenderHost();
177
+ this.previewRenderer.normalizePreviewChildren(renderHost);
178
+
179
+ const shell = document.createElement('div');
180
+ shell.className = 'preview-shell';
181
+ const pre = document.createElement('pre');
182
+ const code = document.createElement('code');
183
+ code.textContent = String(content ?? '');
184
+ pre.appendChild(code);
185
+ shell.appendChild(pre);
186
+
187
+ if (renderHost) {
188
+ renderHost.replaceChildren(shell);
189
+ renderHost.style.minHeight = '';
190
+ }
191
+
192
+ previewElement.dataset.renderPhase = 'ready';
193
+ this.outlineController.close();
194
+ this.backlinksPanel.clear();
195
+ this.scrollSyncController.setLargeDocumentMode(false);
196
+ this.scrollSyncController.invalidatePreviewBlocks();
197
+ this.videoEmbed?.reconcileEmbeds(previewElement);
198
+ this.schedulePreviewLayoutSyncCallback({ delayMs: 0 });
199
+ }
200
+
167
201
  createResizeHandler(restoreSidebarState) {
168
202
  let resizeTimer = null;
169
203
  return () => {
@@ -2,9 +2,11 @@ export class WorkspaceRouteController {
2
2
  constructor({
3
3
  backlinksPanel,
4
4
  clearInitialFileBootstrap,
5
+ clearStaticPreviewDocument = null,
5
6
  closeSidebarOnMobile,
6
7
  elements,
7
8
  excalidrawEmbed,
9
+ fileHistoryView = null,
8
10
  fileExplorer,
9
11
  getIsTabActive,
10
12
  getSessionLoadToken,
@@ -22,7 +24,11 @@ export class WorkspaceRouteController {
22
24
  setSessionLoadToken,
23
25
  setSidebarTab,
24
26
  setCurrentFilePath,
27
+ showGitCommit,
25
28
  showGitDiff,
29
+ showGitFileHistory,
30
+ showGitFilePreview,
31
+ showGitHistory,
26
32
  syncMainChrome,
27
33
  videoEmbed,
28
34
  workspaceCoordinator,
@@ -30,9 +36,11 @@ export class WorkspaceRouteController {
30
36
  }) {
31
37
  this.backlinksPanel = backlinksPanel;
32
38
  this.clearInitialFileBootstrap = clearInitialFileBootstrap;
39
+ this.clearStaticPreviewDocument = clearStaticPreviewDocument;
33
40
  this.closeSidebarOnMobile = closeSidebarOnMobile;
34
41
  this.elements = elements;
35
42
  this.excalidrawEmbed = excalidrawEmbed;
43
+ this.fileHistoryView = fileHistoryView;
36
44
  this.fileExplorer = fileExplorer;
37
45
  this.getIsTabActive = getIsTabActive;
38
46
  this.getSessionLoadToken = getSessionLoadToken;
@@ -50,7 +58,11 @@ export class WorkspaceRouteController {
50
58
  this.setSessionLoadToken = setSessionLoadToken;
51
59
  this.setSidebarTab = setSidebarTab;
52
60
  this.setCurrentFilePath = setCurrentFilePath;
61
+ this.showGitCommit = showGitCommit;
53
62
  this.showGitDiff = showGitDiff;
63
+ this.showGitFileHistory = showGitFileHistory;
64
+ this.showGitFilePreview = showGitFilePreview;
65
+ this.showGitHistory = showGitHistory;
54
66
  this.syncMainChrome = syncMainChrome;
55
67
  this.videoEmbed = videoEmbed;
56
68
  this.workspaceCoordinator = workspaceCoordinator;
@@ -76,13 +88,39 @@ export class WorkspaceRouteController {
76
88
  return;
77
89
  }
78
90
 
91
+ if (route.type === 'git-file-history') {
92
+ this.setSidebarTab('files');
93
+ await this.showGitFileHistory(route);
94
+ return;
95
+ }
96
+
97
+ if (route.type === 'git-file-preview') {
98
+ this.setSidebarTab('files');
99
+ await this.showGitFilePreview(route);
100
+ return;
101
+ }
102
+
103
+ if (route.type === 'git-history') {
104
+ this.setSidebarTab('git');
105
+ await this.showGitHistory();
106
+ return;
107
+ }
108
+
109
+ if (route.type === 'git-commit') {
110
+ this.setSidebarTab('git');
111
+ await this.showGitCommit(route);
112
+ return;
113
+ }
114
+
79
115
  this.setSidebarTab('files');
80
116
  await this.openFile(route.filePath);
81
117
  }
82
118
 
83
119
  showEmptyState() {
84
120
  this.gitDiffView.hide();
121
+ this.fileHistoryView?.hide?.();
85
122
  this.workspaceCoordinator.cleanupSession();
123
+ this.clearStaticPreviewDocument?.();
86
124
  this.setSession(null);
87
125
  this.setSessionLoadToken(this.getSessionLoadToken() + 1);
88
126
  this.clearInitialFileBootstrap();
@@ -113,8 +151,10 @@ export class WorkspaceRouteController {
113
151
  }
114
152
 
115
153
  showDiffState() {
154
+ this.fileHistoryView?.hide?.();
116
155
  this.setSessionLoadToken(this.getSessionLoadToken() + 1);
117
156
  this.clearInitialFileBootstrap();
157
+ this.clearStaticPreviewDocument?.();
118
158
  this.workspaceCoordinator.cleanupSession();
119
159
  this.setSession(null);
120
160
  this.resetPreviewMode();
@@ -141,10 +181,74 @@ export class WorkspaceRouteController {
141
181
  this.backlinksPanel.clear();
142
182
  }
143
183
 
184
+ showFileHistoryState(filePath) {
185
+ this.gitDiffView.hide();
186
+ this.setSessionLoadToken(this.getSessionLoadToken() + 1);
187
+ this.clearInitialFileBootstrap();
188
+ this.clearStaticPreviewDocument?.();
189
+ this.workspaceCoordinator.cleanupSession();
190
+ this.setSession(null);
191
+ this.resetPreviewMode();
192
+ this.layoutController.reset();
193
+ this.setCurrentFilePath(filePath);
194
+ this.lobby.setCurrentFile(null);
195
+ this.fileExplorer.setActiveFile(filePath);
196
+
197
+ this.elements.emptyState?.classList.add('hidden');
198
+ this.elements.editorPage?.classList.add('hidden');
199
+ this.elements.diffPage?.classList.remove('hidden');
200
+ if (this.elements.previewContent) {
201
+ this.elements.previewContent.innerHTML = '';
202
+ this.elements.previewContent.dataset.renderPhase = 'ready';
203
+ }
204
+ this.videoEmbed?.reconcileEmbeds(this.elements.previewContent);
205
+ this.resetPreviewSurface();
206
+
207
+ this.elements.outlineToggle?.classList.add('hidden');
208
+ this.elements.markdownToolbar?.classList.add('hidden');
209
+
210
+ this.renderAvatars();
211
+ this.renderPresence();
212
+ this.backlinksPanel.clear();
213
+ }
214
+
215
+ showPreviewOnlyState(filePath) {
216
+ this.gitDiffView.hide();
217
+ this.fileHistoryView?.hide?.();
218
+ this.setSessionLoadToken(this.getSessionLoadToken() + 1);
219
+ this.clearInitialFileBootstrap();
220
+ this.clearStaticPreviewDocument?.();
221
+ this.workspaceCoordinator.cleanupSession();
222
+ this.setSession(null);
223
+ this.resetPreviewMode();
224
+ this.layoutController.reset();
225
+ this.setCurrentFilePath(filePath);
226
+ this.lobby.setCurrentFile(null);
227
+ this.fileExplorer.setActiveFile(filePath);
228
+
229
+ this.elements.emptyState?.classList.add('hidden');
230
+ this.elements.editorPage?.classList.remove('hidden');
231
+ this.elements.diffPage?.classList.add('hidden');
232
+ if (this.elements.previewContent) {
233
+ this.elements.previewContent.innerHTML = '';
234
+ this.elements.previewContent.dataset.renderPhase = 'ready';
235
+ }
236
+ this.videoEmbed?.reconcileEmbeds(this.elements.previewContent);
237
+ this.resetPreviewSurface();
238
+
239
+ this.elements.markdownToolbar?.classList.add('hidden');
240
+
241
+ this.renderAvatars();
242
+ this.renderPresence();
243
+ this.backlinksPanel.clear();
244
+ }
245
+
144
246
  async openFile(filePath) {
145
247
  this.imageLightbox?.close?.();
146
248
  this.gitPanel.setSelection();
147
249
  this.gitDiffView.hide();
250
+ this.fileHistoryView?.hide?.();
251
+ this.clearStaticPreviewDocument?.();
148
252
  this.syncMainChrome({ mode: 'editor' });
149
253
  await this.workspaceCoordinator.openFile(filePath);
150
254
  this.setSession(this.workspaceCoordinator.getSession());
@@ -23,6 +23,7 @@ import { BacklinksPanel } from '../presentation/backlinks-panel.js';
23
23
  import { CommentUiController } from '../presentation/comment-ui-controller.js';
24
24
  import { ExcalidrawEmbedController } from '../presentation/excalidraw-embed-controller.js';
25
25
  import { FileExplorerController } from '../presentation/file-explorer-controller.js';
26
+ import { FileHistoryViewController } from '../presentation/file-history-view-controller.js';
26
27
  import { GitDiffViewController } from '../presentation/git-diff-view-controller.js';
27
28
  import { GitPanelController } from '../presentation/git-panel-controller.js';
28
29
  import { LayoutController } from '../presentation/layout-controller.js';
@@ -103,6 +104,7 @@ export class CollabMdAppShell {
103
104
  this._previewHydrationPaused = false;
104
105
  this._previewLayoutResizeObserver = null;
105
106
  this._previewLayoutSyncTimer = null;
107
+ this._staticPreviewDocument = null;
106
108
  this.pendingGitResetPath = null;
107
109
  this.chatTimeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
108
110
  this.chatNotificationsEnabled = this.preferences.getChatNotificationsEnabled();
@@ -113,6 +115,7 @@ export class CollabMdAppShell {
113
115
  this.fileExplorerReadyPromise = Promise.resolve();
114
116
  this.mobileBreakpointQuery = window.matchMedia('(max-width: 768px)');
115
117
  this.pendingWorkspaceRequestIds = new Set();
118
+ this._fileOpenPerf = null;
116
119
 
117
120
  this.lobby = new LobbyPresence({
118
121
  preferredUserName: this.getStoredUserName(),
@@ -148,6 +151,7 @@ export class CollabMdAppShell {
148
151
  onPushBranch: () => this.pushGitBranch(),
149
152
  onRepoChange: (isGitRepo, status) => this.handleGitRepoChange(isGitRepo, status),
150
153
  onResetFile: (filePath, { scope }) => this.openGitResetDialog(filePath, { scope }),
154
+ onSelectCommit: (hash, { path }) => this.handleGitCommitSelection(hash, { closeSidebarOnMobile: true, path }),
151
155
  onSelectDiff: (filePath, { scope }) => this.handleGitDiffSelection(filePath, { closeSidebarOnMobile: true, scope }),
152
156
  onStageFile: (filePath, { scope }) => this.stageGitFile(filePath, { scope }),
153
157
  onUnstageFile: (filePath, { scope }) => this.unstageGitFile(filePath, { scope }),
@@ -175,6 +179,10 @@ export class CollabMdAppShell {
175
179
  getFileList: () => this.fileExplorer.flatDocumentFiles,
176
180
  getSourceFilePath: () => this.currentFilePath,
177
181
  onAfterRenderCommit: (_previewElement, stats) => {
182
+ this.recordFileOpenMetric('preview_committed', {
183
+ chars: stats?.chars ?? 0,
184
+ renderVersion: stats?.renderVersion ?? 0,
185
+ });
178
186
  this.videoEmbed.reconcileEmbeds(this.elements.previewContent);
179
187
  this.videoEmbed.syncLayout();
180
188
  this.excalidrawEmbed.reconcileEmbeds(this.elements.previewContent, { isLargeDocument: stats.isLargeDocument });
@@ -266,12 +274,35 @@ export class CollabMdAppShell {
266
274
  vaultApiClient: this.vaultApiClient,
267
275
  });
268
276
  this.gitDiffView = new GitDiffViewController({
277
+ onBackToHistory: ({ historyFilePath } = {}) => {
278
+ if (historyFilePath) {
279
+ this.navigation.navigateToGitFileHistory({ filePath: historyFilePath });
280
+ return;
281
+ }
282
+ this.navigation.navigateToGitHistory();
283
+ },
269
284
  onCommitStaged: () => this.openGitCommitDialog(),
270
285
  onOpenFile: (filePath) => filePath && this.navigation.navigateToFile(filePath),
271
286
  onStageFile: (filePath, { scope }) => this.stageGitFile(filePath, { scope }),
272
287
  onUnstageFile: (filePath, { scope }) => this.unstageGitFile(filePath, { scope }),
273
288
  toastController: this.toastController,
274
289
  });
290
+ this.fileHistoryView = new FileHistoryViewController({
291
+ diffRenderer: this.gitDiffView,
292
+ onOpenCommitDiff: (hash, { historyFilePath, path }) => this.handleGitCommitSelection(hash, {
293
+ closeSidebarOnMobile: false,
294
+ historyFilePath,
295
+ path,
296
+ }),
297
+ onOpenFile: (filePath) => filePath && this.navigation.navigateToFile(filePath),
298
+ onOpenPreview: ({ hash, path, currentFilePath }) => this.handleGitFilePreviewSelection({
299
+ hash,
300
+ path,
301
+ currentFilePath,
302
+ }),
303
+ onOpenWorkspaceDiff: (filePath) => this.handleGitDiffSelection(filePath, { closeSidebarOnMobile: false, scope: 'all' }),
304
+ toastController: this.toastController,
305
+ });
275
306
  this.tabActivityLock = new TabActivityLock({
276
307
  onActivated: ({ takeover }) => this.handleTabActivated({ takeover }),
277
308
  onBlocked: () => this.handleTabBlocked({ reason: 'active-elsewhere' }),
@@ -313,6 +344,10 @@ export class CollabMdAppShell {
313
344
  isMermaidFile: (filePath) => this.isMermaidFile(filePath),
314
345
  isPlantUmlFile: (filePath) => this.isPlantUmlFile(filePath),
315
346
  isTabActive: () => this.isTabActive,
347
+ loadBootstrapContent: async (filePath) => {
348
+ const response = await this.vaultApiClient.readFile(filePath);
349
+ return typeof response?.content === 'string' ? response.content : null;
350
+ },
316
351
  loadEditorSessionClass: () => this.loadEditorSessionClass(),
317
352
  loadBacklinks: (filePath) => this.backlinksPanel.load(filePath),
318
353
  onBeforeFileOpen: () => {
@@ -345,6 +380,7 @@ export class CollabMdAppShell {
345
380
  },
346
381
  onSelectionChange: (anchor) => this.handleCommentSelectionChange(anchor),
347
382
  onImagePaste: (file) => this.handleEditorImageInsert(file),
383
+ onFileOpenMetric: (name, payload) => this.recordFileOpenMetric(name, payload),
348
384
  onSessionAssigned: (session) => {
349
385
  this.session = session;
350
386
  this.commentUi.attachSession(session);
@@ -360,6 +396,7 @@ export class CollabMdAppShell {
360
396
  onUpdateVisibleChrome: (filePath, { displayName }) => {
361
397
  this.syncFileChrome(filePath);
362
398
  this.syncCommentChrome(filePath);
399
+ this.syncFileHistoryButton({ filePath, mode: 'editor' });
363
400
  if (this.elements.activeFileName) {
364
401
  this.elements.activeFileName.textContent = displayName;
365
402
  }
@@ -373,9 +410,11 @@ export class CollabMdAppShell {
373
410
  this.workspaceRouteController = new WorkspaceRouteController({
374
411
  backlinksPanel: this.backlinksPanel,
375
412
  clearInitialFileBootstrap: () => this.clearInitialFileBootstrap(),
413
+ clearStaticPreviewDocument: () => this.clearStaticPreviewDocument(),
376
414
  closeSidebarOnMobile: () => this.closeSidebarOnMobile(),
377
415
  elements: this.elements,
378
416
  excalidrawEmbed: this.excalidrawEmbed,
417
+ fileHistoryView: this.fileHistoryView,
379
418
  fileExplorer: this.fileExplorer,
380
419
  getIsTabActive: () => this.isTabActive,
381
420
  getSessionLoadToken: () => this.sessionLoadToken,
@@ -406,7 +445,11 @@ export class CollabMdAppShell {
406
445
  this.sessionLoadToken = value;
407
446
  },
408
447
  setSidebarTab: (value) => this.setSidebarTab(value),
448
+ showGitCommit: (route) => this.showGitCommit(route),
409
449
  showGitDiff: (route) => this.showGitDiff(route),
450
+ showGitFileHistory: (route) => this.showGitFileHistory(route),
451
+ showGitFilePreview: (route) => this.showGitFilePreview(route),
452
+ showGitHistory: () => this.showGitHistory(),
410
453
  syncMainChrome: (payload) => this.syncMainChrome(payload),
411
454
  videoEmbed: this.videoEmbed,
412
455
  workspaceCoordinator: this.workspaceCoordinator,
@@ -452,6 +495,58 @@ export class CollabMdAppShell {
452
495
  get followedCursorSignature() { return this.stateStore.get('followedCursorSignature'); }
453
496
  set followedCursorSignature(value) { this.stateStore.set('followedCursorSignature', value); }
454
497
 
498
+ publishFileOpenPerf() {
499
+ if (typeof window === 'undefined') {
500
+ return;
501
+ }
502
+
503
+ window.__COLLABMD_PERF__ ??= {};
504
+ window.__COLLABMD_PERF__.fileOpen = this._fileOpenPerf
505
+ ? {
506
+ ...this._fileOpenPerf,
507
+ details: { ...this._fileOpenPerf.details },
508
+ marks: { ...this._fileOpenPerf.marks },
509
+ }
510
+ : null;
511
+ }
512
+
513
+ recordFileOpenMetric(name, payload = {}) {
514
+ if (name === 'open_started') {
515
+ this._fileOpenPerf = {
516
+ details: {
517
+ filePath: payload.filePath || this.currentFilePath || '',
518
+ loadToken: payload.loadToken ?? 0,
519
+ },
520
+ marks: {
521
+ open_started: performance.now(),
522
+ },
523
+ };
524
+ this.publishFileOpenPerf();
525
+ return;
526
+ }
527
+
528
+ if (!this._fileOpenPerf) {
529
+ return;
530
+ }
531
+
532
+ const metricLoadToken = payload.loadToken ?? this._fileOpenPerf.details.loadToken;
533
+ if (metricLoadToken !== this._fileOpenPerf.details.loadToken) {
534
+ return;
535
+ }
536
+
537
+ this._fileOpenPerf.marks[name] = performance.now();
538
+ if (payload.filePath) {
539
+ this._fileOpenPerf.details.filePath = payload.filePath;
540
+ }
541
+ Object.entries(payload).forEach(([key, value]) => {
542
+ if (key === 'filePath' || key === 'loadToken') {
543
+ return;
544
+ }
545
+ this._fileOpenPerf.details[key] = value;
546
+ });
547
+ this.publishFileOpenPerf();
548
+ }
549
+
455
550
  loadEditorSessionClass() {
456
551
  if (!this._editorSessionModulePromise) {
457
552
  this._editorSessionModulePromise = import('../infrastructure/editor-session.js')
@@ -63,6 +63,8 @@ export function getClientRuntimeConfig() {
63
63
  auth: {
64
64
  enabled: false,
65
65
  implemented: true,
66
+ loginEndpoint: '/api/auth/oidc/login',
67
+ provider: '',
66
68
  requiresLogin: false,
67
69
  sessionEndpoint: '/api/auth/session',
68
70
  statusEndpoint: '/api/auth/status',
@@ -79,6 +81,8 @@ export function getClientRuntimeConfig() {
79
81
  const authConfig = {
80
82
  enabled: false,
81
83
  implemented: true,
84
+ loginEndpoint: '/api/auth/oidc/login',
85
+ provider: '',
82
86
  requiresLogin: false,
83
87
  sessionEndpoint: '/api/auth/session',
84
88
  statusEndpoint: '/api/auth/status',
@@ -90,6 +94,7 @@ export function getClientRuntimeConfig() {
90
94
  ...rawConfig,
91
95
  auth: {
92
96
  ...authConfig,
97
+ loginEndpoint: applyBasePath(basePath, authConfig.loginEndpoint),
93
98
  sessionEndpoint: applyBasePath(basePath, authConfig.sessionEndpoint),
94
99
  statusEndpoint: applyBasePath(basePath, authConfig.statusEndpoint),
95
100
  },