@mzzsfy/dsh-shell-select 0.3.0 → 0.4.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/README.md +10 -4
- package/cordis.patch.yml +181 -29
- package/package.json +19 -13
- package/src/apply-state.mjs +13 -0
- package/src/client.js +108 -49
- package/src/config.mjs +108 -14
- package/src/executor.mjs +204 -101
- package/src/guard-state.mjs +26 -0
- package/src/guard.js +14 -6
- package/src/index.js +157 -0
- package/src/takeover.mjs +167 -0
- package/src/tool.mjs +39 -13
- package/src/web-routes.mjs +91 -0
- package/test/client-card.test.mjs +24 -4
- package/test/client-render.test.mjs +170 -0
- package/test/config-entry.test.mjs +13 -8
- package/test/config.test.mjs +57 -4
- package/test/deny-config.test.mjs +8 -8
- package/test/executor-seam.test.mjs +3 -3
- package/test/executor.test.mjs +150 -121
- package/test/guard-state.test.mjs +46 -1
- package/test/preset-drift.test.mjs +49 -69
- package/test/takeover-shell.test.mjs +397 -0
- package/test/web-routes.test.mjs +267 -0
package/src/client.js
CHANGED
|
@@ -8,7 +8,8 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
const React = require('react')
|
|
9
9
|
const { useState, useEffect } = React
|
|
10
10
|
|
|
11
|
-
// 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)
|
|
11
|
+
// 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)。
|
|
12
|
+
// 0.1.7 起图标名带字重后缀(Regular/Medium),尺寸是 prop 不在名字里
|
|
12
13
|
const primitives = (() => {
|
|
13
14
|
try {
|
|
14
15
|
return require('@deepseek-ai/dsh-client-ui-primitives')
|
|
@@ -16,31 +17,34 @@ window.__ModuleLoader__.load({
|
|
|
16
17
|
return null
|
|
17
18
|
}
|
|
18
19
|
})()
|
|
19
|
-
const createElementOf = (
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
const createElementOf = (names, fallback) => {
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
const Component = primitives !== null ? primitives[name] : undefined
|
|
23
|
+
if (typeof Component === 'function' || typeof Component === 'object') {
|
|
24
|
+
return (props) => React.createElement(Component, props ?? null)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return fallback
|
|
24
28
|
}
|
|
25
29
|
const DOT_SVG = { done: '#2e9e5b', error: '#d4553f', warning: '#d9a13b', ongoing: '#7a7f8a' }
|
|
26
30
|
// 降级自绘:模块表缺 primitives 时保持可见性(形态近似官方 StateDot/图标)
|
|
27
31
|
const TOOLVIEW_ICONS = {
|
|
28
|
-
StateDot: createElementOf('StateDot', ({ state }) => h('span', {
|
|
32
|
+
StateDot: createElementOf(['StateDot'], ({ state }) => h('span', {
|
|
29
33
|
style: {
|
|
30
34
|
width: 8, height: 8, borderRadius: '50%', background: DOT_SVG[state] ?? DOT_SVG.ongoing,
|
|
31
35
|
display: 'inline-block', flex: 'none',
|
|
32
36
|
},
|
|
33
37
|
})),
|
|
34
|
-
IconApi: createElementOf('IconApiOutline14', () => h('span', {
|
|
38
|
+
IconApi: createElementOf(['IconApiOutlineRegular', 'IconApiOutlineMedium', 'IconApiOutline14'], () => h('span', {
|
|
35
39
|
style: {
|
|
36
40
|
width: 12, height: 12, borderRadius: 3, border: '1.5px solid currentColor',
|
|
37
41
|
opacity: .7, display: 'inline-block', flex: 'none',
|
|
38
42
|
},
|
|
39
43
|
})),
|
|
40
|
-
IconChevron: createElementOf('IconChevronDownOutline14', ({ size }) => h('span', {
|
|
44
|
+
IconChevron: createElementOf(['IconChevronDownOutlineRegular', 'IconChevronDownOutlineMedium', 'IconChevronDownOutline14'], ({ size }) => h('span', {
|
|
41
45
|
style: { fontSize: (size ?? 14) - 3, lineHeight: 1, userSelect: 'none' },
|
|
42
46
|
}, '▾')),
|
|
43
|
-
IconInspect: createElementOf('IconInspectOutline12', () => h('span', {
|
|
47
|
+
IconInspect: createElementOf(['IconInspectOutlineRegular', 'IconInspectOutlineMedium', 'IconInspectOutline12'], () => h('span', {
|
|
44
48
|
style: { fontSize: 10, lineHeight: 1, opacity: .8 },
|
|
45
49
|
}, 'ⓘ')),
|
|
46
50
|
}
|
|
@@ -66,7 +70,7 @@ window.__ModuleLoader__.load({
|
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// 工具卡客户端名册缓存:GET /config 单飞拉取,shell 徽章按 id→用户命名解析。
|
|
69
|
-
//
|
|
73
|
+
// 失败复位单飞(宿主启动窗口路由未就绪的 404 在下次卡渲染自然重试)
|
|
70
74
|
let clientCatalog = null
|
|
71
75
|
let clientCatalogPromise = null
|
|
72
76
|
// LOGIC-BEGIN ensureClientCatalog
|
|
@@ -74,9 +78,11 @@ window.__ModuleLoader__.load({
|
|
|
74
78
|
if (clientCatalogPromise === null) {
|
|
75
79
|
clientCatalogPromise = api(API.config).then((section) => {
|
|
76
80
|
const byId = {}
|
|
77
|
-
for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.
|
|
81
|
+
for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.id
|
|
78
82
|
clientCatalog = { default: section.resolved?.default, byId }
|
|
79
|
-
}).catch(() => {
|
|
83
|
+
}).catch(() => {
|
|
84
|
+
clientCatalogPromise = null
|
|
85
|
+
})
|
|
80
86
|
}
|
|
81
87
|
return clientCatalogPromise
|
|
82
88
|
}
|
|
@@ -128,7 +134,16 @@ window.__ModuleLoader__.load({
|
|
|
128
134
|
'.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
|
|
129
135
|
'.sls-tv__row--exp { cursor:pointer; user-select:none; }',
|
|
130
136
|
'.sls-tv__lead { display:flex; align-items:center; gap:4px; color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
131
|
-
|
|
137
|
+
// 官方 DisclosureRow 同构:icon 与 chevron 同 16px 槽,hover 交叉淡化;
|
|
138
|
+
// 错误/进行中状态点绝对定位覆盖 icon(红点掩盖 icon)
|
|
139
|
+
'.sls-tv__leadStack { position:relative; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; flex:none; }',
|
|
140
|
+
'.sls-tv__iconIdle { display:inline-flex; opacity:1; transition:opacity 100ms ease; }',
|
|
141
|
+
'.sls-tv__stateCover { position:absolute; inset:0; margin:auto; display:inline-flex; align-items:center; justify-content:center; background:transparent; }',
|
|
142
|
+
'.sls-tv__stateCover::before { content:""; position:absolute; inset:-2px; border-radius:50%; background:var(--dsw-alias-bg-primary, #fff); }',
|
|
143
|
+
'.sls-tv__stateCover > span { position:relative; }',
|
|
144
|
+
'.sls-tv__row:hover .sls-tv__iconIdle { opacity:0; }',
|
|
145
|
+
'.sls-tv__chev { opacity:.45; transition:transform .15s ease, opacity .1s ease; }',
|
|
146
|
+
'.sls-tv__row:hover .sls-tv__chev { opacity:1; }',
|
|
132
147
|
'.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
|
|
133
148
|
'.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
|
|
134
149
|
'.sls-tv__title { font-weight:400; }',
|
|
@@ -141,6 +156,10 @@ window.__ModuleLoader__.load({
|
|
|
141
156
|
'.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
|
|
142
157
|
'.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
|
|
143
158
|
'.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
159
|
+
'.sls-tv__duration { font-family:var(--sls-mono, monospace); font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; font-variant-numeric:tabular-nums; }',
|
|
160
|
+
// 运行中脉冲文字:零信息窗口(参数未到)的活体反馈
|
|
161
|
+
'.sls-tv__pulse { font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; animation:sls-pulse 1.5s ease-in-out infinite; }',
|
|
162
|
+
'@keyframes sls-pulse { 0%,100% { opacity:.35; } 50% { opacity:1; } }',
|
|
144
163
|
'.sls-tv__sp { flex:1; }',
|
|
145
164
|
'.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
|
|
146
165
|
'.sls-tv__copyBtn { padding:2px 8px; border:1px solid rgba(128,128,128,.35); border-radius:6px; background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
|
|
@@ -186,7 +205,6 @@ window.__ModuleLoader__.load({
|
|
|
186
205
|
return {
|
|
187
206
|
shells: entries.map((entry) => ({
|
|
188
207
|
id: entry.id.trim(),
|
|
189
|
-
name: entry.name.trim(),
|
|
190
208
|
kind: entry.kind,
|
|
191
209
|
path: entry.path.trim(),
|
|
192
210
|
args: entry.argsText.split(/\s+/).filter((item) => item.length > 0),
|
|
@@ -243,7 +261,6 @@ window.__ModuleLoader__.load({
|
|
|
243
261
|
function toEntries(section) {
|
|
244
262
|
return section.shells.map((entry) => ({
|
|
245
263
|
id: entry.id,
|
|
246
|
-
name: entry.name,
|
|
247
264
|
kind: entry.kind,
|
|
248
265
|
path: entry.path,
|
|
249
266
|
argsText: (entry.args ?? []).join(' '),
|
|
@@ -290,7 +307,7 @@ window.__ModuleLoader__.load({
|
|
|
290
307
|
|
|
291
308
|
const addEntry = () => {
|
|
292
309
|
setDirty(true)
|
|
293
|
-
setEntries([...entries, { id: '',
|
|
310
|
+
setEntries([...entries, { id: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
|
|
294
311
|
}
|
|
295
312
|
|
|
296
313
|
const removeEntry = (index) => {
|
|
@@ -422,16 +439,9 @@ window.__ModuleLoader__.load({
|
|
|
422
439
|
h('input', {
|
|
423
440
|
className: 'sls-input',
|
|
424
441
|
value: entry.id,
|
|
425
|
-
placeholder: 'pwsh / git-bash / …(模型看到的 shell
|
|
442
|
+
placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值,亦作显示名)',
|
|
426
443
|
onChange: (event) => patchEntry(index, { id: event.target.value }),
|
|
427
444
|
}),
|
|
428
|
-
h('span', { className: 'sls-grid__label' }, '名称'),
|
|
429
|
-
h('input', {
|
|
430
|
-
className: 'sls-input',
|
|
431
|
-
value: entry.name,
|
|
432
|
-
placeholder: '显示名,如 Git Bash',
|
|
433
|
-
onChange: (event) => patchEntry(index, { name: event.target.value }),
|
|
434
|
-
}),
|
|
435
445
|
h('span', { className: 'sls-grid__label' }, '形态'),
|
|
436
446
|
h('div', { className: 'sls-row' },
|
|
437
447
|
h('select', {
|
|
@@ -624,12 +634,12 @@ window.__ModuleLoader__.load({
|
|
|
624
634
|
const cwdDir = cwdFull !== undefined ? lastSegment(cwdFull) : undefined
|
|
625
635
|
// shell 名 = 用户在配置页起的条目名;args.shell 缺省时按 default 客户端解析(配置当前读数)
|
|
626
636
|
const shellName = clientDisplayName(typeof args?.shell === 'string' ? args.shell : undefined)
|
|
627
|
-
if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null }
|
|
637
|
+
if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null, running: !settled, alert: undefined }
|
|
628
638
|
|
|
629
639
|
if (!settled) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
|
|
640
|
+
const startedAt = typeof block.time === 'number' ? block.time : undefined
|
|
641
|
+
// 运行中一律全量卡:阻塞时展开即可见命令与时长,不落无命令的 generic 回退行
|
|
642
|
+
return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined, startedAt }
|
|
633
643
|
}
|
|
634
644
|
|
|
635
645
|
const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
|
|
@@ -638,9 +648,11 @@ window.__ModuleLoader__.load({
|
|
|
638
648
|
const jobId = /started background job (\S+)/.exec(contentText)?.[1]
|
|
639
649
|
return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: contentText, jobId }
|
|
640
650
|
}
|
|
641
|
-
// 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done
|
|
651
|
+
// 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡;
|
|
652
|
+
// isError/error 块带 alert(错误红点语义),其余(空输出/无描述/截断)中性无状态点
|
|
642
653
|
if (contentText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
|
|
643
|
-
|
|
654
|
+
const alert = block.isError === true || block.error !== undefined ? 'error' : undefined
|
|
655
|
+
return { kind: 'generic', command, summary: description, output: contentText, running: false, alert }
|
|
644
656
|
}
|
|
645
657
|
const tail = parseExitTail(contentText)
|
|
646
658
|
const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
|
|
@@ -648,18 +660,49 @@ window.__ModuleLoader__.load({
|
|
|
648
660
|
}
|
|
649
661
|
// LOGIC-END shellCardModel
|
|
650
662
|
|
|
651
|
-
// 官方
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
663
|
+
// 官方 DisclosureRow leading 同构:16px 槽内 icon 与 chevron 同位,
|
|
664
|
+
// 收起时 chevron hover 才现(交叉淡化),展开时仅 chevron-up;错误态
|
|
665
|
+
// 红点叠 icon 之上(掩盖 icon,官方状态点语义)
|
|
666
|
+
function leadingStack({ status, icons }) {
|
|
667
|
+
const failed = status === 'failed' || status === 'signaled' || status === 'generic-error'
|
|
668
|
+
const dot = failed ? icons.StateDot({ state: 'error' }) : status === 'generic-warn' ? icons.StateDot({ state: 'warning' }) : status === 'running' ? icons.StateDot({ state: 'ongoing' }) : null
|
|
669
|
+
if (dot !== null) {
|
|
670
|
+
return h('span', { className: 'sls-tv__leadStack' },
|
|
671
|
+
h('span', { className: 'sls-tv__iconIdle' }, icons.IconApi({ size: 14 })),
|
|
672
|
+
h('span', { className: 'sls-tv__stateCover' }, dot),
|
|
673
|
+
)
|
|
674
|
+
}
|
|
656
675
|
return icons.IconApi({ size: 14 })
|
|
657
676
|
}
|
|
658
677
|
|
|
678
|
+
// 时长格式:秒 → m:ss;超一小时 → h:mm:ss
|
|
679
|
+
// LOGIC-BEGIN formatDuration
|
|
680
|
+
function formatDuration(elapsedMs) {
|
|
681
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
|
|
682
|
+
const seconds = totalSeconds % 60
|
|
683
|
+
const minutes = Math.floor(totalSeconds / 60) % 60
|
|
684
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
685
|
+
const two = (value) => (value < 10 ? '0' + value : String(value))
|
|
686
|
+
return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`
|
|
687
|
+
}
|
|
688
|
+
// LOGIC-END formatDuration
|
|
689
|
+
|
|
690
|
+
// 运行中实时时长:每秒 tick;秒级粒度对"阻塞了多久"足够
|
|
691
|
+
function RunningDuration({ startedAt, en }) {
|
|
692
|
+
const [, setTick] = useState(0)
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
const timer = setInterval(() => setTick((value) => value + 1), 1000)
|
|
695
|
+
return () => clearInterval(timer)
|
|
696
|
+
}, [])
|
|
697
|
+
const elapsed = startedAt !== undefined ? Date.now() - startedAt : undefined
|
|
698
|
+
const text = elapsed !== undefined && elapsed >= 0 ? formatDuration(elapsed) : en ? 'running' : '运行中'
|
|
699
|
+
return h('span', { className: 'sls-tv__duration', title: en ? 'Elapsed' : '已运行' }, text)
|
|
700
|
+
}
|
|
701
|
+
|
|
659
702
|
function statusTextOf(status, en) {
|
|
660
703
|
switch (status) {
|
|
661
704
|
case 'running': return en ? 'Running' : '运行中'
|
|
662
|
-
case 'background': return en ? 'Background' : '
|
|
705
|
+
case 'background': return en ? 'Background' : '后台'
|
|
663
706
|
case 'failed': case 'signaled': return en ? 'Failed' : '失败'
|
|
664
707
|
default: return null
|
|
665
708
|
}
|
|
@@ -668,9 +711,10 @@ window.__ModuleLoader__.load({
|
|
|
668
711
|
function headMetaOf(model, en) {
|
|
669
712
|
switch (model.status) {
|
|
670
713
|
case 'running': return { dot: 'ongoing', label: en ? 'Running' : '运行中', pill: undefined }
|
|
714
|
+
// ack 块静态不反映 job 生命周期,状态点缺省(后台任务由 bg pill 标识)
|
|
671
715
|
case 'background': return {
|
|
672
|
-
dot:
|
|
673
|
-
label: en ? 'Background' : '
|
|
716
|
+
dot: undefined,
|
|
717
|
+
label: en ? 'Background' : '后台',
|
|
674
718
|
pill: model.jobId !== undefined ? (en ? `bg ${model.jobId}` : `后台 ${model.jobId}`) : undefined,
|
|
675
719
|
pillTone: 'bg',
|
|
676
720
|
}
|
|
@@ -699,13 +743,17 @@ window.__ModuleLoader__.load({
|
|
|
699
743
|
}, done ? (detectEnglish() ? 'Copied' : '已复制') : label)
|
|
700
744
|
}
|
|
701
745
|
|
|
702
|
-
// 非终端意图回退行(后台 ack / isError / 截断):摘要 +
|
|
746
|
+
// 非终端意图回退行(后台 ack / isError / 截断 / 参数未到):摘要 + 可展开原文。
|
|
747
|
+
// 状态语义对齐官方:运行中灰 spinner,settled 错误红点,其余中性 icon——
|
|
748
|
+
// 黄点(warning)仅真实告警,禁止挪用作"进行中"(用户把黄点当卡死)。
|
|
749
|
+
// 运行中且零信息(参数未到)显示脉冲「运行中…」,给用户活着的感觉。
|
|
703
750
|
function GenericShellRow({ model, inspect, en }) {
|
|
704
751
|
const [open, setOpen] = useState(false)
|
|
752
|
+
const status = model.running === true ? 'running' : model.alert === 'error' ? 'generic-error' : undefined
|
|
705
753
|
const summary = model.summary !== undefined && model.summary !== ''
|
|
706
754
|
? model.summary.split('\n')[0]
|
|
707
755
|
: (model.output !== null && model.output !== '' ? model.output.split('\n')[0] : '')
|
|
708
|
-
const expandable = model.output !== null && model.output !== ''
|
|
756
|
+
const expandable = (model.output !== null && model.output !== '') || model.command !== ''
|
|
709
757
|
return h('div', { className: 'sls-tv' },
|
|
710
758
|
h('div', {
|
|
711
759
|
className: 'sls-tv__row' + (expandable ? ' sls-tv__row--exp' : ''),
|
|
@@ -721,14 +769,15 @@ window.__ModuleLoader__.load({
|
|
|
721
769
|
} : undefined,
|
|
722
770
|
},
|
|
723
771
|
h('span', { className: 'sls-tv__lead' },
|
|
724
|
-
|
|
772
|
+
leadingStack({ status, icons: TOOLVIEW_ICONS }),
|
|
725
773
|
expandable ? h('span', { className: 'sls-tv__chev', 'data-open': open ? '1' : '0', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })) : null,
|
|
726
774
|
),
|
|
727
775
|
h('span', { className: 'sls-tv__title' }, 'Shell'),
|
|
728
776
|
summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
|
|
729
|
-
summary !== '' ? h('span', { className: 'sls-tv__sum' }, summary) : null,
|
|
777
|
+
summary !== '' ? h('span', { className: 'sls-tv__sum' + (status === 'generic-error' ? ' sls-tv__sum--err' : '') }, summary) : null,
|
|
778
|
+
status === 'running' ? h('span', { className: 'sls-tv__pulse' }, en ? 'running…' : '运行中…') : null,
|
|
730
779
|
),
|
|
731
|
-
open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.output) : null,
|
|
780
|
+
open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.command + (model.output !== null && model.output !== '' ? `\n${model.output}` : '')) : null,
|
|
732
781
|
inspect !== undefined ? h('button', { className: 'sls-tv__inspect', onClick: inspect }, TOOLVIEW_ICONS.IconInspect({}), en ? 'Inspect' : '检查') : null,
|
|
733
782
|
)
|
|
734
783
|
}
|
|
@@ -748,16 +797,20 @@ window.__ModuleLoader__.load({
|
|
|
748
797
|
const meta = headMetaOf(model, en)
|
|
749
798
|
const copyCommandLabel = en ? 'Copy command' : '复制命令'
|
|
750
799
|
const copyOutputLabel = en ? 'Copy output' : '复制输出'
|
|
800
|
+
// 运行中头部 pill = 实时时长(组件,hook 内每秒自刷新)
|
|
801
|
+
const pill = model.status === 'running'
|
|
802
|
+
? h(RunningDuration, { startedAt: model.startedAt, en })
|
|
803
|
+
: meta.pill
|
|
751
804
|
return h('div', { className: 'sls-tv__body', 'data-status': model.status },
|
|
752
805
|
h('div', { className: 'sls-tv__head' },
|
|
753
806
|
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
|
|
754
|
-
TOOLVIEW_ICONS.StateDot({ state: meta.dot }),
|
|
807
|
+
meta.dot !== undefined ? TOOLVIEW_ICONS.StateDot({ state: meta.dot }) : null,
|
|
755
808
|
h('span', { className: 'sls-tv__sr' }, meta.label),
|
|
756
809
|
),
|
|
757
810
|
model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
|
|
758
811
|
model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
|
|
759
812
|
h('span', { className: 'sls-tv__sp' }),
|
|
760
|
-
|
|
813
|
+
pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, pill) : null,
|
|
761
814
|
h('span', { className: 'sls-tv__copy' },
|
|
762
815
|
h(CopyButton, {
|
|
763
816
|
label: copyCommandLabel,
|
|
@@ -783,8 +836,10 @@ window.__ModuleLoader__.load({
|
|
|
783
836
|
const { block, cwd, inspect } = props
|
|
784
837
|
const en = detectEnglish()
|
|
785
838
|
const model = shellCardModel(block, cwd)
|
|
786
|
-
//
|
|
787
|
-
|
|
839
|
+
// hooks 恒序:必须在任何提前 return 之前完整执行(真实 React 的 hooks
|
|
840
|
+
// 链表按调用序对位,generic 行与 terminal 行的提前 return 分叉会让
|
|
841
|
+
// 后续 hook 错位,行组件树被整棵卸载——卡片全消失事故根因)
|
|
842
|
+
const [open, setOpen] = useState(false)
|
|
788
843
|
const [, setCatalogReady] = useState(false)
|
|
789
844
|
useEffect(() => {
|
|
790
845
|
let disposed = false
|
|
@@ -812,13 +867,17 @@ window.__ModuleLoader__.load({
|
|
|
812
867
|
},
|
|
813
868
|
},
|
|
814
869
|
h('span', { className: 'sls-tv__lead' },
|
|
815
|
-
|
|
870
|
+
leadingStack({ status: model.status, icons: TOOLVIEW_ICONS }),
|
|
816
871
|
h('span', { className: 'sls-tv__chev', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })),
|
|
817
872
|
),
|
|
818
873
|
srStatus !== null ? h('span', { className: 'sls-tv__sr' }, srStatus) : null,
|
|
819
874
|
h('span', { className: 'sls-tv__title' }, 'Shell'),
|
|
820
875
|
h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
|
|
821
876
|
h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
|
|
877
|
+
model.status === 'running' ? h(RunningDuration, { startedAt: model.startedAt, en }) : null,
|
|
878
|
+
model.status === 'background' && model.jobId !== undefined
|
|
879
|
+
? h('span', { className: 'sls-tv__badge', title: en ? 'Background job' : '后台任务' }, en ? `bg ${model.jobId}` : `后台 ${model.jobId}`)
|
|
880
|
+
: null,
|
|
822
881
|
),
|
|
823
882
|
open ? h(ShellCard, { model, inspect, en }) : null,
|
|
824
883
|
)
|
package/src/config.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// cmd /d /s /c 与 wsl --exec 为本包定义的保守形态。
|
|
4
4
|
|
|
5
5
|
import z from '@deepseek-ai/schemastery'
|
|
6
|
+
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
6
7
|
|
|
7
8
|
// 支持的 shell 形态
|
|
8
9
|
export const KINDS = ['pwsh', 'bash', 'cmd', 'wsl']
|
|
@@ -29,7 +30,6 @@ const EXECUTOR_DEFAULTS = {
|
|
|
29
30
|
|
|
30
31
|
const shellEntry = z.object({
|
|
31
32
|
id: z.string().required(),
|
|
32
|
-
name: z.string().required(),
|
|
33
33
|
kind: z.union(KINDS).required(),
|
|
34
34
|
path: z.string().default(RESOLVED_AUTO),
|
|
35
35
|
args: z.array(z.string()).default([]),
|
|
@@ -39,27 +39,121 @@ const shellEntry = z.object({
|
|
|
39
39
|
})
|
|
40
40
|
|
|
41
41
|
export const Config = z.object({
|
|
42
|
+
// 0.1.7 设置页写路径(settings.write)只接受 volatile 字段(免重挂载的活
|
|
43
|
+
// 字段,官方 dsh-bash-local Config 逐字段 volatile 同构);缺声明即
|
|
44
|
+
// "has no volatile fields" 保存失败
|
|
42
45
|
shells: z.array(shellEntry).default([
|
|
43
|
-
{ id: 'pwsh',
|
|
44
|
-
{ id: 'git-bash',
|
|
45
|
-
{ id: 'cmd',
|
|
46
|
-
]),
|
|
47
|
-
default: z.string().default('pwsh'),
|
|
46
|
+
{ id: 'pwsh', kind: 'pwsh', path: RESOLVED_AUTO, args: [] },
|
|
47
|
+
{ id: 'git-bash', kind: 'bash', path: RESOLVED_AUTO, args: [] },
|
|
48
|
+
{ id: 'cmd', kind: 'cmd', path: RESOLVED_AUTO, args: [] },
|
|
49
|
+
]).volatile(),
|
|
50
|
+
default: z.string().default('pwsh').volatile(),
|
|
48
51
|
// 命令黑名单(整文本正则,大小写不敏感):命中即拒,deny 绝对无豁免;
|
|
49
52
|
// 精细放行用正则前瞻在模式内表达。空数组 = 不拦截。
|
|
50
53
|
// 护栏防误触,非安全边界(真边界是沙箱与访问模式)
|
|
51
|
-
deny: z.array(z.string()).default([]),
|
|
52
|
-
cwd: z.string(),
|
|
53
|
-
timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs),
|
|
54
|
-
maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs),
|
|
55
|
-
maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes),
|
|
56
|
-
maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes),
|
|
57
|
-
graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs),
|
|
54
|
+
deny: z.array(z.string()).default([]).volatile(),
|
|
55
|
+
cwd: z.string().volatile(),
|
|
56
|
+
timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs).volatile(),
|
|
57
|
+
maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs).volatile(),
|
|
58
|
+
maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes).volatile(),
|
|
59
|
+
maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes).volatile(),
|
|
60
|
+
graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs).volatile(),
|
|
58
61
|
})
|
|
59
62
|
|
|
63
|
+
// volatile 字段解箱:schema 声明 .volatile() 后(0.1.7 设置页写路径只接受
|
|
64
|
+
// volatile 字段,缺声明即 "has no volatile fields" 拒写),Config 产物字段为
|
|
65
|
+
// boxed ref(读值须 .get(),官方 dsh-bash-local 同构)。统一在此解箱,消费者
|
|
66
|
+
// (executor/web-routes/测试)一律拿普通对象。
|
|
67
|
+
// structuredClone 深拷贝双向:输入侧(sanitize 产物)切断与调用方对象的耦合——
|
|
68
|
+
// volatile ref .get() 产物是 cosmokit snapshot 深冻结对象,schemastery resolve
|
|
69
|
+
// 原地改写入参,冻结 env 直接炸挂载;输出侧:schemastery resolve 会原地改写入参
|
|
70
|
+
// (填默认/键归一)并把产出对象置为不可扩展,二轮 resolve(快照回灌,web 写路径
|
|
71
|
+
// current+patch 合并常态)对不可扩展对象赋值即炸;clone 切断产物与 schema 的原地
|
|
72
|
+
// 耦合,保证 resolveConfig 幂等且对输入不可变。
|
|
73
|
+
export function unwrapConfig(value) {
|
|
74
|
+
return Object.fromEntries(Object.entries(value).map(([key, field]) => {
|
|
75
|
+
const raw = typeof field?.get === 'function' ? field.get() : field
|
|
76
|
+
return [key, raw !== null && typeof raw === 'object' ? structuredClone(raw) : raw]
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** schema 应用 + 解箱 + 输入深拷:全部消费者经此入口,不直接触 Config 产物。 */
|
|
81
|
+
export function resolveConfig(entry) {
|
|
82
|
+
const sanitized = sanitizeConfigEntry(entry ?? {})
|
|
83
|
+
return unwrapConfig(Config(structuredClone(sanitized)))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 落盘行 config 的类型防御:设置写路径(原生页/mutate)可能把 volatile 字段
|
|
87
|
+
// 以非预期形态(ref 序列化产物 {})持久化,冷启动 Config 直接抛 → 行死 →
|
|
88
|
+
// settings describe 无此节 → 设置页保存 "no longer configurable" 死锁,
|
|
89
|
+
// 只能手工改 patch 解锁。类型不符的键降级为 schema 默认:行活、设置页活、
|
|
90
|
+
// 数据可经保存覆盖修复(官方 schema default 兜底同语义)。
|
|
91
|
+
// 字段类型契约:与 schema 声明一一对应,清洗按此对账
|
|
92
|
+
const FIELD_TYPES = {
|
|
93
|
+
shells: 'array',
|
|
94
|
+
deny: 'array',
|
|
95
|
+
default: 'string',
|
|
96
|
+
cwd: 'string',
|
|
97
|
+
timeoutMs: 'number',
|
|
98
|
+
maxTimeoutMs: 'number',
|
|
99
|
+
maxOutputBytes: 'number',
|
|
100
|
+
maxSpillBytes: 'number',
|
|
101
|
+
graceMs: 'number',
|
|
102
|
+
}
|
|
103
|
+
export function sanitizeConfigEntry(entry) {
|
|
104
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return {}
|
|
105
|
+
const out = {}
|
|
106
|
+
for (const [key, kind] of Object.entries(FIELD_TYPES)) {
|
|
107
|
+
const value = entry[key]
|
|
108
|
+
if (value === undefined) continue
|
|
109
|
+
const valid = kind === 'array' ? Array.isArray(value) : typeof value === kind
|
|
110
|
+
if (valid) out[key] = value
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
60
115
|
/** 出厂默认配置(供文档与测试比对;schema 内 default 为同构静态值)。 */
|
|
61
116
|
export function defaultConfig() {
|
|
62
|
-
return
|
|
117
|
+
return resolveConfig({})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// yaml 无引号标量会把 `\` 字面落盘,任何一层再转义都让路径翻倍(C:\\ 实测):
|
|
121
|
+
// 入口统一归一,保证进 schema 的 path 就是干净值。
|
|
122
|
+
// 仅 Windows 宿主生效:posix 路径以 / 分隔,归一会把合法路径毁成反斜杠字面(CI linux 实测)
|
|
123
|
+
export function normalizeWin32Path(path) {
|
|
124
|
+
if (process.platform !== 'win32') return path
|
|
125
|
+
if (typeof path !== 'string' || path.length === 0) return path
|
|
126
|
+
return path.replace(/\\{2,}/g, '\\').replace(/\//g, '\\')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 更新负载深归一:shells[].path 与任意层字符串值只处理 path 键,避免误伤 args 模板
|
|
130
|
+
export function normalizeConfigPaths(patch) {
|
|
131
|
+
if (typeof patch !== 'object' || patch === null || !Array.isArray(patch.shells)) return patch
|
|
132
|
+
return {
|
|
133
|
+
...patch,
|
|
134
|
+
shells: patch.shells.map((entry) => (typeof entry?.path === 'string' ? { ...entry, path: normalizeWin32Path(entry.path) } : entry)),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertPositiveFinite(name, value) {
|
|
139
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`shell-select: ${name} must be a positive finite number`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 拒绝无法运行的已解析配置节(schema 之外的正数/时限/清单约束,官方 assertServiceable 同构)。 */
|
|
143
|
+
export function assertServiceableConfig(config) {
|
|
144
|
+
assertPositiveFinite('timeoutMs', config.timeoutMs)
|
|
145
|
+
assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs)
|
|
146
|
+
assertPositiveFinite('maxOutputBytes', config.maxOutputBytes)
|
|
147
|
+
assertPositiveFinite('maxSpillBytes', config.maxSpillBytes)
|
|
148
|
+
assertPositiveFinite('graceMs', config.graceMs)
|
|
149
|
+
if (config.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`shell-select: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
150
|
+
if (!Array.isArray(config.shells) || config.shells.length === 0) throw new Error('shell-select: shells must not be empty')
|
|
151
|
+
const ids = new Set()
|
|
152
|
+
for (const entry of config.shells) {
|
|
153
|
+
if (ids.has(entry.id)) throw new Error(`shell-select: duplicate shell id "${entry.id}"`)
|
|
154
|
+
ids.add(entry.id)
|
|
155
|
+
}
|
|
156
|
+
if (!ids.has(config.default)) throw new Error(`shell-select: default "${config.default}" is not a configured shell id`)
|
|
63
157
|
}
|
|
64
158
|
|
|
65
159
|
/**
|