dsh-locator 0.7.7 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -26,6 +26,34 @@ window.__ModuleLoader__.load({
26
26
  }
27
27
  }
28
28
  const store = createStore()
29
+
30
+ // Transient feedback used after the panel closes (a jump keeps the panel
31
+ // closed, so the panel-local toast would never be seen).
32
+ function createNoticeStore() {
33
+ const listeners = new Set()
34
+ let text = ''
35
+ let timer = 0
36
+ return {
37
+ get: () => text,
38
+ subscribe(listener) {
39
+ listeners.add(listener)
40
+ return () => listeners.delete(listener)
41
+ },
42
+ show(next) {
43
+ text = next
44
+ for (const listener of listeners) listener()
45
+ clearTimeout(timer)
46
+ timer = setTimeout(() => { text = ''; for (const listener of listeners) listener() }, 3200)
47
+ },
48
+ }
49
+ }
50
+ const notices = createNoticeStore()
51
+ function showNotice(text) { notices.show(text) }
52
+ function Notice() {
53
+ const text = useSyncExternalStore(notices.subscribe, notices.get, notices.get)
54
+ return text === '' ? null : h('div', { className: 'dsh-locator-notice', role: 'status' }, text)
55
+ }
56
+
29
57
  function useOpen() {
30
58
  return useSyncExternalStore(store.subscribe, store.isOpen, store.isOpen)
31
59
  }
@@ -34,51 +62,70 @@ window.__ModuleLoader__.load({
34
62
  const NS = 'dsh-locator'
35
63
  const CSS = `
36
64
  .dsh-locator-overlay{position:fixed;inset:0;z-index:80;display:flex;align-items:flex-start;justify-content:center;
37
- padding:9vh 16px 16px;background:rgba(15,17,21,.38);backdrop-filter:blur(3px);color-scheme:light dark}
38
- .dsh-locator-panel{display:flex;flex-direction:column;width:min(960px,94vw);max-height:76vh;overflow:hidden;
39
- border-radius:14px;border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.28));
40
- background:var(--dsw-alias-bg-base,#fff);box-shadow:0 24px 64px rgba(0,0,0,.28);font:13px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
65
+ padding:max(9vh,64px) 16px 16px;background:var(--dsw-alias-bg-mask-1,rgba(15,17,21,.38));color-scheme:light dark}
66
+ .dsh-locator-panel{display:flex;flex-direction:column;width:min(920px,94vw);max-height:min(76vh,720px);overflow:hidden;
67
+ border-radius:var(--dsw-radius-lg,14px);border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.28));
68
+ background:var(--dsw-alias-bg-base,#fff);box-shadow:0 24px 64px rgba(0,0,0,.28);font:13px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;
69
+ animation:dsh-locator-in 120ms ease-out}
70
+ @keyframes dsh-locator-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
71
+ @media (prefers-reduced-motion:reduce){.dsh-locator-panel{animation:none}}
41
72
  .dsh-locator-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.2))}
42
73
  .dsh-locator-glyph{font-size:15px;opacity:.7}
43
74
  .dsh-locator-input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;font-size:14px;padding:2px 0}
44
75
  .dsh-locator-kbd{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.35));border-radius:6px;padding:1px 6px;font-size:11px;opacity:.6;white-space:nowrap}
45
76
  .dsh-locator-close{border:0;background:transparent;color:inherit;opacity:.55;cursor:pointer;font-size:16px;padding:2px 4px;border-radius:6px}
46
- .dsh-locator-close:hover{opacity:.9;background:rgba(120,125,135,.12)}
77
+ .dsh-locator-close:hover{opacity:.9;background:var(--dsw-alias-interactive-bg-hover,rgba(120,125,135,.12))}
47
78
  .dsh-locator-bar{display:flex;align-items:center;gap:12px;padding:7px 14px;border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.16));
48
- font-size:12px;opacity:.85;flex-wrap:wrap}
49
- .dsh-locator-check{display:inline-flex;align-items:center;gap:5px;cursor:pointer;user-select:none}
50
- .dsh-locator-status{margin-left:auto;opacity:.65;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%}
51
- .dsh-locator-scroll{overflow:auto;flex:1;min-height:120px}
52
- .dsh-locator-table{width:100%;border-collapse:collapse;table-layout:fixed}
53
- .dsh-locator-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-base,#fff);text-align:left;font-weight:500;font-size:11px;
54
- letter-spacing:.03em;text-transform:uppercase;opacity:.6;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.16));
55
- cursor:pointer;user-select:none;white-space:nowrap}
56
- .dsh-locator-table th:hover{opacity:.9}
57
- .dsh-locator-table th.sorted{opacity:1}
58
- .dsh-locator-table td{padding:5px 10px;border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.09));
59
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}
60
- .dsh-locator-row{cursor:pointer}
61
- .dsh-locator-row:hover td{background:rgba(120,125,135,.08)}
62
- .dsh-locator-row.sel td{background:rgba(64,116,255,.12)}
63
- .dsh-locator-name{display:flex;align-items:center;gap:7px;min-width:0}
64
- .dsh-locator-badge{flex:none;font-size:10px;padding:0 5px;border-radius:4px;background:rgba(120,125,135,.14);opacity:.8}
65
- .dsh-locator-hit{background:rgba(255,196,0,.35);border-radius:2px;color:inherit}
66
- .dsh-locator-path{opacity:.55;font-size:11.5px}
67
- .dsh-locator-size,.dsh-locator-time{opacity:.7;font-variant-numeric:tabular-nums;white-space:nowrap}
68
- .dsh-locator-empty{padding:36px 16px;text-align:center;opacity:.6}
69
- .dsh-locator-foot{display:flex;align-items:center;gap:10px;padding:7px 14px;border-top:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.16));
70
- font-size:11.5px;opacity:.7;flex-wrap:wrap}
79
+ font-size:12px;flex-wrap:wrap}
80
+ .dsh-locator-check{display:inline-flex;align-items:center;gap:5px;cursor:pointer;user-select:none;white-space:nowrap}
81
+ .dsh-locator-check.disabled{opacity:.45;cursor:not-allowed}
82
+ .dsh-locator-count{opacity:.55;font-variant-numeric:tabular-nums}
83
+ .dsh-locator-bar .grow{flex:1}
84
+ .dsh-locator-sort{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:11.5px;padding:2px 4px}
85
+ .dsh-locator-dirbtn{border:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.3));border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:11.5px;padding:2px 6px;cursor:pointer}
86
+ .dsh-locator-status{margin-left:auto;opacity:.65;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
87
+ .dsh-locator-scroll{overflow:auto;flex:1;min-height:160px}
88
+ .dsh-locator-group{display:block;border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.12))}
89
+ .dsh-locator-grouphead{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 14px;
90
+ font-size:11px;font-weight:500;letter-spacing:.03em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,rgba(120,125,135,.9));
91
+ background:var(--dsw-alias-menu-group-header-fill,var(--dsw-alias-bg-base,#fff));border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.1));
92
+ cursor:default}
93
+ .dsh-locator-grouphead .grow{flex:1}
94
+ .dsh-locator-groupempty{padding:10px 14px;font-size:12px;opacity:.55}
95
+ .dsh-locator-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;align-items:baseline;
96
+ padding:6px 14px;border-bottom:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.07));cursor:pointer;position:relative}
97
+ .dsh-locator-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(120,125,135,.08))}
98
+ .dsh-locator-row.sel{background:var(--dsw-alias-interactive-bg-active,rgba(64,116,255,.12))}
99
+ .dsh-locator-row.sel::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:#4f7cff}
100
+ .dsh-locator-rowcontent{padding-top:8px;padding-bottom:8px}
101
+ .dsh-locator-title{display:flex;align-items:baseline;gap:7px;min-width:0;font-size:13px}
102
+ .dsh-locator-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
103
+ .dsh-locator-badge{flex:none;font-size:10px;padding:0 5px;border-radius:4px;background:var(--dsw-alias-bg-layer-2,rgba(120,125,135,.14));opacity:.8;text-transform:uppercase;letter-spacing:.04em}
104
+ .dsh-locator-badge.chat{background:rgba(79,124,255,.16);color:var(--dsw-alias-brand-primary,#4f7cff)}
105
+ .dsh-locator-snippet{grid-column:1/-1;margin-top:2px;font-size:12.5px;opacity:.85;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.4}
106
+ .dsh-locator-path{grid-column:1/-1;font-size:11.5px;opacity:.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
107
+ .dsh-locator-meta{font-size:11.5px;opacity:.65;white-space:nowrap;font-variant-numeric:tabular-nums;text-align:right}
108
+ .dsh-locator-hit{background:rgba(245,158,11,.32);border-radius:2px;color:inherit}
109
+ .dsh-locator-empty{padding:36px 16px;text-align:center;opacity:.65;font-size:12.5px;white-space:pre-wrap}
110
+ .dsh-locator-empty .dsh-locator-retry{margin-left:8px}
111
+ .dsh-locator-foot{display:flex;align-items:center;gap:10px;padding:6px 14px;border-top:1px solid var(--dsw-alias-border-base,rgba(120,125,135,.16));
112
+ font-size:11px;opacity:.7;flex-wrap:wrap}
71
113
  .dsh-locator-foot .grow{flex:1}
72
- .dsh-locator-toast{color:#2e7d32}
73
- @media (prefers-color-scheme:dark){
74
- .dsh-locator-overlay{background:rgba(0,0,0,.55)}
75
- .dsh-locator-panel{box-shadow:0 24px 64px rgba(0,0,0,.6)}
76
- }
114
+ .dsh-locator-toast{color:var(--dsw-alias-state-success-primary,#2e7d32)}
77
115
  .dsh-locator-launch{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:0;border-radius:7px;
78
116
  background:transparent;color:inherit;opacity:.65;cursor:pointer;font-size:14px}
79
117
  .dsh-locator-launch:hover{opacity:1;background:rgba(120,125,135,.14)}
118
+ body[data-ds-dark-theme] .dsh-locator-panel{box-shadow:0 24px 64px rgba(0,0,0,.6)}
119
+ body[data-ds-dark-theme] .dsh-locator-overlay{background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.55))}
120
+ .dsh-locator-target{outline:2px solid rgba(245,158,11,.75);outline-offset:2px;border-radius:6px}
121
+ .dsh-locator-notice{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:90;pointer-events:none;
122
+ padding:7px 12px;border-radius:8px;font:12px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;
123
+ background:var(--dsw-alias-bg-layer-2,rgba(30,32,38,.92));color:var(--dsw-alias-label-primary,#fff);
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
+ 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}
80
128
  `
81
-
82
129
  function ensureStyles() {
83
130
  if (typeof document === 'undefined') return
84
131
  if (document.querySelector('style[data-plugin-css="' + NS + '"]') !== null) return
@@ -104,78 +151,132 @@ window.__ModuleLoader__.load({
104
151
  return data
105
152
  }
106
153
 
107
-
108
154
  // ── i18n ────────────────────────────────────────────────────────────────
109
155
  const STRINGS = {
110
156
  en: {
111
- contentMode: 'Content', sessionBadge: 'CHAT', jumped: 'Opened session',
112
- placeholder: 'Search file names or paths… e.g. ext:pdf size:>10mb dm:thisweek path:src !tmp',
157
+ scopeContent: 'Content', scopeFiles: 'Files', scopeDirs: 'Folders',
158
+ placeholder: 'Search names, paths, bodies or sessions… e.g. ext:pdf size:>10mb dm:thisweek path:src',
113
159
  searchLabel: 'Search files', dialog: 'Local file search', closeTitle: 'Close (Esc)', closeLabel: 'Close',
114
- filesOnly: 'Files only', dirsOnly: 'Directories only', statusLoading: 'Reading index status…',
115
- status: 'Index {entries} entries · {mode} · {roots} roots', live: 'live', snapshot: 'snapshot', usn: 'USN',
116
- sortTitle: 'Click to change sorting', name: 'Name', size: 'Size', mtime: 'Modified', path: 'Path',
117
- dirBadge: 'DIR', fileBadge: 'FILE', loading: 'Searching…', errorPrefix: 'Search failed: ', empty: 'No matching files',
118
- results: '{total} results', showing: 'showing first {n}', indexing: 'indexing, results may be incomplete',
119
- copied: 'Path copied', revealed: 'Revealed in file manager', opened: 'Opened', inserted: 'Inserted @file',
120
- insertFailed: 'Insert failed; @file copied', footer: '↑↓ select · Enter copy · Ctrl+O open · Ctrl+Enter insert @file · double-click reveal · Esc close',
160
+ sortLabel: 'Sort', sortMtime: 'Modified', sortName: 'Name', sortSize: 'Size', sortPath: 'Path',
161
+ asc: 'Ascending', descOrder: 'Descending',
162
+ groupContent: 'Content', groupFiles: 'Files', groupDirs: 'Folders',
163
+ relevance: 'by relevance', contentBuilding: 'indexing… {files} files / {messages} messages',
164
+ contentShort: 'Type at least 2 characters to search bodies and sessions',
165
+ contentDisabled: 'Content index is disabled (contentIndex: false)',
166
+ contentCapped: 'first {n}', showing: 'first {n}', deduped: '{n} name hits merged into content', loading: 'Searching…',
167
+ emptyQuery: 'Type to search file names, paths, bodies and sessions',
168
+ emptyResults: 'No matches · try fewer scopes or another keyword',
169
+ emptyScopes: 'Select at least one scope (Content, Files, Folders)',
170
+ errorPrefix: 'Search failed: ', retry: 'Retry', indexing: 'indexing, results may be incomplete',
171
+ statusLoading: 'Reading index status…', status: 'Index {entries} entries · {mode} · {roots} roots',
172
+ live: 'live', snapshot: 'snapshot', usn: 'USN',
173
+ copiedPath: 'Path copied', revealed: 'Revealed in file manager', opened: 'Opened', inserted: 'Inserted @file',
174
+ insertFailed: 'Insert failed; @file copied', jumped: 'Opened session', jumpFailed: 'Opened the session but could not locate the match',
175
+ footer: '↑↓ select · Enter open/jump · Ctrl+Shift+C copy path · Ctrl+O open · Alt+1–3 scopes · Esc close',
176
+ results: 'content {content} · files {files} · folders {dirs} · {ms} ms',
121
177
  launcher: 'Local file search (Ctrl+Shift+F)',
122
178
  justNow: 'just now', minutesAgo: '{n} min ago', hoursAgo: '{n} h ago',
179
+ phraseNotFound: 'No match in the conversation (it may not be loaded yet)',
123
180
  },
124
181
  zh: {
125
- contentMode: '搜内容', sessionBadge: '会话', jumped: '已打开会话',
126
- placeholder: '搜索文件名或路径… 支持 ext:pdf size:>10mb dm:thisweek path:src !tmp',
182
+ scopeContent: '内容', scopeFiles: '文件', scopeDirs: '目录',
183
+ placeholder: '搜文件名、路径、正文或会话… 支持 ext:pdf size:>10mb dm:thisweek path:src',
127
184
  searchLabel: '文件搜索', dialog: '本地文件搜索', closeTitle: '关闭 (Esc)', closeLabel: '关闭',
128
- filesOnly: '只看文件', dirsOnly: '只看目录', statusLoading: '索引状态读取中…',
129
- status: '索引 {entries} 条 · {mode} · {roots} 个根目录', live: '实时监听', snapshot: '快照', usn: 'USN',
130
- sortTitle: '点击切换排序', name: '名称', size: '大小', mtime: '修改时间', path: '路径',
131
- dirBadge: '目录', fileBadge: '文件', loading: '搜索中…', errorPrefix: '搜索失败:', empty: '没有匹配的文件',
132
- results: '{total} 条结果', showing: '显示前 {n} 条', indexing: '索引构建中,结果可能不完整',
133
- copied: '已复制路径', revealed: '已在文件管理器中定位', opened: '已打开', inserted: '已插入 @file',
134
- insertFailed: '插入失败,已复制 @file', footer: '↑↓ 选择 · Enter 复制 · Ctrl+O 打开 · Ctrl+Enter 插入 @file · 双击定位 · Esc 关闭',
185
+ sortLabel: '排序', sortMtime: '修改时间', sortName: '名称', sortSize: '大小', sortPath: '路径',
186
+ asc: '升序', descOrder: '降序',
187
+ groupContent: '内容', groupFiles: '文件', groupDirs: '目录',
188
+ relevance: '按相关度', contentBuilding: '索引构建中… {files} 文件 / {messages} 消息',
189
+ contentShort: '输入至少 2 个字符后搜索正文与会话',
190
+ contentDisabled: '内容索引已关闭(contentIndex: false)',
191
+ contentCapped: '前 {n} 条', showing: '前 {n} 条', deduped: '{n} 条文件命中已并入内容', loading: '搜索中…',
192
+ emptyQuery: '输入关键词:文件名、路径、正文或会话内容',
193
+ emptyResults: '没有匹配结果 · 可试试减少范围或换个关键词',
194
+ emptyScopes: '至少选择一个搜索范围(内容 / 文件 / 目录)',
195
+ errorPrefix: '搜索失败:', retry: '重试', indexing: '索引构建中,结果可能不完整',
196
+ statusLoading: '索引状态读取中…', status: '索引 {entries} 条 · {mode} · {roots} 个根目录',
197
+ live: '实时监听', snapshot: '快照', usn: 'USN',
198
+ copiedPath: '已复制路径', revealed: '已在文件管理器中定位', opened: '已打开', inserted: '已插入 @file',
199
+ insertFailed: '插入失败,已复制 @file', jumped: '已打开会话', jumpFailed: '已打开会话,但未定位到该消息',
200
+ footer: '↑↓ 选择 · Enter 打开/跳转 · Ctrl+Shift+C 复制路径 · Ctrl+O 打开 · Alt+1–3 切换范围 · Esc 关闭',
201
+ results: '内容 {content} · 文件 {files} · 目录 {dirs} · {ms} ms',
135
202
  launcher: '本地文件搜索 (Ctrl+Shift+F)',
136
203
  justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
204
+ phraseNotFound: '会话里没有找到匹配内容(可能还没加载)',
137
205
  },
138
206
  es: {
139
- contentMode: 'Contenido', sessionBadge: 'CHAT', jumped: 'Sesión abierta',
140
- placeholder: 'Busca nombres o rutas… p. ej. ext:pdf size:>10mb dm:thisweek path:src !tmp',
207
+ scopeContent: 'Contenido', scopeFiles: 'Archivos', scopeDirs: 'Carpetas',
208
+ placeholder: 'Busca nombres, rutas, contenido o sesiones… p. ej. ext:pdf size:>10mb dm:thisweek path:src',
141
209
  searchLabel: 'Buscar archivos', dialog: 'Búsqueda local de archivos', closeTitle: 'Cerrar (Esc)', closeLabel: 'Cerrar',
142
- filesOnly: 'Solo archivos', dirsOnly: 'Solo carpetas', statusLoading: 'Leyendo el estado del índice…',
143
- status: 'Índice: {entries} entradas · {mode} · {roots} raíces', live: 'en vivo', snapshot: 'instantánea', usn: 'USN',
144
- sortTitle: 'Clic para cambiar el orden', name: 'Nombre', size: 'Tamaño', mtime: 'Modificado', path: 'Ruta',
145
- dirBadge: 'DIR', fileBadge: 'ARCH', loading: 'Buscando…', errorPrefix: 'Error de búsqueda: ', empty: 'Sin coincidencias',
146
- results: '{total} resultados', showing: 'mostrando {n}', indexing: 'indexando, resultados incompletos',
147
- copied: 'Ruta copiada', revealed: 'Mostrado en el gestor de archivos', opened: 'Abierto', inserted: '@file insertado',
148
- insertFailed: 'No se pudo insertar; @file copiado', footer: '↑↓ elegir · Enter copiar · Ctrl+O abrir · Ctrl+Enter insertar @file · doble clic revelar · Esc cerrar',
210
+ sortLabel: 'Orden', sortMtime: 'Modificado', sortName: 'Nombre', sortSize: 'Tamaño', sortPath: 'Ruta',
211
+ asc: 'Ascendente', descOrder: 'Descendente',
212
+ groupContent: 'Contenido', groupFiles: 'Archivos', groupDirs: 'Carpetas',
213
+ relevance: 'por relevancia', contentBuilding: 'indexando… {files} archivos / {messages} mensajes',
214
+ contentShort: 'Escribe al menos 2 caracteres para buscar contenido y sesiones',
215
+ contentDisabled: 'Índice de contenido desactivado (contentIndex: false)',
216
+ contentCapped: 'primeros {n}', showing: 'primeros {n}', deduped: '{n} aciertos de nombre fusionados', loading: 'Buscando…',
217
+ emptyQuery: 'Escribe para buscar nombres, rutas, contenido y sesiones',
218
+ emptyResults: 'Sin coincidencias · prueba menos ámbitos u otra palabra',
219
+ emptyScopes: 'Elige al menos un ámbito (Contenido, Archivos, Carpetas)',
220
+ errorPrefix: 'Error de búsqueda: ', retry: 'Reintentar', indexing: 'indexando, resultados incompletos',
221
+ statusLoading: 'Leyendo el estado del índice…', status: 'Índice: {entries} entradas · {mode} · {roots} raíces',
222
+ live: 'en vivo', snapshot: 'instantánea', usn: 'USN',
223
+ copiedPath: 'Ruta copiada', revealed: 'Mostrado en el gestor de archivos', opened: 'Abierto', inserted: '@file insertado',
224
+ insertFailed: 'No se pudo insertar; @file copiado', jumped: 'Sesión abierta', jumpFailed: 'Sesión abierta, pero no se localizó el mensaje',
225
+ footer: '↑↓ elegir · Enter abrir/ir · Ctrl+Shift+C copiar ruta · Ctrl+O abrir · Alt+1–3 ámbitos · Esc cerrar',
226
+ results: 'contenido {content} · archivos {files} · carpetas {dirs} · {ms} ms',
149
227
  launcher: 'Búsqueda local (Ctrl+Shift+F)',
150
228
  justNow: 'ahora', minutesAgo: 'hace {n} min', hoursAgo: 'hace {n} h',
229
+ phraseNotFound: 'No se encontró en la conversación (quizá no está cargada)',
151
230
  },
152
231
  pt: {
153
- contentMode: 'Conteúdo', sessionBadge: 'CHAT', jumped: 'Sessão aberta',
154
- placeholder: 'Busque nomes ou caminhos… ex. ext:pdf size:>10mb dm:thisweek path:src !tmp',
232
+ scopeContent: 'Conteúdo', scopeFiles: 'Arquivos', scopeDirs: 'Pastas',
233
+ placeholder: 'Busque nomes, caminhos, conteúdo ou sessões… ex. ext:pdf size:>10mb dm:thisweek path:src',
155
234
  searchLabel: 'Buscar arquivos', dialog: 'Busca local de arquivos', closeTitle: 'Fechar (Esc)', closeLabel: 'Fechar',
156
- filesOnly: 'Somente arquivos', dirsOnly: 'Somente pastas', statusLoading: 'Lendo o estado do índice…',
157
- status: 'Índice: {entries} entradas · {mode} · {roots} raízes', live: 'ao vivo', snapshot: 'instantâneo', usn: 'USN',
158
- sortTitle: 'Clique para mudar a ordem', name: 'Nome', size: 'Tamanho', mtime: 'Modificado', path: 'Caminho',
159
- dirBadge: 'DIR', fileBadge: 'ARQ', loading: 'Buscando…', errorPrefix: 'Falha na busca: ', empty: 'Nenhum resultado',
160
- results: '{total} resultados', showing: 'mostrando {n}', indexing: 'indexando, resultados incompletos',
161
- copied: 'Caminho copiado', revealed: 'Mostrado no gerenciador de arquivos', opened: 'Aberto', inserted: '@file inserido',
162
- insertFailed: 'Falha ao inserir; @file copiado', footer: '↑↓ escolher · Enter copiar · Ctrl+O abrir · Ctrl+Enter inserir @file · clique duplo revelar · Esc fechar',
235
+ sortLabel: 'Ordem', sortMtime: 'Modificado', sortName: 'Nome', sortSize: 'Tamanho', sortPath: 'Caminho',
236
+ asc: 'Ascendente', descOrder: 'Descendente',
237
+ groupContent: 'Conteúdo', groupFiles: 'Arquivos', groupDirs: 'Pastas',
238
+ relevance: 'por relevância', contentBuilding: 'indexando… {files} arquivos / {messages} mensagens',
239
+ contentShort: 'Digite pelo menos 2 caracteres para buscar conteúdo e sessões',
240
+ contentDisabled: 'Índice de conteúdo desativado (contentIndex: false)',
241
+ contentCapped: 'primeiros {n}', showing: 'primeiros {n}', deduped: '{n} acertos de nome mesclados', loading: 'Buscando…',
242
+ emptyQuery: 'Digite para buscar nomes, caminhos, conteúdo e sessões',
243
+ emptyResults: 'Sem resultados · tente menos escopos ou outra palavra',
244
+ emptyScopes: 'Escolha pelo menos um escopo (Conteúdo, Arquivos, Pastas)',
245
+ errorPrefix: 'Falha na busca: ', retry: 'Tentar novamente', indexing: 'indexando, resultados incompletos',
246
+ statusLoading: 'Lendo o estado do índice…', status: 'Índice: {entries} entradas · {mode} · {roots} raízes',
247
+ live: 'ao vivo', snapshot: 'instantâneo', usn: 'USN',
248
+ copiedPath: 'Caminho copiado', revealed: 'Mostrado no gerenciador de arquivos', opened: 'Aberto', inserted: '@file inserido',
249
+ insertFailed: 'Falha ao inserir; @file copiado', jumped: 'Sessão aberta', jumpFailed: 'Sessão aberta, mas a mensagem não foi localizada',
250
+ footer: '↑↓ escolher · Enter abrir/ir · Ctrl+Shift+C copiar caminho · Ctrl+O abrir · Alt+1–3 escopos · Esc fechar',
251
+ results: 'conteúdo {content} · arquivos {files} · pastas {dirs} · {ms} ms',
163
252
  launcher: 'Busca local (Ctrl+Shift+F)',
164
253
  justNow: 'agora', minutesAgo: 'há {n} min', hoursAgo: 'há {n} h',
254
+ phraseNotFound: 'Não encontrado na conversa (talvez ainda não carregada)',
165
255
  },
166
256
  hi: {
167
- contentMode: 'सामग्री', sessionBadge: 'चैट', jumped: 'सत्र खुला',
168
- placeholder: 'फ़ाइल नाम या पथ खोजें… जैसे ext:pdf size:>10mb dm:thisweek path:src !tmp',
257
+ scopeContent: 'सामग्री', scopeFiles: 'फ़ाइलें', scopeDirs: 'फ़ोल्डर',
258
+ placeholder: 'नाम, पथ, सामग्री या सत्र खोजें… जैसे ext:pdf size:>10mb dm:thisweek path:src',
169
259
  searchLabel: 'फ़ाइल खोज', dialog: 'स्थानीय फ़ाइल खोज', closeTitle: 'बंद करें (Esc)', closeLabel: 'बंद करें',
170
- filesOnly: 'केवल फ़ाइलें', dirsOnly: 'केवल फ़ोल्डर', statusLoading: 'इंडेक्स स्थिति पढ़ी जा रही है…',
171
- status: 'इंडेक्स {entries} प्रविष्टियाँ · {mode} · {roots} रूट', live: 'लाइव', snapshot: 'स्नैपशॉट', usn: 'USN',
172
- sortTitle: 'क्रम बदलने के लिए क्लिक करें', name: 'नाम', size: 'आकार', mtime: 'संशोधित', path: 'पथ',
173
- dirBadge: 'DIR', fileBadge: 'FILE', loading: 'खोज रहे हैं…', errorPrefix: 'खोज विफल: ', empty: 'कोई मेल नहीं',
174
- results: '{total} परिणाम', showing: 'पहले {n} दिखाए', indexing: 'इंडेक्स बन रहा है, परिणाम अधूरे हो सकते हैं',
175
- copied: 'पथ कॉपी हुआ', revealed: 'फ़ाइल मैनेजर में दिखाया', opened: 'खोला गया', inserted: '@file डाला गया',
176
- insertFailed: 'डालना विफल; @file कॉपी हुआ', footer: '↑↓ चुनें · Enter कॉपी · Ctrl+O खोलें · Ctrl+Enter @file डालें · डबल-क्लिक दिखाएँ · Esc बंद',
260
+ sortLabel: 'क्रम', sortMtime: 'संशोधित', sortName: 'नाम', sortSize: 'आकार', sortPath: 'पथ',
261
+ asc: 'आरोही', descOrder: 'अवरोही',
262
+ groupContent: 'सामग्री', groupFiles: 'फ़ाइलें', groupDirs: 'फ़ोल्डर',
263
+ relevance: 'प्रासंगिकता से', contentBuilding: 'इंडेक्स बन रहा है… {files} फ़ाइलें / {messages} संदेश',
264
+ contentShort: 'सामग्री और सत्र खोजने के लिए कम से कम 2 अक्षर लिखें',
265
+ contentDisabled: 'सामग्री इंडेक्स बंद है (contentIndex: false)',
266
+ contentCapped: 'पहले {n}', showing: 'पहले {n}', deduped: '{n} नाम-परिणाम सामग्री में मिलाए', loading: 'खोज रहे हैं…',
267
+ emptyQuery: 'नाम, पथ, सामग्री और सत्र खोजने के लिए लिखें',
268
+ emptyResults: 'कोई मेल नहीं · कम क्षेत्र या दूसरा शब्द आज़माएँ',
269
+ emptyScopes: 'कम से कम एक क्षेत्र चुनें (सामग्री, फ़ाइलें, फ़ोल्डर)',
270
+ errorPrefix: 'खोज विफल: ', retry: 'पुन: प्रयास', indexing: 'इंडेक्स बन रहा है, परिणाम अधूरे हो सकते हैं',
271
+ statusLoading: 'इंडेक्स स्थिति पढ़ी जा रही है…', status: 'इंडेक्स {entries} प्रविष्टियाँ · {mode} · {roots} रूट',
272
+ live: 'लाइव', snapshot: 'स्नैपशॉट', usn: 'USN',
273
+ copiedPath: 'पथ कॉपी हुआ', revealed: 'फ़ाइल मैनेजर में दिखाया', opened: 'खोला गया', inserted: '@file डाला गया',
274
+ insertFailed: 'डालना विफल; @file कॉपी हुआ', jumped: 'सत्र खुला', jumpFailed: 'सत्र खुला, पर संदेश नहीं मिला',
275
+ footer: '↑↓ चुनें · Enter खोलें/जाएँ · Ctrl+Shift+C पथ कॉपी · Ctrl+O खोलें · Alt+1–3 क्षेत्र · Esc बंद',
276
+ results: 'सामग्री {content} · फ़ाइलें {files} · फ़ोल्डर {dirs} · {ms} ms',
177
277
  launcher: 'स्थानीय फ़ाइल खोज (Ctrl+Shift+F)',
178
278
  justNow: 'अभी', minutesAgo: '{n} मिनट पहले', hoursAgo: '{n} घंटे पहले',
279
+ phraseNotFound: 'बातचीत में नहीं मिला (शायद अभी लोड नहीं हुआ)',
179
280
  },
180
281
  }
181
282
  const LANG = (() => {
@@ -202,9 +303,7 @@ window.__ModuleLoader__.load({
202
303
  }
203
304
 
204
305
  function copyText(text) {
205
- if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
206
- return navigator.clipboard.writeText(text)
207
- }
306
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') return navigator.clipboard.writeText(text)
208
307
  return new Promise((resolve, reject) => {
209
308
  const area = document.createElement('textarea')
210
309
  area.value = text
@@ -234,7 +333,6 @@ window.__ModuleLoader__.load({
234
333
  return document.execCommand('insertText', false, text)
235
334
  }
236
335
 
237
- // ── helpers ─────────────────────────────────────────────────────────────
238
336
  function formatSize(bytes) {
239
337
  if (!bytes) return ''
240
338
  if (bytes < 1024) return bytes + ' B'
@@ -262,39 +360,117 @@ window.__ModuleLoader__.load({
262
360
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
263
361
  }
264
362
 
265
- function highlight(name, query) {
266
- const words = query.split(/\s+/).filter(word => word !== '' && !word.includes(':') && !word.startsWith('!') && !word.includes('*') && !word.includes('?'))
267
- if (words.length === 0) return name
363
+ function queryWords(query) {
364
+ const words = String(query || '').split(/\s+/).filter(word => word !== ''
365
+ && !word.includes(':') && !word.startsWith('!') && !word.includes('*') && !word.includes('?'))
366
+ return words.map(word => word.replace(/^"|"$/g, '')).filter(word => word.length > 0)
367
+ }
368
+
369
+ function highlight(text, query) {
370
+ const words = queryWords(query)
371
+ if (words.length === 0) return text
268
372
  try {
269
373
  const pattern = new RegExp('(' + words.map(escapeRegExp).join('|') + ')', 'gi')
270
- const parts = name.split(pattern)
374
+ const parts = String(text).split(pattern)
271
375
  return parts.map((part, index) => (index % 2 === 1 ? h('mark', { className: 'dsh-locator-hit', key: index }, part) : part))
272
376
  } catch {
273
- return name
377
+ return text
274
378
  }
275
379
  }
276
380
 
277
- // ── panel ───────────────────────────────────────────────────────────────
278
- let workspaceService = null
381
+ // ── panel preferences ───────────────────────────────────────────────────
382
+ const PREFS_KEY = 'dsh-locator.panel.v1'
383
+ const SORTS = ['mtime', 'name', 'size', 'path']
384
+ function readPrefs() {
385
+ const fallback = { scopes: { content: true, files: true, dirs: true }, sort: 'mtime', desc: true }
386
+ try {
387
+ const raw = window.localStorage.getItem(PREFS_KEY)
388
+ if (raw === null) return fallback
389
+ const value = JSON.parse(raw)
390
+ if (typeof value !== 'object' || value === null) return fallback
391
+ const scopes = value.scopes
392
+ return {
393
+ scopes: {
394
+ content: typeof scopes?.content === 'boolean' ? scopes.content : true,
395
+ files: typeof scopes?.files === 'boolean' ? scopes.files : true,
396
+ dirs: typeof scopes?.dirs === 'boolean' ? scopes.dirs : true,
397
+ },
398
+ sort: SORTS.includes(value.sort) ? value.sort : 'mtime',
399
+ desc: typeof value.desc === 'boolean' ? value.desc : true,
400
+ }
401
+ } catch {
402
+ return fallback
403
+ }
404
+ }
405
+ function writePrefs(prefs) {
406
+ try { window.localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch { /* storage unavailable */ }
407
+ }
408
+
409
+ // ── find highlight: CSS Custom Highlight API ────────────────────────────
410
+ const HL_BASE = 'dsh-locator-find'
411
+ const HL_ACTIVE = 'dsh-locator-find-active'
412
+ const GESTURES = ['wheel', 'touchstart', 'pointerdown', 'keydown']
413
+ const EXCLUDED = '[hidden],[data-chat-history],[data-text-interaction-surface],[data-composer-seat],script,style'
414
+ const BLOCK_SELECTOR = 'p,li,pre,td,th,h1,h2,h3,h4,h5,h6,blockquote,dd,dt'
415
+ const legacyTargets = new Set()
416
+
417
+ function highlightsAvailable() {
418
+ return typeof Highlight === 'function' && typeof CSS !== 'undefined' && CSS !== null
419
+ && typeof CSS.highlights === 'object' && CSS.highlights !== null
420
+ }
421
+
422
+ function clearLegacyTargets() {
423
+ for (const element of legacyTargets) element.classList.remove('dsh-locator-target')
424
+ legacyTargets.clear()
425
+ }
426
+
427
+ function clearFindHighlight() {
428
+ clearLegacyTargets()
429
+ if (!highlightsAvailable()) return
430
+ CSS.highlights.delete(HL_BASE)
431
+ CSS.highlights.delete(HL_ACTIVE)
432
+ }
433
+
434
+ function setFindHighlight(ranges) {
435
+ 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
+ }
442
+ return
443
+ }
444
+ if (ranges.length === 0) return
445
+ CSS.highlights.set(HL_BASE, new Highlight(...ranges))
446
+ CSS.highlights.set(HL_ACTIVE, new Highlight(ranges[0]))
447
+ }
279
448
 
280
- // ── chat anchors ────────────────────────────────────────────────────────
281
- // The Chat flow renders one `[data-chat-node-key]` row per node and the key
282
- // of a human input embeds the message id the content index stored
283
- // (`"<n>:input-message<messageId>"`). Anchor on that id first: it lands on
284
- // the exact message instead of whichever copy of the words comes first in
285
- // the document (the sidebar title, for example).
449
+ function conversationScope(sessionId) {
450
+ const id = sessionId === undefined || sessionId === null ? '' : String(sessionId)
451
+ let embedded = null
452
+ for (const element of document.querySelectorAll('[data-conversation-content][data-conversation-session]')) {
453
+ if (id !== '' && element.getAttribute('data-conversation-session') !== id) continue
454
+ if (element.closest('[data-sidebar-chat]') === null) return element
455
+ embedded = element
456
+ }
457
+ return embedded
458
+ }
286
459
 
287
- function chatScope() {
288
- return document.querySelector('[data-conversation-scroll]')
289
- || document.querySelector('[data-slot="conversation.chat.flow"]')
290
- || null
460
+ function chatFlow(scope) {
461
+ return scope === null ? null : scope.querySelector('[data-chat-flow]')
291
462
  }
292
463
 
293
- function findMessageNode(messageId) {
464
+ function findMessageRow(flow, messageId, nodeKey) {
465
+ if (flow === null) return null
466
+ const rows = flow.querySelectorAll('[data-chat-node-key]')
467
+ if (nodeKey) {
468
+ for (const row of rows) {
469
+ if (row.getAttribute('data-chat-node-key') === String(nodeKey)) return row
470
+ }
471
+ }
294
472
  if (!messageId) return null
295
473
  const id = String(messageId)
296
- if (id === '') return null
297
- const rows = document.querySelectorAll('[data-chat-node-key]')
298
474
  for (const row of rows) {
299
475
  const key = row.getAttribute('data-chat-node-key') || ''
300
476
  if (key === id || key.endsWith(id)) return row
@@ -302,32 +478,75 @@ window.__ModuleLoader__.load({
302
478
  return null
303
479
  }
304
480
 
305
- function markElement(element) {
306
- if (!element || typeof element.scrollIntoView !== 'function') return false
307
- element.scrollIntoView({ block: 'center' })
308
- const previous = element.style.outline
309
- const previousOffset = element.style.outlineOffset
310
- const previousRadius = element.style.borderRadius
311
- element.style.outline = '2px solid #4f7cff'
312
- element.style.outlineOffset = '3px'
313
- element.style.borderRadius = '8px'
314
- clearTimeout(element.__dshLocatorMark)
315
- element.__dshLocatorMark = setTimeout(() => {
316
- element.style.outline = previous
317
- element.style.outlineOffset = previousOffset
318
- element.style.borderRadius = previousRadius
319
- }, 2400)
320
- return true
481
+ function needlesFromQuery(query) {
482
+ const raw = String(query || '').trim()
483
+ const words = queryWords(raw).filter(word => word.length >= 1)
484
+ if (words.length === 0 && raw.length >= 2) return [raw]
485
+ return [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 8)
486
+ }
487
+
488
+ function inlineGroups(root) {
489
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
490
+ const groups = new Map()
491
+ let node = walker.nextNode()
492
+ while (node !== null) {
493
+ const parent = node.parentElement
494
+ if (parent !== null && parent.closest(EXCLUDED) === null) {
495
+ const value = node.nodeValue || ''
496
+ if (value !== '') {
497
+ const block = parent.closest(BLOCK_SELECTOR) || root
498
+ let group = groups.get(block)
499
+ if (group === undefined) { group = { nodes: [], text: '' }; groups.set(block, group) }
500
+ group.nodes.push(node)
501
+ group.text += value
502
+ }
503
+ }
504
+ node = walker.nextNode()
505
+ }
506
+ return [...groups.values()]
507
+ }
508
+
509
+ function offsetInGroup(group, index) {
510
+ let consumed = 0
511
+ for (const node of group.nodes) {
512
+ const length = (node.nodeValue || '').length
513
+ if (index <= consumed + length) return [node, index - consumed]
514
+ consumed += length
515
+ }
516
+ const last = group.nodes[group.nodes.length - 1]
517
+ return [last, (last.nodeValue || '').length]
518
+ }
519
+
520
+ function rangesForNeedles(root, needles) {
521
+ const ranges = []
522
+ if (root === null || needles.length === 0) return ranges
523
+ for (const group of inlineGroups(root)) {
524
+ const lower = group.text.toLowerCase()
525
+ for (const needle of needles) {
526
+ const target = needle.toLowerCase()
527
+ let from = 0
528
+ while (ranges.length < 200) {
529
+ const index = lower.indexOf(target, from)
530
+ if (index < 0) break
531
+ const start = offsetInGroup(group, index)
532
+ const end = offsetInGroup(group, index + target.length)
533
+ const range = document.createRange()
534
+ range.setStart(start[0], start[1])
535
+ range.setEnd(end[0], end[1])
536
+ ranges.push(range)
537
+ from = index + target.length
538
+ }
539
+ }
540
+ }
541
+ return ranges
542
+ }
543
+
544
+ function normalizePhrase(text) {
545
+ return String(text || '').replace(/[\u0001\u0002\u2026]/g, ' ').replace(/\s+/g, ' ').trim()
321
546
  }
322
547
 
323
- // Progressively shorter fallbacks: a FTS snippet may be cut mid-word or
324
- // separated by ellipses, so try the whole phrase before the leading words.
325
548
  function phraseCandidates(snippet) {
326
- const clean = String(snippet || '')
327
- .replace(/[\u0001\u0002]/g, ' ')
328
- .replace(/…/g, ' ')
329
- .replace(/\s+/g, ' ')
330
- .trim()
549
+ const clean = normalizePhrase(snippet)
331
550
  const out = []
332
551
  const push = value => {
333
552
  const text = String(value || '').trim()
@@ -346,65 +565,280 @@ window.__ModuleLoader__.load({
346
565
  return out
347
566
  }
348
567
 
349
- function findPhraseNode(scope, phrase) {
350
- if (!scope || phrase.length < 2) return null
351
- const needle = phrase.toLowerCase()
352
- const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT)
353
- let node
354
- while ((node = walker.nextNode())) {
355
- const text = node.nodeValue || ''
356
- if (text.toLowerCase().indexOf(needle) < 0) continue
357
- // The scope is the conversation transcript, so the first match already
358
- // belongs to the bubble that contains the phrase.
359
- const parent = node.parentElement
360
- if (parent) return parent
568
+ function phraseRange(root, snippet) {
569
+ if (root === null) return null
570
+ for (const phrase of phraseCandidates(snippet)) {
571
+ const needle = phrase.toLowerCase()
572
+ for (const group of inlineGroups(root)) {
573
+ const index = group.text.toLowerCase().indexOf(needle)
574
+ if (index < 0) continue
575
+ const start = offsetInGroup(group, index)
576
+ const end = offsetInGroup(group, index + needle.length)
577
+ const range = document.createRange()
578
+ range.setStart(start[0], start[1])
579
+ range.setEnd(end[0], end[1])
580
+ return range
581
+ }
361
582
  }
362
583
  return null
363
584
  }
364
585
 
365
- function jumpHighlight(snippet, messageId) {
366
- const anchor = findMessageNode(messageId)
367
- if (anchor !== null) return markElement(anchor)
368
- const scope = chatScope()
369
- for (const phrase of phraseCandidates(snippet)) {
370
- const element = findPhraseNode(scope, phrase)
371
- if (element !== null) return markElement(element)
586
+ function rangeRect(range) {
587
+ for (const rect of range.getClientRects()) if (rect.width > 0 && rect.height > 0) return rect
588
+ const rect = range.getBoundingClientRect()
589
+ return rect.width > 0 && rect.height > 0 ? rect : null
590
+ }
591
+
592
+ function isScrollable(element) {
593
+ if (!(element instanceof HTMLElement)) return false
594
+ return /(auto|scroll|overlay)/u.test(getComputedStyle(element).overflowY)
595
+ && element.scrollHeight - element.clientHeight > 2
596
+ }
597
+
598
+ function scrollChain(element) {
599
+ const chain = []
600
+ for (let node = element; node !== null; node = node.parentElement) if (isScrollable(node)) chain.push(node)
601
+ return chain
602
+ }
603
+
604
+ function visibleBand(scroller) {
605
+ const rect = scroller.getBoundingClientRect()
606
+ const seat = scroller.querySelector('[data-composer-seat]')
607
+ const seatRect = seat === null ? null : seat.getBoundingClientRect()
608
+ const bottom = seatRect !== null && seatRect.height > 0 ? Math.min(rect.bottom, seatRect.top) : rect.bottom
609
+ return { top: rect.top, bottom, height: Math.max(0, bottom - rect.top) }
610
+ }
611
+
612
+ function centerRange(range) {
613
+ const start = range.startContainer
614
+ const element = start instanceof Element ? start : start.parentElement
615
+ if (element === null) return false
616
+ let moved = false
617
+ for (const scroller of scrollChain(element)) {
618
+ const rect = rangeRect(range)
619
+ if (rect === null) return moved
620
+ const band = visibleBand(scroller)
621
+ if (band.height <= 0) continue
622
+ const next = Math.max(0, Math.min(
623
+ Math.max(0, scroller.scrollHeight - scroller.clientHeight),
624
+ scroller.scrollTop + (rect.top + rect.height / 2) - (band.top + band.height / 2),
625
+ ))
626
+ if (Math.abs(next - scroller.scrollTop) > 1) { scroller.scrollTop = next; moved = true }
627
+ }
628
+ return moved
629
+ }
630
+
631
+ function expandHidden(node) {
632
+ let element = node instanceof Element ? node : node.parentElement
633
+ for (; element !== null; element = element.parentElement) {
634
+ if (element.getAttribute('hidden') === 'until-found') {
635
+ element.dispatchEvent(new Event('beforematch'))
636
+ }
637
+ }
638
+ }
639
+
640
+ function waitFor(scope, test, timeoutMs, intervalMs) {
641
+ return new Promise(resolve => {
642
+ let settled = false
643
+ const finish = value => {
644
+ if (settled) return
645
+ settled = true
646
+ clearInterval(timer)
647
+ clearTimeout(timeout)
648
+ resolve(value)
649
+ }
650
+ const check = () => {
651
+ try {
652
+ const value = test()
653
+ if (value !== null && value !== undefined) finish(value)
654
+ } catch { /* DOM not ready yet */ }
655
+ }
656
+ const timer = setInterval(check, intervalMs || 120)
657
+ const timeout = setTimeout(() => finish(null), timeoutMs)
658
+ check()
659
+ })
660
+ }
661
+
662
+ function waitForScope(sessionId, timeoutMs) {
663
+ return waitFor(document, () => conversationScope(sessionId), timeoutMs, 120)
664
+ }
665
+
666
+ function revealOlder(scope) {
667
+ const button = scope === null ? null : scope.querySelector('[data-chat-history] button')
668
+ if (button === null || button.disabled === true) return false
669
+ button.click()
670
+ return true
671
+ }
672
+
673
+ let jumpSeq = 0
674
+ let activeHighlight = null
675
+
676
+ function clearJumpHighlight() {
677
+ clearFindHighlight()
678
+ if (activeHighlight !== null) {
679
+ const clear = activeHighlight
680
+ activeHighlight = null
681
+ clear()
682
+ }
683
+ }
684
+
685
+ function cancelJump() {
686
+ jumpSeq++
687
+ clearJumpHighlight()
688
+ }
689
+
690
+ async function jumpToContentHit(hit, query, notify) {
691
+ cancelJump()
692
+ const token = jumpSeq
693
+ const stale = () => token !== jumpSeq
694
+ if (!hit || hit.kind !== 'session' || !hit.sessionId) return false
695
+ if (!workspaceService || typeof workspaceService.openSession !== 'function') return false
696
+ try { workspaceService.openSession(hit.sessionId) } catch { return false }
697
+ const scope = await waitForScope(hit.sessionId, 10_000)
698
+ if (stale()) return false
699
+ if (scope === null) { showNotice(t('jumpFailed')); return false }
700
+ const flow = chatFlow(scope)
701
+ if (flow === null) { showNotice(t('jumpFailed')); return false }
702
+ const needles = needlesFromQuery(query)
703
+ const finder = () => {
704
+ const row = findMessageRow(flow, hit.messageId, hit.nodeKey)
705
+ if (row !== null) {
706
+ expandHidden(row)
707
+ const ranges = rangesForNeedles(row, needles)
708
+ if (ranges.length > 0) return { row, ranges }
709
+ const phrase = phraseRange(row, hit.snippet)
710
+ if (phrase !== null) return { row, ranges: [phrase] }
711
+ return null
712
+ }
713
+ const phrase = phraseRange(flow, hit.snippet)
714
+ if (phrase === null) return null
715
+ expandHidden(phrase.startContainer)
716
+ const phraseRow = phrase.startContainer instanceof Element
717
+ ? phrase.startContainer.closest('[data-chat-node-key]')
718
+ : phrase.startContainer.parentElement?.closest('[data-chat-node-key]') ?? null
719
+ const ranges = phraseRow === null ? [phrase] : rangesForNeedles(phraseRow, needles)
720
+ return { row: phraseRow, ranges: ranges.length > 0 ? ranges : [phrase] }
721
+ }
722
+ let target = await waitFor(flow, finder, 6_000, 120)
723
+ for (let attempt = 0; target === null && attempt < 10; attempt++) {
724
+ if (stale()) return false
725
+ for (const hidden of flow.querySelectorAll('[hidden="until-found"]')) hidden.dispatchEvent(new Event('beforematch'))
726
+ if (!revealOlder(scope)) break
727
+ target = await waitFor(flow, finder, 1_500, 150)
728
+ }
729
+ if (stale()) return false
730
+ if (target === null) { showNotice(t('phraseNotFound')); return false }
731
+ setFindHighlight(target.ranges)
732
+ centerRange(target.ranges[0])
733
+ const start = target.ranges[0].startContainer
734
+ const chain = scrollChain(start instanceof Element ? start : start.parentElement)
735
+ const scroller = chain.length > 0 ? chain[chain.length - 1] : null
736
+ const lockCleanup = []
737
+ const highlightCleanup = []
738
+ const unlock = () => {
739
+ while (lockCleanup.length > 0) {
740
+ const fn = lockCleanup.pop()
741
+ try { fn() } catch { /* ignore */ }
742
+ }
743
+ }
744
+ const clearHighlight = () => {
745
+ while (highlightCleanup.length > 0) {
746
+ const fn = highlightCleanup.pop()
747
+ try { fn() } catch { /* ignore */ }
748
+ }
749
+ clearFindHighlight()
372
750
  }
373
- return false
751
+ activeHighlight = clearHighlight
752
+ if (scroller !== null) {
753
+ let writing = false
754
+ let expected = scroller.scrollTop
755
+ const recenter = () => {
756
+ if (token !== jumpSeq) return
757
+ const rect = rangeRect(target.ranges[0])
758
+ if (rect === null) { cancelJump(); return }
759
+ writing = true
760
+ centerRange(target.ranges[0])
761
+ expected = scroller.scrollTop
762
+ requestAnimationFrame(() => { writing = false })
763
+ }
764
+ for (const delay of [80, 240, 800, 1600]) {
765
+ const id = setTimeout(recenter, delay)
766
+ lockCleanup.push(() => clearTimeout(id))
767
+ }
768
+ const onUser = () => { if (!writing) unlock() }
769
+ for (const type of GESTURES) {
770
+ scroller.addEventListener(type, onUser, true)
771
+ lockCleanup.push(() => scroller.removeEventListener(type, onUser, true))
772
+ }
773
+ const onScroll = event => {
774
+ if (writing) return
775
+ if (event.target === scroller && Math.abs(scroller.scrollTop - expected) <= 2) return
776
+ unlock()
777
+ }
778
+ scroller.addEventListener('scroll', onScroll, true)
779
+ lockCleanup.push(() => scroller.removeEventListener('scroll', onScroll, true))
780
+ }
781
+ const onKey = event => {
782
+ if (event.key === 'Escape') cancelJump()
783
+ }
784
+ document.addEventListener('keydown', onKey, true)
785
+ highlightCleanup.push(() => document.removeEventListener('keydown', onKey, true))
786
+ const sessionWatch = setInterval(() => {
787
+ const connected = target.ranges[0].startContainer.isConnected
788
+ if (!connected || conversationScope(hit.sessionId) === null) cancelJump()
789
+ }, 1_000)
790
+ highlightCleanup.push(() => clearInterval(sessionWatch))
791
+ showNotice(t('jumped'))
792
+ return true
374
793
  }
375
794
 
376
- const PAGE = 100
795
+ // ── panel ───────────────────────────────────────────────────────────────
796
+ let workspaceService = null
797
+ const PAGE_NAME = 50
798
+ const PAGE_CONTENT = 30
377
799
 
378
800
  function Panel() {
379
801
  const open = useOpen()
802
+ const prefs = useMemo(readPrefs, [])
380
803
  const [query, setQuery] = useState('')
381
- const [sort, setSort] = useState('mtime')
382
- const [desc, setDesc] = useState(true)
383
- const [filesOnly, setFilesOnly] = useState(false)
384
- const [dirsOnly, setDirsOnly] = useState(false)
385
- const [contentMode, setContentMode] = useState(false)
386
- const [hits, setHits] = useState([])
387
- const [total, setTotal] = useState(0)
804
+ const [sort, setSort] = useState(prefs.sort)
805
+ const [desc, setDesc] = useState(prefs.desc)
806
+ const [scopes, setScopes] = useState(prefs.scopes)
807
+ const [groups, setGroups] = useState(null)
808
+ const [loading, setLoading] = useState(false)
809
+ const [error, setError] = useState('')
388
810
  const [took, setTook] = useState(0)
389
- const [truncated, setTruncated] = useState(false)
390
- const [contentBuilding, setContentBuilding] = useState(false)
391
- const [refreshTick, setRefreshTick] = useState(0)
392
811
  const [status, setStatus] = useState(null)
393
- const [error, setError] = useState('')
394
- const [loading, setLoading] = useState(false)
812
+ const [availableContent, setAvailableContent] = useState(true)
395
813
  const [selected, setSelected] = useState(0)
396
814
  const [toast, setToast] = useState('')
815
+ const [refreshTick, setRefreshTick] = useState(0)
397
816
  const inputRef = useRef(null)
817
+ const panelRef = useRef(null)
398
818
  const abortRef = useRef(null)
399
819
  const listRef = useRef(null)
400
820
 
401
821
  const close = useCallback(() => store.set(false), [])
822
+ const anyScope = scopes.content || scopes.files || scopes.dirs
823
+
824
+ useEffect(() => {
825
+ writePrefs({ scopes, sort, desc })
826
+ }, [scopes, sort, desc])
402
827
 
403
828
  useEffect(() => {
404
829
  if (!open) return undefined
830
+ const previous = document.activeElement
405
831
  setError('')
406
- const timer = setTimeout(() => inputRef.current && inputRef.current.focus(), 30)
407
- return () => clearTimeout(timer)
832
+ const timer = setTimeout(() => {
833
+ if (inputRef.current) {
834
+ inputRef.current.focus()
835
+ if (inputRef.current.value !== '') inputRef.current.select()
836
+ }
837
+ }, 30)
838
+ return () => {
839
+ clearTimeout(timer)
840
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
841
+ }
408
842
  }, [open])
409
843
 
410
844
  useEffect(() => {
@@ -412,10 +846,13 @@ window.__ModuleLoader__.load({
412
846
  let cancelled = false
413
847
  let timer = 0
414
848
  const load = () => {
415
- const controller = new AbortController()
416
- post('/dsh-locator/status', {}, controller.signal).then(next => {
849
+ post('/dsh-locator/status', {}).then(next => {
417
850
  if (cancelled) return
418
851
  setStatus(next)
852
+ if (next && next.content && next.content.enabled === false) {
853
+ setAvailableContent(false)
854
+ setScopes(value => (value.content ? { ...value, content: false } : value))
855
+ }
419
856
  if (next && next.scanning) timer = setTimeout(load, 1500)
420
857
  }).catch(() => {})
421
858
  }
@@ -423,63 +860,72 @@ window.__ModuleLoader__.load({
423
860
  return () => { cancelled = true; clearTimeout(timer) }
424
861
  }, [open])
425
862
 
863
+ // One request for every enabled scope; the server owns the grouping and
864
+ // de-duplication. Empty and one-character queries never start a content
865
+ // build (see src/combined.ts), so opening the panel stays cheap.
426
866
  useEffect(() => {
427
867
  if (!open) return undefined
868
+ if (!anyScope) {
869
+ setGroups(null)
870
+ setLoading(false)
871
+ setError('')
872
+ return undefined
873
+ }
428
874
  const timer = setTimeout(() => {
429
875
  if (abortRef.current) abortRef.current.abort()
430
876
  const controller = new AbortController()
431
877
  abortRef.current = controller
432
878
  setLoading(true)
433
879
  setError('')
434
- const request = contentMode
435
- ? post('/dsh-locator/content', { query, limit: PAGE }, controller.signal)
436
- : post('/dsh-locator/search', { query, limit: PAGE, offset: 0, sort, desc, filesOnly, dirsOnly }, controller.signal)
437
- request.then(data => {
880
+ post('/dsh-locator/combined', {
881
+ query,
882
+ content: scopes.content,
883
+ files: scopes.files,
884
+ dirs: scopes.dirs,
885
+ limit: PAGE_NAME,
886
+ contentLimit: PAGE_CONTENT,
887
+ sort,
888
+ desc,
889
+ }, controller.signal).then(data => {
438
890
  if (controller.signal.aborted) return
439
- const nextHits = contentMode
440
- ? (Array.isArray(data.hits) ? data.hits : []).map(hit => ({
441
- ...hit,
442
- name: hit.title || String(hit.path).split('/').pop() || hit.path,
443
- dir: false,
444
- size: 0,
445
- mtime: 0,
446
- content: true,
447
- }))
448
- : (Array.isArray(data.hits) ? data.hits : []).map(hit => ({ ...hit, content: false }))
449
- setHits(nextHits)
450
- setTotal(typeof data.total === 'number' ? data.total : nextHits.length)
891
+ setGroups({
892
+ content: data.content,
893
+ files: data.files,
894
+ dirs: data.dirs,
895
+ })
896
+ if (typeof data.contentEnabled === 'boolean') setAvailableContent(data.contentEnabled)
451
897
  setTook(typeof data.tookMs === 'number' ? data.tookMs : 0)
452
- setTruncated(data.truncated === true)
453
- setContentBuilding(contentMode && data.state != null && data.state.building === true)
454
- setSelected(0)
455
898
  setLoading(false)
456
899
  }).catch(cause => {
457
900
  if (controller.signal.aborted) return
458
- setHits([])
459
- setTotal(0)
460
901
  setError(cause instanceof Error ? cause.message : String(cause))
461
902
  setLoading(false)
462
903
  })
463
- }, 110)
904
+ }, 140)
464
905
  return () => clearTimeout(timer)
465
- }, [open, query, sort, desc, filesOnly, dirsOnly, contentMode, refreshTick])
906
+ }, [open, query, sort, desc, scopes.content, scopes.files, scopes.dirs, refreshTick, anyScope])
466
907
 
467
- // The content index is built lazily on first use; poll until it settles so
468
- // the first query fills in without the user retyping it.
908
+ const contentBuilding = groups !== null && groups.content !== undefined && groups.content.building === true
469
909
  useEffect(() => {
470
- if (!open || !contentMode || !contentBuilding) return undefined
910
+ if (!open || !contentBuilding) return undefined
471
911
  const timer = setTimeout(() => setRefreshTick(value => value + 1), 2500)
472
912
  return () => clearTimeout(timer)
473
- }, [open, contentMode, contentBuilding, refreshTick])
913
+ }, [open, contentBuilding, refreshTick])
474
914
 
475
- const toggleSort = useCallback(key => {
476
- if (sort === key) {
477
- setDesc(value => !value)
478
- } else {
479
- setSort(key)
480
- setDesc(key === 'mtime' || key === 'size')
915
+ const rows = useMemo(() => {
916
+ if (groups === null) return []
917
+ const out = []
918
+ for (const group of ['content', 'files', 'dirs']) {
919
+ if (!scopes[group]) continue
920
+ const hits = groups[group] && Array.isArray(groups[group].hits) ? groups[group].hits : []
921
+ for (const hit of hits) out.push({ group, hit })
481
922
  }
482
- }, [sort])
923
+ return out
924
+ }, [groups, scopes])
925
+
926
+ useEffect(() => {
927
+ setSelected(value => Math.min(Math.max(0, value), Math.max(0, rows.length - 1)))
928
+ }, [rows.length])
483
929
 
484
930
  const notify = useCallback(message => {
485
931
  setToast(message)
@@ -488,105 +934,115 @@ window.__ModuleLoader__.load({
488
934
 
489
935
  const copy = useCallback(hit => {
490
936
  if (!hit) return
491
- copyText(hit.path).then(() => notify(t('copied'))).catch(() => {})
937
+ copyText(hit.path).then(() => notify(t('copiedPath'))).catch(() => {})
492
938
  }, [notify])
493
939
 
494
940
  const reveal = useCallback(hit => {
495
- if (!hit) return
941
+ if (!hit || loading) return
496
942
  post('/dsh-locator/reveal', { path: hit.path }).then(() => notify(t('revealed'))).catch(() => copy(hit))
497
943
  }, [copy, notify])
498
944
 
499
- const jumpSession = useCallback(hit => {
500
- if (!hit || hit.kind !== 'session' || !hit.sessionId) return
501
- close()
502
- if (!workspaceService || typeof workspaceService.openSession !== 'function') {
503
- copy(hit)
504
- return
505
- }
506
- try {
507
- workspaceService.openSession(hit.sessionId)
508
- notify(t('jumped'))
509
- } catch {
510
- copy(hit)
511
- return
512
- }
513
- const snippet = hit.snippet
514
- const messageId = hit.messageId
515
- let tries = 0
516
- let pulledEarlier = false
517
- const timer = setInterval(() => {
518
- tries++
519
- if (jumpHighlight(snippet, messageId)) {
520
- clearInterval(timer)
521
- return
522
- }
523
- // A long session renders its tail first: nudge the transcript to the
524
- // top once so paging can load the message the hit points at.
525
- if (!pulledEarlier && tries === 15) {
526
- pulledEarlier = true
527
- const scroller = document.querySelector('[data-conversation-scroll]')
528
- if (scroller !== null && scroller.scrollTop > 0) scroller.scrollTop = 0
529
- }
530
- if (tries > 100) clearInterval(timer)
531
- }, 150)
532
- }, [close, copy, notify])
533
-
534
- const activate = useCallback(hit => {
535
- if (!hit) return
536
- if (hit.kind === 'session') jumpSession(hit)
537
- else reveal(hit)
538
- }, [jumpSession, reveal])
539
-
540
945
  const openFile = useCallback(hit => {
541
- if (!hit || hit.dir) return
946
+ if (!hit || hit.dir || loading) return
542
947
  post('/dsh-locator/open', { path: hit.path }).then(() => notify(t('opened'))).catch(() => copy(hit))
543
948
  }, [copy, notify])
544
949
 
950
+ const activate = useCallback(hit => {
951
+ if (!hit || loading) return
952
+ if (hit.kind === 'session' && hit.sessionId) {
953
+ close()
954
+ void jumpToContentHit(hit, query, notify)
955
+ return
956
+ }
957
+ reveal(hit)
958
+ }, [close, notify, query, reveal])
959
+
545
960
  const insert = useCallback(hit => {
546
- if (!hit) return
961
+ if (!hit || hit.kind === 'session') return
547
962
  const mention = '@"' + hit.path.replace(/"/g, '') + '" '
548
963
  close()
549
964
  setTimeout(() => {
550
- if (insertIntoComposer(mention)) {
551
- notify(t('inserted'))
552
- } else {
553
- copyText(mention).then(() => notify(t('insertFailed'))).catch(() => {})
554
- }
965
+ if (insertIntoComposer(mention)) notify(t('inserted'))
966
+ else copyText(mention).then(() => notify(t('insertFailed'))).catch(() => {})
555
967
  }, 80)
556
968
  }, [close, notify])
557
969
 
970
+ const toggleScope = useCallback(key => {
971
+ if (key === 'content' && !availableContent) return
972
+ setScopes(value => ({ ...value, [key]: !value[key] }))
973
+ }, [availableContent])
974
+
558
975
  const onKeyDown = useCallback(event => {
559
976
  if (event.key === 'Escape') {
560
977
  event.preventDefault()
561
978
  close()
562
979
  return
563
980
  }
564
- if (event.key === 'ArrowDown') {
981
+ if (event.key === 'Tab') {
982
+ const panel = panelRef.current
983
+ if (!panel) return
984
+ const focusables = [...panel.querySelectorAll('input,select,button')].filter(element => !element.disabled)
985
+ if (focusables.length === 0) return
986
+ const first = focusables[0]
987
+ const last = focusables[focusables.length - 1]
988
+ if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
989
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
990
+ return
991
+ }
992
+ const interactive = event.target instanceof HTMLElement
993
+ && (event.target.tagName === 'BUTTON' || event.target.tagName === 'SELECT'
994
+ || (event.target.tagName === 'INPUT' && event.target.type !== 'text'))
995
+ if (interactive) return
996
+ const scopeDigit = event.altKey
997
+ ? (event.code === 'Digit1' ? '1' : event.code === 'Digit2' ? '2' : event.code === 'Digit3' ? '3' : ['1', '2', '3'].includes(event.key) ? event.key : '')
998
+ : ''
999
+ if (scopeDigit !== '') {
1000
+ event.preventDefault()
1001
+ toggleScope(scopeDigit === '1' ? 'content' : scopeDigit === '2' ? 'files' : 'dirs')
1002
+ return
1003
+ }
1004
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1005
+ event.preventDefault()
1006
+ setSelected(value => Math.max(0, Math.min(rows.length - 1, value + (event.key === 'ArrowDown' ? 1 : -1))))
1007
+ return
1008
+ }
1009
+ if ((event.key === 'Home' || event.key === 'End') && event.target === inputRef.current) return
1010
+ if (event.key === 'Home' || event.key === 'End') {
1011
+ event.preventDefault()
1012
+ setSelected(event.key === 'Home' ? 0 : Math.max(0, rows.length - 1))
1013
+ return
1014
+ }
1015
+ if (event.key === 'PageDown' || event.key === 'PageUp') {
565
1016
  event.preventDefault()
566
- setSelected(value => Math.min(hits.length - 1, value + 1))
1017
+ setSelected(value => Math.max(0, Math.min(rows.length - 1, value + (event.key === 'PageDown' ? 8 : -8))))
567
1018
  return
568
1019
  }
569
- if (event.key === 'ArrowUp') {
1020
+ if (event.key === 'Enter' && !event.isComposing && !event.ctrlKey && !event.metaKey && !event.altKey) {
570
1021
  event.preventDefault()
571
- setSelected(value => Math.max(0, value - 1))
1022
+ activate(rows[selected]?.hit)
572
1023
  return
573
1024
  }
574
1025
  const modifier = event.ctrlKey || event.metaKey
575
- if (modifier && String(event.key).toLowerCase() === 'o') {
1026
+ if (modifier && event.shiftKey && String(event.key).toLowerCase() === 'c') {
576
1027
  event.preventDefault()
577
- openFile(hits[selected])
1028
+ copy(rows[selected]?.hit)
578
1029
  return
579
1030
  }
580
- if (modifier && event.key === 'Enter' && !event.isComposing) {
1031
+ if (modifier && event.shiftKey && String(event.key).toLowerCase() === 'o') {
581
1032
  event.preventDefault()
582
- insert(hits[selected])
1033
+ reveal(rows[selected]?.hit)
583
1034
  return
584
1035
  }
585
- if (event.key === 'Enter' && !event.isComposing) {
1036
+ if (modifier && String(event.key).toLowerCase() === 'o') {
586
1037
  event.preventDefault()
587
- copy(hits[selected])
1038
+ openFile(rows[selected]?.hit)
1039
+ return
588
1040
  }
589
- }, [close, copy, hits, insert, openFile, selected])
1041
+ if (modifier && event.key === 'Enter' && !event.isComposing) {
1042
+ event.preventDefault()
1043
+ insert(rows[selected]?.hit)
1044
+ }
1045
+ }, [activate, close, copy, insert, loading, openFile, reveal, rows, selected, toggleScope])
590
1046
 
591
1047
  useEffect(() => {
592
1048
  const list = listRef.current
@@ -597,18 +1053,104 @@ window.__ModuleLoader__.load({
597
1053
 
598
1054
  if (!open) return null
599
1055
 
600
- const header = (key, label, width) => h('th', {
601
- key,
602
- className: sort === key ? 'sorted' : '',
603
- style: { width },
604
- onClick: () => toggleSort(key),
605
- title: t('sortTitle'),
606
- }, label + (sort === key ? (desc ? ' ↓' : ' ↑') : ''))
1056
+ const content = groups !== null ? groups.content : undefined
1057
+ const files = groups !== null ? groups.files : undefined
1058
+ const dirs = groups !== null ? groups.dirs : undefined
1059
+ const contentCount = content === undefined ? 0 : (content.returned ?? 0)
1060
+ const filesCount = files === undefined ? 0 : (files.returned ?? files.hits?.length ?? 0)
1061
+ const dirsCount = dirs === undefined ? 0 : (dirs.returned ?? dirs.hits?.length ?? 0)
1062
+ const totalRows = rows.length
1063
+
1064
+ const scopeBox = (key, label, count) => h('label', {
1065
+ className: 'dsh-locator-check' + (key === 'content' && !availableContent ? ' disabled' : ''),
1066
+ title: key === 'content' && !availableContent ? t('contentDisabled') : label,
1067
+ },
1068
+ h('input', {
1069
+ type: 'checkbox',
1070
+ checked: scopes[key],
1071
+ disabled: key === 'content' && !availableContent,
1072
+ onChange: () => toggleScope(key),
1073
+ }),
1074
+ label,
1075
+ count > 0 ? h('span', { className: 'dsh-locator-count' }, String(count)) : null,
1076
+ )
1077
+
1078
+ const groupHead = (key, label, count, note) => h('div', {
1079
+ className: 'dsh-locator-grouphead',
1080
+ id: 'dsh-locator-group-' + key,
1081
+ },
1082
+ h('span', null, label),
1083
+ h('span', { className: 'dsh-locator-count' }, String(count)),
1084
+ h('span', { className: 'grow' }),
1085
+ note === null ? null : h('span', { className: 'dsh-locator-count' }, note),
1086
+ )
1087
+
1088
+ const rowFor = (group, hit, index) => {
1089
+ const isContent = group === 'content'
1090
+ const isSession = isContent && hit.kind === 'session'
1091
+ const badge = isContent
1092
+ ? (isSession ? t('groupContent') : (hit.title || '').split('.').pop() || t('groupFiles'))
1093
+ : (hit.dir ? t('groupDirs') : (hit.ext || t('groupFiles')))
1094
+ const name = isSession ? (hit.title || hit.path) : (hit.name || hit.path)
1095
+ const meta = isContent
1096
+ ? formatTime(hit.mtime)
1097
+ : [formatSize(hit.size), formatTime(hit.mtime)].filter(Boolean).join(' · ')
1098
+ return h('div', {
1099
+ key: group + ':' + hit.path + ':' + String(hit.messageId || index),
1100
+ id: 'dsh-locator-opt-' + index,
1101
+ role: 'option',
1102
+ 'aria-selected': index === selected ? 'true' : 'false',
1103
+ 'data-index': index,
1104
+ className: 'dsh-locator-row' + (isContent ? ' dsh-locator-rowcontent' : '') + (index === selected ? ' sel' : ''),
1105
+ title: hit.path,
1106
+ onClick: () => setSelected(index),
1107
+ onDoubleClick: () => activate(hit),
1108
+ },
1109
+ h('div', { className: 'dsh-locator-title' },
1110
+ h('span', { className: 'dsh-locator-badge' + (isSession ? ' chat' : '') }, String(badge).slice(0, 6)),
1111
+ h('span', { className: 'dsh-locator-name' }, isSession ? name : highlight(name, query)),
1112
+ h('span', { className: 'dsh-locator-meta', style: { marginLeft: 'auto' } }, meta),
1113
+ ),
1114
+ isContent && hit.snippet
1115
+ ? h('div', { className: 'dsh-locator-snippet' }, highlight(hit.snippet, query))
1116
+ : null,
1117
+ h('div', { className: 'dsh-locator-path' }, hit.path),
1118
+ )
1119
+ }
1120
+
1121
+ let index = -1
1122
+ const renderGroup = (key, label, group, extra) => {
1123
+ if (!scopes[key] || group === undefined) return null
1124
+ const hits = Array.isArray(group.hits) ? group.hits : []
1125
+ const count = typeof group.returned === 'number' ? group.returned : hits.length
1126
+ 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)))
1129
+ : (group.deduped > 0 ? t('deduped', { n: group.deduped }) : (group.truncated ? t('showing', { n: hits.length }) : null))
1130
+ if (typeof group.error === 'string' && group.error !== '') {
1131
+ return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1132
+ groupHead(key, label, count, null),
1133
+ h('div', { className: 'dsh-locator-groupempty', role: 'alert' }, t('errorPrefix') + group.error,
1134
+ h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry'))),
1135
+ )
1136
+ }
1137
+ return h('div', { className: 'dsh-locator-group', role: 'group', 'aria-labelledby': 'dsh-locator-group-' + key },
1138
+ groupHead(key, label, count, note),
1139
+ extra === undefined ? null : extra,
1140
+ hits.length === 0
1141
+ ? h('div', { className: 'dsh-locator-groupempty' },
1142
+ key === 'content' && scopes.content
1143
+ ? (query.trim().length < 2 ? t('contentShort') : (contentBuilding ? t('loading') : t('emptyResults')))
1144
+ : (query.trim() === '' ? t('emptyQuery') : t('emptyResults')))
1145
+ : hits.map(hit => { index++; return rowFor(key, hit, index) }),
1146
+ )
1147
+ }
607
1148
 
608
1149
  return h('div', {
609
1150
  className: 'dsh-locator-overlay',
610
1151
  onMouseDown: event => { if (event.target === event.currentTarget) close() },
611
1152
  }, h('div', {
1153
+ ref: panelRef,
612
1154
  className: 'dsh-locator-panel',
613
1155
  role: 'dialog',
614
1156
  'aria-modal': 'true',
@@ -624,22 +1166,40 @@ window.__ModuleLoader__.load({
624
1166
  spellCheck: false,
625
1167
  autoComplete: 'off',
626
1168
  placeholder: t('placeholder'),
627
- onChange: event => setQuery(event.target.value),
1169
+ onChange: event => { setQuery(event.target.value); setSelected(0) },
628
1170
  'aria-label': t('searchLabel'),
1171
+ role: 'combobox',
1172
+ 'aria-expanded': 'true',
1173
+ 'aria-controls': 'dsh-locator-results',
1174
+ 'aria-autocomplete': 'list',
1175
+ 'aria-activedescendant': totalRows > 0 ? 'dsh-locator-opt-' + selected : undefined,
629
1176
  }),
630
1177
  h('span', { className: 'dsh-locator-kbd' }, 'Ctrl+Shift+F'),
631
1178
  h('button', { className: 'dsh-locator-close', onClick: close, title: t('closeTitle'), 'aria-label': t('closeLabel') }, '×'),
632
1179
  ),
633
1180
  h('div', { className: 'dsh-locator-bar' },
634
- h('label', { className: 'dsh-locator-check' },
635
- h('input', { type: 'checkbox', checked: filesOnly, onChange: event => { setFilesOnly(event.target.checked); if (event.target.checked) setDirsOnly(false) } }),
636
- t('filesOnly')),
637
- h('label', { className: 'dsh-locator-check' },
638
- h('input', { type: 'checkbox', checked: dirsOnly, onChange: event => { setDirsOnly(event.target.checked); if (event.target.checked) setFilesOnly(false) } }),
639
- t('dirsOnly')),
640
- h('label', { className: 'dsh-locator-check' },
641
- h('input', { type: 'checkbox', checked: contentMode, onChange: event => setContentMode(event.target.checked) }),
642
- t('contentMode')),
1181
+ scopeBox('content', t('scopeContent'), contentCount),
1182
+ scopeBox('files', t('scopeFiles'), filesCount),
1183
+ scopeBox('dirs', t('scopeDirs'), dirsCount),
1184
+ h('span', { className: 'grow' }),
1185
+ h('label', { className: 'dsh-locator-check' }, t('sortLabel'),
1186
+ h('select', {
1187
+ className: 'dsh-locator-sort',
1188
+ value: sort,
1189
+ onChange: event => setSort(SORTS.includes(event.target.value) ? event.target.value : 'mtime'),
1190
+ 'aria-label': t('sortLabel'),
1191
+ },
1192
+ h('option', { value: 'mtime' }, t('sortMtime')),
1193
+ h('option', { value: 'name' }, t('sortName')),
1194
+ h('option', { value: 'size' }, t('sortSize')),
1195
+ h('option', { value: 'path' }, t('sortPath')),
1196
+ )),
1197
+ h('button', {
1198
+ className: 'dsh-locator-dirbtn',
1199
+ onClick: () => setDesc(value => !value),
1200
+ title: desc ? t('descOrder') : t('asc'),
1201
+ 'aria-label': desc ? t('descOrder') : t('asc'),
1202
+ }, desc ? '↓' : '↑'),
643
1203
  h('span', { className: 'dsh-locator-status' },
644
1204
  status === null
645
1205
  ? t('statusLoading')
@@ -647,57 +1207,33 @@ window.__ModuleLoader__.load({
647
1207
  entries: (status.entries || 0).toLocaleString(),
648
1208
  mode: status.watching ? t('live') : t('snapshot'),
649
1209
  roots: (status.roots || []).length,
650
- }) + ((status.usnActive || status.usn) ? ' · ' + t('usn') : '')),
1210
+ })),
651
1211
  ),
652
- h('div', { className: 'dsh-locator-scroll', ref: listRef },
653
- h('table', { className: 'dsh-locator-table' },
654
- h('colgroup', null,
655
- h('col', { style: { width: '34%' } }),
656
- h('col', { style: { width: '12%' } }),
657
- h('col', { style: { width: '13%' } }),
658
- h('col', { style: { width: '41%' } }),
659
- ),
660
- h('thead', null, h('tr', null,
661
- header('name', t('name')),
662
- header('size', t('size')),
663
- header('mtime', t('mtime')),
664
- header('path', t('path')),
665
- )),
666
- h('tbody', null,
667
- hits.length === 0
668
- ? h('tr', null, h('td', { colSpan: 4, className: 'dsh-locator-empty' },
669
- loading ? t('loading') : error !== '' ? t('errorPrefix') + error : t('empty')))
670
- : hits.map((hit, index) => h('tr', {
671
- key: hit.path,
672
- 'data-index': index,
673
- className: 'dsh-locator-row' + (index === selected ? ' sel' : ''),
674
- title: hit.path,
675
- onClick: () => setSelected(index),
676
- onDoubleClick: () => activate(hit),
677
- },
678
- h('td', null, h('div', { className: 'dsh-locator-name' },
679
- h('span', { className: 'dsh-locator-badge' }, hit.kind === 'session' ? t('sessionBadge') : (hit.ext || t('fileBadge')).toUpperCase()),
680
- h('span', null, hit.content ? hit.name : highlight(hit.name, query)),
681
- hit.content && hit.snippet ? h('span', { className: 'dsh-locator-snippet' }, hit.snippet.slice(0, 180)) : null,
682
- )),
683
- h('td', { className: 'dsh-locator-size' }, hit.dir ? '' : formatSize(hit.size)),
684
- h('td', { className: 'dsh-locator-time' }, formatTime(hit.mtime)),
685
- h('td', { className: 'dsh-locator-path' }, hit.path),
686
- )),
687
- ),
688
- ),
1212
+ h('div', { id: 'dsh-locator-results', className: 'dsh-locator-scroll', ref: listRef, role: 'listbox', 'aria-busy': loading ? 'true' : 'false' },
1213
+ error !== ''
1214
+ ? h('div', { className: 'dsh-locator-empty', role: 'alert' }, t('errorPrefix') + error,
1215
+ h('button', { className: 'dsh-locator-dirbtn dsh-locator-retry', onClick: () => setRefreshTick(v => v + 1) }, t('retry')))
1216
+ : null,
1217
+ !anyScope
1218
+ ? h('div', { className: 'dsh-locator-empty' }, t('emptyScopes'))
1219
+ : groups === null
1220
+ ? (error === '' ? h('div', { className: 'dsh-locator-empty' }, t('loading')) : null)
1221
+ : [
1222
+ renderGroup('content', t('groupContent'), content),
1223
+ renderGroup('files', t('groupFiles'), files),
1224
+ renderGroup('dirs', t('groupDirs'), dirs),
1225
+ ],
689
1226
  ),
690
1227
  h('div', { className: 'dsh-locator-foot' },
691
- h('span', null,
692
- loading
693
- ? t('loading')
694
- : t('results', { total: total.toLocaleString() }) + (truncated ? ' (' + t('showing', { n: hits.length }) + ')' : '') + ' · ' + took.toFixed(1) + ' ms'
695
- + (status && status.scanning ? ' · ' + t('indexing') : '')
696
- + (contentBuilding ? ' · ' + t('indexing') : '')),
1228
+ h('span', null, (loading ? t('loading') + ' · ' : '') + t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: took.toFixed(1) })
1229
+ + (status && status.scanning ? ' · ' + t('indexing') : '')
1230
+ + (contentBuilding ? ' · ' + t('indexing') : '')),
697
1231
  h('span', { className: 'grow' }),
698
1232
  toast === '' ? null : h('span', { className: 'dsh-locator-toast' }, toast),
699
1233
  h('span', null, t('footer')),
700
1234
  ),
1235
+ h('div', { style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, role: 'status', 'aria-live': 'polite' },
1236
+ t('results', { content: contentCount, files: filesCount, dirs: dirsCount, ms: Math.round(took) })),
701
1237
  ))
702
1238
  }
703
1239
 
@@ -728,8 +1264,10 @@ window.__ModuleLoader__.load({
728
1264
  document.addEventListener('keydown', onKey, true)
729
1265
  return () => document.removeEventListener('keydown', onKey, true)
730
1266
  }, 'dsh-locator: search hotkey')
1267
+ ctx.effect(() => () => { cancelJump() }, 'dsh-locator: find highlight')
731
1268
  try {
732
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))
733
1271
  } catch (error) {
734
1272
  console.error('dsh-locator: overlay slot unavailable', error)
735
1273
  }