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.
Files changed (120) hide show
  1. package/README.md +98 -9
  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/chunk-GFDM7YCF.js +1 -0
  8. package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
  9. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  10. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  11. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +503 -182
  14. package/public/assets/js/preview-render-worker.js +15 -15
  15. package/public/index.html +104 -84
  16. package/src/client/application/app-shell/git-feature.js +304 -27
  17. package/src/client/application/app-shell/ui-feature.js +69 -7
  18. package/src/client/application/app-shell/workspace-feature.js +46 -0
  19. package/src/client/application/app-shell-elements.js +2 -0
  20. package/src/client/application/mermaid-preview-hydrator.js +1 -1
  21. package/src/client/application/plantuml-preview-hydrator.js +1 -1
  22. package/src/client/application/workspace-coordinator.js +147 -18
  23. package/src/client/application/workspace-preview-controller.js +34 -0
  24. package/src/client/application/workspace-route-controller.js +104 -0
  25. package/src/client/bootstrap/collabmd-app-shell.js +107 -0
  26. package/src/client/domain/runtime-paths.js +5 -0
  27. package/src/client/domain/vault-utils.js +2 -2
  28. package/src/client/excalidraw-editor.js +52 -3
  29. package/src/client/infrastructure/auth-client.js +219 -52
  30. package/src/client/infrastructure/browser-navigation-port.js +20 -0
  31. package/src/client/infrastructure/editor-collaboration-client.js +4 -0
  32. package/src/client/infrastructure/editor-session.js +51 -7
  33. package/src/client/infrastructure/editor-view-adapter.js +297 -53
  34. package/src/client/infrastructure/runtime-config.js +66 -0
  35. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  36. package/src/client/presentation/comment-ui-controller.js +274 -31
  37. package/src/client/presentation/comments-panel.js +2 -2
  38. package/src/client/presentation/excalidraw-embed-controller.js +60 -2
  39. package/src/client/presentation/file-explorer-controller.js +15 -3
  40. package/src/client/presentation/file-explorer-view.js +152 -11
  41. package/src/client/presentation/file-history-view-controller.js +806 -0
  42. package/src/client/presentation/git-diff-view-controller.js +718 -121
  43. package/src/client/presentation/git-panel-controller.js +458 -66
  44. package/src/client/presentation/layout-controller.js +1 -1
  45. package/src/client/styles/components/actions.css +89 -0
  46. package/src/client/styles/components/feedback.css +43 -0
  47. package/src/client/styles/components/navigation.css +68 -0
  48. package/src/client/styles/components/scrollbars.css +104 -0
  49. package/src/client/styles/components/states.css +289 -0
  50. package/src/client/styles/components/surfaces.css +155 -0
  51. package/src/client/styles/features/backlinks.css +213 -0
  52. package/src/client/styles/features/collaboration-chat.css +228 -0
  53. package/src/client/styles/features/collaboration-dialogs.css +154 -0
  54. package/src/client/styles/features/collaboration-presence.css +103 -0
  55. package/src/client/styles/features/collaboration-ui.css +3 -0
  56. package/src/client/styles/features/comment-card.css +236 -0
  57. package/src/client/styles/features/comment-markdown.css +127 -0
  58. package/src/client/styles/features/comment-overlays.css +60 -0
  59. package/src/client/styles/features/comments-drawer.css +120 -0
  60. package/src/client/styles/features/comments.css +4 -0
  61. package/src/client/styles/features/diagram-preview.css +227 -0
  62. package/src/client/styles/features/diff-commit.css +268 -0
  63. package/src/client/styles/features/diff-lines.css +160 -0
  64. package/src/client/styles/features/diff-toolbar.css +98 -0
  65. package/src/client/styles/features/diff.css +3 -0
  66. package/src/client/styles/features/embedded-preview.css +218 -0
  67. package/src/client/styles/features/git-branch.css +155 -0
  68. package/src/client/styles/features/git-files.css +105 -0
  69. package/src/client/styles/features/git-history.css +339 -0
  70. package/src/client/styles/features/git.css +3 -0
  71. package/src/client/styles/features/media-lightbox.css +157 -0
  72. package/src/client/styles/features/outline.css +115 -0
  73. package/src/client/styles/features/preview-content.css +3 -0
  74. package/src/client/styles/features/preview-markdown.css +201 -0
  75. package/src/client/styles/features/preview-sidebars.css +2 -0
  76. package/src/client/styles/features/quick-switcher.css +167 -0
  77. package/src/client/styles/foundation/themes.css +347 -0
  78. package/src/client/styles/foundation/tokens.css +66 -0
  79. package/src/client/styles/foundation/utilities.css +36 -0
  80. package/src/client/styles/layout/editor-page.css +203 -0
  81. package/src/client/styles/layout/overlays.css +132 -0
  82. package/src/client/styles/layout/responsive.css +251 -0
  83. package/src/client/styles/layout/shell.css +314 -0
  84. package/src/client/styles/layout/sidebar.css +176 -0
  85. package/src/client/styles/layout/view-modes.css +92 -0
  86. package/src/client/styles/overrides/codemirror.css +53 -0
  87. package/src/client/styles/overrides/highlightjs.css +7 -0
  88. package/src/client/styles/overrides/vendor.css +2 -0
  89. package/src/client/styles/primitives/controls.css +276 -0
  90. package/src/client/styles/style.css +25 -4901
  91. package/src/domain/wiki-link-resolver.js +16 -5
  92. package/src/domain/workspace-change.js +68 -0
  93. package/src/domain/workspace-room.js +3 -0
  94. package/src/server/auth/create-auth-service.js +373 -15
  95. package/src/server/auth/session-cookie.js +30 -4
  96. package/src/server/config/env.js +121 -5
  97. package/src/server/create-app-server.js +49 -10
  98. package/src/server/domain/backlink-index.js +94 -1
  99. package/src/server/domain/collaboration/collaboration-room.js +281 -69
  100. package/src/server/domain/collaboration/room-registry.js +64 -10
  101. package/src/server/index.js +3 -1
  102. package/src/server/infrastructure/git/command-runner.js +2 -1
  103. package/src/server/infrastructure/git/git-service.js +48 -2
  104. package/src/server/infrastructure/git/history-service.js +683 -0
  105. package/src/server/infrastructure/git/parsers.js +30 -0
  106. package/src/server/infrastructure/git/responses.js +85 -19
  107. package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
  108. package/src/server/infrastructure/http/create-git-api-command-handler.js +55 -43
  109. package/src/server/infrastructure/http/create-git-api-handler.js +4 -0
  110. package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
  111. package/src/server/infrastructure/http/create-request-handler.js +22 -0
  112. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  113. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  114. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  115. package/src/server/infrastructure/http/http-response.js +65 -28
  116. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  117. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  118. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  119. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  120. package/public/assets/js/chunks/editor-session-TAV2VXTA.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());
@@ -18,10 +18,12 @@ import { BrowserPreferencesPort } from '../infrastructure/browser-preferences-po
18
18
  import { getRuntimeConfig } from '../infrastructure/runtime-config.js';
19
19
  import { TabActivityLock } from '../infrastructure/tab-activity-lock.js';
20
20
  import { vaultApiClient } from '../infrastructure/vault-api-client.js';
21
+ import { WorkspaceSyncClient } from '../infrastructure/workspace-sync-client.js';
21
22
  import { BacklinksPanel } from '../presentation/backlinks-panel.js';
22
23
  import { CommentUiController } from '../presentation/comment-ui-controller.js';
23
24
  import { ExcalidrawEmbedController } from '../presentation/excalidraw-embed-controller.js';
24
25
  import { FileExplorerController } from '../presentation/file-explorer-controller.js';
26
+ import { FileHistoryViewController } from '../presentation/file-history-view-controller.js';
25
27
  import { GitDiffViewController } from '../presentation/git-diff-view-controller.js';
26
28
  import { GitPanelController } from '../presentation/git-panel-controller.js';
27
29
  import { LayoutController } from '../presentation/layout-controller.js';
@@ -102,6 +104,7 @@ export class CollabMdAppShell {
102
104
  this._previewHydrationPaused = false;
103
105
  this._previewLayoutResizeObserver = null;
104
106
  this._previewLayoutSyncTimer = null;
107
+ this._staticPreviewDocument = null;
105
108
  this.pendingGitResetPath = null;
106
109
  this.chatTimeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
107
110
  this.chatNotificationsEnabled = this.preferences.getChatNotificationsEnabled();
@@ -111,11 +114,23 @@ export class CollabMdAppShell {
111
114
  this.quickSwitcherModulePromise = null;
112
115
  this.fileExplorerReadyPromise = Promise.resolve();
113
116
  this.mobileBreakpointQuery = window.matchMedia('(max-width: 768px)');
117
+ this.pendingWorkspaceRequestIds = new Set();
118
+ this._fileOpenPerf = null;
114
119
 
115
120
  this.lobby = new LobbyPresence({
116
121
  preferredUserName: this.getStoredUserName(),
117
122
  onChange: (users) => this.updateGlobalUsers(users),
118
123
  onChatChange: (messages, meta) => this.updateChatMessages(messages, meta),
124
+ });
125
+ this.workspaceSync = new WorkspaceSyncClient({
126
+ onTreeChange: (tree, metadata = {}) => {
127
+ const wasReady = this.fileExplorerReady;
128
+ this.fileExplorer.setTree(tree, metadata);
129
+ this.fileExplorerReady = true;
130
+ if (!wasReady && this.isTabActive) {
131
+ void this.handleHashChange();
132
+ }
133
+ },
119
134
  onWorkspaceEvent: (event) => {
120
135
  void this.handleIncomingWorkspaceEvent(event);
121
136
  },
@@ -136,6 +151,7 @@ export class CollabMdAppShell {
136
151
  onPushBranch: () => this.pushGitBranch(),
137
152
  onRepoChange: (isGitRepo, status) => this.handleGitRepoChange(isGitRepo, status),
138
153
  onResetFile: (filePath, { scope }) => this.openGitResetDialog(filePath, { scope }),
154
+ onSelectCommit: (hash, { path }) => this.handleGitCommitSelection(hash, { closeSidebarOnMobile: true, path }),
139
155
  onSelectDiff: (filePath, { scope }) => this.handleGitDiffSelection(filePath, { closeSidebarOnMobile: true, scope }),
140
156
  onStageFile: (filePath, { scope }) => this.stageGitFile(filePath, { scope }),
141
157
  onUnstageFile: (filePath, { scope }) => this.unstageGitFile(filePath, { scope }),
@@ -163,6 +179,10 @@ export class CollabMdAppShell {
163
179
  getFileList: () => this.fileExplorer.flatDocumentFiles,
164
180
  getSourceFilePath: () => this.currentFilePath,
165
181
  onAfterRenderCommit: (_previewElement, stats) => {
182
+ this.recordFileOpenMetric('preview_committed', {
183
+ chars: stats?.chars ?? 0,
184
+ renderVersion: stats?.renderVersion ?? 0,
185
+ });
166
186
  this.videoEmbed.reconcileEmbeds(this.elements.previewContent);
167
187
  this.videoEmbed.syncLayout();
168
188
  this.excalidrawEmbed.reconcileEmbeds(this.elements.previewContent, { isLargeDocument: stats.isLargeDocument });
@@ -254,12 +274,35 @@ export class CollabMdAppShell {
254
274
  vaultApiClient: this.vaultApiClient,
255
275
  });
256
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
+ },
257
284
  onCommitStaged: () => this.openGitCommitDialog(),
258
285
  onOpenFile: (filePath) => filePath && this.navigation.navigateToFile(filePath),
259
286
  onStageFile: (filePath, { scope }) => this.stageGitFile(filePath, { scope }),
260
287
  onUnstageFile: (filePath, { scope }) => this.unstageGitFile(filePath, { scope }),
261
288
  toastController: this.toastController,
262
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
+ });
263
306
  this.tabActivityLock = new TabActivityLock({
264
307
  onActivated: ({ takeover }) => this.handleTabActivated({ takeover }),
265
308
  onBlocked: () => this.handleTabBlocked({ reason: 'active-elsewhere' }),
@@ -301,6 +344,10 @@ export class CollabMdAppShell {
301
344
  isMermaidFile: (filePath) => this.isMermaidFile(filePath),
302
345
  isPlantUmlFile: (filePath) => this.isPlantUmlFile(filePath),
303
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
+ },
304
351
  loadEditorSessionClass: () => this.loadEditorSessionClass(),
305
352
  loadBacklinks: (filePath) => this.backlinksPanel.load(filePath),
306
353
  onBeforeFileOpen: () => {
@@ -333,6 +380,7 @@ export class CollabMdAppShell {
333
380
  },
334
381
  onSelectionChange: (anchor) => this.handleCommentSelectionChange(anchor),
335
382
  onImagePaste: (file) => this.handleEditorImageInsert(file),
383
+ onFileOpenMetric: (name, payload) => this.recordFileOpenMetric(name, payload),
336
384
  onSessionAssigned: (session) => {
337
385
  this.session = session;
338
386
  this.commentUi.attachSession(session);
@@ -348,6 +396,7 @@ export class CollabMdAppShell {
348
396
  onUpdateVisibleChrome: (filePath, { displayName }) => {
349
397
  this.syncFileChrome(filePath);
350
398
  this.syncCommentChrome(filePath);
399
+ this.syncFileHistoryButton({ filePath, mode: 'editor' });
351
400
  if (this.elements.activeFileName) {
352
401
  this.elements.activeFileName.textContent = displayName;
353
402
  }
@@ -361,9 +410,11 @@ export class CollabMdAppShell {
361
410
  this.workspaceRouteController = new WorkspaceRouteController({
362
411
  backlinksPanel: this.backlinksPanel,
363
412
  clearInitialFileBootstrap: () => this.clearInitialFileBootstrap(),
413
+ clearStaticPreviewDocument: () => this.clearStaticPreviewDocument(),
364
414
  closeSidebarOnMobile: () => this.closeSidebarOnMobile(),
365
415
  elements: this.elements,
366
416
  excalidrawEmbed: this.excalidrawEmbed,
417
+ fileHistoryView: this.fileHistoryView,
367
418
  fileExplorer: this.fileExplorer,
368
419
  getIsTabActive: () => this.isTabActive,
369
420
  getSessionLoadToken: () => this.sessionLoadToken,
@@ -394,7 +445,11 @@ export class CollabMdAppShell {
394
445
  this.sessionLoadToken = value;
395
446
  },
396
447
  setSidebarTab: (value) => this.setSidebarTab(value),
448
+ showGitCommit: (route) => this.showGitCommit(route),
397
449
  showGitDiff: (route) => this.showGitDiff(route),
450
+ showGitFileHistory: (route) => this.showGitFileHistory(route),
451
+ showGitFilePreview: (route) => this.showGitFilePreview(route),
452
+ showGitHistory: () => this.showGitHistory(),
398
453
  syncMainChrome: (payload) => this.syncMainChrome(payload),
399
454
  videoEmbed: this.videoEmbed,
400
455
  workspaceCoordinator: this.workspaceCoordinator,
@@ -440,6 +495,58 @@ export class CollabMdAppShell {
440
495
  get followedCursorSignature() { return this.stateStore.get('followedCursorSignature'); }
441
496
  set followedCursorSignature(value) { this.stateStore.set('followedCursorSignature', value); }
442
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
+
443
550
  loadEditorSessionClass() {
444
551
  if (!this._editorSessionModulePromise) {
445
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
  },
@@ -25,8 +25,8 @@ export function escapeHtml(text) {
25
25
  *
26
26
  * Matching rules (in order):
27
27
  * 1. Exact path match (with .md appended if missing)
28
- * 2. Filename match at any directory depth
29
- * 3. Path without .md extension matches the target
28
+ * 2. Exact path without .md extension matches the target
29
+ * 3. Filename/path suffix match at any directory depth
30
30
  *
31
31
  * @param {string} target — the raw wiki-link target text
32
32
  * @param {string[]} files — list of vault file paths