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
@@ -87,6 +87,7 @@ export class GitPanelController {
87
87
  constructor({
88
88
  enabled = true,
89
89
  onCommitStaged = () => {},
90
+ onOpenPullBackup = () => {},
90
91
  onPullBranch = () => {},
91
92
  onPushBranch = () => {},
92
93
  onRepoChange = () => {},
@@ -100,6 +101,7 @@ export class GitPanelController {
100
101
  } = {}) {
101
102
  this.enabled = enabled;
102
103
  this.onCommitStaged = onCommitStaged;
104
+ this.onOpenPullBackup = onOpenPullBackup;
103
105
  this.onPullBranch = onPullBranch;
104
106
  this.onPushBranch = onPushBranch;
105
107
  this.onRepoChange = onRepoChange;
@@ -111,6 +113,7 @@ export class GitPanelController {
111
113
  this.searchInput = searchInput;
112
114
  this.toastController = toastController;
113
115
  this.panel = document.getElementById('gitPanel');
116
+ this.pullBackups = [];
114
117
  this.status = null;
115
118
  this.active = false;
116
119
  this.refreshTimer = null;
@@ -147,6 +150,18 @@ export class GitPanelController {
147
150
  return;
148
151
  }
149
152
 
153
+ const pullBackupButton = event.target instanceof Element
154
+ ? event.target.closest('[data-git-pull-backup-path]')
155
+ : null;
156
+ if (pullBackupButton) {
157
+ const summaryPath = pullBackupButton.getAttribute('data-git-pull-backup-path');
158
+ if (!summaryPath) {
159
+ return;
160
+ }
161
+ this.onOpenPullBackup(summaryPath);
162
+ return;
163
+ }
164
+
150
165
  const actionButton = event.target instanceof Element
151
166
  ? event.target.closest('[data-git-file-action]')
152
167
  : null;
@@ -243,6 +258,7 @@ export class GitPanelController {
243
258
  sections: [],
244
259
  summary: { changedFiles: 0 },
245
260
  };
261
+ this.pullBackups = [];
246
262
  this.onRepoChange(false, this.status);
247
263
  this.render();
248
264
  return this.status;
@@ -256,6 +272,19 @@ export class GitPanelController {
256
272
  }
257
273
 
258
274
  this.status = data;
275
+ this.pullBackups = [];
276
+ if (data.isGitRepo) {
277
+ try {
278
+ const backupResponse = await fetch(resolveApiUrl('/git/pull-backups'));
279
+ const backupData = await backupResponse.json();
280
+ if (!backupResponse.ok) {
281
+ throw new Error(backupData.error || 'Failed to load pull backups');
282
+ }
283
+ this.pullBackups = Array.isArray(backupData.backups) ? backupData.backups : [];
284
+ } catch (backupError) {
285
+ console.error('[git-panel] Failed to load pull backups:', backupError);
286
+ }
287
+ }
259
288
  this.onRepoChange(Boolean(data.isGitRepo), data);
260
289
  this.render();
261
290
  return data;
@@ -267,6 +296,7 @@ export class GitPanelController {
267
296
  sections: [],
268
297
  summary: { changedFiles: 0 },
269
298
  };
299
+ this.pullBackups = [];
270
300
  this.onRepoChange(false, this.status);
271
301
  this.render();
272
302
  return this.status;
@@ -367,6 +397,47 @@ export class GitPanelController {
367
397
  `;
368
398
  }
369
399
 
400
+ renderPullBackupsSection() {
401
+ if (!Array.isArray(this.pullBackups) || this.pullBackups.length === 0) {
402
+ return '';
403
+ }
404
+
405
+ return `
406
+ <section class="git-section">
407
+ <button class="git-section-header" type="button" data-git-section-toggle="pull-backups">
408
+ ${chevronSvg(this.collapsedSections.has('pull-backups'))}
409
+ Pull Backups
410
+ <span class="git-section-count">${this.pullBackups.length}</span>
411
+ </button>
412
+ <div class="git-file-list${this.collapsedSections.has('pull-backups') ? ' hidden' : ''}">
413
+ ${this.pullBackups.map((backup) => this.renderPullBackup(backup)).join('')}
414
+ </div>
415
+ </section>
416
+ `;
417
+ }
418
+
419
+ renderPullBackup(backup) {
420
+ const createdAt = String(backup?.createdAt || '').replace('T', ' ').replace(/\.\d+Z?$/u, 'Z');
421
+ const fileCount = Number(backup?.fileCount || 0);
422
+
423
+ return `
424
+ <div class="git-file-row">
425
+ <button
426
+ class="git-file-item"
427
+ type="button"
428
+ data-git-pull-backup-path="${escapeHtml(backup.summaryPath || '')}"
429
+ >
430
+ ${fileIconSvg()}
431
+ <span class="git-file-copy">
432
+ <span class="git-file-name">Pull backup ${escapeHtml(backup.id || '')}</span>
433
+ <span class="git-file-path">${escapeHtml(`${createdAt} · ${backup.branch || 'HEAD'} · ${fileCount} file${fileCount === 1 ? '' : 's'}`)}</span>
434
+ </span>
435
+ <span class="git-status-badge modified">BK</span>
436
+ </button>
437
+ </div>
438
+ `;
439
+ }
440
+
370
441
  renderFile(file) {
371
442
  const isActive = this.selection.path === file.path && this.selection.scope === file.scope;
372
443
  const dirPath = getPathDir(file.path);
@@ -449,6 +520,7 @@ export class GitPanelController {
449
520
  }
450
521
 
451
522
  const branch = this.status.branch ?? {};
523
+ const pullBackupsMarkup = this.renderPullBackupsSection();
452
524
  const sectionMarkup = (this.status.sections ?? [])
453
525
  .map((section) => this.renderSection(section))
454
526
  .filter(Boolean)
@@ -492,6 +564,7 @@ export class GitPanelController {
492
564
  </button>
493
565
  </div>
494
566
  </div>
567
+ ${pullBackupsMarkup}
495
568
  ${sectionMarkup || '<div class="git-panel-empty">No local changes</div>'}
496
569
  ${hasChanges ? `
497
570
  <div class="git-panel-footer">
@@ -2,6 +2,7 @@ export class OutlineController {
2
2
  constructor({
3
3
  mobileBreakpointQuery = window.matchMedia('(max-width: 768px)'),
4
4
  onNavigateToHeading,
5
+ onWillOpen,
5
6
  } = {}) {
6
7
  this.outlineOpen = false;
7
8
  this.activeHeadingFrame = null;
@@ -9,16 +10,36 @@ export class OutlineController {
9
10
  this.headings = [];
10
11
  this.pinnedHeadingId = null;
11
12
  this.onNavigateToHeading = onNavigateToHeading;
13
+ this.onWillOpen = onWillOpen;
12
14
  this.panel = document.getElementById('outlinePanel');
13
15
  this.navigation = document.getElementById('outlineNav');
14
16
  this.previewContainer = document.getElementById('previewContainer');
15
17
  this.toggleButton = document.getElementById('outlineToggle');
16
18
  this.mobileBreakpointQuery = mobileBreakpointQuery;
17
19
  this.handlePreviewScroll = () => this.scheduleActiveHeadingUpdate();
20
+ this.handleDocumentPointerDown = (event) => {
21
+ if (!this.outlineOpen) {
22
+ return;
23
+ }
24
+
25
+ const target = event.target;
26
+ if (
27
+ target instanceof Node
28
+ && (
29
+ this.panel?.contains(target)
30
+ || this.toggleButton?.contains(target)
31
+ )
32
+ ) {
33
+ return;
34
+ }
35
+
36
+ this.close();
37
+ };
18
38
  }
19
39
 
20
40
  initialize() {
21
41
  this.toggleButton?.addEventListener('click', () => this.toggle());
42
+ document.addEventListener('pointerdown', this.handleDocumentPointerDown);
22
43
  }
23
44
 
24
45
  toggle() {
@@ -30,6 +51,10 @@ export class OutlineController {
30
51
  }
31
52
 
32
53
  setOpenState(nextState) {
54
+ if (nextState && !this.outlineOpen) {
55
+ this.onWillOpen?.();
56
+ }
57
+
33
58
  this.outlineOpen = nextState;
34
59
  this.panel?.classList.toggle('hidden', !this.outlineOpen);
35
60
  this.toggleButton?.classList.toggle('active', this.outlineOpen);
@@ -2265,6 +2265,12 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2265
2265
 
2266
2266
  /* Preview body (contains preview + outline side by side) */
2267
2267
  .preview-body {
2268
+ --preview-floating-sidebar-gap: var(--space-3);
2269
+ --preview-floating-comments-width: min(300px, 34vw);
2270
+ --preview-floating-outline-width: 220px;
2271
+ --preview-content-max-width: 720px;
2272
+ --preview-comment-rail-width: 0px;
2273
+ --preview-comment-rail-inset: 10px;
2268
2274
  display: flex;
2269
2275
  flex: 1;
2270
2276
  overflow: hidden;
@@ -2283,7 +2289,7 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2283
2289
  }
2284
2290
 
2285
2291
  .preview-content {
2286
- padding: var(--space-6) var(--space-6) var(--space-12);
2292
+ padding: var(--space-6) calc(var(--space-6) + var(--preview-comment-rail-width)) var(--space-12) var(--space-6);
2287
2293
  max-width: 720px;
2288
2294
  margin: 0 auto;
2289
2295
  min-width: 0;
@@ -2298,6 +2304,16 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2298
2304
  color: var(--color-text-muted);
2299
2305
  }
2300
2306
 
2307
+ @media (min-width: 769px) {
2308
+ .preview-body {
2309
+ --preview-comment-rail-width: 52px;
2310
+ }
2311
+
2312
+ .preview-content {
2313
+ padding-bottom: calc(var(--space-12) + 96px);
2314
+ }
2315
+ }
2316
+
2301
2317
  .preview-content.is-excalidraw-file-preview {
2302
2318
  display: flex;
2303
2319
  width: 100%;
@@ -3180,7 +3196,7 @@ body.plantuml-maximized-open::before {
3180
3196
  font-size: 10px;
3181
3197
  font-weight: 700;
3182
3198
  pointer-events: auto;
3183
- transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease;
3199
+ transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease;
3184
3200
  }
3185
3201
 
3186
3202
  .comment-editor-badge.is-active,
@@ -3190,6 +3206,26 @@ body.plantuml-maximized-open::before {
3190
3206
  color: var(--color-text-inverse);
3191
3207
  }
3192
3208
 
3209
+ .comment-preview-badge {
3210
+ right: var(--preview-comment-rail-inset);
3211
+ }
3212
+
3213
+ .comment-editor-badge.is-passive,
3214
+ .comment-preview-badge.is-passive {
3215
+ opacity: 0.48;
3216
+ background: color-mix(in srgb, var(--color-surface) 76%, var(--color-comment) 24%);
3217
+ border-color: oklch(from var(--color-comment) l c h / 0.16);
3218
+ box-shadow: none;
3219
+ }
3220
+
3221
+ .comment-editor-badge.is-hovered,
3222
+ .comment-preview-badge.is-hovered {
3223
+ opacity: 1;
3224
+ background: color-mix(in srgb, var(--color-surface) 56%, var(--color-comment) 44%);
3225
+ border-color: oklch(from var(--color-comment) l c h / 0.28);
3226
+ color: var(--color-text-inverse);
3227
+ }
3228
+
3193
3229
  .comment-marker-icon {
3194
3230
  display: inline-flex;
3195
3231
  width: 12px;
@@ -3236,6 +3272,24 @@ body.plantuml-maximized-open::before {
3236
3272
  border-radius: 6px;
3237
3273
  background: var(--color-comment-soft);
3238
3274
  border: 1px solid oklch(from var(--color-comment) l c h / 0.16);
3275
+ opacity: 0.56;
3276
+ transition: opacity 140ms ease, background-color 140ms ease, border-color 140ms ease;
3277
+ }
3278
+
3279
+ .comment-preview-highlight.is-passive {
3280
+ opacity: 0.32;
3281
+ }
3282
+
3283
+ .comment-preview-highlight.is-hovered {
3284
+ opacity: 0.72;
3285
+ background: color-mix(in srgb, var(--color-comment-soft) 74%, var(--color-comment) 26%);
3286
+ border-color: oklch(from var(--color-comment) l c h / 0.24);
3287
+ }
3288
+
3289
+ .comment-preview-highlight.is-active {
3290
+ opacity: 0.9;
3291
+ background: color-mix(in srgb, var(--color-comment-soft) 62%, var(--color-comment) 38%);
3292
+ border-color: oklch(from var(--color-comment) l c h / 0.3);
3239
3293
  }
3240
3294
 
3241
3295
  .comment-card-root {
@@ -3670,14 +3724,45 @@ body.plantuml-maximized-open::before {
3670
3724
 
3671
3725
  /* ===== BACKLINKS PANEL ===== */
3672
3726
 
3727
+ .backlinks-panel.hidden,
3728
+ .backlinks-panel-layer.hidden {
3729
+ display: none;
3730
+ }
3673
3731
  .backlinks-panel {
3732
+ min-width: 0;
3733
+ }
3734
+ .backlinks-panel-layer {
3735
+ position: absolute;
3736
+ left: max(
3737
+ var(--space-4),
3738
+ calc((100% - min(var(--preview-content-max-width), 100%)) / 2)
3739
+ );
3740
+ bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
3741
+ width: min(320px, calc(min(var(--preview-content-max-width), 100%) - var(--space-8)));
3742
+ z-index: 14;
3743
+ pointer-events: none;
3744
+ }
3745
+ .backlinks-panel-dock {
3746
+ display: flex;
3747
+ flex-direction: column-reverse;
3748
+ border: 1px solid var(--color-divider);
3749
+ border-radius: 18px;
3750
+ background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-offset) 16%);
3751
+ box-shadow: 0 14px 34px oklch(0 0 0 / 0.18);
3752
+ backdrop-filter: blur(16px);
3753
+ overflow: hidden;
3754
+ pointer-events: auto;
3755
+ transition: box-shadow 160ms ease, transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
3756
+ }
3757
+ .backlinks-panel-dock:hover {
3758
+ transform: translateY(-1px);
3759
+ box-shadow: 0 18px 40px oklch(0 0 0 / 0.2);
3760
+ }
3761
+ .backlinks-panel-inline {
3674
3762
  border-top: 1px solid var(--color-divider);
3675
3763
  margin-top: var(--space-6);
3676
3764
  padding: 0 var(--space-4);
3677
3765
  }
3678
- .backlinks-panel.hidden {
3679
- display: none;
3680
- }
3681
3766
  .backlinks-header {
3682
3767
  display: flex;
3683
3768
  align-items: center;
@@ -3693,6 +3778,10 @@ body.plantuml-maximized-open::before {
3693
3778
  .backlinks-header:hover {
3694
3779
  color: var(--color-text);
3695
3780
  }
3781
+ .backlinks-panel-dock .backlinks-header {
3782
+ padding: 11px 14px;
3783
+ background: color-mix(in srgb, var(--color-surface-offset) 76%, var(--color-bg) 24%);
3784
+ }
3696
3785
  .backlinks-header-empty {
3697
3786
  cursor: default;
3698
3787
  opacity: 0.5;
@@ -3709,15 +3798,20 @@ body.plantuml-maximized-open::before {
3709
3798
  }
3710
3799
  .backlinks-toggle {
3711
3800
  flex: 1;
3801
+ min-width: 0;
3802
+ overflow: hidden;
3803
+ text-overflow: ellipsis;
3804
+ white-space: nowrap;
3712
3805
  }
3713
3806
  .backlinks-count {
3714
3807
  font-size: var(--text-xs);
3715
3808
  font-weight: 600;
3716
3809
  color: var(--color-primary);
3717
- background: oklch(from var(--color-primary) l c h / 0.12);
3810
+ background: oklch(from var(--color-primary) l c h / 0.14);
3718
3811
  border-radius: var(--radius-full);
3719
- padding: 1px 8px;
3720
- min-width: 0;
3812
+ padding: 1px 7px;
3813
+ min-width: 22px;
3814
+ text-align: center;
3721
3815
  }
3722
3816
  .backlinks-count:empty {
3723
3817
  display: none;
@@ -3727,16 +3821,31 @@ body.plantuml-maximized-open::before {
3727
3821
  max-height: 0;
3728
3822
  transition: max-height 200ms ease;
3729
3823
  }
3730
- .backlinks-panel.expanded .backlinks-body {
3824
+ .backlinks-panel-inline.expanded .backlinks-body {
3731
3825
  max-height: 600px;
3732
3826
  overflow-y: auto;
3733
3827
  }
3828
+ .backlinks-panel-dock .backlinks-body {
3829
+ background: color-mix(in srgb, var(--color-surface) 94%, var(--color-surface-offset) 6%);
3830
+ }
3831
+ .backlinks-panel-dock.expanded .backlinks-body {
3832
+ max-height: min(44vh, 400px);
3833
+ overflow-y: auto;
3834
+ border-bottom: 1px solid color-mix(in srgb, var(--color-divider) 78%, var(--color-primary) 22%);
3835
+ }
3836
+ .backlinks-panel-dock.expanded {
3837
+ border-color: color-mix(in srgb, var(--color-divider) 72%, var(--color-primary) 28%);
3838
+ background: color-mix(in srgb, var(--color-surface) 90%, var(--color-surface-offset) 10%);
3839
+ }
3734
3840
  .backlinks-list {
3735
3841
  padding-bottom: var(--space-4);
3736
3842
  display: flex;
3737
3843
  flex-direction: column;
3738
3844
  gap: var(--space-2);
3739
3845
  }
3846
+ .backlinks-panel-dock .backlinks-list {
3847
+ padding: 10px;
3848
+ }
3740
3849
  .backlink-item {
3741
3850
  display: block;
3742
3851
  width: 100%;
@@ -3748,6 +3857,10 @@ body.plantuml-maximized-open::before {
3748
3857
  cursor: pointer;
3749
3858
  transition: border-color var(--transition-interactive), background var(--transition-interactive);
3750
3859
  }
3860
+ .backlinks-panel-dock .backlink-item {
3861
+ border-radius: 12px;
3862
+ background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-offset) 12%);
3863
+ }
3751
3864
  .backlink-item:hover {
3752
3865
  border-color: var(--color-primary);
3753
3866
  background: oklch(from var(--color-primary) l c h / 0.04);
@@ -3789,6 +3902,12 @@ body.plantuml-maximized-open::before {
3789
3902
  color: var(--color-text-faint);
3790
3903
  }
3791
3904
 
3905
+ @media (min-width: 769px) {
3906
+ .backlinks-panel-inline {
3907
+ display: none !important;
3908
+ }
3909
+ }
3910
+
3792
3911
  .outline-panel {
3793
3912
  width: 220px;
3794
3913
  flex-shrink: 0;
@@ -3889,6 +4008,9 @@ body.plantuml-maximized-open::before {
3889
4008
  .editor-layout[data-view="preview"] .preview-pane {
3890
4009
  flex: 1;
3891
4010
  }
4011
+ .editor-layout[data-view="preview"] .preview-body {
4012
+ --preview-content-max-width: 1080px;
4013
+ }
3892
4014
  .editor-layout[data-view="preview"] .preview-content {
3893
4015
  max-width: 1080px;
3894
4016
  }
@@ -3923,6 +4045,55 @@ body.plantuml-maximized-open::before {
3923
4045
  .editor-layout[data-view="preview"] .preview-content p:has(> .plantuml-shell:only-child) {
3924
4046
  max-width: none;
3925
4047
  }
4048
+ @media (min-width: 769px) {
4049
+ .preview-body > .comments-drawer,
4050
+ .preview-body > .outline-panel {
4051
+ position: absolute;
4052
+ top: var(--preview-floating-sidebar-gap);
4053
+ right: var(--preview-floating-sidebar-gap);
4054
+ bottom: var(--preview-floating-sidebar-gap);
4055
+ z-index: 20;
4056
+ overflow: hidden;
4057
+ border: 1px solid var(--color-divider);
4058
+ border-radius: calc(var(--radius-lg) + 2px);
4059
+ box-shadow: var(--shadow-lg);
4060
+ backdrop-filter: blur(14px);
4061
+ }
4062
+
4063
+ .preview-body > .comments-drawer {
4064
+ width: var(--preview-floating-comments-width);
4065
+ min-width: 240px;
4066
+ border-left: 1px solid var(--color-divider);
4067
+ }
4068
+
4069
+ .preview-body > .outline-panel {
4070
+ width: var(--preview-floating-outline-width);
4071
+ border-left: 1px solid var(--color-divider);
4072
+ }
4073
+
4074
+ .preview-body > .comments-drawer.hidden,
4075
+ .preview-body > .outline-panel.hidden {
4076
+ display: flex !important;
4077
+ opacity: 0;
4078
+ pointer-events: none;
4079
+ transform: translateX(12px);
4080
+ visibility: hidden;
4081
+ }
4082
+
4083
+ .preview-body > .comments-drawer:not(.hidden),
4084
+ .preview-body > .outline-panel:not(.hidden) {
4085
+ opacity: 1;
4086
+ transform: translateX(0);
4087
+ visibility: visible;
4088
+ }
4089
+
4090
+ .preview-body:has(> .outline-panel:not(.hidden)) > .comments-drawer:not(.hidden) {
4091
+ right: calc(
4092
+ var(--preview-floating-sidebar-gap) * 2
4093
+ + var(--preview-floating-outline-width)
4094
+ );
4095
+ }
4096
+ }
3926
4097
  .editor-layout[data-view="editor"] .editor-pane {
3927
4098
  flex: 1;
3928
4099
  }
@@ -4022,6 +4193,10 @@ body.plantuml-maximized-open::before {
4022
4193
  padding: var(--space-4);
4023
4194
  }
4024
4195
 
4196
+ .backlinks-panel-layer {
4197
+ display: none !important;
4198
+ }
4199
+
4025
4200
  .diff-toolbar {
4026
4201
  flex-wrap: wrap;
4027
4202
  padding: var(--space-2) var(--space-3);
@@ -66,10 +66,21 @@ export function resolveWikiTargetPath(target, files) {
66
66
  }
67
67
 
68
68
  const rawTarget = String(target ?? '').trim();
69
+ let fallbackSuffixMatch = null;
69
70
 
70
- return files.find((filePath) => (
71
- filePath === normalizedTarget
72
- || filePath.endsWith(`/${normalizedTarget}`)
73
- || filePath.replace(/\.md$/i, '') === rawTarget
74
- )) ?? null;
71
+ for (const filePath of files) {
72
+ if (filePath === normalizedTarget) {
73
+ return filePath;
74
+ }
75
+
76
+ if (filePath.replace(/\.md$/i, '') === rawTarget) {
77
+ return filePath;
78
+ }
79
+
80
+ if (!fallbackSuffixMatch && filePath.endsWith(`/${normalizedTarget}`)) {
81
+ fallbackSuffixMatch = filePath;
82
+ }
83
+ }
84
+
85
+ return fallbackSuffixMatch;
75
86
  }
@@ -0,0 +1,68 @@
1
+ function uniquePaths(values = []) {
2
+ return Array.from(new Set((values ?? []).filter(Boolean)));
3
+ }
4
+
5
+ function uniqueRenames(values = []) {
6
+ return Array.from(new Map(
7
+ (values ?? [])
8
+ .filter((entry) => entry?.oldPath && entry?.newPath && entry.oldPath !== entry.newPath)
9
+ .map((entry) => [`${entry.oldPath}:${entry.newPath}`, {
10
+ newPath: entry.newPath,
11
+ oldPath: entry.oldPath,
12
+ }]),
13
+ ).values());
14
+ }
15
+
16
+ function normalizeHighlightRanges(values = []) {
17
+ return (values ?? [])
18
+ .filter((entry) => entry?.path && Number.isFinite(entry?.from) && Number.isFinite(entry?.to))
19
+ .map((entry) => ({
20
+ from: Math.max(0, Math.round(entry.from)),
21
+ path: entry.path,
22
+ to: Math.max(0, Math.round(entry.to)),
23
+ }));
24
+ }
25
+
26
+ export function createWorkspaceChange({
27
+ changedPaths = [],
28
+ deletedPaths = [],
29
+ refreshExplorer = true,
30
+ renamedPaths = [],
31
+ } = {}) {
32
+ return {
33
+ changedPaths: uniquePaths(changedPaths),
34
+ deletedPaths: uniquePaths(deletedPaths),
35
+ refreshExplorer: refreshExplorer !== false,
36
+ renamedPaths: uniqueRenames(renamedPaths),
37
+ };
38
+ }
39
+
40
+ export function createEmptyWorkspaceChange() {
41
+ return createWorkspaceChange();
42
+ }
43
+
44
+ export function hasWorkspaceMutation(workspaceChange = {}) {
45
+ return Boolean(
46
+ (workspaceChange.changedPaths?.length ?? 0) > 0
47
+ || (workspaceChange.deletedPaths?.length ?? 0) > 0
48
+ || (workspaceChange.renamedPaths?.length ?? 0) > 0,
49
+ );
50
+ }
51
+
52
+ export function normalizeWorkspaceEvent(event) {
53
+ if (!event || typeof event !== 'object' || typeof event.id !== 'string') {
54
+ return null;
55
+ }
56
+
57
+ return {
58
+ action: typeof event.action === 'string' ? event.action : 'workspace',
59
+ createdAt: Number.isFinite(event.createdAt) ? event.createdAt : Date.now(),
60
+ highlightRanges: normalizeHighlightRanges(event.highlightRanges),
61
+ id: event.id,
62
+ origin: typeof event.origin === 'string' ? event.origin : 'api',
63
+ reloadRequiredPaths: uniquePaths(event.reloadRequiredPaths),
64
+ requestId: typeof event.requestId === 'string' ? event.requestId : null,
65
+ sourceRef: typeof event.sourceRef === 'string' ? event.sourceRef : null,
66
+ workspaceChange: createWorkspaceChange(event.workspaceChange),
67
+ };
68
+ }
@@ -0,0 +1,3 @@
1
+ export const WORKSPACE_ROOM_NAME = '__workspace__';
2
+ export const WORKSPACE_EVENT_MAX_MESSAGES = 40;
3
+
@@ -11,6 +11,9 @@ import { RoomRegistry } from './domain/collaboration/room-registry.js';
11
11
  import { createRequestHandler } from './infrastructure/http/create-request-handler.js';
12
12
  import { VaultFileStore } from './infrastructure/persistence/vault-file-store.js';
13
13
  import { attachCollaborationGateway } from './infrastructure/websocket/attach-collaboration-gateway.js';
14
+ import { WORKSPACE_ROOM_NAME } from '../domain/workspace-room.js';
15
+ import { FileSystemSyncService } from './infrastructure/workspace/file-system-sync-service.js';
16
+ import { WorkspaceMutationCoordinator } from './infrastructure/workspace/workspace-mutation-coordinator.js';
14
17
 
15
18
  function getDisplayHost(host) {
16
19
  return host === '127.0.0.1' ? 'localhost' : host;
@@ -45,18 +48,39 @@ export function createAppServer(config = loadConfig()) {
45
48
  enabled: config.gitEnabled,
46
49
  vaultDir: config.vaultDir,
47
50
  });
51
+ let workspaceMutationCoordinator = null;
48
52
  const roomRegistry = new RoomRegistry({
49
- createRoom: ({ name, onEmpty }) => new CollaborationRoom({
50
- documentStore: new CollaborationDocumentStore({
51
- backlinkIndex: name === '__lobby__' ? null : backlinkIndex,
53
+ createRoom: ({ name, onEmpty }) => {
54
+ const room = new CollaborationRoom({
55
+ documentStore: new CollaborationDocumentStore({
56
+ backlinkIndex: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : backlinkIndex,
57
+ name,
58
+ vaultFileStore: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : vaultFileStore,
59
+ }),
60
+ idleGraceMs: config.wsRoomIdleGraceMs,
61
+ maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
52
62
  name,
53
- vaultFileStore: name === '__lobby__' ? null : vaultFileStore,
54
- }),
55
- idleGraceMs: config.wsRoomIdleGraceMs,
56
- maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
57
- name,
58
- onEmpty,
59
- }),
63
+ onEmpty,
64
+ });
65
+
66
+ if (name === WORKSPACE_ROOM_NAME && workspaceMutationCoordinator?.workspaceState) {
67
+ room.replaceWorkspaceEntries(workspaceMutationCoordinator.workspaceState.entries, {
68
+ generatedAt: workspaceMutationCoordinator.workspaceState.scannedAt,
69
+ });
70
+ }
71
+
72
+ return room;
73
+ },
74
+ });
75
+ workspaceMutationCoordinator = new WorkspaceMutationCoordinator({
76
+ backlinkIndex,
77
+ roomRegistry,
78
+ vaultFileStore,
79
+ });
80
+ vaultFileStore.setManagedWriteTracker(workspaceMutationCoordinator);
81
+ const fileSystemSyncService = new FileSystemSyncService({
82
+ mutationCoordinator: workspaceMutationCoordinator,
83
+ vaultFileStore,
60
84
  });
61
85
  const requestHandler = createRequestHandler(
62
86
  config,
@@ -66,6 +90,8 @@ export function createAppServer(config = loadConfig()) {
66
90
  roomRegistry,
67
91
  plantUmlRenderer,
68
92
  gitService,
93
+ workspaceMutationCoordinator,
94
+ fileSystemSyncService,
69
95
  );
70
96
  const httpServer = createServer((req, res) => {
71
97
  requestHandler(req, res).catch((error) => {
@@ -95,6 +121,8 @@ export function createAppServer(config = loadConfig()) {
95
121
  async function listen() {
96
122
  vaultFileCount = await vaultFileStore.countVaultFiles();
97
123
  await backlinkIndex.build();
124
+ await workspaceMutationCoordinator.initialize();
125
+ await fileSystemSyncService.start();
98
126
 
99
127
  return new Promise((resolve, reject) => {
100
128
  httpServer.once('error', reject);
@@ -118,6 +146,7 @@ export function createAppServer(config = loadConfig()) {
118
146
 
119
147
  shutdownPromise = (async () => {
120
148
  await collaborationGateway.close();
149
+ await fileSystemSyncService.close();
121
150
  await roomRegistry.reset();
122
151
  await Promise.all([
123
152
  closeHttpServer(httpServer),
@@ -135,7 +164,9 @@ export function createAppServer(config = loadConfig()) {
135
164
  httpServer,
136
165
  listen,
137
166
  roomRegistry,
167
+ workspaceMutationCoordinator,
138
168
  authService,
169
+ fileSystemSyncService,
139
170
  gitService,
140
171
  vaultFileStore,
141
172
  get vaultFileCount() { return vaultFileCount; },