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
@@ -317,6 +317,12 @@ function applyLocalScene(scene, {
317
317
  ...buildApiSceneUpdate(normalizedScene),
318
318
  captureUpdate,
319
319
  });
320
+
321
+ if (suppressOnChange) {
322
+ roomClient.commitSceneJson(normalizedJson, {
323
+ origin: 'excalidraw-local-scene-apply',
324
+ });
325
+ }
320
326
  }
321
327
 
322
328
  function onRoomTextUpdate() {
@@ -469,9 +475,41 @@ function disconnectRealtimeRoom() {
469
475
  roomClient.disconnect();
470
476
  }
471
477
 
472
- window.addEventListener('pagehide', () => {
478
+ let didDisconnectRealtimeRoom = false;
479
+
480
+ function disconnectRealtimeRoomOnce() {
481
+ if (didDisconnectRealtimeRoom) {
482
+ return;
483
+ }
484
+
485
+ didDisconnectRealtimeRoom = true;
473
486
  disconnectRealtimeRoom();
474
- });
487
+ }
488
+
489
+ async function waitForPendingRoomWrites({
490
+ intervalMs = 10,
491
+ maxWaitMs = 150,
492
+ } = {}) {
493
+ const startedAt = performance.now();
494
+
495
+ while ((performance.now() - startedAt) < maxWaitMs) {
496
+ const ws = roomClient.provider?.ws;
497
+ if (!ws || ws.readyState !== WebSocket.OPEN || ws.bufferedAmount === 0) {
498
+ return;
499
+ }
500
+
501
+ await new Promise((resolve) => window.setTimeout(resolve, intervalMs));
502
+ }
503
+ }
504
+
505
+ async function prepareRealtimeRoomDisconnect() {
506
+ roomClient.flushSceneSync();
507
+ await waitForPendingRoomWrites();
508
+ }
509
+
510
+ window.addEventListener('pagehide', disconnectRealtimeRoomOnce);
511
+ window.addEventListener('beforeunload', disconnectRealtimeRoomOnce);
512
+ window.addEventListener('unload', disconnectRealtimeRoomOnce);
475
513
 
476
514
  window.addEventListener('message', (event) => {
477
515
  if (event.origin !== parentOrigin) {
@@ -503,6 +541,16 @@ window.addEventListener('message', (event) => {
503
541
 
504
542
  if (message.type === 'follow-user') {
505
543
  applyHostFollowRequest(message.peerId || null);
544
+ return;
545
+ }
546
+
547
+ if (message.type === 'prepare-disconnect') {
548
+ void (async () => {
549
+ await prepareRealtimeRoomDisconnect();
550
+ postToParent('disconnect-ready', {
551
+ requestId: message.requestId || '',
552
+ });
553
+ })();
506
554
  }
507
555
  });
508
556
 
@@ -3,7 +3,9 @@ import { getRuntimeConfig } from './runtime-config.js';
3
3
  const DEFAULT_AUTH_CONFIG = {
4
4
  enabled: false,
5
5
  implemented: true,
6
+ loginEndpoint: '/api/auth/oidc/login',
6
7
  passwordLabel: 'Password',
8
+ provider: '',
7
9
  requiresLogin: false,
8
10
  sessionEndpoint: '/api/auth/session',
9
11
  statusEndpoint: '/api/auth/status',
@@ -11,6 +13,8 @@ const DEFAULT_AUTH_CONFIG = {
11
13
  submitLabel: 'Continue',
12
14
  };
13
15
 
16
+ const GOOGLE_G_LOGO_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAIAAAABc2X6AAAGsklEQVR4Ae3cA3jsShgG4Gvbtu1js7aObdu2bdbudmvbtu1VtUg2ydw7x95mstnt5rZ5voflG80/fzL72L89bOsF94J7wWgb2dSgiAzpPHmwbfPK1qWzxDNsRdb6Ar2BgtF9hSbDRbYG4mk2beuXwm+Q89yV+TmAILgHptrb5DyPtg3LhBZjBCP+RovewNZlc2TOl8nGel0HA4LA4qPhkRSM6XcHoEYk86fIfT2AQqFzYIBjMncHofkoZBWNCM1GSq+cpdpadQIMKEoRzIeXIroEkW08TObpDAiiO8F4Tga85TBnoEc8xVKZm9UNYECS0stnBCP/Ud+AnJH/SC+eAiShPTDZ3CRZPIN9CUokC6ZSYqE2wHhmKhw/2TegR2RnSFSUaRaMJccLxg7QsAQhktkTAACaAmOxkbA80h2taKIZKWzR1BFWRAQLRvXhphYdDEtcWDxxU4sOhjdDkdVYbmrRwYAgJIums3RTNWrftbHz/AmZh5MiLBAGVqOdZ4+1790inmrFvpYZuPPEATWdknmT5f7eZEN9V2N7o9zPu3XNInQte2BlcYE6tVTbltXw4kf9h5QlhW3rliBr1QcDAODsjPFlhmelqfNvKQty4S9B0KoPVgTxmWk7Tx0COMZCF6GzA3ZC0LWMwJSsTWiOXj+O6iP382KzowAAvMOhaxmAqw/Ijr4tGPsHgnbsACwxlpNNPABIIv5LIvxpzOVlkdXPNMGK8CCudi2pFi+ovRFlwLOtc7/uUgvnxhxu05LpIyD17nTu+EAw8q9HaVtXzgcAcBUMcAER/sx9YBj5pdeExr897NLtT9TVcLgRTzVcgbyHBuc9L5n8PdLJzAEwmWP1KDCMMvTp9jWf3tbC1gdQKDgMBhRBRL0GYaojO35zxOo8fZjbz5ZAZz700Anm9pLI9g+yoY7bYKrJBWJoRpk8SJ0/fDoc13QuRuNdgMmydfTBZPkGdcDDd0q1EDkOVIKzTOiDKVGY7oPLmylVYCKlL30wIKW6D44tJlSCE76nC45+CwHXfWDfdKVKcMwHdMEJ33MC7JqEqwRHvEgXnDaYE+ArMayBB3ECDAennnVKn4tUDU74gS446k1OgB3i8Z41LHmkKHtW4eGfpRpctv5/VlomlxOsTR6kKQN1H1wnptiZHpYFv27pMbqus4kxOChbySzTz8loakfukhIkYKEBEBzw6SBXk79cLQ5lXv5XuxtJAaMDdA/v+JMydVs8irBnDvB+g9QbGeY1SUFg2gTn1JD0z+cdPIVaTbym0JemeQ6FzrtzJtdFm+DT4Rh9MD9DybxNmxL03mg3g/u0MP3cbWo6GrWjbZMBg/0Id6wqAcWwEX+F/10fVzPIe2jmRW3VTiP+VBjC4bU6KgMAID9qaQt9frl3P6hSndOaP7HrxdSYPQiH92QYhvAwTRH7KdSWBL9h5j4GeugkqDpWc1oZBqaelSGNwMWNJMLjUkHxBn//zwZeH3topr+7bWxDmia0AID17gok7aTTMrTnw1KsfZTPVMhAyj9uVl5lIexqcQLs4KFpYXjpSjQw3HwrIqCBQQ5mXsZInBWtoIOac1GOqrU4IsUJwOSllsmha5iZTf3npzXnqXka+1dFmzo4D9vZgQp2TsAZvrZUIC7729WSmRlmVfz+HGExKpUCVGJj1oSQlTd+SZ9Lm4bubqKvtTkmk+OAIRhu+zMu3jEwyqTQ1d7lofWdzV0VyWSxpPJkjpOB76z7fsPfjjOHHMhBb0QzAhMUMS18nRrgOzHkz96YdPR4toNTMT+gKjq4OtatNPBCvgfcp9PD1w/wsFP14y7WA4/zu9SudVWw8HKpUC4Zw5uOLmQ//c4fGrZL8iit+WEpvMmxAIZbtrCor7uNLpj/ubpk6N6Kh059M6tINl8Qh4UUHGa73wzjNGHQ4dj7wC6JOPtLACLqkvq4WeuE2cWi/+nLt0csOKnQ1CKP+IYMWELqgPnGiLVh6O7Go8GYZpfxpDbnwkaHjpj3JfLRV7Wgb01SARxIupc6wntyfGOG9pbikRQJmzvodRg7mRq2tkkm7IbFlhktBdaBS7SsPZx1haDIbltOSwGKXxl5px7UZGaEb8gXlerEgmmMxB2KfEciTKHRAsvbmPo0nVsST1BEdH3Kyrh9bJVlQ7wmbk05USSu0PUPPWjDOtxLg5bF7h7tM42B08x//o7U0/AmrCSV3PtYiwZpS3B13MHMS/DIz4rYaBO0VM93JpwbwaJtqNfEsbwZFgEL50VtgcJLBV6wK9aKtfd+cEkvuBfcC37k9h8VGR+csPdltgAAAABJRU5ErkJggg==';
17
+
14
18
  let authGateStylesInjected = false;
15
19
 
16
20
  function ensureAuthGateStyles() {
@@ -29,99 +33,182 @@ function ensureAuthGateStyles() {
29
33
  justify-content: center;
30
34
  padding: 24px;
31
35
  background:
32
- radial-gradient(circle at top, rgba(59, 130, 246, 0.18), transparent 42%),
33
- rgba(10, 15, 29, 0.74);
34
- backdrop-filter: blur(14px);
36
+ radial-gradient(circle at 50% 8%, rgba(37, 99, 235, 0.18), transparent 28%),
37
+ radial-gradient(circle at 60% 45%, rgba(14, 165, 233, 0.08), transparent 18%),
38
+ linear-gradient(180deg, rgba(8, 12, 24, 0.9), rgba(6, 10, 22, 0.96));
39
+ backdrop-filter: blur(18px);
35
40
  }
36
41
 
37
42
  .auth-gate-card {
38
- width: min(100%, 420px);
39
- padding: 28px;
40
- border-radius: 18px;
41
- border: 1px solid rgba(148, 163, 184, 0.24);
42
- background: rgba(15, 23, 42, 0.94);
43
+ width: min(100%, 440px);
44
+ padding: 32px;
45
+ border-radius: 24px;
46
+ border: 1px solid rgba(148, 163, 184, 0.2);
47
+ background:
48
+ linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(12, 18, 32, 0.96));
43
49
  color: #e2e8f0;
44
- box-shadow: 0 24px 80px rgba(15, 23, 42, 0.36);
45
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
50
+ box-shadow:
51
+ 0 28px 90px rgba(15, 23, 42, 0.42),
52
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
53
+ font-family: "Geist", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
46
54
  }
47
55
 
48
56
  .auth-gate-card h1 {
49
57
  margin: 0 0 10px;
50
- font-size: 1.35rem;
51
- line-height: 1.2;
58
+ font-size: 2rem;
59
+ line-height: 1.05;
60
+ letter-spacing: -0.04em;
52
61
  }
53
62
 
54
63
  .auth-gate-card p {
55
- margin: 0 0 18px;
64
+ margin: 0 0 22px;
56
65
  color: #cbd5e1;
57
66
  line-height: 1.5;
67
+ font-size: 1rem;
58
68
  }
59
69
 
60
70
  .auth-gate-form {
61
71
  display: grid;
62
- gap: 12px;
72
+ gap: 14px;
63
73
  }
64
74
 
65
75
  .auth-gate-label {
66
76
  display: grid;
67
- gap: 8px;
77
+ gap: 10px;
68
78
  font-size: 0.92rem;
79
+ font-weight: 500;
69
80
  color: #cbd5e1;
70
81
  }
71
82
 
72
83
  .auth-gate-input {
73
84
  width: 100%;
74
- padding: 12px 14px;
85
+ padding: 14px 16px;
75
86
  border: 1px solid rgba(148, 163, 184, 0.28);
76
- border-radius: 12px;
87
+ border-radius: 16px;
77
88
  background: rgba(15, 23, 42, 0.72);
78
89
  color: inherit;
79
90
  font: inherit;
91
+ box-sizing: border-box;
80
92
  }
81
93
 
82
94
  .auth-gate-input:focus {
83
- outline: 2px solid rgba(59, 130, 246, 0.5);
84
- outline-offset: 2px;
95
+ outline: none;
96
+ border-color: rgba(56, 189, 248, 0.72);
97
+ box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
85
98
  }
86
99
 
87
100
  .auth-gate-actions {
88
- display: flex;
89
- align-items: center;
90
- justify-content: space-between;
101
+ display: grid;
91
102
  gap: 12px;
92
103
  }
93
104
 
94
105
  .auth-gate-button,
95
106
  .auth-gate-secondary-button {
96
107
  border: 0;
97
- border-radius: 999px;
108
+ border-radius: 16px;
98
109
  font: inherit;
99
110
  cursor: pointer;
111
+ transition:
112
+ transform 160ms ease,
113
+ box-shadow 160ms ease,
114
+ border-color 160ms ease,
115
+ background-color 160ms ease;
116
+ }
117
+
118
+ .auth-gate-button:hover,
119
+ .auth-gate-secondary-button:hover {
120
+ transform: translateY(-1px);
121
+ }
122
+
123
+ .auth-gate-button:focus-visible,
124
+ .auth-gate-secondary-button:focus-visible {
125
+ outline: 3px solid rgba(125, 211, 252, 0.3);
126
+ outline-offset: 2px;
100
127
  }
101
128
 
102
129
  .auth-gate-button {
103
- padding: 11px 16px;
130
+ display: inline-flex;
131
+ align-items: center;
132
+ justify-content: center;
133
+ gap: 14px;
134
+ width: 100%;
135
+ padding: 14px 18px;
104
136
  background: linear-gradient(135deg, #0f766e, #0284c7);
105
137
  color: #f8fafc;
106
138
  font-weight: 600;
139
+ box-shadow: 0 16px 40px rgba(2, 132, 199, 0.24);
140
+ }
141
+
142
+ .auth-gate-button:hover {
143
+ box-shadow: 0 18px 44px rgba(2, 132, 199, 0.28);
107
144
  }
108
145
 
109
146
  .auth-gate-secondary-button {
110
- padding: 10px 0;
111
- background: transparent;
112
- color: #93c5fd;
147
+ padding: 13px 16px;
148
+ background: rgba(15, 23, 42, 0.48);
149
+ color: #cbd5e1;
150
+ border: 1px solid rgba(148, 163, 184, 0.22);
113
151
  }
114
152
 
115
153
  .auth-gate-button[disabled],
116
154
  .auth-gate-secondary-button[disabled] {
117
155
  opacity: 0.7;
118
156
  cursor: wait;
157
+ transform: none;
158
+ }
159
+
160
+ .auth-gate-button--google {
161
+ background: rgba(255, 255, 255, 0.98);
162
+ color: #111827;
163
+ border: 1px solid rgba(148, 163, 184, 0.22);
164
+ box-shadow:
165
+ 0 22px 40px rgba(15, 23, 42, 0.2),
166
+ inset 0 1px 0 rgba(255, 255, 255, 0.65);
167
+ }
168
+
169
+ .auth-gate-button--google:hover {
170
+ background: #ffffff;
171
+ box-shadow:
172
+ 0 24px 44px rgba(15, 23, 42, 0.24),
173
+ inset 0 1px 0 rgba(255, 255, 255, 0.8);
174
+ }
175
+
176
+ .auth-gate-button__icon {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ flex: 0 0 auto;
181
+ width: 22px;
182
+ height: 22px;
183
+ }
184
+
185
+ .auth-gate-button__icon img {
186
+ display: block;
187
+ width: 100%;
188
+ height: 100%;
189
+ object-fit: contain;
119
190
  }
120
191
 
121
192
  .auth-gate-error {
122
193
  min-height: 1.25rem;
123
194
  color: #fca5a5;
124
195
  font-size: 0.92rem;
196
+ margin-bottom: 4px;
197
+ }
198
+
199
+ @media (max-width: 640px) {
200
+ .auth-gate-overlay {
201
+ padding: 16px;
202
+ }
203
+
204
+ .auth-gate-card {
205
+ padding: 24px;
206
+ border-radius: 20px;
207
+ }
208
+
209
+ .auth-gate-card h1 {
210
+ font-size: 1.7rem;
211
+ }
125
212
  }
126
213
  `;
127
214
 
@@ -159,6 +246,24 @@ function removePasswordFromHash() {
159
246
  window.history.replaceState(null, '', nextUrl);
160
247
  }
161
248
 
249
+ function consumeHashParam(name) {
250
+ const params = getHashParams();
251
+ const value = params.get(name) || '';
252
+ if (!value) {
253
+ return '';
254
+ }
255
+
256
+ params.delete(name);
257
+ const nextHash = params.toString();
258
+ const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`;
259
+ window.history.replaceState(null, '', nextUrl);
260
+ return value;
261
+ }
262
+
263
+ function getCurrentReturnTo() {
264
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`;
265
+ }
266
+
162
267
  async function fetchAuthStatus(config) {
163
268
  const response = await fetch(config.statusEndpoint, {
164
269
  headers: {
@@ -174,6 +279,24 @@ async function fetchAuthStatus(config) {
174
279
  return payload;
175
280
  }
176
281
 
282
+ function syncOidcIdentityToLocalState(status) {
283
+ const authConfig = status?.auth ?? {};
284
+ if (authConfig.strategy !== 'oidc' || authConfig.provider !== 'google') {
285
+ return;
286
+ }
287
+
288
+ const userName = String(status?.user?.name ?? '').trim();
289
+ if (!userName) {
290
+ return;
291
+ }
292
+
293
+ try {
294
+ window.localStorage.setItem('collabmd-user-name', userName);
295
+ } catch {
296
+ // Ignore storage errors.
297
+ }
298
+ }
299
+
177
300
  async function submitPassword(config, password) {
178
301
  const response = await fetch(config.sessionEndpoint, {
179
302
  method: 'POST',
@@ -204,6 +327,53 @@ function createOverlayShell() {
204
327
  return { card, overlay };
205
328
  }
206
329
 
330
+ function createGoogleIcon() {
331
+ const icon = document.createElement('span');
332
+ icon.className = 'auth-gate-button__icon';
333
+ icon.setAttribute('aria-hidden', 'true');
334
+ const image = document.createElement('img');
335
+ image.alt = '';
336
+ image.decoding = 'async';
337
+ image.height = 20;
338
+ image.loading = 'eager';
339
+ image.src = GOOGLE_G_LOGO_DATA_URL;
340
+ image.width = 20;
341
+ icon.append(image);
342
+ return icon;
343
+ }
344
+
345
+ function renderOidcPrompt(card, config, {
346
+ errorMessage = '',
347
+ }) {
348
+ card.replaceChildren();
349
+
350
+ const heading = document.createElement('h1');
351
+ heading.textContent = 'Authentication required';
352
+
353
+ const copy = document.createElement('p');
354
+ copy.textContent = 'Sign in with Google to join this session.';
355
+
356
+ const actions = document.createElement('div');
357
+ actions.className = 'auth-gate-actions';
358
+
359
+ const signInButton = document.createElement('button');
360
+ signInButton.className = 'auth-gate-button auth-gate-button--google';
361
+ signInButton.type = 'button';
362
+ signInButton.append(createGoogleIcon(), document.createTextNode(config.submitLabel || 'Continue with Google'));
363
+
364
+ const error = document.createElement('div');
365
+ error.className = 'auth-gate-error';
366
+ error.textContent = errorMessage;
367
+
368
+ actions.append(signInButton);
369
+ card.append(heading, copy, error, actions);
370
+ signInButton.addEventListener('click', () => {
371
+ const loginUrl = new URL(config.loginEndpoint, window.location.origin);
372
+ loginUrl.searchParams.set('returnTo', getCurrentReturnTo());
373
+ window.location.assign(loginUrl.toString());
374
+ });
375
+ }
376
+
207
377
  function renderStatusCard(card, {
208
378
  title,
209
379
  body,
@@ -234,7 +404,6 @@ function renderStatusCard(card, {
234
404
 
235
405
  function renderPasswordPrompt(card, config, {
236
406
  onSubmit,
237
- onRetryStatus,
238
407
  }) {
239
408
  card.replaceChildren();
240
409
 
@@ -264,31 +433,21 @@ function renderPasswordPrompt(card, config, {
264
433
  const actions = document.createElement('div');
265
434
  actions.className = 'auth-gate-actions';
266
435
 
267
- const retryButton = document.createElement('button');
268
- retryButton.className = 'auth-gate-secondary-button';
269
- retryButton.type = 'button';
270
- retryButton.textContent = 'Retry status';
271
-
272
436
  const submitButton = document.createElement('button');
273
437
  submitButton.className = 'auth-gate-button';
274
438
  submitButton.type = 'submit';
275
439
  submitButton.textContent = config.submitLabel;
276
440
 
277
- actions.append(retryButton, submitButton);
441
+ actions.append(submitButton);
278
442
  label.append(input);
279
443
  form.append(heading, copy, label, error, actions);
280
444
  card.append(form);
281
445
 
282
- retryButton.addEventListener('click', () => {
283
- void onRetryStatus();
284
- });
285
-
286
446
  form.addEventListener('submit', (event) => {
287
447
  event.preventDefault();
288
448
  const password = input.value;
289
449
  input.disabled = true;
290
450
  submitButton.disabled = true;
291
- retryButton.disabled = true;
292
451
  error.textContent = '';
293
452
 
294
453
  void onSubmit(password).catch((submissionError) => {
@@ -297,7 +456,6 @@ function renderPasswordPrompt(card, config, {
297
456
  : 'Authentication failed';
298
457
  input.disabled = false;
299
458
  submitButton.disabled = false;
300
- retryButton.disabled = false;
301
459
  input.select();
302
460
  input.focus();
303
461
  });
@@ -316,11 +474,17 @@ export async function ensureClientAuthenticated() {
316
474
 
317
475
  const { card, overlay } = createOverlayShell();
318
476
  document.body.append(overlay);
477
+ let pendingOidcError = consumeHashParam('auth_error');
319
478
  return new Promise((resolve) => {
320
- const resolveAuthenticated = () => {
479
+ const resolveAuthenticated = (status = null) => {
321
480
  removePasswordFromHash();
481
+ syncOidcIdentityToLocalState(status);
322
482
  overlay.remove();
323
- resolve({ authenticated: true, auth: config });
483
+ resolve({
484
+ authenticated: true,
485
+ auth: status?.auth ?? config,
486
+ user: status?.user ?? null,
487
+ });
324
488
  };
325
489
 
326
490
  const verifyAccess = async () => {
@@ -332,15 +496,15 @@ export async function ensureClientAuthenticated() {
332
496
  try {
333
497
  const status = await fetchAuthStatus(config);
334
498
  if (status.authenticated) {
335
- resolveAuthenticated();
499
+ resolveAuthenticated(status);
336
500
  return;
337
501
  }
338
502
  } catch (error) {
339
503
  renderStatusCard(card, {
340
- body: error instanceof Error ? error.message : 'Failed to contact the auth service.',
341
- secondaryActionLabel: 'Retry',
504
+ body: error instanceof Error
505
+ ? `${error.message} Refresh the page and try again.`
506
+ : 'Failed to contact the auth service. Refresh the page and try again.',
342
507
  title: 'Cannot verify access',
343
- onSecondaryAction: verifyAccess,
344
508
  });
345
509
  return;
346
510
  }
@@ -359,7 +523,6 @@ export async function ensureClientAuthenticated() {
359
523
  }
360
524
 
361
525
  renderPasswordPrompt(card, config, {
362
- onRetryStatus: verifyAccess,
363
526
  onSubmit: async (password) => {
364
527
  await submitPassword(config, password);
365
528
  resolveAuthenticated();
@@ -368,13 +531,17 @@ export async function ensureClientAuthenticated() {
368
531
  return;
369
532
  }
370
533
 
534
+ if (config.strategy === 'oidc') {
535
+ renderOidcPrompt(card, config, {
536
+ errorMessage: pendingOidcError,
537
+ });
538
+ pendingOidcError = '';
539
+ return;
540
+ }
541
+
371
542
  renderStatusCard(card, {
372
- body: config.strategy === 'oidc'
373
- ? 'OIDC authentication is configured but not implemented in this build yet.'
374
- : 'This authentication strategy is not available.',
375
- secondaryActionLabel: 'Retry status',
543
+ body: 'This authentication strategy is not available.',
376
544
  title: 'Authentication unavailable',
377
- onSecondaryAction: verifyAccess,
378
545
  });
379
546
  };
380
547
 
@@ -1,7 +1,11 @@
1
1
  import {
2
2
  getHashRoute,
3
+ navigateToGitCommit,
3
4
  navigateToFile,
4
5
  navigateToGitDiff,
6
+ navigateToGitFileHistory,
7
+ navigateToGitFilePreview,
8
+ navigateToGitHistory,
5
9
  } from './runtime-config.js';
6
10
 
7
11
  export class BrowserNavigationPort {
@@ -16,4 +20,20 @@ export class BrowserNavigationPort {
16
20
  navigateToGitDiff(payload) {
17
21
  navigateToGitDiff(payload);
18
22
  }
23
+
24
+ navigateToGitCommit(payload) {
25
+ navigateToGitCommit(payload);
26
+ }
27
+
28
+ navigateToGitHistory() {
29
+ navigateToGitHistory();
30
+ }
31
+
32
+ navigateToGitFileHistory(payload) {
33
+ navigateToGitFileHistory(payload);
34
+ }
35
+
36
+ navigateToGitFilePreview(payload) {
37
+ navigateToGitFilePreview(payload);
38
+ }
19
39
  }
@@ -125,6 +125,10 @@ export class EditorCollaborationClient {
125
125
  return Promise.resolve();
126
126
  }
127
127
 
128
+ if (timeoutMs === null || timeoutMs === undefined || timeoutMs === false) {
129
+ return this.initialSyncPromise;
130
+ }
131
+
128
132
  return Promise.race([
129
133
  this.initialSyncPromise,
130
134
  new Promise((resolve) => {
@@ -24,6 +24,9 @@ export class EditorSession {
24
24
  this.onCommentsChange = onCommentsChange;
25
25
  this.onContentChange = onContentChange;
26
26
  this.onSelectionChange = onSelectionChange;
27
+ this.activeFilePath = '';
28
+ this.bootstrapContent = null;
29
+ this.pendingCollaborativeBindings = null;
27
30
  this.hasDeliveredContent = false;
28
31
  this.lastDeliveredContent = null;
29
32
 
@@ -61,24 +64,58 @@ export class EditorSession {
61
64
  }
62
65
 
63
66
  async initialize(filePath) {
67
+ this.activeFilePath = filePath;
64
68
  this.hasDeliveredContent = false;
65
69
  this.lastDeliveredContent = null;
66
70
  const collaborationBindings = await this.collaborationClient.initialize(filePath);
67
-
68
- this.viewAdapter.initialize({
69
- awareness: collaborationBindings.awareness,
70
- filePath,
71
- undoManager: collaborationBindings.undoManager,
72
- ytext: collaborationBindings.ytext,
73
- });
74
71
  this.commentThreadStore.bind({
75
72
  commentThreads: collaborationBindings.commentThreads,
76
73
  ydoc: collaborationBindings.ydoc,
77
74
  ytext: collaborationBindings.ytext,
78
75
  });
76
+
77
+ this.pendingCollaborativeBindings = collaborationBindings;
78
+ if (this.collaborationClient.initialSyncComplete) {
79
+ this.activateCollaborativeView();
80
+ }
81
+
79
82
  this.onAwarenessChange?.(this.collaborationClient.collectUsers((cursor) => this.resolveAwarenessCursor(cursor)));
80
83
  }
81
84
 
85
+ activateCollaborativeView() {
86
+ if (!this.pendingCollaborativeBindings) {
87
+ return false;
88
+ }
89
+
90
+ this.viewAdapter.initialize({
91
+ awareness: this.pendingCollaborativeBindings.awareness,
92
+ filePath: this.activeFilePath,
93
+ undoManager: this.pendingCollaborativeBindings.undoManager,
94
+ ytext: this.pendingCollaborativeBindings.ytext,
95
+ });
96
+ this.pendingCollaborativeBindings = null;
97
+ this.bootstrapContent = null;
98
+ return true;
99
+ }
100
+
101
+ showBootstrapContent({ content = '', filePath = this.activeFilePath } = {}) {
102
+ if (this.collaborationClient.initialSyncComplete) {
103
+ return false;
104
+ }
105
+
106
+ this.activeFilePath = filePath;
107
+ this.bootstrapContent = String(content ?? '');
108
+ this.viewAdapter.initializeProvisional({
109
+ content: this.bootstrapContent,
110
+ filePath: this.activeFilePath,
111
+ });
112
+ return this.emitContentChange();
113
+ }
114
+
115
+ hasBootstrapContent() {
116
+ return this.bootstrapContent !== null;
117
+ }
118
+
82
119
  emitContentChange({ force = false } = {}) {
83
120
  const nextContent = this.getText();
84
121
  if (!force && this.hasDeliveredContent && nextContent === this.lastDeliveredContent) {
@@ -239,6 +276,9 @@ export class EditorSession {
239
276
 
240
277
  destroy() {
241
278
  this.commentThreadStore.unbind();
279
+ this.activeFilePath = '';
280
+ this.bootstrapContent = null;
281
+ this.pendingCollaborativeBindings = null;
242
282
  this.hasDeliveredContent = false;
243
283
  this.lastDeliveredContent = null;
244
284
  this.collaborationClient.destroy();