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,14 +1,16 @@
1
+ import { createWorkspaceChange } from '../../../domain/workspace-change.js';
1
2
  import { resolveApiUrl } from '../../domain/runtime-paths.js';
2
3
 
3
4
  function normalizeWorkspaceChange(workspaceChange = {}) {
4
- return {
5
- changedPaths: Array.isArray(workspaceChange.changedPaths) ? workspaceChange.changedPaths.filter(Boolean) : [],
6
- deletedPaths: Array.isArray(workspaceChange.deletedPaths) ? workspaceChange.deletedPaths.filter(Boolean) : [],
7
- refreshExplorer: workspaceChange.refreshExplorer !== false,
8
- renamedPaths: Array.isArray(workspaceChange.renamedPaths)
9
- ? workspaceChange.renamedPaths.filter((entry) => entry?.oldPath && entry?.newPath)
10
- : [],
11
- };
5
+ return createWorkspaceChange(workspaceChange);
6
+ }
7
+
8
+ function createWorkspaceRequestId() {
9
+ if (globalThis.crypto?.randomUUID) {
10
+ return globalThis.crypto.randomUUID();
11
+ }
12
+
13
+ return `workspace-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
12
14
  }
13
15
 
14
16
  export const gitFeature = {
@@ -51,6 +53,46 @@ export const gitFeature = {
51
53
  this.navigation.navigateToGitDiff({ filePath, scope });
52
54
  },
53
55
 
56
+ handleGitCommitSelection(hash, { closeSidebarOnMobile = false, historyFilePath = null, path = null } = {}) {
57
+ if (closeSidebarOnMobile) {
58
+ this.closeSidebarOnMobile();
59
+ }
60
+
61
+ this.navigation.navigateToGitCommit({ hash, historyFilePath, path });
62
+ },
63
+
64
+ handleGitHistorySelection({ closeSidebarOnMobile = false } = {}) {
65
+ if (closeSidebarOnMobile) {
66
+ this.closeSidebarOnMobile();
67
+ }
68
+
69
+ this.navigation.navigateToGitHistory();
70
+ },
71
+
72
+ handleGitFileHistorySelection(filePath = this.currentFilePath, { closeSidebarOnMobile = false } = {}) {
73
+ if (closeSidebarOnMobile) {
74
+ this.closeSidebarOnMobile();
75
+ }
76
+
77
+ if (!filePath) {
78
+ return;
79
+ }
80
+
81
+ this.navigation.navigateToGitFileHistory({ filePath });
82
+ },
83
+
84
+ handleGitFilePreviewSelection({ hash, path, currentFilePath = null, closeSidebarOnMobile = false } = {}) {
85
+ if (closeSidebarOnMobile) {
86
+ this.closeSidebarOnMobile();
87
+ }
88
+
89
+ if (!hash || !path) {
90
+ return;
91
+ }
92
+
93
+ this.navigation.navigateToGitFilePreview({ hash, path, currentFilePath });
94
+ },
95
+
54
96
  handleGitRepoChange(isGitRepo, status = null) {
55
97
  this.gitRepoAvailable = Boolean(isGitRepo);
56
98
  this.elements.sidebarTabs?.classList.toggle('hidden', !this.gitRepoAvailable);
@@ -62,47 +104,225 @@ export const gitFeature = {
62
104
  this.gitDiffView.setRepoStatus(this.gitRepoAvailable ? status : null);
63
105
 
64
106
  if (!this.gitRepoAvailable && this.activeSidebarTab === 'git') {
107
+ this.syncFileHistoryButton({ mode: this.navigation.getHashRoute().type === 'git-file-preview' ? 'history-preview' : 'editor' });
65
108
  this.setSidebarTab('files');
66
109
  return;
67
110
  }
68
111
 
69
- if (this.gitRepoAvailable && this.navigation.getHashRoute().type === 'git-diff' && this.activeSidebarTab !== 'git') {
112
+ const routeType = this.navigation.getHashRoute().type;
113
+ if (
114
+ this.gitRepoAvailable
115
+ && (routeType === 'git-diff' || routeType === 'git-commit' || routeType === 'git-history')
116
+ && this.activeSidebarTab !== 'git'
117
+ ) {
70
118
  this.setSidebarTab('git');
71
119
  }
120
+
121
+ this.syncFileHistoryButton({
122
+ filePath: this.currentFilePath,
123
+ mode: routeType === 'git-file-preview'
124
+ ? 'history-preview'
125
+ : routeType === 'file'
126
+ ? 'editor'
127
+ : 'empty',
128
+ });
72
129
  },
73
130
 
74
- syncMainChrome({ mode, title = null } = {}) {
75
- const isDiffMode = mode === 'diff';
76
- this.elements.toolbarCenter?.classList.toggle('hidden', isDiffMode);
77
- this.elements.mobileViewToggle?.classList.toggle('hidden', isDiffMode);
78
- this.elements.userCount?.classList.toggle('hidden', isDiffMode);
79
- this.elements.toolbarDiffBadge?.classList.toggle('hidden', !isDiffMode);
131
+ syncFileHistoryButton({ filePath = this.currentFilePath, mode = 'editor' } = {}) {
132
+ const button = this.elements.fileHistoryButton;
133
+ const label = this.elements.fileHistoryButtonLabel;
134
+ if (!button || !label) {
135
+ return;
136
+ }
137
+
138
+ const supported = this.gitRepoAvailable && this.supportsFileHistory?.(filePath);
139
+ if (!supported) {
140
+ button.classList.add('hidden');
141
+ return;
142
+ }
143
+
144
+ button.classList.remove('hidden');
145
+ if (mode === 'history-preview') {
146
+ label.textContent = 'Back to History';
147
+ button.setAttribute('aria-label', 'Back to file history');
148
+ button.title = 'Back to file history';
149
+ return;
150
+ }
151
+
152
+ if (mode !== 'editor') {
153
+ button.classList.add('hidden');
154
+ return;
155
+ }
156
+
157
+ label.textContent = 'History';
158
+ button.setAttribute('aria-label', 'Open file history');
159
+ button.title = 'View file history';
160
+ },
161
+
162
+ syncMainChrome({ badgeLabel = '', mode, title = null } = {}) {
163
+ const isSpecialMode = mode === 'diff' || mode === 'history' || mode === 'history-preview';
164
+ this.elements.toolbarCenter?.classList.toggle('hidden', isSpecialMode);
165
+ this.elements.mobileViewToggle?.classList.toggle('hidden', isSpecialMode);
166
+ this.elements.userCount?.classList.toggle('hidden', isSpecialMode);
167
+ this.elements.toolbarDiffBadge?.classList.toggle('hidden', !badgeLabel);
168
+ if (this.elements.toolbarDiffBadge) {
169
+ this.elements.toolbarDiffBadge.textContent = badgeLabel;
170
+ }
80
171
 
81
172
  if (title && this.elements.activeFileName) {
82
173
  this.elements.activeFileName.textContent = title;
83
174
  }
175
+
176
+ this.syncFileHistoryButton({ filePath: this.currentFilePath, mode });
84
177
  },
85
178
 
86
179
  async showGitDiff({ filePath = null, scope = 'all' } = {}) {
87
- this.gitPanel.setSelection(filePath ? { path: filePath, scope } : {});
180
+ this.clearStaticPreviewDocument?.();
181
+ this.gitPanel.setSelection(filePath ? { path: filePath, scope, source: 'workspace' } : {});
182
+ this.gitPanel.setMode('changes');
88
183
  this.showDiffState();
89
184
  this.syncMainChrome({
185
+ badgeLabel: 'Diff',
90
186
  mode: 'diff',
91
187
  title: this.gitDiffView.getToolbarTitle({ filePath, scope }),
92
188
  });
93
- await this.gitDiffView.open({ filePath, scope });
189
+ this.syncFileHistoryButton({ filePath, mode: 'diff' });
190
+ await this.gitDiffView.openWorkspaceDiff({ filePath, scope });
191
+ },
192
+
193
+ async showGitCommit({ hash, path = null, historyFilePath = null } = {}) {
194
+ this.clearStaticPreviewDocument?.();
195
+ this.gitPanel.setMode('history');
196
+ this.gitPanel.setSelection(hash ? { commitHash: hash, path, source: 'commit' } : { source: 'commit' });
197
+ this.showDiffState();
198
+ this.syncMainChrome({
199
+ badgeLabel: 'Diff',
200
+ mode: 'diff',
201
+ title: this.gitDiffView.getToolbarTitle({ commitHash: hash, path, source: 'commit' }),
202
+ });
203
+ this.syncFileHistoryButton({ filePath: path, mode: 'diff' });
204
+ await this.gitDiffView.openCommitDiff({ hash, historyFilePath, path });
205
+ },
206
+
207
+ async showGitHistory() {
208
+ this.clearStaticPreviewDocument?.();
209
+ this.gitPanel.setMode('history');
210
+ this.gitPanel.setSelection({ source: 'commit' });
211
+ this.workspaceRouteController?.showEmptyState?.();
212
+ this.syncMainChrome({
213
+ mode: 'empty',
214
+ title: 'Git History',
215
+ });
216
+ this.syncFileHistoryButton({ mode: 'empty' });
217
+ },
218
+
219
+ async showGitFileHistory({ filePath = null } = {}) {
220
+ if (!filePath) {
221
+ this.workspaceRouteController?.showEmptyState?.();
222
+ return;
223
+ }
224
+
225
+ this.clearStaticPreviewDocument?.();
226
+ this.commentUi?.attachSession?.(null);
227
+ this.commentUi?.setCurrentFile?.(filePath, {
228
+ fileKind: this.getCommentFileKind(filePath),
229
+ supported: false,
230
+ });
231
+ this.handleCommentThreadsChange?.([]);
232
+ this.handleCommentSelectionChange?.(null);
233
+ this.workspaceRouteController?.showFileHistoryState?.(filePath);
234
+ this.syncMainChrome({
235
+ badgeLabel: 'History',
236
+ mode: 'history',
237
+ title: this.getDisplayName(filePath),
238
+ });
239
+ this.syncFileHistoryButton({ filePath, mode: 'history' });
240
+ await this.fileHistoryView.openFileHistory({ filePath });
241
+ },
242
+
243
+ async showGitFilePreview({ hash, filePath = null, currentFilePath = null } = {}) {
244
+ const resolvedCurrentFilePath = String(currentFilePath ?? '').trim() || filePath;
245
+ if (!hash || !filePath || !resolvedCurrentFilePath) {
246
+ this.workspaceRouteController?.showEmptyState?.();
247
+ return;
248
+ }
249
+
250
+ this.commentUi?.attachSession?.(null);
251
+ this.commentUi?.setCurrentFile?.(resolvedCurrentFilePath, {
252
+ fileKind: this.getCommentFileKind(resolvedCurrentFilePath),
253
+ supported: false,
254
+ });
255
+ this.handleCommentThreadsChange?.([]);
256
+ this.handleCommentSelectionChange?.(null);
257
+ this.workspaceRouteController?.showPreviewOnlyState?.(resolvedCurrentFilePath);
258
+ this.layoutController.setView('preview', { persist: false });
259
+ this.syncMainChrome({
260
+ badgeLabel: 'History Preview',
261
+ mode: 'history-preview',
262
+ title: this.getDisplayName(resolvedCurrentFilePath),
263
+ });
264
+ this.syncFileHistoryButton({ filePath: resolvedCurrentFilePath, mode: 'history-preview' });
265
+
266
+ try {
267
+ const query = new URLSearchParams();
268
+ query.set('hash', hash);
269
+ query.set('path', filePath);
270
+ const response = await fetch(resolveApiUrl(`/git/file-snapshot?${query.toString()}`));
271
+ const data = await response.json();
272
+ if (!response.ok) {
273
+ throw new Error(data.error || 'Failed to load historical file preview');
274
+ }
275
+
276
+ this.setStaticPreviewDocument?.({
277
+ ...data,
278
+ currentFilePath: resolvedCurrentFilePath,
279
+ });
280
+ this.resetPreviewMode();
281
+ this.elements.markdownToolbar?.classList.add('hidden');
282
+ this.elements.outlineToggle?.classList.toggle('hidden', data.fileKind !== 'markdown');
283
+
284
+ if (data.fileKind === 'markdown' || data.fileKind === 'mermaid' || data.fileKind === 'plantuml') {
285
+ if (data.fileKind === 'mermaid') {
286
+ this.elements.previewContent?.classList.add('is-mermaid-file-preview');
287
+ } else if (data.fileKind === 'plantuml') {
288
+ this.elements.previewContent?.classList.add('is-plantuml-file-preview');
289
+ }
290
+ this.previewRenderer.beginDocumentLoad();
291
+ this.previewRenderer.queueRender();
292
+ return;
293
+ }
294
+
295
+ this.elements.outlineToggle?.classList.add('hidden');
296
+ this.renderTextFilePreview({
297
+ content: data.content,
298
+ filePath: resolvedCurrentFilePath,
299
+ });
300
+ } catch (error) {
301
+ console.error('[git-preview] Failed to load file snapshot:', error);
302
+ this.clearStaticPreviewDocument?.();
303
+ this.toastController.show('Failed to load historical file preview');
304
+ this.renderTextFilePreview({
305
+ content: 'Failed to load historical file preview.',
306
+ filePath: resolvedCurrentFilePath,
307
+ });
308
+ this.elements.outlineToggle?.classList.add('hidden');
309
+ }
94
310
  },
95
311
 
96
312
  async postGitAction(endpoint, payload) {
313
+ const requestId = createWorkspaceRequestId();
314
+ this.pendingWorkspaceRequestIds?.add(requestId);
97
315
  const response = await fetch(endpoint, {
98
316
  body: JSON.stringify(payload),
99
317
  headers: {
100
318
  'Content-Type': 'application/json',
319
+ 'X-CollabMD-Request-Id': requestId,
101
320
  },
102
321
  method: 'POST',
103
322
  });
104
323
  const data = await response.json();
105
324
  if (!response.ok) {
325
+ this.pendingWorkspaceRequestIds?.delete(requestId);
106
326
  const error = new Error(data.error || 'Git action failed');
107
327
  if (typeof data?.code === 'string') {
108
328
  error.code = data.code;
@@ -121,6 +341,30 @@ export const gitFeature = {
121
341
  await this.gitPanel.refresh({ force: true });
122
342
 
123
343
  const route = this.navigation.getHashRoute();
344
+ if (route.type === 'git-history') {
345
+ await this.showGitHistory();
346
+ return;
347
+ }
348
+
349
+ if (route.type === 'git-file-history') {
350
+ await this.showGitFileHistory({ filePath: route.filePath });
351
+ return;
352
+ }
353
+
354
+ if (route.type === 'git-file-preview') {
355
+ await this.showGitFilePreview({
356
+ hash: route.hash,
357
+ filePath: route.filePath,
358
+ currentFilePath: route.currentFilePath,
359
+ });
360
+ return;
361
+ }
362
+
363
+ if (route.type === 'git-commit') {
364
+ await this.showGitCommit({ hash: route.hash, path: route.path, historyFilePath: route.historyFilePath });
365
+ return;
366
+ }
367
+
124
368
  if (route.type !== 'git-diff') {
125
369
  return;
126
370
  }
@@ -145,11 +389,6 @@ export const gitFeature = {
145
389
  const workspaceChange = normalizeWorkspaceChange(result.workspaceChange);
146
390
  await this.refreshWorkspaceAfterGitAction({ filePath, preferredScope });
147
391
  this.handleWorkspaceChangeForCurrentFile(workspaceChange, { action, local: true, showToast: showLocalFileToast });
148
- this.lobby.sendWorkspaceEvent({
149
- action,
150
- sourceRef: result.sourceRef ?? null,
151
- workspaceChange,
152
- });
153
392
  return workspaceChange;
154
393
  },
155
394
 
@@ -165,21 +404,27 @@ export const gitFeature = {
165
404
  return false;
166
405
  }
167
406
 
168
- this.navigation.navigateToFile(null);
169
407
  if (!showToast) {
408
+ if (renameEntry) {
409
+ this.navigation.navigateToFile(renameEntry.newPath);
410
+ } else {
411
+ this.navigation.navigateToFile(null);
412
+ }
170
413
  return true;
171
414
  }
172
415
 
173
416
  const displayName = this.getDisplayName(currentFilePath);
174
417
  if (renameEntry) {
418
+ this.navigation.navigateToFile(renameEntry.newPath);
175
419
  this.toastController.show(
176
420
  local
177
- ? `${displayName} was reset away from this branch path`
178
- : `${displayName} moved after a ${action} operation`,
421
+ ? `${displayName} moved to ${this.getDisplayName(renameEntry.newPath)}`
422
+ : `${displayName} moved on disk`,
179
423
  );
180
424
  return true;
181
425
  }
182
426
 
427
+ this.navigation.navigateToFile(null);
183
428
  this.toastController.show(
184
429
  local
185
430
  ? `${displayName} was removed by ${action}`
@@ -193,13 +438,45 @@ export const gitFeature = {
193
438
  return;
194
439
  }
195
440
 
441
+ if (event.requestId && this.pendingWorkspaceRequestIds?.has(event.requestId)) {
442
+ this.pendingWorkspaceRequestIds.delete(event.requestId);
443
+ return;
444
+ }
445
+
196
446
  const workspaceChange = normalizeWorkspaceChange(event.workspaceChange);
197
- await this.refreshWorkspaceAfterGitAction();
447
+ if (event.origin === 'git') {
448
+ await this.refreshGitAfterAction();
449
+ }
198
450
  this.handleWorkspaceChangeForCurrentFile(workspaceChange, {
199
- action: event.action || 'git',
451
+ action: event.action || event.origin || 'workspace',
200
452
  local: false,
201
453
  showToast: true,
202
454
  });
455
+
456
+ if (
457
+ event.origin === 'filesystem'
458
+ && this.currentFilePath
459
+ && workspaceChange.changedPaths.includes(this.currentFilePath)
460
+ ) {
461
+ const canUseInlineCue = workspaceChange.changedPaths.length === 1;
462
+ const highlightRange = canUseInlineCue && Array.isArray(event.highlightRanges)
463
+ ? event.highlightRanges.find((entry) => entry.path === this.currentFilePath)
464
+ : null;
465
+ const didFlash = highlightRange
466
+ ? this.session?.flashExternalUpdate?.(highlightRange)
467
+ : false;
468
+ if (!didFlash) {
469
+ this.toastController.show(`${this.getDisplayName(this.currentFilePath)} updated from disk`);
470
+ }
471
+ }
472
+
473
+ if (
474
+ this.currentFilePath
475
+ && Array.isArray(event.reloadRequiredPaths)
476
+ && event.reloadRequiredPaths.includes(this.currentFilePath)
477
+ ) {
478
+ this.toastController.show(`${this.getDisplayName(this.currentFilePath)} needs a manual reload`);
479
+ }
203
480
  },
204
481
 
205
482
  async stageGitFile(filePath, { scope = 'working-tree' } = {}) {
@@ -15,10 +15,13 @@ export const uiFeature = {
15
15
  this.fileExplorer.initialize();
16
16
  this.gitPanel.initialize();
17
17
  this.gitDiffView.initialize();
18
+ this.fileHistoryView.initialize();
18
19
  this.initializePreviewLayoutObserver();
20
+ this.syncIdentityManagementUi();
19
21
  this.syncCurrentUserName();
20
22
  this.syncWrapToggle();
21
23
  this.syncChatNotificationButton();
24
+ this.syncFileHistoryButton({ mode: 'empty' });
22
25
  this.renderChat();
23
26
  void this.gitPanel.refresh({ force: true });
24
27
  this.elements.chatInput?.setAttribute('maxlength', String(this.lobbyChatMessageMaxLength));
@@ -39,6 +42,11 @@ export const uiFeature = {
39
42
  });
40
43
  },
41
44
 
45
+ isIdentityManagedByAuth() {
46
+ return this.runtimeConfig?.auth?.strategy === 'oidc'
47
+ && this.runtimeConfig?.auth?.provider === 'google';
48
+ },
49
+
42
50
  bindEvents() {
43
51
  this.elements.emptyStateNewFileBtn?.addEventListener('click', () => {
44
52
  this.fileExplorer.actionController.handleNewFile();
@@ -65,6 +73,16 @@ export const uiFeature = {
65
73
  void this.copyCurrentLink();
66
74
  });
67
75
 
76
+ this.elements.fileHistoryButton?.addEventListener('click', () => {
77
+ const route = this.navigation.getHashRoute();
78
+ if (route.type === 'git-file-preview') {
79
+ this.handleGitFileHistorySelection(route.currentFilePath ?? route.filePath, { closeSidebarOnMobile: true });
80
+ return;
81
+ }
82
+
83
+ this.handleGitFileHistorySelection(this.currentFilePath, { closeSidebarOnMobile: true });
84
+ });
85
+
68
86
  this.elements.editNameButton?.addEventListener('click', () => {
69
87
  this.openDisplayNameDialog();
70
88
  });
@@ -389,6 +407,11 @@ export const uiFeature = {
389
407
 
390
408
  handleConnectionChange(state) {
391
409
  this.connectionState = state;
410
+ if (state?.firstConnection) {
411
+ this.recordFileOpenMetric?.('ws_connected', {
412
+ status: state.status,
413
+ });
414
+ }
392
415
  this.renderPresence();
393
416
 
394
417
  if (state.unreachable && !this.connectionHelpShown) {
@@ -398,6 +421,10 @@ export const uiFeature = {
398
421
  },
399
422
 
400
423
  openDisplayNameDialog({ mode = 'edit' } = {}) {
424
+ if (this.isIdentityManagedByAuth()) {
425
+ return;
426
+ }
427
+
401
428
  if (!this.isTabActive) {
402
429
  return;
403
430
  }
@@ -439,7 +466,12 @@ export const uiFeature = {
439
466
  },
440
467
 
441
468
  promptForDisplayNameIfNeeded() {
442
- if (!this.isTabActive || this._hasPromptedForDisplayName || this.getStoredUserName()) {
469
+ if (
470
+ !this.isTabActive
471
+ || this._hasPromptedForDisplayName
472
+ || this.getStoredUserName()
473
+ || this.isIdentityManagedByAuth()
474
+ ) {
443
475
  return;
444
476
  }
445
477
 
@@ -450,6 +482,10 @@ export const uiFeature = {
450
482
  },
451
483
 
452
484
  handleDisplayNameSubmit() {
485
+ if (this.isIdentityManagedByAuth()) {
486
+ return;
487
+ }
488
+
453
489
  const input = this.elements.displayNameInput;
454
490
  const dialog = this.elements.displayNameDialog;
455
491
  if (!input || !dialog) return;
@@ -501,10 +537,21 @@ export const uiFeature = {
501
537
  const name = this.getCurrentUserName();
502
538
  el.textContent = name || 'Set name';
503
539
  el.classList.toggle('has-name', Boolean(name));
504
- this.elements.editNameButton?.setAttribute(
505
- 'aria-label',
506
- `${name || 'Set name'}. Change display name`,
507
- );
540
+ if (!this.isIdentityManagedByAuth()) {
541
+ this.elements.editNameButton?.setAttribute(
542
+ 'aria-label',
543
+ `${name || 'Set name'}. Change display name`,
544
+ );
545
+ }
546
+ },
547
+
548
+ syncIdentityManagementUi() {
549
+ const isManaged = this.isIdentityManagedByAuth();
550
+ this.elements.editNameButton?.classList.toggle('hidden', isManaged);
551
+ this.elements.editNameButton?.toggleAttribute('disabled', isManaged);
552
+ if (isManaged && this.elements.displayNameDialog?.open) {
553
+ this.elements.displayNameDialog.close();
554
+ }
508
555
  },
509
556
 
510
557
  toggleLineWrapping() {
@@ -534,6 +581,9 @@ export const uiFeature = {
534
581
  if (!this.lobby.provider) {
535
582
  this.lobby.connect();
536
583
  }
584
+ if (!this.workspaceSync.provider) {
585
+ this.workspaceSync.connect();
586
+ }
537
587
 
538
588
  if (wasInactive) {
539
589
  if (this.fileExplorerReady) {
@@ -547,13 +597,20 @@ export const uiFeature = {
547
597
  }
548
598
  },
549
599
 
550
- handleTabBlocked({ reason } = {}) {
600
+ async handleTabBlocked({ reason } = {}) {
551
601
  const wasActive = this.isTabActive;
602
+ const blockedFilePath = this.currentFilePath;
603
+ const shouldPrepareExcalidrawDisconnect = Boolean(
604
+ blockedFilePath
605
+ && this.isExcalidrawFile?.(blockedFilePath),
606
+ );
607
+
552
608
  this.isTabActive = false;
553
609
  if (this.elements.displayNameDialog?.open) {
554
610
  this.elements.displayNameDialog.close();
555
611
  }
556
612
  this.lobby.disconnect();
613
+ this.workspaceSync.disconnect();
557
614
  this.globalUsers = [];
558
615
  this.chatMessages = [];
559
616
  this.chatMessageIds.clear();
@@ -562,9 +619,14 @@ export const uiFeature = {
562
619
  this.followedUserClientId = null;
563
620
  this.followedCursorSignature = '';
564
621
  this.connectionState = { status: 'disconnected', unreachable: false };
622
+ this.showTabLockOverlay({ reason });
623
+
624
+ if (shouldPrepareExcalidrawDisconnect) {
625
+ await this.excalidrawEmbed.prepareFileDisconnect(blockedFilePath);
626
+ }
627
+
565
628
  this.showEmptyState();
566
629
  this.renderChat();
567
- this.showTabLockOverlay({ reason });
568
630
 
569
631
  if (wasActive && reason === 'taken-over') {
570
632
  this.toastController.show('Another tab took over this session');
@@ -1,4 +1,5 @@
1
1
  import {
2
+ getVaultFileKind,
2
3
  isExcalidrawFilePath,
3
4
  isImageAttachmentFilePath,
4
5
  isMermaidFilePath,
@@ -28,9 +29,50 @@ export const workspaceFeature = {
28
29
  },
29
30
 
30
31
  getPreviewSource() {
32
+ const previewDocument = this.getStaticPreviewDocument?.();
33
+ const previewFilePath = previewDocument?.currentFilePath ?? previewDocument?.filePath ?? null;
34
+ if (previewDocument && previewFilePath && previewFilePath === this.currentFilePath) {
35
+ if (this.isMermaidFile(this.currentFilePath)) {
36
+ return this.createDiagramPreviewDocument('mermaid', previewDocument.content);
37
+ }
38
+
39
+ if (this.isPlantUmlFile(this.currentFilePath)) {
40
+ return this.createDiagramPreviewDocument('plantuml', previewDocument.content);
41
+ }
42
+
43
+ return String(previewDocument.content ?? '');
44
+ }
45
+
31
46
  return this.workspacePreviewController.getPreviewSource(this.currentFilePath);
32
47
  },
33
48
 
49
+ getStaticPreviewDocument() {
50
+ return this._staticPreviewDocument ?? null;
51
+ },
52
+
53
+ setStaticPreviewDocument(document) {
54
+ const normalizedFilePath = document?.filePath ?? document?.path ?? null;
55
+ const normalizedCurrentFilePath = document?.currentFilePath ?? normalizedFilePath;
56
+ this._staticPreviewDocument = document
57
+ ? {
58
+ content: String(document.content ?? ''),
59
+ currentFilePath: normalizedCurrentFilePath,
60
+ fileKind: document.fileKind ?? getVaultFileKind(normalizedFilePath),
61
+ filePath: normalizedFilePath,
62
+ hash: document.hash ?? null,
63
+ }
64
+ : null;
65
+ },
66
+
67
+ clearStaticPreviewDocument() {
68
+ this._staticPreviewDocument = null;
69
+ },
70
+
71
+ supportsFileHistory(filePath) {
72
+ const kind = getVaultFileKind(filePath);
73
+ return kind !== null && kind !== 'image';
74
+ },
75
+
34
76
  getDisplayName(filePath) {
35
77
  return stripVaultFileExtension(String(filePath ?? '')
36
78
  .split('/')
@@ -53,6 +95,10 @@ export const workspaceFeature = {
53
95
  this.workspacePreviewController.renderImageFilePreview(filePath);
54
96
  },
55
97
 
98
+ renderTextFilePreview(payload) {
99
+ this.workspacePreviewController.renderTextFilePreview(payload);
100
+ },
101
+
56
102
  createResizeHandler() {
57
103
  return this.workspacePreviewController.createResizeHandler(() => this.restoreSidebarState());
58
104
  },
@@ -38,6 +38,8 @@ export function bindAppShellElements(doc = document) {
38
38
  editorPage: doc.getElementById('editor-page'),
39
39
  diffPage: doc.getElementById('diff-page'),
40
40
  fileSearch: doc.getElementById('fileSearch'),
41
+ fileHistoryButton: doc.getElementById('fileHistoryBtn'),
42
+ fileHistoryButtonLabel: doc.getElementById('fileHistoryBtnLabel'),
41
43
  filesSidebarTab: doc.getElementById('filesSidebarTab'),
42
44
  gitSearch: doc.getElementById('gitSearch'),
43
45
  gitSearchInput: doc.getElementById('gitSearchInput'),
@@ -561,7 +561,7 @@ export class MermaidPreviewHydrator extends DiagramPreviewHydrator {
561
561
  createZoomButton(label, ariaLabel) {
562
562
  const button = document.createElement('button');
563
563
  button.type = 'button';
564
- button.className = 'mermaid-zoom-btn diagram-preview-action-btn';
564
+ button.className = 'mermaid-zoom-btn ui-preview-action';
565
565
  button.setAttribute('aria-label', ariaLabel);
566
566
  button.title = ariaLabel;
567
567
  button.textContent = label;
@@ -554,7 +554,7 @@ export class PlantUmlPreviewHydrator extends DiagramPreviewHydrator {
554
554
  createToolButton(label, ariaLabel) {
555
555
  const button = document.createElement('button');
556
556
  button.type = 'button';
557
- button.className = 'plantuml-tool-btn diagram-preview-action-btn';
557
+ button.className = 'plantuml-tool-btn ui-preview-action';
558
558
  button.setAttribute('aria-label', ariaLabel);
559
559
  button.title = ariaLabel;
560
560
  button.textContent = label;