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
package/public/index.html CHANGED
@@ -364,16 +364,26 @@
364
364
  <div class="preview-body">
365
365
  <div id="previewContainer" class="preview-container">
366
366
  <div id="previewContent" class="preview-content"></div>
367
-
368
- <!-- Backlinks (Linked Mentions) -->
369
- <div id="backlinksPanel" class="backlinks-panel hidden">
367
+ <div id="backlinksInlinePanel" class="backlinks-panel backlinks-panel-inline hidden">
368
+ <div class="backlinks-header" role="button" tabindex="0" aria-expanded="false">
369
+ <svg class="backlinks-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
370
+ <span class="backlinks-toggle">0 Linked Mentions</span>
371
+ <span class="backlinks-count"></span>
372
+ </div>
373
+ <div class="backlinks-body" aria-hidden="true" inert>
374
+ <div class="backlinks-list"></div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ <div id="backlinksPanel" class="backlinks-panel-layer hidden">
379
+ <div class="backlinks-panel backlinks-panel-dock" data-backlinks-variant="dock">
370
380
  <div class="backlinks-header" role="button" tabindex="0" aria-expanded="false">
371
381
  <svg class="backlinks-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
372
382
  <span class="backlinks-toggle">0 Linked Mentions</span>
373
383
  <span class="backlinks-count"></span>
374
384
  </div>
375
385
  <div class="backlinks-body" aria-hidden="true" inert>
376
- <div class="backlinks-list" id="backlinksList"></div>
386
+ <div class="backlinks-list"></div>
377
387
  </div>
378
388
  </div>
379
389
  </div>
@@ -1,17 +1,30 @@
1
+ import { createWorkspaceChange } from '../../../domain/workspace-change.js';
1
2
  import { resolveApiUrl } from '../../domain/runtime-paths.js';
2
3
 
3
4
  function normalizeWorkspaceChange(workspaceChange = {}) {
4
- return {
5
- changedPaths: Array.isArray(workspaceChange.changedPaths) ? workspaceChange.changedPaths.filter(Boolean) : [],
6
- deletedPaths: Array.isArray(workspaceChange.deletedPaths) ? workspaceChange.deletedPaths.filter(Boolean) : [],
7
- refreshExplorer: workspaceChange.refreshExplorer !== false,
8
- renamedPaths: Array.isArray(workspaceChange.renamedPaths)
9
- ? workspaceChange.renamedPaths.filter((entry) => entry?.oldPath && entry?.newPath)
10
- : [],
11
- };
5
+ return createWorkspaceChange(workspaceChange);
6
+ }
7
+
8
+ function createWorkspaceRequestId() {
9
+ if (globalThis.crypto?.randomUUID) {
10
+ return globalThis.crypto.randomUUID();
11
+ }
12
+
13
+ return `workspace-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
12
14
  }
13
15
 
14
16
  export const gitFeature = {
17
+ formatPullBackupToast(pullBackup = null) {
18
+ const fileCount = Number(pullBackup?.fileCount || 0);
19
+ if (fileCount === 1) {
20
+ return 'Pulled latest changes. 1 overlapping local file was backed up.';
21
+ }
22
+ if (fileCount > 1) {
23
+ return `Pulled latest changes. ${fileCount} overlapping local files were backed up.`;
24
+ }
25
+ return 'Pulled latest changes. Overlapping local changes were recorded in a pull backup.';
26
+ },
27
+
15
28
  setGitOperationStatus(message = '') {
16
29
  const badge = this.elements.gitOperationStatus;
17
30
  if (!badge) {
@@ -83,16 +96,24 @@ export const gitFeature = {
83
96
  },
84
97
 
85
98
  async postGitAction(endpoint, payload) {
99
+ const requestId = createWorkspaceRequestId();
100
+ this.pendingWorkspaceRequestIds?.add(requestId);
86
101
  const response = await fetch(endpoint, {
87
102
  body: JSON.stringify(payload),
88
103
  headers: {
89
104
  'Content-Type': 'application/json',
105
+ 'X-CollabMD-Request-Id': requestId,
90
106
  },
91
107
  method: 'POST',
92
108
  });
93
109
  const data = await response.json();
94
110
  if (!response.ok) {
95
- throw new Error(data.error || 'Git action failed');
111
+ this.pendingWorkspaceRequestIds?.delete(requestId);
112
+ const error = new Error(data.error || 'Git action failed');
113
+ if (typeof data?.code === 'string') {
114
+ error.code = data.code;
115
+ }
116
+ throw error;
96
117
  }
97
118
  return data;
98
119
  },
@@ -130,11 +151,6 @@ export const gitFeature = {
130
151
  const workspaceChange = normalizeWorkspaceChange(result.workspaceChange);
131
152
  await this.refreshWorkspaceAfterGitAction({ filePath, preferredScope });
132
153
  this.handleWorkspaceChangeForCurrentFile(workspaceChange, { action, local: true, showToast: showLocalFileToast });
133
- this.lobby.sendWorkspaceEvent({
134
- action,
135
- sourceRef: result.sourceRef ?? null,
136
- workspaceChange,
137
- });
138
154
  return workspaceChange;
139
155
  },
140
156
 
@@ -150,21 +166,27 @@ export const gitFeature = {
150
166
  return false;
151
167
  }
152
168
 
153
- this.navigation.navigateToFile(null);
154
169
  if (!showToast) {
170
+ if (renameEntry) {
171
+ this.navigation.navigateToFile(renameEntry.newPath);
172
+ } else {
173
+ this.navigation.navigateToFile(null);
174
+ }
155
175
  return true;
156
176
  }
157
177
 
158
178
  const displayName = this.getDisplayName(currentFilePath);
159
179
  if (renameEntry) {
180
+ this.navigation.navigateToFile(renameEntry.newPath);
160
181
  this.toastController.show(
161
182
  local
162
- ? `${displayName} was reset away from this branch path`
163
- : `${displayName} moved after a ${action} operation`,
183
+ ? `${displayName} moved to ${this.getDisplayName(renameEntry.newPath)}`
184
+ : `${displayName} moved on disk`,
164
185
  );
165
186
  return true;
166
187
  }
167
188
 
189
+ this.navigation.navigateToFile(null);
168
190
  this.toastController.show(
169
191
  local
170
192
  ? `${displayName} was removed by ${action}`
@@ -178,13 +200,45 @@ export const gitFeature = {
178
200
  return;
179
201
  }
180
202
 
203
+ if (event.requestId && this.pendingWorkspaceRequestIds?.has(event.requestId)) {
204
+ this.pendingWorkspaceRequestIds.delete(event.requestId);
205
+ return;
206
+ }
207
+
181
208
  const workspaceChange = normalizeWorkspaceChange(event.workspaceChange);
182
- await this.refreshWorkspaceAfterGitAction();
209
+ if (event.origin === 'git') {
210
+ await this.refreshGitAfterAction();
211
+ }
183
212
  this.handleWorkspaceChangeForCurrentFile(workspaceChange, {
184
- action: event.action || 'git',
213
+ action: event.action || event.origin || 'workspace',
185
214
  local: false,
186
215
  showToast: true,
187
216
  });
217
+
218
+ if (
219
+ event.origin === 'filesystem'
220
+ && this.currentFilePath
221
+ && workspaceChange.changedPaths.includes(this.currentFilePath)
222
+ ) {
223
+ const canUseInlineCue = workspaceChange.changedPaths.length === 1;
224
+ const highlightRange = canUseInlineCue && Array.isArray(event.highlightRanges)
225
+ ? event.highlightRanges.find((entry) => entry.path === this.currentFilePath)
226
+ : null;
227
+ const didFlash = highlightRange
228
+ ? this.session?.flashExternalUpdate?.(highlightRange)
229
+ : false;
230
+ if (!didFlash) {
231
+ this.toastController.show(`${this.getDisplayName(this.currentFilePath)} updated from disk`);
232
+ }
233
+ }
234
+
235
+ if (
236
+ this.currentFilePath
237
+ && Array.isArray(event.reloadRequiredPaths)
238
+ && event.reloadRequiredPaths.includes(this.currentFilePath)
239
+ ) {
240
+ this.toastController.show(`${this.getDisplayName(this.currentFilePath)} needs a manual reload`);
241
+ }
188
242
  },
189
243
 
190
244
  async stageGitFile(filePath, { scope = 'working-tree' } = {}) {
@@ -242,7 +296,20 @@ export const gitFeature = {
242
296
  result,
243
297
  showLocalFileToast: true,
244
298
  });
299
+ if (result.pullBackup) {
300
+ this.toastController.show(this.formatPullBackupToast(result.pullBackup), 5000);
301
+ }
245
302
  } catch (error) {
303
+ if (error?.code === 'pull_diverged_ff_only') {
304
+ this.toastController.show('Cannot pull because local and remote commits have diverged. Fast-forward only pull is not possible.', 5000);
305
+ return;
306
+ }
307
+
308
+ if (error?.code === 'pull_conflicted_after_autostash') {
309
+ this.toastController.show('Pull updated the branch, but reapplying local changes caused conflicts. Review the conflicted files and the pull backup summary.', 6000);
310
+ return;
311
+ }
312
+
246
313
  this.toastController.show(error.message || 'Failed to pull branch');
247
314
  }
248
315
  });
@@ -534,6 +534,9 @@ export const uiFeature = {
534
534
  if (!this.lobby.provider) {
535
535
  this.lobby.connect();
536
536
  }
537
+ if (!this.workspaceSync.provider) {
538
+ this.workspaceSync.connect();
539
+ }
537
540
 
538
541
  if (wasInactive) {
539
542
  if (this.fileExplorerReady) {
@@ -554,6 +557,7 @@ export const uiFeature = {
554
557
  this.elements.displayNameDialog.close();
555
558
  }
556
559
  this.lobby.disconnect();
560
+ this.workspaceSync.disconnect();
557
561
  this.globalUsers = [];
558
562
  this.chatMessages = [];
559
563
  this.chatMessageIds.clear();
@@ -61,6 +61,7 @@ export function bindAppShellElements(doc = document) {
61
61
  emptyStateNewFileBtn: doc.getElementById('emptyStateNewFileBtn'),
62
62
  emptyStateSearchBtn: doc.getElementById('emptyStateSearchBtn'),
63
63
  backlinksPanel: doc.getElementById('backlinksPanel'),
64
+ backlinksInlinePanel: doc.getElementById('backlinksInlinePanel'),
64
65
  commentSelectionButton: doc.getElementById('commentSelectionBtn'),
65
66
  commentsDrawer: doc.getElementById('commentsDrawer'),
66
67
  commentsDrawerEmpty: doc.getElementById('commentsDrawerEmpty'),
@@ -18,6 +18,7 @@ import { BrowserPreferencesPort } from '../infrastructure/browser-preferences-po
18
18
  import { getRuntimeConfig } from '../infrastructure/runtime-config.js';
19
19
  import { TabActivityLock } from '../infrastructure/tab-activity-lock.js';
20
20
  import { vaultApiClient } from '../infrastructure/vault-api-client.js';
21
+ import { WorkspaceSyncClient } from '../infrastructure/workspace-sync-client.js';
21
22
  import { BacklinksPanel } from '../presentation/backlinks-panel.js';
22
23
  import { CommentUiController } from '../presentation/comment-ui-controller.js';
23
24
  import { ExcalidrawEmbedController } from '../presentation/excalidraw-embed-controller.js';
@@ -111,11 +112,22 @@ export class CollabMdAppShell {
111
112
  this.quickSwitcherModulePromise = null;
112
113
  this.fileExplorerReadyPromise = Promise.resolve();
113
114
  this.mobileBreakpointQuery = window.matchMedia('(max-width: 768px)');
115
+ this.pendingWorkspaceRequestIds = new Set();
114
116
 
115
117
  this.lobby = new LobbyPresence({
116
118
  preferredUserName: this.getStoredUserName(),
117
119
  onChange: (users) => this.updateGlobalUsers(users),
118
120
  onChatChange: (messages, meta) => this.updateChatMessages(messages, meta),
121
+ });
122
+ this.workspaceSync = new WorkspaceSyncClient({
123
+ onTreeChange: (tree, metadata = {}) => {
124
+ const wasReady = this.fileExplorerReady;
125
+ this.fileExplorer.setTree(tree, metadata);
126
+ this.fileExplorerReady = true;
127
+ if (!wasReady && this.isTabActive) {
128
+ void this.handleHashChange();
129
+ }
130
+ },
119
131
  onWorkspaceEvent: (event) => {
120
132
  void this.handleIncomingWorkspaceEvent(event);
121
133
  },
@@ -131,6 +143,7 @@ export class CollabMdAppShell {
131
143
  this.gitPanel = new GitPanelController({
132
144
  enabled: this.runtimeConfig.gitEnabled !== false,
133
145
  onCommitStaged: () => this.openGitCommitDialog(),
146
+ onOpenPullBackup: (filePath) => filePath && this.navigation.navigateToFile(filePath),
134
147
  onPullBranch: () => this.pullGitBranch(),
135
148
  onPushBranch: () => this.pushGitBranch(),
136
149
  onRepoChange: (isGitRepo, status) => this.handleGitRepoChange(isGitRepo, status),
@@ -149,6 +162,7 @@ export class CollabMdAppShell {
149
162
  this.scrollSyncController.suspendSync(250);
150
163
  this.session?.scrollToLine(sourceLine, 0);
151
164
  },
165
+ onWillOpen: () => this.commentUi?.closeDrawer(),
152
166
  });
153
167
  this.videoEmbed = new VideoEmbedController({
154
168
  previewElement: this.elements.previewContent,
@@ -196,6 +210,7 @@ export class CollabMdAppShell {
196
210
  scrollEditorToLine: (lineNumber, viewportRatio) => this.session?.scrollToLine(lineNumber, viewportRatio),
197
211
  });
198
212
  this.backlinksPanel = new BacklinksPanel({
213
+ inlinePanelElement: this.elements.backlinksInlinePanel,
199
214
  onFileSelect: (filePath) => this.handleFileSelection(filePath, { closeSidebarOnMobile: true }),
200
215
  panelElement: this.elements.backlinksPanel,
201
216
  });
@@ -214,6 +229,7 @@ export class CollabMdAppShell {
214
229
  commentsDrawerList: this.elements.commentsDrawerList,
215
230
  commentsToggleButton: this.elements.commentsToggleButton,
216
231
  editorContainer: this.elements.editorContainer,
232
+ onWillOpenDrawer: () => this.outlineController.close(),
217
233
  previewContainer: this.elements.previewContainer,
218
234
  previewElement: this.elements.previewContent,
219
235
  onCreateThread: ({ anchor, body }) => this.session?.createCommentThread({ anchor, body }),
@@ -25,8 +25,8 @@ export function escapeHtml(text) {
25
25
  *
26
26
  * Matching rules (in order):
27
27
  * 1. Exact path match (with .md appended if missing)
28
- * 2. Filename match at any directory depth
29
- * 3. Path without .md extension matches the target
28
+ * 2. Exact path without .md extension matches the target
29
+ * 3. Filename/path suffix match at any directory depth
30
30
  *
31
31
  * @param {string} target — the raw wiki-link target text
32
32
  * @param {string[]} files — list of vault file paths
@@ -149,6 +149,7 @@ if (isTestMode) {
149
149
  && collabReady
150
150
  && roomClient.canWriteToRoom === true
151
151
  && roomClient.waitingForAuthoritativeSync === false
152
+ && roomClient.isApplyingSharedSnapshot() === false
152
153
  ),
153
154
  isReady: () => collabReady && Boolean(excalidrawAPI) && Boolean(getNativeHistoryButton('undo')) && Boolean(getNativeHistoryButton('redo')),
154
155
  redoShared: () => triggerNativeHistory('redo'),
@@ -506,7 +507,7 @@ window.addEventListener('message', (event) => {
506
507
  });
507
508
 
508
509
  function scheduleSyncToRoom(elements, appState, files) {
509
- if (!collabReady || suppressOnChange || roomClient.isApplyingSharedSnapshot()) {
510
+ if (!collabReady || suppressOnChange) {
510
511
  return;
511
512
  }
512
513
 
@@ -229,6 +229,10 @@ export class EditorSession {
229
229
  return this.viewAdapter.insertText(text);
230
230
  }
231
231
 
232
+ flashExternalUpdate(range) {
233
+ return this.viewAdapter.flashRemoteRange(range);
234
+ }
235
+
232
236
  waitForInitialSync(timeoutMs = 1500) {
233
237
  return this.collaborationClient.waitForInitialSync(timeoutMs);
234
238
  }