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