@kkutysllb/dsh-terminal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,778 @@
1
+ /**
2
+ * @kkutysllb/dsh-terminal — client 半(dsh shell 页面内自包含交付物)。
3
+ *
4
+ * 嵌入式终端面板:主界面底部的真实终端(VS Code 同款),平移自退役
5
+ * 宿主(desktop/main/terminal-panel.ts 的 PAGE_JS + pty-host 编排 +
6
+ * desktop/renderer/src/views/terminal.ts 的 xterm 视图),语义一致:
7
+ * - 每工作区独立面板桶(DOM 常驻、xterm buffer 不丢,display 切换);
8
+ * 切工作区仅切显示,不销毁、不杀 shell;开合偏好 per-workspace 记忆;
9
+ * - 工作区探针:选中会话 fiber 解析 + session/list RPC(强信号唯一
10
+ * 映射;弱信号仅启动初态兜底;乱序防御 wsGen 代数)——与宿主一致;
11
+ * - 布局:left = 侧边栏实时宽度(ResizeObserver 跟随),不侵占侧栏;
12
+ * 让位:centerCol/detailsCol padding-bottom + --dsh-terminal-inset
13
+ * (client 直设,无需主进程 executeJavaScript);
14
+ * - 多标签:每标签一个 shell(server PtyHost 会话),"+" 新建、× 关闭、
15
+ * restart 重建;隐藏标签持续收数据(SSE 全量广播按桶路由);
16
+ * - header 上缘 4px 拖条调高(clamp + localStorage 持久化);
17
+ * - 右键菜单:复制/粘贴/清屏/新建/关闭(navigator.clipboard,页面
18
+ * 同源权限;宿主 bridge IPC 不再存在)。
19
+ *
20
+ * 与宿主实现的差异(README 同步说明):
21
+ * - WebContentsView → 页面内 fixed DOM(z-index 900,低于上游 modal,
22
+ * 上下文沉浸让位由层叠天然达成,ctxMode 冻结协议省略);
23
+ * - IPC 推送 → SSE(/dsh-terminal/api/stream 全局一条,断线 3s 重连);
24
+ * - xterm 依赖 vendor 化:client 无 import,首次开面板懒拉 vendor 三件
25
+ * (server 白名单托管,eval 挂 window.Terminal / FitAddonTrust);
26
+ * - ⌘W 关标签砍掉(主页面里 ⌘W 属关窗语义不可拦);⌘T 新建保留。
27
+ *
28
+ * 按钮接替旧宿主位 right:44(拖拽区显式 no-drag),id __dsh_kc_term_btn。
29
+ *
30
+ * @module @kkutysllb/dsh-terminal/client
31
+ */
32
+
33
+ window.__ModuleLoader__.load({
34
+ id: '@kkutysllb/dsh-terminal',
35
+ factory: () => {
36
+ const exports = {}
37
+
38
+ exports.inject = []
39
+
40
+ exports.apply = function apply() {
41
+ if (window.__dshKcTermWired) return
42
+ window.__dshKcTermWired = true
43
+
44
+ const BTN_ID = '__dsh_kc_term_btn'
45
+ const STYLE_ID = '__dsh_kc_term_style'
46
+ const API = '/dsh-terminal/api'
47
+ const TITLEBAR_ID = '__dsh_desktop_titlebar'
48
+ /** 无工作区桶键(兜底:探针尚未解析到时)。 */
49
+ const NO_WORKSPACE_KEY = ''
50
+ /** 面板高度(默认/界限;localStorage 持久化)。 */
51
+ const PANEL_DEFAULT_H = 280
52
+ const PANEL_MIN_H = 140
53
+ const PANEL_MAX_H = 620
54
+ const H_KEY = 'dsh-terminal-panel-h'
55
+ /** SSE 断线重连间隔。 */
56
+ const SSE_RETRY_MS = 3000
57
+ /** vendor 懒加载三件。 */
58
+ const VENDOR = ['xterm.js', 'addon-fit.js', 'xterm.css']
59
+
60
+ /* ---- 主题 token(上游 bg-base/sidebar-fill 系;亮暗双轨) ---- */
61
+ const themeOf = () => {
62
+ const dark = document.body.hasAttribute('data-ds-dark-theme')
63
+ return dark
64
+ ? { dark: true, bg: '#151517', headerBg: '#1B1B1C', fg: '#E8EAED', border: '#2C2C2E', accent: '#4D6BFE' }
65
+ : { dark: false, bg: '#FFFFFF', headerBg: '#F9FAFB', fg: '#1A1D21', border: 'rgba(0,0,0,.10)', accent: '#4D6BFE' }
66
+ }
67
+
68
+ const clampH = (h) => Math.min(PANEL_MAX_H, Math.max(PANEL_MIN_H, Math.round(h)))
69
+
70
+ const el = (tag, cls, text) => {
71
+ const node = document.createElement(tag)
72
+ if (cls !== '') node.className = cls
73
+ if (text !== undefined) node.textContent = text
74
+ return node
75
+ }
76
+
77
+ const iconBtn = (label, svg) => {
78
+ const btn = el('button', 'kt-btn')
79
+ btn.type = 'button'
80
+ btn.title = label
81
+ btn.setAttribute('aria-label', label)
82
+ btn.innerHTML = svg
83
+ return btn
84
+ }
85
+
86
+ /** 标签文字:目录短名(区分度最高;shell 名在 title 属性里)。 */
87
+ const tabLabel = (tab) => {
88
+ const parts = String(tab.cwd || '').split('/').filter(Boolean)
89
+ return parts.pop() ?? String(tab.cwd ?? '')
90
+ }
91
+
92
+ /* ---- xterm vendor 懒加载(首次开面板时拉一次,失败重试) ---- */
93
+ let vendorPromise = null
94
+ const ensureXterm = () => {
95
+ if (vendorPromise !== null) return vendorPromise
96
+ vendorPromise = (async () => {
97
+ for (const name of VENDOR) {
98
+ const res = await fetch(`${API}/vendor/${name}`)
99
+ if (!res.ok) throw new Error(`vendor ${name} ${res.status}`)
100
+ const text = await res.text()
101
+ if (name === 'xterm.css') {
102
+ const style = el('style')
103
+ style.setAttribute('data-dsh-terminal-vendor', name)
104
+ style.textContent = text
105
+ document.head.append(style)
106
+ } else {
107
+ // UMD:eval 下无 module/exports,走 globalThis 挂载分支
108
+ ;(0, eval)(text)
109
+ }
110
+ }
111
+ // UMD 挂载名实测:xterm.js → window.Terminal(class 本体);
112
+ // addon-fit → window.FitAddon({ FitAddon: class } 命名空间对象)
113
+ const TerminalCtor = window.Terminal
114
+ const FitNs = window.FitAddon
115
+ const FitCtor = FitNs !== null && typeof FitNs === 'object' ? (FitNs.FitAddon ?? FitNs) : FitNs
116
+ if (typeof TerminalCtor !== 'function' || typeof FitCtor !== 'function') {
117
+ throw new Error('xterm vendor eval failed')
118
+ }
119
+ return { Terminal: TerminalCtor, FitAddon: FitCtor }
120
+ })()
121
+ vendorPromise.catch(() => { vendorPromise = null }) // 失败允许重试
122
+ return vendorPromise
123
+ }
124
+
125
+ /* ---- 当前会话 → 工作目录解析(同源 RPC;平移自 PAGE_JS) ---- */
126
+ // 收集全部 selected 树行的会话 id:多棵树可能同时各有 selected
127
+ //(会话树 + 搜索结果等),取第一个会拿到另一棵树的残留选中
128
+ const probeSessionIds = () => {
129
+ const ids = []
130
+ for (const node of document.querySelectorAll('[role="treeitem"][aria-selected="true"]')) {
131
+ const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber$'))
132
+ let fiber = fiberKey !== undefined ? node[fiberKey] : null
133
+ while (fiber != null) {
134
+ const n = fiber.memoizedProps != null ? fiber.memoizedProps.node : null
135
+ if (n != null && typeof n.id === 'string') { ids.push(n.id); break }
136
+ fiber = fiber.return
137
+ }
138
+ }
139
+ return ids
140
+ }
141
+ let rpcSeq = 0
142
+ // 解析代数:debounce 上报与按钮点击并发时,后发起的解析读到更新的
143
+ // DOM;先发起的旧结果即使响应晚到也不得覆盖 → 代数不等的直接丢弃。
144
+ let wsGen = 0
145
+ // 结果语义(平移):matched=true 强信号;matched=false 弱信号(仅
146
+ // 启动初态);null = 有选中但解析不出或响应乱序——宁可不上报。
147
+ const resolveWorkspace = async () => {
148
+ const gen = ++wsGen
149
+ const doResolve = async () => {
150
+ const res = await fetch('/api/session/list', {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({
154
+ type: 'client-request', rpcId: 'kcoder-terminal-' + (++rpcSeq),
155
+ method: 'session/list', payload: { args: { _request: {} } },
156
+ }),
157
+ })
158
+ if (!res.ok) return null
159
+ const envelope = await res.json().catch(() => null)
160
+ const result = envelope != null && envelope.result != null ? envelope.result : null
161
+ const items = result != null && result.ok === true && result.value != null
162
+ && Array.isArray(result.value.items) ? result.value.items : null
163
+ if (items == null || items.length === 0) return null
164
+ const usable = items.filter(it => it != null && typeof it.sessionId === 'string')
165
+ if (usable.length === 0) return null
166
+ const ids = probeSessionIds()
167
+ if (ids.length > 0) {
168
+ const selected = usable.filter(it => ids.includes(it.sessionId))
169
+ const dirs = new Set()
170
+ for (const it of selected) {
171
+ if (typeof it.cwd === 'string' && it.cwd !== '') dirs.add(it.cwd)
172
+ }
173
+ if (dirs.size !== 1) return null
174
+ const path = [...dirs][0]
175
+ return { matched: true, path, title: path.split('/').filter(Boolean).pop() ?? '' }
176
+ }
177
+ const withCwd = usable.filter(it => typeof it.cwd === 'string' && it.cwd !== '')
178
+ if (withCwd.length === 0) return null
179
+ const latest = withCwd.slice()
180
+ .sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0))[0]
181
+ return { matched: false, path: latest.cwd, title: latest.cwd.split('/').filter(Boolean).pop() ?? '' }
182
+ }
183
+ const ws = await doResolve().catch(() => null)
184
+ return gen === wsGen ? ws : null
185
+ }
186
+
187
+ /* ---- 工作区状态(宿主 TerminalPanel.activeBucket 语义平移) ---- */
188
+ let activeBucket = null // null = 未解析(绝不猜)
189
+ let activeTitle = ''
190
+ let fallbackSeen = false // 弱信号仅从未有过强信号时采纳(错桶根因防御)
191
+
192
+ const applyWorkspace = (ws) => {
193
+ if (ws == null) return
194
+ if (ws.matched) {
195
+ activeBucket = ws.path
196
+ activeTitle = ws.title
197
+ fallbackSeen = true
198
+ } else if (!fallbackSeen && activeBucket === null) {
199
+ activeBucket = ws.path
200
+ activeTitle = ws.title
201
+ } else {
202
+ return
203
+ }
204
+ switchVisible(activeBucket)
205
+ }
206
+
207
+ const watchSelection = () => {
208
+ let debounce = 0
209
+ new MutationObserver(() => {
210
+ window.clearTimeout(debounce)
211
+ debounce = window.setTimeout(() => { void resolveWorkspace().then(applyWorkspace) }, 600)
212
+ }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['aria-selected'] })
213
+ void resolveWorkspace().then(applyWorkspace)
214
+ }
215
+
216
+ /* ---- 侧边栏宽度探针(面板 left/width 跟随;平移 PAGE_JS) ---- */
217
+ let sidebarW = 0
218
+ const watchSidebar = () => {
219
+ const sidebarEl = () => document.querySelector('[class*="sidebarCol"]')
220
+ const target = sidebarEl()
221
+ if (target == null) { requestAnimationFrame(watchSidebar); return }
222
+ let raf = 0
223
+ const reportW = () => {
224
+ raf = 0
225
+ sidebarW = Math.round(target.getBoundingClientRect().width)
226
+ layout()
227
+ }
228
+ new ResizeObserver(() => { if (raf === 0) raf = requestAnimationFrame(reportW) }).observe(target)
229
+ reportW()
230
+ }
231
+
232
+ /* ---- 让位(平移 __dshTerminalPad):内容列 padding + 几何广播 ---- */
233
+ const pad = (h) => {
234
+ document.documentElement.style.setProperty('--dsh-terminal-inset', h > 0 ? h + 'px' : '0px')
235
+ const cols = document.querySelectorAll('[class*="centerCol"], [class*="detailsCol"]')
236
+ for (const col of cols) {
237
+ if (h > 0) col.style.paddingBottom = h + 'px'
238
+ else col.style.removeProperty('padding-bottom')
239
+ }
240
+ }
241
+
242
+ /* ---- 面板高度(全局一个值,localStorage 持久化) ---- */
243
+ let panelH = clampH(Number(window.localStorage.getItem(H_KEY)) || PANEL_DEFAULT_H)
244
+ const setPanelH = (h) => {
245
+ const next = clampH(h)
246
+ if (next === panelH) return
247
+ panelH = next
248
+ try { window.localStorage.setItem(H_KEY, String(next)) } catch { /* 隐私态静默 */ }
249
+ layout()
250
+ }
251
+
252
+ /* ---- 全局样式(面板 + 按钮 + 菜单;平移 PAGE_CSS,前缀 kt-) ---- */
253
+ const style = el('style')
254
+ style.id = STYLE_ID
255
+ style.textContent = `
256
+ #${BTN_ID}{all:unset;box-sizing:border-box;position:absolute;right:44px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:7px;cursor:pointer;color:rgba(26,29,33,.65);-webkit-app-region:no-drag;transition:background .15s ease}
257
+ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
258
+ #${BTN_ID}:hover{background:color-mix(in srgb,currentColor 10%,transparent)}
259
+ #${BTN_ID}:active{background:color-mix(in srgb,currentColor 18%,transparent)}
260
+ #${BTN_ID}[data-open="1"]{background:color-mix(in srgb,currentColor 14%,transparent)}
261
+ .kt-panel{all:unset;box-sizing:border-box;position:fixed;bottom:0;display:none;flex-direction:column;font:500 12px -apple-system,"PingFang SC","Segoe UI",sans-serif;z-index:900;background:#fff}
262
+ .kt-panel[data-shown="1"]{display:flex}
263
+ .kt-grip{height:4px;flex:none;cursor:row-resize}
264
+ .kt-header{flex:none;height:32px;display:flex;align-items:stretch;gap:6px;padding:0 8px;user-select:none}
265
+ .kt-tabs{flex:1;min-width:0;display:flex;align-items:stretch;gap:2px;overflow-x:auto;scrollbar-width:none}
266
+ .kt-tabs::-webkit-scrollbar{display:none}
267
+ .kt-tab{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:7px;padding:0 7px 0 11px;max-width:170px;border-radius:7px;cursor:pointer;flex:none}
268
+ .kt-tab .kt-tab-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.55}
269
+ .kt-tab[data-active="1"]{background:rgba(128,128,128,.14)}
270
+ .kt-tab[data-active="1"] .kt-tab-label{opacity:1;font-weight:600}
271
+ .kt-tab .kt-x{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:4px;cursor:pointer;opacity:0;flex:none}
272
+ .kt-tab:hover .kt-x,.kt-tab[data-exited="1"] .kt-x{opacity:.7}
273
+ .kt-tab .kt-x:hover{background:rgba(128,128,128,.25);opacity:1}
274
+ .kt-tab[data-exited="1"] .kt-tab-label{opacity:.35;font-style:italic}
275
+ .kt-btn{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;margin-top:4px;border-radius:6px;cursor:pointer}
276
+ .kt-btn:hover{background:rgba(128,128,128,.18)}
277
+ .kt-btn svg{display:block}
278
+ .kt-term{flex:1;min-height:0;position:relative}
279
+ .kt-term .kt-page{position:absolute;inset:0;padding:2px 8px 6px}
280
+ .kt-term .kt-page[hidden]{display:none}
281
+ .kt-term .xterm{height:100%}
282
+ .kt-exit{flex:none;display:none;align-items:center;gap:10px;padding:6px 12px;font-size:12px;opacity:.8}
283
+ .kt-menu{position:fixed;z-index:901;min-width:148px;padding:4px;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.28);font-size:12px}
284
+ .kt-menu button{all:unset;box-sizing:border-box;display:flex;width:100%;padding:5px 10px;border-radius:5px;cursor:pointer}
285
+ .kt-menu button:hover{background:rgba(128,128,128,.18)}
286
+ .kt-menu button:disabled{opacity:.35;cursor:default}
287
+ .kt-menu .kt-sep{height:1px;margin:4px 6px}
288
+ `
289
+ document.head.append(style)
290
+
291
+ /* ---- pty RPC 封装 ---- */
292
+ const rpc = async (body) => {
293
+ const res = await fetch(`${API}/rpc`, {
294
+ method: 'POST',
295
+ headers: { 'content-type': 'application/json' },
296
+ body: JSON.stringify(body),
297
+ })
298
+ if (!res.ok) throw new Error(`rpc ${res.status}`)
299
+ return res.json()
300
+ }
301
+
302
+ /* ---- 单工作区面板(平移 WorkspaceView + mountTerminal) ---- */
303
+ const panels = new Map() // bucket → PanelState
304
+ const xtermReady = ensureXterm()
305
+
306
+ const palette = () => {
307
+ const t = themeOf()
308
+ return {
309
+ token: t,
310
+ xterm: {
311
+ background: t.bg,
312
+ foreground: t.fg,
313
+ cursor: t.accent,
314
+ cursorAccent: t.bg,
315
+ selectionBackground: t.accent + '59',
316
+ },
317
+ }
318
+ }
319
+
320
+ const applyPalette = (panel) => {
321
+ const p = palette()
322
+ panel.root.style.background = p.token.bg
323
+ panel.header.style.background = p.token.headerBg
324
+ panel.header.style.color = p.token.fg
325
+ panel.header.style.borderBottom = `1px solid ${p.token.border}`
326
+ panel.grip.style.background = p.token.border
327
+ panel.exitBar.style.background = p.token.bg
328
+ panel.exitBar.style.color = p.token.fg
329
+ panel.menu.style.background = p.token.headerBg
330
+ panel.menu.style.color = p.token.fg
331
+ panel.menu.style.border = `1px solid ${p.token.border}`
332
+ panel.menu.querySelectorAll('.kt-sep').forEach(sep => { sep.style.background = p.token.border })
333
+ panel.palette = p.xterm
334
+ for (const st of panel.tabs.values()) st.term.options.theme = p.xterm
335
+ }
336
+
337
+ /** 懒建指定工作区面板 DOM(vendor 就绪后;已存在直接返回)。 */
338
+ const ensurePanel = async (bucket) => {
339
+ let panel = panels.get(bucket)
340
+ // 上游 SPA 重渲染会整表重写 body(切会话等),外部节点随之被清:
341
+ // 断连的孤儿面板按缺失处理,删档重建(xterm buffer 不保留)。
342
+ if (panel !== undefined && !panel.root.isConnected) {
343
+ panels.delete(bucket)
344
+ panel = undefined
345
+ }
346
+ if (panel !== undefined) return panel
347
+ // 现取而非用预热常量:预热失败置空 vendorPromise 后,这里重拉新 promise
348
+ const { Terminal, FitAddon } = await ensureXterm()
349
+ const root = el('div', 'kt-panel')
350
+ root.id = bucket === NO_WORKSPACE_KEY ? '__dsh_kc_term_panel' : `__dsh_kc_term_panel_${panels.size}`
351
+ const grip = el('div', 'kt-grip')
352
+ const header = el('div', 'kt-header')
353
+ const tabsBar = el('div', 'kt-tabs')
354
+ const newBtn = iconBtn('新建终端标签(⌘T)',
355
+ '<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M8 3.2v9.6M3.2 8h9.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>')
356
+ const restartBtn = iconBtn('重启 shell(在当前工作区目录)',
357
+ '<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M13.7 1.8v2.7h-2.7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>')
358
+ const closeBtn = iconBtn('关闭终端面板(会话保留)',
359
+ '<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>')
360
+ header.append(tabsBar, newBtn, restartBtn, closeBtn)
361
+ const termHost = el('div', 'kt-term')
362
+ const exitBar = el('div', 'kt-exit')
363
+ const exitText = el('span', '', 'shell 进程已退出')
364
+ const relaunch = el('button', '', '重新启动')
365
+ relaunch.type = 'button'
366
+ relaunch.style.cssText = 'all:unset;cursor:pointer;padding:3px 10px;border-radius:6px;font-weight:600'
367
+ relaunch.onmouseenter = () => { relaunch.style.background = 'rgba(128,128,128,.25)' }
368
+ relaunch.onmouseleave = () => { relaunch.style.background = 'transparent' }
369
+ exitBar.append(exitText, relaunch)
370
+ const menu = el('div', 'kt-menu')
371
+ menu.style.display = 'none'
372
+ root.append(grip, header, termHost, exitBar, menu)
373
+ // 挂 documentElement:上游 SPA 重渲染(恢复会话/切树)会重写 body,
374
+ // 外部节点全灭;html 直下不动(git-panel 同款教训)。
375
+ document.documentElement.append(root)
376
+
377
+ panel = {
378
+ bucket, root, grip, header, tabsBar, termHost, exitBar, menu,
379
+ tabs: new Map(), activeId: -1, open: false, shown: false, loaded: false,
380
+ palette: null,
381
+ }
382
+
383
+ const registerTab = (tab) => {
384
+ const host = el('div', 'kt-page')
385
+ host.hidden = true
386
+ termHost.append(host)
387
+ const term = new Terminal({
388
+ fontFamily: 'Menlo, Monaco, "DejaVu Sans Mono", "Courier New", monospace',
389
+ fontSize: 13,
390
+ cursorBlink: true,
391
+ convertEol: false,
392
+ scrollback: 4000,
393
+ theme: panel.palette,
394
+ })
395
+ const fit = new FitAddon()
396
+ term.loadAddon(fit)
397
+ term.open(host)
398
+ // ⌘T 新建(⌘W 不拦:主页面里属关窗语义)
399
+ term.attachCustomKeyEventHandler((event) => {
400
+ if (event.metaKey && event.key === 't' && event.type === 'keydown') {
401
+ void newTab(panel)
402
+ return false
403
+ }
404
+ return true
405
+ })
406
+ const tabEl = el('button', 'kt-tab')
407
+ tabEl.type = 'button'
408
+ tabEl.title = `${tab.title} — ${tab.cwd}`
409
+ const label = el('span', 'kt-tab-label', tabLabel(tab))
410
+ const x = el('button', 'kt-x')
411
+ x.type = 'button'
412
+ x.title = '关闭标签'
413
+ x.innerHTML = '<svg viewBox="0 0 16 16" width="10" height="10" fill="none"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
414
+ tabEl.append(label, x)
415
+ tabsBar.append(tabEl)
416
+ const st = { id: tab.id, term, fit, host, el: tabEl, exited: !tab.alive }
417
+ tabEl.dataset.exited = st.exited ? '1' : '0'
418
+ tabEl.onclick = () => setActive(panel, st.id)
419
+ x.onclick = e => { e.stopPropagation(); void closeTab(panel, st.id) }
420
+ term.onData(data => { void rpc({ op: 'write', id: st.id, data }).catch(() => {}) })
421
+ term.onResize(({ cols, rows }) => { void rpc({ op: 'resize', id: st.id, cols, rows }).catch(() => {}) })
422
+ panel.tabs.set(st.id, st)
423
+ return st
424
+ }
425
+
426
+ const setActive = (p, id) => {
427
+ const st = p.tabs.get(id)
428
+ if (st === undefined) return
429
+ p.activeId = id
430
+ for (const t of p.tabs.values()) {
431
+ t.el.dataset.active = t.id === id ? '1' : '0'
432
+ t.host.hidden = t.id !== id
433
+ }
434
+ p.exitBar.style.display = st.exited ? 'flex' : 'none'
435
+ // 隐藏期间尺寸可能滞后:激活即补 fit + 上报(pty 端补 resize)
436
+ if (p.termHost.clientWidth !== 0 && p.termHost.clientHeight !== 0) {
437
+ try { st.fit.fit() } catch { /* 容器暂不可测 */ }
438
+ }
439
+ st.term.focus()
440
+ }
441
+
442
+ const markExited = (p, id) => {
443
+ const st = p.tabs.get(id)
444
+ if (st === undefined) return
445
+ st.exited = true
446
+ st.el.dataset.exited = '1'
447
+ if (id === p.activeId) p.exitBar.style.display = 'flex'
448
+ }
449
+
450
+ const closeTab = async (p, id) => {
451
+ if (id === -1) return
452
+ const st = p.tabs.get(id)
453
+ if (st === undefined) return
454
+ const out = await rpc({ op: 'close', id, cwd: p.bucket === NO_WORKSPACE_KEY ? '' : p.bucket }).catch(() => null)
455
+ if (out === null) return
456
+ st.term.dispose()
457
+ st.host.remove()
458
+ st.el.remove()
459
+ p.tabs.delete(id)
460
+ // 对齐服务端剩余标签(防御:本地与远端应一致)
461
+ const ids = new Set((out.tabs ?? []).map(t => t.id))
462
+ for (const [kid, kst] of [...p.tabs]) {
463
+ if (!ids.has(kid)) { kst.term.dispose(); kst.host.remove(); kst.el.remove(); p.tabs.delete(kid) }
464
+ }
465
+ if (p.tabs.size === 0) {
466
+ // 全部关闭 → 收起面板(下次打开 ensureFirst 新建全新会话)
467
+ p.activeId = -1
468
+ hide(p.bucket)
469
+ return
470
+ }
471
+ if (id === p.activeId) {
472
+ const last = [...p.tabs.keys()].pop()
473
+ if (last !== undefined) setActive(p, last)
474
+ }
475
+ }
476
+
477
+ const newTab = async (p) => {
478
+ const out = await rpc({ op: 'new', cwd: p.bucket === NO_WORKSPACE_KEY ? '' : p.bucket }).catch(() => null)
479
+ if (out === null || out.tab === undefined) return
480
+ const st = registerTab(out.tab)
481
+ setActive(p, st.id)
482
+ st.term.focus()
483
+ }
484
+
485
+ // 按当前桶增量对齐(宿主 terminal:reset 同语义:同 id 保留原
486
+ // xterm 实例与 buffer,仅做差异增删——切走再切回原状恢复)。
487
+ const resyncTabs = async (p) => {
488
+ const out = await rpc({ op: 'tabs', cwd: p.bucket === NO_WORKSPACE_KEY ? '' : p.bucket }).catch(() => null)
489
+ if (out === null) return
490
+ const fresh = out.tabs ?? []
491
+ const freshIds = new Set(fresh.map(t => t.id))
492
+ for (const [kid, kst] of [...p.tabs]) {
493
+ if (!freshIds.has(kid)) {
494
+ kst.term.dispose(); kst.host.remove(); kst.el.remove(); p.tabs.delete(kid)
495
+ }
496
+ }
497
+ for (const tab of fresh) {
498
+ if (p.tabs.has(tab.id)) continue
499
+ const st = registerTab(tab)
500
+ if (p.activeId === -1 || tab.alive) p.activeId = st.id
501
+ }
502
+ if (p.activeId !== -1 && !p.tabs.has(p.activeId)) {
503
+ p.activeId = [...p.tabs.keys()][0] ?? -1
504
+ }
505
+ if (p.activeId !== -1) setActive(p, p.activeId)
506
+ }
507
+ panel.resyncTabs = () => { void resyncTabs(panel) }
508
+
509
+ /* ---- header 动作 ---- */
510
+ newBtn.onclick = () => { void newTab(panel) }
511
+ restartBtn.onclick = async () => {
512
+ if (panel.activeId === -1) return
513
+ const out = await rpc({ op: 'restart', id: panel.activeId, cwd: panel.bucket === NO_WORKSPACE_KEY ? '' : panel.bucket }).catch(() => null)
514
+ const st = panel.tabs.get(panel.activeId)
515
+ if (out === null || out.tab === undefined || out.tab === null || st === undefined) return
516
+ st.exited = false
517
+ st.el.dataset.exited = '0'
518
+ st.el.title = `${out.tab.title} — ${out.tab.cwd}`
519
+ const lbl = st.el.querySelector('.kt-tab-label')
520
+ if (lbl !== null) lbl.textContent = tabLabel(out.tab)
521
+ st.term.reset()
522
+ panel.exitBar.style.display = 'none'
523
+ st.term.focus()
524
+ }
525
+ closeBtn.onclick = () => { hide(panel.bucket) }
526
+ relaunch.onclick = () => { restartBtn.click() }
527
+
528
+ /* ---- 右键菜单(navigator.clipboard;页面同源权限) ---- */
529
+ const closeMenu = () => { menu.style.display = 'none' }
530
+ const menuItem = (label, action, disabled = false) => {
531
+ const item = el('button', '', label)
532
+ item.type = 'button'
533
+ item.disabled = disabled
534
+ item.onclick = () => { closeMenu(); action() }
535
+ return item
536
+ }
537
+ const menuSep = () => el('div', 'kt-sep')
538
+ termHost.addEventListener('contextmenu', e => {
539
+ e.preventDefault()
540
+ const st = panel.tabs.get(panel.activeId)
541
+ if (st === undefined) return
542
+ const hasSel = st.term.hasSelection()
543
+ menu.innerHTML = ''
544
+ menu.append(
545
+ menuItem('复制', () => {
546
+ const sel = st.term.getSelection()
547
+ if (sel !== '') void navigator.clipboard?.writeText(sel).catch(() => {})
548
+ st.term.clearSelection()
549
+ }, !hasSel),
550
+ menuItem('粘贴', () => {
551
+ void navigator.clipboard?.readText().then(text => { if (text !== '') st.term.paste(text) }).catch(() => {})
552
+ }),
553
+ menuItem('清屏', () => { st.term.clear() }),
554
+ menuSep(),
555
+ menuItem('新建标签', () => { void newTab(panel) }),
556
+ menuItem('关闭标签', () => { void closeTab(panel, st.id) }, panel.tabs.size <= 1),
557
+ )
558
+ menu.style.display = 'block'
559
+ const rect = menu.getBoundingClientRect()
560
+ menu.style.left = `${Math.max(2, Math.min(e.clientX, window.innerWidth - rect.width - 2))}px`
561
+ menu.style.top = `${Math.max(2, Math.min(e.clientY, window.innerHeight - rect.height - 2))}px`
562
+ })
563
+ document.addEventListener('pointerdown', e => {
564
+ if (menu.style.display === 'none') return
565
+ if (e.target instanceof Node && menu.contains(e.target)) return
566
+ closeMenu()
567
+ }, true)
568
+ window.addEventListener('blur', closeMenu)
569
+
570
+ /* ---- 上缘拖条:调面板高度(向下拖正 = 面板变矮) ---- */
571
+ let dragging = false
572
+ let lastY = 0
573
+ let pending = 0
574
+ let raf = 0
575
+ grip.onpointerdown = e => {
576
+ dragging = true
577
+ lastY = e.clientY
578
+ pending = 0
579
+ grip.setPointerCapture(e.pointerId)
580
+ e.preventDefault()
581
+ }
582
+ grip.onpointermove = e => {
583
+ if (!dragging) return
584
+ pending += e.clientY - lastY
585
+ lastY = e.clientY
586
+ if (raf === 0) {
587
+ raf = requestAnimationFrame(() => {
588
+ raf = 0
589
+ if (pending !== 0) {
590
+ const sent = pending
591
+ pending = 0
592
+ setPanelH(panelH - sent)
593
+ }
594
+ })
595
+ }
596
+ }
597
+ grip.onpointerup = () => { dragging = false }
598
+ grip.onpointercancel = () => { dragging = false }
599
+
600
+ /* ---- 尺寸:容器变化只 refit 活动标签(隐藏的激活时补) ---- */
601
+ const refit = () => {
602
+ if (termHost.clientWidth === 0 || termHost.clientHeight === 0) return
603
+ const st = panel.tabs.get(panel.activeId)
604
+ if (st === undefined) return
605
+ try { st.fit.fit() } catch { /* 忽略瞬时不可测 */ }
606
+ }
607
+ new ResizeObserver(() => refit()).observe(termHost)
608
+
609
+ applyPalette(panel)
610
+ panels.set(bucket, panel)
611
+ await resyncTabs(panel)
612
+ // 宿主 ensureFirst 语义:桶内无标签时新建首标签(否则空面板)
613
+ if (panel.tabs.size === 0) await newTab(panel)
614
+ return panel
615
+ }
616
+
617
+ /* ---- 开合(平移 TerminalPanel show/hide/toggle/switchVisible) ---- */
618
+ const syncButtonState = () => {
619
+ const open = [...panels.values()].some(p => p.open)
620
+ const btn = document.getElementById(BTN_ID)
621
+ if (btn !== null) btn.setAttribute('data-open', open ? '1' : '0')
622
+ }
623
+
624
+ const layout = () => {
625
+ const contentW = window.innerWidth
626
+ const x = Math.min(sidebarW, Math.max(contentW - 200, 0))
627
+ // 右边界让位右侧栏(better-sidebar 浮层展开时)
628
+ const w = Math.max(contentW - x - rightPanelW, 0)
629
+ let anyShown = false
630
+ for (const p of panels.values()) {
631
+ if (!p.shown) continue
632
+ anyShown = true
633
+ p.root.style.left = `${x}px`
634
+ p.root.style.width = `${w}px`
635
+ p.root.style.height = `${panelH}px`
636
+ }
637
+ pad(anyShown ? panelH : 0)
638
+ }
639
+
640
+ const show = (bucket) => {
641
+ const key = bucket ?? NO_WORKSPACE_KEY
642
+ void ensurePanel(key).then(p => {
643
+ p.open = true
644
+ p.shown = true
645
+ p.root.setAttribute('data-shown', '1')
646
+ void rpc({ op: 'tabs', cwd: key === NO_WORKSPACE_KEY ? '' : key }).then(() => p.resyncTabs()).catch(() => {})
647
+ layout()
648
+ syncButtonState()
649
+ const first = [...p.tabs.values()].find(t => t.id === p.activeId)
650
+ if (first !== undefined) first.term.focus()
651
+ }).catch((error) => { console.error('[dsh-terminal] show failed:', error) /* vendor 加载失败静默(下次点击重试) */ })
652
+ }
653
+
654
+ const hide = (bucket) => {
655
+ const key = bucket ?? NO_WORKSPACE_KEY
656
+ const p = panels.get(key)
657
+ if (p === undefined) return
658
+ p.open = false
659
+ p.shown = false
660
+ p.root.removeAttribute('data-shown')
661
+ layout()
662
+ syncButtonState()
663
+ }
664
+
665
+ const toggle = () => {
666
+ const bucket = activeBucket ?? NO_WORKSPACE_KEY
667
+ const p = panels.get(bucket)
668
+ if (p !== undefined && p.open) hide(bucket)
669
+ else show(bucket)
670
+ }
671
+
672
+ /** 切工作区:目标桶按自己的开合记忆恢复,其余仅隐藏(记忆保留)。 */
673
+ const switchVisible = (newBucket) => {
674
+ for (const [bucket, p] of panels) {
675
+ if (!p.root.isConnected) continue // 孤儿(SPA 重渲染清除):跳过不 resync
676
+ const shouldShow = bucket === newBucket && p.open
677
+ p.shown = shouldShow
678
+ if (shouldShow) p.root.setAttribute('data-shown', '1')
679
+ else p.root.removeAttribute('data-shown')
680
+ if (shouldShow) p.resyncTabs()
681
+ }
682
+ layout()
683
+ syncButtonState()
684
+ }
685
+
686
+ /* ---- SSE 输出流(全局一条广播,按桶路由;断线重连) ---- */
687
+ const connectStream = () => {
688
+ const es = new EventSource(`${API}/stream`)
689
+ es.onmessage = (ev) => {
690
+ let msg = null
691
+ try { msg = JSON.parse(ev.data) } catch { return }
692
+ if (msg === null || msg === undefined) return
693
+ const p = panels.get(msg.bucket)
694
+ if (p === undefined) return
695
+ if (msg.type === 'data') p.tabs.get(msg.id)?.term.write(msg.chunk)
696
+ else if (msg.type === 'exit') {
697
+ const st = p.tabs.get(msg.id)
698
+ if (st === undefined) return
699
+ st.exited = true
700
+ st.el.dataset.exited = '1'
701
+ if (msg.id === p.activeId) p.exitBar.style.display = 'flex'
702
+ }
703
+ }
704
+ es.onerror = () => {
705
+ es.close()
706
+ window.setTimeout(connectStream, SSE_RETRY_MS)
707
+ }
708
+ }
709
+
710
+ /* ---- 主题跟随(body data-ds-dark-theme 翻转 → 全部面板重涂) ---- */
711
+ new MutationObserver(() => {
712
+ for (const p of panels.values()) applyPalette(p)
713
+ }).observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
714
+
715
+ /* ---- 窗口 resize → 重排(view 时代 win.on('resize') 的等价物) ---- */
716
+ window.addEventListener('resize', () => layout())
717
+
718
+ /* ---- 右侧栏宽度探针(better-sidebar 浮层;面板右边界让位) ---- */
719
+ // better-sidebar 把面板实时宽度写在根变量 --dsh-sidebar-width
720
+ //(展开/拖宽 setProperty、收起 removeProperty)。监听 style 属性
721
+ // 变化即得右边界,无变量时视作收起(0)。
722
+ let rightPanelW = 0
723
+ const readRightPanel = () => {
724
+ const w = Number.parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
725
+ const next = Number.isFinite(w) && w > 0 ? Math.round(w) : 0
726
+ if (next !== rightPanelW) { rightPanelW = next; layout() }
727
+ }
728
+ readRightPanel()
729
+ new MutationObserver(readRightPanel).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
730
+
731
+ /* ---- 标题栏按钮(theme-watcher 注入宿主,时序不保证 → 轮询等待) ---- */
732
+ const injectBtn = () => {
733
+ if (document.getElementById(BTN_ID) !== null) return 'present'
734
+ const host = document.getElementById(TITLEBAR_ID)
735
+ if (host === null) return 'absent'
736
+ const btn = el('button')
737
+ btn.type = 'button'
738
+ btn.id = BTN_ID
739
+ btn.title = '切换内嵌终端'
740
+ btn.setAttribute('aria-label', '切换内嵌终端')
741
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
742
+ svg.setAttribute('viewBox', '0 0 16 16')
743
+ svg.setAttribute('width', '16')
744
+ svg.setAttribute('height', '16')
745
+ svg.setAttribute('fill', 'none')
746
+ svg.innerHTML = '<rect x="2" y="2.5" width="12" height="11" rx="1.75" stroke="currentColor" stroke-width="1.2"/><path d="M4.9 6.3 6.6 8l-1.7 1.7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.3 9.9h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>'
747
+ btn.append(svg)
748
+ btn.onclick = () => {
749
+ void resolveWorkspace().then(ws => {
750
+ if (ws != null && ws.matched) { activeBucket = ws.path; activeTitle = ws.title; fallbackSeen = true }
751
+ toggle()
752
+ }).catch(() => toggle())
753
+ }
754
+ host.append(btn)
755
+ return 'injected'
756
+ }
757
+ // 标题栏会被上游 SPA 重渲染整表重写(按钮随之丢失):前 60s 高频
758
+ // 注入,此后转低频常驻巡逻,发现按钮被清即重建。
759
+ let tries = 0
760
+ const fastPoll = setInterval(() => {
761
+ if (injectBtn() !== 'absent' || ++tries > 120) {
762
+ clearInterval(fastPoll)
763
+ setInterval(() => { injectBtn() }, 5000)
764
+ }
765
+ }, 500)
766
+
767
+ /* ---- 启动:探针 + SSE + 预热 vendor ---- */
768
+ if (document.body !== null) watchSelection()
769
+ else document.addEventListener('DOMContentLoaded', () => watchSelection(), { once: true })
770
+ requestAnimationFrame(watchSidebar)
771
+ connectStream()
772
+ void xtermReady.catch(() => {}) // 预热失败静默,开面板时重试
773
+ void rpc({ op: 'tabs', cwd: '' }).catch(() => {}) // 预热连接(无害)
774
+ }
775
+
776
+ return exports
777
+ },
778
+ })