@vibe-x/dsh-html-workbench 0.1.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 +48 -0
- package/assets/workbench.html +901 -0
- package/cordis.patch.yml +6 -0
- package/dsh.plugin.json +16 -0
- package/lib/client.js +267 -0
- package/package.json +51 -0
- package/scripts/workbench.py +774 -0
- package/src/client.js +229 -0
- package/src/host.js +201 -0
- package/src/index.js +27 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Workbench DSH plugin — Client half (function body).
|
|
3
|
+
*
|
|
4
|
+
* This file is the plain-JavaScript function body consumed by DeepSeek
|
|
5
|
+
* Harness's dynamic Cordis plugin loader: pass this exact text as `code.client`
|
|
6
|
+
* to `cordis_define`. The static client bundle wraps this same body in
|
|
7
|
+
* `window.__ModuleLoader__.load(...)` at build time.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities (runs in the browser):
|
|
10
|
+
* - Register a right-side panel + corner trigger in `shell.overlay`.
|
|
11
|
+
* - Preview takes the full panel height; the HTML-asset picker is a compact,
|
|
12
|
+
* collapsible dock at the bottom (a <select> switcher + expandable list).
|
|
13
|
+
* - Open a file by calling Host `open`, then embed the workbench URL in an
|
|
14
|
+
* iframe. Left edge is draggable to resize the panel width.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
inject: ['timer'],
|
|
19
|
+
apply(ctx) {
|
|
20
|
+
const slots = ctx.get('slots')
|
|
21
|
+
if (slots === undefined) return
|
|
22
|
+
|
|
23
|
+
styles.insert(`
|
|
24
|
+
html #root { margin-right: var(--hwb-panel-width, 0px); transition: margin-right var(--ds-transition-duration-slow, 200ms) ease; }
|
|
25
|
+
body[data-hwb-dragging] #root { transition: none; }
|
|
26
|
+
body[data-hwb-dragging] { user-select: none; }
|
|
27
|
+
.hwb-panel, .hwb-panel * { box-sizing: border-box; }
|
|
28
|
+
.hwb-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 820px; max-width: calc(100vw - 24px); display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-left: 1px solid var(--dsw-alias-border-l1); box-shadow: var(--dsw-shadow-lv2); z-index: 9999; font-family: var(--dsw-font-family, system-ui, sans-serif); font-size: 13px; line-height: 1.5; pointer-events: auto; }
|
|
29
|
+
.hwb-resize { position: absolute; left: -4px; top: 0; bottom: 0; width: 9px; cursor: col-resize; z-index: 3; touch-action: none; }
|
|
30
|
+
.hwb-resize::after { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: transparent; transition: background .15s; }
|
|
31
|
+
.hwb-resize:hover::after, .hwb-resize:active::after { background: var(--dsw-alias-interactive-bg-hover-accent); }
|
|
32
|
+
.hwb-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); flex: none; }
|
|
33
|
+
.hwb-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); flex: none; }
|
|
34
|
+
.hwb-dot.on { background: var(--dsw-alias-state-success-primary); }
|
|
35
|
+
.hwb-titlewrap { min-width: 0; display: flex; flex-direction: column; }
|
|
36
|
+
.hwb-title { font-weight: 600; font-size: 13px; line-height: 1.2; white-space: nowrap; }
|
|
37
|
+
.hwb-sub { font-size: 11px; color: var(--dsw-alias-label-tertiary); line-height: 1.2; }
|
|
38
|
+
.hwb-spacer { flex: 1; }
|
|
39
|
+
.hwb-close { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; border-radius: 6px; flex: none; }
|
|
40
|
+
.hwb-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
41
|
+
.hwb-preview { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: #fff; }
|
|
42
|
+
.hwb-frame { flex: 1; border: 0; width: 100%; }
|
|
43
|
+
.hwb-hint { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); text-align: center; padding: 16px; }
|
|
44
|
+
.hwb-error { padding: 10px 14px; color: var(--dsw-alias-state-error-primary); font-size: 12px; }
|
|
45
|
+
.hwb-dock { flex: none; border-top: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); display: flex; flex-direction: column; }
|
|
46
|
+
.hwb-dockbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; }
|
|
47
|
+
.hwb-select { flex: 1; min-width: 0; height: 28px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 0 8px; cursor: pointer; }
|
|
48
|
+
.hwb-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); flex: none; white-space: nowrap; }
|
|
49
|
+
.hwb-docktoggle { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; border-radius: 6px; flex: none; }
|
|
50
|
+
.hwb-docktoggle:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
51
|
+
.hwb-docklist { flex: none; max-height: 38vh; overflow-y: auto; border-top: 1px solid var(--dsw-alias-border-l2); }
|
|
52
|
+
.hwb-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); cursor: pointer; }
|
|
53
|
+
.hwb-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
54
|
+
.hwb-item.active { background: var(--dsw-alias-interactive-bg-active); }
|
|
55
|
+
.hwb-item-main { flex: 1; min-width: 0; }
|
|
56
|
+
.hwb-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
57
|
+
.hwb-item-path { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
58
|
+
.hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
|
|
59
|
+
.hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
60
|
+
.hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
|
|
61
|
+
.hwb-trigger { position: fixed; top: 10px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
|
|
62
|
+
.hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
63
|
+
`)
|
|
64
|
+
|
|
65
|
+
const DEFAULT_WIDTH = 820
|
|
66
|
+
const MIN_WIDTH = 480
|
|
67
|
+
|
|
68
|
+
const store = {
|
|
69
|
+
open: false,
|
|
70
|
+
panelWidth: DEFAULT_WIDTH,
|
|
71
|
+
expanded: false,
|
|
72
|
+
assets: [],
|
|
73
|
+
running: false,
|
|
74
|
+
current: null,
|
|
75
|
+
loading: false,
|
|
76
|
+
error: null,
|
|
77
|
+
listeners: [],
|
|
78
|
+
subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
|
|
79
|
+
set(p) { Object.assign(this, p); this.listeners.forEach((fn) => { try { fn() } catch (e) {} }) },
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const useStore = () => {
|
|
83
|
+
const [, force] = React.useState(0)
|
|
84
|
+
React.useEffect(() => store.subscribe(() => force((x) => x + 1)), [])
|
|
85
|
+
return store
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const basename = (p) => {
|
|
89
|
+
const parts = String(p).split('/')
|
|
90
|
+
return parts[parts.length - 1] || p
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const refresh = () => {
|
|
94
|
+
host.call('list').then((res) => {
|
|
95
|
+
if (res && res.ok) store.set({ assets: res.assets || [], running: !!res.running })
|
|
96
|
+
else store.set({ error: (res && res.error) || 'list failed' })
|
|
97
|
+
}).catch((e) => store.set({ error: String(e && e.message ? e.message : e) }))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const openFile = (path) => {
|
|
101
|
+
store.set({ loading: true, current: { path: path, url: null }, error: null })
|
|
102
|
+
host.call('open', { file: path }).then((res) => {
|
|
103
|
+
if (res && res.ok) store.set({ loading: false, current: { path: path, url: res.url }, running: true })
|
|
104
|
+
else store.set({ loading: false, error: (res && res.error) || 'open failed' })
|
|
105
|
+
}).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const startResize = (e) => {
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
document.body.setAttribute('data-hwb-dragging', '')
|
|
111
|
+
const maxW = Math.max(MIN_WIDTH, window.innerWidth - 60)
|
|
112
|
+
const onMove = (ev) => {
|
|
113
|
+
const w = window.innerWidth - ev.clientX
|
|
114
|
+
store.set({ panelWidth: Math.max(MIN_WIDTH, Math.min(w, maxW)) })
|
|
115
|
+
}
|
|
116
|
+
const onUp = () => {
|
|
117
|
+
document.body.removeAttribute('data-hwb-dragging')
|
|
118
|
+
document.removeEventListener('mousemove', onMove)
|
|
119
|
+
document.removeEventListener('mouseup', onUp)
|
|
120
|
+
}
|
|
121
|
+
document.addEventListener('mousemove', onMove)
|
|
122
|
+
document.addEventListener('mouseup', onUp)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const chevronUp = React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
126
|
+
React.createElement('path', { d: 'M18 15l-6-6-6 6' }))
|
|
127
|
+
const chevronDown = React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
128
|
+
React.createElement('path', { d: 'M6 9l6 6 6-6' }))
|
|
129
|
+
|
|
130
|
+
const Panel = () => {
|
|
131
|
+
const s = useStore()
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
const root = document.documentElement
|
|
134
|
+
root.style.setProperty('--hwb-panel-width', s.open ? (s.panelWidth + 'px') : '0px')
|
|
135
|
+
return () => { root.style.setProperty('--hwb-panel-width', '0px') }
|
|
136
|
+
}, [s.open, s.panelWidth])
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
if (!s.open) return
|
|
139
|
+
refresh()
|
|
140
|
+
const dispose = ctx.interval(refresh, 3000)
|
|
141
|
+
return () => { if (dispose) dispose() }
|
|
142
|
+
}, [s.open])
|
|
143
|
+
if (!s.open) return null
|
|
144
|
+
|
|
145
|
+
const assets = s.assets || []
|
|
146
|
+
const currentPath = s.current && s.current.path ? s.current.path : ''
|
|
147
|
+
const opts = assets.map((a) => React.createElement('option', { key: a.id || a.path, value: a.path }, basename(a.path)))
|
|
148
|
+
const items = assets.map((a) => {
|
|
149
|
+
const active = currentPath === a.path
|
|
150
|
+
return React.createElement('div', { key: a.id || a.path, className: 'hwb-item' + (active ? ' active' : '') },
|
|
151
|
+
React.createElement('div', { className: 'hwb-item-main' },
|
|
152
|
+
React.createElement('div', { className: 'hwb-item-name' }, basename(a.path)),
|
|
153
|
+
React.createElement('div', { className: 'hwb-item-path', title: a.path }, a.path),
|
|
154
|
+
),
|
|
155
|
+
React.createElement('button', { type: 'button', className: 'hwb-item-btn', onClick: () => openFile(a.path) }, '打开'),
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return React.createElement('div', { className: 'hwb-panel', style: { width: s.panelWidth + 'px' }, role: 'dialog', 'aria-label': 'HTML Workbench' },
|
|
160
|
+
React.createElement('div', { className: 'hwb-resize', title: '拖动调整宽度', onMouseDown: startResize }),
|
|
161
|
+
React.createElement('div', { className: 'hwb-head' },
|
|
162
|
+
React.createElement('span', { className: 'hwb-dot' + (s.running ? ' on' : '') }),
|
|
163
|
+
React.createElement('div', { className: 'hwb-titlewrap' },
|
|
164
|
+
React.createElement('div', { className: 'hwb-title' }, 'HTML Workbench'),
|
|
165
|
+
React.createElement('div', { className: 'hwb-sub' }, s.running ? '服务运行中 · 拖动左边缘调整宽度' : '服务未就绪'),
|
|
166
|
+
),
|
|
167
|
+
React.createElement('span', { className: 'hwb-spacer' }),
|
|
168
|
+
React.createElement('button', { type: 'button', className: 'hwb-close', title: '关闭', onClick: () => store.set({ open: false }) },
|
|
169
|
+
React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' },
|
|
170
|
+
React.createElement('path', { d: 'M18 6L6 18' }),
|
|
171
|
+
React.createElement('path', { d: 'M6 6l12 12' }),
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
React.createElement('div', { className: 'hwb-preview' },
|
|
176
|
+
s.loading ? React.createElement('div', { className: 'hwb-hint' }, '正在启动 Workbench…')
|
|
177
|
+
: s.current && s.current.url ? React.createElement('iframe', { className: 'hwb-frame', src: s.current.url, title: 'HTML Workbench' })
|
|
178
|
+
: React.createElement('div', { className: 'hwb-hint' }, '从下方选择一个 HTML 文件开始编辑与预览'),
|
|
179
|
+
),
|
|
180
|
+
React.createElement('div', { className: 'hwb-dock' },
|
|
181
|
+
React.createElement('div', { className: 'hwb-dockbar' },
|
|
182
|
+
React.createElement('select', {
|
|
183
|
+
className: 'hwb-select',
|
|
184
|
+
value: currentPath,
|
|
185
|
+
onChange: (e) => { const v = e.target.value; if (v) openFile(v) },
|
|
186
|
+
},
|
|
187
|
+
React.createElement('option', { value: '', disabled: true }, assets.length ? '选择 HTML 文件…' : '暂无 HTML 产物'),
|
|
188
|
+
opts,
|
|
189
|
+
),
|
|
190
|
+
React.createElement('span', { className: 'hwb-count' }, assets.length + ' 个'),
|
|
191
|
+
React.createElement('button', { type: 'button', className: 'hwb-docktoggle', title: s.expanded ? '收起列表' : '展开列表', onClick: () => store.set({ expanded: !s.expanded }) },
|
|
192
|
+
s.expanded ? chevronDown : chevronUp,
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
s.expanded ? React.createElement('div', { className: 'hwb-docklist' },
|
|
196
|
+
items.length ? items : React.createElement('div', { className: 'hwb-dockempty' }, '暂无 HTML 产物 — 代理 write/edit 的 .html 文件会出现在这里。'),
|
|
197
|
+
s.error ? React.createElement('div', { className: 'hwb-error' }, s.error) : null,
|
|
198
|
+
) : null,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const Trigger = () => {
|
|
204
|
+
const s = useStore()
|
|
205
|
+
if (s.open) return null
|
|
206
|
+
return React.createElement('button', {
|
|
207
|
+
type: 'button',
|
|
208
|
+
className: 'hwb-trigger',
|
|
209
|
+
title: 'HTML Workbench',
|
|
210
|
+
'aria-expanded': s.open,
|
|
211
|
+
onClick: () => { store.set({ open: true }); refresh() },
|
|
212
|
+
}, React.createElement('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
213
|
+
React.createElement('rect', { x: 2, y: 4, width: 20, height: 14, rx: 2 }),
|
|
214
|
+
React.createElement('path', { d: 'M8 21h8' }),
|
|
215
|
+
React.createElement('path', { d: 'M12 18v3' }),
|
|
216
|
+
))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
220
|
+
{ name: 'shell.overlay', id: 'html-workbench-panel', order: 60, label: 'HTML Workbench' },
|
|
221
|
+
() => React.createElement(Panel),
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
225
|
+
{ name: 'shell.overlay', id: 'html-workbench-trigger', order: 59, label: 'HTML Workbench 入口' },
|
|
226
|
+
() => React.createElement(Trigger),
|
|
227
|
+
))
|
|
228
|
+
},
|
|
229
|
+
}
|
package/src/host.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Workbench DSH plugin — Host half (function body).
|
|
3
|
+
*
|
|
4
|
+
* This file is the plain-JavaScript function body consumed by DeepSeek
|
|
5
|
+
* Harness's dynamic Cordis plugin loader: pass this exact text as `code.host`
|
|
6
|
+
* to `cordis_define`. The static host entry `src/index.js` evaluates this same
|
|
7
|
+
* body via `new Function` and re-exports it, so it runs unchanged in both
|
|
8
|
+
* modes. `harness` usage is guarded (`typeof harness !== 'undefined'`) so the
|
|
9
|
+
* body also runs as a static bundle where that symbol does not exist.
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities (runs in the DSH Node.js process):
|
|
12
|
+
* - Start / reuse the local `html-workbench` Python service on registration
|
|
13
|
+
* and stop the process it started when the plugin unloads (side-effect
|
|
14
|
+
* lifecycle via ctx.effect).
|
|
15
|
+
* - Track HTML files produced by the `write` / `edit` tools via `tools/result`.
|
|
16
|
+
* - Expose the same three operations through two transports:
|
|
17
|
+
* * dynamic: `harness.handle` Package-private RPC (Client `host.call`);
|
|
18
|
+
* * static: `webServer.register` HTTP routes under `/html-workbench/*`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
inject: ['shell', 'timer'],
|
|
23
|
+
apply(ctx, config) {
|
|
24
|
+
// 静态/NPM 装载时由 src/index.js 相对插件自身解析 scripts/workbench.py 并注入 config.script;
|
|
25
|
+
// 动态装载(cordis_define)无 config → script 为空,服务不自动拉起(仅注册传输层)。
|
|
26
|
+
// editorRoot 可选:缺省不传 --editor-root,由 workbench.py 自行回退到用户家目录。
|
|
27
|
+
const opts = config || {}
|
|
28
|
+
const PORT = opts.port || 4317
|
|
29
|
+
const SCRIPT = opts.script || null
|
|
30
|
+
const EDITOR_ROOT = opts.editorRoot || null
|
|
31
|
+
|
|
32
|
+
let assets = []
|
|
33
|
+
let seq = 0
|
|
34
|
+
let ownedProcess = null
|
|
35
|
+
let serviceRunning = false
|
|
36
|
+
let startError = null
|
|
37
|
+
let disposed = false
|
|
38
|
+
|
|
39
|
+
const shell = ctx.get('shell')
|
|
40
|
+
|
|
41
|
+
const runCli = async (args, timeoutMs) => {
|
|
42
|
+
if (!shell || !SCRIPT) return null
|
|
43
|
+
try {
|
|
44
|
+
const spec = shell.resolve({
|
|
45
|
+
command: 'python3 "' + SCRIPT + '" ' + args,
|
|
46
|
+
timeoutMs: timeoutMs || 15000,
|
|
47
|
+
stdoutMaxBytes: 64 * 1024,
|
|
48
|
+
})
|
|
49
|
+
return await shell.run(spec)
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const health = async () => {
|
|
56
|
+
const r = await runCli('health --port ' + PORT, 8000)
|
|
57
|
+
if (!r || r.exitCode !== 0) return null
|
|
58
|
+
try {
|
|
59
|
+
const p = JSON.parse((r.stdout && r.stdout.text) || '')
|
|
60
|
+
return p && p.ok === true ? p : null
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const startService = async () => {
|
|
67
|
+
if (disposed) return null
|
|
68
|
+
if (serviceRunning) return health()
|
|
69
|
+
const existing = await health()
|
|
70
|
+
if (existing) { serviceRunning = true; startError = null; return existing }
|
|
71
|
+
if (!shell) { startError = 'shell service unavailable'; return null }
|
|
72
|
+
if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; return null }
|
|
73
|
+
let proc = null
|
|
74
|
+
try {
|
|
75
|
+
const editorRootArg = EDITOR_ROOT ? ' --editor-root "' + EDITOR_ROOT + '"' : ''
|
|
76
|
+
const spec = shell.resolve({
|
|
77
|
+
command: 'python3 "' + SCRIPT + '" serve --port ' + PORT + editorRootArg,
|
|
78
|
+
stdoutMaxBytes: 64 * 1024,
|
|
79
|
+
})
|
|
80
|
+
proc = shell.start(spec)
|
|
81
|
+
if (disposed) { try { proc.kill() } catch (e) {} return null }
|
|
82
|
+
ownedProcess = proc
|
|
83
|
+
for (let i = 0; i < 240; i += 1) {
|
|
84
|
+
const h = await health()
|
|
85
|
+
if (h) { serviceRunning = true; startError = null; return h }
|
|
86
|
+
await ctx.timeout(250)
|
|
87
|
+
}
|
|
88
|
+
startError = 'service did not become healthy'
|
|
89
|
+
return null
|
|
90
|
+
} catch (e) {
|
|
91
|
+
startError = e && e.message ? String(e.message) : String(e)
|
|
92
|
+
if (proc) { try { proc.kill() } catch (e2) {} }
|
|
93
|
+
ownedProcess = null
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
|
|
99
|
+
ctx.effect(() => () => {
|
|
100
|
+
disposed = true
|
|
101
|
+
const proc = ownedProcess
|
|
102
|
+
ownedProcess = null
|
|
103
|
+
serviceRunning = false
|
|
104
|
+
if (proc) { try { proc.kill() } catch (e) { console.error('[html-workbench] stop failed', e) } }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const recordAsset = (path, kind) => {
|
|
108
|
+
const existing = assets.find((a) => a.path === path)
|
|
109
|
+
if (existing) {
|
|
110
|
+
existing.kind = kind
|
|
111
|
+
existing.at = Date.now()
|
|
112
|
+
existing.seq = ++seq
|
|
113
|
+
} else {
|
|
114
|
+
assets.push({ id: 'w' + (++seq), path: path, kind: kind, at: Date.now(), seq: seq })
|
|
115
|
+
if (assets.length > 500) assets = assets.slice(-500)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
ctx.on('tools/result', (exec, result) => {
|
|
120
|
+
try {
|
|
121
|
+
if (!exec || !result || result.isError === true) return
|
|
122
|
+
if (exec.name !== 'write' && exec.name !== 'edit') return
|
|
123
|
+
const args = exec.arguments || {}
|
|
124
|
+
const path = args.file_path
|
|
125
|
+
if (typeof path !== 'string' || !path) return
|
|
126
|
+
if (!/\.html?$/i.test(path)) return
|
|
127
|
+
recordAsset(path, exec.name === 'write' ? 'create' : 'edit')
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error('[html-workbench] track failed', e)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const snapshot = () => assets.slice().sort((a, b) => b.seq - a.seq)
|
|
134
|
+
|
|
135
|
+
const openFile = async (file) => {
|
|
136
|
+
if (typeof file !== 'string' || !file) return { ok: false, error: 'file is required' }
|
|
137
|
+
if (!/\.html?$/i.test(file)) return { ok: false, error: 'only .html files are supported' }
|
|
138
|
+
if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置' }
|
|
139
|
+
const wasUp = await health()
|
|
140
|
+
if (!wasUp) await startService()
|
|
141
|
+
const h = await health()
|
|
142
|
+
if (!h) return { ok: false, error: startError || 'workbench service unavailable' }
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
|
|
146
|
+
reused: !!wasUp,
|
|
147
|
+
port: PORT,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 动态传输:Package-private RPC(仅动态运行时存在 harness)。
|
|
152
|
+
if (typeof harness !== 'undefined') {
|
|
153
|
+
harness.handle('list', () => ({ ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }))
|
|
154
|
+
harness.handle('status', async () => { const h = await health(); return { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null } })
|
|
155
|
+
harness.handle('open', async (args) => openFile(args && args.file))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
|
|
159
|
+
const webServer = ctx.get('webServer')
|
|
160
|
+
if (webServer) {
|
|
161
|
+
const sendJson = (res, status, body) => {
|
|
162
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
163
|
+
res.end(JSON.stringify(body))
|
|
164
|
+
}
|
|
165
|
+
const parseQuery = (req) => {
|
|
166
|
+
const qs = (req.url || '').split('?')[1] || ''
|
|
167
|
+
const out = {}
|
|
168
|
+
qs.split('&').forEach((pair) => {
|
|
169
|
+
if (!pair) return
|
|
170
|
+
const eq = pair.indexOf('=')
|
|
171
|
+
const k = decodeURIComponent(eq < 0 ? pair : pair.slice(0, eq))
|
|
172
|
+
const v = decodeURIComponent(eq < 0 ? '' : pair.slice(eq + 1))
|
|
173
|
+
out[k] = v
|
|
174
|
+
})
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
ctx.effect(() => webServer.register({
|
|
178
|
+
kind: 'exact',
|
|
179
|
+
path: '/html-workbench/list',
|
|
180
|
+
handler: (req, res) => sendJson(res, 200, { ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }),
|
|
181
|
+
}), 'html-workbench: list route')
|
|
182
|
+
ctx.effect(() => webServer.register({
|
|
183
|
+
kind: 'exact',
|
|
184
|
+
path: '/html-workbench/status',
|
|
185
|
+
handler: async (req, res) => { const h = await health(); sendJson(res, 200, { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null }) },
|
|
186
|
+
}), 'html-workbench: status route')
|
|
187
|
+
ctx.effect(() => webServer.register({
|
|
188
|
+
kind: 'exact',
|
|
189
|
+
path: '/html-workbench/open',
|
|
190
|
+
handler: async (req, res) => {
|
|
191
|
+
const file = parseQuery(req).file
|
|
192
|
+
const out = await openFile(file)
|
|
193
|
+
sendJson(res, out.ok ? 200 : 400, out)
|
|
194
|
+
},
|
|
195
|
+
}), 'html-workbench: open route')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 注册时主动拉起服务(异步,不阻塞 apply)。
|
|
199
|
+
void startService()
|
|
200
|
+
},
|
|
201
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Workbench DSH plugin — static host entry.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates the canonical plugin body in `src/host.js` (the very same text
|
|
5
|
+
* passed to `cordis_define` as `code.host`) and re-exports it for the Cordis
|
|
6
|
+
* loader. The bundled Python service ships next to this package
|
|
7
|
+
* (`scripts/workbench.py` + `assets/workbench.html`), so its absolute path is
|
|
8
|
+
* resolved relative to THIS module (`import.meta.url`) — never hard-coded to a
|
|
9
|
+
* specific machine — and handed to the shared body as `config.script`.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, resolve } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const scriptPath = resolve(here, '..', 'scripts', 'workbench.py')
|
|
17
|
+
|
|
18
|
+
// The body is a `return { ... }` statement; wrap it in a function and call it.
|
|
19
|
+
// eslint-disable-next-line no-new-func
|
|
20
|
+
const makePlugin = new Function(readFileSync(new URL('./host.js', import.meta.url), 'utf8'))
|
|
21
|
+
const plugin = makePlugin()
|
|
22
|
+
|
|
23
|
+
export const name = 'html-workbench'
|
|
24
|
+
export const inject = plugin.inject ?? []
|
|
25
|
+
// Inject the portably-resolved script path; a deployment-provided config may
|
|
26
|
+
// still override `script` / `editorRoot` / `port`.
|
|
27
|
+
export const apply = (ctx, config) => plugin.apply(ctx, { script: scriptPath, ...(config || {}) })
|