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/LICENSE +21 -0
- package/README.md +106 -2
- package/cordis.patch.yml +14 -0
- package/docs/images/import-dialog.png +0 -0
- package/docs/images/sidebar-entry.png +0 -0
- package/lib/client.js +924 -0
- package/lib/index.js +688 -0
- package/lib/share.js +161 -0
- package/package.json +67 -4
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
|
+
})
|