dsh-webseek 0.0.0-stage → 0.2.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/lib/client.js ADDED
@@ -0,0 +1,924 @@
1
+ /**
2
+ * dsh-webseek — browser half.
3
+ *
4
+ * The plugin's whole job now: a public DeepSeek **share link** becomes a DSH
5
+ * session. The entry point is one icon in the left sidebar — on the row that
6
+ * carries the workspace section header (search / view options / add
7
+ * workspace) — which opens a small dialog: paste the share link, pick the
8
+ * target workspace, import.
9
+ *
10
+ * ## Why the icon is mounted by hand
11
+ *
12
+ * DSH declares no slot for that header row: the sidebar's plugin-facing
13
+ * surfaces are `sidebar.footer.action` (the icon row above Settings),
14
+ * `sidebar.panellist` (the global panel icons) and the session-row slots. The
15
+ * workspace header is rendered inside the single-owner `sidebar.workspaces`
16
+ * region, so nothing can be added to it through a slot.
17
+ *
18
+ * The button therefore lives in a React root of our own, placed into the row
19
+ * directly. Both the row and the fallback are found through **stable hooks**:
20
+ * the slot system stamps `data-slot` on what it renders, so the region is
21
+ * `[data-slot="sidebar.workspaces"]` and the fallback is
22
+ * `[data-slot="sidebar.footer.action"]` — no CSS-module class names, which
23
+ * DSH re-hashes on every build.
24
+ *
25
+ * The dialog gets a second root on `<body>`: it must escape every transformed
26
+ * ancestor (the sidebar is animated), and `position: fixed` inside a
27
+ * transformed ancestor would be positioned against that ancestor instead of
28
+ * the window.
29
+ */
30
+ window.__ModuleLoader__.load({
31
+ id: 'dsh-webseek',
32
+ factory: (require) => {
33
+ const module = { exports: {} }
34
+ const exports = module.exports
35
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
36
+
37
+ const React = require('react')
38
+ const ReactDOM = require('react-dom')
39
+ const h = React.createElement
40
+
41
+ /** `createRoot` lives in 'react-dom/client' (React 18); older bundles expose it on 'react-dom'. */
42
+ let createRoot = null
43
+ try {
44
+ createRoot = require('react-dom/client').createRoot
45
+ } catch (error) {
46
+ createRoot = ReactDOM.createRoot
47
+ }
48
+
49
+ /** Locale namespace, and the i18n key for this plugin's copy. */
50
+ const NS = 'dsh-webseek'
51
+
52
+ /** The slot the workspace region is rendered into. */
53
+ const WORKSPACE_REGION = '[data-slot="sidebar.workspaces"]'
54
+
55
+ /** The plugin row above Settings — where the icon lands if the header row is gone. */
56
+ const FOOTER_ACTION = '[data-slot="sidebar.footer.action"]'
57
+
58
+ /** The class-name suffix of the workspace section header row. */
59
+ const SECTION_HEADER = '[class*="_sectionHeader"]'
60
+
61
+ /** The id of the workspace field's label, so the picker can name itself. */
62
+ const WORKSPACE_LABEL_ID = 'dsh-webseek-workspace-label'
63
+
64
+ /** The version shown in the dialog footer; a test keeps it equal to package.json. */
65
+ const VERSION = '0.2.0'
66
+
67
+ /** Where the footer links point. */
68
+ const REPO_URL = 'https://github.com/HelloQingTao/dsh-webseek'
69
+ const ISSUES_URL = REPO_URL + '/issues'
70
+
71
+ /** zh/en copy. Flat dotted keys — the locale service indexes one level. */
72
+ const COPY = {
73
+ zh: {
74
+ 'entry.title': '导入网页会话',
75
+ 'dialog.title': '导入网页会话',
76
+ 'dialog.link': '网页会话分享链接',
77
+ 'dialog.linkPlaceholder': 'https://chat.deepseek.com/share/xxxxxxxx',
78
+ 'dialog.workspace': '导入到工作区',
79
+ 'dialog.workspaceLoading': '正在读取工作区…',
80
+ 'dialog.workspaceEmpty': '没有可用工作区',
81
+ 'dialog.submit': '导入',
82
+ 'dialog.importing': '导入中…',
83
+ 'dialog.cancel': '取消',
84
+ 'dialog.done': '已导入到工作区',
85
+ 'dialog.already': '这个分享之前已经导入过了',
86
+ 'dialog.treeHint': '点「打开会话」直接跳过去;左栏那一行也可以手动点',
87
+ 'dialog.open': '打开会话',
88
+ 'dialog.opening': '正在打开…',
89
+ 'dialog.openTimeout': '左栏还没出现这一条,刷新一下 DSH 页面再试',
90
+ 'footer.feedback': '欢迎提 Issue 和建议',
91
+ 'footer.check': '检查更新',
92
+ 'footer.checking': '检查中…',
93
+ 'footer.latest': '已是最新',
94
+ 'footer.updateAvailable': '有新版本',
95
+ 'footer.notPublished': '尚未发布',
96
+ 'footer.checkFailed': '检查失败',
97
+ 'error.link': '链接格式不对:需要 chat.deepseek.com/share/… 或直接是分享 id',
98
+ 'error.workspace': '工作区不可用',
99
+ 'error.rate': '上游限流了,稍后再试一次',
100
+ 'error.gone': '分享不存在或已失效',
101
+ 'error.empty': '这个分享里没有可导入的内容',
102
+ 'error.upstream': '网页接口没有返回预期内容(可能改版了)',
103
+ 'error.timeout': '请求超时',
104
+ 'error.network': '网络不通',
105
+ 'error.large': '这个分享太大了',
106
+ 'error.unavailable': '会话服务不可用',
107
+ 'error.unknown': '导入失败',
108
+ },
109
+ en: {
110
+ 'entry.title': 'Import web conversation',
111
+ 'dialog.title': 'Import web conversation',
112
+ 'dialog.link': 'Share link',
113
+ 'dialog.linkPlaceholder': 'https://chat.deepseek.com/share/xxxxxxxx',
114
+ 'dialog.workspace': 'Import into workspace',
115
+ 'dialog.workspaceEmpty': 'No workspace available',
116
+ 'dialog.submit': 'Import',
117
+ 'dialog.importing': 'Importing…',
118
+ 'dialog.cancel': 'Cancel',
119
+ 'dialog.done': 'Imported into workspace',
120
+ 'dialog.already': 'This share was imported before',
121
+ 'dialog.treeHint': 'Use “Open conversation” to jump to it; the new sidebar row works too',
122
+ 'dialog.open': 'Open conversation',
123
+ 'dialog.opening': 'Opening…',
124
+ 'dialog.openTimeout': 'The row is not in the sidebar yet — refresh the page and try again',
125
+ 'footer.feedback': 'Issues & suggestions welcome',
126
+ 'footer.check': 'Check for updates',
127
+ 'footer.checking': 'Checking…',
128
+ 'footer.latest': 'Up to date',
129
+ 'footer.updateAvailable': 'New version',
130
+ 'footer.notPublished': 'Not published yet',
131
+ 'footer.checkFailed': 'Check failed',
132
+ 'error.link': 'Bad link: expected chat.deepseek.com/share/… or a bare share id',
133
+ 'error.workspace': 'Workspace unavailable',
134
+ 'error.rate': 'Upstream rate limit — try again later',
135
+ 'error.gone': 'The share is gone or was deleted',
136
+ 'error.empty': 'That share has nothing to import',
137
+ 'error.upstream': 'The web API did not answer as expected',
138
+ 'error.timeout': 'The request timed out',
139
+ 'error.network': 'Network unreachable',
140
+ 'error.large': 'That share is too large',
141
+ 'error.unavailable': 'Session services unavailable',
142
+ 'error.unknown': 'Import failed',
143
+ },
144
+ }
145
+
146
+ /** The bound binder, set once the locale service is injected. */
147
+ let localeBinder = null
148
+
149
+ /** Translate through the bound locale service with a zh fallback. */
150
+ const t = (key) => {
151
+ try {
152
+ if (localeBinder !== null) {
153
+ const value = localeBinder(key)
154
+ if (typeof value === 'string' && value.length > 0) return value
155
+ }
156
+ } catch { /* fall through to the static table */ }
157
+ const node = COPY.zh[key]
158
+ return typeof node === 'string' ? node : key
159
+ }
160
+
161
+ /**
162
+ * Map a host error code onto the copy key that explains it.
163
+ * @param {unknown} code - the host's `error` field.
164
+ * @returns {string} a copy key.
165
+ */
166
+ function errorKey(code) {
167
+ const text = typeof code === 'string' ? code : ''
168
+ if (text === 'invalid share link') return 'error.link'
169
+ if (text === 'unknown workspace' || text === 'workspaceRegistry unavailable') return 'error.workspace'
170
+ if (text === 'rate-limited') return 'error.rate'
171
+ if (text === 'not-found') return 'error.gone'
172
+ if (text === 'empty') return 'error.empty'
173
+ if (text === 'bad-response') return 'error.upstream'
174
+ if (text === 'timeout') return 'error.timeout'
175
+ if (text === 'network') return 'error.network'
176
+ if (text === 'too-large') return 'error.large'
177
+ if (text === 'session services unavailable') return 'error.unavailable'
178
+ return 'error.unknown'
179
+ }
180
+
181
+ /** The plugin's own styles, injected once. */
182
+ const STYLE_ID = 'dsh-webseek-style'
183
+
184
+ /**
185
+ * Put the plugin stylesheet into the document (once).
186
+ */
187
+ function ensureStyle() {
188
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID) !== null) return
189
+ const style = document.createElement('style')
190
+ style.id = STYLE_ID
191
+ // Every colour comes from DSH's own design tokens (`--dsw-*`), so the
192
+ // dialog follows the active theme — light or dark — without a second
193
+ // stylesheet. The fallbacks only matter when the plugin runs outside
194
+ // the shell.
195
+ style.textContent = [
196
+ '.dsh-webseek-entry{display:contents}',
197
+ '.dsh-webseek-btn{width:28px;height:28px;flex:none;display:inline-flex;align-items:center;justify-content:center;',
198
+ 'border:none;border-radius:var(--dsw-radius-sm,8px);background:transparent;',
199
+ 'color:var(--dsw-alias-label-secondary,#c9c9cf);cursor:pointer;padding:0}',
200
+ '.dsh-webseek-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}',
201
+ '.dsh-webseek-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#5b8cff))}',
202
+ '.dsh-webseek-btn:disabled{opacity:.45;cursor:default}',
203
+ // Overlay + panel, the way the shell's own modal layer does it.
204
+ '.dsh-webseek-mask{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;',
205
+ 'background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.4));backdrop-filter:var(--dsw-mask-blur,none)}',
206
+ '.dsh-webseek-modal{width:min(380px,100%);box-sizing:border-box;padding:24px;',
207
+ 'border-radius:var(--dsw-radius-panel,28px);overflow:visible;',
208
+ '--dsw-webseek-panel-base:var(--dsh-dream-skin-modal-base,var(--dsw-alias-bg-layer-2,#1d1d20));',
209
+ '--dsw-webseek-panel-fill:var(--dsh-dream-skin-modal-fill,100%);',
210
+ 'background:var(--dsw-alias-bg-layer-2,#1d1d20);',
211
+ 'background:color-mix(in srgb,var(--dsw-webseek-panel-base) var(--dsw-webseek-panel-fill),transparent);',
212
+ '-webkit-backdrop-filter:blur(var(--dsh-dream-skin-glass-blur,0px)) var(--dsh-dream-skin-glass-tone,none);',
213
+ 'backdrop-filter:blur(var(--dsh-dream-skin-glass-blur,0px)) var(--dsh-dream-skin-glass-tone,none);',
214
+ 'color:var(--dsw-alias-label-primary,#ececf0);box-shadow:var(--dsw-elevation-prominent,0 12px 40px rgba(0,0,0,.35))}',
215
+ '.dsh-webseek-modal-title{font-size:16px;line-height:24px;font-weight:600;margin-bottom:20px;',
216
+ 'color:var(--dsw-alias-label-primary,#ececf0)}',
217
+ '.dsh-webseek-field{display:block;margin-bottom:20px;font-size:13px;font-weight:500;line-height:1.5;',
218
+ 'color:var(--dsw-alias-label-primary,#ececf0)}',
219
+ // The input shell: one bordered surface, the label sits outside it.
220
+ '.dsh-webseek-input{display:flex;align-items:center;gap:6px;height:32px;padding:0 8px;margin-top:6px;',
221
+ 'box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4,rgba(255,255,255,.2));',
222
+ 'border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1,#202024)}',
223
+ '.dsh-webseek-input:focus-within{border-color:var(--dsw-alias-state-business-primary,#5b8cff)}',
224
+ '.dsh-webseek-input input{flex:1;min-width:0;border:none;outline:none;background:transparent;',
225
+ 'font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary,#ececf0)}',
226
+ '.dsh-webseek-input input::placeholder{color:var(--dsw-alias-label-dimmed,#8a8a8a)}',
227
+ '.dsh-webseek-input input[readonly]{color:var(--dsw-alias-label-tertiary,#8a8a8a)}',
228
+ // The workspace picker: a trigger that looks like the input, and
229
+ // the shell's own menu surface underneath it.
230
+ '.dsh-webseek-picker{position:relative;margin-top:6px}',
231
+ '.dsh-webseek-field-btn{width:100%;box-sizing:border-box;display:flex;align-items:center;gap:8px;',
232
+ 'height:32px;padding:0 8px;border:.5px solid var(--dsw-alias-border-l4,rgba(255,255,255,.2));',
233
+ 'border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1,#202024);',
234
+ 'color:var(--dsw-alias-label-primary,#ececf0);font-size:14px;line-height:22px;cursor:pointer;text-align:left}',
235
+ '.dsh-webseek-field-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}',
236
+ '.dsh-webseek-field-btn:disabled{cursor:not-allowed;opacity:.4}',
237
+ '.dsh-webseek-field-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
238
+ '.dsh-webseek-field-btn svg{flex:none;opacity:.6}',
239
+ '.dsh-webseek-menu{position:fixed;z-index:1100;',
240
+ 'max-height:240px;overflow-y:auto;box-sizing:border-box;padding:4px;',
241
+ 'display:flex;flex-direction:column;border:0;border-radius:var(--dsw-radius-lg,16px);',
242
+ '--dsw-webseek-menu-base:var(--dsh-dream-skin-modal-base,var(--dsw-specific-menu,rgba(48,49,54,.98)));',
243
+ '--dsw-webseek-menu-fill:var(--dsh-dream-skin-modal-fill,94%);',
244
+ 'background:var(--dsw-specific-menu,rgba(48,49,54,.98));',
245
+ 'background:color-mix(in srgb,var(--dsw-webseek-menu-base) var(--dsw-webseek-menu-fill),transparent);',
246
+ 'backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));',
247
+ 'box-shadow:var(--dsw-elevation-prominent,0 12px 40px rgba(0,0,0,.35));',
248
+ '--dsw-elevation-stroke-color:var(--dsw-alias-border-l1,rgba(255,255,255,.1));',
249
+ '--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2,rgba(255,255,255,.2));',
250
+ '--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2,rgba(255,255,255,.3))}',
251
+ '.dsh-webseek-menu-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;',
252
+ 'border:none;border-radius:var(--dsw-radius-md,12px);background:transparent;cursor:pointer;',
253
+ 'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#ececf0);text-align:left}',
254
+ '.dsh-webseek-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}',
255
+ '.dsh-webseek-menu-item.selected{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}',
256
+ '.dsh-webseek-menu-item svg{flex:none}',
257
+ '.dsh-webseek-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
258
+ '.dsh-webseek-error{font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary,#e05a5a);margin:0 0 12px}',
259
+ '.dsh-webseek-note{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary,#c9c9cf);margin:0 0 12px}',
260
+ '.dsh-webseek-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}',
261
+ '.dsh-webseek-dialog-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;',
262
+ 'gap:4px;border:none;border-radius:var(--dsw-radius-md,12px);cursor:pointer;font-size:14px;line-height:22px;',
263
+ 'padding:0 14px;background:transparent;color:var(--dsw-alias-label-primary,#ececf0)}',
264
+ '.dsh-webseek-dialog-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}',
265
+ '.dsh-webseek-dialog-btn:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active,rgba(128,128,128,.24))}',
266
+ '.dsh-webseek-dialog-btn:disabled{cursor:not-allowed;opacity:.4}',
267
+ '.dsh-webseek-dialog-btn.primary{background:var(--dsw-alias-button-primary-fill,#5b8cff);',
268
+ 'color:var(--dsw-alias-label-primary-foreground,#fff)}',
269
+ '.dsh-webseek-dialog-btn.primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#4d7ff0)}',
270
+ // Footer: project links + version + a live update check, kept quiet.
271
+ '.dsh-webseek-footer{margin-top:20px;padding-top:14px;display:flex;align-items:center;justify-content:space-between;',
272
+ 'gap:10px 12px;flex-wrap:wrap;border-top:.5px solid var(--dsw-alias-border-l3,rgba(255,255,255,.12));',
273
+ 'font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c9c9cf)}',
274
+ '.dsh-webseek-footer-links{display:flex;align-items:center;gap:8px;min-width:0}',
275
+ '.dsh-webseek-footer-link{color:var(--dsw-alias-label-secondary,#c9c9cf);text-decoration:none;cursor:pointer}',
276
+ '.dsh-webseek-footer-link:hover{color:var(--dsw-alias-state-business-primary,#5b8cff);text-decoration:underline}',
277
+ '.dsh-webseek-footer-dot{opacity:.5}',
278
+ '.dsh-webseek-footer-meta{display:flex;align-items:center;gap:8px;flex:none}',
279
+ '.dsh-webseek-footer-version{color:var(--dsw-alias-label-secondary,#c9c9cf);font-variant-numeric:tabular-nums}',
280
+ '.dsh-webseek-linkbtn{border:none;background:transparent;padding:0;cursor:pointer;font:inherit;',
281
+ 'color:var(--dsw-alias-label-secondary,#c9c9cf)}',
282
+ '.dsh-webseek-linkbtn:hover:not(:disabled){color:var(--dsw-alias-state-business-primary,#5b8cff);text-decoration:underline}',
283
+ '.dsh-webseek-linkbtn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#5b8cff));outline-offset:2px}',
284
+ '.dsh-webseek-linkbtn:disabled{cursor:default;opacity:.6}',
285
+ '.dsh-webseek-footer-status{white-space:nowrap}',
286
+ '.dsh-webseek-footer-status.ok{color:var(--dsw-alias-state-success-primary,#3ba55d)}',
287
+ '.dsh-webseek-footer-status.update{color:var(--dsw-alias-state-business-primary,#5b8cff)}',
288
+ '.dsh-webseek-footer-status.error{color:var(--dsw-alias-state-error-primary,#e05a5a)}',
289
+ ].join('')
290
+ document.head.appendChild(style)
291
+ }
292
+
293
+ /** The entry icon: an arrow dropping into a tray. */
294
+ function EntryIcon() {
295
+ return h('svg', {
296
+ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
297
+ 'aria-hidden': 'true',
298
+ },
299
+ h('path', {
300
+ d: 'M8 1.8v7.2M8 9 5.2 6.2M8 9l2.8-2.8M2.6 10.6v1.8a1.4 1.4 0 0 0 1.4 1.4h8a1.4 1.4 0 0 0 1.4-1.4v-1.8',
301
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round',
302
+ }),
303
+ )
304
+ }
305
+
306
+ /** The chevron of the workspace trigger. */
307
+ function ChevronIcon() {
308
+ return h('svg', {
309
+ width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true',
310
+ },
311
+ h('path', {
312
+ d: 'M4 6.5 8 10.5l4-4',
313
+ stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round',
314
+ }),
315
+ )
316
+ }
317
+
318
+ /** The check mark of the selected workspace. */
319
+ function CheckIcon() {
320
+ return h('svg', {
321
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true',
322
+ },
323
+ h('path', {
324
+ d: 'M3.5 8.5 6.5 11.5 12.5 4.5',
325
+ stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
326
+ }),
327
+ )
328
+ }
329
+
330
+ /**
331
+ * The workspace picker: a field-shaped trigger with the shell's menu
332
+ * below it.
333
+ *
334
+ * A native `<select>` cannot wear the shell's theme (its popup is
335
+ * drawn by the OS), so the dropdown is built from the same menu
336
+ * surface, menu item and hover tokens the shell uses.
337
+ * @param props - `{ workspaces, value, onChange }`.
338
+ * @returns React element.
339
+ */
340
+ function WorkspacePicker({ workspaces, value, onChange, labelId }) {
341
+ const [open, setOpen] = React.useState(false)
342
+ const [rect, setRect] = React.useState(null)
343
+ const wrapRef = React.useRef(null)
344
+ const menuRef = React.useRef(null)
345
+ const current = workspaces.find((workspace) => String(workspace.id) === String(value))
346
+ // The menu is portaled to `<body>` (like the shell's own menus) so
347
+ // the panel never clips it; its position follows the trigger.
348
+ const place = () => {
349
+ const node = wrapRef.current
350
+ if (node === null) return
351
+ const box = node.getBoundingClientRect()
352
+ const room = window.innerHeight - box.bottom
353
+ const flip = room < 220 && box.top > room
354
+ setRect({
355
+ left: box.left,
356
+ width: box.width,
357
+ top: flip ? null : box.bottom + 4,
358
+ bottom: flip ? window.innerHeight - box.top + 4 : null,
359
+ })
360
+ }
361
+ // Close on an outside click and on Escape, like the shell's menus.
362
+ React.useEffect(() => {
363
+ if (!open) return undefined
364
+ place()
365
+ const onPointerDown = (event) => {
366
+ const inTrigger = wrapRef.current !== null && wrapRef.current.contains(event.target)
367
+ const inMenu = menuRef.current !== null && menuRef.current.contains(event.target)
368
+ if (!inTrigger && !inMenu) setOpen(false)
369
+ }
370
+ const onKeyDown = (event) => {
371
+ if (event.key !== 'Escape') return
372
+ event.stopPropagation()
373
+ event.preventDefault()
374
+ setOpen(false)
375
+ }
376
+ document.addEventListener('pointerdown', onPointerDown, true)
377
+ document.addEventListener('keydown', onKeyDown, true)
378
+ window.addEventListener('resize', place)
379
+ return () => {
380
+ document.removeEventListener('pointerdown', onPointerDown, true)
381
+ document.removeEventListener('keydown', onKeyDown, true)
382
+ window.removeEventListener('resize', place)
383
+ }
384
+ }, [open])
385
+ const menu = open && rect !== null
386
+ ? ReactDOM.createPortal(
387
+ h('div', {
388
+ ref: menuRef,
389
+ className: 'dsh-webseek-menu',
390
+ role: 'listbox',
391
+ 'aria-labelledby': labelId,
392
+ style: {
393
+ left: rect.left + 'px',
394
+ width: rect.width + 'px',
395
+ top: rect.top === null ? undefined : rect.top + 'px',
396
+ bottom: rect.bottom === null ? undefined : rect.bottom + 'px',
397
+ },
398
+ },
399
+ workspaces.map((workspace) => h('button', {
400
+ key: String(workspace.id),
401
+ type: 'button',
402
+ role: 'option',
403
+ 'aria-selected': String(workspace.id) === String(value),
404
+ className: 'dsh-webseek-menu-item'
405
+ + (String(workspace.id) === String(value) ? ' selected' : ''),
406
+ onClick: () => { onChange(String(workspace.id)); setOpen(false) },
407
+ },
408
+ h('span', { className: 'dsh-webseek-menu-label' }, workspace.title),
409
+ String(workspace.id) === String(value) ? h(CheckIcon, null) : null,
410
+ )),
411
+ ),
412
+ document.body,
413
+ )
414
+ : null
415
+ return h('div', { className: 'dsh-webseek-picker', ref: wrapRef },
416
+ h('button', {
417
+ type: 'button',
418
+ className: 'dsh-webseek-field-btn',
419
+ 'aria-haspopup': 'listbox',
420
+ 'aria-expanded': open,
421
+ 'aria-labelledby': labelId,
422
+ onClick: () => { setOpen((isOpen) => !isOpen); place() },
423
+ },
424
+ h('span', { className: 'dsh-webseek-field-value' }, current === undefined ? '' : current.title),
425
+ h(ChevronIcon, null),
426
+ ),
427
+ menu,
428
+ )
429
+ }
430
+
431
+ /**
432
+ * The dialog footer: the project links, the running version, and a live
433
+ * "check for updates" that asks the host (which asks npm).
434
+ * @returns React element.
435
+ */
436
+ function DialogFooter() {
437
+ const [state, setState] = React.useState({ status: 'idle', latest: '' })
438
+
439
+ const check = async () => {
440
+ if (state.status === 'checking') return
441
+ setState({ status: 'checking', latest: '' })
442
+ try {
443
+ const response = await fetch('/api/dsh-webseek/update-check')
444
+ const body = await response.json()
445
+ if (body?.ok === true) {
446
+ setState(body.hasUpdate === true
447
+ ? { status: 'update', latest: typeof body.latest === 'string' ? body.latest : '' }
448
+ : { status: 'latest', latest: '' })
449
+ } else if (body?.code === 'not-published') {
450
+ setState({ status: 'notPublished', latest: '' })
451
+ } else {
452
+ setState({ status: 'failed', latest: '' })
453
+ }
454
+ } catch (error) {
455
+ setState({ status: 'failed', latest: '' })
456
+ }
457
+ }
458
+
459
+ let statusNode = null
460
+ if (state.status === 'latest') {
461
+ statusNode = h('span', { className: 'dsh-webseek-footer-status ok' }, t('footer.latest'))
462
+ } else if (state.status === 'update') {
463
+ statusNode = h('span', { className: 'dsh-webseek-footer-status update' }, t('footer.updateAvailable') + ' v' + state.latest)
464
+ } else if (state.status === 'notPublished') {
465
+ statusNode = h('span', { className: 'dsh-webseek-footer-status' }, t('footer.notPublished'))
466
+ } else if (state.status === 'failed') {
467
+ statusNode = h('span', { className: 'dsh-webseek-footer-status error' }, t('footer.checkFailed'))
468
+ }
469
+
470
+ return h('div', { className: 'dsh-webseek-footer' },
471
+ h('div', { className: 'dsh-webseek-footer-links' },
472
+ h('a', {
473
+ className: 'dsh-webseek-footer-link',
474
+ href: REPO_URL, target: '_blank', rel: 'noopener noreferrer',
475
+ }, 'GitHub'),
476
+ h('span', { className: 'dsh-webseek-footer-dot', 'aria-hidden': 'true' }, '·'),
477
+ h('a', {
478
+ className: 'dsh-webseek-footer-link',
479
+ href: ISSUES_URL, target: '_blank', rel: 'noopener noreferrer',
480
+ }, t('footer.feedback')),
481
+ ),
482
+ h('div', { className: 'dsh-webseek-footer-meta' },
483
+ h('span', { className: 'dsh-webseek-footer-version' }, 'v' + VERSION),
484
+ h('button', {
485
+ type: 'button',
486
+ className: 'dsh-webseek-linkbtn',
487
+ disabled: state.status === 'checking',
488
+ onClick: () => { void check() },
489
+ }, state.status === 'checking' ? t('footer.checking') : t('footer.check')),
490
+ statusNode,
491
+ ),
492
+ )
493
+ }
494
+
495
+ /**
496
+ * The import dialog: the share link, the target workspace, the button.
497
+ * @param props - `{ onClose, onImported, onOpen }`.
498
+ * @returns React element.
499
+ */
500
+ function ImportDialog({ onClose, onImported, onOpen }) {
501
+ const [link, setLink] = React.useState('')
502
+ const [workspaces, setWorkspaces] = React.useState([])
503
+ const [loading, setLoading] = React.useState(true)
504
+ const [workspaceId, setWorkspaceId] = React.useState('')
505
+ const [busy, setBusy] = React.useState(false)
506
+ const [error, setError] = React.useState('')
507
+ const [note, setNote] = React.useState('')
508
+ const [imported, setImported] = React.useState(null)
509
+ const [opening, setOpening] = React.useState(false)
510
+ const inputRef = React.useRef(null)
511
+ const dialogRef = React.useRef(null)
512
+
513
+ React.useEffect(() => {
514
+ let alive = true
515
+ fetch('/api/dsh-webseek/workspaces')
516
+ .then((response) => response.json())
517
+ .then((body) => {
518
+ if (!alive) return
519
+ const list = Array.isArray(body?.workspaces) ? body.workspaces : []
520
+ setWorkspaces(list)
521
+ if (list.length > 0) setWorkspaceId((current) => current || String(list[0].id))
522
+ })
523
+ .catch(() => { if (alive) setError(t('error.workspace')) })
524
+ .finally(() => { if (alive) setLoading(false) })
525
+ return () => { alive = false }
526
+ }, [])
527
+
528
+ React.useEffect(() => {
529
+ if (inputRef.current !== null) inputRef.current.focus()
530
+ }, [])
531
+
532
+ React.useEffect(() => {
533
+ const onKeyDown = (event) => {
534
+ if (event.key === 'Escape') {
535
+ // An open menu owns Escape: it closes itself first.
536
+ if (document.querySelector('.dsh-webseek-menu') !== null) return
537
+ onClose()
538
+ return
539
+ }
540
+ // aria-modal promises the focus stays inside; keep Tab on it.
541
+ if (event.key !== 'Tab' || dialogRef.current === null) return
542
+ const focusable = dialogRef.current.querySelectorAll(
543
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
544
+ )
545
+ if (focusable.length === 0) return
546
+ const first = focusable[0]
547
+ const last = focusable[focusable.length - 1]
548
+ const active = document.activeElement
549
+ const outside = !dialogRef.current.contains(active)
550
+ if (event.shiftKey && (active === first || outside)) {
551
+ event.preventDefault()
552
+ last.focus()
553
+ } else if (!event.shiftKey && (active === last || outside)) {
554
+ event.preventDefault()
555
+ first.focus()
556
+ }
557
+ }
558
+ document.addEventListener('keydown', onKeyDown, true)
559
+ return () => document.removeEventListener('keydown', onKeyDown, true)
560
+ }, [onClose])
561
+
562
+ const submit = async () => {
563
+ if (busy) return
564
+ setBusy(true)
565
+ setError('')
566
+ setNote('')
567
+ try {
568
+ const response = await fetch('/api/dsh-webseek/share-import', {
569
+ method: 'POST',
570
+ headers: { 'content-type': 'application/json' },
571
+ body: JSON.stringify({ shareUrl: link, workspaceId }),
572
+ })
573
+ const body = await response.json()
574
+ if (body?.ok !== true) {
575
+ setError(t(errorKey(body?.error)))
576
+ return
577
+ }
578
+ setImported(body)
579
+ if (typeof onImported === 'function') onImported(body)
580
+ const title = typeof body?.title === 'string' && body.title !== '' ? body.title : link.trim()
581
+ const workspaceTitle = typeof body?.workspaceTitle === 'string' && body.workspaceTitle !== '' ? body.workspaceTitle : workspaceId
582
+ setNote((body.already === true ? t('dialog.already') : t('dialog.done')) + '「' + workspaceTitle + '」:' + title)
583
+ } catch (error) {
584
+ setError(t('error.network'))
585
+ } finally {
586
+ setBusy(false)
587
+ }
588
+ }
589
+
590
+ const canSubmit = !busy && link.trim() !== '' && workspaceId !== '' && workspaces.length > 0
591
+ return h('div', {
592
+ className: 'dsh-webseek-mask',
593
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() },
594
+ },
595
+ h('div', {
596
+ ref: dialogRef,
597
+ className: 'dsh-webseek-modal',
598
+ role: 'dialog',
599
+ 'aria-modal': 'true',
600
+ 'aria-label': t('dialog.title'),
601
+ },
602
+ h('div', { className: 'dsh-webseek-modal-title' }, t('dialog.title')),
603
+ h('label', { className: 'dsh-webseek-field' },
604
+ h('span', null, t('dialog.link')),
605
+ h('div', { className: 'dsh-webseek-input' },
606
+ h('input', {
607
+ ref: inputRef,
608
+ value: link,
609
+ placeholder: t('dialog.linkPlaceholder'),
610
+ spellCheck: false,
611
+ autoComplete: 'off',
612
+ onChange: (event) => { setLink(event.target.value); setError('') },
613
+ onKeyDown: (event) => { if (event.key === 'Enter') void submit() },
614
+ }),
615
+ ),
616
+ ),
617
+ h('div', { className: 'dsh-webseek-field' },
618
+ h('span', { id: WORKSPACE_LABEL_ID }, t('dialog.workspace')),
619
+ workspaces.length === 0
620
+ ? h('div', { className: 'dsh-webseek-input' },
621
+ h('input', { value: loading ? t('dialog.workspaceLoading') : t('dialog.workspaceEmpty'), readOnly: true }))
622
+ : h(WorkspacePicker, {
623
+ workspaces,
624
+ value: workspaceId,
625
+ onChange: setWorkspaceId,
626
+ labelId: WORKSPACE_LABEL_ID,
627
+ }),
628
+ ),
629
+ error !== '' ? h('div', { className: 'dsh-webseek-error' }, error) : null,
630
+ note !== '' ? h('div', { className: 'dsh-webseek-note' }, note) : null,
631
+ imported !== null
632
+ ? h('div', { className: 'dsh-webseek-note' }, t('dialog.treeHint'))
633
+ : null,
634
+ h('div', { className: 'dsh-webseek-actions' },
635
+ imported !== null
636
+ ? h('button', {
637
+ type: 'button',
638
+ className: 'dsh-webseek-dialog-btn primary',
639
+ disabled: opening,
640
+ onClick: () => {
641
+ setOpening(true)
642
+ setError('')
643
+ onOpen(imported, () => {
644
+ setOpening(false)
645
+ setError(t('dialog.openTimeout'))
646
+ })
647
+ },
648
+ }, opening ? t('dialog.opening') : t('dialog.open'))
649
+ : null,
650
+ h('button', {
651
+ type: 'button',
652
+ className: 'dsh-webseek-dialog-btn',
653
+ onClick: onClose,
654
+ }, t('dialog.cancel')),
655
+ imported === null
656
+ ? h('button', {
657
+ type: 'button',
658
+ className: 'dsh-webseek-dialog-btn primary',
659
+ onClick: () => { void submit() },
660
+ disabled: !canSubmit,
661
+ }, busy ? t('dialog.importing') : t('dialog.submit'))
662
+ : null,
663
+ ),
664
+ h(DialogFooter, null),
665
+ ),
666
+ )
667
+ }
668
+
669
+ /**
670
+ * Open the imported session the way the user would: through its row.
671
+ *
672
+ * The session is durable the moment the host wrote it, but a session
673
+ * written straight to the log reaches the client's own session lists a
674
+ * beat later — and `openSession(id)` on a session the client has never
675
+ * seen lands on the workspace's blank session instead. The sidebar row
676
+ * is the host's own entry for that session (`data-row-key`), so
677
+ * clicking it is the one path that always opens the right session.
678
+ * @param sessionId - the imported session.
679
+ */
680
+ /** The pending "open the imported session" poll, if any. */
681
+ let openSessionTimer = 0
682
+
683
+ /** Stop waiting for the sidebar row. */
684
+ function cancelOpenSession() {
685
+ if (openSessionTimer !== 0) {
686
+ clearTimeout(openSessionTimer)
687
+ openSessionTimer = 0
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Ask the client to re-pull the host's session list.
693
+ *
694
+ * The list is a snapshot the host pushes, and it only pushes live
695
+ * sessions — an imported session lives on disk until the list is pulled
696
+ * again. The host's list merges cold sessions, so one pull is enough.
697
+ * @param ctx - client context.
698
+ */
699
+ function refreshSessionList(ctx) {
700
+ try {
701
+ const sessions = ctx?.get?.('sessions')
702
+ if (sessions !== undefined && typeof sessions.refresh === 'function') {
703
+ void Promise.resolve(sessions.refresh()).catch(() => {})
704
+ }
705
+ } catch (error) { /* the list refreshes on its own */ }
706
+ }
707
+
708
+ function openSession(ctx, sessionId, onOpened, onTimeout) {
709
+ if (typeof sessionId !== 'string' || sessionId === '') {
710
+ if (typeof onTimeout === 'function') onTimeout()
711
+ return
712
+ }
713
+ cancelOpenSession()
714
+ const selector = '[data-row-key="session:' + sessionId + '"]'
715
+ // Two ways in, in order of reliability:
716
+ // 1. once the client's session list knows the id, DSH's own open
717
+ // path works — and unlike clicking a row it does not care whether
718
+ // the workspace group is expanded;
719
+ // 2. the sidebar row itself, for the window before the list caught up.
720
+ let tries = 120
721
+ let refreshes = 0
722
+ const tick = () => {
723
+ // `refresh()` is single-flight: a pull already in flight returns the
724
+ // list as it was *before* the import, so ask again every few
725
+ // seconds until the new session shows up.
726
+ if (refreshes < 20 && tries % 8 === 0) {
727
+ refreshes += 1
728
+ refreshSessionList(ctx)
729
+ }
730
+ if (clientKnowsSession(ctx, sessionId)) {
731
+ openSessionTimer = 0
732
+ try {
733
+ const ui = ctx?.get?.('uiWorkspace')
734
+ if (ui !== undefined && typeof ui.openSession === 'function') {
735
+ ui.openSession(sessionId)
736
+ if (typeof onOpened === 'function') onOpened()
737
+ return
738
+ }
739
+ } catch (error) { /* fall through to the row */ }
740
+ }
741
+ const row = document.querySelector(selector)
742
+ if (row !== null) {
743
+ openSessionTimer = 0
744
+ row.click()
745
+ if (typeof onOpened === 'function') onOpened()
746
+ return
747
+ }
748
+ if (tries-- <= 0) {
749
+ openSessionTimer = 0
750
+ if (typeof onTimeout === 'function') onTimeout()
751
+ return
752
+ }
753
+ openSessionTimer = setTimeout(tick, 500)
754
+ }
755
+ tick()
756
+ }
757
+
758
+ /**
759
+ * Whether the client's session list already knows this session.
760
+ * @param sessionId - the session to look for.
761
+ * @returns true when the list holds it.
762
+ */
763
+ function clientKnowsSession(ctx, sessionId) {
764
+ try {
765
+ const list = ctx?.get?.('sessions')?.list?.getSnapshot()
766
+ if (list === undefined || list === null) return false
767
+ if (Array.isArray(list.ids)) return list.ids.includes(sessionId)
768
+ return list.byId !== undefined && list.byId !== null && list.byId[sessionId] !== undefined
769
+ } catch (error) {
770
+ return false
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Mount the entry icon and keep it where it belongs.
776
+ *
777
+ * The icon goes on the workspace section header row (the row with the
778
+ * search / view options / add-workspace buttons); if that row cannot be
779
+ * found — a different DSH build, or the collapsed rail — it falls back
780
+ * to the plugin row above Settings.
781
+ * @param ctx - client context.
782
+ * @returns a disposer that unmounts everything.
783
+ */
784
+ function mountEntry(ctx) {
785
+ ensureStyle()
786
+ const container = document.createElement('div')
787
+ container.className = 'dsh-webseek-entry'
788
+ const root = createRoot(container)
789
+ root.render(h('button', {
790
+ type: 'button',
791
+ className: 'dsh-webseek-btn',
792
+ title: t('entry.title'),
793
+ 'aria-label': t('entry.title'),
794
+ onClick: () => openDialog(ctx),
795
+ }, h(EntryIcon, null)))
796
+
797
+ /**
798
+ * Put the icon into the best host available right now.
799
+ * @returns {boolean} whether a host was found.
800
+ */
801
+ const place = () => {
802
+ const region = document.querySelector(WORKSPACE_REGION)
803
+ const header = region === null ? null : region.querySelector(SECTION_HEADER)
804
+ if (header !== null) {
805
+ if (container.parentElement !== header) header.appendChild(container)
806
+ return true
807
+ }
808
+ const footer = document.querySelector(FOOTER_ACTION)
809
+ if (footer !== null) {
810
+ if (container.parentElement !== footer) footer.appendChild(container)
811
+ return true
812
+ }
813
+ return false
814
+ }
815
+
816
+ place()
817
+ // The sidebar re-renders (workspace switch, collapse, a session
818
+ // landing); when the host row is rebuilt our node is dropped with it.
819
+ const observer = new MutationObserver(() => {
820
+ if (!container.isConnected) place()
821
+ })
822
+ observer.observe(document.body, { childList: true, subtree: true })
823
+
824
+ return () => {
825
+ observer.disconnect()
826
+ closeDialog()
827
+ try { root.unmount() } catch (error) { /* already gone */ }
828
+ try { container.remove() } catch (error) { /* already gone */ }
829
+ }
830
+ }
831
+
832
+ /** The open dialog, if any — one at a time, so a second click cannot stack modals. */
833
+ let openDialogHandle = null
834
+
835
+ /**
836
+ * Close the open dialog (if any) and give the focus back.
837
+ */
838
+ function closeDialog() {
839
+ cancelOpenSession()
840
+ if (openDialogHandle === null) return
841
+ const handle = openDialogHandle
842
+ openDialogHandle = null
843
+ try { handle.root.unmount() } catch (error) { /* already gone */ }
844
+ try { handle.host.remove() } catch (error) { /* already gone */ }
845
+ if (handle.previousFocus !== null && handle.previousFocus.isConnected) {
846
+ try { handle.previousFocus.focus() } catch (error) { /* gone */ }
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Open the import dialog in its own root on `<body>`.
852
+ * @param ctx - client context.
853
+ */
854
+ function openDialog(ctx) {
855
+ if (openDialogHandle !== null) return
856
+ ensureStyle()
857
+ const host = document.createElement('div')
858
+ document.body.appendChild(host)
859
+ const root = createRoot(host)
860
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
861
+ openDialogHandle = { root, host, previousFocus }
862
+ root.render(h(ImportDialog, {
863
+ onClose: closeDialog,
864
+ onImported: () => {
865
+ // The client's session list is a snapshot the host pushes; a
866
+ // session that only exists on disk is in it after the next
867
+ // pull, so ask for that pull instead of waiting for a reload.
868
+ refreshSessionList(ctx)
869
+ },
870
+ onOpen: (body, onTimeout) => {
871
+ // The list may not have caught up yet; pull it once more before
872
+ // trying to open.
873
+ refreshSessionList(ctx)
874
+ // The dialog stays open until the session really opens, so the
875
+ // user sees what happened.
876
+ openSession(ctx, body?.sessionId, closeDialog, onTimeout)
877
+ },
878
+ }))
879
+ }
880
+
881
+ /**
882
+ * Browser-half activation.
883
+ *
884
+ * ## Why nothing UI-shaped is in the module `inject`
885
+ *
886
+ * cordis has no optional-dependency form: every key of an `inject`
887
+ * descriptor is a requirement, and a key the host does not implement
888
+ * parks this plugin's fiber — which fails the whole web boot instead
889
+ * of skipping this plugin (dsh-sidebar-qa documents the same trap).
890
+ * So the module inject stays empty and every UI service is probed
891
+ * with `ctx.get` inside apply.
892
+ * @param ctx - client context (slots, locale, uiWorkspace).
893
+ */
894
+ function apply(ctx) {
895
+ console.info('[dsh-webseek] apply')
896
+ // Locale is a data service with a real implementation: ctx.inject
897
+ // is the sanctioned way to wait for it inside apply.
898
+ ctx.inject(['locale'], (localeCtx) => {
899
+ try {
900
+ localeCtx.locale.register(NS, 'zh', COPY.zh)
901
+ localeCtx.locale.register(NS, 'en', COPY.en)
902
+ localeBinder = localeCtx.locale.bind(NS)
903
+ } catch (error) {
904
+ console.warn('[dsh-webseek] locale registration failed:', error)
905
+ }
906
+ })
907
+
908
+ let dispose = null
909
+ ctx.effect(() => {
910
+ dispose = mountEntry(ctx)
911
+ return () => {
912
+ if (dispose !== null) dispose()
913
+ dispose = null
914
+ }
915
+ }, 'webseek: entry icon')
916
+ }
917
+
918
+ const inject = []
919
+
920
+ exports.apply = apply
921
+ exports.inject = inject
922
+ return module.exports
923
+ },
924
+ })