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
@@ -11,11 +11,12 @@ import {
11
11
  } from '@codemirror/language';
12
12
  import { languages } from '@codemirror/language-data';
13
13
  import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
14
- import { Compartment, EditorSelection, EditorState, Prec } from '@codemirror/state';
14
+ import { Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField } from '@codemirror/state';
15
15
  import { oneDark } from '@codemirror/theme-one-dark';
16
16
  import {
17
17
  Decoration,
18
18
  EditorView,
19
+ WidgetType,
19
20
  crosshairCursor,
20
21
  drawSelection,
21
22
  highlightActiveLine,
@@ -36,6 +37,59 @@ import { handleImagePasteEvent } from './editor-paste-utils.js';
36
37
  const markdownCodeLanguages = [...languages, plantUmlLanguageDescription];
37
38
  const pairedMatchingBracketMark = Decoration.mark({ class: 'cm-matchingBracket cm-matchingBracket-paired' });
38
39
  const nonmatchingBracketMark = Decoration.mark({ class: 'cm-nonmatchingBracket' });
40
+ const remoteUpdateMark = Decoration.mark({ class: 'cm-remoteUpdateFlash' });
41
+ const REMOTE_UPDATE_FLASH_DURATION_MS = 1350;
42
+ const REMOTE_UPDATE_CARET_MAX_LENGTH = 160;
43
+ const RECENT_LOCAL_INPUT_WINDOW_MS = 900;
44
+
45
+ class RemoteUpdateCaretWidget extends WidgetType {
46
+ toDOM() {
47
+ const element = document.createElement('span');
48
+ element.className = 'cm-remoteUpdateCaretWidget';
49
+ element.setAttribute('aria-hidden', 'true');
50
+ return element;
51
+ }
52
+
53
+ ignoreEvent() {
54
+ return true;
55
+ }
56
+ }
57
+
58
+ const addRemoteUpdateFlashEffect = StateEffect.define();
59
+ const clearRemoteUpdateFlashEffect = StateEffect.define();
60
+ const remoteUpdateFlashField = StateField.define({
61
+ create: () => Decoration.none,
62
+ update(decorations, transaction) {
63
+ let nextDecorations = decorations.map(transaction.changes);
64
+
65
+ transaction.effects.forEach((effect) => {
66
+ if (effect.is(clearRemoteUpdateFlashEffect)) {
67
+ nextDecorations = Decoration.none;
68
+ return;
69
+ }
70
+
71
+ if (effect.is(addRemoteUpdateFlashEffect)) {
72
+ const { from, showCaret, to } = effect.value;
73
+ const nextRanges = [];
74
+ if (to > from) {
75
+ nextRanges.push(remoteUpdateMark.range(from, to));
76
+ }
77
+ if (showCaret) {
78
+ nextRanges.push(Decoration.widget({
79
+ side: -1,
80
+ widget: new RemoteUpdateCaretWidget(),
81
+ }).range(from));
82
+ }
83
+ nextDecorations = nextRanges.length > 0
84
+ ? Decoration.set(nextRanges, true)
85
+ : Decoration.none;
86
+ }
87
+ });
88
+
89
+ return nextDecorations;
90
+ },
91
+ provide: (field) => EditorView.decorations.from(field),
92
+ });
39
93
 
40
94
  function isBracketBeforeCaret(range, state) {
41
95
  return state.selection.ranges.some((selectionRange) =>
@@ -150,6 +204,35 @@ function createEditorTheme(theme) {
150
204
  '.cm-selectionMatch': {
151
205
  backgroundColor: 'var(--color-primary-highlight)',
152
206
  },
207
+ '.cm-remoteUpdateFlash': {
208
+ animation: 'collabmd-remote-update-flash 1.35s ease-out',
209
+ backgroundColor: theme === 'dark'
210
+ ? 'oklch(from var(--color-primary) l c h / 0.22)'
211
+ : 'oklch(from var(--color-primary) l c h / 0.16)',
212
+ borderRadius: '3px',
213
+ boxShadow: `0 0 0 1px ${theme === 'dark'
214
+ ? 'oklch(from var(--color-primary) calc(l + 0.05) c h / 0.26)'
215
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.2)'}`,
216
+ },
217
+ '.cm-remoteUpdateCaretWidget': {
218
+ animation: 'collabmd-remote-update-caret 1s ease-out',
219
+ backgroundColor: 'transparent',
220
+ borderLeft: `2px solid ${theme === 'dark'
221
+ ? 'color-mix(in oklab, var(--color-primary) 78%, white)'
222
+ : 'color-mix(in oklab, var(--color-primary) 84%, black)'}`,
223
+ borderRadius: '999px',
224
+ boxShadow: `0 0 0 1px ${theme === 'dark'
225
+ ? 'oklch(from var(--color-primary) calc(l + 0.06) c h / 0.22)'
226
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.16)'}`,
227
+ display: 'inline-block',
228
+ height: '1.2em',
229
+ marginLeft: '-1px',
230
+ marginRight: '-1px',
231
+ pointerEvents: 'none',
232
+ transformOrigin: 'center bottom',
233
+ verticalAlign: 'text-bottom',
234
+ width: '0',
235
+ },
153
236
  '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
154
237
  backgroundColor: selectionBackground,
155
238
  },
@@ -157,6 +240,42 @@ function createEditorTheme(theme) {
157
240
  border: `1px solid ${selectionBorder}`,
158
241
  borderRadius: '2px',
159
242
  },
243
+ '@keyframes collabmd-remote-update-flash': {
244
+ '0%': {
245
+ backgroundColor: theme === 'dark'
246
+ ? 'oklch(from var(--color-primary) l c h / 0.34)'
247
+ : 'oklch(from var(--color-primary) l c h / 0.26)',
248
+ },
249
+ '100%': {
250
+ backgroundColor: theme === 'dark'
251
+ ? 'oklch(from var(--color-primary) l c h / 0)'
252
+ : 'oklch(from var(--color-primary) l c h / 0)',
253
+ },
254
+ },
255
+ '@keyframes collabmd-remote-update-caret': {
256
+ '0%': {
257
+ boxShadow: `0 0 0 1px ${theme === 'dark'
258
+ ? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0.34)'
259
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.24)'}`,
260
+ opacity: '0',
261
+ transform: 'translateY(0.2em) scaleY(0.7)',
262
+ },
263
+ '18%': {
264
+ opacity: '1',
265
+ transform: 'translateY(0) scaleY(1)',
266
+ },
267
+ '72%': {
268
+ opacity: '1',
269
+ transform: 'translateY(0) scaleY(1)',
270
+ },
271
+ '100%': {
272
+ boxShadow: `0 0 0 1px ${theme === 'dark'
273
+ ? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0)'
274
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0)'}`,
275
+ opacity: '0',
276
+ transform: 'translateY(-0.05em) scaleY(0.9)',
277
+ },
278
+ },
160
279
  }, { dark: theme === 'dark' });
161
280
  }
162
281
 
@@ -204,6 +323,8 @@ export class EditorViewAdapter {
204
323
  this.syntaxThemeCompartment = new Compartment();
205
324
  this.lineWrappingCompartment = new Compartment();
206
325
  this.viewportFrame = 0;
326
+ this.remoteUpdateFlashTimer = 0;
327
+ this.lastLocalInputAt = 0;
207
328
  this.handleScroll = () => {
208
329
  if (this.viewportFrame) {
209
330
  return;
@@ -214,10 +335,13 @@ export class EditorViewAdapter {
214
335
  this.emitViewportChange();
215
336
  });
216
337
  };
338
+ this.handleLocalInputActivity = () => {
339
+ this.lastLocalInputAt = Date.now();
340
+ };
217
341
  }
218
342
 
219
- initialize({ awareness, filePath, undoManager, ytext }) {
220
- const updateListener = EditorView.updateListener.of((update) => {
343
+ createUpdateListener() {
344
+ return EditorView.updateListener.of((update) => {
221
345
  if (update.docChanged) {
222
346
  this.onDocChanged?.();
223
347
  }
@@ -227,65 +351,144 @@ export class EditorViewAdapter {
227
351
  this.onSelectionChanged?.(update.state);
228
352
  }
229
353
  });
354
+ }
355
+
356
+ getBaseExtensions(filePath, { readOnly = false } = {}) {
357
+ const extensions = [
358
+ lineNumbers(),
359
+ highlightActiveLineGutter(),
360
+ highlightSpecialChars(),
361
+ history(),
362
+ foldGutter(),
363
+ drawSelection(),
364
+ EditorState.allowMultipleSelections.of(true),
365
+ indentOnInput(),
366
+ syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
367
+ bracketMatching({ renderMatch: renderBracketMatch }),
368
+ closeBrackets(),
369
+ autocompletion({
370
+ override: [wikiLinkCompletions(this.getFileList)],
371
+ }),
372
+ rectangularSelection(),
373
+ crosshairCursor(),
374
+ highlightActiveLine(),
375
+ highlightSelectionMatches(),
376
+ keymap.of([
377
+ ...closeBracketsKeymap,
378
+ ...defaultKeymap,
379
+ ...historyKeymap,
380
+ ...searchKeymap,
381
+ ...foldKeymap,
382
+ indentWithTab,
383
+ ]),
384
+ EditorView.contentAttributes.of({
385
+ 'aria-label': 'Markdown editor',
386
+ }),
387
+ createLanguageExtension(filePath),
388
+ this.themeCompartment.of(createEditorTheme(this.initialTheme)),
389
+ this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
390
+ this.lineWrappingCompartment.of(this.lineWrappingEnabled ? EditorView.lineWrapping : []),
391
+ remoteUpdateFlashField,
392
+ this.createUpdateListener(),
393
+ ];
394
+
395
+ if (readOnly) {
396
+ extensions.push(EditorState.readOnly.of(true));
397
+ extensions.push(EditorView.editable.of(false));
398
+ return extensions;
399
+ }
400
+
401
+ extensions.splice(13, 0, Prec.highest(EditorView.domEventHandlers({
402
+ paste: (event) => handleImagePasteEvent(event, this.onImagePaste),
403
+ })));
404
+ return extensions;
405
+ }
230
406
 
231
- const loadingIndicator = this.editorContainer.querySelector('#editorLoading');
407
+ clearEditorDom({ preserveLoadingIndicator = true } = {}) {
408
+ if (!this.editorContainer) {
409
+ return;
410
+ }
411
+
412
+ const loadingIndicator = preserveLoadingIndicator
413
+ ? this.editorContainer.querySelector('#editorLoading')
414
+ : null;
232
415
  Array.from(this.editorContainer.children).forEach((child) => {
233
416
  if (child !== loadingIndicator) {
234
417
  child.remove();
235
418
  }
236
419
  });
420
+ }
421
+
422
+ teardownEditorView({ clearContainer = false } = {}) {
423
+ this.editorView?.contentDOM?.removeEventListener('beforeinput', this.handleLocalInputActivity);
424
+ this.editorView?.contentDOM?.removeEventListener('keydown', this.handleLocalInputActivity);
425
+ this.editorView?.contentDOM?.removeEventListener('paste', this.handleLocalInputActivity);
426
+ this.editorView?.contentDOM?.removeEventListener('compositionstart', this.handleLocalInputActivity);
427
+ this.editorView?.scrollDOM?.removeEventListener('scroll', this.handleScroll);
428
+ this.editorView?.destroy();
429
+ this.editorView = null;
430
+
431
+ if (clearContainer && this.editorContainer) {
432
+ this.editorContainer.innerHTML = '';
433
+ delete this.editorContainer.dataset.editorMode;
434
+ }
435
+ }
436
+
437
+ mountEditor(state, { editorMode = 'collaborative', preserveScrollTop = 0 } = {}) {
438
+ this.clearEditorDom({ preserveLoadingIndicator: true });
439
+ this.teardownEditorView();
237
440
 
238
441
  this.editorView = new EditorView({
239
442
  parent: this.editorContainer,
240
- state: EditorState.create({
241
- doc: ytext.toString(),
242
- extensions: [
243
- lineNumbers(),
244
- highlightActiveLineGutter(),
245
- highlightSpecialChars(),
246
- history(),
247
- foldGutter(),
248
- drawSelection(),
249
- EditorState.allowMultipleSelections.of(true),
250
- indentOnInput(),
251
- syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
252
- bracketMatching({ renderMatch: renderBracketMatch }),
253
- closeBrackets(),
254
- autocompletion({
255
- override: [wikiLinkCompletions(this.getFileList)],
256
- }),
257
- rectangularSelection(),
258
- crosshairCursor(),
259
- highlightActiveLine(),
260
- highlightSelectionMatches(),
261
- keymap.of([
262
- ...closeBracketsKeymap,
263
- ...defaultKeymap,
264
- ...historyKeymap,
265
- ...searchKeymap,
266
- ...foldKeymap,
267
- indentWithTab,
268
- ]),
269
- EditorView.contentAttributes.of({
270
- 'aria-label': 'Markdown editor',
271
- }),
272
- Prec.highest(EditorView.domEventHandlers({
273
- paste: (event) => handleImagePasteEvent(event, this.onImagePaste),
274
- })),
275
- createLanguageExtension(filePath),
276
- this.themeCompartment.of(createEditorTheme(this.initialTheme)),
277
- this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
278
- this.lineWrappingCompartment.of(this.lineWrappingEnabled ? EditorView.lineWrapping : []),
279
- yCollab(ytext, awareness, { undoManager }),
280
- updateListener,
281
- ],
282
- }),
443
+ state,
283
444
  });
284
445
 
446
+ this.editorContainer.dataset.editorMode = editorMode;
285
447
  this.editorView.scrollDOM.addEventListener('scroll', this.handleScroll, { passive: true });
448
+ this.editorView.contentDOM.addEventListener('beforeinput', this.handleLocalInputActivity);
449
+ this.editorView.contentDOM.addEventListener('keydown', this.handleLocalInputActivity);
450
+ this.editorView.contentDOM.addEventListener('paste', this.handleLocalInputActivity);
451
+ this.editorView.contentDOM.addEventListener('compositionstart', this.handleLocalInputActivity);
286
452
  this.updateCursorInfo(this.editorView.state);
287
453
  this.onSelectionChanged?.(this.editorView.state);
288
454
  this.emitViewportChange();
455
+
456
+ if (preserveScrollTop > 0) {
457
+ requestAnimationFrame(() => {
458
+ if (this.editorView?.scrollDOM) {
459
+ this.editorView.scrollDOM.scrollTop = preserveScrollTop;
460
+ }
461
+ });
462
+ }
463
+ }
464
+
465
+ initialize({ awareness, filePath, undoManager, ytext }) {
466
+ const preserveScrollTop = this.editorView?.scrollDOM?.scrollTop ?? 0;
467
+ const state = EditorState.create({
468
+ doc: ytext.toString(),
469
+ extensions: [
470
+ ...this.getBaseExtensions(filePath),
471
+ yCollab(ytext, awareness, { undoManager }),
472
+ ],
473
+ });
474
+
475
+ this.mountEditor(state, {
476
+ editorMode: 'collaborative',
477
+ preserveScrollTop,
478
+ });
479
+ }
480
+
481
+ initializeProvisional({ content = '', filePath }) {
482
+ const preserveScrollTop = this.editorView?.scrollDOM?.scrollTop ?? 0;
483
+ const state = EditorState.create({
484
+ doc: String(content ?? ''),
485
+ extensions: this.getBaseExtensions(filePath, { readOnly: true }),
486
+ });
487
+
488
+ this.mountEditor(state, {
489
+ editorMode: 'provisional',
490
+ preserveScrollTop,
491
+ });
289
492
  }
290
493
 
291
494
  destroy() {
@@ -293,13 +496,11 @@ export class EditorViewAdapter {
293
496
  cancelAnimationFrame(this.viewportFrame);
294
497
  this.viewportFrame = 0;
295
498
  }
296
- this.editorView?.scrollDOM?.removeEventListener('scroll', this.handleScroll);
297
- this.editorView?.destroy();
298
- this.editorView = null;
299
-
300
- if (this.editorContainer) {
301
- this.editorContainer.innerHTML = '';
499
+ if (this.remoteUpdateFlashTimer) {
500
+ clearTimeout(this.remoteUpdateFlashTimer);
501
+ this.remoteUpdateFlashTimer = 0;
302
502
  }
503
+ this.teardownEditorView({ clearContainer: true });
303
504
  }
304
505
 
305
506
  getText() {
@@ -355,6 +556,49 @@ export class EditorViewAdapter {
355
556
  this.editorView?.requestMeasure();
356
557
  }
357
558
 
559
+ flashRemoteRange({ from = 0, to = 0 } = {}, durationMs = REMOTE_UPDATE_FLASH_DURATION_MS) {
560
+ const state = this.editorView?.state;
561
+ if (!state || state.doc.length === 0) {
562
+ return false;
563
+ }
564
+
565
+ let start = Math.min(Math.max(Math.round(from), 0), state.doc.length);
566
+ let end = Math.min(Math.max(Math.round(to), 0), state.doc.length);
567
+ if (end <= start) {
568
+ if (start < state.doc.length) {
569
+ end = start + 1;
570
+ } else if (start > 0) {
571
+ start -= 1;
572
+ end = start + 1;
573
+ } else {
574
+ return false;
575
+ }
576
+ }
577
+
578
+ const rangeLength = Math.max(end - start, 0);
579
+ const showCaret = (
580
+ rangeLength <= REMOTE_UPDATE_CARET_MAX_LENGTH
581
+ && (Date.now() - this.lastLocalInputAt) > RECENT_LOCAL_INPUT_WINDOW_MS
582
+ );
583
+
584
+ this.editorView.dispatch({
585
+ effects: addRemoteUpdateFlashEffect.of({ from: start, showCaret, to: end }),
586
+ });
587
+
588
+ if (this.remoteUpdateFlashTimer) {
589
+ clearTimeout(this.remoteUpdateFlashTimer);
590
+ }
591
+
592
+ this.remoteUpdateFlashTimer = window.setTimeout(() => {
593
+ this.remoteUpdateFlashTimer = 0;
594
+ this.editorView?.dispatch({
595
+ effects: clearRemoteUpdateFlashEffect.of(null),
596
+ });
597
+ }, durationMs);
598
+
599
+ return true;
600
+ }
601
+
358
602
  getViewportState(viewportRatio = 0.35) {
359
603
  return {
360
604
  topLine: this.getTopVisibleLineNumber(viewportRatio),
@@ -30,6 +30,36 @@ function normalizeDiffScope(scope) {
30
30
  export function getHashRoute() {
31
31
  const params = getHashParams();
32
32
 
33
+ if (params.has('git-file-preview')) {
34
+ const historicalFilePath = params.get('git-file-preview') || null;
35
+ return {
36
+ currentFilePath: params.get('current') || historicalFilePath,
37
+ filePath: historicalFilePath,
38
+ hash: params.get('hash') || null,
39
+ type: 'git-file-preview',
40
+ };
41
+ }
42
+
43
+ if (params.has('git-file-history')) {
44
+ return {
45
+ filePath: params.get('git-file-history') || null,
46
+ type: 'git-file-history',
47
+ };
48
+ }
49
+
50
+ if (params.has('git-history')) {
51
+ return { type: 'git-history' };
52
+ }
53
+
54
+ if (params.has('git-commit')) {
55
+ return {
56
+ hash: params.get('git-commit') || null,
57
+ historyFilePath: params.get('history') || null,
58
+ path: params.get('path') || null,
59
+ type: 'git-commit',
60
+ };
61
+ }
62
+
33
63
  if (params.has('git-diff')) {
34
64
  const filePath = params.get('git-diff') || null;
35
65
  return {
@@ -63,3 +93,39 @@ export function navigateToGitDiff({ filePath = null, scope = 'all' } = {}) {
63
93
  params.set('scope', normalizeDiffScope(scope));
64
94
  window.location.hash = params.toString();
65
95
  }
96
+
97
+ export function navigateToGitCommit({ hash, path = null, historyFilePath = null } = {}) {
98
+ const normalizedHash = String(hash ?? '').trim();
99
+ const params = new URLSearchParams();
100
+ params.set('git-commit', normalizedHash);
101
+ if (historyFilePath) {
102
+ params.set('history', historyFilePath);
103
+ }
104
+ if (path) {
105
+ params.set('path', path);
106
+ }
107
+ window.location.hash = params.toString();
108
+ }
109
+
110
+ export function navigateToGitHistory() {
111
+ const params = new URLSearchParams();
112
+ params.set('git-history', '1');
113
+ window.location.hash = params.toString();
114
+ }
115
+
116
+ export function navigateToGitFileHistory({ filePath } = {}) {
117
+ const params = new URLSearchParams();
118
+ params.set('git-file-history', filePath ?? '');
119
+ window.location.hash = params.toString();
120
+ }
121
+
122
+ export function navigateToGitFilePreview({ hash, path, currentFilePath = null } = {}) {
123
+ const normalizedHash = String(hash ?? '').trim();
124
+ const params = new URLSearchParams();
125
+ params.set('git-file-preview', path ?? '');
126
+ if (currentFilePath) {
127
+ params.set('current', currentFilePath);
128
+ }
129
+ params.set('hash', normalizedHash);
130
+ window.location.hash = params.toString();
131
+ }