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
@@ -2,62 +2,95 @@ import { escapeHtml } from '../domain/vault-utils.js';
2
2
  import { resolveApiUrl } from '../domain/runtime-paths.js';
3
3
 
4
4
  /**
5
- * BacklinksPanel — a collapsible panel below the preview showing
6
- * which files link to the currently open file ("Linked Mentions").
5
+ * BacklinksPanel — renders "Linked Mentions" for the current file.
7
6
  *
8
- * Each mention shows the source file name and the context line
9
- * containing the [[wiki link]]. Clicking a mention navigates to that file.
7
+ * Desktop uses a docked bottom-left panel outside the preview scroll flow.
8
+ * Mobile keeps an inline collapsible panel at the end of the preview content.
10
9
  */
11
10
  export class BacklinksPanel {
12
- constructor({ panelElement, onFileSelect }) {
13
- this.panel = panelElement;
11
+ constructor({
12
+ documentRef = document,
13
+ inlinePanelElement = null,
14
+ onFileSelect,
15
+ panelElement,
16
+ }) {
17
+ this.documentRef = documentRef;
18
+ this.panelRoot = panelElement;
19
+ this.inlinePanel = inlinePanelElement;
14
20
  this.onFileSelect = onFileSelect;
15
21
 
16
- this.header = this.panel?.querySelector('.backlinks-header');
17
- this.toggle = this.panel?.querySelector('.backlinks-toggle');
18
- this.countBadge = this.panel?.querySelector('.backlinks-count');
19
- this.body = this.panel?.querySelector('.backlinks-body');
20
- this.list = this.panel?.querySelector('.backlinks-list');
21
-
22
22
  this._expanded = false;
23
23
  this._currentFile = null;
24
24
  this._fetchController = null;
25
25
  this._backlinks = [];
26
26
 
27
+ const desktopPanel = this.panelRoot?.querySelector('[data-backlinks-variant="dock"]') ?? this.panelRoot;
28
+ this.panels = [
29
+ this._createPanelRef(desktopPanel),
30
+ this._createPanelRef(this.inlinePanel),
31
+ ].filter(Boolean);
32
+
33
+ this.handleDocumentPointerDown = (event) => {
34
+ if (!this._expanded) {
35
+ return;
36
+ }
37
+
38
+ const target = event?.target;
39
+ if (target && this.panels.some(({ panel }) => panel?.contains?.(target))) {
40
+ return;
41
+ }
42
+
43
+ this.close();
44
+ };
45
+
46
+ this.handleDocumentKeyDown = (event) => {
47
+ if (!this._expanded || event?.key !== 'Escape') {
48
+ return;
49
+ }
50
+
51
+ event.preventDefault?.();
52
+ this.close();
53
+ };
54
+
27
55
  this.bindEvents();
28
56
  }
29
57
 
30
58
  bindEvents() {
31
- this.header?.addEventListener('click', () => {
32
- if (this._backlinks.length === 0) return;
33
- this._expanded = !this._expanded;
34
- this._applyExpandState();
35
- });
59
+ this.panels.forEach((refs) => {
60
+ refs.header?.addEventListener('click', () => {
61
+ if (this._backlinks.length === 0) return;
62
+ this._expanded = !this._expanded;
63
+ this._applyExpandState();
64
+ });
36
65
 
37
- this.header?.addEventListener('keydown', (event) => {
38
- if (event.key !== 'Enter' && event.key !== ' ') {
39
- return;
40
- }
66
+ refs.header?.addEventListener('keydown', (event) => {
67
+ if (event.key !== 'Enter' && event.key !== ' ') {
68
+ return;
69
+ }
41
70
 
42
- event.preventDefault();
43
- this.header.click();
71
+ event.preventDefault();
72
+ refs.header.click();
73
+ });
44
74
  });
75
+
76
+ this.documentRef?.addEventListener?.('pointerdown', this.handleDocumentPointerDown);
77
+ this.documentRef?.addEventListener?.('keydown', this.handleDocumentKeyDown);
45
78
  }
46
79
 
47
- /**
48
- * Load backlinks for a file. Call on every file open and after
49
- * content changes (debounced).
50
- */
51
80
  async load(filePath) {
81
+ const fileChanged = filePath !== this._currentFile;
52
82
  this._currentFile = filePath;
53
83
 
84
+ if (fileChanged) {
85
+ this._expanded = false;
86
+ }
87
+
54
88
  if (!filePath) {
55
89
  this._backlinks = [];
56
90
  this._render();
57
91
  return;
58
92
  }
59
93
 
60
- // Abort any in-flight fetch
61
94
  this._fetchController?.abort();
62
95
  this._fetchController = new AbortController();
63
96
 
@@ -68,7 +101,6 @@ export class BacklinksPanel {
68
101
  );
69
102
  const data = await response.json();
70
103
 
71
- // Guard against stale responses (user switched files)
72
104
  if (this._currentFile !== filePath) return;
73
105
 
74
106
  this._backlinks = data.backlinks ?? [];
@@ -81,7 +113,6 @@ export class BacklinksPanel {
81
113
  this._render();
82
114
  }
83
115
 
84
- /** Clear the panel (e.g. when navigating to empty state). */
85
116
  clear() {
86
117
  this._currentFile = null;
87
118
  this._backlinks = [];
@@ -89,100 +120,125 @@ export class BacklinksPanel {
89
120
  this._render();
90
121
  }
91
122
 
92
- // --- Private ---
93
-
94
- _render() {
95
- if (!this.panel) return;
123
+ close() {
124
+ if (!this._expanded) {
125
+ return;
126
+ }
96
127
 
97
- const count = this._backlinks.length;
98
- const label = count === 1 ? '1 Linked Mention' : `${count} Linked Mentions`;
128
+ this._expanded = false;
129
+ this._applyExpandState();
130
+ }
99
131
 
100
- if (this.countBadge) {
101
- this.countBadge.textContent = count > 0 ? String(count) : '';
132
+ _createPanelRef(panel) {
133
+ if (!panel) {
134
+ return null;
102
135
  }
103
136
 
104
- if (this.toggle) {
105
- this.toggle.textContent = label;
106
- }
137
+ return {
138
+ panel,
139
+ header: panel.querySelector('.backlinks-header'),
140
+ toggle: panel.querySelector('.backlinks-toggle'),
141
+ countBadge: panel.querySelector('.backlinks-count'),
142
+ body: panel.querySelector('.backlinks-body'),
143
+ list: panel.querySelector('.backlinks-list'),
144
+ };
145
+ }
107
146
 
108
- // Hide entire panel when no current file
109
- this.panel.classList.toggle('hidden', !this._currentFile);
147
+ _render() {
148
+ const count = this._backlinks.length;
149
+ const label = count === 1 ? 'Linked Mention' : 'Linked Mentions';
150
+ const shouldShow = Boolean(this._currentFile) && count > 0;
110
151
 
111
- // Disable header click when 0 backlinks
112
- this.header?.classList.toggle('backlinks-header-empty', count === 0);
152
+ this.panelRoot?.classList?.toggle('hidden', !shouldShow);
153
+ this.inlinePanel?.classList?.toggle('hidden', !shouldShow);
113
154
 
114
- if (count === 0) {
155
+ if (!shouldShow) {
115
156
  this._expanded = false;
116
- this._applyExpandState();
117
- if (this.list) this.list.innerHTML = '';
118
- return;
119
157
  }
120
158
 
121
- this._renderList();
159
+ this.panels.forEach((refs) => {
160
+ refs.countBadge.textContent = shouldShow ? String(count) : '';
161
+ refs.toggle.textContent = label;
162
+ refs.header?.classList.toggle('backlinks-header-empty', !shouldShow);
163
+
164
+ if (!shouldShow) {
165
+ refs.list.innerHTML = '';
166
+ } else {
167
+ this._renderList(refs.list);
168
+ }
169
+ });
170
+
122
171
  this._applyExpandState();
123
172
  }
124
173
 
125
- _renderList() {
126
- if (!this.list) return;
127
- this.list.innerHTML = '';
174
+ _renderList(listElement) {
175
+ if (!listElement) return;
176
+ listElement.innerHTML = '';
128
177
 
129
- const fragment = document.createDocumentFragment();
178
+ const fragment = this.documentRef?.createDocumentFragment?.();
179
+ const target = fragment ?? listElement;
130
180
 
131
- for (const backlink of this._backlinks) {
132
- const fileName = backlink.file.replace(/\.md$/i, '').split('/').pop();
133
- const dirPath = backlink.file.includes('/')
134
- ? backlink.file.substring(0, backlink.file.lastIndexOf('/'))
135
- : '';
181
+ this._backlinks.forEach((backlink) => {
182
+ target.appendChild(this._createBacklinkItem(backlink));
183
+ });
136
184
 
137
- const item = document.createElement('button');
138
- item.type = 'button';
139
- item.className = 'backlink-item';
140
- item.addEventListener('click', () => {
141
- this.onFileSelect?.(backlink.file);
142
- });
185
+ if (fragment) {
186
+ listElement.appendChild(fragment);
187
+ }
188
+ }
143
189
 
144
- // File name row
145
- const nameRow = document.createElement('div');
146
- nameRow.className = 'backlink-file-name';
147
- nameRow.innerHTML = `
148
- <svg class="backlink-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
149
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
150
- <polyline points="14 2 14 8 20 8"/>
151
- </svg>
152
- <span>${escapeHtml(fileName)}</span>
153
- ${dirPath ? `<span class="backlink-dir">${escapeHtml(dirPath)}</span>` : ''}
154
- `;
155
- item.appendChild(nameRow);
156
-
157
- // Context snippets
158
- for (const ctx of backlink.contexts.slice(0, 3)) {
159
- const contextEl = document.createElement('div');
160
- contextEl.className = 'backlink-context';
161
- contextEl.textContent = ctx;
162
- item.appendChild(contextEl);
163
- }
190
+ _createBacklinkItem(backlink) {
191
+ const fileName = backlink.file.replace(/\.md$/i, '').split('/').pop();
192
+ const dirPath = backlink.file.includes('/')
193
+ ? backlink.file.substring(0, backlink.file.lastIndexOf('/'))
194
+ : '';
195
+
196
+ const item = this.documentRef.createElement('button');
197
+ item.type = 'button';
198
+ item.className = 'backlink-item';
199
+ item.addEventListener('click', () => {
200
+ this.close();
201
+ this.onFileSelect?.(backlink.file);
202
+ });
164
203
 
165
- if (backlink.contexts.length > 3) {
166
- const more = document.createElement('div');
167
- more.className = 'backlink-context backlink-more';
168
- more.textContent = `+${backlink.contexts.length - 3} more`;
169
- item.appendChild(more);
170
- }
204
+ const nameRow = this.documentRef.createElement('div');
205
+ nameRow.className = 'backlink-file-name';
206
+ nameRow.innerHTML = `
207
+ <svg class="backlink-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
208
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
209
+ <polyline points="14 2 14 8 20 8"/>
210
+ </svg>
211
+ <span>${escapeHtml(fileName)}</span>
212
+ ${dirPath ? `<span class="backlink-dir">${escapeHtml(dirPath)}</span>` : ''}
213
+ `;
214
+ item.appendChild(nameRow);
215
+
216
+ backlink.contexts.slice(0, 3).forEach((ctx) => {
217
+ const contextElement = this.documentRef.createElement('div');
218
+ contextElement.className = 'backlink-context';
219
+ contextElement.textContent = ctx;
220
+ item.appendChild(contextElement);
221
+ });
171
222
 
172
- fragment.appendChild(item);
223
+ if (backlink.contexts.length > 3) {
224
+ const moreElement = this.documentRef.createElement('div');
225
+ moreElement.className = 'backlink-context backlink-more';
226
+ moreElement.textContent = `+${backlink.contexts.length - 3} more`;
227
+ item.appendChild(moreElement);
173
228
  }
174
229
 
175
- this.list.appendChild(fragment);
230
+ return item;
176
231
  }
177
232
 
178
233
  _applyExpandState() {
179
- this.panel?.classList.toggle('expanded', this._expanded);
180
- this.header?.setAttribute('aria-expanded', String(this._expanded));
181
- this.header?.setAttribute('aria-disabled', String(this._backlinks.length === 0));
182
- this.body?.setAttribute('aria-hidden', String(!this._expanded));
183
-
184
- if (this.body) {
185
- this.body.toggleAttribute('inert', !this._expanded);
186
- }
234
+ const expanded = this._expanded && this._backlinks.length > 0;
235
+
236
+ this.panels.forEach((refs) => {
237
+ refs.panel.classList.toggle('expanded', expanded);
238
+ refs.header?.setAttribute('aria-expanded', String(expanded));
239
+ refs.header?.setAttribute('aria-disabled', String(this._backlinks.length === 0));
240
+ refs.body?.setAttribute('aria-hidden', String(!expanded));
241
+ refs.body?.toggleAttribute('inert', !expanded);
242
+ });
187
243
  }
188
244
  }