dsh-locator 0.8.1 → 0.9.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.
package/client.js CHANGED
@@ -123,8 +123,39 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
123
123
  background:var(--dsw-alias-bg-layer-2,rgba(30,32,38,.92));color:var(--dsw-alias-label-primary,#fff);
124
124
  border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));box-shadow:0 8px 24px rgba(0,0,0,.25);
125
125
  animation:dsh-locator-in 120ms ease-out}
126
- ::highlight(dsh-locator-find){background-color:rgba(245,158,11,.28);color:inherit}
127
- ::highlight(dsh-locator-find-active){background-color:rgba(245,158,11,.45);color:inherit}
126
+ ::highlight(dsh-locator-find){background-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 28%,transparent);color:inherit}
127
+ ::highlight(dsh-locator-find-active){background-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 50%,transparent);color:inherit;
128
+ text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:2px}
129
+ ::highlight(dsh-locator-find-scope){background-color:color-mix(in srgb,var(--dsw-alias-bg-document-selection,rgba(90,120,255,.25)) 35%,transparent)}
130
+ .dsh-locator-findbar{position:fixed;z-index:70;display:flex;flex-direction:column;width:min(560px,calc(100vw - 24px));
131
+ border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-base,rgba(120,125,135,.28)));
132
+ border-radius:var(--dsw-radius-md,12px);background:var(--dsw-menu-surface-fill,var(--dsw-alias-bg-base,#fff));
133
+ color:var(--dsw-alias-label-primary,inherit);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.24));
134
+ font:13px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;animation:dsh-locator-in 120ms ease-out}
135
+ @media (prefers-reduced-motion:reduce){.dsh-locator-findbar{animation:none}}
136
+ .dsh-locator-findbar-main{display:flex;align-items:center;gap:6px;padding:6px 8px}
137
+ .dsh-locator-findbar-glyph{font-size:14px;opacity:.6;padding:0 2px}
138
+ .dsh-locator-findbar-input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;font-size:13px;padding:3px 0}
139
+ .dsh-locator-findbar-count{font-variant-numeric:tabular-nums;font-size:12px;opacity:.75;white-space:nowrap;min-width:52px;text-align:right}
140
+ .dsh-locator-findbar-btn{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 5px;
141
+ border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:13px}
142
+ .dsh-locator-findbar-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(120,125,135,.14))}
143
+ .dsh-locator-findbar-btn:disabled{opacity:.4;cursor:default}
144
+ .dsh-locator-findbar-btn[aria-pressed=true]{background:var(--dsw-alias-interactive-bg-active,rgba(64,116,255,.18));
145
+ color:var(--dsw-alias-state-business-primary,#4f7cff)}
146
+ .dsh-locator-findbar-opts{display:flex;align-items:center;gap:8px;padding:5px 9px 7px;flex-wrap:wrap;
147
+ border-top:1px solid var(--dsw-alias-border-l1,var(--dsw-alias-border-base,rgba(120,125,135,.16)));font-size:11.5px}
148
+ .dsh-locator-findbar-opts select{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));border-radius:5px;
149
+ background:transparent;color:inherit;font:inherit;font-size:11.5px;padding:1px 3px}
150
+ .dsh-locator-findbar-label{display:inline-flex;align-items:center;gap:4px;opacity:.8;white-space:nowrap}
151
+ .dsh-locator-findbar-status{display:flex;align-items:center;gap:8px;padding:4px 10px 6px;font-size:11.5px;opacity:.75;flex-wrap:wrap}
152
+ .dsh-locator-findbar-status .grow{flex:1}
153
+ .dsh-locator-findbar-error{padding:4px 10px 6px;font-size:11.5px;color:var(--dsw-alias-state-error-primary,#d33);
154
+ background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#d33) 9%,transparent)}
155
+ .dsh-locator-findbar-chip{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:999px;
156
+ background:var(--dsw-alias-bg-layer-2,rgba(120,125,135,.14));font-size:11px;white-space:nowrap}
157
+ .dsh-locator-findbar-chip button{border:0;background:transparent;color:inherit;cursor:pointer;padding:0 1px;font:inherit;font-size:12px}
158
+ .dsh-locator-findbar kbd{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));border-radius:4px;padding:0 3px;font-size:10px;opacity:.6}
128
159
  `
129
160
  function ensureStyles() {
130
161
  if (typeof document === 'undefined') return
@@ -177,6 +208,25 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
177
208
  launcher: 'Local file search (Ctrl+Shift+F)',
178
209
  justNow: 'just now', minutesAgo: '{n} min ago', hoursAgo: '{n} h ago',
179
210
  phraseNotFound: 'No match in the conversation (it may not be loaded yet)',
211
+ findOpen: 'Find in conversation', findTitle: 'Find in conversation', findAria: 'Find in conversation',
212
+ findPlaceholder: 'Find in conversation…', findPlaceholderSelection: 'Find in selection…',
213
+ findPrev: 'Previous match (Shift+F3)', findNext: 'Next match (F3)', findClose: 'Close (Esc)',
214
+ findCase: 'Match case', findWhole: 'Whole word', findWrap: 'Wrap around', findReverse: 'Search backwards',
215
+ findMode: 'Mode', modeNormal: 'Normal', modeExtended: 'Extended', modeRegex: 'Regex',
216
+ findScope: 'In selection', findScopeChip: 'selection {n} chars', findSessionChip: 'this conversation',
217
+ findNoMatch: 'No matches', findEmpty: 'Type to find in this conversation',
218
+ findInvalidRegex: 'Invalid regular expression: {message}', findInvalidEscape: 'Invalid extended escape: {token}',
219
+ findTimeout: 'Regex timed out; simplify the expression', findWrappedTop: 'Wrapped to top',
220
+ findWrappedBottom: 'Wrapped to bottom', findNoWrapEnd: 'Reached the last match (wrap is off)',
221
+ findNoWrapStart: 'Reached the first match (wrap is off)', findOlder: 'Load earlier messages and retry',
222
+ findLoadingOlder: 'Loading earlier messages…', findLoadedOnly: 'loaded window only · earlier history not loaded',
223
+ findCapped: 'capped', findCopied: 'Match copied', findSelectionLost: 'Selection expired; searching the whole conversation',
224
+ findNoSession: 'Open a conversation first', findOptions: 'Options',
225
+ findExtendedHelp: 'Extended: \\n \\r \\t \\0 \\xHH \\\\',
226
+ panelTotal: 'selected {index}/{total}', panelRangeAll: 'All indexed', panelRangeSession: 'This conversation',
227
+ panelSessionHint: 'This scope searches indexed session messages only; press Ctrl+F for live in-conversation find',
228
+ panelRegexPaused: 'Content scope paused in regex mode', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
229
+ panelCaseShort: 'Aa', panelWholeShort: 'Word', panelModeLabel: 'Mode',
180
230
  },
181
231
  zh: {
182
232
  scopeContent: '内容', scopeFiles: '文件', scopeDirs: '目录',
@@ -202,6 +252,25 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
202
252
  launcher: '本地文件搜索 (Ctrl+Shift+F)',
203
253
  justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
204
254
  phraseNotFound: '会话里没有找到匹配内容(可能还没加载)',
255
+ findOpen: '在会话中查找', findTitle: '在会话中查找', findAria: '在会话中查找',
256
+ findPlaceholder: '在会话中查找…', findPlaceholderSelection: '在选区内查找…',
257
+ findPrev: '上一个匹配 (Shift+F3)', findNext: '下一个匹配 (F3)', findClose: '关闭 (Esc)',
258
+ findCase: '区分大小写', findWhole: '全词匹配', findWrap: '循环查找', findReverse: '反向查找',
259
+ findMode: '模式', modeNormal: '普通', modeExtended: '扩展', modeRegex: '正则',
260
+ findScope: '仅在选区内', findScopeChip: '选区 {n} 字', findSessionChip: '本会话',
261
+ findNoMatch: '没有匹配', findEmpty: '输入关键词后在此会话中查找',
262
+ findInvalidRegex: '正则无效:{message}', findInvalidEscape: '扩展转义无效:{token}',
263
+ findTimeout: '正则执行超时,请简化表达式', findWrappedTop: '已回到顶部',
264
+ findWrappedBottom: '已到底部,回到开头', findNoWrapEnd: '已到最后一个匹配(循环查找已关闭)',
265
+ findNoWrapStart: '已到第一个匹配(循环查找已关闭)', findOlder: '加载更早消息后重试',
266
+ findLoadingOlder: '正在加载更早消息…', findLoadedOnly: '仅已加载窗口 · 更早历史未加载',
267
+ findCapped: '已截断', findCopied: '已复制匹配', findSelectionLost: '选区已失效,已切回整个会话',
268
+ findNoSession: '请先打开一个会话', findOptions: '选项',
269
+ findExtendedHelp: '扩展:\\n \\r \\t \\0 \\xHH \\\\',
270
+ panelTotal: '已选 {index}/{total}', panelRangeAll: '全部索引', panelRangeSession: '当前会话',
271
+ panelSessionHint: '此范围只查已索引的会话消息;实时查找请按 Ctrl+F',
272
+ panelRegexPaused: '正则模式下内容范围暂停', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
273
+ panelCaseShort: 'Aa', panelWholeShort: '全词', panelModeLabel: '模式',
205
274
  },
206
275
  es: {
207
276
  scopeContent: 'Contenido', scopeFiles: 'Archivos', scopeDirs: 'Carpetas',
@@ -227,6 +296,25 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
227
296
  launcher: 'Búsqueda local (Ctrl+Shift+F)',
228
297
  justNow: 'ahora', minutesAgo: 'hace {n} min', hoursAgo: 'hace {n} h',
229
298
  phraseNotFound: 'No se encontró en la conversación (quizá no está cargada)',
299
+ findOpen: 'Buscar en la conversación', findTitle: 'Buscar en la conversación', findAria: 'Buscar en la conversación',
300
+ findPlaceholder: 'Buscar en la conversación…', findPlaceholderSelection: 'Buscar en la selección…',
301
+ findPrev: 'Coincidencia anterior (Shift+F3)', findNext: 'Siguiente coincidencia (F3)', findClose: 'Cerrar (Esc)',
302
+ findCase: 'Distinguir mayúsculas', findWhole: 'Palabra completa', findWrap: 'Continuar al inicio', findReverse: 'Buscar hacia atrás',
303
+ findMode: 'Modo', modeNormal: 'Normal', modeExtended: 'Extendido', modeRegex: 'Regex',
304
+ findScope: 'En la selección', findScopeChip: 'selección {n} caracteres', findSessionChip: 'esta conversación',
305
+ findNoMatch: 'Sin coincidencias', findEmpty: 'Escribe para buscar en esta conversación',
306
+ 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',
308
+ findWrappedBottom: 'Vuelto al final', findNoWrapEnd: 'Última coincidencia (continuación desactivada)',
309
+ findNoWrapStart: 'Primera coincidencia (continuación desactivada)', findOlder: 'Cargar mensajes anteriores y reintentar',
310
+ findLoadingOlder: 'Cargando mensajes anteriores…', findLoadedOnly: 'solo ventana cargada · historial anterior sin cargar',
311
+ findCapped: 'limitado', findCopied: 'Coincidencia copiada', findSelectionLost: 'La selección caducó; buscando en toda la conversación',
312
+ findNoSession: 'Abre primero una conversación', findOptions: 'Opciones',
313
+ findExtendedHelp: 'Extendido: \\n \\r \\t \\0 \\xHH \\\\',
314
+ panelTotal: 'seleccionado {index}/{total}', panelRangeAll: 'Todo el índice', panelRangeSession: 'Esta conversación',
315
+ panelSessionHint: 'Este ámbito solo busca mensajes indexados; pulsa Ctrl+F para buscar en vivo',
316
+ panelRegexPaused: 'Ámbito de contenido en pausa en modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
317
+ panelCaseShort: 'Aa', panelWholeShort: 'Palabra', panelModeLabel: 'Modo',
230
318
  },
231
319
  pt: {
232
320
  scopeContent: 'Conteúdo', scopeFiles: 'Arquivos', scopeDirs: 'Pastas',
@@ -252,6 +340,25 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
252
340
  launcher: 'Busca local (Ctrl+Shift+F)',
253
341
  justNow: 'agora', minutesAgo: 'há {n} min', hoursAgo: 'há {n} h',
254
342
  phraseNotFound: 'Não encontrado na conversa (talvez ainda não carregada)',
343
+ findOpen: 'Buscar na conversa', findTitle: 'Buscar na conversa', findAria: 'Buscar na conversa',
344
+ findPlaceholder: 'Buscar na conversa…', findPlaceholderSelection: 'Buscar na seleção…',
345
+ findPrev: 'Ocorrência anterior (Shift+F3)', findNext: 'Próxima ocorrência (F3)', findClose: 'Fechar (Esc)',
346
+ findCase: 'Diferenciar maiúsculas', findWhole: 'Palavra inteira', findWrap: 'Continuar do início', findReverse: 'Buscar para trás',
347
+ findMode: 'Modo', modeNormal: 'Normal', modeExtended: 'Estendido', modeRegex: 'Regex',
348
+ findScope: 'Na seleção', findScopeChip: 'seleção {n} caracteres', findSessionChip: 'esta conversa',
349
+ findNoMatch: 'Sem resultados', findEmpty: 'Digite para buscar nesta conversa',
350
+ 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',
352
+ findWrappedBottom: 'Voltou ao fim', findNoWrapEnd: 'Última ocorrência (continuação desativada)',
353
+ findNoWrapStart: 'Primeira ocorrência (continuação desativada)', findOlder: 'Carregar mensagens anteriores e tentar novamente',
354
+ findLoadingOlder: 'Carregando mensagens anteriores…', findLoadedOnly: 'apenas janela carregada · histórico anterior não carregado',
355
+ findCapped: 'limitado', findCopied: 'Ocorrência copiada', findSelectionLost: 'A seleção expirou; buscando em toda a conversa',
356
+ findNoSession: 'Abra uma conversa primeiro', findOptions: 'Opções',
357
+ findExtendedHelp: 'Estendido: \\n \\r \\t \\0 \\xHH \\\\',
358
+ panelTotal: 'selecionado {index}/{total}', panelRangeAll: 'Todo o índice', panelRangeSession: 'Esta conversa',
359
+ panelSessionHint: 'Este escopo busca apenas mensagens indexadas; use Ctrl+F para buscar ao vivo',
360
+ panelRegexPaused: 'Escopo de conteúdo pausado no modo regex', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
361
+ panelCaseShort: 'Aa', panelWholeShort: 'Palavra', panelModeLabel: 'Modo',
255
362
  },
256
363
  hi: {
257
364
  scopeContent: 'सामग्री', scopeFiles: 'फ़ाइलें', scopeDirs: 'फ़ोल्डर',
@@ -277,6 +384,25 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
277
384
  launcher: 'स्थानीय फ़ाइल खोज (Ctrl+Shift+F)',
278
385
  justNow: 'अभी', minutesAgo: '{n} मिनट पहले', hoursAgo: '{n} घंटे पहले',
279
386
  phraseNotFound: 'बातचीत में नहीं मिला (शायद अभी लोड नहीं हुआ)',
387
+ findOpen: 'बातचीत में खोजें', findTitle: 'बातचीत में खोजें', findAria: 'बातचीत में खोजें',
388
+ findPlaceholder: 'बातचीत में खोजें…', findPlaceholderSelection: 'चयन में खोजें…',
389
+ findPrev: 'पिछला मेल (Shift+F3)', findNext: 'अगला मेल (F3)', findClose: 'बंद करें (Esc)',
390
+ findCase: 'केस मिलाएँ', findWhole: 'पूरा शब्द', findWrap: 'चक्रीय खोज', findReverse: 'उल्टा खोजें',
391
+ findMode: 'मोड', modeNormal: 'सामान्य', modeExtended: 'विस्तृत', modeRegex: 'रेगेक्स',
392
+ findScope: 'चयन में', findScopeChip: 'चयन {n} अक्षर', findSessionChip: 'यह बातचीत',
393
+ findNoMatch: 'कोई मेल नहीं', findEmpty: 'इस बातचीत में खोजने के लिए लिखें',
394
+ findInvalidRegex: 'अमान्य रेगुलर एक्सप्रेशन: {message}', findInvalidEscape: 'अमान्य विस्तृत एस्केप: {token}',
395
+ findTimeout: 'रेगेक्स का समय समाप्त; सरल बनाएँ', findWrappedTop: 'शीर्ष पर लौटा',
396
+ findWrappedBottom: 'अंत पर लौटा', findNoWrapEnd: 'अंतिम मेल (चक्र बंद)',
397
+ findNoWrapStart: 'पहला मेल (चक्र बंद)', findOlder: 'पुराने संदेश लोड कर पुन: प्रयास',
398
+ findLoadingOlder: 'पुराने संदेश लोड हो रहे हैं…', findLoadedOnly: 'केवल लोडेड विंडो · पुराना इतिहास लोड नहीं',
399
+ findCapped: 'सीमित', findCopied: 'मेल कॉपी हुआ', findSelectionLost: 'चयन समाप्त; पूरी बातचीत में खोज',
400
+ findNoSession: 'पहले एक बातचीत खोलें', findOptions: 'विकल्प',
401
+ findExtendedHelp: 'विस्तृत: \\n \\r \\t \\0 \\xHH \\\\',
402
+ panelTotal: 'चयनित {index}/{total}', panelRangeAll: 'पूरा इंडेक्स', panelRangeSession: 'यह बातचीत',
403
+ panelSessionHint: 'यह क्षेत्र केवल इंडेक्स किए संदेश खोजता है; लाइव खोज के लिए Ctrl+F दबाएँ',
404
+ panelRegexPaused: 'रेगेक्स मोड में सामग्री क्षेत्र रुका है', panelCountBounded: '≥ {n}', panelCountExact: '{n}',
405
+ panelCaseShort: 'Aa', panelWholeShort: 'शब्द', panelModeLabel: 'मोड',
280
406
  },
281
407
  }
282
408
  const LANG = (() => {
@@ -382,7 +508,8 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
382
508
  const PREFS_KEY = 'dsh-locator.panel.v1'
383
509
  const SORTS = ['mtime', 'name', 'size', 'path']
384
510
  function readPrefs() {
385
- const fallback = { scopes: { content: true, files: true, dirs: true }, sort: 'mtime', desc: true }
511
+ const fallback = { scopes: { content: true, files: true, dirs: true }, sort: 'mtime', desc: true,
512
+ matchMode: 'normal', caseSensitive: false, wholeWord: false }
386
513
  try {
387
514
  const raw = window.localStorage.getItem(PREFS_KEY)
388
515
  if (raw === null) return fallback
@@ -397,6 +524,9 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
397
524
  },
398
525
  sort: SORTS.includes(value.sort) ? value.sort : 'mtime',
399
526
  desc: typeof value.desc === 'boolean' ? value.desc : true,
527
+ matchMode: ['normal', 'extended', 'regex'].includes(value.matchMode) ? value.matchMode : 'normal',
528
+ caseSensitive: typeof value.caseSensitive === 'boolean' ? value.caseSensitive : false,
529
+ wholeWord: typeof value.wholeWord === 'boolean' ? value.wholeWord : false,
400
530
  }
401
531
  } catch {
402
532
  return fallback
@@ -424,37 +554,57 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
424
554
  legacyTargets.clear()
425
555
  }
426
556
 
427
- function clearFindHighlight() {
557
+ const HL_SCOPE = 'dsh-locator-find-scope'
558
+ let highlightOwner = null
559
+
560
+ function clearFindHighlight(owner) {
561
+ if (owner && highlightOwner !== null && owner !== highlightOwner) return
562
+ highlightOwner = null
428
563
  clearLegacyTargets()
429
564
  if (!highlightsAvailable()) return
430
565
  CSS.highlights.delete(HL_BASE)
431
566
  CSS.highlights.delete(HL_ACTIVE)
567
+ CSS.highlights.delete(HL_SCOPE)
432
568
  }
433
569
 
434
- function setFindHighlight(ranges) {
570
+ function setFindHighlight(ranges, activeIndex, owner) {
571
+ clearFindHighlight()
572
+ if (ranges.length === 0) return
573
+ highlightOwner = owner || null
574
+ const active = ranges[Math.max(0, Math.min(ranges.length - 1, activeIndex || 0))]
435
575
  if (!highlightsAvailable()) {
436
- for (const range of ranges.slice(0, 1)) {
437
- const node = range.startContainer
438
- const element = node instanceof Element ? node : node.parentElement
439
- const row = element === null ? null : element.closest('[data-chat-node-key]')
440
- if (row !== null) { row.classList.add('dsh-locator-target'); legacyTargets.add(row) }
441
- }
576
+ const node = active.startContainer
577
+ const element = node instanceof Element ? node : node.parentElement
578
+ const row = element === null ? null : element.closest('[data-chat-node-key]')
579
+ if (row !== null) { row.classList.add('dsh-locator-target'); legacyTargets.add(row) }
442
580
  return
443
581
  }
444
- if (ranges.length === 0) return
445
582
  CSS.highlights.set(HL_BASE, new Highlight(...ranges))
446
- CSS.highlights.set(HL_ACTIVE, new Highlight(ranges[0]))
583
+ CSS.highlights.set(HL_ACTIVE, new Highlight(active))
584
+ }
585
+
586
+ function setFindScope(range) {
587
+ if (!highlightsAvailable()) return
588
+ CSS.highlights.delete(HL_SCOPE)
589
+ if (range !== null) CSS.highlights.set(HL_SCOPE, new Highlight(range))
590
+ }
591
+
592
+ function isVisible(element) {
593
+ if (element.getClientRects().length === 0) return false
594
+ return getComputedStyle(element).visibility !== 'hidden'
447
595
  }
448
596
 
449
597
  function conversationScope(sessionId) {
450
598
  const id = sessionId === undefined || sessionId === null ? '' : String(sessionId)
451
- let embedded = null
599
+ let visibleMain = null
600
+ let visibleEmbedded = null
452
601
  for (const element of document.querySelectorAll('[data-conversation-content][data-conversation-session]')) {
453
602
  if (id !== '' && element.getAttribute('data-conversation-session') !== id) continue
454
- if (element.closest('[data-sidebar-chat]') === null) return element
455
- embedded = element
603
+ if (!isVisible(element)) continue
604
+ if (element.closest('[data-sidebar-chat]') === null) visibleMain = element
605
+ else visibleEmbedded = element
456
606
  }
457
- return embedded
607
+ return visibleMain ?? visibleEmbedded
458
608
  }
459
609
 
460
610
  function chatFlow(scope) {
@@ -674,7 +824,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
674
824
  let activeHighlight = null
675
825
 
676
826
  function clearJumpHighlight() {
677
- clearFindHighlight()
827
+ clearFindHighlight('jump')
678
828
  if (activeHighlight !== null) {
679
829
  const clear = activeHighlight
680
830
  activeHighlight = null
@@ -728,7 +878,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
728
878
  }
729
879
  if (stale()) return false
730
880
  if (target === null) { showNotice(t('phraseNotFound')); return false }
731
- setFindHighlight(target.ranges)
881
+ setFindHighlight(target.ranges, 0, 'jump')
732
882
  centerRange(target.ranges[0])
733
883
  const start = target.ranges[0].startContainer
734
884
  const chain = scrollChain(start instanceof Element ? start : start.parentElement)
@@ -746,9 +896,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
746
896
  const fn = highlightCleanup.pop()
747
897
  try { fn() } catch { /* ignore */ }
748
898
  }
749
- clearFindHighlight()
899
+ clearFindHighlight('jump')
750
900
  }
751
- activeHighlight = clearHighlight
901
+ // Cancelling must drop the scroll lock too, not only the paint: the lock
902
+ // owns timers and scroll/pointer listeners that would otherwise outlive
903
+ // the highlight.
904
+ activeHighlight = () => { unlock(); clearHighlight() }
752
905
  if (scroller !== null) {
753
906
  let writing = false
754
907
  let expected = scroller.scrollTop
@@ -784,8 +937,13 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
784
937
  document.addEventListener('keydown', onKey, true)
785
938
  highlightCleanup.push(() => document.removeEventListener('keydown', onKey, true))
786
939
  const sessionWatch = setInterval(() => {
787
- const connected = target.ranges[0].startContainer.isConnected
788
- if (!connected || conversationScope(hit.sessionId) === null) cancelJump()
940
+ if (conversationScope(hit.sessionId) === null) { cancelJump(); return }
941
+ if (target.ranges[0].startContainer.isConnected) return
942
+ const next = finder()
943
+ if (next === null) { cancelJump(); return }
944
+ target = next
945
+ setFindHighlight(target.ranges, 0, 'jump')
946
+ centerRange(target.ranges[0])
789
947
  }, 1_000)
790
948
  highlightCleanup.push(() => clearInterval(sessionWatch))
791
949
  showNotice(t('jumped'))
@@ -797,6 +955,640 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
797
955
  const PAGE_NAME = 50
798
956
  const PAGE_CONTENT = 30
799
957
 
958
+ // ── in-conversation find bar ────────────────────────────────────────────
959
+ const FIND_MAX_MATCHES = 2000
960
+ const FIND_MAX_CHARS = 4_000_000
961
+ const FIND_BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
962
+ const FIND_EXCLUDED = '[hidden]:not([hidden="until-found"]),[data-chat-history],[data-text-interaction-surface],'
963
+ + '[data-composer-seat],[data-text-interaction-exclude],script,style,button,input,textarea,select,'
964
+ + '.sr-only,.katex-mathml,[aria-hidden="true"]:not(.katex-html)'
965
+
966
+ function createFindStore() {
967
+ const listeners = new Set()
968
+ let state = {
969
+ open: false, sessionId: '', scope: 'session', selection: null, query: '',
970
+ mode: 'normal', caseSensitive: false, wholeWord: false, wrap: true, direction: 1,
971
+ optionsOpen: false, count: 0, index: -1, capped: false, status: 'idle', error: '', hint: '',
972
+ }
973
+ return {
974
+ get: () => state,
975
+ subscribe(listener) {
976
+ listeners.add(listener)
977
+ return () => listeners.delete(listener)
978
+ },
979
+ set(patch) {
980
+ state = { ...state, ...patch }
981
+ for (const listener of listeners) listener()
982
+ },
983
+ }
984
+ }
985
+ const findStore = createFindStore()
986
+ const findApi = { move: () => false, cancel: () => {}, focus: () => {} }
987
+ function useFindState() {
988
+ return useSyncExternalStore(findStore.subscribe, findStore.get, findStore.get)
989
+ }
990
+
991
+ function selectionSnapshot(scope) {
992
+ if (scope === null || typeof window.getSelection !== 'function') return null
993
+ const selection = window.getSelection()
994
+ if (selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return null
995
+ const range = selection.getRangeAt(0).cloneRange()
996
+ if (!scope.contains(range.commonAncestorContainer)) return null
997
+ const startEl = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
998
+ if (startEl !== null && startEl.closest('[data-composer-seat]') !== null) return null
999
+ const text = String(selection.toString() || '').replace(/\s+/g, ' ').trim()
1000
+ if (text === '') return null
1001
+ return { range, text: text.slice(0, 500), chars: text.length }
1002
+ }
1003
+
1004
+ function openFind(options) {
1005
+ const opts = options || {}
1006
+ let scope = opts.sessionId ? conversationScope(opts.sessionId) : null
1007
+ if (scope === null) scope = conversationScope('')
1008
+ if (scope === null) { showNotice(t('findNoSession')); return false }
1009
+ const sessionId = scope.getAttribute('data-conversation-session') || opts.sessionId || ''
1010
+ const selection = opts.selection === false ? null : selectionSnapshot(scope)
1011
+ const useSelection = opts.selection === true && selection !== null
1012
+ findApi.cancel()
1013
+ cancelJump()
1014
+ if (store.isOpen()) store.set(false)
1015
+ findStore.set({
1016
+ open: true, sessionId, scope: useSelection ? 'selection' : 'session',
1017
+ selection: useSelection ? selection : null, query: '', mode: 'normal',
1018
+ caseSensitive: false, wholeWord: false, wrap: true, direction: 1,
1019
+ optionsOpen: false, count: 0, index: -1, capped: false, status: 'idle', error: '', hint: '',
1020
+ })
1021
+ return true
1022
+ }
1023
+
1024
+ function closeFind() {
1025
+ if (!findStore.get().open) return
1026
+ findStore.set({ open: false, selection: null, query: '', error: '', hint: '', status: 'idle' })
1027
+ findApi.cancel()
1028
+ clearFindHighlight('find')
1029
+ setFindScope(null)
1030
+ }
1031
+
1032
+ const FIND_WORKER_SRC = `
1033
+ self.onmessage = event => {
1034
+ const { id, pattern, flags, texts } = event.data
1035
+ try {
1036
+ const re = new RegExp(pattern, flags)
1037
+ const out = []
1038
+ for (let ti = 0; ti < texts.length; ti++) {
1039
+ const text = texts[ti]
1040
+ re.lastIndex = 0
1041
+ let match
1042
+ while ((match = re.exec(text)) !== null) {
1043
+ if (match[0].length === 0) { re.lastIndex++; continue }
1044
+ out.push([ti, match.index, match.index + match[0].length])
1045
+ if (out.length >= 2000) break
1046
+ }
1047
+ if (out.length >= 2000) break
1048
+ }
1049
+ self.postMessage({ id, ok: true, matches: out, capped: out.length >= 2000 })
1050
+ } catch (error) {
1051
+ self.postMessage({ id, ok: false, error: String(error && error.message || error) })
1052
+ }
1053
+ }
1054
+ `
1055
+
1056
+ function scanRegexInWorker(texts, source, flags) {
1057
+ if (typeof Worker !== 'function' || typeof Blob !== 'function' || typeof URL.createObjectURL !== 'function') {
1058
+ return Promise.resolve(null)
1059
+ }
1060
+ return new Promise(resolve => {
1061
+ let worker = null
1062
+ let url = ''
1063
+ let done = false
1064
+ const finish = value => {
1065
+ if (done) return
1066
+ done = true
1067
+ clearTimeout(timer)
1068
+ try { if (worker !== null) worker.terminate() } catch { /* ignore */ }
1069
+ try { if (url !== '') URL.revokeObjectURL(url) } catch { /* ignore */ }
1070
+ resolve(value)
1071
+ }
1072
+ const timer = setTimeout(() => finish({ timeout: true }), 500)
1073
+ try {
1074
+ const blob = new Blob([FIND_WORKER_SRC], { type: 'text/javascript' })
1075
+ url = URL.createObjectURL(blob)
1076
+ worker = new Worker(url)
1077
+ worker.onmessage = event => finish(event.data)
1078
+ worker.onerror = () => finish(null)
1079
+ worker.postMessage({ id: 1, pattern: source, flags, texts })
1080
+ } catch {
1081
+ finish(null)
1082
+ }
1083
+ })
1084
+ }
1085
+
1086
+ function expandExtended(input) {
1087
+ let out = ''
1088
+ for (let index = 0; index < input.length; index++) {
1089
+ const char = input[index]
1090
+ if (char !== '\\') { out += char; continue }
1091
+ const next = input[index + 1]
1092
+ if (next === undefined) { out += '\\'; continue }
1093
+ index++
1094
+ if (next === 'n') out += '\n'
1095
+ else if (next === 'r') out += '\r'
1096
+ else if (next === 't') out += '\t'
1097
+ else if (next === '0') out += '\u0000'
1098
+ else if (next === '\\') out += '\\'
1099
+ else if (next === 'x') {
1100
+ const hex = input.slice(index + 1, index + 3)
1101
+ if (!/^[0-9a-fA-F]{2}$/u.test(hex)) return { error: '\\x' }
1102
+ out += String.fromCharCode(parseInt(hex, 16))
1103
+ index += 2
1104
+ } else if (next === 'u') {
1105
+ const hex = input.slice(index + 1, index + 5)
1106
+ if (!/^[0-9a-fA-F]{4}$/u.test(hex)) return { error: '\\u' }
1107
+ out += String.fromCharCode(parseInt(hex, 16))
1108
+ index += 4
1109
+ } else out += '\\' + next
1110
+ }
1111
+ return { value: out }
1112
+ }
1113
+
1114
+ function compileFind(query, mode, caseSensitive) {
1115
+ let source = String(query || '')
1116
+ if (mode === 'extended') {
1117
+ const expanded = expandExtended(source)
1118
+ if (expanded.error !== undefined) return { error: t('findInvalidEscape', { token: expanded.error }) }
1119
+ source = expanded.value
1120
+ }
1121
+ if (mode !== 'regex') source = escapeRegExp(source)
1122
+ const flags = 'g' + (caseSensitive ? '' : 'i')
1123
+ try {
1124
+ return { source, flags: /[\u{10000}-\u{10FFFF}]/u.test(source) ? flags + 'u' : flags, re: new RegExp(source, flags) }
1125
+ } catch (error) {
1126
+ if (mode === 'regex') {
1127
+ try { return { source, flags: flags + 'u', re: new RegExp(source, flags + 'u') } } catch { /* fall through */ }
1128
+ }
1129
+ return { error: t('findInvalidRegex', { message: String(error && error.message || error).slice(0, 140) }) }
1130
+ }
1131
+ }
1132
+
1133
+ function isWordCharacter(char) {
1134
+ return typeof char === 'string' && char !== '' && /^[A-Za-z0-9_]$/u.test(char)
1135
+ }
1136
+
1137
+ function wholeWordAt(text, start, end) {
1138
+ return !isWordCharacter(text[start - 1]) && !isWordCharacter(text[end])
1139
+ }
1140
+
1141
+ 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()]
1165
+ }
1166
+
1167
+ function clipForGroup(group, range) {
1168
+ 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
1174
+ let nodeStart = 0
1175
+ let nodeEnd = length
1176
+ try {
1177
+ if (!range.intersectsNode(node)) { position += length; continue }
1178
+ } catch { position += length; continue }
1179
+ if (node === range.startContainer) nodeStart = range.startOffset
1180
+ if (node === range.endContainer) nodeEnd = range.endOffset
1181
+ if (nodeStart < nodeEnd) {
1182
+ if (start === null) start = position + nodeStart
1183
+ end = position + nodeEnd
1184
+ }
1185
+ position += length
1186
+ }
1187
+ if (start === null || end === null || start >= end) return null
1188
+ return [start, end]
1189
+ }
1190
+
1191
+ function rangeForGroupOffsets(group, start, end) {
1192
+ 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
+ }
1203
+ }
1204
+
1205
+ function findHistoryButton(scope) {
1206
+ return scope === null ? null : scope.querySelector('[data-chat-history] button')
1207
+ }
1208
+
1209
+ function FindToggle(props) {
1210
+ const onClick = () => { openFind({ sessionId: props && props.sessionId ? props.sessionId : '', selection: false }) }
1211
+ return h('button', {
1212
+ className: 'dsh-locator-launch',
1213
+ title: t('findOpen') + ' (Ctrl+F)',
1214
+ 'aria-label': t('findOpen'),
1215
+ onClick,
1216
+ }, '⌕')
1217
+ }
1218
+
1219
+ function FindBar() {
1220
+ const state = useFindState()
1221
+ const inputRef = useRef(null)
1222
+ const barRef = useRef(null)
1223
+ const scopeRef = useRef(null)
1224
+ const matchesRef = useRef([])
1225
+ const activeRef = useRef(-1)
1226
+ const sequenceRef = useRef(0)
1227
+ const debounceRef = useRef(0)
1228
+ const observerRef = useRef(null)
1229
+ const revealTimersRef = useRef([])
1230
+ const returnFocusRef = useRef(null)
1231
+ const [anchor, setAnchor] = useState(null)
1232
+ const composingRef = useRef(false)
1233
+
1234
+ const applyHighlight = useCallback((index) => {
1235
+ const matches = matchesRef.current
1236
+ if (matches.length === 0) { clearFindHighlight('find'); setFindScope(null); return }
1237
+ const safe = Math.max(0, Math.min(matches.length - 1, index))
1238
+ activeRef.current = safe
1239
+ setFindHighlight(matches.map(match => match.range), safe, 'find')
1240
+ setFindScope(state.scope === 'selection' && state.selection ? state.selection.range : null)
1241
+ }, [state.scope, state.selection])
1242
+
1243
+ const revealActive = useCallback(() => {
1244
+ const match = matchesRef.current[activeRef.current]
1245
+ if (!match || !match.range.startContainer.isConnected) return false
1246
+ expandHidden(match.range.startContainer)
1247
+ const run = () => {
1248
+ if (!match.range.startContainer.isConnected) return
1249
+ centerRange(match.range)
1250
+ }
1251
+ requestAnimationFrame(run)
1252
+ for (const delay of [80, 240]) {
1253
+ const timer = setTimeout(run, delay)
1254
+ revealTimersRef.current.push(timer)
1255
+ }
1256
+ return true
1257
+ }, [])
1258
+
1259
+ const move = useCallback(delta => {
1260
+ const matches = matchesRef.current
1261
+ if (matches.length === 0) return false
1262
+ const direction = delta === 0 ? (findStore.get().direction === -1 ? -1 : 1) : delta
1263
+ if (!findStore.get().wrap && (activeRef.current + direction >= matches.length || activeRef.current + direction < 0)) {
1264
+ findStore.set({ hint: direction > 0 ? t('findNoWrapEnd') : t('findNoWrapStart') })
1265
+ return false
1266
+ }
1267
+ let next = activeRef.current + direction
1268
+ if (next >= matches.length) next = 0
1269
+ if (next < 0) next = matches.length - 1
1270
+ const wrapped = (activeRef.current + direction >= matches.length) || (activeRef.current + direction < 0)
1271
+ activeRef.current = next
1272
+ applyHighlight(next)
1273
+ findStore.set({
1274
+ index: next,
1275
+ hint: wrapped ? (direction > 0 ? t('findWrappedTop') : t('findWrappedBottom')) : '',
1276
+ })
1277
+ revealActive()
1278
+ return true
1279
+ }, [applyHighlight, revealActive])
1280
+
1281
+ const runScan = useCallback(async (keepActive) => {
1282
+ const token = ++sequenceRef.current
1283
+ const scope = scopeRef.current || conversationScope(state.sessionId) || conversationScope('')
1284
+ if (scope === null) { return }
1285
+ scopeRef.current = scope
1286
+ if (state.query === '') {
1287
+ matchesRef.current = []
1288
+ activeRef.current = -1
1289
+ clearFindHighlight('find')
1290
+ setFindScope(state.scope === 'selection' && state.selection ? state.selection.range : null)
1291
+ findStore.set({ count: 0, index: -1, capped: false, status: 'idle', error: '', hint: '' })
1292
+ return
1293
+ }
1294
+ const compiled = compileFind(state.query, state.mode, state.caseSensitive)
1295
+ if (compiled.error !== undefined) {
1296
+ matchesRef.current = []
1297
+ activeRef.current = -1
1298
+ clearFindHighlight('find')
1299
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: compiled.error })
1300
+ return
1301
+ }
1302
+ let selectionRange = null
1303
+ if (state.scope === 'selection' && state.selection) {
1304
+ selectionRange = state.selection.range
1305
+ if (!selectionRange.startContainer.isConnected) {
1306
+ findStore.set({ scope: 'session', selection: null, hint: t('findSelectionLost') })
1307
+ return
1308
+ }
1309
+ }
1310
+ const groups = collectFindGroups(scope)
1311
+ const texts = []
1312
+ const clips = []
1313
+ let chars = 0
1314
+ for (const group of groups) {
1315
+ const clip = selectionRange === null ? null : clipForGroup(group, selectionRange)
1316
+ if (selectionRange !== null && clip === null) { texts.push(null); clips.push(null); continue }
1317
+ const start = clip === null ? 0 : clip[0]
1318
+ const end = clip === null ? group.text.length : clip[1]
1319
+ const text = group.text.slice(start, end)
1320
+ chars += text.length
1321
+ if (chars > FIND_MAX_CHARS) { texts.push(null); clips.push(null); continue }
1322
+ texts.push(text)
1323
+ clips.push(clip === null ? [0, group.text.length] : clip)
1324
+ }
1325
+ let rawMatches = null
1326
+ let capped = false
1327
+ if (state.mode === 'regex') {
1328
+ try {
1329
+ const result = await scanRegexInWorker(texts.filter(text => text !== null), compiled.source, compiled.flags)
1330
+ if (token !== sequenceRef.current) return
1331
+ if (result !== null) {
1332
+ if (result.timeout === true) {
1333
+ matchesRef.current = []
1334
+ clearFindHighlight('find')
1335
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findTimeout') })
1336
+ return
1337
+ }
1338
+ if (result.ok === false) {
1339
+ matchesRef.current = []
1340
+ clearFindHighlight('find')
1341
+ findStore.set({ count: 0, index: -1, capped: false, status: 'error', error: t('findInvalidRegex', { message: String(result.error || '') }) })
1342
+ return
1343
+ }
1344
+ const compact = []
1345
+ let cursor = 0
1346
+ for (let groupIndex = 0; groupIndex < texts.length; groupIndex++) {
1347
+ if (texts[groupIndex] === null) continue
1348
+ compact.push(groupIndex)
1349
+ cursor++
1350
+ }
1351
+ rawMatches = []
1352
+ for (const item of result.matches) {
1353
+ const groupIndex = compact[item[0]]
1354
+ if (groupIndex === undefined) continue
1355
+ rawMatches.push({ groupIndex, start: item[1] + clips[groupIndex][0], end: item[2] + clips[groupIndex][0] })
1356
+ }
1357
+ capped = result.capped === true
1358
+ }
1359
+ } catch { /* fall back below */ }
1360
+ }
1361
+ if (rawMatches === null) {
1362
+ rawMatches = []
1363
+ const re = new RegExp(compiled.source, compiled.flags)
1364
+ const started = performance.now()
1365
+ for (let groupIndex = 0; groupIndex < texts.length; groupIndex++) {
1366
+ const text = texts[groupIndex]
1367
+ if (text === null) continue
1368
+ re.lastIndex = 0
1369
+ let match
1370
+ while ((match = re.exec(text)) !== null) {
1371
+ if (match[0].length === 0) { re.lastIndex++; continue }
1372
+ const start = clips[groupIndex][0] + match.index
1373
+ const end = start + match[0].length
1374
+ if (state.wholeWord && !wholeWordAt(groups[groupIndex].text, start, end)) continue
1375
+ rawMatches.push({ groupIndex, start, end })
1376
+ if (rawMatches.length >= FIND_MAX_MATCHES) break
1377
+ }
1378
+ if (rawMatches.length >= FIND_MAX_MATCHES) break
1379
+ if (performance.now() - started > 1500) { capped = true; break }
1380
+ }
1381
+ capped = capped || rawMatches.length >= FIND_MAX_MATCHES
1382
+ } else if (state.wholeWord) {
1383
+ rawMatches = rawMatches.filter(raw => {
1384
+ const group = groups[raw.groupIndex]
1385
+ return group !== undefined && wholeWordAt(group.text, raw.start, raw.end)
1386
+ })
1387
+ capped = capped || rawMatches.length >= FIND_MAX_MATCHES
1388
+ }
1389
+ if (token !== sequenceRef.current) return
1390
+ const matches = []
1391
+ for (const raw of rawMatches) {
1392
+ const group = groups[raw.groupIndex]
1393
+ if (group === undefined) continue
1394
+ const range = rangeForGroupOffsets(group, raw.start, raw.end)
1395
+ if (range === null) continue
1396
+ const row = group.block.closest('[data-chat-node-key]')
1397
+ matches.push({ range, rowKey: row === null ? '' : (row.getAttribute('data-chat-node-key') || '') })
1398
+ }
1399
+ if (token !== sequenceRef.current) return
1400
+ matchesRef.current = matches
1401
+ const next = matches.length === 0 ? -1 : (keepActive && activeRef.current >= 0 ? Math.min(activeRef.current, matches.length - 1) : 0)
1402
+ activeRef.current = next
1403
+ const history = findHistoryButton(scopeRef.current)
1404
+ findStore.set({
1405
+ count: matches.length,
1406
+ index: next,
1407
+ capped,
1408
+ status: matches.length === 0 ? 'none' : 'idle',
1409
+ error: '',
1410
+ hint: matches.length === 0 && history !== null && !history.disabled ? t('findLoadedOnly') : '',
1411
+ })
1412
+ if (next >= 0) { applyHighlight(next); revealActive() }
1413
+ else { clearFindHighlight('find'); setFindScope(state.scope === 'selection' && state.selection ? state.selection.range : null) }
1414
+ }, [applyHighlight, revealActive, state.caseSensitive, state.mode, state.query, state.scope, state.selection, state.sessionId, state.wholeWord])
1415
+ const runScanRef = useRef(runScan)
1416
+ runScanRef.current = runScan
1417
+
1418
+ useEffect(() => {
1419
+ if (!state.open) return undefined
1420
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
1421
+ const measure = () => {
1422
+ const scope = conversationScope(findStore.get().sessionId) || conversationScope('')
1423
+ if (scope === null) { closeFind(); return }
1424
+ scopeRef.current = scope
1425
+ const rect = scope.getBoundingClientRect()
1426
+ const width = Math.max(280, Math.min(560, rect.width - 24))
1427
+ setAnchor({ top: Math.max(8, rect.top + 8), right: Math.max(8, window.innerWidth - rect.right + 12), width })
1428
+ }
1429
+ measure()
1430
+ const timer = setTimeout(() => {
1431
+ if (inputRef.current !== null) { inputRef.current.focus(); inputRef.current.select() }
1432
+ }, 0)
1433
+ const onResize = () => measure()
1434
+ window.addEventListener('resize', onResize)
1435
+ let observer = null
1436
+ const schedule = () => {
1437
+ clearTimeout(debounceRef.current)
1438
+ debounceRef.current = setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 400)
1439
+ }
1440
+ const scope = scopeRef.current
1441
+ if (scope !== null) {
1442
+ try {
1443
+ observer = new MutationObserver(schedule)
1444
+ observer.observe(scope, { childList: true, subtree: true, characterData: true })
1445
+ } catch { /* observer unavailable */ }
1446
+ }
1447
+ return () => {
1448
+ clearTimeout(timer)
1449
+ window.removeEventListener('resize', onResize)
1450
+ clearTimeout(debounceRef.current)
1451
+ if (observer !== null) observer.disconnect()
1452
+ for (const id of revealTimersRef.current) clearTimeout(id)
1453
+ revealTimersRef.current = []
1454
+ sequenceRef.current++
1455
+ clearFindHighlight('find')
1456
+ setFindScope(null)
1457
+ const target = returnFocusRef.current
1458
+ if (target !== null && target.isConnected) target.focus({ preventScroll: true })
1459
+ }
1460
+ }, [state.open])
1461
+
1462
+ useEffect(() => {
1463
+ if (!state.open) return undefined
1464
+ const timer = setTimeout(() => { void runScan(false) }, 150)
1465
+ return () => clearTimeout(timer)
1466
+ }, [state.open, state.query, state.mode, state.caseSensitive, state.wholeWord, state.scope, state.sessionId, state.selection, runScan])
1467
+
1468
+ // Close when the conversation is switched, hidden, or the panel opens.
1469
+ useEffect(() => {
1470
+ if (!state.open) return undefined
1471
+ const timer = setInterval(() => {
1472
+ if (!findStore.get().open) return
1473
+ if (store.isOpen()) { closeFind(); return }
1474
+ if (conversationScope(findStore.get().sessionId) === null) closeFind()
1475
+ }, 800)
1476
+ return () => clearInterval(timer)
1477
+ }, [state.open])
1478
+
1479
+ useEffect(() => {
1480
+ findApi.move = delta => move(delta)
1481
+ findApi.cancel = () => { sequenceRef.current++ }
1482
+ findApi.focus = () => { if (inputRef.current !== null) { inputRef.current.focus(); inputRef.current.select() } }
1483
+ return () => { findApi.move = () => false; findApi.cancel = () => {}; findApi.focus = () => {} }
1484
+ }, [move])
1485
+
1486
+ if (!state.open) return null
1487
+ const onInput = event => { findStore.set({ query: event.target.value, error: '', hint: '' }) }
1488
+ const onKeyDown = event => {
1489
+ if (composingRef.current || event.isComposing) return
1490
+ if (event.key === 'Enter') { event.preventDefault(); move(event.shiftKey ? -1 : 0); return }
1491
+ if (event.key === 'Escape') { event.preventDefault(); closeFind(); return }
1492
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && String(event.key).toLowerCase() === 'c') {
1493
+ const match = matchesRef.current[activeRef.current]
1494
+ const text = match && match.range.startContainer.isConnected ? match.range.toString() : findStore.get().query
1495
+ if (text !== '') { event.preventDefault(); copyText(text).then(() => findStore.set({ hint: t('findCopied') })).catch(() => {}) }
1496
+ }
1497
+ }
1498
+ const pickMode = event => { findStore.set({ mode: event.target.value }) }
1499
+ const toggle = key => { findStore.set({ [key]: !findStore.get()[key] }) }
1500
+ const toggleScope = () => {
1501
+ if (state.scope === 'selection') findStore.set({ scope: 'session', selection: null, hint: '' })
1502
+ else {
1503
+ const scope = scopeRef.current
1504
+ const selection = scope === null ? null : selectionSnapshot(scope)
1505
+ if (selection === null) { findStore.set({ hint: t('findPlaceholderSelection') }); return }
1506
+ findStore.set({ scope: 'selection', selection })
1507
+ }
1508
+ }
1509
+ const countText = state.query === '' ? '—'
1510
+ : (state.count === 0 ? '0/0' : (state.index < 0 ? '…' : (state.index + 1) + '/' + state.count + (state.capped ? '+' : '')))
1511
+ const history = scopeRef.current === null ? null : findHistoryButton(scopeRef.current)
1512
+ const canLoadOlder = history !== null && !history.disabled
1513
+ const loadOlder = () => {
1514
+ if (scopeRef.current === null || !revealOlder(scopeRef.current)) return
1515
+ findStore.set({ hint: t('findLoadingOlder') })
1516
+ setTimeout(() => { if (findStore.get().open) void runScanRef.current(true) }, 700)
1517
+ }
1518
+ return h('div', {
1519
+ ref: barRef,
1520
+ className: 'dsh-locator-findbar',
1521
+ role: 'search',
1522
+ 'aria-label': t('findAria'),
1523
+ style: anchor === null ? { top: 12, right: 12, width: 'min(560px,calc(100vw - 24px))' } : { top: anchor.top, right: anchor.right, width: anchor.width },
1524
+ },
1525
+ h('div', { className: 'dsh-locator-findbar-main' },
1526
+ h('span', { className: 'dsh-locator-findbar-glyph', 'aria-hidden': 'true' }, '⌕'),
1527
+ h('input', {
1528
+ ref: inputRef,
1529
+ className: 'dsh-locator-findbar-input',
1530
+ value: state.query,
1531
+ placeholder: state.scope === 'selection' ? t('findPlaceholderSelection') : t('findPlaceholder'),
1532
+ 'aria-label': t('findAria'),
1533
+ 'aria-invalid': state.error !== '' ? 'true' : undefined,
1534
+ spellCheck: false,
1535
+ autoComplete: 'off',
1536
+ onInput,
1537
+ onKeyDown,
1538
+ onCompositionStart: () => { composingRef.current = true },
1539
+ onCompositionEnd: () => { composingRef.current = false; void runScan(false) },
1540
+ }),
1541
+ h('span', { className: 'dsh-locator-findbar-count', 'aria-hidden': 'true' }, countText),
1542
+ h('span', { style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' },
1543
+ state.error !== '' ? state.error
1544
+ : state.query === '' ? ''
1545
+ : state.count === 0 ? t('findNoMatch')
1546
+ : (state.index + 1) + '/' + state.count),
1547
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findPrev'), 'aria-label': t('findPrev'),
1548
+ disabled: state.count === 0, onMouseDown: event => event.preventDefault(), onClick: () => move(-1) }, '↑'),
1549
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findNext'), 'aria-label': t('findNext'),
1550
+ disabled: state.count === 0, onMouseDown: event => event.preventDefault(), onClick: () => move(1) }, '↓'),
1551
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findClose'), 'aria-label': t('findClose'),
1552
+ onClick: () => closeFind() }, '×'),
1553
+ ),
1554
+ h('div', { className: 'dsh-locator-findbar-opts' },
1555
+ h('label', { className: 'dsh-locator-findbar-label' }, t('findMode'),
1556
+ h('select', { value: state.mode, onChange: pickMode, 'aria-label': t('findMode') },
1557
+ h('option', { value: 'normal' }, t('modeNormal')),
1558
+ h('option', { value: 'extended' }, t('modeExtended')),
1559
+ h('option', { value: 'regex' }, t('modeRegex')),
1560
+ )),
1561
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findCase'), 'aria-label': t('findCase'),
1562
+ 'aria-pressed': state.caseSensitive ? 'true' : 'false', onClick: () => toggle('caseSensitive') }, t('panelCaseShort')),
1563
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findWhole'), 'aria-label': t('findWhole'),
1564
+ 'aria-pressed': state.wholeWord ? 'true' : 'false', onClick: () => toggle('wholeWord') }, t('panelWholeShort')),
1565
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findWrap'), 'aria-label': t('findWrap'),
1566
+ 'aria-pressed': state.wrap ? 'true' : 'false', onClick: () => toggle('wrap') }, '↻'),
1567
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findReverse'), 'aria-label': t('findReverse'),
1568
+ 'aria-pressed': state.direction === -1 ? 'true' : 'false', onClick: () => findStore.set({ direction: state.direction === -1 ? 1 : -1 }) }, '⇅'),
1569
+ h('button', { className: 'dsh-locator-findbar-btn', title: t('findScope'), 'aria-label': t('findScope'),
1570
+ 'aria-pressed': state.scope === 'selection' ? 'true' : 'false', onClick: toggleScope }, '⛶'),
1571
+ state.scope === 'selection' && state.selection
1572
+ ? h('span', { className: 'dsh-locator-findbar-chip' }, t('findScopeChip', { n: state.selection.chars }),
1573
+ h('button', { onClick: () => findStore.set({ scope: 'session', selection: null, hint: '' }), 'aria-label': t('findClose') }, '×'))
1574
+ : h('span', { className: 'dsh-locator-findbar-chip' }, t('findSessionChip')),
1575
+ state.mode === 'extended' ? h('span', { className: 'dsh-locator-findbar-label', style: { opacity: .55 } }, t('findExtendedHelp')) : null,
1576
+ ),
1577
+ state.error !== '' ? h('div', { className: 'dsh-locator-findbar-error', role: 'alert' }, state.error) : null,
1578
+ h('div', { className: 'dsh-locator-findbar-status' },
1579
+ h('span', null, state.query === '' ? t('findEmpty') : state.count === 0 ? t('findNoMatch') : (state.capped ? t('findCapped') : '')),
1580
+ state.hint !== '' ? h('span', null, state.hint) : null,
1581
+ h('span', { className: 'grow' }),
1582
+ canLoadOlder && state.query !== '' && state.count === 0
1583
+ ? h('button', { className: 'dsh-locator-dirbtn', onClick: loadOlder }, t('findOlder'))
1584
+ : null,
1585
+ h('kbd', null, 'Enter'), h('span', null, '↓'),
1586
+ h('kbd', null, 'Shift+Enter'), h('span', null, '↑'),
1587
+ h('kbd', null, 'Esc'),
1588
+ ),
1589
+ )
1590
+ }
1591
+
800
1592
  function Panel() {
801
1593
  const open = useOpen()
802
1594
  const prefs = useMemo(readPrefs, [])
@@ -810,6 +1602,10 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
810
1602
  const [took, setTook] = useState(0)
811
1603
  const [status, setStatus] = useState(null)
812
1604
  const [availableContent, setAvailableContent] = useState(true)
1605
+ const [matchMode, setMatchMode] = useState(prefs.matchMode || 'normal')
1606
+ const [caseSensitive, setCaseSensitive] = useState(prefs.caseSensitive === true)
1607
+ const [wholeWord, setWholeWord] = useState(prefs.wholeWord === true)
1608
+ const [sessionOnly, setSessionOnly] = useState(false)
813
1609
  const [selected, setSelected] = useState(0)
814
1610
  const [toast, setToast] = useState('')
815
1611
  const [refreshTick, setRefreshTick] = useState(0)
@@ -819,14 +1615,20 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
819
1615
  const listRef = useRef(null)
820
1616
 
821
1617
  const close = useCallback(() => store.set(false), [])
822
- const anyScope = scopes.content || scopes.files || scopes.dirs
1618
+ const activeScope = conversationScope('')
1619
+ const activeSessionId = activeScope === null ? '' : (activeScope.getAttribute('data-conversation-session') || '')
1620
+ const effectiveScopes = sessionOnly
1621
+ ? { content: scopes.content, files: false, dirs: false }
1622
+ : { content: scopes.content, files: scopes.files, dirs: scopes.dirs }
1623
+ const anyScope = effectiveScopes.content || effectiveScopes.files || effectiveScopes.dirs
823
1624
 
824
1625
  useEffect(() => {
825
- writePrefs({ scopes, sort, desc })
826
- }, [scopes, sort, desc])
1626
+ writePrefs({ scopes, sort, desc, matchMode, caseSensitive, wholeWord })
1627
+ }, [scopes, sort, desc, matchMode, caseSensitive, wholeWord])
827
1628
 
828
1629
  useEffect(() => {
829
1630
  if (!open) return undefined
1631
+ closeFind()
830
1632
  const previous = document.activeElement
831
1633
  setError('')
832
1634
  const timer = setTimeout(() => {
@@ -879,13 +1681,18 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
879
1681
  setError('')
880
1682
  post('/dsh-locator/combined', {
881
1683
  query,
882
- content: scopes.content,
883
- files: scopes.files,
884
- dirs: scopes.dirs,
1684
+ content: effectiveScopes.content,
1685
+ files: effectiveScopes.files,
1686
+ dirs: effectiveScopes.dirs,
885
1687
  limit: PAGE_NAME,
886
1688
  contentLimit: PAGE_CONTENT,
887
1689
  sort,
888
1690
  desc,
1691
+ matchMode,
1692
+ caseSensitive,
1693
+ wholeWord,
1694
+ sessionId: sessionOnly ? activeSessionId : '',
1695
+ count: matchMode === 'regex' ? 'none' : 'exact',
889
1696
  }, controller.signal).then(data => {
890
1697
  if (controller.signal.aborted) return
891
1698
  setGroups({
@@ -903,7 +1710,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
903
1710
  })
904
1711
  }, 140)
905
1712
  return () => clearTimeout(timer)
906
- }, [open, query, sort, desc, scopes.content, scopes.files, scopes.dirs, refreshTick, anyScope])
1713
+ }, [open, query, sort, desc, scopes.content, scopes.files, scopes.dirs, matchMode, caseSensitive, wholeWord, sessionOnly, activeSessionId, refreshTick, anyScope, effectiveScopes.content, effectiveScopes.files, effectiveScopes.dirs])
907
1714
 
908
1715
  const contentBuilding = groups !== null && groups.content !== undefined && groups.content.building === true
909
1716
  useEffect(() => {
@@ -916,17 +1723,23 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
916
1723
  if (groups === null) return []
917
1724
  const out = []
918
1725
  for (const group of ['content', 'files', 'dirs']) {
919
- if (!scopes[group]) continue
1726
+ if (!effectiveScopes[group]) continue
920
1727
  const hits = groups[group] && Array.isArray(groups[group].hits) ? groups[group].hits : []
921
1728
  for (const hit of hits) out.push({ group, hit })
922
1729
  }
923
1730
  return out
924
- }, [groups, scopes])
1731
+ }, [groups, effectiveScopes.content, effectiveScopes.files, effectiveScopes.dirs])
925
1732
 
926
1733
  useEffect(() => {
927
1734
  setSelected(value => Math.min(Math.max(0, value), Math.max(0, rows.length - 1)))
928
1735
  }, [rows.length])
929
1736
 
1737
+ useEffect(() => {
1738
+ if (!open) return
1739
+ const row = document.getElementById('dsh-locator-opt-' + selected)
1740
+ if (row !== null) row.scrollIntoView({ block: 'nearest' })
1741
+ }, [open, selected, rows.length])
1742
+
930
1743
  const notify = useCallback(message => {
931
1744
  setToast(message)
932
1745
  setTimeout(() => setToast(''), 1600)
@@ -940,12 +1753,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
940
1753
  const reveal = useCallback(hit => {
941
1754
  if (!hit || loading) return
942
1755
  post('/dsh-locator/reveal', { path: hit.path }).then(() => notify(t('revealed'))).catch(() => copy(hit))
943
- }, [copy, notify])
1756
+ }, [copy, loading, notify])
944
1757
 
945
1758
  const openFile = useCallback(hit => {
946
1759
  if (!hit || hit.dir || loading) return
947
1760
  post('/dsh-locator/open', { path: hit.path }).then(() => notify(t('opened'))).catch(() => copy(hit))
948
- }, [copy, notify])
1761
+ }, [copy, loading, notify])
949
1762
 
950
1763
  const activate = useCallback(hit => {
951
1764
  if (!hit || loading) return
@@ -955,17 +1768,17 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
955
1768
  return
956
1769
  }
957
1770
  reveal(hit)
958
- }, [close, notify, query, reveal])
1771
+ }, [close, loading, notify, query, reveal])
959
1772
 
960
1773
  const insert = useCallback(hit => {
961
- if (!hit || hit.kind === 'session') return
1774
+ if (!hit || hit.kind === 'session' || loading) return
962
1775
  const mention = '@"' + hit.path.replace(/"/g, '') + '" '
963
1776
  close()
964
1777
  setTimeout(() => {
965
1778
  if (insertIntoComposer(mention)) notify(t('inserted'))
966
1779
  else copyText(mention).then(() => notify(t('insertFailed'))).catch(() => {})
967
1780
  }, 80)
968
- }, [close, notify])
1781
+ }, [close, loading, notify])
969
1782
 
970
1783
  const toggleScope = useCallback(key => {
971
1784
  if (key === 'content' && !availableContent) return
@@ -1001,6 +1814,11 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1001
1814
  toggleScope(scopeDigit === '1' ? 'content' : scopeDigit === '2' ? 'files' : 'dirs')
1002
1815
  return
1003
1816
  }
1817
+ if (event.key === 'F3') {
1818
+ event.preventDefault()
1819
+ if (rows.length > 0) setSelected(value => (value + (event.shiftKey ? -1 : 1) + rows.length) % rows.length)
1820
+ return
1821
+ }
1004
1822
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1005
1823
  event.preventDefault()
1006
1824
  setSelected(value => Math.max(0, Math.min(rows.length - 1, value + (event.key === 'ArrowDown' ? 1 : -1))))
@@ -1056,23 +1874,35 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1056
1874
  const content = groups !== null ? groups.content : undefined
1057
1875
  const files = groups !== null ? groups.files : undefined
1058
1876
  const dirs = groups !== null ? groups.dirs : undefined
1877
+ const countLabel = (group, fallback) => {
1878
+ const count = group && group.count
1879
+ if (count && typeof count.value === 'number' && Number.isFinite(count.value)) {
1880
+ return (count.kind === 'bounded' ? '≥ ' : '') + String(count.value)
1881
+ }
1882
+ return String(fallback)
1883
+ }
1059
1884
  const contentCount = content === undefined ? 0 : (content.returned ?? 0)
1060
1885
  const filesCount = files === undefined ? 0 : (files.returned ?? files.hits?.length ?? 0)
1061
1886
  const dirsCount = dirs === undefined ? 0 : (dirs.returned ?? dirs.hits?.length ?? 0)
1887
+ const contentCountText = content === undefined ? '' : countLabel(content, contentCount)
1888
+ const filesCountText = files === undefined ? '' : countLabel(files, filesCount)
1889
+ const dirsCountText = dirs === undefined ? '' : countLabel(dirs, dirsCount)
1062
1890
  const totalRows = rows.length
1063
1891
 
1064
1892
  const scopeBox = (key, label, count) => h('label', {
1065
- className: 'dsh-locator-check' + (key === 'content' && !availableContent ? ' disabled' : ''),
1066
- title: key === 'content' && !availableContent ? t('contentDisabled') : label,
1893
+ className: 'dsh-locator-check' + (key === 'content' && !availableContent ? ' disabled' : '')
1894
+ + (sessionOnly && key !== 'content' ? ' disabled' : ''),
1895
+ title: key === 'content' && !availableContent ? t('contentDisabled')
1896
+ : sessionOnly && key !== 'content' ? t('panelSessionHint') : label,
1067
1897
  },
1068
1898
  h('input', {
1069
1899
  type: 'checkbox',
1070
1900
  checked: scopes[key],
1071
- disabled: key === 'content' && !availableContent,
1901
+ disabled: (key === 'content' && !availableContent) || (sessionOnly && key !== 'content'),
1072
1902
  onChange: () => toggleScope(key),
1073
1903
  }),
1074
1904
  label,
1075
- count > 0 ? h('span', { className: 'dsh-locator-count' }, String(count)) : null,
1905
+ count ? h('span', { className: 'dsh-locator-count' }, count) : null,
1076
1906
  )
1077
1907
 
1078
1908
  const groupHead = (key, label, count, note) => h('div', {
@@ -1124,23 +1954,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1124
1954
  const hits = Array.isArray(group.hits) ? group.hits : []
1125
1955
  const count = typeof group.returned === 'number' ? group.returned : hits.length
1126
1956
  const note = key === 'content'
1127
- ? (contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
1128
- : (content?.capped ? t('contentCapped', { n: count }) : (query.trim() !== '' ? t('relevance') : null)))
1957
+ ? (matchMode === 'regex' ? t('panelRegexPaused')
1958
+ : contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
1959
+ : (content?.capped ? t('contentCapped', { n: count }) : (query.trim() !== '' ? t('relevance') : null)))
1129
1960
  : (group.deduped > 0 ? t('deduped', { n: group.deduped }) : (group.truncated ? t('showing', { n: hits.length }) : null))
1130
1961
  if (typeof group.error === 'string' && group.error !== '') {
1962
+ const message = group.error === 'content-regex-not-supported' ? t('panelRegexPaused') : group.error
1131
1963
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1132
1964
  groupHead(key, label, count, null),
1133
- h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + group.error,
1965
+ h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + message,
1134
1966
  h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
1135
1967
  )
1136
1968
  }
1137
1969
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1138
- groupHead(key, label, count, note),
1970
+ groupHead(key, label, countLabel(group, count), note),
1139
1971
  extra === undefined ? null : extra,
1140
1972
  hits.length === 0
1141
1973
  ? h('div', { className: 'dsh-locator-groupempty' },
1142
1974
  key === 'content' && scopes.content
1143
- ? (query.trim().length < 2 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
1975
+ ? (sessionOnly && query.trim().length >= 2 ? t('panelSessionHint')
1976
+ : query.trim().length < 2 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
1144
1977
  : (query.trim() === '' ? t('emptyQuery') : t('emptyResults')))
1145
1978
  : hits.map(hit => { index++; return rowFor(key, hit, index) }),
1146
1979
  )
@@ -1174,14 +2007,32 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1174
2007
  'aria-autocomplete': 'list',
1175
2008
  'aria-activedescendant': totalRows > 0 ? 'dsh-locator-opt-' + selected : undefined,
1176
2009
  }),
2010
+ h('span', { className: 'dsh-locator-count', 'aria-hidden': 'true' },
2011
+ loading ? t('loading') : totalRows > 0 ? t('panelTotal', { index: selected + 1, total: totalRows }) : ''),
1177
2012
  h('span', { className: 'dsh-locator-kbd' }, 'Ctrl+Shift+F'),
1178
2013
  h('button', { className: 'dsh-locator-close', onClick: close, title: t('closeTitle'), 'aria-label': t('closeLabel') }, '×'),
1179
2014
  ),
1180
2015
  h('div', { className: 'dsh-locator-bar' },
1181
- scopeBox('content', t('scopeContent'), contentCount),
1182
- scopeBox('files', t('scopeFiles'), filesCount),
1183
- scopeBox('dirs', t('scopeDirs'), dirsCount),
2016
+ scopeBox('content', t('scopeContent'), contentCountText),
2017
+ scopeBox('files', t('scopeFiles'), filesCountText),
2018
+ scopeBox('dirs', t('scopeDirs'), dirsCountText),
1184
2019
  h('span', { className: 'grow' }),
2020
+ h('label', { className: 'dsh-locator-check' + (activeSessionId === '' ? ' disabled' : ''),
2021
+ title: activeSessionId === '' ? t('findNoSession') : t('panelSessionHint') },
2022
+ h('input', { type: 'checkbox', checked: sessionOnly, disabled: activeSessionId === '',
2023
+ onChange: () => setSessionOnly(value => !value) }),
2024
+ t('panelRangeSession')),
2025
+ h('label', { className: 'dsh-locator-check' }, t('panelModeLabel'),
2026
+ h('select', { className: 'dsh-locator-sort', value: matchMode,
2027
+ onChange: event => setMatchMode(['normal', 'extended', 'regex'].includes(event.target.value) ? event.target.value : 'normal') },
2028
+ h('option', { value: 'normal' }, t('modeNormal')),
2029
+ h('option', { value: 'extended' }, t('modeExtended')),
2030
+ h('option', { value: 'regex' }, t('modeRegex')),
2031
+ )),
2032
+ h('button', { className: 'dsh-locator-dirbtn', title: t('findCase'),
2033
+ 'aria-pressed': caseSensitive ? 'true' : 'false', onClick: () => setCaseSensitive(value => !value) }, t('panelCaseShort')),
2034
+ h('button', { className: 'dsh-locator-dirbtn', title: t('findWhole'),
2035
+ 'aria-pressed': wholeWord ? 'true' : 'false', onClick: () => setWholeWord(value => !value) }, t('panelWholeShort')),
1185
2036
  h('label', { className: 'dsh-locator-check' }, t('sortLabel'),
1186
2037
  h('select', {
1187
2038
  className: 'dsh-locator-sort',
@@ -1209,6 +2060,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1209
2060
  roots: (status.roots || []).length,
1210
2061
  })),
1211
2062
  ),
2063
+ sessionOnly ? h('div', { className: 'dsh-locator-groupempty' }, t('panelSessionHint')) : null,
1212
2064
  h('div', { id: 'dsh-locator-results', className: 'dsh-locator-scroll', ref: listRef, role: 'listbox', 'aria-busy': loading ? 'true' : 'false' },
1213
2065
  error !== ''
1214
2066
  ? h('div', { className: 'dsh-locator-empty', role: 'alert' }, t('errorPrefix') + error,
@@ -1230,7 +2082,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1230
2082
  + (contentBuilding ? ' · ' + t('indexing') : '')),
1231
2083
  h('span', { className: 'grow' }),
1232
2084
  toast === '' ? null : h('span', { className: 'dsh-locator-toast' }, toast),
1233
- h('span', null, t('footer')),
2085
+ h('span', null, t('footer') + ' · Ctrl+F ' + t('findOpen')),
1234
2086
  ),
1235
2087
  h('div', { style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, role: 'status', 'aria-live': 'polite' },
1236
2088
  t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: Math.round(took) })),
@@ -1252,25 +2104,87 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1252
2104
  inject: ['slots', 'uiWorkspace'],
1253
2105
  apply(ctx) {
1254
2106
  workspaceService = ctx.uiWorkspace ?? (typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null)
1255
- ensureStyles()
2107
+ ctx.effect(() => {
2108
+ ensureStyles()
2109
+ return () => {
2110
+ const tag = document.querySelector('style[data-plugin-css="' + NS + '"]')
2111
+ if (tag !== null) tag.remove()
2112
+ }
2113
+ }, 'dsh-locator: styles')
1256
2114
  ctx.effect(() => {
1257
2115
  const onKey = event => {
1258
2116
  if ((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f') {
1259
2117
  event.preventDefault()
1260
2118
  event.stopPropagation()
2119
+ closeFind()
1261
2120
  store.toggle()
1262
2121
  }
1263
2122
  }
1264
2123
  document.addEventListener('keydown', onKey, true)
1265
2124
  return () => document.removeEventListener('keydown', onKey, true)
1266
2125
  }, 'dsh-locator: search hotkey')
1267
- ctx.effect(() => () => { cancelJump() }, 'dsh-locator: find highlight')
2126
+ ctx.effect(() => {
2127
+ const onFindKey = event => {
2128
+ if (event.defaultPrevented || event.isComposing) return
2129
+ const target = event.target
2130
+ if (target instanceof Element && target.closest('.xterm,.monaco-editor,[data-code-editor]') !== null) return
2131
+ const inFind = target instanceof Element && target.closest('.dsh-locator-findbar') !== null
2132
+ const ctrl = event.ctrlKey || event.metaKey
2133
+ if (ctrl && !event.shiftKey && !event.altKey && event.code === 'KeyF') {
2134
+ if (store.isOpen()) {
2135
+ event.preventDefault()
2136
+ event.stopPropagation()
2137
+ const panelInput = document.querySelector('.dsh-locator-input')
2138
+ if (panelInput !== null) { panelInput.focus(); panelInput.select() }
2139
+ return
2140
+ }
2141
+ if (document.querySelector('[role="dialog"][aria-modal="true"]') !== null && !inFind) return
2142
+ if (findStore.get().open) { event.preventDefault(); event.stopPropagation(); findApi.focus(); return }
2143
+ if (conversationScope('') === null) return
2144
+ event.preventDefault()
2145
+ event.stopPropagation()
2146
+ openFind({ selection: true })
2147
+ return
2148
+ }
2149
+ if (!ctrl && !event.altKey && event.code === 'F3') {
2150
+ if (!findStore.get().open) return
2151
+ event.preventDefault()
2152
+ event.stopPropagation()
2153
+ findApi.move(event.shiftKey ? -1 : 1)
2154
+ return
2155
+ }
2156
+ if (event.key === 'Escape' && findStore.get().open) {
2157
+ event.preventDefault()
2158
+ event.stopPropagation()
2159
+ closeFind()
2160
+ }
2161
+ }
2162
+ document.addEventListener('keydown', onFindKey, true)
2163
+ return () => document.removeEventListener('keydown', onFindKey, true)
2164
+ }, 'dsh-locator: find hotkeys')
2165
+ ctx.effect(() => () => { closeFind(); cancelJump() }, 'dsh-locator: find cleanup')
1268
2166
  try {
1269
- ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-panel', order: 60 }, Panel))
1270
- ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-notice', order: 61 }, Notice))
2167
+ ctx.slots.inject('shell.overlay', () => {
2168
+ const disposers = [
2169
+ ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-findbar', order: 59 }, FindBar),
2170
+ ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-panel', order: 60 }, Panel),
2171
+ ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-notice', order: 61 }, Notice),
2172
+ ]
2173
+ return () => {
2174
+ for (const dispose of disposers.reverse()) {
2175
+ if (typeof dispose === 'function') dispose()
2176
+ }
2177
+ }
2178
+ })
1271
2179
  } catch (error) {
1272
2180
  console.error('dsh-locator: overlay slot unavailable', error)
1273
2181
  }
2182
+ try {
2183
+ ctx.slots.inject('conversation.session.header.utilities', () =>
2184
+ ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-locator-find-toggle', order: 30 }, FindToggle))
2185
+ } catch (error) {
2186
+ console.error('dsh-locator: session header slot unavailable', error)
2187
+ }
1274
2188
  try {
1275
2189
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-locator-launch', order: 36 }, Launcher))
1276
2190
  } catch (error) {