dsh-locator 0.8.2 → 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,26 +554,39 @@ 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))
447
590
  }
448
591
 
449
592
  function isVisible(element) {
@@ -681,7 +824,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
681
824
  let activeHighlight = null
682
825
 
683
826
  function clearJumpHighlight() {
684
- clearFindHighlight()
827
+ clearFindHighlight('jump')
685
828
  if (activeHighlight !== null) {
686
829
  const clear = activeHighlight
687
830
  activeHighlight = null
@@ -735,7 +878,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
735
878
  }
736
879
  if (stale()) return false
737
880
  if (target === null) { showNotice(t('phraseNotFound')); return false }
738
- setFindHighlight(target.ranges)
881
+ setFindHighlight(target.ranges, 0, 'jump')
739
882
  centerRange(target.ranges[0])
740
883
  const start = target.ranges[0].startContainer
741
884
  const chain = scrollChain(start instanceof Element ? start : start.parentElement)
@@ -753,7 +896,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
753
896
  const fn = highlightCleanup.pop()
754
897
  try { fn() } catch { /* ignore */ }
755
898
  }
756
- clearFindHighlight()
899
+ clearFindHighlight('jump')
757
900
  }
758
901
  // Cancelling must drop the scroll lock too, not only the paint: the lock
759
902
  // owns timers and scroll/pointer listeners that would otherwise outlive
@@ -799,7 +942,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
799
942
  const next = finder()
800
943
  if (next === null) { cancelJump(); return }
801
944
  target = next
802
- setFindHighlight(target.ranges)
945
+ setFindHighlight(target.ranges, 0, 'jump')
803
946
  centerRange(target.ranges[0])
804
947
  }, 1_000)
805
948
  highlightCleanup.push(() => clearInterval(sessionWatch))
@@ -812,6 +955,640 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
812
955
  const PAGE_NAME = 50
813
956
  const PAGE_CONTENT = 30
814
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
+
815
1592
  function Panel() {
816
1593
  const open = useOpen()
817
1594
  const prefs = useMemo(readPrefs, [])
@@ -825,6 +1602,10 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
825
1602
  const [took, setTook] = useState(0)
826
1603
  const [status, setStatus] = useState(null)
827
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)
828
1609
  const [selected, setSelected] = useState(0)
829
1610
  const [toast, setToast] = useState('')
830
1611
  const [refreshTick, setRefreshTick] = useState(0)
@@ -834,14 +1615,20 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
834
1615
  const listRef = useRef(null)
835
1616
 
836
1617
  const close = useCallback(() => store.set(false), [])
837
- 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
838
1624
 
839
1625
  useEffect(() => {
840
- writePrefs({ scopes, sort, desc })
841
- }, [scopes, sort, desc])
1626
+ writePrefs({ scopes, sort, desc, matchMode, caseSensitive, wholeWord })
1627
+ }, [scopes, sort, desc, matchMode, caseSensitive, wholeWord])
842
1628
 
843
1629
  useEffect(() => {
844
1630
  if (!open) return undefined
1631
+ closeFind()
845
1632
  const previous = document.activeElement
846
1633
  setError('')
847
1634
  const timer = setTimeout(() => {
@@ -894,13 +1681,18 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
894
1681
  setError('')
895
1682
  post('/dsh-locator/combined', {
896
1683
  query,
897
- content: scopes.content,
898
- files: scopes.files,
899
- dirs: scopes.dirs,
1684
+ content: effectiveScopes.content,
1685
+ files: effectiveScopes.files,
1686
+ dirs: effectiveScopes.dirs,
900
1687
  limit: PAGE_NAME,
901
1688
  contentLimit: PAGE_CONTENT,
902
1689
  sort,
903
1690
  desc,
1691
+ matchMode,
1692
+ caseSensitive,
1693
+ wholeWord,
1694
+ sessionId: sessionOnly ? activeSessionId : '',
1695
+ count: matchMode === 'regex' ? 'none' : 'exact',
904
1696
  }, controller.signal).then(data => {
905
1697
  if (controller.signal.aborted) return
906
1698
  setGroups({
@@ -918,7 +1710,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
918
1710
  })
919
1711
  }, 140)
920
1712
  return () => clearTimeout(timer)
921
- }, [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])
922
1714
 
923
1715
  const contentBuilding = groups !== null && groups.content !== undefined && groups.content.building === true
924
1716
  useEffect(() => {
@@ -931,17 +1723,23 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
931
1723
  if (groups === null) return []
932
1724
  const out = []
933
1725
  for (const group of ['content', 'files', 'dirs']) {
934
- if (!scopes[group]) continue
1726
+ if (!effectiveScopes[group]) continue
935
1727
  const hits = groups[group] && Array.isArray(groups[group].hits) ? groups[group].hits : []
936
1728
  for (const hit of hits) out.push({ group, hit })
937
1729
  }
938
1730
  return out
939
- }, [groups, scopes])
1731
+ }, [groups, effectiveScopes.content, effectiveScopes.files, effectiveScopes.dirs])
940
1732
 
941
1733
  useEffect(() => {
942
1734
  setSelected(value => Math.min(Math.max(0, value), Math.max(0, rows.length - 1)))
943
1735
  }, [rows.length])
944
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
+
945
1743
  const notify = useCallback(message => {
946
1744
  setToast(message)
947
1745
  setTimeout(() => setToast(''), 1600)
@@ -1016,6 +1814,11 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1016
1814
  toggleScope(scopeDigit === '1' ? 'content' : scopeDigit === '2' ? 'files' : 'dirs')
1017
1815
  return
1018
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
+ }
1019
1822
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1020
1823
  event.preventDefault()
1021
1824
  setSelected(value => Math.max(0, Math.min(rows.length - 1, value + (event.key === 'ArrowDown' ? 1 : -1))))
@@ -1071,23 +1874,35 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1071
1874
  const content = groups !== null ? groups.content : undefined
1072
1875
  const files = groups !== null ? groups.files : undefined
1073
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
+ }
1074
1884
  const contentCount = content === undefined ? 0 : (content.returned ?? 0)
1075
1885
  const filesCount = files === undefined ? 0 : (files.returned ?? files.hits?.length ?? 0)
1076
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)
1077
1890
  const totalRows = rows.length
1078
1891
 
1079
1892
  const scopeBox = (key, label, count) => h('label', {
1080
- className: 'dsh-locator-check' + (key === 'content' && !availableContent ? ' disabled' : ''),
1081
- 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,
1082
1897
  },
1083
1898
  h('input', {
1084
1899
  type: 'checkbox',
1085
1900
  checked: scopes[key],
1086
- disabled: key === 'content' && !availableContent,
1901
+ disabled: (key === 'content' && !availableContent) || (sessionOnly && key !== 'content'),
1087
1902
  onChange: () => toggleScope(key),
1088
1903
  }),
1089
1904
  label,
1090
- count > 0 ? h('span', { className: 'dsh-locator-count' }, String(count)) : null,
1905
+ count ? h('span', { className: 'dsh-locator-count' }, count) : null,
1091
1906
  )
1092
1907
 
1093
1908
  const groupHead = (key, label, count, note) => h('div', {
@@ -1139,23 +1954,26 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1139
1954
  const hits = Array.isArray(group.hits) ? group.hits : []
1140
1955
  const count = typeof group.returned === 'number' ? group.returned : hits.length
1141
1956
  const note = key === 'content'
1142
- ? (contentBuilding ? t('contentBuilding', { files: content?.files ?? 0, messages: content?.messages ?? 0 })
1143
- : (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)))
1144
1960
  : (group.deduped > 0 ? t('deduped', { n: group.deduped }) : (group.truncated ? t('showing', { n: hits.length }) : null))
1145
1961
  if (typeof group.error === 'string' && group.error !== '') {
1962
+ const message = group.error === 'content-regex-not-supported' ? t('panelRegexPaused') : group.error
1146
1963
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1147
1964
  groupHead(key, label, count, null),
1148
- h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + group.error,
1965
+ h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + message,
1149
1966
  h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
1150
1967
  )
1151
1968
  }
1152
1969
  return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1153
- groupHead(key, label, count, note),
1970
+ groupHead(key, label, countLabel(group, count), note),
1154
1971
  extra === undefined ? null : extra,
1155
1972
  hits.length === 0
1156
1973
  ? h('div', { className: 'dsh-locator-groupempty' },
1157
1974
  key === 'content' && scopes.content
1158
- ? (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')))
1159
1977
  : (query.trim() === '' ? t('emptyQuery') : t('emptyResults')))
1160
1978
  : hits.map(hit => { index++; return rowFor(key, hit, index) }),
1161
1979
  )
@@ -1189,14 +2007,32 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1189
2007
  'aria-autocomplete': 'list',
1190
2008
  'aria-activedescendant': totalRows > 0 ? 'dsh-locator-opt-' + selected : undefined,
1191
2009
  }),
2010
+ h('span', { className: 'dsh-locator-count', 'aria-hidden': 'true' },
2011
+ loading ? t('loading') : totalRows > 0 ? t('panelTotal', { index: selected + 1, total: totalRows }) : ''),
1192
2012
  h('span', { className: 'dsh-locator-kbd' }, 'Ctrl+Shift+F'),
1193
2013
  h('button', { className: 'dsh-locator-close', onClick: close, title: t('closeTitle'), 'aria-label': t('closeLabel') }, '×'),
1194
2014
  ),
1195
2015
  h('div', { className: 'dsh-locator-bar' },
1196
- scopeBox('content', t('scopeContent'), contentCount),
1197
- scopeBox('files', t('scopeFiles'), filesCount),
1198
- scopeBox('dirs', t('scopeDirs'), dirsCount),
2016
+ scopeBox('content', t('scopeContent'), contentCountText),
2017
+ scopeBox('files', t('scopeFiles'), filesCountText),
2018
+ scopeBox('dirs', t('scopeDirs'), dirsCountText),
1199
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')),
1200
2036
  h('label', { className: 'dsh-locator-check' }, t('sortLabel'),
1201
2037
  h('select', {
1202
2038
  className: 'dsh-locator-sort',
@@ -1224,6 +2060,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1224
2060
  roots: (status.roots || []).length,
1225
2061
  })),
1226
2062
  ),
2063
+ sessionOnly ? h('div', { className: 'dsh-locator-groupempty' }, t('panelSessionHint')) : null,
1227
2064
  h('div', { id: 'dsh-locator-results', className: 'dsh-locator-scroll', ref: listRef, role: 'listbox', 'aria-busy': loading ? 'true' : 'false' },
1228
2065
  error !== ''
1229
2066
  ? h('div', { className: 'dsh-locator-empty', role: 'alert' }, t('errorPrefix') + error,
@@ -1245,7 +2082,7 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1245
2082
  + (contentBuilding ? ' · ' + t('indexing') : '')),
1246
2083
  h('span', { className: 'grow' }),
1247
2084
  toast === '' ? null : h('span', { className: 'dsh-locator-toast' }, toast),
1248
- h('span', null, t('footer')),
2085
+ h('span', null, t('footer') + ' · Ctrl+F ' + t('findOpen')),
1249
2086
  ),
1250
2087
  h('div', { style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, role: 'status', 'aria-live': 'polite' },
1251
2088
  t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: Math.round(took) })),
@@ -1267,22 +2104,69 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1267
2104
  inject: ['slots', 'uiWorkspace'],
1268
2105
  apply(ctx) {
1269
2106
  workspaceService = ctx.uiWorkspace ?? (typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null)
1270
- 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')
1271
2114
  ctx.effect(() => {
1272
2115
  const onKey = event => {
1273
2116
  if ((event.metaKey || event.ctrlKey) && event.shiftKey && String(event.key).toLowerCase() === 'f') {
1274
2117
  event.preventDefault()
1275
2118
  event.stopPropagation()
2119
+ closeFind()
1276
2120
  store.toggle()
1277
2121
  }
1278
2122
  }
1279
2123
  document.addEventListener('keydown', onKey, true)
1280
2124
  return () => document.removeEventListener('keydown', onKey, true)
1281
2125
  }, 'dsh-locator: search hotkey')
1282
- 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')
1283
2166
  try {
1284
2167
  ctx.slots.inject('shell.overlay', () => {
1285
2168
  const disposers = [
2169
+ ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-findbar', order: 59 }, FindBar),
1286
2170
  ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-panel', order: 60 }, Panel),
1287
2171
  ctx.slots.register({ name: 'shell.overlay', id: 'dsh-locator-notice', order: 61 }, Notice),
1288
2172
  ]
@@ -1295,6 +2179,12 @@ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask
1295
2179
  } catch (error) {
1296
2180
  console.error('dsh-locator: overlay slot unavailable', error)
1297
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
+ }
1298
2188
  try {
1299
2189
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-locator-launch', order: 36 }, Launcher))
1300
2190
  } catch (error) {