collabmd 0.1.19 → 0.1.21

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 (53) hide show
  1. package/README.md +13 -11
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  6. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  7. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  8. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  9. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  10. package/public/assets/js/excalidraw-editor.js +1 -1
  11. package/public/assets/js/main.js +102 -75
  12. package/public/assets/js/preview-render-worker.js +15 -15
  13. package/public/index.html +14 -4
  14. package/src/client/application/app-shell/git-feature.js +86 -19
  15. package/src/client/application/app-shell/ui-feature.js +4 -0
  16. package/src/client/application/app-shell-elements.js +1 -0
  17. package/src/client/bootstrap/collabmd-app-shell.js +16 -0
  18. package/src/client/domain/vault-utils.js +2 -2
  19. package/src/client/excalidraw-editor.js +2 -1
  20. package/src/client/infrastructure/editor-session.js +4 -0
  21. package/src/client/infrastructure/editor-view-adapter.js +245 -7
  22. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  23. package/src/client/presentation/backlinks-panel.js +157 -101
  24. package/src/client/presentation/comment-ui-controller.js +233 -10
  25. package/src/client/presentation/file-explorer-controller.js +15 -3
  26. package/src/client/presentation/file-explorer-view.js +152 -11
  27. package/src/client/presentation/git-panel-controller.js +73 -0
  28. package/src/client/presentation/outline-controller.js +25 -0
  29. package/src/client/styles/style.css +184 -9
  30. package/src/domain/wiki-link-resolver.js +16 -5
  31. package/src/domain/workspace-change.js +68 -0
  32. package/src/domain/workspace-room.js +3 -0
  33. package/src/server/create-app-server.js +41 -10
  34. package/src/server/domain/backlink-index.js +94 -1
  35. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  36. package/src/server/domain/collaboration/room-registry.js +64 -10
  37. package/src/server/infrastructure/git/errors.js +4 -1
  38. package/src/server/infrastructure/git/git-service.js +215 -1
  39. package/src/server/infrastructure/git/responses.js +12 -19
  40. package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
  41. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  42. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  43. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  44. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  45. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  46. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  47. package/src/server/infrastructure/http/http-response.js +65 -28
  48. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  49. package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
  50. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  51. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  52. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  53. package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +0 -22
@@ -11,10 +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
+ Decoration,
17
18
  EditorView,
19
+ WidgetType,
18
20
  crosshairCursor,
19
21
  drawSelection,
20
22
  highlightActiveLine,
@@ -33,6 +35,91 @@ import { plantUmlLanguage, plantUmlLanguageDescription } from '../domain/plantum
33
35
  import { handleImagePasteEvent } from './editor-paste-utils.js';
34
36
 
35
37
  const markdownCodeLanguages = [...languages, plantUmlLanguageDescription];
38
+ const pairedMatchingBracketMark = Decoration.mark({ class: 'cm-matchingBracket cm-matchingBracket-paired' });
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
+ });
93
+
94
+ function isBracketBeforeCaret(range, state) {
95
+ return state.selection.ranges.some((selectionRange) =>
96
+ selectionRange.empty && range.to === selectionRange.head
97
+ );
98
+ }
99
+
100
+ function renderBracketMatch(match, state) {
101
+ const decorations = [];
102
+
103
+ if (!match.matched) {
104
+ decorations.push(nonmatchingBracketMark.range(match.start.from, match.start.to));
105
+ if (match.end) {
106
+ decorations.push(nonmatchingBracketMark.range(match.end.from, match.end.to));
107
+ }
108
+ return decorations;
109
+ }
110
+
111
+ const shouldHideStartBracket = isBracketBeforeCaret(match.start, state);
112
+ const shouldHideEndBracket = match.end ? isBracketBeforeCaret(match.end, state) : false;
113
+
114
+ if (!shouldHideStartBracket) {
115
+ decorations.push(pairedMatchingBracketMark.range(match.start.from, match.start.to));
116
+ }
117
+ if (match.end && !shouldHideEndBracket) {
118
+ decorations.push(pairedMatchingBracketMark.range(match.end.from, match.end.to));
119
+ }
120
+
121
+ return decorations;
122
+ }
36
123
 
37
124
  function createEditorTheme(theme) {
38
125
  const activeLineBackground = theme === 'dark'
@@ -47,6 +134,21 @@ function createEditorTheme(theme) {
47
134
  const selectionBorder = theme === 'dark'
48
135
  ? 'oklch(from var(--color-primary) l c h / 0.65)'
49
136
  : 'oklch(from var(--color-primary) l c h / 0.5)';
137
+ const caretColor = theme === 'dark'
138
+ ? 'color-mix(in oklab, var(--color-primary) 78%, white)'
139
+ : 'color-mix(in oklab, var(--color-primary) 84%, black)';
140
+ const bracketPairBackground = theme === 'dark'
141
+ ? 'oklch(from var(--color-primary) l c h / 0.2)'
142
+ : 'oklch(from var(--color-primary) l c h / 0.12)';
143
+ const bracketPairOutline = theme === 'dark'
144
+ ? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0.85)'
145
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.7)';
146
+ const nonmatchingBracketBackground = theme === 'dark'
147
+ ? 'oklch(from var(--color-error) l c h / 0.12)'
148
+ : 'oklch(from var(--color-error) l c h / 0.1)';
149
+ const nonmatchingBracketOutline = theme === 'dark'
150
+ ? 'oklch(from var(--color-error) calc(l + 0.08) c h / 0.8)'
151
+ : 'oklch(from var(--color-error) calc(l - 0.04) c h / 0.7)';
50
152
 
51
153
  return EditorView.theme({
52
154
  '&': {
@@ -54,13 +156,14 @@ function createEditorTheme(theme) {
54
156
  color: 'var(--color-text)',
55
157
  },
56
158
  '.cm-content': {
57
- caretColor: 'var(--color-primary)',
159
+ caretColor,
58
160
  fontFamily: 'var(--font-mono)',
59
161
  padding: '16px 0',
60
162
  },
61
163
  '.cm-cursor, .cm-dropCursor': {
62
- borderLeftColor: 'var(--color-primary)',
63
- borderLeftWidth: '2px',
164
+ borderLeftColor: caretColor,
165
+ borderLeftWidth: '3px',
166
+ marginLeft: '-1px',
64
167
  },
65
168
  '.cm-foldPlaceholder': {
66
169
  backgroundColor: 'var(--color-surface-dynamic)',
@@ -86,12 +189,50 @@ function createEditorTheme(theme) {
86
189
  color: 'var(--color-text-muted)',
87
190
  },
88
191
  '.cm-matchingBracket': {
89
- backgroundColor: 'var(--color-primary-highlight)',
90
- outline: '1px solid var(--color-primary)',
192
+ borderRadius: '2px',
193
+ color: 'inherit',
194
+ },
195
+ '.cm-matchingBracket.cm-matchingBracket-paired': {
196
+ backgroundColor: bracketPairBackground,
197
+ outline: `1px solid ${bracketPairOutline}`,
198
+ },
199
+ '.cm-nonmatchingBracket': {
200
+ backgroundColor: nonmatchingBracketBackground,
201
+ outline: `1px solid ${nonmatchingBracketOutline}`,
202
+ borderRadius: '2px',
91
203
  },
92
204
  '.cm-selectionMatch': {
93
205
  backgroundColor: 'var(--color-primary-highlight)',
94
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
+ },
95
236
  '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
96
237
  backgroundColor: selectionBackground,
97
238
  },
@@ -99,6 +240,42 @@ function createEditorTheme(theme) {
99
240
  border: `1px solid ${selectionBorder}`,
100
241
  borderRadius: '2px',
101
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
+ },
102
279
  }, { dark: theme === 'dark' });
103
280
  }
104
281
 
@@ -146,6 +323,8 @@ export class EditorViewAdapter {
146
323
  this.syntaxThemeCompartment = new Compartment();
147
324
  this.lineWrappingCompartment = new Compartment();
148
325
  this.viewportFrame = 0;
326
+ this.remoteUpdateFlashTimer = 0;
327
+ this.lastLocalInputAt = 0;
149
328
  this.handleScroll = () => {
150
329
  if (this.viewportFrame) {
151
330
  return;
@@ -156,6 +335,9 @@ export class EditorViewAdapter {
156
335
  this.emitViewportChange();
157
336
  });
158
337
  };
338
+ this.handleLocalInputActivity = () => {
339
+ this.lastLocalInputAt = Date.now();
340
+ };
159
341
  }
160
342
 
161
343
  initialize({ awareness, filePath, undoManager, ytext }) {
@@ -191,7 +373,7 @@ export class EditorViewAdapter {
191
373
  EditorState.allowMultipleSelections.of(true),
192
374
  indentOnInput(),
193
375
  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
194
- bracketMatching(),
376
+ bracketMatching({ renderMatch: renderBracketMatch }),
195
377
  closeBrackets(),
196
378
  autocompletion({
197
379
  override: [wikiLinkCompletions(this.getFileList)],
@@ -218,6 +400,7 @@ export class EditorViewAdapter {
218
400
  this.themeCompartment.of(createEditorTheme(this.initialTheme)),
219
401
  this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
220
402
  this.lineWrappingCompartment.of(this.lineWrappingEnabled ? EditorView.lineWrapping : []),
403
+ remoteUpdateFlashField,
221
404
  yCollab(ytext, awareness, { undoManager }),
222
405
  updateListener,
223
406
  ],
@@ -225,6 +408,10 @@ export class EditorViewAdapter {
225
408
  });
226
409
 
227
410
  this.editorView.scrollDOM.addEventListener('scroll', this.handleScroll, { passive: true });
411
+ this.editorView.contentDOM.addEventListener('beforeinput', this.handleLocalInputActivity);
412
+ this.editorView.contentDOM.addEventListener('keydown', this.handleLocalInputActivity);
413
+ this.editorView.contentDOM.addEventListener('paste', this.handleLocalInputActivity);
414
+ this.editorView.contentDOM.addEventListener('compositionstart', this.handleLocalInputActivity);
228
415
  this.updateCursorInfo(this.editorView.state);
229
416
  this.onSelectionChanged?.(this.editorView.state);
230
417
  this.emitViewportChange();
@@ -235,6 +422,14 @@ export class EditorViewAdapter {
235
422
  cancelAnimationFrame(this.viewportFrame);
236
423
  this.viewportFrame = 0;
237
424
  }
425
+ if (this.remoteUpdateFlashTimer) {
426
+ clearTimeout(this.remoteUpdateFlashTimer);
427
+ this.remoteUpdateFlashTimer = 0;
428
+ }
429
+ this.editorView?.contentDOM?.removeEventListener('beforeinput', this.handleLocalInputActivity);
430
+ this.editorView?.contentDOM?.removeEventListener('keydown', this.handleLocalInputActivity);
431
+ this.editorView?.contentDOM?.removeEventListener('paste', this.handleLocalInputActivity);
432
+ this.editorView?.contentDOM?.removeEventListener('compositionstart', this.handleLocalInputActivity);
238
433
  this.editorView?.scrollDOM?.removeEventListener('scroll', this.handleScroll);
239
434
  this.editorView?.destroy();
240
435
  this.editorView = null;
@@ -297,6 +492,49 @@ export class EditorViewAdapter {
297
492
  this.editorView?.requestMeasure();
298
493
  }
299
494
 
495
+ flashRemoteRange({ from = 0, to = 0 } = {}, durationMs = REMOTE_UPDATE_FLASH_DURATION_MS) {
496
+ const state = this.editorView?.state;
497
+ if (!state || state.doc.length === 0) {
498
+ return false;
499
+ }
500
+
501
+ let start = Math.min(Math.max(Math.round(from), 0), state.doc.length);
502
+ let end = Math.min(Math.max(Math.round(to), 0), state.doc.length);
503
+ if (end <= start) {
504
+ if (start < state.doc.length) {
505
+ end = start + 1;
506
+ } else if (start > 0) {
507
+ start -= 1;
508
+ end = start + 1;
509
+ } else {
510
+ return false;
511
+ }
512
+ }
513
+
514
+ const rangeLength = Math.max(end - start, 0);
515
+ const showCaret = (
516
+ rangeLength <= REMOTE_UPDATE_CARET_MAX_LENGTH
517
+ && (Date.now() - this.lastLocalInputAt) > RECENT_LOCAL_INPUT_WINDOW_MS
518
+ );
519
+
520
+ this.editorView.dispatch({
521
+ effects: addRemoteUpdateFlashEffect.of({ from: start, showCaret, to: end }),
522
+ });
523
+
524
+ if (this.remoteUpdateFlashTimer) {
525
+ clearTimeout(this.remoteUpdateFlashTimer);
526
+ }
527
+
528
+ this.remoteUpdateFlashTimer = window.setTimeout(() => {
529
+ this.remoteUpdateFlashTimer = 0;
530
+ this.editorView?.dispatch({
531
+ effects: clearRemoteUpdateFlashEffect.of(null),
532
+ });
533
+ }, durationMs);
534
+
535
+ return true;
536
+ }
537
+
300
538
  getViewportState(viewportRatio = 0.35) {
301
539
  return {
302
540
  topLine: this.getTopVisibleLineNumber(viewportRatio),
@@ -0,0 +1,324 @@
1
+ import { WebsocketProvider } from 'y-websocket';
2
+ import * as Y from 'yjs';
3
+
4
+ import { normalizeWorkspaceEvent } from '../../domain/workspace-change.js';
5
+ import { WORKSPACE_ROOM_NAME } from '../../domain/workspace-room.js';
6
+ import { resolveWsBaseUrl } from '../domain/runtime-paths.js';
7
+ import { stopReconnectOnControlledClose } from './yjs-provider-reset-guard.js';
8
+
9
+ function createNode(entry) {
10
+ if (!entry?.path || !entry?.type) {
11
+ return null;
12
+ }
13
+
14
+ if (entry.nodeType === 'directory' || entry.type === 'directory') {
15
+ return {
16
+ children: [],
17
+ name: entry.name,
18
+ path: entry.path,
19
+ type: 'directory',
20
+ };
21
+ }
22
+
23
+ return {
24
+ name: entry.name,
25
+ path: entry.path,
26
+ type: entry.type,
27
+ };
28
+ }
29
+
30
+ function sortNodes(nodes = []) {
31
+ nodes.sort((left, right) => {
32
+ if (left.type === 'directory' && right.type !== 'directory') return -1;
33
+ if (left.type !== 'directory' && right.type === 'directory') return 1;
34
+ return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
35
+ });
36
+
37
+ nodes.forEach((node) => {
38
+ if (Array.isArray(node.children)) {
39
+ sortNodes(node.children);
40
+ }
41
+ });
42
+
43
+ return nodes;
44
+ }
45
+
46
+ function toEntryMap(value) {
47
+ if (value instanceof Map) {
48
+ return value;
49
+ }
50
+
51
+ return new Map(Object.entries(value ?? {}));
52
+ }
53
+
54
+ function sortPathsByDepth(values = [], direction = 'asc') {
55
+ const factor = direction === 'desc' ? -1 : 1;
56
+ return [...values].sort((left, right) => {
57
+ const depthDelta = left.split('/').length - right.split('/').length;
58
+ if (depthDelta !== 0) {
59
+ return depthDelta * factor;
60
+ }
61
+
62
+ return left.localeCompare(right, undefined, { sensitivity: 'base' }) * factor;
63
+ });
64
+ }
65
+
66
+ class WorkspaceTreeModel {
67
+ constructor() {
68
+ this.entriesByPath = new Map();
69
+ this.nodesByPath = new Map();
70
+ this.nodeParentPathByPath = new Map();
71
+ this.roots = [];
72
+ }
73
+
74
+ reset(rawEntries) {
75
+ this.entriesByPath = toEntryMap(rawEntries);
76
+ this.nodesByPath = new Map();
77
+ this.nodeParentPathByPath = new Map();
78
+ this.roots = [];
79
+
80
+ this.entriesByPath.forEach((entry) => {
81
+ const node = createNode(entry);
82
+ if (node) {
83
+ this.nodesByPath.set(entry.path, node);
84
+ }
85
+ });
86
+
87
+ sortPathsByDepth(Array.from(this.nodesByPath.keys())).forEach((pathValue) => {
88
+ const node = this.nodesByPath.get(pathValue);
89
+ if (node) {
90
+ this.attachNode(pathValue, node, this.entriesByPath.get(pathValue)?.parentPath || '');
91
+ }
92
+ });
93
+
94
+ sortNodes(this.roots);
95
+ return this.roots;
96
+ }
97
+
98
+ getTree() {
99
+ return this.roots;
100
+ }
101
+
102
+ applyMapChanges(changes, entriesMap) {
103
+ const deletePaths = [];
104
+ const upsertPaths = [];
105
+
106
+ changes.forEach((change, pathValue) => {
107
+ if (change.action === 'delete') {
108
+ deletePaths.push(pathValue);
109
+ } else {
110
+ upsertPaths.push(pathValue);
111
+ }
112
+ });
113
+
114
+ sortPathsByDepth(deletePaths, 'desc').forEach((pathValue) => {
115
+ this.removeEntry(pathValue);
116
+ });
117
+
118
+ sortPathsByDepth(upsertPaths).forEach((pathValue) => {
119
+ this.upsertEntry(pathValue, entriesMap.get(pathValue));
120
+ });
121
+
122
+ sortNodes(this.roots);
123
+ return this.roots;
124
+ }
125
+
126
+ removeEntry(pathValue) {
127
+ const node = this.nodesByPath.get(pathValue);
128
+ if (!node) {
129
+ this.entriesByPath.delete(pathValue);
130
+ this.nodeParentPathByPath.delete(pathValue);
131
+ return;
132
+ }
133
+
134
+ this.detachNode(pathValue, node);
135
+ this.nodesByPath.delete(pathValue);
136
+ this.entriesByPath.delete(pathValue);
137
+ this.nodeParentPathByPath.delete(pathValue);
138
+ }
139
+
140
+ upsertEntry(pathValue, entry) {
141
+ if (!entry?.path || !entry?.type) {
142
+ this.removeEntry(pathValue);
143
+ return;
144
+ }
145
+
146
+ const nextNode = createNode(entry);
147
+ if (!nextNode) {
148
+ this.removeEntry(pathValue);
149
+ return;
150
+ }
151
+
152
+ const existingNode = this.nodesByPath.get(pathValue);
153
+ let node = existingNode;
154
+ if (!node || (node.type === 'directory') !== (nextNode.type === 'directory')) {
155
+ if (node) {
156
+ this.detachNode(pathValue, node);
157
+ }
158
+ node = nextNode;
159
+ this.nodesByPath.set(pathValue, node);
160
+ } else {
161
+ node.name = nextNode.name;
162
+ node.path = nextNode.path;
163
+ node.type = nextNode.type;
164
+ if (node.type === 'directory' && !Array.isArray(node.children)) {
165
+ node.children = [];
166
+ }
167
+ if (node.type !== 'directory' && Array.isArray(node.children)) {
168
+ delete node.children;
169
+ }
170
+ }
171
+
172
+ this.entriesByPath.set(pathValue, entry);
173
+ this.attachNode(pathValue, node, entry.parentPath || '');
174
+
175
+ if (node.type === 'directory') {
176
+ this.rehomeChildren(pathValue);
177
+ }
178
+ }
179
+
180
+ detachNode(pathValue, node) {
181
+ const currentParentPath = this.nodeParentPathByPath.get(pathValue) || '';
182
+ const siblings = currentParentPath
183
+ ? this.nodesByPath.get(currentParentPath)?.children
184
+ : this.roots;
185
+ const index = siblings?.indexOf?.(node) ?? -1;
186
+ if (index >= 0) {
187
+ siblings.splice(index, 1);
188
+ }
189
+ this.nodeParentPathByPath.delete(pathValue);
190
+ }
191
+
192
+ attachNode(pathValue, node, requestedParentPath = '') {
193
+ const parentPath = this.nodesByPath.get(requestedParentPath)?.type === 'directory'
194
+ ? requestedParentPath
195
+ : '';
196
+ const currentParentPath = this.nodeParentPathByPath.get(pathValue);
197
+
198
+ if (currentParentPath === parentPath) {
199
+ return;
200
+ }
201
+
202
+ if (currentParentPath !== undefined) {
203
+ this.detachNode(pathValue, node);
204
+ }
205
+
206
+ const siblings = parentPath
207
+ ? this.nodesByPath.get(parentPath)?.children
208
+ : this.roots;
209
+ if (!siblings.includes(node)) {
210
+ siblings.push(node);
211
+ }
212
+ this.nodeParentPathByPath.set(pathValue, parentPath);
213
+ }
214
+
215
+ rehomeChildren(parentPath) {
216
+ this.entriesByPath.forEach((entry, pathValue) => {
217
+ if (entry?.parentPath !== parentPath) {
218
+ return;
219
+ }
220
+
221
+ const node = this.nodesByPath.get(pathValue);
222
+ if (node) {
223
+ this.attachNode(pathValue, node, parentPath);
224
+ }
225
+ });
226
+ }
227
+ }
228
+
229
+ export class WorkspaceSyncClient {
230
+ constructor({
231
+ onTreeChange = () => {},
232
+ onWorkspaceEvent = () => {},
233
+ } = {}) {
234
+ this.onTreeChange = onTreeChange;
235
+ this.onWorkspaceEvent = onWorkspaceEvent;
236
+ this.ydoc = new Y.Doc();
237
+ this.entries = this.ydoc.getMap('entries');
238
+ this.events = this.ydoc.getArray('events');
239
+ this.provider = null;
240
+ this._didInitialSync = false;
241
+ this.seenEventIds = new Set();
242
+ this.treeModel = new WorkspaceTreeModel();
243
+
244
+ this.handleEntriesChange = (event) => {
245
+ if (!this._didInitialSync || !event) {
246
+ return;
247
+ }
248
+
249
+ this.onTreeChange(this.treeModel.applyMapChanges(event.changes.keys, this.entries), {
250
+ changedPaths: Array.from(event.changes.keys.keys()),
251
+ reset: false,
252
+ });
253
+ };
254
+ this.handleEventsChange = () => {
255
+ if (!this._didInitialSync) {
256
+ this.primeEventCache();
257
+ return;
258
+ }
259
+
260
+ this.events.toArray().forEach((event) => {
261
+ const normalized = normalizeWorkspaceEvent(event);
262
+ if (!normalized || this.seenEventIds.has(normalized.id)) {
263
+ return;
264
+ }
265
+
266
+ this.seenEventIds.add(normalized.id);
267
+ this.onWorkspaceEvent(normalized);
268
+ });
269
+ };
270
+ }
271
+
272
+ connect() {
273
+ if (this.provider) {
274
+ return;
275
+ }
276
+
277
+ this._didInitialSync = false;
278
+ this.provider = new WebsocketProvider(resolveWsBaseUrl(), WORKSPACE_ROOM_NAME, this.ydoc, {
279
+ disableBc: true,
280
+ maxBackoffTime: 5000,
281
+ });
282
+ stopReconnectOnControlledClose(this.provider);
283
+
284
+ this.entries.observe(this.handleEntriesChange);
285
+ this.events.observe(this.handleEventsChange);
286
+ this.provider.on('sync', (isSynced) => {
287
+ if (!isSynced || this._didInitialSync) {
288
+ return;
289
+ }
290
+
291
+ this._didInitialSync = true;
292
+ this.primeEventCache();
293
+ this.onTreeChange(this.treeModel.reset(this.entries.toJSON()), {
294
+ changedPaths: [],
295
+ reset: true,
296
+ });
297
+ });
298
+ }
299
+
300
+ primeEventCache() {
301
+ this.events.toArray().forEach((event) => {
302
+ const normalized = normalizeWorkspaceEvent(event);
303
+ if (normalized) {
304
+ this.seenEventIds.add(normalized.id);
305
+ }
306
+ });
307
+ }
308
+
309
+ disconnect() {
310
+ this.entries.unobserve(this.handleEntriesChange);
311
+ this.events.unobserve(this.handleEventsChange);
312
+ this.provider?.disconnect();
313
+ this.provider?.destroy();
314
+ this.provider = null;
315
+ this._didInitialSync = false;
316
+ this.seenEventIds.clear();
317
+ this.treeModel.reset(new Map());
318
+ }
319
+
320
+ destroy() {
321
+ this.disconnect();
322
+ this.ydoc.destroy();
323
+ }
324
+ }