dsh-locator 0.9.0 → 0.10.0

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 (57) hide show
  1. package/CHANGELOG.md +34 -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 +336 -145
  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 +270 -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/tools.d.ts +30 -0
  45. package/lib/tools.d.ts.map +1 -1
  46. package/lib/tools.js +65 -3
  47. package/lib/tools.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/combined.ts +13 -4
  50. package/src/content-search-worker.ts +47 -26
  51. package/src/content-search.ts +217 -66
  52. package/src/content.ts +287 -74
  53. package/src/export.ts +240 -50
  54. package/src/find.ts +82 -16
  55. package/src/http.ts +260 -81
  56. package/src/reveal.ts +148 -10
  57. package/src/tools.ts +83 -6
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,6 +226,7 @@ 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',
230
231
  },
231
232
  zh: {
@@ -236,7 +237,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
236
237
  asc: '升序', descOrder: '降序',
237
238
  groupContent: '内容', groupFiles: '文件', groupDirs: '目录',
238
239
  relevance: '按相关度', contentBuilding: '索引构建中… {files} 文件 / {messages} 消息',
239
- contentShort: '输入至少 2 个字符后搜索正文与会话',
240
+ contentShort: '输入至少 3 个字符后搜索正文与会话(更短的词请用 Ctrl+F)',
240
241
  contentDisabled: '内容索引已关闭(contentIndex: false)',
241
242
  contentCapped: '前 {n} 条', showing: '前 {n} 条', deduped: '{n} 条文件命中已并入内容', loading: '搜索中…',
242
243
  emptyQuery: '输入关键词:文件名、路径、正文或会话内容',
@@ -260,7 +261,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
260
261
  findScope: '仅在选区内', findScopeChip: '选区 {n} 字', findSessionChip: '本会话',
261
262
  findNoMatch: '没有匹配', findEmpty: '输入关键词后在此会话中查找',
262
263
  findInvalidRegex: '正则无效:{message}', findInvalidEscape: '扩展转义无效:{token}',
263
- findTimeout: '正则执行超时,请简化表达式', findWrappedTop: '已回到顶部',
264
+ findTimeout: '正则执行超时,请简化表达式', findRegexUnavailable: '此处无法运行正则(worker 被阻止)', findWrappedTop: '已回到顶部',
264
265
  findWrappedBottom: '已到底部,回到开头', findNoWrapEnd: '已到最后一个匹配(循环查找已关闭)',
265
266
  findNoWrapStart: '已到第一个匹配(循环查找已关闭)', findOlder: '加载更早消息后重试',
266
267
  findLoadingOlder: '正在加载更早消息…', findLoadedOnly: '仅已加载窗口 · 更早历史未加载',
@@ -270,6 +271,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
270
271
  panelTotal: '已选 {index}/{total}', panelRangeAll: '全部索引', panelRangeSession: '当前会话',
271
272
  panelSessionHint: '此范围只查已索引的会话消息;实时查找请按 Ctrl+F',
272
273
  panelRegexPaused: '正则模式下内容范围暂停', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
274
+ contentWarning: '索引警告:{list}', contentBudget: '内容索引已达预算上限,部分文件未被收录', contentTooShort: '内容搜索至少需要 3 个字符(更短的词请用 Ctrl+F 在会话内查找)', contentTimeout: '内容搜索超时,请缩小关键词或范围', contentBadChars: '内容搜索不支持 NUL/控制字符',
273
275
  panelCaseShort: 'Aa', panelWholeShort: '全词', panelModeLabel: '模式',
274
276
  },
275
277
  es: {
@@ -280,7 +282,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
280
282
  asc: 'Ascendente', descOrder: 'Descendente',
281
283
  groupContent: 'Contenido', groupFiles: 'Archivos', groupDirs: 'Carpetas',
282
284
  relevance: 'por relevancia', contentBuilding: 'indexando… {files} archivos / {messages} mensajes',
283
- contentShort: 'Escribe al menos 2 caracteres para buscar contenido y sesiones',
285
+ contentShort: 'Escribe al menos 3 caracteres para buscar contenido y sesiones (Ctrl+F para consultas cortas)',
284
286
  contentDisabled: 'Índice de contenido desactivado (contentIndex: false)',
285
287
  contentCapped: 'primeros {n}', showing: 'primeros {n}', deduped: '{n} aciertos de nombre fusionados', loading: 'Buscando…',
286
288
  emptyQuery: 'Escribe para buscar nombres, rutas, contenido y sesiones',
@@ -304,7 +306,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
304
306
  findScope: 'En la selección', findScopeChip: 'selección {n} caracteres', findSessionChip: 'esta conversación',
305
307
  findNoMatch: 'Sin coincidencias', findEmpty: 'Escribe para buscar en esta conversación',
306
308
  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',
309
+ findTimeout: 'La regex agotó el tiempo; simplifícala', findRegexUnavailable: 'La búsqueda regex no está disponible aquí (worker bloqueado)', findWrappedTop: 'Vuelto al inicio',
308
310
  findWrappedBottom: 'Vuelto al final', findNoWrapEnd: 'Última coincidencia (continuación desactivada)',
309
311
  findNoWrapStart: 'Primera coincidencia (continuación desactivada)', findOlder: 'Cargar mensajes anteriores y reintentar',
310
312
  findLoadingOlder: 'Cargando mensajes anteriores…', findLoadedOnly: 'solo ventana cargada · historial anterior sin cargar',
@@ -314,6 +316,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
314
316
  panelTotal: 'seleccionado {index}/{total}', panelRangeAll: 'Todo el índice', panelRangeSession: 'Esta conversación',
315
317
  panelSessionHint: 'Este ámbito solo busca mensajes indexados; pulsa Ctrl+F para buscar en vivo',
316
318
  panelRegexPaused: 'Ámbito de contenido en pausa en modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
319
+ 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
320
  panelCaseShort: 'Aa', panelWholeShort: 'Palabra', panelModeLabel: 'Modo',
318
321
  },
319
322
  pt: {
@@ -324,7 +327,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
324
327
  asc: 'Ascendente', descOrder: 'Descendente',
325
328
  groupContent: 'Conteúdo', groupFiles: 'Arquivos', groupDirs: 'Pastas',
326
329
  relevance: 'por relevância', contentBuilding: 'indexando… {files} arquivos / {messages} mensagens',
327
- contentShort: 'Digite pelo menos 2 caracteres para buscar conteúdo e sessões',
330
+ contentShort: 'Digite pelo menos 3 caracteres para buscar conteúdo e sessões (Ctrl+F para consultas curtas)',
328
331
  contentDisabled: 'Índice de conteúdo desativado (contentIndex: false)',
329
332
  contentCapped: 'primeiros {n}', showing: 'primeiros {n}', deduped: '{n} acertos de nome mesclados', loading: 'Buscando…',
330
333
  emptyQuery: 'Digite para buscar nomes, caminhos, conteúdo e sessões',
@@ -348,7 +351,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
348
351
  findScope: 'Na seleção', findScopeChip: 'seleção {n} caracteres', findSessionChip: 'esta conversa',
349
352
  findNoMatch: 'Sem resultados', findEmpty: 'Digite para buscar nesta conversa',
350
353
  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',
354
+ 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
355
  findWrappedBottom: 'Voltou ao fim', findNoWrapEnd: 'Última ocorrência (continuação desativada)',
353
356
  findNoWrapStart: 'Primeira ocorrência (continuação desativada)', findOlder: 'Carregar mensagens anteriores e tentar novamente',
354
357
  findLoadingOlder: 'Carregando mensagens anteriores…', findLoadedOnly: 'apenas janela carregada · histórico anterior não carregado',
@@ -358,6 +361,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
358
361
  panelTotal: 'selecionado {index}/{total}', panelRangeAll: 'Todo o índice', panelRangeSession: 'Esta conversa',
359
362
  panelSessionHint: 'Este escopo busca apenas mensagens indexadas; use Ctrl+F para buscar ao vivo',
360
363
  panelRegexPaused: 'Escopo de conteúdo pausado no modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
364
+ 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
365
  panelCaseShort: 'Aa', panelWholeShort: 'Palavra', panelModeLabel: 'Modo',
362
366
  },
363
367
  hi: {
@@ -368,7 +372,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
368
372
  asc: 'आरोही', descOrder: 'अवरोही',
369
373
  groupContent: 'सामग्री', groupFiles: 'फ़ाइलें', groupDirs: 'फ़ोल्डर',
370
374
  relevance: 'प्रासंगिकता से', contentBuilding: 'इंडेक्स बन रहा है… {files} फ़ाइलें / {messages} संदेश',
371
- contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 2 अक्षर लिखें',
375
+ contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 3 अक्षर लिखें (छोटी खोज के लिए Ctrl+F)',
372
376
  contentDisabled: 'सामग्री इंडेक्स बंद है (contentIndex: false)',
373
377
  contentCapped: 'पहले {n}', showing: 'पहले {n}', deduped: '{n} नाम-परिणाम सामग्री में मिलाए', loading: 'खोज रहे हैं…',
374
378
  emptyQuery: 'नाम, पथ, सामग्री और सत्र खोजने के लिए लिखें',
@@ -392,7 +396,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
392
396
  findScope: 'चयन में', findScopeChip: 'चयन {n} अक्षर', findSessionChip: 'यह बातचीत',
393
397
  findNoMatch: 'कोई मेल नहीं', findEmpty: 'इस बातचीत में खोजने के लिए लिखें',
394
398
  findInvalidRegex: 'अमान्य रेगुलर एक्सप्रेशन: {message}', findInvalidEscape: 'अमान्य विस्तृत एस्केप: {token}',
395
- findTimeout: 'रेगेक्स का समय समाप्त; सरल बनाएँ', findWrappedTop: 'शीर्ष पर लौटा',
399
+ findTimeout: 'रेगेक्स का समय समाप्त; सरल बनाएँ', findRegexUnavailable: 'यहाँ रेगेक्स उपलब्ध नहीं है (वर्कर अवरुद्ध)', findWrappedTop: 'शीर्ष पर लौटा',
396
400
  findWrappedBottom: 'अंत पर लौटा', findNoWrapEnd: 'अंतिम मेल (चक्र बंद)',
397
401
  findNoWrapStart: 'पहला मेल (चक्र बंद)', findOlder: 'पुराने संदेश लोड कर पुन: प्रयास',
398
402
  findLoadingOlder: 'पुराने संदेश लोड हो रहे हैं…', findLoadedOnly: 'केवल लोडेड विंडो · पुराना इतिहास लोड नहीं',
@@ -402,6 +406,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
402
406
  panelTotal: 'चयनित {index}/{total}', panelRangeAll: 'पूरा इंडेक्स', panelRangeSession: 'यह बातचीत',
403
407
  panelSessionHint: 'यह क्षेत्र केवल इंडेक्स किए संदेश खोजता है; लाइव खोज के लिए Ctrl+F दबाएँ',
404
408
  panelRegexPaused: 'रेगेक्स मोड में सामग्री क्षेत्र रुका है', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
409
+ contentWarning: 'इंडेक्स चेतावनी: {list}', contentBudget: 'सामग्री इंडेक्स बजट पूरा; कुछ फ़ाइलें इंडेक्स नहीं हैं', contentTooShort: 'सामग्री के लिए कम से कम 3 अक्षर चाहिए (छोटी खोज के लिए Ctrl+F)', contentTimeout: 'सामग्री खोज का समय समाप्त; क्वेरी या क्षेत्र घटाएँ', contentBadChars: 'सामग्री खोज NUL/नियंत्रण अक्षर नहीं लेती',
405
410
  panelCaseShort: 'Aa', panelWholeShort: 'शब्द', panelModeLabel: 'मोड',
406
411
  },
407
412
  }
@@ -540,7 +545,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
540
545
  const HL_BASE = 'dsh-locator-find'
541
546
  const HL_ACTIVE = 'dsh-locator-find-active'
542
547
  const GESTURES = ['wheel', 'touchstart', 'pointerdown', 'keydown']
543
- const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style'
548
+ const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style,'
549
+ + '.sr-only,[class*="visuallyHidden"],[role="status"],[aria-live]'
544
550
  const BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
545
551
  const legacyTargets = new Set()
546
552
 
@@ -611,21 +617,28 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
611
617
  return scope === null ? null : scope.querySelector('[data-chat-flow]')
612
618
  }
613
619
 
614
- function findMessageRow(flow, messageId, nodeKey) {
615
- if (flow === null) return null
620
+ function findMessageRows(flow, messageId, nodeKey) {
621
+ if (flow === null) return []
616
622
  const rows = flow.querySelectorAll('[data-chat-node-key]')
623
+ const exact = []
617
624
  if (nodeKey) {
618
625
  for (const row of rows) {
619
- if (row.getAttribute('data-chat-node-key') === String(nodeKey)) return row
626
+ if (row.getAttribute('data-chat-node-key') === String(nodeKey)) exact.push(row)
620
627
  }
621
628
  }
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
629
+ if (exact.length === 0 && messageId) {
630
+ const id = String(messageId)
631
+ for (const row of rows) {
632
+ const key = row.getAttribute('data-chat-node-key') || ''
633
+ if (key === id || key.endsWith(id)) exact.push(row)
634
+ }
627
635
  }
628
- return null
636
+ // A single assistant step can be rendered as a reasoning row and a
637
+ // response row under the same node key; prefer the response row.
638
+ return exact.sort((left, right) => {
639
+ const score = row => row.getAttribute('data-chat-group-part') === 'response' ? 0 : 1
640
+ return score(left) - score(right)
641
+ })
629
642
  }
630
643
 
631
644
  function needlesFromQuery(query) {
@@ -635,57 +648,173 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
635
648
  return [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 8)
636
649
  }
637
650
 
638
- function inlineGroups(root) {
639
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
651
+ function projectGroups(root, excluded) {
652
+ if (root === null) return []
640
653
  const groups = new Map()
654
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
655
+ acceptNode(node) {
656
+ if (node.nodeType === Node.ELEMENT_NODE) {
657
+ if (node.matches(excluded)) return NodeFilter.FILTER_REJECT
658
+ if (node.tagName === 'BR') return NodeFilter.FILTER_ACCEPT
659
+ return NodeFilter.FILTER_SKIP
660
+ }
661
+ return NodeFilter.FILTER_ACCEPT
662
+ },
663
+ })
664
+ const groupFor = block => {
665
+ let group = groups.get(block)
666
+ if (group === undefined) {
667
+ group = {
668
+ block, raw: '', rawLength: 0, text: '', runs: [], nodes: [], breaks: [],
669
+ collapse: !(block instanceof Element && block.tagName === 'PRE'),
670
+ }
671
+ groups.set(block, group)
672
+ }
673
+ return group
674
+ }
675
+ const appendText = (group, node) => {
676
+ const value = node.nodeValue || ''
677
+ if (value === '') return
678
+ group.nodes.push({ node, start: group.raw.length, end: group.raw.length + value.length })
679
+ group.raw += value
680
+ }
681
+ const appendBreak = group => {
682
+ group.breaks.push(group.raw.length)
683
+ group.raw += '\u0000'
684
+ }
641
685
  let node = walker.nextNode()
642
686
  while (node !== null) {
643
687
  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
688
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
689
+ if (parent !== null && parent.closest(excluded) === null) {
690
+ const block = parent.closest(BLOCK_SELECTOR)
691
+ || parent.closest('[data-text-interaction-source]')
692
+ || parent.closest('[data-chat-node-key]')
693
+ || root
694
+ appendBreak(groupFor(block))
652
695
  }
696
+ } else if (parent !== null && parent.closest(excluded) === null) {
697
+ const block = parent.closest(BLOCK_SELECTOR)
698
+ || parent.closest('[data-text-interaction-source]')
699
+ || parent.closest('[data-chat-node-key]')
700
+ || root
701
+ appendText(groupFor(block), node)
653
702
  }
654
703
  node = walker.nextNode()
655
704
  }
705
+ // Project the concatenated raw text once per block. Soft newlines and
706
+ // repeated whitespace become one space outside <pre>; <br> becomes a
707
+ // separator. rums maps projected offsets back to raw offsets without
708
+ // allocating one object per character (a multi-MB transcript would
709
+ // otherwise create millions of position objects).
710
+ for (const group of groups.values()) {
711
+ const raw = group.raw
712
+ group.rawLength = raw.length
713
+ if (!group.collapse) {
714
+ group.text = raw.replace(/\u0000/g, '\n')
715
+ group.runs = []
716
+ continue
717
+ }
718
+ let text = ''
719
+ const runs = []
720
+ let index = 0
721
+ while (index < raw.length) {
722
+ const char = raw[index]
723
+ if (char === '\u0000' || /\s/u.test(char)) {
724
+ const start = index
725
+ while (index < raw.length && (raw[index] === '\u0000' || /\s/u.test(raw[index]))) index++
726
+ runs.push({ rawStart: start, rawLength: index - start, projectedStart: text.length })
727
+ text += ' '
728
+ } else {
729
+ text += char
730
+ index++
731
+ }
732
+ }
733
+ group.text = text
734
+ group.runs = runs
735
+ }
656
736
  return [...groups.values()]
657
737
  }
658
738
 
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
739
+ function inlineGroups(root) {
740
+ return projectGroups(root, EXCLUDED)
741
+ }
742
+
743
+ function projectedToRaw(group, index, isEnd) {
744
+ if (!group.collapse) return Math.max(0, Math.min(group.rawLength, index))
745
+ let extra = 0
746
+ for (const run of group.runs) {
747
+ if (run.projectedStart < index || (isEnd && run.projectedStart === index)) extra += run.rawLength - 1
748
+ else break
749
+ }
750
+ return Math.max(0, Math.min(group.rawLength, index + extra))
751
+ }
752
+
753
+ function rawToProjected(group, raw) {
754
+ if (!group.collapse) return Math.max(0, Math.min(group.rawLength, raw))
755
+ let extra = 0
756
+ for (const run of group.runs) {
757
+ const end = run.rawStart + run.rawLength
758
+ if (raw >= end) extra += run.rawLength - 1
759
+ else if (raw > run.rawStart) return run.projectedStart
760
+ else break
761
+ }
762
+ return Math.max(0, Math.min(group.rawLength, raw - extra))
763
+ }
764
+
765
+ function pointForRaw(group, raw, isEnd) {
766
+ for (const entry of group.nodes) {
767
+ if (raw < entry.start) continue
768
+ if (raw <= entry.end) return { node: entry.node, offset: Math.min(raw - entry.start, entry.end - entry.start) }
769
+ }
770
+ for (const br of group.breaks) {
771
+ if (raw !== br) continue
772
+ if (isEnd) {
773
+ for (let i = group.nodes.length - 1; i >= 0; i--) {
774
+ const entry = group.nodes[i]
775
+ if (entry.end <= br) return { node: entry.node, offset: entry.end - entry.start }
776
+ }
777
+ } else {
778
+ for (const entry of group.nodes) {
779
+ if (entry.start > br) return { node: entry.node, offset: 0 }
780
+ }
781
+ }
665
782
  }
666
783
  const last = group.nodes[group.nodes.length - 1]
667
- return [last, (last.nodeValue || '').length]
784
+ return last === undefined ? null : { node: last.node, offset: last.end - last.start }
785
+ }
786
+
787
+ function positionAt(group, index, isEnd) {
788
+ const raw = projectedToRaw(group, index, isEnd)
789
+ return pointForRaw(group, raw, isEnd)
790
+ }
791
+
792
+ function rangeAt(group, start, end) {
793
+ const from = positionAt(group, start, false)
794
+ const to = positionAt(group, end, true)
795
+ if (from === null || to === null) return null
796
+ const range = document.createRange()
797
+ try {
798
+ range.setStart(from.node, from.offset)
799
+ range.setEnd(to.node, to.offset)
800
+ return range
801
+ } catch {
802
+ return null
803
+ }
668
804
  }
669
805
 
670
806
  function rangesForNeedles(root, needles) {
671
807
  const ranges = []
672
808
  if (root === null || needles.length === 0) return ranges
809
+ const patterns = needles.map(escapeRegExp)
810
+ const expression = new RegExp(patterns.join('|'), 'giu')
673
811
  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
- }
812
+ expression.lastIndex = 0
813
+ let match
814
+ while (ranges.length < 200 && (match = expression.exec(group.text)) !== null) {
815
+ if (match[0].length === 0) { expression.lastIndex++; continue }
816
+ const range = rangeAt(group, match.index, match.index + match[0].length)
817
+ if (range !== null) ranges.push(range)
689
818
  }
690
819
  }
691
820
  return ranges
@@ -718,16 +847,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
718
847
  function phraseRange(root, snippet) {
719
848
  if (root === null) return null
720
849
  for (const phrase of phraseCandidates(snippet)) {
721
- const needle = phrase.toLowerCase()
850
+ const expression = new RegExp(escapeRegExp(phrase), 'iu')
722
851
  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
852
+ const match = expression.exec(group.text)
853
+ if (match === null) continue
854
+ const range = rangeAt(group, match.index, match.index + match[0].length)
855
+ if (range !== null) return range
731
856
  }
732
857
  }
733
858
  return null
@@ -851,15 +976,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
851
976
  if (flow === null) { showNotice(t('jumpFailed')); return false }
852
977
  const needles = needlesFromQuery(query)
853
978
  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] }
979
+ const rows = findMessageRows(flow, hit.messageId, hit.nodeKey)
980
+ if (rows.length > 0) {
981
+ for (const row of rows) {
982
+ expandHidden(row)
983
+ const ranges = rangesForNeedles(row, needles)
984
+ if (ranges.length > 0) return { row, ranges }
985
+ const phrase = phraseRange(row, hit.snippet)
986
+ if (phrase !== null) {
987
+ // Keep the phrase range inside the anchored row instead of
988
+ // falling through to a wrong copy elsewhere in the transcript.
989
+ const inside = row.contains(phrase.commonAncestorContainer)
990
+ if (inside) return { row, ranges: [phrase] }
991
+ }
992
+ }
861
993
  return null
862
994
  }
995
+ // With an exact node key there is nothing safe to fall back to in the
996
+ // already loaded window; wait for older pages instead of landing on a
997
+ // similar phrase in a different message.
998
+ if (hit.nodeKey || hit.messageId) return null
863
999
  const phrase = phraseRange(flow, hit.snippet)
864
1000
  if (phrase === null) return null
865
1001
  expandHidden(phrase.startContainer)
@@ -872,7 +1008,6 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
872
1008
  let target = await waitFor(flow, finder, 6_000, 120)
873
1009
  for (let attempt = 0; target === null && attempt < 10; attempt++) {
874
1010
  if (stale()) return false
875
- for (const hidden of flow.querySelectorAll('[hidden="until-found"]')) hidden.dispatchEvent(new Event('beforematch'))
876
1011
  if (!revealOlder(scope)) break
877
1012
  target = await waitFor(flow, finder, 1_500, 150)
878
1013
  }
@@ -961,7 +1096,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
961
1096
  const FIND_BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
962
1097
  const FIND_EXCLUDED = '[hidden]:not([hidden="until-found"]),[data-chat-history],[data-text-interaction-surface],'
963
1098
  + '[data-composer-seat],[data-text-interaction-exclude],script,style,button,input,textarea,select,'
964
- + '.sr-only,.katex-mathml,[aria-hidden="true"]:not(.katex-html)'
1099
+ + '.sr-only,[class*="visuallyHidden"],[role="status"],[aria-live],.katex-mathml,[aria-hidden="true"]:not(.katex-html)'
965
1100
 
966
1101
  function createFindStore() {
967
1102
  const listeners = new Set()
@@ -1120,13 +1255,17 @@ self.onmessage = event => {
1120
1255
  }
1121
1256
  if (mode !== 'regex') source = escapeRegExp(source)
1122
1257
  const flags = 'g' + (caseSensitive ? '' : 'i')
1258
+ // Prefer the Unicode flag so \p{...}, astral literals and modern syntax
1259
+ // behave as users expect. Fall back once for legacy patterns that the
1260
+ // unicode grammar rejects (for example some lone escapes).
1123
1261
  try {
1124
- return { source, flags: /[\u{10000}-\u{10FFFF}]/u.test(source) ? flags + 'u' : flags, re: new RegExp(source, flags) }
1262
+ return { source, flags: flags + 'u', re: new RegExp(source, flags + 'u') }
1125
1263
  } catch (error) {
1126
- if (mode === 'regex') {
1127
- try { return { source, flags: flags + 'u', re: new RegExp(source, flags + 'u') } } catch { /* fall through */ }
1264
+ try {
1265
+ return { source, flags, re: new RegExp(source, flags) }
1266
+ } catch (fallbackError) {
1267
+ return { error: t('findInvalidRegex', { message: String(fallbackError && fallbackError.message || fallbackError).slice(0, 140) }) }
1128
1268
  }
1129
- return { error: t('findInvalidRegex', { message: String(error && error.message || error).slice(0, 140) }) }
1130
1269
  }
1131
1270
  }
1132
1271
 
@@ -1139,67 +1278,38 @@ self.onmessage = event => {
1139
1278
  }
1140
1279
 
1141
1280
  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()]
1281
+ return projectGroups(root, FIND_EXCLUDED)
1165
1282
  }
1166
1283
 
1167
1284
  function clipForGroup(group, range) {
1168
1285
  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
1286
+ let rawStart = null
1287
+ let rawEnd = null
1288
+ for (const entry of group.nodes) {
1289
+ const node = entry.node
1290
+ const length = entry.end - entry.start
1291
+ try {
1292
+ if (!range.intersectsNode(node)) continue
1293
+ } catch { continue }
1174
1294
  let nodeStart = 0
1175
1295
  let nodeEnd = length
1176
- try {
1177
- if (!range.intersectsNode(node)) { position += length; continue }
1178
- } catch { position += length; continue }
1179
1296
  if (node === range.startContainer) nodeStart = range.startOffset
1180
1297
  if (node === range.endContainer) nodeEnd = range.endOffset
1181
1298
  if (nodeStart < nodeEnd) {
1182
- if (start === null) start = position + nodeStart
1183
- end = position + nodeEnd
1299
+ if (rawStart === null) rawStart = entry.start + nodeStart
1300
+ rawEnd = entry.start + nodeEnd
1184
1301
  }
1185
- position += length
1186
1302
  }
1187
- if (start === null || end === null || start >= end) return null
1303
+ if (rawStart === null || rawEnd === null) return null
1304
+ const start = rawToProjected(group, rawStart)
1305
+ const end = rawToProjected(group, rawEnd)
1306
+ if (start >= end) return null
1188
1307
  return [start, end]
1189
1308
  }
1190
1309
 
1191
1310
  function rangeForGroupOffsets(group, start, end) {
1192
1311
  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
- }
1312
+ return rangeAt(group, start, end)
1203
1313
  }
1204
1314
 
1205
1315
  function findHistoryButton(scope) {
@@ -1227,6 +1337,8 @@ self.onmessage = event => {
1227
1337
  const debounceRef = useRef(0)
1228
1338
  const observerRef = useRef(null)
1229
1339
  const revealTimersRef = useRef([])
1340
+ const revealFrameRef = useRef(0)
1341
+ const loadOlderTimerRef = useRef(0)
1230
1342
  const returnFocusRef = useRef(null)
1231
1343
  const [anchor, setAnchor] = useState(null)
1232
1344
  const composingRef = useRef(false)
@@ -1248,7 +1360,9 @@ self.onmessage = event => {
1248
1360
  if (!match.range.startContainer.isConnected) return
1249
1361
  centerRange(match.range)
1250
1362
  }
1251
- requestAnimationFrame(run)
1363
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(revealFrameRef.current)
1364
+ revealFrameRef.current = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : 0
1365
+ if (revealFrameRef.current === 0) run()
1252
1366
  for (const delay of [80, 240]) {
1253
1367
  const timer = setTimeout(run, delay)
1254
1368
  revealTimersRef.current.push(timer)
@@ -1302,7 +1416,11 @@ self.onmessage = event => {
1302
1416
  let selectionRange = null
1303
1417
  if (state.scope === 'selection' && state.selection) {
1304
1418
  selectionRange = state.selection.range
1305
- if (!selectionRange.startContainer.isConnected) {
1419
+ let selectionDead = !selectionRange.startContainer.isConnected || selectionRange.collapsed
1420
+ if (!selectionDead) {
1421
+ try { selectionDead = selectionRange.toString().trim() === '' } catch { selectionDead = true }
1422
+ }
1423
+ if (selectionDead) {
1306
1424
  findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1307
1425
  return
1308
1426
  }
@@ -1322,12 +1440,20 @@ self.onmessage = event => {
1322
1440
  texts.push(text)
1323
1441
  clips.push(clip === null ? [0, group.text.length] : clip)
1324
1442
  }
1443
+ if (selectionRange !== null && clips.every(clip => clip === null)) {
1444
+ findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1445
+ return
1446
+ }
1325
1447
  let rawMatches = null
1326
1448
  let capped = false
1327
1449
  if (state.mode === 'regex') {
1328
1450
  try {
1329
1451
  const result = await scanRegexInWorker(texts.filter(text => text !== null), compiled.source, compiled.flags)
1330
1452
  if (token !== sequenceRef.current) return
1453
+ if (result === null) {
1454
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findRegexUnavailable') })
1455
+ return
1456
+ }
1331
1457
  if (result !== null) {
1332
1458
  if (result.timeout === true) {
1333
1459
  matchesRef.current = []
@@ -1438,10 +1564,11 @@ self.onmessage = event => {
1438
1564
  debounceRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 400)
1439
1565
  }
1440
1566
  const scope = scopeRef.current
1441
- if (scope !== null) {
1567
+ const observed = scope === null ? null : (chatFlow(scope) || scope)
1568
+ if (observed !== null) {
1442
1569
  try {
1443
1570
  observer = new MutationObserver(schedule)
1444
- observer.observe(scope, { childList: true, subtree: true, characterData: true })
1571
+ observer.observe(observed, { childList: true, subtree: true, characterData: true })
1445
1572
  } catch { /* observer unavailable */ }
1446
1573
  }
1447
1574
  return () => {
@@ -1451,6 +1578,8 @@ self.onmessage = event => {
1451
1578
  if (observer !== null) observer.disconnect()
1452
1579
  for (const id of revealTimersRef.current) clearTimeout(id)
1453
1580
  revealTimersRef.current = []
1581
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(revealFrameRef.current)
1582
+ clearTimeout(loadOlderTimerRef.current)
1454
1583
  sequenceRef.current++
1455
1584
  clearFindHighlight('find')
1456
1585
  setFindScope(null)
@@ -1495,7 +1624,10 @@ self.onmessage = event => {
1495
1624
  if (text !== '') { event.preventDefault(); copyText(text).then(() => findStore.set({ hint: t('findCopied') })).catch(() => {}) }
1496
1625
  }
1497
1626
  }
1498
- const pickMode = event => { findStore.set({ mode: event.target.value }) }
1627
+ const pickMode = event => {
1628
+ findStore.set({ mode: event.target.value })
1629
+ if (inputRef.current !== null) inputRef.current.focus()
1630
+ }
1499
1631
  const toggle = key => { findStore.set({ [key]: !findStore.get()[key] }) }
1500
1632
  const toggleScope = () => {
1501
1633
  if (state.scope === 'selection') findStore.set({ scope: 'session', selection: null, hint: '' })
@@ -1513,7 +1645,8 @@ self.onmessage = event => {
1513
1645
  const loadOlder = () => {
1514
1646
  if (scopeRef.current === null || !revealOlder(scopeRef.current)) return
1515
1647
  findStore.set({ hint: t('findLoadingOlder') })
1516
- setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 700)
1648
+ clearTimeout(loadOlderTimerRef.current)
1649
+ loadOlderTimerRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 700)
1517
1650
  }
1518
1651
  return h('div', {
1519
1652
  ref: barRef,
@@ -1551,7 +1684,10 @@ self.onmessage = event => {
1551
1684
  h('button', { className: 'dsh-locator-findbar-btn', title: t('findClose'), 'aria-label': t('findClose'),
1552
1685
  onClick: () => closeFind() }, '×'),
1553
1686
  ),
1554
- h('div', { className: 'dsh-locator-findbar-opts' },
1687
+ h('div', { className: 'dsh-locator-findbar-opts', onMouseDown: event => {
1688
+ if (event.target instanceof HTMLSelectElement) return
1689
+ event.preventDefault()
1690
+ } },
1555
1691
  h('label', { className: 'dsh-locator-findbar-label' }, t('findMode'),
1556
1692
  h('select', { value: state.mode, onChange: pickMode, 'aria-label': t('findMode') },
1557
1693
  h('option', { value: 'normal' }, t('modeNormal')),
@@ -1613,6 +1749,8 @@ self.onmessage = event => {
1613
1749
  const panelRef = useRef(null)
1614
1750
  const abortRef = useRef(null)
1615
1751
  const listRef = useRef(null)
1752
+ const toastTimerRef = useRef(0)
1753
+ const insertTimerRef = useRef(0)
1616
1754
 
1617
1755
  const close = useCallback(() => store.set(false), [])
1618
1756
  const activeScope = conversationScope('')
@@ -1628,7 +1766,9 @@ self.onmessage = event => {
1628
1766
 
1629
1767
  useEffect(() => {
1630
1768
  if (!open) return undefined
1769
+ if (sessionOnly && activeSessionId === '') setSessionOnly(false)
1631
1770
  closeFind()
1771
+ setGroups(null)
1632
1772
  const previous = document.activeElement
1633
1773
  setError('')
1634
1774
  const timer = setTimeout(() => {
@@ -1639,9 +1779,24 @@ self.onmessage = event => {
1639
1779
  }, 30)
1640
1780
  return () => {
1641
1781
  clearTimeout(timer)
1782
+ if (abortRef.current !== null) { abortRef.current.abort(); abortRef.current = null }
1783
+ setGroups(null)
1642
1784
  if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
1643
1785
  }
1644
- }, [open])
1786
+ }, [open, sessionOnly, activeSessionId])
1787
+
1788
+ useEffect(() => {
1789
+ if (!open) return undefined
1790
+ const onDocumentKey = event => {
1791
+ if (event.key !== 'Escape' || event.defaultPrevented) return
1792
+ const target = event.target
1793
+ if (target instanceof Element && target.closest('.dsh-locator-panel') !== null) return
1794
+ event.preventDefault()
1795
+ close()
1796
+ }
1797
+ document.addEventListener('keydown', onDocumentKey, true)
1798
+ return () => document.removeEventListener('keydown', onDocumentKey, true)
1799
+ }, [open, close])
1645
1800
 
1646
1801
  useEffect(() => {
1647
1802
  if (!open) return undefined
@@ -1742,7 +1897,12 @@ self.onmessage = event => {
1742
1897
 
1743
1898
  const notify = useCallback(message => {
1744
1899
  setToast(message)
1745
- setTimeout(() => setToast(''), 1600)
1900
+ clearTimeout(toastTimerRef.current)
1901
+ toastTimerRef.current = setTimeout(() => setToast(''), 1600)
1902
+ }, [])
1903
+ useEffect(() => () => {
1904
+ clearTimeout(toastTimerRef.current)
1905
+ clearTimeout(insertTimerRef.current)
1746
1906
  }, [])
1747
1907
 
1748
1908
  const copy = useCallback(hit => {
@@ -1774,7 +1934,8 @@ self.onmessage = event => {
1774
1934
  if (!hit || hit.kind === 'session' || loading) return
1775
1935
  const mention = '@"' + hit.path.replace(/"/g, '') + '" '
1776
1936
  close()
1777
- setTimeout(() => {
1937
+ clearTimeout(insertTimerRef.current)
1938
+ insertTimerRef.current = setTimeout(() => {
1778
1939
  if (insertIntoComposer(mention)) notify(t('inserted'))
1779
1940
  else copyText(mention).then(() => notify(t('insertFailed'))).catch(() => {})
1780
1941
  }, 80)
@@ -1806,7 +1967,8 @@ self.onmessage = event => {
1806
1967
  && (event.target.tagName === 'BUTTON' || event.target.tagName === 'SELECT'
1807
1968
  || (event.target.tagName === 'INPUT' && event.target.type !== 'text'))
1808
1969
  if (interactive) return
1809
- const scopeDigit = event.altKey
1970
+ const altGraph = typeof event.getModifierState === 'function' && event.getModifierState('AltGraph')
1971
+ const scopeDigit = event.altKey && !event.ctrlKey && !event.metaKey && !altGraph
1810
1972
  ? (event.code === 'Digit1' ? '1' : event.code === 'Digit2' ? '2' : event.code === 'Digit3' ? '3' : ['1', '2', '3'].includes(event.key) ? event.key : '')
1811
1973
  : ''
1812
1974
  if (scopeDigit !== '') {
@@ -1908,6 +2070,7 @@ self.onmessage = event => {
1908
2070
  const groupHead = (key, label, count, note) => h('div', {
1909
2071
  className: 'dsh-locator-grouphead',
1910
2072
  id: 'dsh-locator-group-' + key,
2073
+ onMouseDown: event => event.preventDefault(),
1911
2074
  },
1912
2075
  h('span', null, label),
1913
2076
  h('span', { className: 'dsh-locator-count' }, String(count)),
@@ -1934,6 +2097,7 @@ self.onmessage = event => {
1934
2097
  className: 'dsh-locator-row' + (isContent ? ' dsh-locator-rowcontent' : '') + (index === selected ? ' sel' : ''),
1935
2098
  title: hit.path,
1936
2099
  onClick: () => setSelected(index),
2100
+ onMouseDown: event => event.preventDefault(),
1937
2101
  onDoubleClick: () => activate(hit),
1938
2102
  },
1939
2103
  h('div', { className: 'dsh-locator-title' },
@@ -1948,32 +2112,46 @@ self.onmessage = event => {
1948
2112
  )
1949
2113
  }
1950
2114
 
2115
+ const contentErrorText = code => {
2116
+ if (code === 'content-regex-not-supported') return t('panelRegexPaused')
2117
+ if (code === 'content-query-too-short') return t('contentTooShort')
2118
+ if (code === 'content-search-timeout') return t('contentTimeout')
2119
+ if (code === 'invalid-control-char') return t('contentBadChars')
2120
+ return code
2121
+ }
1951
2122
  let index = -1
1952
2123
  const renderGroup = (key, label, group, extra) => {
1953
2124
  if (!scopes[key] || group === undefined) return null
1954
2125
  const hits = Array.isArray(group.hits) ? group.hits : []
1955
2126
  const count = typeof group.returned === 'number' ? group.returned : hits.length
2127
+ const warnings = Array.isArray(group.warnings) && group.warnings.length > 0
2128
+ ? t('contentWarning', { list: group.warnings.join(', ') })
2129
+ : null
2130
+ const budget = key === 'content' && group.truncated === true ? t('contentBudget') : null
1956
2131
  const note = key === 'content'
1957
2132
  ? (matchMode === 'regex' ? t('panelRegexPaused')
1958
2133
  : contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
1959
2134
  : (content?.capped ? t('contentCapped', { n: count }) : (query.trim() !== '' ? t('relevance') : null)))
1960
2135
  : (group.deduped > 0 ? t('deduped', { n: group.deduped }) : (group.truncated ? t('showing', { n: hits.length }) : null))
2136
+ const combinedNote = [note, warnings, budget].filter(value => value !== null && value !== '').join(' · ')
1961
2137
  if (typeof group.error === 'string' && group.error !== '') {
1962
- const message = group.error === 'content-regex-not-supported' ? t('panelRegexPaused') : group.error
2138
+ const informational = ['content-regex-not-supported', 'content-query-too-short', 'invalid-control-char'].includes(group.error)
2139
+ const message = contentErrorText(group.error)
1963
2140
  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'))),
2141
+ groupHead(key, label, countLabel(group, count), combinedNote === '' ? null : combinedNote),
2142
+ h('div', { className: 'dsh-locator-groupempty', role: informational ? 'status' : 'alert' },
2143
+ informational ? message : t('errorPrefix') + message,
2144
+ informational ? null : h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
1967
2145
  )
1968
2146
  }
1969
2147
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1970
- groupHead(key, label, countLabel(group, count), note),
2148
+ groupHead(key, label, countLabel(group, count), combinedNote === '' ? null : combinedNote),
1971
2149
  extra === undefined ? null : extra,
1972
2150
  hits.length === 0
1973
2151
  ? h('div', { className: 'dsh-locator-groupempty' },
1974
2152
  key === 'content' && scopes.content
1975
- ? (sessionOnly && query.trim().length >= 2 ? t('panelSessionHint')
1976
- : query.trim().length < 2 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
2153
+ ? (sessionOnly && query.trim().length >= 3 ? t('panelSessionHint')
2154
+ : query.trim().length < 3 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
1977
2155
  : (query.trim() === '' ? t('emptyQuery') : t('emptyResults')))
1978
2156
  : hits.map(hit => { index++; return rowFor(key, hit, index) }),
1979
2157
  )
@@ -2077,7 +2255,12 @@ self.onmessage = event => {
2077
2255
  ],
2078
2256
  ),
2079
2257
  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) })
2258
+ h('span', null, (loading ? t('loading') + ' · ' : '') + t('results', {
2259
+ content: contentCountText || '0',
2260
+ files: filesCountText || '0',
2261
+ dirs: dirsCountText || '0',
2262
+ ms: took.toFixed(1),
2263
+ })
2081
2264
  + (status && status.scanning ? ' · ' + t('indexing') : '')
2082
2265
  + (contentBuilding ? ' · ' + t('indexing') : '')),
2083
2266
  h('span', { className: 'grow' }),
@@ -2113,12 +2296,16 @@ self.onmessage = event => {
2113
2296
  }, 'dsh-locator: styles')
2114
2297
  ctx.effect(() => {
2115
2298
  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
- }
2299
+ if (event.defaultPrevented || event.isComposing || event.repeat) return
2300
+ if (!((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f')) return
2301
+ const target = event.target
2302
+ if (target instanceof Element && target.closest('.xterm,.monaco-editor,[data-code-editor]') !== null) return
2303
+ const foreignModal = document.querySelector('[role="dialog"][aria-modal="true"]:not(.dsh-locator-panel)')
2304
+ if (foreignModal !== null) return
2305
+ event.preventDefault()
2306
+ event.stopPropagation()
2307
+ closeFind()
2308
+ store.toggle()
2122
2309
  }
2123
2310
  document.addEventListener('keydown', onKey, true)
2124
2311
  return () => document.removeEventListener('keydown', onKey, true)
@@ -2162,7 +2349,11 @@ self.onmessage = event => {
2162
2349
  document.addEventListener('keydown', onFindKey, true)
2163
2350
  return () => document.removeEventListener('keydown', onFindKey, true)
2164
2351
  }, 'dsh-locator: find hotkeys')
2165
- ctx.effect(() => () => { closeFind(); cancelJump() }, 'dsh-locator: find cleanup')
2352
+ ctx.effect(() => () => {
2353
+ closeFind()
2354
+ cancelJump()
2355
+ store.set(false)
2356
+ }, 'dsh-locator: find cleanup')
2166
2357
  try {
2167
2358
  ctx.slots.inject('shell.overlay', () => {
2168
2359
  const disposers = [