dsh-manage-session 0.4.8 → 0.5.3
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/CHANGELOG.md +36 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/cordis.patch.yml +6 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +3848 -202
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/index.ts +154 -26
- package/src/client/locales.ts +857 -223
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +54 -5
- package/src/host/history/archive-source.ts +48 -0
- package/src/host/history/archive.ts +56 -1
- package/src/host/history/favorites.ts +71 -0
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 搜索子域 client 组件(原 dsh-search-index client 半身的面板部分)。
|
|
3
|
+
*
|
|
4
|
+
* 注册职责(侧栏入口 / 家族 tab / 插件页配置卡)在合并包的 client/index.ts;
|
|
5
|
+
* 本文件只持有:样式表、悬浮搜索面板(标题/内容双模式)、侧栏 footer 按钮、
|
|
6
|
+
* 唤出和弦绑定。
|
|
7
|
+
*
|
|
8
|
+
* The `locale` and config services are consumed structurally: when
|
|
9
|
+
* the host release lacks them the footer falls back to the bundled zh
|
|
10
|
+
* dictionary and the host-composition config layer.
|
|
11
|
+
*/
|
|
12
|
+
import { createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
|
|
13
|
+
import { createPortal } from 'react-dom'
|
|
14
|
+
import type { SwitchSearchConfig } from '../../config.ts'
|
|
15
|
+
// 0.1.7:插件族共用设置节(dsh-thinking-levels 持有并声明 `dsh-family.tab`
|
|
16
|
+
// 子席位;本仓不依赖 ui-slots 类型包,席位键以运行时 children 表为准)。
|
|
17
|
+
import { callHost, callHostAny, type HostContentHit, type HostIndexStatus, type HostSessionItem, type HostSortMode, type SwitchSessionsService, type SwitchUiWorkspaceService } from './host-api.ts'
|
|
18
|
+
import type { SwitchCardScope } from './card.tsx'
|
|
19
|
+
import { translate, type LocaleKey } from './locales.ts'
|
|
20
|
+
import { LABELS, isInvokeChord } from './platform.ts'
|
|
21
|
+
|
|
22
|
+
/** ------------------------------------------------------------------ types */
|
|
23
|
+
|
|
24
|
+
/** The client slots service face (structural subset used here). */
|
|
25
|
+
interface SwitchSlotsService {
|
|
26
|
+
inject(key: string, callback: () => () => void, label?: string): () => void
|
|
27
|
+
register(options: {
|
|
28
|
+
name: string
|
|
29
|
+
id?: string
|
|
30
|
+
key?: string
|
|
31
|
+
order?: number
|
|
32
|
+
store?: unknown
|
|
33
|
+
locale?: string
|
|
34
|
+
label?: string | (() => string)
|
|
35
|
+
inject?: (actions: unknown) => unknown
|
|
36
|
+
}, component: unknown): () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The client sessions service face: open a session from a search result.
|
|
41
|
+
* Defined in `host-api.ts` (shared with `openSessionThrough`); re-imported as
|
|
42
|
+
* a type here for the cordis Context augmentation below.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The client settings-scope service face (structural subset). */
|
|
46
|
+
interface SwitchSettingsScope<C> {
|
|
47
|
+
bind(spec: { namespace: string }): SwitchScopeLike<C>
|
|
48
|
+
}
|
|
49
|
+
/** configForms 服务面(0.1.7:以 profile entry id 取句柄)。 */
|
|
50
|
+
interface SwitchConfigForms<T> {
|
|
51
|
+
get(entryId: string): SwitchScopeLike<T>
|
|
52
|
+
}
|
|
53
|
+
interface SwitchScopeLike<T> {
|
|
54
|
+
getSnapshot(): {
|
|
55
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
56
|
+
value: T | undefined
|
|
57
|
+
revision: number | undefined
|
|
58
|
+
writable: boolean
|
|
59
|
+
}
|
|
60
|
+
subscribe(listener: () => void): () => void
|
|
61
|
+
set(field: string, value: unknown): Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The client locale service face (structural mirror; the plugin must not
|
|
66
|
+
* value- or type-import a single release of the official locale package).
|
|
67
|
+
*/
|
|
68
|
+
interface SwitchLocaleService {
|
|
69
|
+
register(ns: string, dicts: Record<string, Record<string, string>>): () => void
|
|
70
|
+
register(ns: string, localeId: string, dicts: Record<string, string>): () => void
|
|
71
|
+
/**
|
|
72
|
+
* Read-time translator bound to a namespace (host `dsh-client-locale`).
|
|
73
|
+
* Needed for slot `label` thunks, which the owner re-reads per render so the
|
|
74
|
+
* tab text follows locale switches without re-registration. Optional: older
|
|
75
|
+
* hosts may expose `register` only, hence the guarded call site.
|
|
76
|
+
*/
|
|
77
|
+
bind?(ns: string): (key: string, params?: Record<string, unknown>) => string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The locale dictionary face the renderer may hand the card as `t`. */
|
|
81
|
+
type CardLocale = (key: LocaleKey, params?: Record<string, unknown>) => string
|
|
82
|
+
|
|
83
|
+
/** Coarse content-type filter carried to the host content-search. */
|
|
84
|
+
type ContentType = 'all' | 'user' | 'reply' | 'tool'
|
|
85
|
+
|
|
86
|
+
/** 检索域(R1:归档正文入索引后的 chip 维度)。 */
|
|
87
|
+
type ArchivedDomain = 'all' | 'active' | 'archived'
|
|
88
|
+
|
|
89
|
+
/** 面板模式:标题 / 内容(管理台归管家面板,见 search/manage.tsx)。 */
|
|
90
|
+
type PanelMode = 'title' | 'content'
|
|
91
|
+
|
|
92
|
+
/** The coarse filter chips rendered above content results. */
|
|
93
|
+
const CONTENT_TYPE_CHIPS: readonly { id: ContentType; labelKey: LocaleKey }[] = [
|
|
94
|
+
{ id: 'all', labelKey: 'filter.all' },
|
|
95
|
+
{ id: 'user', labelKey: 'filter.user' },
|
|
96
|
+
{ id: 'reply', labelKey: 'filter.reply' },
|
|
97
|
+
{ id: 'tool', labelKey: 'filter.tool' },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
/** 检索域 chips(标题/内容两模式共用)。 */
|
|
101
|
+
const ARCHIVED_CHIPS: readonly { id: ArchivedDomain; labelKey: LocaleKey }[] = [
|
|
102
|
+
{ id: 'all', labelKey: 'domain.all' },
|
|
103
|
+
{ id: 'active', labelKey: 'domain.active' },
|
|
104
|
+
{ id: 'archived', labelKey: 'domain.archived' },
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
/** Result-ordering chips rendered at the right of the same filter row. */
|
|
108
|
+
const SORT_CHIPS: readonly { id: HostSortMode; labelKey: LocaleKey }[] = [
|
|
109
|
+
{ id: 'relevance', labelKey: 'sort.relevance' },
|
|
110
|
+
{ id: 'time', labelKey: 'sort.time' },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The identity of one content-search request, derived from every input that
|
|
115
|
+
* changes the result set.
|
|
116
|
+
*
|
|
117
|
+
* This is the single owner of that identity. The effect that issues the
|
|
118
|
+
* request and the render path that decides whether the stored result belongs
|
|
119
|
+
* to the current inputs must both come through here: two hand-written copies
|
|
120
|
+
* of the same template literal drift apart silently, and the render path then
|
|
121
|
+
* falls through to its empty `loading` state for every query — results arrive,
|
|
122
|
+
* parse, and are never shown.
|
|
123
|
+
*
|
|
124
|
+
* @param normalized - the trimmed query text.
|
|
125
|
+
* @param contentType - the active content-type filter.
|
|
126
|
+
* @param sortBy - the active result ordering.
|
|
127
|
+
* @param archivedFilter - the retrieval-domain chip (all/active/archived).
|
|
128
|
+
* @returns an opaque key, stable for equal inputs.
|
|
129
|
+
*/
|
|
130
|
+
function contentRequestKey(normalized: string, contentType: ContentType, sortBy: HostSortMode, archivedFilter: ArchivedDomain, dateRange: { from: string; to: string }): string {
|
|
131
|
+
return `${normalized}\u0000${contentType}\u0000${sortBy}\u0000${archivedFilter}\u0000${dateRange.from}\u0000${dateRange.to}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Persisted ordering preference. Unlike `lastPanelMode` this one survives a
|
|
136
|
+
* page reload — an ordering is a durable preference, not a session mood.
|
|
137
|
+
*/
|
|
138
|
+
const SORT_STORE_KEY = 'dsh-search-index.sortBy'
|
|
139
|
+
|
|
140
|
+
/** Read the persisted ordering; private mode or a bad value degrades to relevance. */
|
|
141
|
+
function readStoredSort(): HostSortMode {
|
|
142
|
+
try {
|
|
143
|
+
return window.localStorage.getItem(SORT_STORE_KEY) === 'time' ? 'time' : 'relevance'
|
|
144
|
+
} catch {
|
|
145
|
+
return 'relevance'
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Persist the ordering; a storage failure still leaves this session working. */
|
|
150
|
+
function writeStoredSort(next: HostSortMode): void {
|
|
151
|
+
try {
|
|
152
|
+
window.localStorage.setItem(SORT_STORE_KEY, next)
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore: the choice applies for the rest of this session regardless
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Local re-sort so the page honours the chosen ordering even against an older
|
|
160
|
+
* host half that ignores `sortBy` and therefore omits `updatedAt` entirely —
|
|
161
|
+
* in that case the host order is left untouched rather than scrambled to NaN.
|
|
162
|
+
*/
|
|
163
|
+
function sortHits(items: readonly HostContentHit[], sortBy: HostSortMode): HostContentHit[] {
|
|
164
|
+
if (sortBy !== 'time') return [...items]
|
|
165
|
+
if (!items.every(item => typeof item.updatedAt === 'number' && Number.isFinite(item.updatedAt))) return [...items]
|
|
166
|
+
return [...items].sort((a, b) => b.updatedAt - a.updatedAt)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The footer-action owner share (structural subset). */
|
|
170
|
+
interface SwitchFooterProps {
|
|
171
|
+
wide: boolean
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Last panel mode used this web session (mode memory, not persisted). */
|
|
175
|
+
let lastPanelMode: PanelMode = 'title'
|
|
176
|
+
|
|
177
|
+
declare module 'cordis' {
|
|
178
|
+
interface Context {
|
|
179
|
+
slots: SwitchSlotsService
|
|
180
|
+
sessions?: SwitchSessionsService
|
|
181
|
+
uiWorkspace?: SwitchUiWorkspaceService
|
|
182
|
+
settingsScope?: SwitchSettingsScope<SwitchSearchConfig>
|
|
183
|
+
locale?: SwitchLocaleService
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** ------------------------------------------------------------------ styles */
|
|
188
|
+
|
|
189
|
+
const CSS = `
|
|
190
|
+
/* 侧栏 footer 槽位公约(2026-09-26;2026-10-01 收紧行距并强制居中):一行多
|
|
191
|
+
入口(第三方 dsh-context 等)会互相挤占 —— 宿主 .footerActions 是 nowrap
|
|
192
|
+
flex 行。这里允许容器换行,并把本插件入口钉成独占一整行(flex-basis:100%);
|
|
193
|
+
其余入口(含第三方)自然落到后续行。justify-content:center 让行内所有入口
|
|
194
|
+
(含不占满行的)统一居中;row-gap:0 配合入口自身 32px 高度压缩纵向占位。
|
|
195
|
+
类名用 [class*=] 中段匹配:宿主是 CSS Module 哈希类名(实测形如
|
|
196
|
+
hHd-Xa_footerActions —— <hash>_<name>,哈希在前),中段跨版本稳定。 */
|
|
197
|
+
[class*="footerActions"]{flex-wrap:wrap;justify-content:center;row-gap:0;height:auto;min-height:0}
|
|
198
|
+
/* —— 第三方矫正:dsh-context「上下文洞察」入口(2026-10-01)——
|
|
199
|
+
.lc-ov-entry 按"独占整行"设计(width:calc(100% + 4px)、无 justify-content、
|
|
200
|
+
42px 高、不对称 padding),与本槽位公约(每个入口独占一行、行内居中、32px)
|
|
201
|
+
冲突,表现为文字靠左、纵向松散。这里按公约强制矫正;:not() 排除收起轨道的
|
|
202
|
+
36px 圆钮形态。lc-ov-* 是 dsh-context 源码硬编码类名(非构建哈希),跨版本
|
|
203
|
+
稳定(实测 0.56.1 / 0.60.0 规则一致)。 */
|
|
204
|
+
.lc-ov-entry:not(.lc-ov-entry-rail){width:auto!important;flex:0 0 100%!important;min-width:0!important;justify-content:center!important;height:32px!important;margin:0!important;padding:0 10px!important}
|
|
205
|
+
.lc-ov-entry-label{flex:0 1 auto!important}
|
|
206
|
+
.dsws_root{box-sizing:border-box;position:relative;display:flex;align-items:center;justify-content:center;flex:0 0 100%;width:100%;min-width:0;container-type:inline-size}
|
|
207
|
+
/* 收起轨道:回落自然宽度(根类的 100% 基准只属于宽栏形态),放弃收缩。 */
|
|
208
|
+
.dsws_rootRail{flex:none;width:auto;container-type:normal}
|
|
209
|
+
/* 高度 32px(内容 22px 行高 + 上下各 5px):宿主默认 42px 的上下裕度在
|
|
210
|
+
多行堆叠后过于松散;左右内边距对称(10px/10px),否则整行居中时按钮内容
|
|
211
|
+
会因不对称 padding 向左偏 1px。 */
|
|
212
|
+
.dsws_button{box-sizing:border-box;display:inline-flex;align-items:center;flex:0 0 auto;min-width:0;gap:8px;height:32px;border:none;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;padding:0 10px;font-family:inherit;font-size:14px;line-height:22px;white-space:nowrap;overflow:hidden;transition:background-color 160ms ease-out,color 160ms ease-out}
|
|
213
|
+
.dsws_buttonRail{flex:none;width:28px;height:28px;padding:0;gap:0;justify-content:center;border-radius:50%}
|
|
214
|
+
.dsws_button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
215
|
+
.dsws_button svg{flex:none}
|
|
216
|
+
.dsws_trigger{position:fixed;z-index:2147483000;left:50%;top:50%;transform:translate(-50%,-50%);width:860px;max-width:calc(100vw - 32px);max-height:min(84vh,880px);box-sizing:border-box;background:var(--dsw-specific-tip);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 8px 28px rgba(0,0,0,.16));overflow:hidden;display:flex;flex-direction:column;font-family:Inter,var(--dsw-font-family)}
|
|
217
|
+
.dsws_toolrow{display:flex;align-items:center;gap:8px;padding:10px 10px 0}
|
|
218
|
+
.dsws_mode{display:inline-flex;align-items:center;gap:2px;flex:none;background:var(--dsw-alias-interactive-bg-hover);border-radius:8px;padding:2px}
|
|
219
|
+
.dsws_modeBtn{height:24px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:6px;padding:0 8px;font-size:12px;font-weight:500;line-height:20px}
|
|
220
|
+
.dsws_modeBtn:hover{color:var(--dsw-alias-label-primary)}
|
|
221
|
+
.dsws_modeBtnActive{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.08)}
|
|
222
|
+
.dsws_search{flex:auto;min-width:0;height:30px;box-sizing:border-box;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;padding:0 10px;font:inherit;font-size:13px;line-height:20px}
|
|
223
|
+
.dsws_search:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
224
|
+
.dsws_search::placeholder{color:var(--dsw-alias-label-caption)}
|
|
225
|
+
.dsws_chips{display:flex;align-items:center;gap:6px;padding:8px 10px 0;flex:none}
|
|
226
|
+
.dsws_chipGap{flex:1;min-width:0}
|
|
227
|
+
.dsws_sortGroup{display:flex;align-items:center;gap:6px;flex:none}
|
|
228
|
+
.dsws_chip{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:999px;padding:0 10px;font-size:12px;font-weight:500;line-height:22px;white-space:nowrap}
|
|
229
|
+
.dsws_chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
230
|
+
.dsws_chipActive{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}
|
|
231
|
+
.dsws_list{flex:1 1 auto;min-height:0;overflow-y:auto;margin:8px 0 0;padding:0 6px 8px;list-style:none}
|
|
232
|
+
.dsws_row{box-sizing:border-box;border-radius:8px;width:100%;padding:7px 8px;cursor:pointer;text-align:left;border:none;background:transparent;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
233
|
+
.dsws_row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
234
|
+
.dsws_rowTitle{display:flex;align-items:center;gap:8px;min-width:0}
|
|
235
|
+
.dsws_titleText{flex:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;line-height:18px}
|
|
236
|
+
.dsws_tag{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
237
|
+
.dsws_snippet{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:17px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
|
|
238
|
+
.dsws_meta{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
239
|
+
.dsws_status{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
|
|
240
|
+
.dsws_error{color:var(--dsw-alias-state-error-primary);padding:8px;font-size:12px;line-height:18px}
|
|
241
|
+
.dsws_empty{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
|
|
242
|
+
.dsws_backdrop{position:fixed;inset:0;z-index:2147482999;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:blur(var(--dsw-mask-blur,4px))}
|
|
243
|
+
.dsws_setRow{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
244
|
+
.dsws_setRow:last-child{border-bottom:none}
|
|
245
|
+
.dsws_setText{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
246
|
+
.dsws_setTitle{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
|
|
247
|
+
.dsws_setDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
|
248
|
+
.dsws_seg{display:inline-flex;align-items:center;gap:2px;background:var(--dsw-alias-interactive-bg-hover);border-radius:8px;padding:2px;flex:none}
|
|
249
|
+
.dsws_segBtn{height:24px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:6px;padding:0 10px;font-size:12px;font-weight:500;line-height:20px}
|
|
250
|
+
.dsws_segBtn:hover{color:var(--dsw-alias-label-primary)}
|
|
251
|
+
.dsws_segBtn:disabled{cursor:not-allowed;opacity:.5}
|
|
252
|
+
.dsws_segBtnActive{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.08)}
|
|
253
|
+
.dsws_actBtn{height:26px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:8px;padding:0 10px;font-size:12px;line-height:24px;white-space:nowrap}
|
|
254
|
+
.dsws_actBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
255
|
+
.dsws_actBtn:disabled{cursor:not-allowed;opacity:.5}
|
|
256
|
+
.dsws_btnRow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
257
|
+
.dsws_progressWrap{display:flex;flex-direction:column;gap:4px;padding:8px 10px 0}
|
|
258
|
+
.dsws_progress{height:6px;border-radius:3px;background:var(--dsw-alias-interactive-bg-hover);overflow:hidden}
|
|
259
|
+
.dsws_progressFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-business-primary);transition:width .4s ease}
|
|
260
|
+
.dsws_progressLabel{color:var(--dsw-alias-state-business-primary);font-size:12px;line-height:18px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
261
|
+
.dsws_panelFoot{flex:none;display:flex;align-items:center;gap:14px;padding:8px 14px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
|
|
262
|
+
.dsws_footItem{display:inline-flex;align-items:center}
|
|
263
|
+
.dsws_footItem>.dsws_kbd{margin-right:5px}
|
|
264
|
+
.dsws_footGap{flex:1;min-width:0}
|
|
265
|
+
.dsws_buttonLabel{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
266
|
+
.dsws_kbd{flex:none;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:18px;width:auto;height:18px;padding:0 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:10px;line-height:1;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
267
|
+
/*
|
|
268
|
+
* The row shares its width with the session-steward entry (order 12), so at
|
|
269
|
+
* narrow sidebar widths the shortcut chip, not the label, is what yields: the
|
|
270
|
+
* chip is a hint repeated by the entry tooltip and the panel footer, while the
|
|
271
|
+
* label is the entry's identity. Measured in Chrome: the pair needs 230px for
|
|
272
|
+
* both labels plus the chip; below that the chip's 43px is what makes the
|
|
273
|
+
* difference. The query is scoped to the wide form: container-type also
|
|
274
|
+
* applies inline-size containment, which zeroes a flex:none rail root.
|
|
275
|
+
*/
|
|
276
|
+
@container (max-width: 132px){.dsws_kbd{display:none}}
|
|
277
|
+
.dsws_pill{flex:none;display:inline-grid;grid-template-columns:14px max-content;align-items:center;column-gap:4px;height:26px;padding:0 10px;box-sizing:border-box;border:none;border-radius:8px;font-size:12px;font-weight:500;line-height:18px;white-space:nowrap;transition:background-color 160ms ease-out,color 160ms ease-out}
|
|
278
|
+
.dsws_pill .dsws_pillIcon{display:grid;place-items:center;width:14px;height:14px}
|
|
279
|
+
.dsws_pill .dsws_pillLabel{display:grid;text-align:left}
|
|
280
|
+
.dsws_pill .dsws_pillLabel>span{grid-area:1/1}
|
|
281
|
+
.dsws_pillReady{background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-state-success-primary)}
|
|
282
|
+
.dsws_pillWarn{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label)}
|
|
283
|
+
.dsws_pillError{background:var(--dsw-alias-state-error-tertiary,var(--dsw-alias-state-warn-tertiary));color:var(--dsw-alias-state-error-primary,var(--dsw-alias-state-warn-label))}
|
|
284
|
+
.dsws_pillNeutral{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
285
|
+
.dsws_pillDots span{opacity:0;animation:dsws-reveal-dot 1.5s step-end infinite}
|
|
286
|
+
.dsws_pillDots span:nth-child(2){animation-delay:.5s}
|
|
287
|
+
.dsws_pillDots span:nth-child(3){animation-delay:1s}
|
|
288
|
+
@keyframes dsws-reveal-dot{0%,32%{opacity:0}33%,100%{opacity:1}}
|
|
289
|
+
@media (prefers-reduced-motion:reduce){.dsws_pillDots span{animation:none;opacity:1}}
|
|
290
|
+
`
|
|
291
|
+
|
|
292
|
+
/** Inject the plugin stylesheet once per activation (removed on disposal). */
|
|
293
|
+
export function injectStyles(): () => void {
|
|
294
|
+
if (typeof document === 'undefined') return () => {}
|
|
295
|
+
if (document.querySelector('style[data-plugin-css="dsw-session-search-toggle/styles"]') !== null) return () => {}
|
|
296
|
+
const tag = document.createElement('style')
|
|
297
|
+
tag.dataset.plugin = 'dsh-search-index'
|
|
298
|
+
tag.dataset.pluginCss = 'dsw-session-search-toggle/styles'
|
|
299
|
+
tag.textContent = CSS
|
|
300
|
+
document.head.appendChild(tag)
|
|
301
|
+
return () => {
|
|
302
|
+
if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** ------------------------------------------------------------------ view */
|
|
307
|
+
|
|
308
|
+
/** The floating search panel. */
|
|
309
|
+
function SwitchPanel({
|
|
310
|
+
t,
|
|
311
|
+
onClose,
|
|
312
|
+
open,
|
|
313
|
+
}: {
|
|
314
|
+
t?: CardLocale
|
|
315
|
+
onClose: () => void
|
|
316
|
+
open: (sessionId: string) => void
|
|
317
|
+
}): ReactElement {
|
|
318
|
+
// Mode memory: the panel reopens in the mode last used in this web session
|
|
319
|
+
// (first open falls back to 'title'). Session-scoped on purpose — no
|
|
320
|
+
// persistence, the settings card's defaultMode stays the durable preference.
|
|
321
|
+
const [mode, setModeState] = useState<PanelMode>(lastPanelMode)
|
|
322
|
+
const setMode = (next: PanelMode): void => {
|
|
323
|
+
lastPanelMode = next
|
|
324
|
+
setModeState(next)
|
|
325
|
+
}
|
|
326
|
+
const [query, setQuery] = useState('')
|
|
327
|
+
const [contentType, setContentType] = useState<ContentType>('all')
|
|
328
|
+
// 检索域 chip:标题/内容两模式共用(R1——归档正文已入索引)。
|
|
329
|
+
const [archivedFilter, setArchivedFilter] = useState<ArchivedDomain>('all')
|
|
330
|
+
// 日期范围(R6):YYYY-MM-DD,空串 = 不限;结束日含当天(宿主半排他上界 +1d)。
|
|
331
|
+
const [dateFrom, setDateFrom] = useState('')
|
|
332
|
+
const [dateTo, setDateTo] = useState('')
|
|
333
|
+
const [sortBy, setSortByState] = useState<HostSortMode>(readStoredSort)
|
|
334
|
+
const setSortBy = (next: HostSortMode): void => {
|
|
335
|
+
writeStoredSort(next)
|
|
336
|
+
setSortByState(next)
|
|
337
|
+
}
|
|
338
|
+
const [sessions, setSessions] = useState<HostSessionItem[] | null>(null)
|
|
339
|
+
const [sessionsError, setSessionsError] = useState<string | null>(null)
|
|
340
|
+
const [content, setContent] = useState<{ query: string; status: 'idle' | 'loading' | 'ready' | 'error'; items: HostContentHit[]; error?: string }>({
|
|
341
|
+
query: '',
|
|
342
|
+
status: 'idle',
|
|
343
|
+
items: [],
|
|
344
|
+
})
|
|
345
|
+
// Independent-index availability probe + rebuild progress.
|
|
346
|
+
const [searchStatus, setSearchStatus] = useState<{
|
|
347
|
+
available: boolean | null
|
|
348
|
+
reason?: string
|
|
349
|
+
rebuilding: boolean
|
|
350
|
+
done: number
|
|
351
|
+
total: number
|
|
352
|
+
count: number
|
|
353
|
+
}>({ available: null, rebuilding: false, done: 0, total: 0, count: 0 })
|
|
354
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
355
|
+
const listRef = useRef<HTMLUListElement | null>(null)
|
|
356
|
+
|
|
357
|
+
const normalized = query.trim().toLowerCase()
|
|
358
|
+
|
|
359
|
+
// Probe the independent index status on open, and keep polling while a
|
|
360
|
+
// rebuild ("整理") is in flight so the panel flips to ready on completion.
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
let cancelled = false
|
|
363
|
+
let timer: number | undefined
|
|
364
|
+
const probe = (): void => {
|
|
365
|
+
callHostAny<HostIndexStatus>('index-status', {}).then((res) => {
|
|
366
|
+
if (cancelled) return
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
// Old host half without this route (browser refresh keeps the old
|
|
369
|
+
// process): show an actionable state instead of a blank panel.
|
|
370
|
+
setSearchStatus({ available: false, reason: 'unreachable', rebuilding: false, done: 0, total: 0, count: 0 })
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
const status = res as HostIndexStatus
|
|
374
|
+
const rebuilding = status.rebuild?.state === 'building' || status.rebuild?.state === 'swapping'
|
|
375
|
+
setSearchStatus({
|
|
376
|
+
available: status.available ?? false,
|
|
377
|
+
reason: status.reason,
|
|
378
|
+
rebuilding,
|
|
379
|
+
done: status.rebuild?.done ?? 0,
|
|
380
|
+
total: status.rebuild?.total ?? 0,
|
|
381
|
+
count: status.sync?.indexed ?? 0,
|
|
382
|
+
})
|
|
383
|
+
if (rebuilding) timer = window.setTimeout(probe, 1500)
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
probe()
|
|
387
|
+
return () => {
|
|
388
|
+
cancelled = true
|
|
389
|
+
if (timer !== undefined) window.clearTimeout(timer)
|
|
390
|
+
}
|
|
391
|
+
}, [])
|
|
392
|
+
|
|
393
|
+
const startIndexBuild = (): void => {
|
|
394
|
+
setSearchStatus(prev => ({ ...prev, rebuilding: true }))
|
|
395
|
+
void callHostAny<HostIndexStatus>('index-rebuild', {})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Load the title-search corpus once on open.
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (sessions !== null) return
|
|
401
|
+
let cancelled = false
|
|
402
|
+
callHost<HostSessionItem>('list-sessions', {}).then((res) => {
|
|
403
|
+
if (cancelled) return
|
|
404
|
+
if (res.ok) { setSessions(res.items); setSessionsError(null) }
|
|
405
|
+
else setSessionsError(res.error ?? '读取会话列表失败')
|
|
406
|
+
})
|
|
407
|
+
return () => { cancelled = true }
|
|
408
|
+
}, [sessions])
|
|
409
|
+
|
|
410
|
+
// Content search debounces against the host route.
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (mode !== 'content' || normalized === '') {
|
|
413
|
+
if (mode !== 'content') setContent({ query: normalized, status: 'idle', items: [] })
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
let cancelled = false
|
|
417
|
+
const requestType: ContentType = contentType
|
|
418
|
+
const requestKey = contentRequestKey(normalized, requestType, sortBy, archivedFilter, { from: dateFrom, to: dateTo })
|
|
419
|
+
setContent(prev => ({ query: requestKey, status: 'loading', items: prev.query === requestKey ? prev.items : [] }))
|
|
420
|
+
const timer = window.setTimeout(() => {
|
|
421
|
+
callHost<HostContentHit>('content-search', {
|
|
422
|
+
query: normalized,
|
|
423
|
+
limit: 50,
|
|
424
|
+
types: requestType === 'all' ? undefined : [requestType],
|
|
425
|
+
sortBy,
|
|
426
|
+
archived: archivedFilter,
|
|
427
|
+
from: dateFrom === '' ? undefined : dateFrom,
|
|
428
|
+
to: dateTo === '' ? undefined : dateTo,
|
|
429
|
+
}).then((res) => {
|
|
430
|
+
if (cancelled) return
|
|
431
|
+
setContent({
|
|
432
|
+
query: requestKey,
|
|
433
|
+
status: res.ok ? 'ready' : 'error',
|
|
434
|
+
items: res.ok ? sortHits(res.items, sortBy) : [],
|
|
435
|
+
error: res.ok ? undefined : (res.error ?? '搜索失败'),
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
}, 250)
|
|
439
|
+
return () => {
|
|
440
|
+
cancelled = true
|
|
441
|
+
window.clearTimeout(timer)
|
|
442
|
+
}
|
|
443
|
+
}, [mode, normalized, contentType, sortBy, archivedFilter, dateFrom, dateTo])
|
|
444
|
+
|
|
445
|
+
// Focus the input on open; reset mode on every open.
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
inputRef.current?.focus()
|
|
448
|
+
}, [])
|
|
449
|
+
|
|
450
|
+
// Escape closes the panel.
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() }
|
|
453
|
+
document.addEventListener('keydown', onKey)
|
|
454
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
455
|
+
}, [onClose])
|
|
456
|
+
|
|
457
|
+
// Title-mode rows: local substring filter over the corpus + domain chip.
|
|
458
|
+
// 标题模式的日期过滤走本地(语料行自带 updatedAt;结束日含当天)。
|
|
459
|
+
const dateRangeMs = useMemo<{ from?: number; to?: number }>(() => {
|
|
460
|
+
const parse = (value: string, nextDay: boolean): number | undefined => {
|
|
461
|
+
if (value === '') return undefined
|
|
462
|
+
const [year, month, day] = value.split('-').map(Number)
|
|
463
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return undefined
|
|
464
|
+
const date = new Date(year, month - 1, day)
|
|
465
|
+
if (nextDay) date.setDate(date.getDate() + 1)
|
|
466
|
+
return date.getTime()
|
|
467
|
+
}
|
|
468
|
+
const from = parse(dateFrom, false)
|
|
469
|
+
const to = parse(dateTo, true)
|
|
470
|
+
return { ...(from === undefined ? {} : { from }), ...(to === undefined ? {} : { to }) }
|
|
471
|
+
}, [dateFrom, dateTo])
|
|
472
|
+
|
|
473
|
+
const titleRows = useMemo<HostSessionItem[]>(() => {
|
|
474
|
+
if (sessions === null) return []
|
|
475
|
+
const byDomain = sessions.filter(item =>
|
|
476
|
+
archivedFilter === 'active' ? item.archived !== true
|
|
477
|
+
: archivedFilter === 'archived' ? item.archived === true
|
|
478
|
+
: true)
|
|
479
|
+
const byDate = byDomain.filter(item =>
|
|
480
|
+
(dateRangeMs.from === undefined || item.updatedAt >= dateRangeMs.from)
|
|
481
|
+
&& (dateRangeMs.to === undefined || item.updatedAt < dateRangeMs.to))
|
|
482
|
+
if (normalized === '') return byDate
|
|
483
|
+
return byDomain.filter(item =>
|
|
484
|
+
item.title.toLowerCase().includes(normalized)
|
|
485
|
+
|| item.cwd.toLowerCase().includes(normalized))
|
|
486
|
+
}, [sessions, normalized, archivedFilter, dateRangeMs])
|
|
487
|
+
|
|
488
|
+
const children: ReactElement[] = []
|
|
489
|
+
if (sessionsError !== null) {
|
|
490
|
+
children.push(createElement('div', { key: 'err', className: 'dsws_error' }, translate(t, 'panel.sessionsError', { error: sessionsError })))
|
|
491
|
+
}
|
|
492
|
+
const activeRequestKey = contentRequestKey(normalized, contentType, sortBy, archivedFilter, { from: dateFrom, to: dateTo })
|
|
493
|
+
const activeContent = content.query === activeRequestKey ? content : { query: activeRequestKey, status: 'loading' as const, items: [] }
|
|
494
|
+
if (mode === 'title') {
|
|
495
|
+
if (sessions === null) {
|
|
496
|
+
children.push(createElement('div', { key: 'loading', className: 'dsws_status' }, translate(t, 'panel.loadingSessions')))
|
|
497
|
+
} else if (titleRows.length === 0) {
|
|
498
|
+
children.push(createElement('div', { key: 'empty', className: 'dsws_empty' }, normalized === '' ? translate(t, 'panel.noSessions') : translate(t, 'panel.noMatch')))
|
|
499
|
+
} else {
|
|
500
|
+
children.push(createElement('ul', {
|
|
501
|
+
key: 'list',
|
|
502
|
+
ref: listRef,
|
|
503
|
+
className: 'dsws_list',
|
|
504
|
+
role: 'listbox',
|
|
505
|
+
'aria-label': translate(t, 'panel.titleSearch'),
|
|
506
|
+
}, titleRows.slice(0, 200).map(item => createElement('li', { key: item.sessionId, role: 'option' }, createElement('button', {
|
|
507
|
+
type: 'button',
|
|
508
|
+
className: 'dsws_row',
|
|
509
|
+
onClick: () => { open(item.sessionId) },
|
|
510
|
+
}, [
|
|
511
|
+
createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
|
|
512
|
+
createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
|
|
513
|
+
item.archived === true && createElement('span', { key: 'arch', className: 'dsws_tagArch' }, translate(t, 'tag.archived')),
|
|
514
|
+
createElement('span', { key: 'tag', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
|
|
515
|
+
]),
|
|
516
|
+
item.cwd !== '' && createElement('span', { key: 'c', className: 'dsws_meta' }, item.cwd),
|
|
517
|
+
])))))
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
if (searchStatus.rebuilding) {
|
|
521
|
+
const pct = searchStatus.total > 0
|
|
522
|
+
? Math.min(100, Math.round((searchStatus.done / searchStatus.total) * 100))
|
|
523
|
+
: undefined
|
|
524
|
+
children.push(createElement('div', { key: 'rebuilding', className: 'dsws_progressWrap' }, [
|
|
525
|
+
createElement('div', { key: 'bar', className: 'dsws_progress' },
|
|
526
|
+
createElement('div', {
|
|
527
|
+
className: 'dsws_progressFill',
|
|
528
|
+
style: pct === undefined ? { width: '30%', opacity: 0.6 } : { width: `${pct}%` },
|
|
529
|
+
})),
|
|
530
|
+
createElement('span', { key: 'label', className: 'dsws_progressLabel' },
|
|
531
|
+
translate(t, 'panel.rebuilding', { done: searchStatus.done, total: searchStatus.total || '?' })),
|
|
532
|
+
]))
|
|
533
|
+
} else if (searchStatus.available === false) {
|
|
534
|
+
children.push(createElement('div', { key: 'unavailable', className: 'dsws_error' }, [
|
|
535
|
+
createElement('div', { key: 'msg' },
|
|
536
|
+
searchStatus.reason === 'unreachable'
|
|
537
|
+
? '索引状态不可达:Host 半可能是旧进程。请完全重启 dsh web(浏览器刷新不重载 Host)后重试。'
|
|
538
|
+
: searchStatus.reason === 'unavailable'
|
|
539
|
+
? translate(t, 'panel.unavailable')
|
|
540
|
+
: translate(t, 'panel.notBuilt')),
|
|
541
|
+
createElement('button', {
|
|
542
|
+
key: 'build',
|
|
543
|
+
type: 'button',
|
|
544
|
+
className: 'dsws_actBtn',
|
|
545
|
+
style: { marginTop: '6px' },
|
|
546
|
+
onClick: startIndexBuild,
|
|
547
|
+
}, translate(t, 'panel.buildIndex')),
|
|
548
|
+
]))
|
|
549
|
+
} else if (activeContent.status === 'loading') {
|
|
550
|
+
children.push(createElement('div', { key: 'loading', className: 'dsws_status' }, translate(t, 'panel.loadingContent')))
|
|
551
|
+
} else if (activeContent.status === 'error') {
|
|
552
|
+
children.push(createElement('div', { key: 'error', className: 'dsws_error' }, translate(t, 'panel.contentError', { error: activeContent.error ?? '未知错误' })))
|
|
553
|
+
} else if (activeContent.items.length === 0) {
|
|
554
|
+
children.push(createElement('div', { key: 'empty', className: 'dsws_empty' }, normalized === '' ? translate(t, 'panel.contentHint') : translate(t, 'panel.noContent')))
|
|
555
|
+
} else {
|
|
556
|
+
children.push(createElement('ul', {
|
|
557
|
+
key: 'list',
|
|
558
|
+
ref: listRef,
|
|
559
|
+
className: 'dsws_list',
|
|
560
|
+
role: 'listbox',
|
|
561
|
+
'aria-label': translate(t, 'panel.contentSearch'),
|
|
562
|
+
}, activeContent.items.slice(0, 200).map(item => createElement('li', { key: item.sessionId, role: 'option' }, createElement('button', {
|
|
563
|
+
type: 'button',
|
|
564
|
+
className: 'dsws_row',
|
|
565
|
+
onClick: () => { open(item.sessionId) },
|
|
566
|
+
}, [
|
|
567
|
+
createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
|
|
568
|
+
createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
|
|
569
|
+
createElement('span', { key: 'tag', className: 'dsws_tag' }, typeLabel(t, item.type)),
|
|
570
|
+
// The session clock — the very field the 「时间」 ordering sorts by and
|
|
571
|
+
// the one the title rows already show. Ordering rows by a time the
|
|
572
|
+
// rows never display is illegible: you cannot tell what the sort did.
|
|
573
|
+
createElement('span', { key: 'time', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
|
|
574
|
+
]),
|
|
575
|
+
createElement('span', { key: 's', className: 'dsws_snippet' }, item.snippet || translate(t, 'panel.noText')),
|
|
576
|
+
])))))
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return createPortal(createElement('div', { key: 'switch-root' }, [
|
|
581
|
+
createElement('div', { key: 'backdrop', className: 'dsws_backdrop', onClick: onClose }),
|
|
582
|
+
createElement('div', { key: 'panel', className: 'dsws_trigger', role: 'dialog', 'aria-label': translate(t, 'card.title') }, [
|
|
583
|
+
createElement('div', { key: 'tools', className: 'dsws_toolrow' }, [
|
|
584
|
+
createElement('div', { key: 'mode', className: 'dsws_mode', role: 'group', 'aria-label': translate(t, 'card.defaultMode') }, [
|
|
585
|
+
createElement('button', {
|
|
586
|
+
key: 'title',
|
|
587
|
+
type: 'button',
|
|
588
|
+
className: `dsws_modeBtn${mode === 'title' ? ' dsws_modeBtnActive' : ''}`,
|
|
589
|
+
onClick: () => { setMode('title') },
|
|
590
|
+
}, translate(t, 'panel.titleSearch')),
|
|
591
|
+
createElement('button', {
|
|
592
|
+
key: 'content',
|
|
593
|
+
type: 'button',
|
|
594
|
+
className: `dsws_modeBtn${mode === 'content' ? ' dsws_modeBtnActive' : ''}`,
|
|
595
|
+
onClick: () => { setMode('content') },
|
|
596
|
+
}, translate(t, 'panel.contentSearch')),
|
|
597
|
+
]),
|
|
598
|
+
createElement('input', {
|
|
599
|
+
key: 'search',
|
|
600
|
+
ref: inputRef,
|
|
601
|
+
className: 'dsws_search',
|
|
602
|
+
type: 'text',
|
|
603
|
+
placeholder: mode === 'title' ? translate(t, 'panel.searchTitle') : translate(t, 'panel.searchContent'),
|
|
604
|
+
value: query,
|
|
605
|
+
onChange: (e: { target: { value: string } }) => setQuery(e.target.value),
|
|
606
|
+
}),
|
|
607
|
+
]),
|
|
608
|
+
createElement('div', { key: 'chips', className: 'dsws_chips', role: 'group', 'aria-label': translate(t, 'domain.all') }, [
|
|
609
|
+
// 检索域 chips(全部/活跃/归档)在前——标题与内容两模式共用。
|
|
610
|
+
...ARCHIVED_CHIPS.map(chip => createElement('button', {
|
|
611
|
+
key: chip.id,
|
|
612
|
+
type: 'button',
|
|
613
|
+
className: `dsws_chip${archivedFilter === chip.id ? ' dsws_chipActive' : ''}`,
|
|
614
|
+
'aria-pressed': archivedFilter === chip.id,
|
|
615
|
+
onClick: () => { setArchivedFilter(chip.id) },
|
|
616
|
+
}, translate(t, chip.labelKey))),
|
|
617
|
+
// 日期范围(R6):两个 date 输入,标题/内容共用;空 = 不限。
|
|
618
|
+
createElement('input', {
|
|
619
|
+
key: 'from', type: 'date', className: 'dsws_search', style: { flex: 'none', width: '128px', height: '24px', padding: '0 6px', fontSize: '12px' },
|
|
620
|
+
value: dateFrom, title: translate(t, 'date.from'),
|
|
621
|
+
onChange: (e: { target: { value: string } }) => { setDateFrom(e.target.value) },
|
|
622
|
+
}),
|
|
623
|
+
createElement('input', {
|
|
624
|
+
key: 'to', type: 'date', className: 'dsws_search', style: { flex: 'none', width: '128px', height: '24px', padding: '0 6px', fontSize: '12px' },
|
|
625
|
+
value: dateTo, title: translate(t, 'date.to'),
|
|
626
|
+
onChange: (e: { target: { value: string } }) => { setDateTo(e.target.value) },
|
|
627
|
+
}),
|
|
628
|
+
// 类型筛选与排序只属于内容模式。
|
|
629
|
+
...(mode === 'content' ? CONTENT_TYPE_CHIPS.map(chip => createElement('button', {
|
|
630
|
+
key: chip.id,
|
|
631
|
+
type: 'button',
|
|
632
|
+
className: `dsws_chip${contentType === chip.id ? ' dsws_chipActive' : ''}`,
|
|
633
|
+
'aria-pressed': contentType === chip.id,
|
|
634
|
+
onClick: () => { setContentType(chip.id) },
|
|
635
|
+
}, translate(t, chip.labelKey))) : []),
|
|
636
|
+
// Ordering sits on the same row, pushed right: it filters the same
|
|
637
|
+
// result set the type chips do, so it is not a separate toolbar.
|
|
638
|
+
createElement('span', { key: 'gap', className: 'dsws_chipGap' }),
|
|
639
|
+
...(mode === 'content' ? [createElement('span', { key: 'sort', className: 'dsws_sortGroup', role: 'group', 'aria-label': translate(t, 'sort.label') },
|
|
640
|
+
SORT_CHIPS.map(chip => createElement('button', {
|
|
641
|
+
key: chip.id,
|
|
642
|
+
type: 'button',
|
|
643
|
+
className: `dsws_chip${sortBy === chip.id ? ' dsws_chipActive' : ''}`,
|
|
644
|
+
'aria-pressed': sortBy === chip.id,
|
|
645
|
+
title: chip.id === 'time' ? translate(t, 'sort.time.hint') : translate(t, 'sort.relevance.hint'),
|
|
646
|
+
onClick: () => { setSortBy(chip.id) },
|
|
647
|
+
}, translate(t, chip.labelKey))))] : []),
|
|
648
|
+
]),
|
|
649
|
+
children,
|
|
650
|
+
// The key bar. It advertises only chords that are actually bound: the
|
|
651
|
+
// invoke chord (see `SwitchFooter`) and Escape (see the effect above).
|
|
652
|
+
// A key cap for a key nothing listens to is a lie the user has to
|
|
653
|
+
// discover by pressing it.
|
|
654
|
+
createElement('div', { key: 'foot', className: 'dsws_panelFoot' }, [
|
|
655
|
+
createElement('span', { key: 'index', className: 'dsws_footItem' }, [
|
|
656
|
+
createElement('span', { key: 'label' }, searchStatus.rebuilding
|
|
657
|
+
? translate(t, 'panel.index.rebuilding', { done: searchStatus.done, total: searchStatus.total || '?' })
|
|
658
|
+
: searchStatus.available === false
|
|
659
|
+
? translate(t, 'panel.index.none')
|
|
660
|
+
: translate(t, 'panel.index.count', { n: searchStatus.count })),
|
|
661
|
+
createElement('button', {
|
|
662
|
+
key: 'rebuild',
|
|
663
|
+
type: 'button',
|
|
664
|
+
className: 'dsws_actBtn',
|
|
665
|
+
style: { height: '20px', padding: '0 8px', fontSize: '11px', lineHeight: '18px' },
|
|
666
|
+
disabled: searchStatus.rebuilding,
|
|
667
|
+
title: translate(t, 'card.index.rebuild.hint'),
|
|
668
|
+
onClick: startIndexBuild,
|
|
669
|
+
}, searchStatus.rebuilding ? translate(t, 'card.index.rebuilding.btn') : translate(t, 'card.index.rebuild')),
|
|
670
|
+
]),
|
|
671
|
+
createElement('span', { key: 'invoke', className: 'dsws_footItem' }, [
|
|
672
|
+
createElement('kbd', { key: 'k', className: 'dsws_kbd' }, LABELS.invokeLabel),
|
|
673
|
+
translate(t, 'panel.footer.invoke'),
|
|
674
|
+
]),
|
|
675
|
+
createElement('span', { key: 'gap', className: 'dsws_footGap' }),
|
|
676
|
+
createElement('span', { key: 'close', className: 'dsws_footItem' }, [
|
|
677
|
+
createElement('kbd', { key: 'k', className: 'dsws_kbd' }, LABELS.escLabel),
|
|
678
|
+
translate(t, 'panel.footer.close'),
|
|
679
|
+
]),
|
|
680
|
+
]),
|
|
681
|
+
]),
|
|
682
|
+
]), document.body)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Whether the user has the sidebar entry switched on.
|
|
687
|
+
*
|
|
688
|
+
* The `enabled` field has existed since the card shipped, but nothing read it:
|
|
689
|
+
* the switch promised "show the search entry at the bottom of the sidebar" and
|
|
690
|
+
* controlled nothing at all. Reading it here is the whole fix.
|
|
691
|
+
*
|
|
692
|
+
* An absent or unreadable scope keeps the entry visible — a settings service we
|
|
693
|
+
* cannot read is not a user asking for the feature off, and hiding the entry
|
|
694
|
+
* would also hide the only way back to the panel.
|
|
695
|
+
*
|
|
696
|
+
* @param scope - the bound `switch-search` namespace scope, when available.
|
|
697
|
+
* @returns `false` only when a readable scope says the entry is switched off.
|
|
698
|
+
*/
|
|
699
|
+
function useEntryEnabled(scope: SwitchCardScope | undefined): boolean {
|
|
700
|
+
const snapshot = useSyncExternalStore(
|
|
701
|
+
(listener) => scope?.subscribe(listener) ?? (() => {}),
|
|
702
|
+
() => scope?.getSnapshot(),
|
|
703
|
+
)
|
|
704
|
+
return snapshot?.value?.enabled !== false
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** The footer entry: one icon button that opens the search panel. */
|
|
708
|
+
export function SwitchFooter({
|
|
709
|
+
t,
|
|
710
|
+
wide,
|
|
711
|
+
open,
|
|
712
|
+
scope,
|
|
713
|
+
}: SwitchFooterProps & { t?: CardLocale; open: (sessionId: string) => void; scope?: SwitchCardScope }): ReactElement | null {
|
|
714
|
+
const [openPanel, setOpenPanel] = useState(false)
|
|
715
|
+
const enabled = useEntryEnabled(scope)
|
|
716
|
+
|
|
717
|
+
// The invoke chord. Bound here, next to the entry, so the shortcut exists
|
|
718
|
+
// exactly while the entry does: a sidebar button that the `enabled` switch
|
|
719
|
+
// hid, but a chord that still opened a panel out of nowhere, would be two
|
|
720
|
+
// answers to "is this plugin on". The match itself lives in `platform.ts`
|
|
721
|
+
// so the chord and the `⌘K`/`Ctrl K` chip cannot disagree.
|
|
722
|
+
useEffect(() => {
|
|
723
|
+
if (!enabled) return undefined
|
|
724
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
725
|
+
if (!isInvokeChord(event, LABELS.isMac)) return
|
|
726
|
+
event.preventDefault()
|
|
727
|
+
setOpenPanel(true)
|
|
728
|
+
}
|
|
729
|
+
document.addEventListener('keydown', onKey)
|
|
730
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
731
|
+
}, [enabled])
|
|
732
|
+
|
|
733
|
+
// Hook order is fixed above; the early return sits after every hook.
|
|
734
|
+
if (!enabled) return null
|
|
735
|
+
|
|
736
|
+
return createElement('div', { className: wide ? 'dsws_root' : 'dsws_root dsws_rootRail' }, [
|
|
737
|
+
createElement('button', {
|
|
738
|
+
key: 'btn',
|
|
739
|
+
type: 'button',
|
|
740
|
+
className: wide ? 'dsws_button' : 'dsws_button dsws_buttonRail',
|
|
741
|
+
title: `${translate(t, 'panel.entry')}(${LABELS.invokeLabel})`,
|
|
742
|
+
'aria-label': `${translate(t, 'panel.entry')}(${translate(t, 'panel.titleSearch')} / ${translate(t, 'panel.contentSearch')},${LABELS.invokeLabel})`,
|
|
743
|
+
'aria-expanded': openPanel,
|
|
744
|
+
onClick: () => { setOpenPanel(true) },
|
|
745
|
+
}, [
|
|
746
|
+
searchIcon(),
|
|
747
|
+
// The label and the key chip carry the same `wide` condition: a chip
|
|
748
|
+
// beside an icon-only entry would be the only thing in the collapsed
|
|
749
|
+
// rail, and `.dsws_button` clips rather than wraps.
|
|
750
|
+
wide && createElement('span', { key: 'label', className: 'dsws_buttonLabel' }, translate(t, 'panel.entry')),
|
|
751
|
+
wide && createElement('kbd', { key: 'key', className: 'dsws_kbd' }, LABELS.invokeLabel),
|
|
752
|
+
]),
|
|
753
|
+
openPanel && createElement(SwitchPanel, {
|
|
754
|
+
key: 'panel',
|
|
755
|
+
t,
|
|
756
|
+
onClose: () => { setOpenPanel(false) },
|
|
757
|
+
open: (sessionId: string) => {
|
|
758
|
+
setOpenPanel(false)
|
|
759
|
+
open(sessionId)
|
|
760
|
+
},
|
|
761
|
+
}),
|
|
762
|
+
])
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** ------------------------------------------------------------------ helpers */
|
|
766
|
+
|
|
767
|
+
/** Format an epoch-ms timestamp: today → HH:mm, else YYYY-MM-DD HH:mm. */
|
|
768
|
+
function fmtTime(ms: number): string {
|
|
769
|
+
if (!ms || typeof ms !== 'number') return ''
|
|
770
|
+
try {
|
|
771
|
+
const d = new Date(ms)
|
|
772
|
+
const now = new Date()
|
|
773
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
774
|
+
const sameDay = d.getFullYear() === now.getFullYear()
|
|
775
|
+
&& d.getMonth() === now.getMonth()
|
|
776
|
+
&& d.getDate() === now.getDate()
|
|
777
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
778
|
+
if (sameDay) return time
|
|
779
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
|
|
780
|
+
} catch {
|
|
781
|
+
return ''
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Short label for a content-hit event type. */
|
|
786
|
+
function typeLabel(t: CardLocale | undefined, type: string): string {
|
|
787
|
+
if (type === 'user/message' || type === 'assistant/message'
|
|
788
|
+
|| type === 'tool/call' || type === 'tool/result') {
|
|
789
|
+
return translate(t, `type.${type}` as LocaleKey)
|
|
790
|
+
}
|
|
791
|
+
return type
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Inline search icon (stroke aligned with the product's 1.75 hairline). */
|
|
795
|
+
function searchIcon(): ReactElement {
|
|
796
|
+
return createElement('svg', {
|
|
797
|
+
width: 14,
|
|
798
|
+
height: 14,
|
|
799
|
+
viewBox: '0 0 16 16',
|
|
800
|
+
fill: 'none',
|
|
801
|
+
'aria-hidden': true,
|
|
802
|
+
}, createElement('circle', {
|
|
803
|
+
cx: 7,
|
|
804
|
+
cy: 7,
|
|
805
|
+
r: 4.5,
|
|
806
|
+
stroke: 'currentColor',
|
|
807
|
+
strokeWidth: 1.75,
|
|
808
|
+
fill: 'none',
|
|
809
|
+
}), createElement('path', {
|
|
810
|
+
d: 'M10.5 10.5 L14 14',
|
|
811
|
+
stroke: 'currentColor',
|
|
812
|
+
strokeWidth: 1.75,
|
|
813
|
+
strokeLinecap: 'round',
|
|
814
|
+
}))
|
|
815
|
+
}
|