dsh-locator 0.9.0 → 0.10.1

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 (62) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README-es.md +9 -0
  3. package/README-hi.md +9 -0
  4. package/README-pt.md +9 -0
  5. package/README.en.md +10 -1
  6. package/README.md +10 -1
  7. package/bin/darwin-arm64/indexd +0 -0
  8. package/bin/darwin-x64/indexd +0 -0
  9. package/bin/linux-arm64/indexd +0 -0
  10. package/bin/linux-x64/indexd +0 -0
  11. package/bin/win32-arm64/indexd.exe +0 -0
  12. package/bin/win32-x64/indexd.exe +0 -0
  13. package/client.js +344 -146
  14. package/lib/combined.d.ts.map +1 -1
  15. package/lib/combined.js +14 -4
  16. package/lib/combined.js.map +1 -1
  17. package/lib/content-search-worker.d.ts +5 -0
  18. package/lib/content-search-worker.d.ts.map +1 -1
  19. package/lib/content-search-worker.js +51 -29
  20. package/lib/content-search-worker.js.map +1 -1
  21. package/lib/content-search.d.ts +46 -0
  22. package/lib/content-search.d.ts.map +1 -1
  23. package/lib/content-search.js +186 -69
  24. package/lib/content-search.js.map +1 -1
  25. package/lib/content.d.ts +66 -8
  26. package/lib/content.d.ts.map +1 -1
  27. package/lib/content.js +292 -78
  28. package/lib/content.js.map +1 -1
  29. package/lib/export.d.ts +61 -0
  30. package/lib/export.d.ts.map +1 -1
  31. package/lib/export.js +217 -52
  32. package/lib/export.js.map +1 -1
  33. package/lib/find.d.ts.map +1 -1
  34. package/lib/find.js +72 -16
  35. package/lib/find.js.map +1 -1
  36. package/lib/http.d.ts +38 -2
  37. package/lib/http.d.ts.map +1 -1
  38. package/lib/http.js +274 -80
  39. package/lib/http.js.map +1 -1
  40. package/lib/reveal.d.ts +69 -3
  41. package/lib/reveal.d.ts.map +1 -1
  42. package/lib/reveal.js +140 -11
  43. package/lib/reveal.js.map +1 -1
  44. package/lib/runtime.d.ts +7 -0
  45. package/lib/runtime.d.ts.map +1 -1
  46. package/lib/runtime.js +4 -0
  47. package/lib/runtime.js.map +1 -1
  48. package/lib/tools.d.ts +30 -0
  49. package/lib/tools.d.ts.map +1 -1
  50. package/lib/tools.js +72 -4
  51. package/lib/tools.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/combined.ts +13 -4
  54. package/src/content-search-worker.ts +47 -26
  55. package/src/content-search.ts +217 -66
  56. package/src/content.ts +287 -74
  57. package/src/export.ts +240 -50
  58. package/src/find.ts +82 -16
  59. package/src/http.ts +264 -81
  60. package/src/reveal.ts +148 -10
  61. package/src/runtime.ts +11 -0
  62. package/src/tools.ts +93 -7
package/client.js CHANGED
@@ -192,7 +192,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
192
192
  asc: 'Ascending', descOrder: 'Descending',
193
193
  groupContent: 'Content', groupFiles: 'Files', groupDirs: 'Folders',
194
194
  relevance: 'by relevance', contentBuilding: 'indexing… {files} files / {messages} messages',
195
- contentShort: 'Type at least 2 characters to search bodies and sessions',
195
+ contentShort: 'Type at least 3 characters to search bodies and sessions (Ctrl+F handles shorter queries)',
196
196
  contentDisabled: 'Content index is disabled (contentIndex: false)',
197
197
  contentCapped: 'first {n}', showing: 'first {n}', deduped: '{n} name hits merged into content', loading: 'Searching…',
198
198
  emptyQuery: 'Type to search file names, paths, bodies and sessions',
@@ -216,7 +216,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
216
216
  findScope: 'In selection', findScopeChip: 'selection {n} chars', findSessionChip: 'this conversation',
217
217
  findNoMatch: 'No matches', findEmpty: 'Type to find in this conversation',
218
218
  findInvalidRegex: 'Invalid regular expression: {message}', findInvalidEscape: 'Invalid extended escape: {token}',
219
- findTimeout: 'Regex timed out; simplify the expression', findWrappedTop: 'Wrapped to top',
219
+ findTimeout: 'Regex timed out; simplify the expression', findRegexUnavailable: 'Regex search is unavailable here (worker blocked)', findWrappedTop: 'Wrapped to top',
220
220
  findWrappedBottom: 'Wrapped to bottom', findNoWrapEnd: 'Reached the last match (wrap is off)',
221
221
  findNoWrapStart: 'Reached the first match (wrap is off)', findOlder: 'Load earlier messages and retry',
222
222
  findLoadingOlder: 'Loading earlier messages…', findLoadedOnly: 'loaded window only · earlier history not loaded',
@@ -226,7 +226,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
226
226
  panelTotal: 'selected {index}/{total}', panelRangeAll: 'All indexed', panelRangeSession: 'This conversation',
227
227
  panelSessionHint: 'This scope searches indexed session messages only; press Ctrl+F for live in-conversation find',
228
228
  panelRegexPaused: 'Content scope paused in regex mode', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
229
+ contentWarning: 'Index warning: {list}', contentBudget: 'Content index budget reached; some files are not indexed', contentTooShort: 'Content needs at least 3 characters (use Ctrl+F for shorter searches)', contentTimeout: 'Content search timed out; narrow the query or scope', contentBadChars: 'Content search does not support NUL/control characters',
229
230
  panelCaseShort: 'Aa', panelWholeShort: 'Word', panelModeLabel: 'Mode',
231
+ watchDegraded: 'watcher degraded; periodic refresh active',
230
232
  },
231
233
  zh: {
232
234
  scopeContent: '内容', scopeFiles: '文件', scopeDirs: '目录',
@@ -236,7 +238,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
236
238
  asc: '升序', descOrder: '降序',
237
239
  groupContent: '内容', groupFiles: '文件', groupDirs: '目录',
238
240
  relevance: '按相关度', contentBuilding: '索引构建中… {files} 文件 / {messages} 消息',
239
- contentShort: '输入至少 2 个字符后搜索正文与会话',
241
+ contentShort: '输入至少 3 个字符后搜索正文与会话(更短的词请用 Ctrl+F)',
240
242
  contentDisabled: '内容索引已关闭(contentIndex: false)',
241
243
  contentCapped: '前 {n} 条', showing: '前 {n} 条', deduped: '{n} 条文件命中已并入内容', loading: '搜索中…',
242
244
  emptyQuery: '输入关键词:文件名、路径、正文或会话内容',
@@ -260,7 +262,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
260
262
  findScope: '仅在选区内', findScopeChip: '选区 {n} 字', findSessionChip: '本会话',
261
263
  findNoMatch: '没有匹配', findEmpty: '输入关键词后在此会话中查找',
262
264
  findInvalidRegex: '正则无效:{message}', findInvalidEscape: '扩展转义无效:{token}',
263
- findTimeout: '正则执行超时,请简化表达式', findWrappedTop: '已回到顶部',
265
+ findTimeout: '正则执行超时,请简化表达式', findRegexUnavailable: '此处无法运行正则(worker 被阻止)', findWrappedTop: '已回到顶部',
264
266
  findWrappedBottom: '已到底部,回到开头', findNoWrapEnd: '已到最后一个匹配(循环查找已关闭)',
265
267
  findNoWrapStart: '已到第一个匹配(循环查找已关闭)', findOlder: '加载更早消息后重试',
266
268
  findLoadingOlder: '正在加载更早消息…', findLoadedOnly: '仅已加载窗口 · 更早历史未加载',
@@ -270,7 +272,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
270
272
  panelTotal: '已选 {index}/{total}', panelRangeAll: '全部索引', panelRangeSession: '当前会话',
271
273
  panelSessionHint: '此范围只查已索引的会话消息;实时查找请按 Ctrl+F',
272
274
  panelRegexPaused: '正则模式下内容范围暂停', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
275
+ contentWarning: '索引警告:{list}', contentBudget: '内容索引已达预算上限,部分文件未被收录', contentTooShort: '内容搜索至少需要 3 个字符(更短的词请用 Ctrl+F 在会话内查找)', contentTimeout: '内容搜索超时,请缩小关键词或范围', contentBadChars: '内容搜索不支持 NUL/控制字符',
273
276
  panelCaseShort: 'Aa', panelWholeShort: '全词', panelModeLabel: '模式',
277
+ watchDegraded: '文件监听降级,正在周期刷新',
274
278
  },
275
279
  es: {
276
280
  scopeContent: 'Contenido', scopeFiles: 'Archivos', scopeDirs: 'Carpetas',
@@ -280,7 +284,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
280
284
  asc: 'Ascendente', descOrder: 'Descendente',
281
285
  groupContent: 'Contenido', groupFiles: 'Archivos', groupDirs: 'Carpetas',
282
286
  relevance: 'por relevancia', contentBuilding: 'indexando… {files} archivos / {messages} mensajes',
283
- contentShort: 'Escribe al menos 2 caracteres para buscar contenido y sesiones',
287
+ contentShort: 'Escribe al menos 3 caracteres para buscar contenido y sesiones (Ctrl+F para consultas cortas)',
284
288
  contentDisabled: 'Índice de contenido desactivado (contentIndex: false)',
285
289
  contentCapped: 'primeros {n}', showing: 'primeros {n}', deduped: '{n} aciertos de nombre fusionados', loading: 'Buscando…',
286
290
  emptyQuery: 'Escribe para buscar nombres, rutas, contenido y sesiones',
@@ -304,7 +308,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
304
308
  findScope: 'En la selección', findScopeChip: 'selección {n} caracteres', findSessionChip: 'esta conversación',
305
309
  findNoMatch: 'Sin coincidencias', findEmpty: 'Escribe para buscar en esta conversación',
306
310
  findInvalidRegex: 'Expresión regular no válida: {message}', findInvalidEscape: 'Escape extendido no válido: {token}',
307
- findTimeout: 'La regex agotó el tiempo; simplifícala', findWrappedTop: 'Vuelto al inicio',
311
+ findTimeout: 'La regex agotó el tiempo; simplifícala', findRegexUnavailable: 'La búsqueda regex no está disponible aquí (worker bloqueado)', findWrappedTop: 'Vuelto al inicio',
308
312
  findWrappedBottom: 'Vuelto al final', findNoWrapEnd: 'Última coincidencia (continuación desactivada)',
309
313
  findNoWrapStart: 'Primera coincidencia (continuación desactivada)', findOlder: 'Cargar mensajes anteriores y reintentar',
310
314
  findLoadingOlder: 'Cargando mensajes anteriores…', findLoadedOnly: 'solo ventana cargada · historial anterior sin cargar',
@@ -314,7 +318,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
314
318
  panelTotal: 'seleccionado {index}/{total}', panelRangeAll: 'Todo el índice', panelRangeSession: 'Esta conversación',
315
319
  panelSessionHint: 'Este ámbito solo busca mensajes indexados; pulsa Ctrl+F para buscar en vivo',
316
320
  panelRegexPaused: 'Ámbito de contenido en pausa en modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
321
+ contentWarning: 'Aviso del índice: {list}', contentBudget: 'Se alcanzó el presupuesto del índice; algunos archivos no están indexados', contentTooShort: 'El contenido requiere al menos 3 caracteres (usa Ctrl+F para búsquedas más cortas)', contentTimeout: 'La búsqueda de contenido agotó el tiempo; reduce la consulta o el ámbito', contentBadChars: 'La búsqueda de contenido no admite NUL/caracteres de control',
317
322
  panelCaseShort: 'Aa', panelWholeShort: 'Palabra', panelModeLabel: 'Modo',
323
+ watchDegraded: 'vigilante degradado; refresco periódico activo',
318
324
  },
319
325
  pt: {
320
326
  scopeContent: 'Conteúdo', scopeFiles: 'Arquivos', scopeDirs: 'Pastas',
@@ -324,7 +330,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
324
330
  asc: 'Ascendente', descOrder: 'Descendente',
325
331
  groupContent: 'Conteúdo', groupFiles: 'Arquivos', groupDirs: 'Pastas',
326
332
  relevance: 'por relevância', contentBuilding: 'indexando… {files} arquivos / {messages} mensagens',
327
- contentShort: 'Digite pelo menos 2 caracteres para buscar conteúdo e sessões',
333
+ contentShort: 'Digite pelo menos 3 caracteres para buscar conteúdo e sessões (Ctrl+F para consultas curtas)',
328
334
  contentDisabled: 'Índice de conteúdo desativado (contentIndex: false)',
329
335
  contentCapped: 'primeiros {n}', showing: 'primeiros {n}', deduped: '{n} acertos de nome mesclados', loading: 'Buscando…',
330
336
  emptyQuery: 'Digite para buscar nomes, caminhos, conteúdo e sessões',
@@ -348,7 +354,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
348
354
  findScope: 'Na seleção', findScopeChip: 'seleção {n} caracteres', findSessionChip: 'esta conversa',
349
355
  findNoMatch: 'Sem resultados', findEmpty: 'Digite para buscar nesta conversa',
350
356
  findInvalidRegex: 'Expressão regular inválida: {message}', findInvalidEscape: 'Escape estendido inválido: {token}',
351
- findTimeout: 'A regex excedeu o tempo; simplifique', findWrappedTop: 'Voltou ao início',
357
+ findTimeout: 'A regex excedeu o tempo; simplifique', findRegexUnavailable: 'A busca regex não está disponível aqui (worker bloqueado)', findWrappedTop: 'Voltou ao início',
352
358
  findWrappedBottom: 'Voltou ao fim', findNoWrapEnd: 'Última ocorrência (continuação desativada)',
353
359
  findNoWrapStart: 'Primeira ocorrência (continuação desativada)', findOlder: 'Carregar mensagens anteriores e tentar novamente',
354
360
  findLoadingOlder: 'Carregando mensagens anteriores…', findLoadedOnly: 'apenas janela carregada · histórico anterior não carregado',
@@ -358,7 +364,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
358
364
  panelTotal: 'selecionado {index}/{total}', panelRangeAll: 'Todo o índice', panelRangeSession: 'Esta conversa',
359
365
  panelSessionHint: 'Este escopo busca apenas mensagens indexadas; use Ctrl+F para buscar ao vivo',
360
366
  panelRegexPaused: 'Escopo de conteúdo pausado no modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
367
+ contentWarning: 'Aviso do índice: {list}', contentBudget: 'O orçamento do índice foi atingido; alguns arquivos não estão indexados', contentTooShort: 'O conteúdo exige pelo menos 3 caracteres (use Ctrl+F para buscas menores)', contentTimeout: 'A busca de conteúdo excedeu o tempo; reduza a consulta ou o escopo', contentBadChars: 'A busca de conteúdo não suporta NUL/caracteres de controle',
361
368
  panelCaseShort: 'Aa', panelWholeShort: 'Palavra', panelModeLabel: 'Modo',
369
+ watchDegraded: 'watcher degradado; atualização periódica ativa',
362
370
  },
363
371
  hi: {
364
372
  scopeContent: 'सामग्री', scopeFiles: 'फ़ाइलें', scopeDirs: 'फ़ोल्डर',
@@ -368,7 +376,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
368
376
  asc: 'आरोही', descOrder: 'अवरोही',
369
377
  groupContent: 'सामग्री', groupFiles: 'फ़ाइलें', groupDirs: 'फ़ोल्डर',
370
378
  relevance: 'प्रासंगिकता से', contentBuilding: 'इंडेक्स बन रहा है… {files} फ़ाइलें / {messages} संदेश',
371
- contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 2 अक्षर लिखें',
379
+ contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 3 अक्षर लिखें (छोटी खोज के लिए Ctrl+F)',
372
380
  contentDisabled: 'सामग्री इंडेक्स बंद है (contentIndex: false)',
373
381
  contentCapped: 'पहले {n}', showing: 'पहले {n}', deduped: '{n} नाम-परिणाम सामग्री में मिलाए', loading: 'खोज रहे हैं…',
374
382
  emptyQuery: 'नाम, पथ, सामग्री और सत्र खोजने के लिए लिखें',
@@ -392,7 +400,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
392
400
  findScope: 'चयन में', findScopeChip: 'चयन {n} अक्षर', findSessionChip: 'यह बातचीत',
393
401
  findNoMatch: 'कोई मेल नहीं', findEmpty: 'इस बातचीत में खोजने के लिए लिखें',
394
402
  findInvalidRegex: 'अमान्य रेगुलर एक्सप्रेशन: {message}', findInvalidEscape: 'अमान्य विस्तृत एस्केप: {token}',
395
- findTimeout: 'रेगेक्स का समय समाप्त; सरल बनाएँ', findWrappedTop: 'शीर्ष पर लौटा',
403
+ findTimeout: 'रेगेक्स का समय समाप्त; सरल बनाएँ', findRegexUnavailable: 'यहाँ रेगेक्स उपलब्ध नहीं है (वर्कर अवरुद्ध)', findWrappedTop: 'शीर्ष पर लौटा',
396
404
  findWrappedBottom: 'अंत पर लौटा', findNoWrapEnd: 'अंतिम मेल (चक्र बंद)',
397
405
  findNoWrapStart: 'पहला मेल (चक्र बंद)', findOlder: 'पुराने संदेश लोड कर पुन: प्रयास',
398
406
  findLoadingOlder: 'पुराने संदेश लोड हो रहे हैं…', findLoadedOnly: 'केवल लोडेड विंडो · पुराना इतिहास लोड नहीं',
@@ -402,7 +410,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
402
410
  panelTotal: 'चयनित {index}/{total}', panelRangeAll: 'पूरा इंडेक्स', panelRangeSession: 'यह बातचीत',
403
411
  panelSessionHint: 'यह क्षेत्र केवल इंडेक्स किए संदेश खोजता है; लाइव खोज के लिए Ctrl+F दबाएँ',
404
412
  panelRegexPaused: 'रेगेक्स मोड में सामग्री क्षेत्र रुका है', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
413
+ contentWarning: 'इंडेक्स चेतावनी: {list}', contentBudget: 'सामग्री इंडेक्स बजट पूरा; कुछ फ़ाइलें इंडेक्स नहीं हैं', contentTooShort: 'सामग्री के लिए कम से कम 3 अक्षर चाहिए (छोटी खोज के लिए Ctrl+F)', contentTimeout: 'सामग्री खोज का समय समाप्त; क्वेरी या क्षेत्र घटाएँ', contentBadChars: 'सामग्री खोज NUL/नियंत्रण अक्षर नहीं लेती',
405
414
  panelCaseShort: 'Aa', panelWholeShort: 'शब्द', panelModeLabel: 'मोड',
415
+ watchDegraded: 'वॉचर क्षीण; आवधिक रिफ्रेश सक्रिय',
406
416
  },
407
417
  }
408
418
  const LANG = (() => {
@@ -540,7 +550,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
540
550
  const HL_BASE = 'dsh-locator-find'
541
551
  const HL_ACTIVE = 'dsh-locator-find-active'
542
552
  const GESTURES = ['wheel', 'touchstart', 'pointerdown', 'keydown']
543
- const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style'
553
+ const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style,'
554
+ + '.sr-only,[class*="visuallyHidden"],[role="status"],[aria-live]'
544
555
  const BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
545
556
  const legacyTargets = new Set()
546
557
 
@@ -611,21 +622,28 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
611
622
  return scope === null ? null : scope.querySelector('[data-chat-flow]')
612
623
  }
613
624
 
614
- function findMessageRow(flow, messageId, nodeKey) {
615
- if (flow === null) return null
625
+ function findMessageRows(flow, messageId, nodeKey) {
626
+ if (flow === null) return []
616
627
  const rows = flow.querySelectorAll('[data-chat-node-key]')
628
+ const exact = []
617
629
  if (nodeKey) {
618
630
  for (const row of rows) {
619
- if (row.getAttribute('data-chat-node-key') === String(nodeKey)) return row
631
+ if (row.getAttribute('data-chat-node-key') === String(nodeKey)) exact.push(row)
620
632
  }
621
633
  }
622
- if (!messageId) return null
623
- const id = String(messageId)
624
- for (const row of rows) {
625
- const key = row.getAttribute('data-chat-node-key') || ''
626
- if (key === id || key.endsWith(id)) return row
634
+ if (exact.length === 0 && messageId) {
635
+ const id = String(messageId)
636
+ for (const row of rows) {
637
+ const key = row.getAttribute('data-chat-node-key') || ''
638
+ if (key === id || key.endsWith(id)) exact.push(row)
639
+ }
627
640
  }
628
- return null
641
+ // A single assistant step can be rendered as a reasoning row and a
642
+ // response row under the same node key; prefer the response row.
643
+ return exact.sort((left, right) => {
644
+ const score = row => row.getAttribute('data-chat-group-part') === 'response' ? 0 : 1
645
+ return score(left) - score(right)
646
+ })
629
647
  }
630
648
 
631
649
  function needlesFromQuery(query) {
@@ -635,57 +653,173 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
635
653
  return [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 8)
636
654
  }
637
655
 
638
- function inlineGroups(root) {
639
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
656
+ function projectGroups(root, excluded) {
657
+ if (root === null) return []
640
658
  const groups = new Map()
659
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
660
+ acceptNode(node) {
661
+ if (node.nodeType === Node.ELEMENT_NODE) {
662
+ if (node.matches(excluded)) return NodeFilter.FILTER_REJECT
663
+ if (node.tagName === 'BR') return NodeFilter.FILTER_ACCEPT
664
+ return NodeFilter.FILTER_SKIP
665
+ }
666
+ return NodeFilter.FILTER_ACCEPT
667
+ },
668
+ })
669
+ const groupFor = block => {
670
+ let group = groups.get(block)
671
+ if (group === undefined) {
672
+ group = {
673
+ block, raw: '', rawLength: 0, text: '', runs: [], nodes: [], breaks: [],
674
+ collapse: !(block instanceof Element && block.tagName === 'PRE'),
675
+ }
676
+ groups.set(block, group)
677
+ }
678
+ return group
679
+ }
680
+ const appendText = (group, node) => {
681
+ const value = node.nodeValue || ''
682
+ if (value === '') return
683
+ group.nodes.push({ node, start: group.raw.length, end: group.raw.length + value.length })
684
+ group.raw += value
685
+ }
686
+ const appendBreak = group => {
687
+ group.breaks.push(group.raw.length)
688
+ group.raw += '\u0000'
689
+ }
641
690
  let node = walker.nextNode()
642
691
  while (node !== null) {
643
692
  const parent = node.parentElement
644
- if (parent !== null && parent.closest(EXCLUDED) === null) {
645
- const value = node.nodeValue || ''
646
- if (value !== '') {
647
- const block = parent.closest(BLOCK_SELECTOR) || root
648
- let group = groups.get(block)
649
- if (group === undefined) { group = { nodes: [], text: '' }; groups.set(block, group) }
650
- group.nodes.push(node)
651
- group.text += value
693
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
694
+ if (parent !== null && parent.closest(excluded) === null) {
695
+ const block = parent.closest(BLOCK_SELECTOR)
696
+ || parent.closest('[data-text-interaction-source]')
697
+ || parent.closest('[data-chat-node-key]')
698
+ || root
699
+ appendBreak(groupFor(block))
652
700
  }
701
+ } else if (parent !== null && parent.closest(excluded) === null) {
702
+ const block = parent.closest(BLOCK_SELECTOR)
703
+ || parent.closest('[data-text-interaction-source]')
704
+ || parent.closest('[data-chat-node-key]')
705
+ || root
706
+ appendText(groupFor(block), node)
653
707
  }
654
708
  node = walker.nextNode()
655
709
  }
710
+ // Project the concatenated raw text once per block. Soft newlines and
711
+ // repeated whitespace become one space outside <pre>; <br> becomes a
712
+ // separator. rums maps projected offsets back to raw offsets without
713
+ // allocating one object per character (a multi-MB transcript would
714
+ // otherwise create millions of position objects).
715
+ for (const group of groups.values()) {
716
+ const raw = group.raw
717
+ group.rawLength = raw.length
718
+ if (!group.collapse) {
719
+ group.text = raw.replace(/\u0000/g, '\n')
720
+ group.runs = []
721
+ continue
722
+ }
723
+ let text = ''
724
+ const runs = []
725
+ let index = 0
726
+ while (index < raw.length) {
727
+ const char = raw[index]
728
+ if (char === '\u0000' || /\s/u.test(char)) {
729
+ const start = index
730
+ while (index < raw.length && (raw[index] === '\u0000' || /\s/u.test(raw[index]))) index++
731
+ runs.push({ rawStart: start, rawLength: index - start, projectedStart: text.length })
732
+ text += ' '
733
+ } else {
734
+ text += char
735
+ index++
736
+ }
737
+ }
738
+ group.text = text
739
+ group.runs = runs
740
+ }
656
741
  return [...groups.values()]
657
742
  }
658
743
 
659
- function offsetInGroup(group, index) {
660
- let consumed = 0
661
- for (const node of group.nodes) {
662
- const length = (node.nodeValue || '').length
663
- if (index <= consumed + length) return [node, index - consumed]
664
- consumed += length
744
+ function inlineGroups(root) {
745
+ return projectGroups(root, EXCLUDED)
746
+ }
747
+
748
+ function projectedToRaw(group, index, isEnd) {
749
+ if (!group.collapse) return Math.max(0, Math.min(group.rawLength, index))
750
+ let extra = 0
751
+ for (const run of group.runs) {
752
+ if (run.projectedStart < index || (isEnd && run.projectedStart === index)) extra += run.rawLength - 1
753
+ else break
754
+ }
755
+ return Math.max(0, Math.min(group.rawLength, index + extra))
756
+ }
757
+
758
+ function rawToProjected(group, raw) {
759
+ if (!group.collapse) return Math.max(0, Math.min(group.rawLength, raw))
760
+ let extra = 0
761
+ for (const run of group.runs) {
762
+ const end = run.rawStart + run.rawLength
763
+ if (raw >= end) extra += run.rawLength - 1
764
+ else if (raw > run.rawStart) return run.projectedStart
765
+ else break
766
+ }
767
+ return Math.max(0, Math.min(group.rawLength, raw - extra))
768
+ }
769
+
770
+ function pointForRaw(group, raw, isEnd) {
771
+ for (const entry of group.nodes) {
772
+ if (raw < entry.start) continue
773
+ if (raw <= entry.end) return { node: entry.node, offset: Math.min(raw - entry.start, entry.end - entry.start) }
774
+ }
775
+ for (const br of group.breaks) {
776
+ if (raw !== br) continue
777
+ if (isEnd) {
778
+ for (let i = group.nodes.length - 1; i >= 0; i--) {
779
+ const entry = group.nodes[i]
780
+ if (entry.end <= br) return { node: entry.node, offset: entry.end - entry.start }
781
+ }
782
+ } else {
783
+ for (const entry of group.nodes) {
784
+ if (entry.start > br) return { node: entry.node, offset: 0 }
785
+ }
786
+ }
665
787
  }
666
788
  const last = group.nodes[group.nodes.length - 1]
667
- return [last, (last.nodeValue || '').length]
789
+ return last === undefined ? null : { node: last.node, offset: last.end - last.start }
790
+ }
791
+
792
+ function positionAt(group, index, isEnd) {
793
+ const raw = projectedToRaw(group, index, isEnd)
794
+ return pointForRaw(group, raw, isEnd)
795
+ }
796
+
797
+ function rangeAt(group, start, end) {
798
+ const from = positionAt(group, start, false)
799
+ const to = positionAt(group, end, true)
800
+ if (from === null || to === null) return null
801
+ const range = document.createRange()
802
+ try {
803
+ range.setStart(from.node, from.offset)
804
+ range.setEnd(to.node, to.offset)
805
+ return range
806
+ } catch {
807
+ return null
808
+ }
668
809
  }
669
810
 
670
811
  function rangesForNeedles(root, needles) {
671
812
  const ranges = []
672
813
  if (root === null || needles.length === 0) return ranges
814
+ const patterns = needles.map(escapeRegExp)
815
+ const expression = new RegExp(patterns.join('|'), 'giu')
673
816
  for (const group of inlineGroups(root)) {
674
- const lower = group.text.toLowerCase()
675
- for (const needle of needles) {
676
- const target = needle.toLowerCase()
677
- let from = 0
678
- while (ranges.length < 200) {
679
- const index = lower.indexOf(target, from)
680
- if (index < 0) break
681
- const start = offsetInGroup(group, index)
682
- const end = offsetInGroup(group, index + target.length)
683
- const range = document.createRange()
684
- range.setStart(start[0], start[1])
685
- range.setEnd(end[0], end[1])
686
- ranges.push(range)
687
- from = index + target.length
688
- }
817
+ expression.lastIndex = 0
818
+ let match
819
+ while (ranges.length < 200 && (match = expression.exec(group.text)) !== null) {
820
+ if (match[0].length === 0) { expression.lastIndex++; continue }
821
+ const range = rangeAt(group, match.index, match.index + match[0].length)
822
+ if (range !== null) ranges.push(range)
689
823
  }
690
824
  }
691
825
  return ranges
@@ -718,16 +852,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
718
852
  function phraseRange(root, snippet) {
719
853
  if (root === null) return null
720
854
  for (const phrase of phraseCandidates(snippet)) {
721
- const needle = phrase.toLowerCase()
855
+ const expression = new RegExp(escapeRegExp(phrase), 'iu')
722
856
  for (const group of inlineGroups(root)) {
723
- const index = group.text.toLowerCase().indexOf(needle)
724
- if (index < 0) continue
725
- const start = offsetInGroup(group, index)
726
- const end = offsetInGroup(group, index + needle.length)
727
- const range = document.createRange()
728
- range.setStart(start[0], start[1])
729
- range.setEnd(end[0], end[1])
730
- return range
857
+ const match = expression.exec(group.text)
858
+ if (match === null) continue
859
+ const range = rangeAt(group, match.index, match.index + match[0].length)
860
+ if (range !== null) return range
731
861
  }
732
862
  }
733
863
  return null
@@ -851,15 +981,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
851
981
  if (flow === null) { showNotice(t('jumpFailed')); return false }
852
982
  const needles = needlesFromQuery(query)
853
983
  const finder = () => {
854
- const row = findMessageRow(flow, hit.messageId, hit.nodeKey)
855
- if (row !== null) {
856
- expandHidden(row)
857
- const ranges = rangesForNeedles(row, needles)
858
- if (ranges.length > 0) return { row, ranges }
859
- const phrase = phraseRange(row, hit.snippet)
860
- if (phrase !== null) return { row, ranges: [phrase] }
984
+ const rows = findMessageRows(flow, hit.messageId, hit.nodeKey)
985
+ if (rows.length > 0) {
986
+ for (const row of rows) {
987
+ expandHidden(row)
988
+ const ranges = rangesForNeedles(row, needles)
989
+ if (ranges.length > 0) return { row, ranges }
990
+ const phrase = phraseRange(row, hit.snippet)
991
+ if (phrase !== null) {
992
+ // Keep the phrase range inside the anchored row instead of
993
+ // falling through to a wrong copy elsewhere in the transcript.
994
+ const inside = row.contains(phrase.commonAncestorContainer)
995
+ if (inside) return { row, ranges: [phrase] }
996
+ }
997
+ }
861
998
  return null
862
999
  }
1000
+ // With an exact node key there is nothing safe to fall back to in the
1001
+ // already loaded window; wait for older pages instead of landing on a
1002
+ // similar phrase in a different message.
1003
+ if (hit.nodeKey || hit.messageId) return null
863
1004
  const phrase = phraseRange(flow, hit.snippet)
864
1005
  if (phrase === null) return null
865
1006
  expandHidden(phrase.startContainer)
@@ -872,7 +1013,6 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
872
1013
  let target = await waitFor(flow, finder, 6_000, 120)
873
1014
  for (let attempt = 0; target === null && attempt < 10; attempt++) {
874
1015
  if (stale()) return false
875
- for (const hidden of flow.querySelectorAll('[hidden="until-found"]')) hidden.dispatchEvent(new Event('beforematch'))
876
1016
  if (!revealOlder(scope)) break
877
1017
  target = await waitFor(flow, finder, 1_500, 150)
878
1018
  }
@@ -961,7 +1101,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
961
1101
  const FIND_BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
962
1102
  const FIND_EXCLUDED = '[hidden]:not([hidden="until-found"]),[data-chat-history],[data-text-interaction-surface],'
963
1103
  + '[data-composer-seat],[data-text-interaction-exclude],script,style,button,input,textarea,select,'
964
- + '.sr-only,.katex-mathml,[aria-hidden="true"]:not(.katex-html)'
1104
+ + '.sr-only,[class*="visuallyHidden"],[role="status"],[aria-live],.katex-mathml,[aria-hidden="true"]:not(.katex-html)'
965
1105
 
966
1106
  function createFindStore() {
967
1107
  const listeners = new Set()
@@ -1120,13 +1260,17 @@ self.onmessage = event => {
1120
1260
  }
1121
1261
  if (mode !== 'regex') source = escapeRegExp(source)
1122
1262
  const flags = 'g' + (caseSensitive ? '' : 'i')
1263
+ // Prefer the Unicode flag so \p{...}, astral literals and modern syntax
1264
+ // behave as users expect. Fall back once for legacy patterns that the
1265
+ // unicode grammar rejects (for example some lone escapes).
1123
1266
  try {
1124
- return { source, flags: /[\u{10000}-\u{10FFFF}]/u.test(source) ? flags + 'u' : flags, re: new RegExp(source, flags) }
1267
+ return { source, flags: flags + 'u', re: new RegExp(source, flags + 'u') }
1125
1268
  } catch (error) {
1126
- if (mode === 'regex') {
1127
- try { return { source, flags: flags + 'u', re: new RegExp(source, flags + 'u') } } catch { /* fall through */ }
1269
+ try {
1270
+ return { source, flags, re: new RegExp(source, flags) }
1271
+ } catch (fallbackError) {
1272
+ return { error: t('findInvalidRegex', { message: String(fallbackError && fallbackError.message || fallbackError).slice(0, 140) }) }
1128
1273
  }
1129
- return { error: t('findInvalidRegex', { message: String(error && error.message || error).slice(0, 140) }) }
1130
1274
  }
1131
1275
  }
1132
1276
 
@@ -1139,67 +1283,38 @@ self.onmessage = event => {
1139
1283
  }
1140
1284
 
1141
1285
  function collectFindGroups(root) {
1142
- if (root === null) return []
1143
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
1144
- const groups = new Map()
1145
- let node = walker.nextNode()
1146
- while (node !== null) {
1147
- const parent = node.parentElement
1148
- if (parent !== null && parent.closest(FIND_EXCLUDED) === null) {
1149
- const value = node.nodeValue || ''
1150
- if (value !== '') {
1151
- const block = parent.closest(FIND_BLOCK_SELECTOR)
1152
- || parent.closest('[data-text-interaction-source]')
1153
- || parent.closest('[data-chat-node-key]')
1154
- || root
1155
- let group = groups.get(block)
1156
- if (group === undefined) { group = { block, nodes: [], text: '', starts: [] }; groups.set(block, group) }
1157
- group.starts.push(group.text.length)
1158
- group.nodes.push(node)
1159
- group.text += value
1160
- }
1161
- }
1162
- node = walker.nextNode()
1163
- }
1164
- return [...groups.values()]
1286
+ return projectGroups(root, FIND_EXCLUDED)
1165
1287
  }
1166
1288
 
1167
1289
  function clipForGroup(group, range) {
1168
1290
  if (range === null) return null
1169
- let start = null
1170
- let end = null
1171
- let position = 0
1172
- for (const node of group.nodes) {
1173
- const length = (node.nodeValue || '').length
1291
+ let rawStart = null
1292
+ let rawEnd = null
1293
+ for (const entry of group.nodes) {
1294
+ const node = entry.node
1295
+ const length = entry.end - entry.start
1296
+ try {
1297
+ if (!range.intersectsNode(node)) continue
1298
+ } catch { continue }
1174
1299
  let nodeStart = 0
1175
1300
  let nodeEnd = length
1176
- try {
1177
- if (!range.intersectsNode(node)) { position += length; continue }
1178
- } catch { position += length; continue }
1179
1301
  if (node === range.startContainer) nodeStart = range.startOffset
1180
1302
  if (node === range.endContainer) nodeEnd = range.endOffset
1181
1303
  if (nodeStart < nodeEnd) {
1182
- if (start === null) start = position + nodeStart
1183
- end = position + nodeEnd
1304
+ if (rawStart === null) rawStart = entry.start + nodeStart
1305
+ rawEnd = entry.start + nodeEnd
1184
1306
  }
1185
- position += length
1186
1307
  }
1187
- if (start === null || end === null || start >= end) return null
1308
+ if (rawStart === null || rawEnd === null) return null
1309
+ const start = rawToProjected(group, rawStart)
1310
+ const end = rawToProjected(group, rawEnd)
1311
+ if (start >= end) return null
1188
1312
  return [start, end]
1189
1313
  }
1190
1314
 
1191
1315
  function rangeForGroupOffsets(group, start, end) {
1192
1316
  if (group.nodes.length === 0) return null
1193
- const startOffset = offsetInGroup(group, start)
1194
- const endOffset = offsetInGroup(group, end)
1195
- const range = document.createRange()
1196
- try {
1197
- range.setStart(startOffset[0], startOffset[1])
1198
- range.setEnd(endOffset[0], endOffset[1])
1199
- return range
1200
- } catch {
1201
- return null
1202
- }
1317
+ return rangeAt(group, start, end)
1203
1318
  }
1204
1319
 
1205
1320
  function findHistoryButton(scope) {
@@ -1227,6 +1342,8 @@ self.onmessage = event => {
1227
1342
  const debounceRef = useRef(0)
1228
1343
  const observerRef = useRef(null)
1229
1344
  const revealTimersRef = useRef([])
1345
+ const revealFrameRef = useRef(0)
1346
+ const loadOlderTimerRef = useRef(0)
1230
1347
  const returnFocusRef = useRef(null)
1231
1348
  const [anchor, setAnchor] = useState(null)
1232
1349
  const composingRef = useRef(false)
@@ -1248,7 +1365,9 @@ self.onmessage = event => {
1248
1365
  if (!match.range.startContainer.isConnected) return
1249
1366
  centerRange(match.range)
1250
1367
  }
1251
- requestAnimationFrame(run)
1368
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(revealFrameRef.current)
1369
+ revealFrameRef.current = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : 0
1370
+ if (revealFrameRef.current === 0) run()
1252
1371
  for (const delay of [80, 240]) {
1253
1372
  const timer = setTimeout(run, delay)
1254
1373
  revealTimersRef.current.push(timer)
@@ -1302,7 +1421,11 @@ self.onmessage = event => {
1302
1421
  let selectionRange = null
1303
1422
  if (state.scope === 'selection' && state.selection) {
1304
1423
  selectionRange = state.selection.range
1305
- if (!selectionRange.startContainer.isConnected) {
1424
+ let selectionDead = !selectionRange.startContainer.isConnected || selectionRange.collapsed
1425
+ if (!selectionDead) {
1426
+ try { selectionDead = selectionRange.toString().trim() === '' } catch { selectionDead = true }
1427
+ }
1428
+ if (selectionDead) {
1306
1429
  findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1307
1430
  return
1308
1431
  }
@@ -1322,12 +1445,20 @@ self.onmessage = event => {
1322
1445
  texts.push(text)
1323
1446
  clips.push(clip === null ? [0, group.text.length] : clip)
1324
1447
  }
1448
+ if (selectionRange !== null && clips.every(clip => clip === null)) {
1449
+ findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1450
+ return
1451
+ }
1325
1452
  let rawMatches = null
1326
1453
  let capped = false
1327
1454
  if (state.mode === 'regex') {
1328
1455
  try {
1329
1456
  const result = await scanRegexInWorker(texts.filter(text => text !== null), compiled.source, compiled.flags)
1330
1457
  if (token !== sequenceRef.current) return
1458
+ if (result === null) {
1459
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findRegexUnavailable') })
1460
+ return
1461
+ }
1331
1462
  if (result !== null) {
1332
1463
  if (result.timeout === true) {
1333
1464
  matchesRef.current = []
@@ -1438,10 +1569,11 @@ self.onmessage = event => {
1438
1569
  debounceRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 400)
1439
1570
  }
1440
1571
  const scope = scopeRef.current
1441
- if (scope !== null) {
1572
+ const observed = scope === null ? null : (chatFlow(scope) || scope)
1573
+ if (observed !== null) {
1442
1574
  try {
1443
1575
  observer = new MutationObserver(schedule)
1444
- observer.observe(scope, { childList: true, subtree: true, characterData: true })
1576
+ observer.observe(observed, { childList: true, subtree: true, characterData: true })
1445
1577
  } catch { /* observer unavailable */ }
1446
1578
  }
1447
1579
  return () => {
@@ -1451,6 +1583,8 @@ self.onmessage = event => {
1451
1583
  if (observer !== null) observer.disconnect()
1452
1584
  for (const id of revealTimersRef.current) clearTimeout(id)
1453
1585
  revealTimersRef.current = []
1586
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(revealFrameRef.current)
1587
+ clearTimeout(loadOlderTimerRef.current)
1454
1588
  sequenceRef.current++
1455
1589
  clearFindHighlight('find')
1456
1590
  setFindScope(null)
@@ -1495,7 +1629,10 @@ self.onmessage = event => {
1495
1629
  if (text !== '') { event.preventDefault(); copyText(text).then(() => findStore.set({ hint: t('findCopied') })).catch(() => {}) }
1496
1630
  }
1497
1631
  }
1498
- const pickMode = event => { findStore.set({ mode: event.target.value }) }
1632
+ const pickMode = event => {
1633
+ findStore.set({ mode: event.target.value })
1634
+ if (inputRef.current !== null) inputRef.current.focus()
1635
+ }
1499
1636
  const toggle = key => { findStore.set({ [key]: !findStore.get()[key] }) }
1500
1637
  const toggleScope = () => {
1501
1638
  if (state.scope === 'selection') findStore.set({ scope: 'session', selection: null, hint: '' })
@@ -1513,7 +1650,8 @@ self.onmessage = event => {
1513
1650
  const loadOlder = () => {
1514
1651
  if (scopeRef.current === null || !revealOlder(scopeRef.current)) return
1515
1652
  findStore.set({ hint: t('findLoadingOlder') })
1516
- setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 700)
1653
+ clearTimeout(loadOlderTimerRef.current)
1654
+ loadOlderTimerRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 700)
1517
1655
  }
1518
1656
  return h('div', {
1519
1657
  ref: barRef,
@@ -1551,7 +1689,10 @@ self.onmessage = event => {
1551
1689
  h('button', { className: 'dsh-locator-findbar-btn', title: t('findClose'), 'aria-label': t('findClose'),
1552
1690
  onClick: () => closeFind() }, '×'),
1553
1691
  ),
1554
- h('div', { className: 'dsh-locator-findbar-opts' },
1692
+ h('div', { className: 'dsh-locator-findbar-opts', onMouseDown: event => {
1693
+ if (event.target instanceof HTMLSelectElement) return
1694
+ event.preventDefault()
1695
+ } },
1555
1696
  h('label', { className: 'dsh-locator-findbar-label' }, t('findMode'),
1556
1697
  h('select', { value: state.mode, onChange: pickMode, 'aria-label': t('findMode') },
1557
1698
  h('option', { value: 'normal' }, t('modeNormal')),
@@ -1613,6 +1754,8 @@ self.onmessage = event => {
1613
1754
  const panelRef = useRef(null)
1614
1755
  const abortRef = useRef(null)
1615
1756
  const listRef = useRef(null)
1757
+ const toastTimerRef = useRef(0)
1758
+ const insertTimerRef = useRef(0)
1616
1759
 
1617
1760
  const close = useCallback(() => store.set(false), [])
1618
1761
  const activeScope = conversationScope('')
@@ -1628,7 +1771,9 @@ self.onmessage = event => {
1628
1771
 
1629
1772
  useEffect(() => {
1630
1773
  if (!open) return undefined
1774
+ if (sessionOnly && activeSessionId === '') setSessionOnly(false)
1631
1775
  closeFind()
1776
+ setGroups(null)
1632
1777
  const previous = document.activeElement
1633
1778
  setError('')
1634
1779
  const timer = setTimeout(() => {
@@ -1639,9 +1784,24 @@ self.onmessage = event => {
1639
1784
  }, 30)
1640
1785
  return () => {
1641
1786
  clearTimeout(timer)
1787
+ if (abortRef.current !== null) { abortRef.current.abort(); abortRef.current = null }
1788
+ setGroups(null)
1642
1789
  if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
1643
1790
  }
1644
- }, [open])
1791
+ }, [open, sessionOnly, activeSessionId])
1792
+
1793
+ useEffect(() => {
1794
+ if (!open) return undefined
1795
+ const onDocumentKey = event => {
1796
+ if (event.key !== 'Escape' || event.defaultPrevented) return
1797
+ const target = event.target
1798
+ if (target instanceof Element && target.closest('.dsh-locator-panel') !== null) return
1799
+ event.preventDefault()
1800
+ close()
1801
+ }
1802
+ document.addEventListener('keydown', onDocumentKey, true)
1803
+ return () => document.removeEventListener('keydown', onDocumentKey, true)
1804
+ }, [open, close])
1645
1805
 
1646
1806
  useEffect(() => {
1647
1807
  if (!open) return undefined
@@ -1742,7 +1902,12 @@ self.onmessage = event => {
1742
1902
 
1743
1903
  const notify = useCallback(message => {
1744
1904
  setToast(message)
1745
- setTimeout(() => setToast(''), 1600)
1905
+ clearTimeout(toastTimerRef.current)
1906
+ toastTimerRef.current = setTimeout(() => setToast(''), 1600)
1907
+ }, [])
1908
+ useEffect(() => () => {
1909
+ clearTimeout(toastTimerRef.current)
1910
+ clearTimeout(insertTimerRef.current)
1746
1911
  }, [])
1747
1912
 
1748
1913
  const copy = useCallback(hit => {
@@ -1774,7 +1939,8 @@ self.onmessage = event => {
1774
1939
  if (!hit || hit.kind === 'session' || loading) return
1775
1940
  const mention = '@"' + hit.path.replace(/"/g, '') + '" '
1776
1941
  close()
1777
- setTimeout(() => {
1942
+ clearTimeout(insertTimerRef.current)
1943
+ insertTimerRef.current = setTimeout(() => {
1778
1944
  if (insertIntoComposer(mention)) notify(t('inserted'))
1779
1945
  else copyText(mention).then(() => notify(t('insertFailed'))).catch(() => {})
1780
1946
  }, 80)
@@ -1806,7 +1972,8 @@ self.onmessage = event => {
1806
1972
  && (event.target.tagName === 'BUTTON' || event.target.tagName === 'SELECT'
1807
1973
  || (event.target.tagName === 'INPUT' && event.target.type !== 'text'))
1808
1974
  if (interactive) return
1809
- const scopeDigit = event.altKey
1975
+ const altGraph = typeof event.getModifierState === 'function' && event.getModifierState('AltGraph')
1976
+ const scopeDigit = event.altKey && !event.ctrlKey && !event.metaKey && !altGraph
1810
1977
  ? (event.code === 'Digit1' ? '1' : event.code === 'Digit2' ? '2' : event.code === 'Digit3' ? '3' : ['1', '2', '3'].includes(event.key) ? event.key : '')
1811
1978
  : ''
1812
1979
  if (scopeDigit !== '') {
@@ -1908,6 +2075,7 @@ self.onmessage = event => {
1908
2075
  const groupHead = (key, label, count, note) => h('div', {
1909
2076
  className: 'dsh-locator-grouphead',
1910
2077
  id: 'dsh-locator-group-' + key,
2078
+ onMouseDown: event => event.preventDefault(),
1911
2079
  },
1912
2080
  h('span', null, label),
1913
2081
  h('span', { className: 'dsh-locator-count' }, String(count)),
@@ -1934,6 +2102,7 @@ self.onmessage = event => {
1934
2102
  className: 'dsh-locator-row' + (isContent ? ' dsh-locator-rowcontent' : '') + (index === selected ? ' sel' : ''),
1935
2103
  title: hit.path,
1936
2104
  onClick: () => setSelected(index),
2105
+ onMouseDown: event => event.preventDefault(),
1937
2106
  onDoubleClick: () => activate(hit),
1938
2107
  },
1939
2108
  h('div', { className: 'dsh-locator-title' },
@@ -1948,32 +2117,46 @@ self.onmessage = event => {
1948
2117
  )
1949
2118
  }
1950
2119
 
2120
+ const contentErrorText = code => {
2121
+ if (code === 'content-regex-not-supported') return t('panelRegexPaused')
2122
+ if (code === 'content-query-too-short') return t('contentTooShort')
2123
+ if (code === 'content-search-timeout') return t('contentTimeout')
2124
+ if (code === 'invalid-control-char') return t('contentBadChars')
2125
+ return code
2126
+ }
1951
2127
  let index = -1
1952
2128
  const renderGroup = (key, label, group, extra) => {
1953
2129
  if (!scopes[key] || group === undefined) return null
1954
2130
  const hits = Array.isArray(group.hits) ? group.hits : []
1955
2131
  const count = typeof group.returned === 'number' ? group.returned : hits.length
2132
+ const warnings = Array.isArray(group.warnings) && group.warnings.length > 0
2133
+ ? t('contentWarning', { list: group.warnings.join(', ') })
2134
+ : null
2135
+ const budget = key === 'content' && group.truncated === true ? t('contentBudget') : null
1956
2136
  const note = key === 'content'
1957
2137
  ? (matchMode === 'regex' ? t('panelRegexPaused')
1958
2138
  : contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
1959
2139
  : (content?.capped ? t('contentCapped', { n: count }) : (query.trim() !== '' ? t('relevance') : null)))
1960
2140
  : (group.deduped > 0 ? t('deduped', { n: group.deduped }) : (group.truncated ? t('showing', { n: hits.length }) : null))
2141
+ const combinedNote = [note, warnings, budget].filter(value => value !== null && value !== '').join(' · ')
1961
2142
  if (typeof group.error === 'string' && group.error !== '') {
1962
- const message = group.error === 'content-regex-not-supported' ? t('panelRegexPaused') : group.error
2143
+ const informational = ['content-regex-not-supported', 'content-query-too-short', 'invalid-control-char'].includes(group.error)
2144
+ const message = contentErrorText(group.error)
1963
2145
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1964
- groupHead(key, label, count, null),
1965
- h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + message,
1966
- h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
2146
+ groupHead(key, label, countLabel(group, count), combinedNote === '' ? null : combinedNote),
2147
+ h('div', { className: 'dsh-locator-groupempty', role: informational ? 'status' : 'alert' },
2148
+ informational ? message : t('errorPrefix') + message,
2149
+ informational ? null : h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
1967
2150
  )
1968
2151
  }
1969
2152
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1970
- groupHead(key, label, countLabel(group, count), note),
2153
+ groupHead(key, label, countLabel(group, count), combinedNote === '' ? null : combinedNote),
1971
2154
  extra === undefined ? null : extra,
1972
2155
  hits.length === 0
1973
2156
  ? h('div', { className: 'dsh-locator-groupempty' },
1974
2157
  key === 'content' && scopes.content
1975
- ? (sessionOnly && query.trim().length >= 2 ? t('panelSessionHint')
1976
- : query.trim().length < 2 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
2158
+ ? (sessionOnly && query.trim().length >= 3 ? t('panelSessionHint')
2159
+ : query.trim().length < 3 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
1977
2160
  : (query.trim() === '' ? t('emptyQuery') : t('emptyResults')))
1978
2161
  : hits.map(hit => { index++; return rowFor(key, hit, index) }),
1979
2162
  )
@@ -2058,7 +2241,9 @@ self.onmessage = event => {
2058
2241
  entries: (status.entries || 0).toLocaleString(),
2059
2242
  mode: status.watching ? t('live') : t('snapshot'),
2060
2243
  roots: (status.roots || []).length,
2061
- })),
2244
+ }) + (status.watchDegraded === true || status.refreshFallback === true
2245
+ ? ' · ' + t('watchDegraded')
2246
+ : '')),
2062
2247
  ),
2063
2248
  sessionOnly ? h('div', { className: 'dsh-locator-groupempty' }, t('panelSessionHint')) : null,
2064
2249
  h('div', { id: 'dsh-locator-results', className: 'dsh-locator-scroll', ref: listRef, role: 'listbox', 'aria-busy': loading ? 'true' : 'false' },
@@ -2077,7 +2262,12 @@ self.onmessage = event => {
2077
2262
  ],
2078
2263
  ),
2079
2264
  h('div', { className: 'dsh-locator-foot' },
2080
- h('span', null, (loading ? t('loading') + ' · ' : '') + t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: took.toFixed(1) })
2265
+ h('span', null, (loading ? t('loading') + ' · ' : '') + t('results', {
2266
+ content: contentCountText || '0',
2267
+ files: filesCountText || '0',
2268
+ dirs: dirsCountText || '0',
2269
+ ms: took.toFixed(1),
2270
+ })
2081
2271
  + (status && status.scanning ? ' · ' + t('indexing') : '')
2082
2272
  + (contentBuilding ? ' · ' + t('indexing') : '')),
2083
2273
  h('span', { className: 'grow' }),
@@ -2113,12 +2303,16 @@ self.onmessage = event => {
2113
2303
  }, 'dsh-locator: styles')
2114
2304
  ctx.effect(() => {
2115
2305
  const onKey = event => {
2116
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f') {
2117
- event.preventDefault()
2118
- event.stopPropagation()
2119
- closeFind()
2120
- store.toggle()
2121
- }
2306
+ if (event.defaultPrevented || event.isComposing || event.repeat) return
2307
+ if (!((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f')) return
2308
+ const target = event.target
2309
+ if (target instanceof Element && target.closest('.xterm,.monaco-editor,[data-code-editor]') !== null) return
2310
+ const foreignModal = document.querySelector('[role="dialog"][aria-modal="true"]:not(.dsh-locator-panel)')
2311
+ if (foreignModal !== null) return
2312
+ event.preventDefault()
2313
+ event.stopPropagation()
2314
+ closeFind()
2315
+ store.toggle()
2122
2316
  }
2123
2317
  document.addEventListener('keydown', onKey, true)
2124
2318
  return () => document.removeEventListener('keydown', onKey, true)
@@ -2162,7 +2356,11 @@ self.onmessage = event => {
2162
2356
  document.addEventListener('keydown', onFindKey, true)
2163
2357
  return () => document.removeEventListener('keydown', onFindKey, true)
2164
2358
  }, 'dsh-locator: find hotkeys')
2165
- ctx.effect(() => () => { closeFind(); cancelJump() }, 'dsh-locator: find cleanup')
2359
+ ctx.effect(() => () => {
2360
+ closeFind()
2361
+ cancelJump()
2362
+ store.set(false)
2363
+ }, 'dsh-locator: find cleanup')
2166
2364
  try {
2167
2365
  ctx.slots.inject('shell.overlay', () => {
2168
2366
  const disposers = [