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.
- package/README.md +13 -11
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
- package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
- package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
- package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +102 -75
- package/public/assets/js/preview-render-worker.js +15 -15
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +86 -19
- package/src/client/application/app-shell/ui-feature.js +4 -0
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/bootstrap/collabmd-app-shell.js +16 -0
- package/src/client/domain/vault-utils.js +2 -2
- package/src/client/excalidraw-editor.js +2 -1
- package/src/client/infrastructure/editor-session.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +245 -7
- package/src/client/infrastructure/workspace-sync-client.js +324 -0
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-ui-controller.js +233 -10
- package/src/client/presentation/file-explorer-controller.js +15 -3
- package/src/client/presentation/file-explorer-view.js +152 -11
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +184 -9
- package/src/domain/wiki-link-resolver.js +16 -5
- package/src/domain/workspace-change.js +68 -0
- package/src/domain/workspace-room.js +3 -0
- package/src/server/create-app-server.js +41 -10
- package/src/server/domain/backlink-index.js +94 -1
- package/src/server/domain/collaboration/collaboration-room.js +191 -22
- package/src/server/domain/collaboration/room-registry.js +64 -10
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/git/responses.js +12 -19
- package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/http/create-request-handler.js +7 -0
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
- package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
- package/src/server/infrastructure/http/http-response.js +65 -28
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
- package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
- package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
- 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
|
|
159
|
+
caretColor,
|
|
58
160
|
fontFamily: 'var(--font-mono)',
|
|
59
161
|
padding: '16px 0',
|
|
60
162
|
},
|
|
61
163
|
'.cm-cursor, .cm-dropCursor': {
|
|
62
|
-
borderLeftColor:
|
|
63
|
-
borderLeftWidth: '
|
|
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
|
-
|
|
90
|
-
|
|
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
|
+
}
|