collabmd 0.1.44 → 0.1.45

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 (170) hide show
  1. package/dist/client/assets/{chunk-AXR3VHVW-0GTk_evH.js → chunk-ERSWUTIN-ME9RVNel.js} +3 -3
  2. package/dist/client/assets/{chunk-CZQZEIFA-DT3N9fo7.js → chunk-YGJVY4SA-CjSe7Bx7.js} +2 -2
  3. package/dist/client/assets/{comment-threads-D-iKphIp.js → comment-threads-DDegmaFO.js} +1 -1
  4. package/dist/client/assets/drawio-editor-ClE8jpoG.js +9 -0
  5. package/dist/client/assets/drawioEditor-CXaTM9Ds.js +2 -0
  6. package/dist/client/assets/editor-session-GTwZS6Df.js +16 -0
  7. package/dist/client/assets/embedded-editor-base-CiNEc8vy.css +1 -0
  8. package/dist/client/assets/{en-GADW4C6J-DQ6zyCEM.js → en-7NFCYGKX-BrgYZAEe.js} +1 -1
  9. package/dist/client/assets/{excalidraw-editor-BHu6k8d4.js → excalidraw-editor-BZVM81BO.js} +2 -2
  10. package/dist/client/assets/excalidraw-editor-CyGwOhLz.css +1 -0
  11. package/dist/client/assets/excalidraw-scene-C8n9CHsK.js +176 -0
  12. package/dist/client/assets/excalidrawEditor-DxJanCze.js +2 -0
  13. package/dist/client/assets/export-page-CUfws2T-.js +39 -0
  14. package/dist/client/assets/exportDocument-BCuxqXDY.js +2 -0
  15. package/dist/client/assets/exportDocument-DV-OX74E.css +1 -0
  16. package/dist/client/assets/image-QXRYHUNP-lfTHE3bb.js +1 -0
  17. package/dist/client/assets/index-C3YnUgMD.css +1 -0
  18. package/dist/client/assets/index-D2wXraJ1.js +2 -0
  19. package/dist/client/assets/main-DtiGz7uI.js +795 -0
  20. package/dist/client/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  21. package/dist/client/assets/preview-diagram-utils-txBCoW8d.js +1 -0
  22. package/dist/client/assets/quick-switcher-controller-shW_6vPY.js +12 -0
  23. package/dist/client/assets/room-CQLoqtMo.js +1 -0
  24. package/dist/client/assets/runtime-config-DSGmGE9V.js +1 -0
  25. package/dist/client/assets/vault-api-client-C6bcGwy-.js +5 -0
  26. package/dist/client/assets/{yjs-provider-reset-guard-Cox4nxwu.js → yjs-provider-reset-guard-DE0vINDP.js} +1 -1
  27. package/dist/client/drawio-editor.html +3 -2
  28. package/dist/client/excalidraw-editor.html +3 -2
  29. package/dist/client/export-document.html +4 -3
  30. package/dist/client/index.html +97 -27
  31. package/package.json +10 -12
  32. package/src/client/app/drawio-editor-entry.js +0 -3
  33. package/src/client/app/drawio-editor.html +1 -0
  34. package/src/client/app/excalidraw-editor-entry.js +0 -3
  35. package/src/client/app/excalidraw-editor.html +1 -0
  36. package/src/client/app/export-document-entry.js +0 -3
  37. package/src/client/app/export-document.html +1 -0
  38. package/src/client/app/index.html +94 -24
  39. package/src/client/app/main-entry.js +0 -5
  40. package/src/client/application/app-shell/chat-feature.js +180 -124
  41. package/src/client/application/app-shell/comments-feature.js +1 -1
  42. package/src/client/application/app-shell/git-feature.js +4 -9
  43. package/src/client/application/app-shell/presence-feature.js +16 -10
  44. package/src/client/application/app-shell/ui-feature-shell.js +33 -15
  45. package/src/client/application/app-shell/ui-feature-sidebar.js +74 -1
  46. package/src/client/application/app-shell/ui-feature-tab-activity.js +2 -2
  47. package/src/client/application/app-shell/workspace-feature.js +22 -83
  48. package/src/client/application/app-shell-elements.js +7 -1
  49. package/src/client/application/diagram-chrome.js +161 -166
  50. package/src/client/application/diagram-preview-export.js +7 -34
  51. package/src/client/application/diagram-preview-hydrator.js +1 -19
  52. package/src/client/application/markdown-frontmatter.js +0 -2
  53. package/src/client/application/mermaid-preview-hydrator.js +1 -14
  54. package/src/client/application/plantuml-preview-hydrator.js +0 -10
  55. package/src/client/application/preview-diagram-utils.js +31 -2
  56. package/src/client/application/preview-render-compiler.js +2 -1
  57. package/src/client/application/preview-renderer.js +2 -2
  58. package/src/client/application/quick-switcher-loader.js +3 -1
  59. package/src/client/application/workspace-preview-controller.js +0 -3
  60. package/src/client/bootstrap/collabmd-app-shell.js +125 -39
  61. package/src/client/domain/excalidraw-collaboration.js +0 -2
  62. package/src/client/domain/room.js +38 -0
  63. package/src/client/export/export-pipeline.js +6 -34
  64. package/src/client/infrastructure/auth-client.js +4 -10
  65. package/src/client/infrastructure/browser-notification-port.js +33 -20
  66. package/src/client/infrastructure/browser-preferences-port.js +36 -9
  67. package/src/client/infrastructure/editor-view-adapter.js +87 -3
  68. package/src/client/infrastructure/lobby-presence.js +1 -0
  69. package/src/client/infrastructure/vault-api-client.js +25 -0
  70. package/src/client/infrastructure/workspace-sync-client.js +13 -1
  71. package/src/client/presentation/comment-overview-controller.js +18 -53
  72. package/src/client/presentation/comment-ui/comment-ui-card.js +1 -12
  73. package/src/client/presentation/comment-ui/comment-ui-layout.js +242 -219
  74. package/src/client/presentation/comment-ui/comment-ui-render.js +32 -45
  75. package/src/client/presentation/comment-ui/comment-ui-shared.js +65 -14
  76. package/src/client/presentation/comment-ui-controller.js +5 -1
  77. package/src/client/presentation/file-action-controller.js +32 -0
  78. package/src/client/presentation/file-explorer-controller.js +33 -3
  79. package/src/client/presentation/file-explorer-view.js +49 -5
  80. package/src/client/presentation/file-tree-state.js +10 -4
  81. package/src/client/presentation/quick-switcher-controller.js +243 -72
  82. package/src/client/presentation/quick-switcher-text-search.js +2 -2
  83. package/src/client/presentation/toast-controller.js +3 -0
  84. package/src/client/styles/components/feedback.css +6 -0
  85. package/src/client/styles/features/collaboration-chat.css +49 -53
  86. package/src/client/styles/features/collaboration-presence.css +9 -7
  87. package/src/client/styles/features/comment-card.css +25 -11
  88. package/src/client/styles/features/comment-overlays.css +0 -4
  89. package/src/client/styles/features/comment-overview.css +44 -21
  90. package/src/client/styles/features/comments-drawer.css +2 -100
  91. package/src/client/styles/features/diagram-preview.css +5 -18
  92. package/src/client/styles/features/embedded-preview.css +8 -33
  93. package/src/client/styles/features/quick-switcher.css +8 -0
  94. package/src/client/styles/foundation/tokens.css +1 -0
  95. package/src/client/styles/layout/editor-page.css +40 -0
  96. package/src/client/styles/layout/responsive.css +31 -1
  97. package/src/client/styles/layout/shell.css +25 -6
  98. package/src/client/styles/layout/sidebar.css +47 -15
  99. package/src/client/styles/overrides/codemirror.css +217 -15
  100. package/src/server/application/workspace-reconciliation.js +1 -1
  101. package/src/server/create-app-server.js +2 -3
  102. package/src/server/domain/bases/base-query-service.js +1 -77
  103. package/src/server/domain/comment-overview.js +10 -3
  104. package/src/server/domain/docx-exporter.js +24 -33
  105. package/src/server/domain/ripgrep-search-service.js +3 -1
  106. package/src/server/domain/workspace-state.js +32 -11
  107. package/src/server/infrastructure/git/diff-service.js +24 -13
  108. package/src/server/infrastructure/git/git-service.js +40 -10
  109. package/src/server/infrastructure/git/history-service.js +42 -29
  110. package/src/server/infrastructure/git/path-utils.js +1 -1
  111. package/src/server/infrastructure/git/responses.js +0 -95
  112. package/src/server/infrastructure/github/github-app-client.js +22 -15
  113. package/src/server/infrastructure/http/create-request-handler.js +2 -8
  114. package/src/server/infrastructure/http/create-vault-api-command-handler.js +5 -5
  115. package/src/server/infrastructure/http/http-errors.js +7 -1
  116. package/dist/client/assets/bases-preview-controller-2pLHk0uk.js +0 -143
  117. package/dist/client/assets/button-BCEiw4Rw.js +0 -1
  118. package/dist/client/assets/diagram-action-icons-aa_KOv9h.js +0 -36
  119. package/dist/client/assets/drawio-editor-DDvFoarP.js +0 -9
  120. package/dist/client/assets/drawio-embed-controller-BD1GTRwn.js +0 -1
  121. package/dist/client/assets/drawioEditor-Bu5grlH5.js +0 -2
  122. package/dist/client/assets/editor-session-DGlpiAsK.js +0 -16
  123. package/dist/client/assets/embedded-editor-base-C9roKYpW.css +0 -1
  124. package/dist/client/assets/excalidraw-editor-Cr_184EF.css +0 -1
  125. package/dist/client/assets/excalidraw-embed-controller-y2fenN_g.js +0 -1
  126. package/dist/client/assets/excalidraw-embed-reconciler-DGiagExg.js +0 -1
  127. package/dist/client/assets/excalidraw-scene-BvsQ6mLP.js +0 -176
  128. package/dist/client/assets/excalidrawEditor-4vFE--Uw.js +0 -2
  129. package/dist/client/assets/export-page-Cby8JzPO.js +0 -39
  130. package/dist/client/assets/exportDocument-C9v-bc7I.css +0 -1
  131. package/dist/client/assets/exportDocument-CRHCfsdo.js +0 -2
  132. package/dist/client/assets/file-history-view-controller-BDjEV9xJ.js +0 -112
  133. package/dist/client/assets/git-diff-view-controller-DgdM1aqb.js +0 -150
  134. package/dist/client/assets/git-panel-controller-BDTR2HVp.js +0 -207
  135. package/dist/client/assets/image-XGD7JQR6-XDUWHRpO.js +0 -1
  136. package/dist/client/assets/index-7xOyXDhP.css +0 -1
  137. package/dist/client/assets/index-Bz1KuQ6y.js +0 -2
  138. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  139. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  140. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  141. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  142. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  143. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  144. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  145. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  146. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  147. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  148. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  149. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  150. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  151. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  152. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  153. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  154. package/dist/client/assets/main-Ci07m9oP.js +0 -152
  155. package/dist/client/assets/preview-diagram-utils-n_bbJA9V.js +0 -1
  156. package/dist/client/assets/quick-switcher-controller-CoiYvllm.js +0 -12
  157. package/dist/client/assets/room-CDrmIdDt.js +0 -1
  158. package/dist/client/assets/runtime-config-CDgeuPWN.js +0 -1
  159. package/dist/client/assets/runtime-config-loader-BHGniHP5.js +0 -1
  160. package/dist/client/assets/runtime-paths-DvpihCEq.js +0 -1
  161. package/dist/client/assets/segmented-control-DJrHSz8M.js +0 -1
  162. package/dist/client/assets/vault-api-client-sLB2bo56.js +0 -1
  163. package/dist/client/assets/vault-paths-CVkuh0j-.js +0 -5
  164. package/src/client/bootstrap/lazy-controller-feature.js +0 -433
  165. package/src/client/infrastructure/backlinks-api-client.js +0 -16
  166. package/src/client/infrastructure/plantuml-api-client.js +0 -19
  167. package/src/client/infrastructure/runtime-config-loader.js +0 -56
  168. package/src/server/infrastructure/git/command-runner.js +0 -49
  169. package/src/server/infrastructure/git/errors.js +0 -8
  170. package/src/server/infrastructure/http/create-esm-proxy-handler.js +0 -130
@@ -7,7 +7,14 @@ import {
7
7
  formatMatchCount,
8
8
  } from './quick-switcher-text-search.js';
9
9
 
10
- const MAX_VISIBLE_RESULTS = 12;
10
+ const MAX_VISIBLE_RESULTS = 30;
11
+ const NO_RECENT_FILE_RANK = Number.MAX_SAFE_INTEGER;
12
+ const FILE_RESULT_ID_PREFIX = 'quick-switcher-file-';
13
+ const TEXT_RESULT_ID_PREFIX = 'quick-switcher-text-';
14
+
15
+ function getRawFileName(filePath) {
16
+ return String(filePath ?? '').split('/').pop() || String(filePath ?? '');
17
+ }
11
18
 
12
19
  function createCorpusEntry(filePath) {
13
20
  const displayName = stripVaultFileExtension(filePath);
@@ -33,9 +40,116 @@ function getDirPath(filePath) {
33
40
  return displayName.includes('/') ? displayName.substring(0, displayName.lastIndexOf('/')) : '';
34
41
  }
35
42
 
43
+ function createRangeIndices(start, length) {
44
+ return Array.from({ length }, (_, index) => start + index);
45
+ }
46
+
47
+ function findFuzzyMatch(text, query) {
48
+ const indices = [];
49
+ let queryIndex = 0;
50
+ let score = 0;
51
+ let consecutiveBonus = 0;
52
+
53
+ for (let index = 0; index < text.length && queryIndex < query.length; index += 1) {
54
+ if (text[index] !== query[queryIndex]) {
55
+ consecutiveBonus = 0;
56
+ continue;
57
+ }
58
+
59
+ queryIndex += 1;
60
+ indices.push(index);
61
+ consecutiveBonus += 1;
62
+ score += consecutiveBonus;
63
+
64
+ if (
65
+ index === 0
66
+ || text[index - 1] === '/'
67
+ || text[index - 1] === '-'
68
+ || text[index - 1] === '_'
69
+ || text[index - 1] === ' '
70
+ ) {
71
+ score += 5;
72
+ }
73
+ }
74
+
75
+ return queryIndex === query.length && score > 0
76
+ ? { indices, score }
77
+ : null;
78
+ }
79
+
80
+ function findCorpusMatch(entry, query) {
81
+ const fileIndex = entry.lowerFileName.indexOf(query);
82
+ if (fileIndex >= 0) {
83
+ return {
84
+ indices: createRangeIndices(fileIndex + (entry.displayName.length - entry.fileName.length), query.length),
85
+ score: 100 + (1 / entry.fileName.length),
86
+ };
87
+ }
88
+
89
+ const pathIndex = entry.lowerDisplayName.indexOf(query);
90
+ if (pathIndex >= 0) {
91
+ return {
92
+ indices: createRangeIndices(pathIndex, query.length),
93
+ score: 50 + (1 / entry.displayName.length),
94
+ };
95
+ }
96
+
97
+ return findFuzzyMatch(entry.lowerDisplayName, query);
98
+ }
99
+
100
+ function splitMatchIndices(entry, indices = []) {
101
+ const fileNameStart = entry.displayName.length - entry.fileName.length;
102
+ return {
103
+ dirPath: indices.filter((index) => index < fileNameStart),
104
+ fileName: indices
105
+ .filter((index) => index >= fileNameStart)
106
+ .map((index) => index - fileNameStart),
107
+ };
108
+ }
109
+
110
+ function highlightText(text, indices = []) {
111
+ if (indices.length === 0) {
112
+ return escapeHtml(text);
113
+ }
114
+
115
+ const matchedIndices = new Set(indices);
116
+ let result = '';
117
+ let runStart = null;
118
+ let cursor = 0;
119
+
120
+ const appendRun = (runEnd) => {
121
+ result += escapeHtml(text.slice(cursor, runStart));
122
+ result += `<mark>${escapeHtml(text.slice(runStart, runEnd + 1))}</mark>`;
123
+ cursor = runEnd + 1;
124
+ runStart = null;
125
+ };
126
+
127
+ for (let index = 0; index < text.length; index += 1) {
128
+ if (!matchedIndices.has(index)) {
129
+ if (runStart !== null) {
130
+ appendRun(index - 1);
131
+ }
132
+ continue;
133
+ }
134
+
135
+ if (runStart === null) {
136
+ runStart = index;
137
+ }
138
+ }
139
+
140
+ if (runStart !== null) {
141
+ appendRun(text.length - 1);
142
+ }
143
+
144
+ result += escapeHtml(text.slice(cursor));
145
+ return result;
146
+ }
147
+
36
148
  export class QuickSwitcherController {
37
149
  constructor({
38
150
  getFileList,
151
+ getFileMetadata = () => [],
152
+ getRecentFiles = () => [],
39
153
  getSearchConfig = () => ({}),
40
154
  onFileSelect,
41
155
  onTextMatchSelect = null,
@@ -43,6 +157,8 @@ export class QuickSwitcherController {
43
157
  searchText = null,
44
158
  }) {
45
159
  this.getFileList = getFileList;
160
+ this.getFileMetadata = getFileMetadata;
161
+ this.getRecentFiles = getRecentFiles;
46
162
  this.getSearchConfig = getSearchConfig;
47
163
  this.onFileSelect = onFileSelect;
48
164
  this.onTextMatchSelect = onTextMatchSelect;
@@ -53,14 +169,20 @@ export class QuickSwitcherController {
53
169
  this.input = document.getElementById('quickSwitcherInput');
54
170
  this.resultsList = document.getElementById('quickSwitcherResults');
55
171
  this.hint = document.getElementById('quickSwitcherHint');
172
+ this.scope = document.getElementById('quickSwitcherScope');
56
173
  this.modeTabs = Array.from(document.querySelectorAll?.('[data-qs-mode]') ?? []);
57
174
 
58
175
  this.filteredFiles = [];
176
+ this.fileMatches = new Map();
177
+ this.fileNameCounts = new Map();
178
+ this.fileMatchCount = 0;
179
+ this.fileResultsTruncated = false;
59
180
  this.fileCorpus = [];
60
181
  this.lastFileListRef = null;
61
182
  this.selectedIndex = 0;
62
183
  this.selectedTextIndex = 0;
63
184
  this.isOpen = false;
185
+ this.previousActiveElement = null;
64
186
  this.mode = 'files';
65
187
  this.textResults = null;
66
188
  this.textResultItems = [];
@@ -119,11 +241,13 @@ export class QuickSwitcherController {
119
241
  open() {
120
242
  if (!this.overlay) return;
121
243
 
244
+ this.previousActiveElement = document.activeElement;
122
245
  this.isOpen = true;
123
246
  this.input.value = '';
124
247
  this.selectedIndex = 0;
125
248
  this.selectedTextIndex = 0;
126
249
  this.overlay.classList.add('visible');
250
+ this.overlay.setAttribute('aria-hidden', 'false');
127
251
  this.setMode('files', { preserveInput: true });
128
252
 
129
253
  // The overlay transitions visibility hidden→visible over 120ms.
@@ -171,15 +295,23 @@ export class QuickSwitcherController {
171
295
  }
172
296
  }
173
297
 
174
- close() {
298
+ close({ restoreFocus = true } = {}) {
175
299
  if (!this.overlay) return;
176
300
 
301
+ const previousActiveElement = this.previousActiveElement;
177
302
  this._cancelPendingFocus();
178
303
  this.abortTextSearch();
179
304
  this.isOpen = false;
180
305
  this.overlay.classList.remove('visible');
306
+ this.overlay.setAttribute('aria-hidden', 'true');
181
307
  this.input.value = '';
182
308
  this.resultsList.innerHTML = '';
309
+ this.setActiveDescendant('');
310
+ this.previousActiveElement = null;
311
+
312
+ if (restoreFocus) {
313
+ previousActiveElement?.focus?.();
314
+ }
183
315
  }
184
316
 
185
317
  toggle() {
@@ -206,10 +338,17 @@ export class QuickSwitcherController {
206
338
  tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
207
339
  });
208
340
 
341
+ this.scope?.classList.toggle('hidden', normalizedMode !== 'text');
342
+ this.scope?.setAttribute('aria-hidden', normalizedMode === 'text' ? 'false' : 'true');
343
+
209
344
  if (this.input) {
210
345
  this.input.placeholder = normalizedMode === 'text'
211
346
  ? 'Search text in files...'
212
347
  : 'Search files...';
348
+ this.input.setAttribute(
349
+ 'aria-label',
350
+ normalizedMode === 'text' ? 'Search text in files' : 'Search files',
351
+ );
213
352
  }
214
353
 
215
354
  this.handleInput();
@@ -231,25 +370,70 @@ export class QuickSwitcherController {
231
370
  if (allFiles !== this.lastFileListRef) {
232
371
  this.lastFileListRef = allFiles;
233
372
  this.fileCorpus = allFiles.map((filePath) => createCorpusEntry(filePath));
373
+ this.fileNameCounts = new Map();
374
+ this.fileCorpus.forEach((entry) => {
375
+ const count = this.fileNameCounts.get(entry.lowerFileName) ?? 0;
376
+ this.fileNameCounts.set(entry.lowerFileName, count + 1);
377
+ });
234
378
  }
235
379
 
380
+ this.fileMatches.clear();
381
+ const fileMetadata = this.getFileMetadata?.() ?? [];
382
+ const modifiedTimes = new Map(
383
+ (Array.isArray(fileMetadata) ? fileMetadata : []).map((entry) => [
384
+ entry?.path,
385
+ Number.isFinite(Number(entry?.mtimeMs)) ? Number(entry.mtimeMs) : 0,
386
+ ]),
387
+ );
388
+ const recentFiles = this.getRecentFiles?.() ?? [];
389
+ const recentRanks = new Map(
390
+ (Array.isArray(recentFiles) ? recentFiles : []).map((filePath, index) => [filePath, index]),
391
+ );
236
392
  if (!query) {
237
- this.filteredFiles = this.fileCorpus
393
+ this.filteredFiles = [...this.fileCorpus]
394
+ .sort((left, right) => {
395
+ const modifiedDelta = (modifiedTimes.get(right.filePath) ?? 0) - (modifiedTimes.get(left.filePath) ?? 0);
396
+ if (modifiedDelta !== 0) {
397
+ return modifiedDelta;
398
+ }
399
+
400
+ return (recentRanks.get(left.filePath) ?? NO_RECENT_FILE_RANK)
401
+ - (recentRanks.get(right.filePath) ?? NO_RECENT_FILE_RANK);
402
+ })
238
403
  .slice(0, MAX_VISIBLE_RESULTS)
239
404
  .map((entry) => entry.filePath);
405
+ this.fileMatchCount = this.fileCorpus.length;
240
406
  } else {
241
407
  const ranked = [];
408
+ let fileMatchCount = 0;
242
409
  this.fileCorpus.forEach((entry) => {
243
- const score = this.fuzzyScore(entry, query);
244
- if (score <= 0) {
410
+ const match = findCorpusMatch(entry, query);
411
+ if (!match) {
245
412
  return;
246
413
  }
247
414
 
248
- const rankedEntry = { filePath: entry.filePath, score };
415
+ fileMatchCount += 1;
416
+ this.fileMatches.set(entry.filePath, match);
417
+ const rankedEntry = {
418
+ filePath: entry.filePath,
419
+ recentRank: recentRanks.get(entry.filePath) ?? NO_RECENT_FILE_RANK,
420
+ score: match.score,
421
+ };
249
422
  let inserted = false;
250
423
  for (let index = 0; index < ranked.length; index += 1) {
251
424
  const current = ranked[index];
252
- if (score > current.score || (score === current.score && entry.lowerPath < String(current.filePath).toLowerCase())) {
425
+ const isBetter = rankedEntry.score > current.score
426
+ || (
427
+ rankedEntry.score === current.score
428
+ && (
429
+ rankedEntry.recentRank < current.recentRank
430
+ || (
431
+ rankedEntry.recentRank === current.recentRank
432
+ && entry.lowerPath < String(current.filePath).toLowerCase()
433
+ )
434
+ )
435
+ );
436
+ if (isBetter) {
253
437
  ranked.splice(index, 0, rankedEntry);
254
438
  inserted = true;
255
439
  break;
@@ -266,45 +450,20 @@ export class QuickSwitcherController {
266
450
  });
267
451
 
268
452
  this.filteredFiles = ranked.map((entry) => entry.filePath);
453
+ this.fileMatchCount = fileMatchCount;
269
454
  }
270
455
 
456
+ this.fileResultsTruncated = this.fileMatchCount > MAX_VISIBLE_RESULTS;
271
457
  this.selectedIndex = 0;
272
458
  this.renderResults(query);
273
459
  }
274
460
 
275
- fuzzyScore(entry, query) {
276
- const name = entry.lowerDisplayName;
277
- const nameOnly = entry.lowerFileName;
278
-
279
- if (nameOnly.includes(query)) return 100 + (1 / nameOnly.length);
280
- if (name.includes(query)) return 50 + (1 / name.length);
281
-
282
- let queryIndex = 0;
283
- let score = 0;
284
- let consecutiveBonus = 0;
285
-
286
- for (let i = 0; i < name.length && queryIndex < query.length; i++) {
287
- if (name[i] === query[queryIndex]) {
288
- queryIndex++;
289
- consecutiveBonus += 1;
290
- score += consecutiveBonus;
291
-
292
- if (i === 0 || name[i - 1] === '/' || name[i - 1] === '-' || name[i - 1] === '_' || name[i - 1] === ' ') {
293
- score += 5;
294
- }
295
- } else {
296
- consecutiveBonus = 0;
297
- }
298
- }
299
-
300
- return queryIndex === query.length ? score : 0;
301
- }
302
-
303
461
  renderResults(query) {
304
462
  if (!this.resultsList) return;
305
463
  this.resultsList.innerHTML = '';
306
464
 
307
465
  if (this.filteredFiles.length === 0) {
466
+ this.setActiveDescendant('');
308
467
  if (this.hint) {
309
468
  this.hint.textContent = query ? 'No files found' : 'No files in vault';
310
469
  this.hint.classList.remove('hidden');
@@ -313,7 +472,13 @@ export class QuickSwitcherController {
313
472
  }
314
473
 
315
474
  if (this.hint) {
316
- this.hint.classList.add('hidden');
475
+ const hint = this.fileResultsTruncated
476
+ ? query
477
+ ? `Showing the top ${MAX_VISIBLE_RESULTS} of ${this.fileMatchCount} matches. Refine the query to narrow results.`
478
+ : `Showing the first ${MAX_VISIBLE_RESULTS} of ${this.fileMatchCount} files.`
479
+ : '';
480
+ this.hint.textContent = hint;
481
+ this.hint.classList.toggle('hidden', !hint);
317
482
  }
318
483
 
319
484
  const fragment = document.createDocumentFragment();
@@ -325,15 +490,25 @@ export class QuickSwitcherController {
325
490
  if (index === this.selectedIndex) {
326
491
  item.classList.add('selected');
327
492
  }
493
+ item.id = `${FILE_RESULT_ID_PREFIX}${index}`;
494
+ item.setAttribute('role', 'option');
495
+ item.setAttribute('aria-selected', index === this.selectedIndex ? 'true' : 'false');
328
496
  item.dataset.index = String(index);
329
497
 
330
498
  const fileName = getFileName(filePath);
499
+ const rawFileName = getRawFileName(filePath);
331
500
  const dirPath = getDirPath(filePath);
501
+ const match = this.fileMatches.get(filePath);
502
+ const corpusEntry = this.fileCorpus.find((entry) => entry.filePath === filePath);
503
+ const matchIndices = corpusEntry ? splitMatchIndices(corpusEntry, match?.indices) : { dirPath: [], fileName: [] };
504
+ const displayFileName = (this.fileNameCounts.get(fileName.toLowerCase()) ?? 0) > 1
505
+ ? rawFileName
506
+ : fileName;
332
507
 
333
508
  item.innerHTML = `
334
509
  <svg class="qs-result-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
335
- <span class="qs-result-name">${this.highlightMatch(fileName, query)}</span>
336
- ${dirPath ? `<span class="qs-result-path">${escapeHtml(dirPath)}</span>` : ''}
510
+ <span class="qs-result-name">${highlightText(displayFileName, matchIndices.fileName)}</span>
511
+ ${dirPath ? `<span class="qs-result-path">${highlightText(dirPath, matchIndices.dirPath)}</span>` : ''}
337
512
  `;
338
513
 
339
514
  item.addEventListener('click', () => {
@@ -350,32 +525,7 @@ export class QuickSwitcherController {
350
525
  });
351
526
 
352
527
  this.resultsList.appendChild(fragment);
353
- }
354
-
355
- highlightMatch(text, query) {
356
- if (!query) return escapeHtml(text);
357
-
358
- const lower = text.toLowerCase();
359
- const idx = lower.indexOf(query);
360
-
361
- if (idx >= 0) {
362
- const before = text.slice(0, idx);
363
- const match = text.slice(idx, idx + query.length);
364
- const after = text.slice(idx + query.length);
365
- return `${escapeHtml(before)}<mark>${escapeHtml(match)}</mark>${escapeHtml(after)}`;
366
- }
367
-
368
- let result = '';
369
- let queryIdx = 0;
370
- for (let i = 0; i < text.length; i++) {
371
- if (queryIdx < query.length && text[i].toLowerCase() === query[queryIdx]) {
372
- result += `<mark>${escapeHtml(text[i])}</mark>`;
373
- queryIdx++;
374
- } else {
375
- result += escapeHtml(text[i]);
376
- }
377
- }
378
- return result;
528
+ this.updateSelection();
379
529
  }
380
530
 
381
531
  abortTextSearch({ invalidate = true } = {}) {
@@ -406,6 +556,8 @@ export class QuickSwitcherController {
406
556
  renderTextState(message) {
407
557
  if (!this.resultsList) return;
408
558
  this.resultsList.innerHTML = '';
559
+ this.resultsList.setAttribute('aria-busy', message === 'Searching...' ? 'true' : 'false');
560
+ this.setActiveDescendant('');
409
561
  if (this.hint) {
410
562
  this.hint.textContent = message;
411
563
  this.hint.classList.remove('hidden');
@@ -415,17 +567,20 @@ export class QuickSwitcherController {
415
567
  renderTextResults(query = '') {
416
568
  if (!this.resultsList) return;
417
569
  this.resultsList.innerHTML = '';
570
+ this.resultsList.setAttribute('aria-busy', 'false');
418
571
 
419
572
  if (!this.textResults?.files?.length || this.textResultItems.length === 0) {
573
+ this.setActiveDescendant('');
420
574
  this.renderTextState(query ? 'No text matches found' : 'Type to search file text');
421
575
  return;
422
576
  }
423
577
 
424
578
  if (this.hint) {
425
- this.hint.classList.toggle('hidden', !this.textResults.truncated);
426
- this.hint.textContent = this.textResults.truncated
427
- ? 'Showing the first matches. Refine the query to narrow results.'
579
+ const hint = this.textResults.truncated
580
+ ? `Showing partial results: ${this.textResults.files.length} files and ${formatMatchCount(this.textResults.matchCount, { truncated: true })}. Refine the query to narrow results.`
428
581
  : '';
582
+ this.hint.textContent = hint;
583
+ this.hint.classList.toggle('hidden', !hint);
429
584
  }
430
585
 
431
586
  const fragment = document.createDocumentFragment();
@@ -434,13 +589,15 @@ export class QuickSwitcherController {
434
589
  this.textResults.files.forEach((fileGroup) => {
435
590
  const group = document.createElement('section');
436
591
  group.className = 'qs-text-group';
592
+ group.setAttribute('role', 'group');
593
+ group.setAttribute('aria-label', fileGroup.file);
437
594
 
438
595
  const header = document.createElement('div');
439
596
  header.className = 'qs-text-group-header';
440
597
  header.innerHTML = `
441
598
  <span class="qs-text-file-name">${escapeHtml(getFileName(fileGroup.file))}</span>
442
599
  <span class="qs-text-file-meta">${escapeHtml(getDirPath(fileGroup.file))}</span>
443
- <span class="qs-text-count">${escapeHtml(formatMatchCount(fileGroup.matchCount))}</span>
600
+ <span class="qs-text-count">${escapeHtml(formatMatchCount(fileGroup.matchCount, { truncated: fileGroup.truncated }))}</span>
444
601
  `;
445
602
  group.appendChild(header);
446
603
 
@@ -452,6 +609,9 @@ export class QuickSwitcherController {
452
609
  if (itemIndex === this.selectedTextIndex) {
453
610
  item.classList.add('selected');
454
611
  }
612
+ item.id = `${TEXT_RESULT_ID_PREFIX}${itemIndex}`;
613
+ item.setAttribute('role', 'option');
614
+ item.setAttribute('aria-selected', itemIndex === this.selectedTextIndex ? 'true' : 'false');
455
615
  item.dataset.textIndex = String(itemIndex);
456
616
  item.innerHTML = `
457
617
  <span class="qs-text-line">L${escapeHtml(String(snippet.line ?? 1))}</span>
@@ -473,6 +633,7 @@ export class QuickSwitcherController {
473
633
  });
474
634
 
475
635
  this.resultsList.appendChild(fragment);
636
+ this.updateSelection();
476
637
  }
477
638
 
478
639
  highlightSnippet(snippet = {}) {
@@ -501,25 +662,35 @@ export class QuickSwitcherController {
501
662
  if (this.mode === 'text') {
502
663
  const items = this.resultsList.querySelectorAll('.qs-text-item');
503
664
  items.forEach((item, i) => {
504
- item.classList.toggle('selected', i === this.selectedTextIndex);
665
+ const isSelected = i === this.selectedTextIndex;
666
+ item.classList.toggle('selected', isSelected);
667
+ item.setAttribute('aria-selected', isSelected ? 'true' : 'false');
505
668
  });
506
669
  items[this.selectedTextIndex]?.scrollIntoView({ block: 'nearest' });
670
+ this.setActiveDescendant(items[this.selectedTextIndex]?.id ?? '');
507
671
  return;
508
672
  }
509
673
 
510
674
  const items = this.resultsList.querySelectorAll('.qs-result-item');
511
675
  items.forEach((item, i) => {
512
- item.classList.toggle('selected', i === this.selectedIndex);
676
+ const isSelected = i === this.selectedIndex;
677
+ item.classList.toggle('selected', isSelected);
678
+ item.setAttribute('aria-selected', isSelected ? 'true' : 'false');
513
679
  });
514
680
 
515
681
  items[this.selectedIndex]?.scrollIntoView({ block: 'nearest' });
682
+ this.setActiveDescendant(items[this.selectedIndex]?.id ?? '');
683
+ }
684
+
685
+ setActiveDescendant(id = '') {
686
+ this.input?.setAttribute('aria-activedescendant', id);
516
687
  }
517
688
 
518
689
  confirmSelection() {
519
690
  if (this.mode === 'text') {
520
691
  const match = this.textResultItems[this.selectedTextIndex];
521
692
  if (match) {
522
- this.close();
693
+ this.close({ restoreFocus: false });
523
694
  this.onTextMatchSelect?.(match);
524
695
  }
525
696
  return;
@@ -527,7 +698,7 @@ export class QuickSwitcherController {
527
698
 
528
699
  const filePath = this.filteredFiles[this.selectedIndex];
529
700
  if (filePath) {
530
- this.close();
701
+ this.close({ restoreFocus: false });
531
702
  this.onFileSelect?.(filePath);
532
703
  }
533
704
  }
@@ -1,9 +1,9 @@
1
1
  export const DEFAULT_SEARCH_DEBOUNCE_MS = 220;
2
2
  const TEXT_RESULT_LIMIT = 50;
3
3
 
4
- export function formatMatchCount(count = 0) {
4
+ export function formatMatchCount(count = 0, { truncated = false } = {}) {
5
5
  const normalized = Number(count) || 0;
6
- return `${normalized} ${normalized === 1 ? 'match' : 'matches'}`;
6
+ return `${normalized}${truncated ? '+' : ''} ${normalized === 1 ? 'match' : 'matches'}`;
7
7
  }
8
8
 
9
9
  export function flattenTextResults(payload = {}) {
@@ -16,6 +16,9 @@ export class ToastController {
16
16
  : 0;
17
17
  const toast = document.createElement('div');
18
18
  toast.className = 'toast';
19
+ if (typeof normalizedOptions.tone === 'string' && normalizedOptions.tone) {
20
+ toast.dataset.tone = normalizedOptions.tone;
21
+ }
19
22
  const content = document.createElement('span');
20
23
  content.className = 'toast__message';
21
24
  content.textContent = message;
@@ -32,6 +32,12 @@
32
32
  display 300ms cubic-bezier(0.16, 1, 0.3, 1) allow-discrete;
33
33
  }
34
34
 
35
+ .toast[data-tone='warning'] {
36
+ border-color: var(--border-warning-toast);
37
+ background: var(--surface-warning-toast);
38
+ color: var(--text-warning-toast);
39
+ }
40
+
35
41
  .toast__message {
36
42
  flex: 1 1 auto;
37
43
  min-width: 0;