@vibe-x/dsh-html-workbench 0.1.2 → 0.3.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/assets/workbench.html +318 -3
- package/dsh.plugin.json +1 -1
- package/lib/client.js +967 -126
- package/package.json +1 -1
- package/scripts/workbench.py +865 -7
- package/src/client.js +937 -122
- package/src/host.js +348 -30
- package/src/index.js +13 -4
package/lib/client.js
CHANGED
|
@@ -6,11 +6,15 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
|
|
7
7
|
// Closure symbols — the same names the dynamic runner injects.
|
|
8
8
|
const React = require('react');
|
|
9
|
+
// Replace-on-insert (never bail when the id already exists): a stale
|
|
10
|
+
// stylesheet left over from a previous plugin generation would otherwise
|
|
11
|
+
// silently win and break the layout while the JS behaves correctly.
|
|
9
12
|
const styles = {
|
|
10
13
|
insert(css) {
|
|
11
14
|
if (typeof document === 'undefined') return () => {};
|
|
12
15
|
const id = 'html-workbench-dsh-plugin-styles';
|
|
13
|
-
|
|
16
|
+
const prev = document.getElementById(id);
|
|
17
|
+
if (prev && prev.parentNode) prev.parentNode.removeChild(prev);
|
|
14
18
|
const el = document.createElement('style');
|
|
15
19
|
el.id = id;
|
|
16
20
|
el.textContent = css;
|
|
@@ -21,9 +25,31 @@ window.__ModuleLoader__.load({
|
|
|
21
25
|
const host = {
|
|
22
26
|
call(method, args) {
|
|
23
27
|
const a = args || {};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const params = Object.keys(a)
|
|
29
|
+
.filter((k) => a[k] !== undefined && a[k] !== null)
|
|
30
|
+
.map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(String(a[k])))
|
|
31
|
+
.join('&');
|
|
32
|
+
const url = '/html-workbench/' + method + (params ? '?' + params : '');
|
|
33
|
+
// A browser client may update before DSH reloads the Node-side host. An
|
|
34
|
+
// old host does not know newer routes (e.g. diagnostics/restart) and
|
|
35
|
+
// falls through to the SPA HTML shell. Never expose that as the useless
|
|
36
|
+
// Do not expose a raw JSON parse error; name the version mismatch and
|
|
37
|
+
// tell the user to restart the DSH host process.
|
|
38
|
+
return fetch(url).then(async (response) => {
|
|
39
|
+
const text = await response.text();
|
|
40
|
+
const contentType = response.headers.get('content-type') || '';
|
|
41
|
+
if (!contentType.includes('application/json')) {
|
|
42
|
+
const staleHost = /<!doctype html|<html[s>]/i.test(text);
|
|
43
|
+
throw new Error(staleHost
|
|
44
|
+
? 'HTML Workbench 的界面已更新,但 DSH 后台仍是旧版本,暂不支持「' + method + '」。请完全重启 DSH 后重试。'
|
|
45
|
+
: '本地服务返回了非 JSON 响应(HTTP ' + response.status + ')。');
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error('本地服务返回了无法解析的 JSON(HTTP ' + response.status + ')。');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
27
53
|
},
|
|
28
54
|
};
|
|
29
55
|
|
|
@@ -38,10 +64,17 @@ window.__ModuleLoader__.load({
|
|
|
38
64
|
*
|
|
39
65
|
* Responsibilities (runs in the browser):
|
|
40
66
|
* - Register a right-side panel + corner trigger in `shell.overlay`.
|
|
41
|
-
* -
|
|
42
|
-
*
|
|
67
|
+
* - Layout is browser-like: a two-row chrome at the TOP (identity row +
|
|
68
|
+
* address/toolbar row) and the preview filling everything below. Nothing
|
|
69
|
+
* sits at the bottom, so the panel never visually competes with the chat
|
|
70
|
+
* composer on the left.
|
|
43
71
|
* - Open a file by calling Host `open`, then embed the workbench URL in an
|
|
44
|
-
* iframe. Left edge is draggable to resize the panel width.
|
|
72
|
+
* iframe. Left edge is draggable to resize the panel width (persisted).
|
|
73
|
+
*
|
|
74
|
+
* Styling contract: every colour/shadow comes from the host's `--dsw-alias-*`
|
|
75
|
+
* design tokens (see the DSH first-party plugins) so light/dark themes and
|
|
76
|
+
* future re-skins are inherited for free. No hard-coded palette, no magic
|
|
77
|
+
* offsets — all overlays are laid out with flex/grid.
|
|
45
78
|
*/
|
|
46
79
|
|
|
47
80
|
return {
|
|
@@ -50,66 +83,270 @@ return {
|
|
|
50
83
|
const slots = ctx.get('slots')
|
|
51
84
|
if (slots === undefined) return
|
|
52
85
|
|
|
53
|
-
|
|
54
|
-
|
|
86
|
+
// ── Styles ───────────────────────────────────────────────────────────────
|
|
87
|
+
// NOTE: both style shims (dynamic runner + static bundle) de-duplicate by a
|
|
88
|
+
// fixed element id and BAIL OUT when it already exists. During iterative
|
|
89
|
+
// development that silently keeps a STALE stylesheet alive — the symptom is
|
|
90
|
+
// "functionality works but the layout is broken" (e.g. an absolutely
|
|
91
|
+
// positioned chevron collapsing into a block below the input). So manage the
|
|
92
|
+
// <style> element here: drop every previous generation, then insert fresh.
|
|
93
|
+
const STYLE_MARK = 'data-hwb-styles'
|
|
94
|
+
const STYLE_VERSION = '5'
|
|
95
|
+
|
|
96
|
+
const CSS = `
|
|
97
|
+
html #root {
|
|
98
|
+
margin-right: calc(var(--dsh-sidebar-width, 0px) + var(--hwb-panel-width, 0px));
|
|
99
|
+
transition: margin-right var(--ds-transition-duration-slow, 200ms) var(--ds-ease-in-out, ease);
|
|
100
|
+
}
|
|
55
101
|
body[data-hwb-dragging] #root { transition: none; }
|
|
56
|
-
body[data-hwb-dragging] { user-select: none; }
|
|
102
|
+
body[data-hwb-dragging] { user-select: none; cursor: col-resize; }
|
|
103
|
+
|
|
104
|
+
/* Reserve right-side clearance in the session header so the corner trigger
|
|
105
|
+
never overlaps its right-aligned utilities (e.g. "Session log"). The
|
|
106
|
+
clearance relaxes as the panel opens, because the trigger then hides. */
|
|
107
|
+
header:has([data-slot="conversation.session.header.utilities"]) {
|
|
108
|
+
padding-right: max(28px, calc(60px - var(--hwb-panel-width, 0px)));
|
|
109
|
+
transition: padding-right var(--ds-transition-duration-slow, 200ms) var(--ds-ease-in-out, ease);
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
.hwb-panel, .hwb-panel * { box-sizing: border-box; }
|
|
58
|
-
.hwb-panel {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.hwb-
|
|
73
|
-
.hwb-
|
|
74
|
-
.hwb-
|
|
75
|
-
.hwb-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.hwb-
|
|
79
|
-
.hwb-
|
|
80
|
-
.hwb-
|
|
81
|
-
.hwb-
|
|
82
|
-
.hwb-
|
|
83
|
-
.hwb-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
.hwb-
|
|
87
|
-
.hwb-
|
|
88
|
-
.hwb-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.hwb-
|
|
95
|
-
.hwb-
|
|
96
|
-
|
|
113
|
+
.hwb-panel {
|
|
114
|
+
position: fixed; top: 0; right: var(--dsh-sidebar-width, 0px); bottom: 0;
|
|
115
|
+
display: flex; flex-direction: column; min-width: 0;
|
|
116
|
+
background: var(--dsw-alias-bg-base);
|
|
117
|
+
color: var(--dsw-alias-label-primary);
|
|
118
|
+
border-left: 1px solid var(--dsw-alias-border-l1);
|
|
119
|
+
box-shadow: var(--dsw-shadow-lv2);
|
|
120
|
+
z-index: 9999; pointer-events: auto;
|
|
121
|
+
font-family: var(--dsw-font-family, ui-sans-serif, system-ui, sans-serif);
|
|
122
|
+
font-size: 13px; line-height: 1.5;
|
|
123
|
+
--hwb-mono: var(--dsh-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Resize handle — a wide invisible hit area with a thin visible rail. */
|
|
127
|
+
.hwb-resize { position: absolute; left: -4px; top: 0; bottom: 0; width: 9px; z-index: 5; cursor: col-resize; touch-action: none; border: none; padding: 0; background: transparent; }
|
|
128
|
+
.hwb-resize::after { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: transparent; transition: background 150ms ease; }
|
|
129
|
+
.hwb-resize:hover::after, .hwb-resize:focus-visible::after, .hwb-resize[data-active]::after { background: var(--dsw-alias-interactive-bg-hover-accent); }
|
|
130
|
+
.hwb-resize:focus-visible { outline: none; }
|
|
131
|
+
|
|
132
|
+
/* ── Chrome: identity row + toolbar row ───────────────────────────────────── */
|
|
133
|
+
.hwb-chrome { flex: none; display: flex; flex-direction: column; background: var(--dsw-alias-bg-layer-1); border-bottom: 1px solid var(--dsw-alias-border-l2); }
|
|
134
|
+
.hwb-idrow { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 8px 0 14px; }
|
|
135
|
+
.hwb-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
136
|
+
.hwb-dot { position: relative; width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-tertiary)); box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 0%, transparent); }
|
|
137
|
+
.hwb-dot[data-on] { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent); }
|
|
138
|
+
.hwb-dot[data-off] { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent); }
|
|
139
|
+
/* The dot is the ONLY signal that a start failed, so make it the way in: it is a
|
|
140
|
+
button that opens the diagnostics log. */
|
|
141
|
+
.hwb-statusbtn { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 6px 2px 4px; margin-left: -4px; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
|
142
|
+
.hwb-statusbtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
143
|
+
.hwb-statusbtn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
|
|
144
|
+
|
|
145
|
+
/* ── Diagnostics ──────────────────────────────────────────────────────────── */
|
|
146
|
+
.hwb-diag { flex: none; display: flex; flex-direction: column; max-height: 46vh; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); }
|
|
147
|
+
.hwb-diag-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); }
|
|
148
|
+
.hwb-diag-title { font-size: 12px; font-weight: 600; }
|
|
149
|
+
.hwb-diag-facts { flex: none; display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; padding: 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); font-size: 11.5px; }
|
|
150
|
+
.hwb-diag-key { color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
|
|
151
|
+
.hwb-diag-val { min-width: 0; font-family: var(--hwb-mono); word-break: break-all; }
|
|
152
|
+
.hwb-diag-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 0; }
|
|
153
|
+
.hwb-diag-row { display: grid; grid-template-columns: auto 1fr; gap: 8px; padding: 4px 14px; font-size: 11.5px; line-height: 1.55; }
|
|
154
|
+
.hwb-diag-row + .hwb-diag-row { border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 50%, transparent); }
|
|
155
|
+
.hwb-diag-time { color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10.5px; padding-top: 1px; }
|
|
156
|
+
.hwb-diag-msg { min-width: 0; }
|
|
157
|
+
.hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
|
|
158
|
+
.hwb-diag-detail { display: block; margin-top: 3px; padding: 6px 8px; border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-secondary); font-family: var(--hwb-mono); font-size: 10.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow-y: auto; }
|
|
159
|
+
.hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
|
|
160
|
+
.hwb-diag-hint { padding: 8px 14px; border-top: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 1.6; }
|
|
161
|
+
.hwb-diag-hint code { padding: 1px 4px; border-radius: 4px; background: var(--dsw-alias-bg-base); font-family: var(--hwb-mono); font-size: 10.5px; }
|
|
162
|
+
.hwb-btn-quiet { background: transparent; border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
|
|
163
|
+
.hwb-btn-quiet:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
164
|
+
.hwb-btn-sm { height: 26px; padding: 0 10px; border-radius: 7px; font-size: 11.5px; }
|
|
165
|
+
.hwb-name { font-weight: 600; font-size: 13px; white-space: nowrap; }
|
|
166
|
+
.hwb-sep { width: 1px; height: 14px; flex: none; background: var(--dsw-alias-border-l1); }
|
|
167
|
+
.hwb-filechip { display: flex; align-items: center; gap: 6px; min-width: 0; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-interactive-bg-hover)); color: var(--dsw-alias-label-secondary); font-size: 11.5px; }
|
|
168
|
+
.hwb-filechip > svg { flex: none; opacity: .7; }
|
|
169
|
+
.hwb-filechip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
|
|
170
|
+
.hwb-spacer { flex: 1 1 auto; min-width: 8px; }
|
|
171
|
+
.hwb-actions { display: flex; align-items: center; gap: 2px; flex: none; }
|
|
172
|
+
|
|
173
|
+
.hwb-icon { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; flex: none; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; transition: background 120ms ease, color 120ms ease; }
|
|
174
|
+
.hwb-icon:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
175
|
+
.hwb-icon:disabled { opacity: .4; cursor: default; }
|
|
176
|
+
.hwb-icon:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
|
|
177
|
+
.hwb-icon[data-spin] > svg { animation: hwb-spin 800ms linear infinite; }
|
|
178
|
+
|
|
179
|
+
.hwb-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 10px 10px; }
|
|
180
|
+
|
|
181
|
+
/* Combobox field: input + status + chevron composed with flex — no absolute
|
|
182
|
+
positioning, so nothing can escape the field box. */
|
|
183
|
+
.hwb-fieldwrap { position: relative; flex: 1 1 auto; min-width: 0; }
|
|
184
|
+
.hwb-field { display: flex; align-items: center; gap: 2px; height: 32px; padding: 0 2px 0 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; background: var(--dsw-alias-bg-base); transition: border-color 150ms ease, box-shadow 150ms ease; }
|
|
185
|
+
.hwb-field:hover { border-color: var(--dsw-alias-border-l1); }
|
|
186
|
+
.hwb-field[data-focus] { border-color: var(--dsw-alias-state-business-primary, #4d6bfe); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 16%, transparent); }
|
|
187
|
+
.hwb-input { flex: 1 1 auto; min-width: 0; height: 100%; border: none; outline: none; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; font-family: var(--hwb-mono); }
|
|
188
|
+
.hwb-input::placeholder { color: var(--dsw-alias-label-tertiary); font-family: var(--dsw-font-family, ui-sans-serif, system-ui, sans-serif); }
|
|
189
|
+
.hwb-state { display: grid; place-items: center; width: 20px; height: 20px; flex: none; }
|
|
190
|
+
.hwb-state[data-state="exists"] { color: var(--dsw-alias-state-success-primary); }
|
|
191
|
+
.hwb-state[data-state="missing"] { color: var(--dsw-alias-state-error-primary); }
|
|
192
|
+
.hwb-state[data-state="invalid"] { color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-warn-label)); }
|
|
193
|
+
.hwb-state[data-state="checking"] { color: var(--dsw-alias-label-tertiary); }
|
|
194
|
+
.hwb-state[data-state="checking"] > svg { animation: hwb-spin 800ms linear infinite; }
|
|
195
|
+
.hwb-caret { display: grid; place-items: center; width: 26px; height: 26px; flex: none; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; transition: background 120ms ease, color 120ms ease; }
|
|
196
|
+
.hwb-caret:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
197
|
+
.hwb-caret > svg { transition: transform 180ms ease; }
|
|
198
|
+
.hwb-caret[aria-expanded="true"] > svg { transform: rotate(180deg); }
|
|
199
|
+
|
|
200
|
+
.hwb-btn { flex: none; height: 32px; padding: 0 14px; border-radius: 9px; border: 1px solid transparent; font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; transition: background 120ms ease, opacity 120ms ease; }
|
|
201
|
+
.hwb-btn-primary { background: var(--dsw-alias-button-primary-fill, #4d6bfe); color: var(--dsw-alias-button-primary-label, #fff); }
|
|
202
|
+
.hwb-btn-primary:hover:not(:disabled) { opacity: .88; }
|
|
203
|
+
.hwb-btn:disabled { opacity: .45; cursor: default; }
|
|
204
|
+
.hwb-btn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 2px; }
|
|
205
|
+
|
|
206
|
+
/* Dropdown opens DOWNWARD (the field lives at the top of the panel). */
|
|
207
|
+
.hwb-menu { position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 30; display: flex; flex-direction: column; max-height: min(340px, 50vh); overflow: hidden; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); box-shadow: var(--dsw-shadow-lv2); animation: hwb-pop 140ms var(--ds-ease-in-out, ease); }
|
|
208
|
+
.hwb-menu-head { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
209
|
+
.hwb-menu-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px; }
|
|
210
|
+
.hwb-menu-empty { padding: 22px 16px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; line-height: 1.7; }
|
|
211
|
+
|
|
212
|
+
.hwb-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 8px; border: none; border-radius: 8px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
213
|
+
.hwb-item:hover, .hwb-item:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
|
|
214
|
+
.hwb-item[data-active] { background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover)); }
|
|
215
|
+
.hwb-item > svg { flex: none; color: var(--dsw-alias-label-tertiary); }
|
|
216
|
+
.hwb-item-main { flex: 1 1 auto; min-width: 0; display: block; }
|
|
217
|
+
.hwb-item-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
218
|
+
.hwb-item-path { display: block; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 11px; font-family: var(--hwb-mono); }
|
|
219
|
+
.hwb-tag { flex: none; padding: 1px 6px; border-radius: 5px; font-size: 10px; font-weight: 600; }
|
|
220
|
+
.hwb-tag[data-kind="create"] { background: var(--dsw-alias-state-success-tertiary, color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)); color: var(--dsw-alias-state-success-primary); }
|
|
221
|
+
.hwb-tag[data-kind="edit"] { background: var(--dsw-alias-state-warn-tertiary, color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b7791f) 14%, transparent)); color: var(--dsw-alias-state-warn-label, var(--dsw-alias-state-warning-primary, #b7791f)); }
|
|
222
|
+
|
|
223
|
+
/* ── Banner ───────────────────────────────────────────────────────────────── */
|
|
224
|
+
.hwb-banner { flex: none; display: flex; align-items: flex-start; gap: 8px; padding: 9px 10px 9px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); color: var(--dsw-alias-state-error-primary); font-size: 12px; }
|
|
225
|
+
.hwb-banner > svg { flex: none; margin-top: 1px; }
|
|
226
|
+
.hwb-banner-text { flex: 1 1 auto; min-width: 0; word-break: break-word; }
|
|
227
|
+
|
|
228
|
+
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
|
229
|
+
.hwb-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
|
|
230
|
+
.hwb-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
|
|
97
231
|
|
|
232
|
+
/* ── Visual selection chips (rendered INTO the host composer) ─────────────── */
|
|
233
|
+
/* The workbench posts one selection at a time; each becomes a removable chip
|
|
234
|
+
above the chat input, mirroring how file attachments behave. The markdown
|
|
235
|
+
evidence is held in memory and only spliced into the message on send, so the
|
|
236
|
+
user never has to look at a wall of HTML while typing. */
|
|
237
|
+
.hwb-chips {
|
|
238
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
239
|
+
margin: 0 14px 8px; padding: 0;
|
|
240
|
+
}
|
|
241
|
+
.hwb-chip {
|
|
242
|
+
display: inline-flex; align-items: center; gap: 6px; max-width: 260px;
|
|
243
|
+
padding: 3px 4px 3px 8px; border-radius: 8px;
|
|
244
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
245
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
246
|
+
color: var(--dsw-alias-label-primary);
|
|
247
|
+
font-size: 11.5px; line-height: 18px;
|
|
248
|
+
}
|
|
249
|
+
.hwb-chip > svg { flex: none; color: var(--dsw-alias-state-business-primary, #4d6bfe); }
|
|
250
|
+
.hwb-chip-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
|
|
251
|
+
.hwb-chip-x {
|
|
252
|
+
flex: none; display: grid; place-items: center; width: 18px; height: 18px; padding: 0;
|
|
253
|
+
border: 0; border-radius: 5px; background: transparent;
|
|
254
|
+
color: var(--dsw-alias-label-tertiary); cursor: pointer;
|
|
255
|
+
}
|
|
256
|
+
.hwb-chip-x:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
257
|
+
.hwb-chip-note { align-self: center; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
258
|
+
|
|
259
|
+
.hwb-center { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 24px; overflow-y: auto; text-align: center; }
|
|
260
|
+
.hwb-blank { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-tertiary); }
|
|
261
|
+
.hwb-blank-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
262
|
+
.hwb-blank-desc { max-width: 320px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 1.7; }
|
|
263
|
+
.hwb-recent { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 4px; margin-top: 2px; text-align: left; }
|
|
264
|
+
.hwb-recent-label { padding: 0 8px 2px; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
265
|
+
.hwb-spinner { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l1); border-top-color: var(--dsw-alias-state-business-primary, #4d6bfe); animation: hwb-spin 700ms linear infinite; }
|
|
266
|
+
|
|
267
|
+
/* ── Corner trigger ───────────────────────────────────────────────────────── */
|
|
268
|
+
.hwb-trigger {
|
|
269
|
+
position: fixed; top: 8px;
|
|
270
|
+
right: calc(var(--dsh-sidebar-width, 0px) + var(--hwb-panel-width, 0px) + 12px);
|
|
271
|
+
z-index: 10000; display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
|
|
272
|
+
border: none; border-radius: 8px; background: transparent;
|
|
273
|
+
color: var(--dsw-alias-label-secondary); cursor: pointer; pointer-events: auto;
|
|
274
|
+
transition: right var(--ds-transition-duration-slow, 200ms) var(--ds-ease-in-out, ease), background 120ms ease, color 120ms ease;
|
|
275
|
+
}
|
|
276
|
+
.hwb-trigger:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
277
|
+
.hwb-trigger:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
|
|
278
|
+
|
|
279
|
+
@keyframes hwb-spin { to { transform: rotate(360deg); } }
|
|
280
|
+
@keyframes hwb-pop { from { opacity: 0; transform: translateY(-4px); } }
|
|
281
|
+
@media (prefers-reduced-motion: reduce) {
|
|
282
|
+
html #root, .hwb-trigger, header:has([data-slot="conversation.session.header.utilities"]) { transition: none; }
|
|
283
|
+
.hwb-menu { animation: none; }
|
|
284
|
+
}
|
|
285
|
+
`
|
|
286
|
+
|
|
287
|
+
const applyStyles = () => {
|
|
288
|
+
if (typeof document === 'undefined') { try { styles.insert(CSS) } catch (e) {} return () => {} }
|
|
289
|
+
const stale = document.querySelectorAll('style[' + STYLE_MARK + '], style#html-workbench-dsh-plugin-styles')
|
|
290
|
+
for (let i = 0; i < stale.length; i += 1) {
|
|
291
|
+
const node = stale[i]
|
|
292
|
+
if (node.parentNode) node.parentNode.removeChild(node)
|
|
293
|
+
}
|
|
294
|
+
const el = document.createElement('style')
|
|
295
|
+
el.setAttribute(STYLE_MARK, STYLE_VERSION)
|
|
296
|
+
el.textContent = CSS
|
|
297
|
+
document.head.appendChild(el)
|
|
298
|
+
return () => { if (el.parentNode) el.parentNode.removeChild(el) }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (typeof ctx.effect === 'function') ctx.effect(applyStyles, 'html-workbench: styles')
|
|
302
|
+
else applyStyles()
|
|
303
|
+
|
|
304
|
+
// ── Store ────────────────────────────────────────────────────────────────
|
|
98
305
|
const DEFAULT_WIDTH = 820
|
|
99
|
-
const MIN_WIDTH =
|
|
306
|
+
const MIN_WIDTH = 460
|
|
307
|
+
const WIDTH_KEY = 'hwb:panel-width'
|
|
308
|
+
|
|
309
|
+
const readWidth = () => {
|
|
310
|
+
try {
|
|
311
|
+
const raw = window.localStorage.getItem(WIDTH_KEY)
|
|
312
|
+
const n = raw == null ? NaN : parseInt(raw, 10)
|
|
313
|
+
return Number.isFinite(n) && n >= MIN_WIDTH ? n : DEFAULT_WIDTH
|
|
314
|
+
} catch (e) { return DEFAULT_WIDTH }
|
|
315
|
+
}
|
|
316
|
+
const writeWidth = (w) => { try { window.localStorage.setItem(WIDTH_KEY, String(w)) } catch (e) {} }
|
|
317
|
+
|
|
318
|
+
let resolveTimer = null
|
|
100
319
|
|
|
101
320
|
const store = {
|
|
102
321
|
open: false,
|
|
103
|
-
panelWidth:
|
|
104
|
-
expanded: false,
|
|
322
|
+
panelWidth: readWidth(),
|
|
105
323
|
assets: [],
|
|
106
324
|
running: false,
|
|
107
325
|
current: null,
|
|
326
|
+
nonce: 0,
|
|
108
327
|
loading: false,
|
|
328
|
+
refreshing: false,
|
|
109
329
|
error: null,
|
|
330
|
+
pathInput: '',
|
|
331
|
+
resolveState: 'idle',
|
|
332
|
+
context: null,
|
|
333
|
+
diag: null,
|
|
334
|
+
diagOpen: false,
|
|
335
|
+
restarting: false,
|
|
110
336
|
listeners: [],
|
|
111
337
|
subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
|
|
112
|
-
set(
|
|
338
|
+
set(patch) {
|
|
339
|
+
// Only notify when something actually changed: the panel polls `list`
|
|
340
|
+
// every few seconds, and blindly re-rendering on every tick makes the
|
|
341
|
+
// input feel laggy and needlessly churns the preview subtree.
|
|
342
|
+
let dirty = false
|
|
343
|
+
const keys = Object.keys(patch)
|
|
344
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
345
|
+
const k = keys[i]
|
|
346
|
+
if (this[k] !== patch[k]) { this[k] = patch[k]; dirty = true }
|
|
347
|
+
}
|
|
348
|
+
if (dirty) this.listeners.forEach((fn) => { try { fn() } catch (e) {} })
|
|
349
|
+
},
|
|
113
350
|
}
|
|
114
351
|
|
|
115
352
|
const useStore = () => {
|
|
@@ -119,117 +356,724 @@ body[data-hwb-dragging] { user-select: none; }
|
|
|
119
356
|
}
|
|
120
357
|
|
|
121
358
|
const basename = (p) => {
|
|
122
|
-
const parts = String(p).split('/')
|
|
123
|
-
return parts[parts.length - 1] || p
|
|
359
|
+
const parts = String(p || '').split('/')
|
|
360
|
+
return parts[parts.length - 1] || String(p || '')
|
|
124
361
|
}
|
|
125
362
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
363
|
+
const dirname = (p) => {
|
|
364
|
+
const s = String(p || '')
|
|
365
|
+
const i = s.lastIndexOf('/')
|
|
366
|
+
return i <= 0 ? '/' : s.slice(0, i)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Paths are long and their INFORMATIVE half is the tail, so clip the head
|
|
370
|
+
// (CSS ellipsis would eat the tail). The CSS ellipsis stays as a backstop
|
|
371
|
+
// for narrow panels.
|
|
372
|
+
const clipHead = (s, max) => {
|
|
373
|
+
const str = String(s || '')
|
|
374
|
+
return str.length <= max ? str : '…' + str.slice(str.length - max + 1)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const sameAssets = (a, b) => {
|
|
378
|
+
if (a === b) return true
|
|
379
|
+
if (!a || !b || a.length !== b.length) return false
|
|
380
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
381
|
+
if (a[i].path !== b[i].path || a[i].kind !== b[i].kind || a[i].seq !== b[i].seq) return false
|
|
382
|
+
}
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The panel polls `list` every few seconds and the payload now carries the
|
|
387
|
+
// whole diagnostics journal — a fresh object every tick. Compare by the
|
|
388
|
+
// facts that actually change so polling does not re-render continuously.
|
|
389
|
+
const sameDiag = (a, b) => {
|
|
390
|
+
if (a === b) return true
|
|
391
|
+
if (!a || !b) return false
|
|
392
|
+
const ja = a.journal || []
|
|
393
|
+
const jb = b.journal || []
|
|
394
|
+
if (ja.length !== jb.length) return false
|
|
395
|
+
if (ja.length && (ja[0].at !== jb[0].at || ja[0].message !== jb[0].message)) return false
|
|
396
|
+
return a.running === b.running && a.startError === b.startError
|
|
397
|
+
&& a.processStatus === b.processStatus && a.exitCode === b.exitCode
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const refresh = (explicit) => {
|
|
401
|
+
if (explicit) store.set({ refreshing: true })
|
|
402
|
+
return host.call('list').then((res) => {
|
|
403
|
+
if (res && res.ok) {
|
|
404
|
+
const next = res.assets || []
|
|
405
|
+
store.set({
|
|
406
|
+
assets: sameAssets(store.assets, next) ? store.assets : next,
|
|
407
|
+
running: !!res.running,
|
|
408
|
+
diag: sameDiag(store.diag, res) ? store.diag : res,
|
|
409
|
+
})
|
|
410
|
+
} else {
|
|
411
|
+
store.set({ error: (res && res.error) || 'list failed' })
|
|
412
|
+
}
|
|
130
413
|
}).catch((e) => store.set({ error: String(e && e.message ? e.message : e) }))
|
|
414
|
+
.then(() => { if (explicit) store.set({ refreshing: false }) })
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Killing and re-spawning is the one action that fixes most start failures,
|
|
418
|
+
// so it belongs next to the log that reports them.
|
|
419
|
+
const restartService = () => {
|
|
420
|
+
store.set({ restarting: true, error: null })
|
|
421
|
+
return host.call('restart').then((res) => {
|
|
422
|
+
store.set({
|
|
423
|
+
restarting: false,
|
|
424
|
+
running: !!(res && res.ok),
|
|
425
|
+
diag: (res && res.diagnostics) || store.diag,
|
|
426
|
+
error: res && res.ok ? null : (res && res.error) || '重启失败',
|
|
427
|
+
})
|
|
428
|
+
if (res && res.ok) refresh()
|
|
429
|
+
}).catch((e) => store.set({ restarting: false, error: String(e && e.message ? e.message : e) }))
|
|
131
430
|
}
|
|
132
431
|
|
|
133
432
|
const openFile = (path) => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
433
|
+
const file = String(path || '').trim()
|
|
434
|
+
if (!file) return
|
|
435
|
+
store.set({ loading: true, error: null, pathInput: file })
|
|
436
|
+
host.call('open', { file: file }).then((res) => {
|
|
437
|
+
if (res && res.ok) {
|
|
438
|
+
// Bump the nonce so re-opening the SAME file remounts the iframe:
|
|
439
|
+
// with an unchanged `src` the browser would otherwise keep the old
|
|
440
|
+
// document and "打开" would look like a no-op.
|
|
441
|
+
store.set({
|
|
442
|
+
loading: false,
|
|
443
|
+
running: true,
|
|
444
|
+
resolveState: 'exists',
|
|
445
|
+
current: { path: file, url: res.url },
|
|
446
|
+
nonce: store.nonce + 1,
|
|
447
|
+
})
|
|
448
|
+
} else {
|
|
449
|
+
// A failed open used to collapse into one opaque line. The host now
|
|
450
|
+
// sends the journal along, so open the log on failure: the cause is
|
|
451
|
+
// one glance away instead of a terminal session away.
|
|
452
|
+
store.set({
|
|
453
|
+
loading: false,
|
|
454
|
+
error: (res && res.error) || 'open failed',
|
|
455
|
+
diag: (res && res.diagnostics) || store.diag,
|
|
456
|
+
diagOpen: !!(res && res.diagnostics),
|
|
457
|
+
})
|
|
458
|
+
}
|
|
138
459
|
}).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
|
|
139
460
|
}
|
|
140
461
|
|
|
462
|
+
const STATE_TITLE = {
|
|
463
|
+
exists: '文件存在',
|
|
464
|
+
missing: '未找到该文件',
|
|
465
|
+
invalid: '仅支持 .html / .htm 文件',
|
|
466
|
+
checking: '检测中…',
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Visual selection → composer chips ────────────────────────────────────
|
|
470
|
+
//
|
|
471
|
+
// The workbench posts one selection per click. Each becomes a removable chip
|
|
472
|
+
// above the chat input, and the bulky markdown evidence stays in memory
|
|
473
|
+
// until the user actually sends — so they compose against a short list of
|
|
474
|
+
// labels instead of a wall of HTML.
|
|
475
|
+
//
|
|
476
|
+
// Two deliberate choices:
|
|
477
|
+
// 1. Chips are plain DOM appended into the host's composer card, not a
|
|
478
|
+
// React portal. The card is a flex column owned by the host's React
|
|
479
|
+
// tree; React leaves DOM nodes it never created alone, but it may
|
|
480
|
+
// `insertBefore` around them, so a MutationObserver re-asserts position.
|
|
481
|
+
// 2. The evidence is spliced in during the CAPTURE phase of send. React 18
|
|
482
|
+
// dispatches discrete events synchronously, so writing the value through
|
|
483
|
+
// the native setter + `input` event during capture means React state is
|
|
484
|
+
// already updated by the time the host's own handler reads it.
|
|
485
|
+
|
|
486
|
+
const CHIP_HOST = '.hwb-chips'
|
|
487
|
+
const pendingSelections = []
|
|
488
|
+
let chipRow = null
|
|
489
|
+
let composerObserver = null
|
|
490
|
+
|
|
491
|
+
const findComposer = () => {
|
|
492
|
+
const nodes = document.querySelectorAll('textarea')
|
|
493
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
494
|
+
const node = nodes[i]
|
|
495
|
+
// The panel's own inputs are inside `.hwb-panel`; never target those.
|
|
496
|
+
if (node.closest('.hwb-panel')) continue
|
|
497
|
+
const box = node.getBoundingClientRect()
|
|
498
|
+
if (box.width >= 120 && box.height > 0) return node
|
|
499
|
+
}
|
|
500
|
+
return null
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// The card is the flex column holding the textarea and the tool row. Walking
|
|
504
|
+
// up from the textarea keeps this working if the host renames its classes.
|
|
505
|
+
const findComposerCard = (textarea) => {
|
|
506
|
+
let node = textarea && textarea.parentElement
|
|
507
|
+
let depth = 0
|
|
508
|
+
while (node && depth < 6) {
|
|
509
|
+
const style = window.getComputedStyle(node)
|
|
510
|
+
if (style.display === 'flex' && style.flexDirection === 'column' && node.querySelector('button')) return node
|
|
511
|
+
node = node.parentElement
|
|
512
|
+
depth += 1
|
|
513
|
+
}
|
|
514
|
+
return textarea ? textarea.parentElement : null
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const setComposerValue = (node, next) => {
|
|
518
|
+
const descriptor = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
|
|
519
|
+
const setter = descriptor && descriptor.set
|
|
520
|
+
if (setter) setter.call(node, next)
|
|
521
|
+
else node.value = next
|
|
522
|
+
node.dispatchEvent(new Event('input', { bubbles: true }))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const evidenceBlock = () => {
|
|
526
|
+
if (!pendingSelections.length) return ''
|
|
527
|
+
return pendingSelections.map((item) => item.markdown).join('\n')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const clearSelections = () => {
|
|
531
|
+
pendingSelections.length = 0
|
|
532
|
+
renderChips()
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const removeSelection = (key) => {
|
|
536
|
+
const index = pendingSelections.findIndex((item) => item.key === key)
|
|
537
|
+
if (index >= 0) pendingSelections.splice(index, 1)
|
|
538
|
+
renderChips()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const chipIcon = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5v-3A1.5 1.5 0 0 1 4.5 3h3M16.5 3h3A1.5 1.5 0 0 1 21 4.5v3M21 16.5v3a1.5 1.5 0 0 1-1.5 1.5h-3M7.5 21h-3A1.5 1.5 0 0 1 3 19.5v-3"/></svg>'
|
|
542
|
+
|
|
543
|
+
const renderChips = () => {
|
|
544
|
+
const textarea = findComposer()
|
|
545
|
+
const card = findComposerCard(textarea)
|
|
546
|
+
if (!card) return
|
|
547
|
+
if (!pendingSelections.length) {
|
|
548
|
+
if (chipRow && chipRow.parentNode) chipRow.parentNode.removeChild(chipRow)
|
|
549
|
+
chipRow = null
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
if (!chipRow) {
|
|
553
|
+
chipRow = document.createElement('div')
|
|
554
|
+
chipRow.className = CHIP_HOST.slice(1)
|
|
555
|
+
}
|
|
556
|
+
chipRow.textContent = ''
|
|
557
|
+
pendingSelections.forEach((item) => {
|
|
558
|
+
const chip = document.createElement('span')
|
|
559
|
+
chip.className = 'hwb-chip'
|
|
560
|
+
chip.title = item.textHint ? item.label + ' — ' + item.textHint : item.label
|
|
561
|
+
chip.innerHTML = chipIcon
|
|
562
|
+
const label = document.createElement('span')
|
|
563
|
+
label.className = 'hwb-chip-label'
|
|
564
|
+
label.textContent = item.label
|
|
565
|
+
const close = document.createElement('button')
|
|
566
|
+
close.type = 'button'
|
|
567
|
+
close.className = 'hwb-chip-x'
|
|
568
|
+
close.setAttribute('aria-label', '移除 ' + item.label)
|
|
569
|
+
close.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
|
|
570
|
+
close.addEventListener('click', (event) => {
|
|
571
|
+
event.preventDefault()
|
|
572
|
+
event.stopPropagation()
|
|
573
|
+
removeSelection(item.key)
|
|
574
|
+
})
|
|
575
|
+
chip.appendChild(label)
|
|
576
|
+
chip.appendChild(close)
|
|
577
|
+
chipRow.appendChild(chip)
|
|
578
|
+
})
|
|
579
|
+
const note = document.createElement('span')
|
|
580
|
+
note.className = 'hwb-chip-note'
|
|
581
|
+
note.textContent = '发送时会自动附上选区源码'
|
|
582
|
+
chipRow.appendChild(note)
|
|
583
|
+
|
|
584
|
+
// Sit directly above the tool row (the last flex child), which puts the
|
|
585
|
+
// chips between the text input and the model/send controls.
|
|
586
|
+
const anchor = card.lastElementChild
|
|
587
|
+
if (chipRow.parentNode !== card || chipRow.nextElementSibling !== anchor) {
|
|
588
|
+
card.insertBefore(chipRow, anchor)
|
|
589
|
+
}
|
|
590
|
+
observeComposer(card)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const observeComposer = (card) => {
|
|
594
|
+
if (composerObserver) return
|
|
595
|
+
composerObserver = new MutationObserver(() => {
|
|
596
|
+
if (!pendingSelections.length) return
|
|
597
|
+
if (!chipRow || chipRow.parentNode !== card) renderChips()
|
|
598
|
+
})
|
|
599
|
+
composerObserver.observe(card, { childList: true })
|
|
600
|
+
ctx.effect(() => () => {
|
|
601
|
+
if (composerObserver) composerObserver.disconnect()
|
|
602
|
+
composerObserver = null
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const receiveContext = (packet) => {
|
|
607
|
+
const key = packet.key || packet.label
|
|
608
|
+
const existing = pendingSelections.findIndex((item) => item.key === key)
|
|
609
|
+
const entry = {
|
|
610
|
+
key: key,
|
|
611
|
+
label: packet.label || 'element',
|
|
612
|
+
textHint: packet.textHint || '',
|
|
613
|
+
markdown: packet.markdown,
|
|
614
|
+
fileName: packet.fileName,
|
|
615
|
+
}
|
|
616
|
+
// Re-adding the same element refreshes its evidence rather than stacking a
|
|
617
|
+
// duplicate: the file may have changed since the first click.
|
|
618
|
+
if (existing >= 0) pendingSelections[existing] = entry
|
|
619
|
+
else pendingSelections.push(entry)
|
|
620
|
+
renderChips()
|
|
621
|
+
const textarea = findComposer()
|
|
622
|
+
if (textarea) textarea.focus()
|
|
623
|
+
store.set({ context: { count: pendingSelections.length, label: entry.label, at: Date.now() } })
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Splice the evidence in just before the host reads the composer, then drop
|
|
627
|
+
// the chips: the message now carries the context, so keeping them would
|
|
628
|
+
// silently re-attach the same source to the next turn.
|
|
629
|
+
const spliceEvidenceIntoMessage = () => {
|
|
630
|
+
if (!pendingSelections.length) return
|
|
631
|
+
const textarea = findComposer()
|
|
632
|
+
if (!textarea) return
|
|
633
|
+
const typed = (textarea.value || '').trim()
|
|
634
|
+
if (!typed) return
|
|
635
|
+
setComposerValue(textarea, evidenceBlock() + '\n' + typed)
|
|
636
|
+
clearSelections()
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const bindSendInterception = () => {
|
|
640
|
+
const onClick = (event) => {
|
|
641
|
+
if (!pendingSelections.length) return
|
|
642
|
+
const target = event.target instanceof Element ? event.target : null
|
|
643
|
+
const button = target && target.closest('button')
|
|
644
|
+
if (!button || button.closest('.hwb-panel')) return
|
|
645
|
+
const label = button.getAttribute('aria-label') || ''
|
|
646
|
+
if (!/发送|send/i.test(label)) return
|
|
647
|
+
spliceEvidenceIntoMessage()
|
|
648
|
+
}
|
|
649
|
+
const onKeyDown = (event) => {
|
|
650
|
+
if (!pendingSelections.length) return
|
|
651
|
+
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
|
|
652
|
+
const target = event.target
|
|
653
|
+
if (!(target instanceof HTMLTextAreaElement) || target.closest('.hwb-panel')) return
|
|
654
|
+
spliceEvidenceIntoMessage()
|
|
655
|
+
}
|
|
656
|
+
document.addEventListener('click', onClick, true)
|
|
657
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
658
|
+
ctx.effect(() => () => {
|
|
659
|
+
document.removeEventListener('click', onClick, true)
|
|
660
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
bindSendInterception()
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
const checkPath = (value) => {
|
|
668
|
+
const trimmed = (value || '').trim()
|
|
669
|
+
if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null }
|
|
670
|
+
if (!trimmed) { store.set({ resolveState: 'idle' }); return }
|
|
671
|
+
store.set({ resolveState: 'checking' })
|
|
672
|
+
resolveTimer = setTimeout(() => {
|
|
673
|
+
resolveTimer = null
|
|
674
|
+
host.call('resolve', { file: trimmed }).then((res) => {
|
|
675
|
+
if (!res || res.ok === false) { store.set({ resolveState: 'idle' }); return }
|
|
676
|
+
if (!res.isHtml) store.set({ resolveState: 'invalid' })
|
|
677
|
+
else if (res.exists === true) store.set({ resolveState: 'exists' })
|
|
678
|
+
else if (res.exists === false) store.set({ resolveState: 'missing' })
|
|
679
|
+
else store.set({ resolveState: 'idle' })
|
|
680
|
+
}).catch(() => store.set({ resolveState: 'idle' }))
|
|
681
|
+
}, 300)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── Icons ────────────────────────────────────────────────────────────────
|
|
685
|
+
const icon = (size, children, extra) => {
|
|
686
|
+
const props = {
|
|
687
|
+
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
|
688
|
+
stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round',
|
|
689
|
+
strokeLinejoin: 'round', 'aria-hidden': true,
|
|
690
|
+
}
|
|
691
|
+
if (extra) Object.assign(props, extra)
|
|
692
|
+
return React.createElement('svg', props, children)
|
|
693
|
+
}
|
|
694
|
+
const path = (d, key) => React.createElement('path', { d: d, key: key })
|
|
695
|
+
|
|
696
|
+
const I_CLOSE = icon(15, [path('M18 6 6 18', 'a'), path('m6 6 12 12', 'b')])
|
|
697
|
+
const I_REFRESH = icon(15, [path('M21 12a9 9 0 1 1-3.2-6.9', 'a'), path('M21 4v5h-5', 'b')])
|
|
698
|
+
const I_EXTERNAL = icon(15, [path('M15 3h6v6', 'a'), path('M10 14 21 3', 'b'), path('M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5', 'c')])
|
|
699
|
+
const I_CARET = icon(14, [path('m6 9 6 6 6-6', 'a')])
|
|
700
|
+
const I_CHECK = icon(14, [path('M20 6 9 17l-5-5', 'a')], { strokeWidth: 2.2 })
|
|
701
|
+
const I_MISSING = icon(14, [path('M18 6 6 18', 'a'), path('m6 6 12 12', 'b')], { strokeWidth: 2.2 })
|
|
702
|
+
const I_WARN = icon(14, [path('M12 9v4', 'a'), path('M12 17h.01', 'b'), path('M10.3 3.9 2.4 17.5A1.8 1.8 0 0 0 4 20.2h16a1.8 1.8 0 0 0 1.6-2.7L13.7 3.9a1.8 1.8 0 0 0-3.4 0Z', 'c')])
|
|
703
|
+
const I_LOADING = icon(14, [React.createElement('circle', { cx: 12, cy: 12, r: 9, strokeOpacity: .25, key: 'a' }), path('M21 12a9 9 0 0 0-9-9', 'b')], { strokeWidth: 2.2 })
|
|
704
|
+
const I_FILE = icon(14, [path('M14 3v5h5', 'a'), path('M15 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z', 'b')])
|
|
705
|
+
const I_ALERT = icon(14, [React.createElement('circle', { cx: 12, cy: 12, r: 9, key: 'a' }), path('M12 8v4', 'b'), path('M12 16h.01', 'c')])
|
|
706
|
+
const I_BLANK = icon(22, [path('M14 3v5h5', 'a'), path('M15 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z', 'b'), path('m9 15 1.8-2.2L12.4 15l1.4-1.8', 'c')])
|
|
707
|
+
const I_TRIGGER = icon(17, [
|
|
708
|
+
React.createElement('rect', { x: 2.5, y: 4, width: 19, height: 14, rx: 2.4, key: 'a' }),
|
|
709
|
+
path('M2.5 8.4h19', 'b'),
|
|
710
|
+
path('M8 21h8', 'c'),
|
|
711
|
+
path('M12 18v3', 'd'),
|
|
712
|
+
])
|
|
713
|
+
const I_LOGS = icon(15, [path('M8 6h10', 'a'), path('M8 12h10', 'b'), path('M8 18h6', 'c'), path('M4 6h.01', 'd'), path('M4 12h.01', 'e'), path('M4 18h.01', 'f')])
|
|
714
|
+
|
|
715
|
+
// ── Diagnostics view ─────────────────────────────────────────────────────
|
|
716
|
+
//
|
|
717
|
+
// A start failure has exactly one visible symptom by default: a red dot. The
|
|
718
|
+
// cause lives in a Python traceback or a spawn error inside the host process,
|
|
719
|
+
// so surface it verbatim here — plus the facts needed to reproduce by hand
|
|
720
|
+
// (port, script path, exit code). That turns "服务起不来" into a fixable
|
|
721
|
+
// report without leaving the panel.
|
|
722
|
+
|
|
723
|
+
const clockTime = (at) => {
|
|
724
|
+
try {
|
|
725
|
+
return new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
|
|
726
|
+
} catch (e) { return '' }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const Diagnostics = () => {
|
|
730
|
+
const s = useStore()
|
|
731
|
+
const d = s.diag
|
|
732
|
+
const journal = (d && d.journal) || []
|
|
733
|
+
|
|
734
|
+
const fact = (key, value) => value == null || value === ''
|
|
735
|
+
? null
|
|
736
|
+
: [
|
|
737
|
+
React.createElement('span', { className: 'hwb-diag-key', key: key + '-k' }, key),
|
|
738
|
+
React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
|
|
739
|
+
]
|
|
740
|
+
|
|
741
|
+
return React.createElement('div', { className: 'hwb-diag' },
|
|
742
|
+
React.createElement('div', { className: 'hwb-diag-head' },
|
|
743
|
+
React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
|
|
744
|
+
React.createElement('span', { className: 'hwb-spacer' }),
|
|
745
|
+
React.createElement('button', {
|
|
746
|
+
type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
|
|
747
|
+
disabled: s.restarting, onClick: restartService,
|
|
748
|
+
}, s.restarting ? '重启中…' : '重启服务'),
|
|
749
|
+
React.createElement('button', {
|
|
750
|
+
type: 'button', className: 'hwb-icon', title: '收起诊断', 'aria-label': '收起诊断',
|
|
751
|
+
onClick: () => store.set({ diagOpen: false }),
|
|
752
|
+
}, I_CLOSE),
|
|
753
|
+
),
|
|
754
|
+
d
|
|
755
|
+
? React.createElement('div', { className: 'hwb-diag-facts' },
|
|
756
|
+
fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
|
|
757
|
+
fact('端口', d.port),
|
|
758
|
+
fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
|
|
759
|
+
fact('失败原因', d.startError),
|
|
760
|
+
fact('脚本', d.script),
|
|
761
|
+
d.hasShell === false ? fact('shell', '不可用') : null,
|
|
762
|
+
)
|
|
763
|
+
: null,
|
|
764
|
+
React.createElement('div', { className: 'hwb-diag-body' },
|
|
765
|
+
journal.length
|
|
766
|
+
? journal.map((entry, i) => React.createElement('div', {
|
|
767
|
+
className: 'hwb-diag-row', 'data-level': entry.level, key: String(entry.at) + '-' + i,
|
|
768
|
+
},
|
|
769
|
+
React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
|
|
770
|
+
React.createElement('span', { className: 'hwb-diag-msg' },
|
|
771
|
+
entry.message,
|
|
772
|
+
entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
|
|
773
|
+
),
|
|
774
|
+
))
|
|
775
|
+
: React.createElement('div', { className: 'hwb-diag-empty' }, '暂无日志。点「重启服务」可重新拉起并记录完整过程。'),
|
|
776
|
+
),
|
|
777
|
+
React.createElement('div', { className: 'hwb-diag-hint' },
|
|
778
|
+
'服务自身日志:',
|
|
779
|
+
React.createElement('code', null, (d && d.logDir) || '/tmp/html-workbench-dsh/logs/'),
|
|
780
|
+
';也可直接访问 ',
|
|
781
|
+
React.createElement('code', null, '/html-workbench/diagnostics'),
|
|
782
|
+
' 查看原始 JSON。',
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const STATE_ICON = { exists: I_CHECK, missing: I_MISSING, invalid: I_WARN, checking: I_LOADING }
|
|
788
|
+
|
|
789
|
+
// ── Resize ───────────────────────────────────────────────────────────────
|
|
141
790
|
const startResize = (e) => {
|
|
791
|
+
if (e.button !== undefined && e.button !== 0) return
|
|
142
792
|
e.preventDefault()
|
|
793
|
+
const handle = e.currentTarget
|
|
794
|
+
handle.setAttribute('data-active', '')
|
|
143
795
|
document.body.setAttribute('data-hwb-dragging', '')
|
|
144
|
-
const maxW = Math.max(MIN_WIDTH, window.innerWidth -
|
|
796
|
+
const maxW = () => Math.max(MIN_WIDTH, window.innerWidth - 80)
|
|
145
797
|
const onMove = (ev) => {
|
|
146
|
-
const w = window.innerWidth - ev.clientX
|
|
147
|
-
store.set({ panelWidth: Math.max(MIN_WIDTH, Math.min(w, maxW)) })
|
|
798
|
+
const w = Math.round(window.innerWidth - ev.clientX)
|
|
799
|
+
store.set({ panelWidth: Math.max(MIN_WIDTH, Math.min(w, maxW())) })
|
|
148
800
|
}
|
|
149
801
|
const onUp = () => {
|
|
802
|
+
handle.removeAttribute('data-active')
|
|
150
803
|
document.body.removeAttribute('data-hwb-dragging')
|
|
151
804
|
document.removeEventListener('mousemove', onMove)
|
|
152
805
|
document.removeEventListener('mouseup', onUp)
|
|
806
|
+
writeWidth(store.panelWidth)
|
|
153
807
|
}
|
|
154
808
|
document.addEventListener('mousemove', onMove)
|
|
155
809
|
document.addEventListener('mouseup', onUp)
|
|
156
810
|
}
|
|
157
811
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
812
|
+
const nudgeWidth = (delta) => {
|
|
813
|
+
const maxW = Math.max(MIN_WIDTH, window.innerWidth - 80)
|
|
814
|
+
const w = Math.max(MIN_WIDTH, Math.min(store.panelWidth + delta, maxW))
|
|
815
|
+
store.set({ panelWidth: w })
|
|
816
|
+
writeWidth(w)
|
|
817
|
+
}
|
|
162
818
|
|
|
819
|
+
// ── Panel ────────────────────────────────────────────────────────────────
|
|
163
820
|
const Panel = () => {
|
|
164
821
|
const s = useStore()
|
|
822
|
+
const [menuOpen, setMenuOpen] = React.useState(false)
|
|
823
|
+
const [focused, setFocused] = React.useState(false)
|
|
824
|
+
const fieldRef = React.useRef(null)
|
|
825
|
+
const inputRef = React.useRef(null)
|
|
826
|
+
|
|
165
827
|
React.useEffect(() => {
|
|
166
828
|
const root = document.documentElement
|
|
167
|
-
root.style.setProperty('--hwb-panel-width', s.open ?
|
|
829
|
+
root.style.setProperty('--hwb-panel-width', s.open ? s.panelWidth + 'px' : '0px')
|
|
168
830
|
return () => { root.style.setProperty('--hwb-panel-width', '0px') }
|
|
169
831
|
}, [s.open, s.panelWidth])
|
|
832
|
+
|
|
170
833
|
React.useEffect(() => {
|
|
171
|
-
if (!s.open) return
|
|
834
|
+
if (!s.open) return undefined
|
|
172
835
|
refresh()
|
|
173
|
-
const dispose = ctx.interval(refresh,
|
|
836
|
+
const dispose = ctx.interval(refresh, 4000)
|
|
174
837
|
return () => { if (dispose) dispose() }
|
|
175
838
|
}, [s.open])
|
|
839
|
+
|
|
840
|
+
// The workbench runs in an iframe, so its selections arrive as messages.
|
|
841
|
+
// Bind while the panel is mounted, regardless of which file is open.
|
|
842
|
+
React.useEffect(() => {
|
|
843
|
+
const onMessage = (event) => {
|
|
844
|
+
const data = event.data
|
|
845
|
+
if (!data || data.type !== 'html-workbench:context') return
|
|
846
|
+
if (!data.markdown) return
|
|
847
|
+
receiveContext(data)
|
|
848
|
+
}
|
|
849
|
+
window.addEventListener('message', onMessage)
|
|
850
|
+
return () => window.removeEventListener('message', onMessage)
|
|
851
|
+
}, [])
|
|
852
|
+
|
|
853
|
+
// Drop any in-flight path check when the panel closes.
|
|
854
|
+
React.useEffect(() => () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null } }, [])
|
|
855
|
+
|
|
856
|
+
React.useEffect(() => {
|
|
857
|
+
if (!menuOpen) return undefined
|
|
858
|
+
const onDown = (e) => { if (fieldRef.current && !fieldRef.current.contains(e.target)) setMenuOpen(false) }
|
|
859
|
+
const onKey = (e) => { if (e.key === 'Escape') { setMenuOpen(false); if (inputRef.current) inputRef.current.focus() } }
|
|
860
|
+
document.addEventListener('mousedown', onDown)
|
|
861
|
+
document.addEventListener('keydown', onKey)
|
|
862
|
+
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
|
|
863
|
+
}, [menuOpen])
|
|
864
|
+
|
|
176
865
|
if (!s.open) return null
|
|
177
866
|
|
|
178
867
|
const assets = s.assets || []
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
return React.createElement('div', { key: a.id || a.path, className: 'hwb-item' + (active ? ' active' : '') },
|
|
184
|
-
React.createElement('div', { className: 'hwb-item-main' },
|
|
185
|
-
React.createElement('div', { className: 'hwb-item-name' }, basename(a.path)),
|
|
186
|
-
React.createElement('div', { className: 'hwb-item-path', title: a.path }, a.path),
|
|
187
|
-
),
|
|
188
|
-
React.createElement('button', { type: 'button', className: 'hwb-item-btn', onClick: () => openFile(a.path) }, '打开'),
|
|
189
|
-
)
|
|
190
|
-
})
|
|
868
|
+
const trimmed = (s.pathInput || '').trim()
|
|
869
|
+
const currentPath = s.current ? s.current.path : null
|
|
870
|
+
const submit = () => { if (trimmed) { setMenuOpen(false); openFile(trimmed) } }
|
|
871
|
+
const pick = (p) => { setMenuOpen(false); store.set({ pathInput: p }); openFile(p) }
|
|
191
872
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
React.createElement('path', { d: 'M6 6l12 12' }),
|
|
205
|
-
),
|
|
206
|
-
),
|
|
207
|
-
),
|
|
208
|
-
React.createElement('div', { className: 'hwb-preview' },
|
|
209
|
-
s.loading ? React.createElement('div', { className: 'hwb-hint' }, '正在启动 Workbench…')
|
|
210
|
-
: s.current && s.current.url ? React.createElement('iframe', { className: 'hwb-frame', src: s.current.url, title: 'HTML Workbench' })
|
|
211
|
-
: React.createElement('div', { className: 'hwb-hint' }, '从下方选择一个 HTML 文件开始编辑与预览'),
|
|
873
|
+
const assetRow = (a, inMenu) => React.createElement('button', {
|
|
874
|
+
key: a.id || a.path,
|
|
875
|
+
type: 'button',
|
|
876
|
+
className: 'hwb-item',
|
|
877
|
+
'data-active': a.path === currentPath ? '' : undefined,
|
|
878
|
+
title: a.path,
|
|
879
|
+
onClick: () => pick(a.path),
|
|
880
|
+
},
|
|
881
|
+
I_FILE,
|
|
882
|
+
React.createElement('span', { className: 'hwb-item-main' },
|
|
883
|
+
React.createElement('span', { className: 'hwb-item-name' }, basename(a.path)),
|
|
884
|
+
React.createElement('span', { className: 'hwb-item-path' }, clipHead(dirname(a.path), 52)),
|
|
212
885
|
),
|
|
213
|
-
React.createElement('
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
886
|
+
inMenu && a.kind ? React.createElement('span', { className: 'hwb-tag', 'data-kind': a.kind }, a.kind === 'create' ? '新建' : '编辑') : null,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
// A dead service makes "填入路径开始编辑" a lie — every open will fail.
|
|
890
|
+
// Say what is actually wrong and offer the two things that help.
|
|
891
|
+
const serviceDown = !s.running && !!s.diag && !s.loading
|
|
892
|
+
const body = s.loading
|
|
893
|
+
? React.createElement('div', { className: 'hwb-center' },
|
|
894
|
+
React.createElement('div', { className: 'hwb-spinner' }),
|
|
895
|
+
React.createElement('div', { className: 'hwb-blank-desc' }, '正在启动 Workbench 服务…'),
|
|
896
|
+
)
|
|
897
|
+
: s.current && s.current.url
|
|
898
|
+
? React.createElement('iframe', {
|
|
899
|
+
key: 'frame-' + s.nonce,
|
|
900
|
+
className: 'hwb-frame',
|
|
901
|
+
src: s.current.url,
|
|
902
|
+
title: 'HTML Workbench — ' + basename(s.current.path),
|
|
903
|
+
})
|
|
904
|
+
: serviceDown
|
|
905
|
+
? React.createElement('div', { className: 'hwb-center' },
|
|
906
|
+
React.createElement('div', { className: 'hwb-blank' }, I_ALERT),
|
|
907
|
+
React.createElement('div', null,
|
|
908
|
+
React.createElement('div', { className: 'hwb-blank-title' }, '本地服务未就绪'),
|
|
909
|
+
React.createElement('div', { className: 'hwb-blank-desc' },
|
|
910
|
+
s.diag.startError || '服务进程没有响应健康检查,暂时无法打开文件。'),
|
|
911
|
+
),
|
|
912
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
913
|
+
React.createElement('button', {
|
|
914
|
+
type: 'button', className: 'hwb-btn hwb-btn-primary hwb-btn-sm',
|
|
915
|
+
disabled: s.restarting, onClick: restartService,
|
|
916
|
+
}, s.restarting ? '重启中…' : '重启服务'),
|
|
917
|
+
React.createElement('button', {
|
|
918
|
+
type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
|
|
919
|
+
onClick: () => store.set({ diagOpen: true }),
|
|
920
|
+
}, '查看诊断日志'),
|
|
921
|
+
),
|
|
922
|
+
)
|
|
923
|
+
: React.createElement('div', { className: 'hwb-center' },
|
|
924
|
+
React.createElement('div', { className: 'hwb-blank' }, I_BLANK),
|
|
925
|
+
React.createElement('div', null,
|
|
926
|
+
React.createElement('div', { className: 'hwb-blank-title' }, '还没有打开文件'),
|
|
927
|
+
React.createElement('div', { className: 'hwb-blank-desc' }, assets.length
|
|
928
|
+
? '在上方地址栏填入 .html 的绝对路径,或从下面的产物里挑一个开始可视化编辑。'
|
|
929
|
+
: '在上方地址栏填入 .html 的绝对路径开始可视化编辑。代理 write / edit 过的 .html 之后也会自动出现在这里。'),
|
|
930
|
+
),
|
|
931
|
+
assets.length
|
|
932
|
+
? React.createElement('div', { className: 'hwb-recent' },
|
|
933
|
+
React.createElement('div', { className: 'hwb-recent-label' }, '最近的 HTML 产物'),
|
|
934
|
+
assets.slice(0, 6).map((a) => assetRow(a, false)),
|
|
935
|
+
)
|
|
936
|
+
: null,
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
return React.createElement('div', {
|
|
940
|
+
className: 'hwb-panel',
|
|
941
|
+
style: { width: s.panelWidth + 'px', maxWidth: 'calc(100vw - 24px)' },
|
|
942
|
+
role: 'complementary',
|
|
943
|
+
'aria-label': 'HTML Workbench',
|
|
944
|
+
},
|
|
945
|
+
React.createElement('div', {
|
|
946
|
+
className: 'hwb-resize',
|
|
947
|
+
role: 'separator',
|
|
948
|
+
'aria-orientation': 'vertical',
|
|
949
|
+
'aria-label': '调整面板宽度',
|
|
950
|
+
tabIndex: 0,
|
|
951
|
+
title: '拖动调整宽度(双击复位)',
|
|
952
|
+
onMouseDown: startResize,
|
|
953
|
+
onDoubleClick: () => { store.set({ panelWidth: DEFAULT_WIDTH }); writeWidth(DEFAULT_WIDTH) },
|
|
954
|
+
onKeyDown: (e) => {
|
|
955
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); nudgeWidth(32) }
|
|
956
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); nudgeWidth(-32) }
|
|
957
|
+
},
|
|
958
|
+
}),
|
|
959
|
+
|
|
960
|
+
React.createElement('div', { className: 'hwb-chrome' },
|
|
961
|
+
React.createElement('div', { className: 'hwb-idrow' },
|
|
962
|
+
React.createElement('button', {
|
|
963
|
+
type: 'button',
|
|
964
|
+
className: 'hwb-statusbtn',
|
|
965
|
+
'aria-expanded': !!s.diagOpen,
|
|
966
|
+
title: s.running ? '本地服务运行中 — 点击查看诊断' : '本地服务未就绪 — 点击查看原因',
|
|
967
|
+
onClick: () => store.set({ diagOpen: !s.diagOpen }),
|
|
219
968
|
},
|
|
220
|
-
React.createElement('
|
|
221
|
-
|
|
969
|
+
React.createElement('span', {
|
|
970
|
+
className: 'hwb-dot',
|
|
971
|
+
'data-on': s.running ? '' : undefined,
|
|
972
|
+
'data-off': !s.running ? '' : undefined,
|
|
973
|
+
}),
|
|
974
|
+
React.createElement('span', { className: 'hwb-name' }, 'HTML Workbench'),
|
|
222
975
|
),
|
|
223
|
-
React.createElement('span', { className: 'hwb-
|
|
224
|
-
|
|
225
|
-
|
|
976
|
+
currentPath ? React.createElement('span', { className: 'hwb-sep' }) : null,
|
|
977
|
+
currentPath
|
|
978
|
+
? React.createElement('span', { className: 'hwb-filechip', title: currentPath }, I_FILE, React.createElement('span', null, basename(currentPath)))
|
|
979
|
+
: null,
|
|
980
|
+
React.createElement('span', { className: 'hwb-spacer' }),
|
|
981
|
+
React.createElement('div', { className: 'hwb-actions' },
|
|
982
|
+
React.createElement('button', {
|
|
983
|
+
type: 'button', className: 'hwb-icon', title: '服务诊断',
|
|
984
|
+
'aria-label': '服务诊断', 'aria-expanded': !!s.diagOpen,
|
|
985
|
+
onClick: () => store.set({ diagOpen: !s.diagOpen }),
|
|
986
|
+
}, I_LOGS),
|
|
987
|
+
React.createElement('button', {
|
|
988
|
+
type: 'button', className: 'hwb-icon', title: '刷新产物列表',
|
|
989
|
+
'aria-label': '刷新产物列表', 'data-spin': s.refreshing ? '' : undefined,
|
|
990
|
+
onClick: () => refresh(true),
|
|
991
|
+
}, I_REFRESH),
|
|
992
|
+
React.createElement('button', {
|
|
993
|
+
type: 'button', className: 'hwb-icon', title: '在浏览器标签页中打开',
|
|
994
|
+
'aria-label': '在浏览器标签页中打开', disabled: !(s.current && s.current.url),
|
|
995
|
+
onClick: () => { if (s.current && s.current.url) window.open(s.current.url, '_blank', 'noopener') },
|
|
996
|
+
}, I_EXTERNAL),
|
|
997
|
+
React.createElement('button', {
|
|
998
|
+
type: 'button', className: 'hwb-icon', title: '关闭面板',
|
|
999
|
+
'aria-label': '关闭面板', onClick: () => store.set({ open: false }),
|
|
1000
|
+
}, I_CLOSE),
|
|
1001
|
+
),
|
|
1002
|
+
),
|
|
1003
|
+
|
|
1004
|
+
React.createElement('div', { className: 'hwb-toolbar' },
|
|
1005
|
+
React.createElement('div', { className: 'hwb-fieldwrap', ref: fieldRef },
|
|
1006
|
+
React.createElement('div', { className: 'hwb-field', 'data-focus': focused ? '' : undefined },
|
|
1007
|
+
React.createElement('input', {
|
|
1008
|
+
ref: inputRef,
|
|
1009
|
+
className: 'hwb-input',
|
|
1010
|
+
type: 'text',
|
|
1011
|
+
placeholder: '/absolute/path/page.html',
|
|
1012
|
+
value: s.pathInput,
|
|
1013
|
+
spellCheck: false,
|
|
1014
|
+
autoComplete: 'off',
|
|
1015
|
+
'aria-label': 'HTML 文件绝对路径',
|
|
1016
|
+
onChange: (e) => { const v = e.target.value; store.set({ pathInput: v }); checkPath(v) },
|
|
1017
|
+
onKeyDown: (e) => {
|
|
1018
|
+
if (e.key === 'Enter') submit()
|
|
1019
|
+
else if (e.key === 'ArrowDown' && assets.length) { e.preventDefault(); setMenuOpen(true) }
|
|
1020
|
+
},
|
|
1021
|
+
onFocus: () => { setFocused(true); if (assets.length && !trimmed) setMenuOpen(true) },
|
|
1022
|
+
onBlur: () => setFocused(false),
|
|
1023
|
+
}),
|
|
1024
|
+
s.resolveState !== 'idle'
|
|
1025
|
+
? React.createElement('span', {
|
|
1026
|
+
className: 'hwb-state',
|
|
1027
|
+
'data-state': s.resolveState,
|
|
1028
|
+
title: STATE_TITLE[s.resolveState] || '',
|
|
1029
|
+
}, STATE_ICON[s.resolveState] || null)
|
|
1030
|
+
: null,
|
|
1031
|
+
React.createElement('button', {
|
|
1032
|
+
type: 'button',
|
|
1033
|
+
className: 'hwb-caret',
|
|
1034
|
+
'aria-expanded': menuOpen,
|
|
1035
|
+
'aria-haspopup': 'listbox',
|
|
1036
|
+
title: '历史 HTML 产物' + (assets.length ? '(' + assets.length + ')' : ''),
|
|
1037
|
+
onClick: () => setMenuOpen((v) => !v),
|
|
1038
|
+
}, I_CARET),
|
|
1039
|
+
),
|
|
1040
|
+
menuOpen
|
|
1041
|
+
? React.createElement('div', { className: 'hwb-menu', role: 'listbox' },
|
|
1042
|
+
React.createElement('div', { className: 'hwb-menu-head' },
|
|
1043
|
+
React.createElement('span', null, 'HTML 产物'),
|
|
1044
|
+
React.createElement('span', null, assets.length ? assets.length + ' 个' : '空'),
|
|
1045
|
+
),
|
|
1046
|
+
assets.length
|
|
1047
|
+
? React.createElement('div', { className: 'hwb-menu-body' }, assets.map((a) => assetRow(a, true)))
|
|
1048
|
+
: React.createElement('div', { className: 'hwb-menu-empty' }, '暂无产物', React.createElement('br'), '代理 write / edit 过的 .html 会出现在这里'),
|
|
1049
|
+
)
|
|
1050
|
+
: null,
|
|
226
1051
|
),
|
|
1052
|
+
React.createElement('button', {
|
|
1053
|
+
type: 'button', className: 'hwb-btn hwb-btn-primary',
|
|
1054
|
+
disabled: !trimmed || s.loading, onClick: submit,
|
|
1055
|
+
}, '打开'),
|
|
227
1056
|
),
|
|
228
|
-
s.expanded ? React.createElement('div', { className: 'hwb-docklist' },
|
|
229
|
-
items.length ? items : React.createElement('div', { className: 'hwb-dockempty' }, '暂无 HTML 产物 — 代理 write/edit 的 .html 文件会出现在这里。'),
|
|
230
|
-
s.error ? React.createElement('div', { className: 'hwb-error' }, s.error) : null,
|
|
231
|
-
) : null,
|
|
232
1057
|
),
|
|
1058
|
+
|
|
1059
|
+
s.error
|
|
1060
|
+
? React.createElement('div', { className: 'hwb-banner', role: 'alert' },
|
|
1061
|
+
I_ALERT,
|
|
1062
|
+
React.createElement('span', { className: 'hwb-banner-text' }, s.error),
|
|
1063
|
+
React.createElement('button', {
|
|
1064
|
+
type: 'button', className: 'hwb-icon', title: '查看诊断', 'aria-label': '查看诊断',
|
|
1065
|
+
onClick: () => store.set({ diagOpen: true }),
|
|
1066
|
+
}, I_LOGS),
|
|
1067
|
+
React.createElement('button', {
|
|
1068
|
+
type: 'button', className: 'hwb-icon', title: '忽略', 'aria-label': '忽略',
|
|
1069
|
+
onClick: () => store.set({ error: null }),
|
|
1070
|
+
}, I_CLOSE),
|
|
1071
|
+
)
|
|
1072
|
+
: null,
|
|
1073
|
+
|
|
1074
|
+
s.diagOpen ? React.createElement(Diagnostics) : null,
|
|
1075
|
+
|
|
1076
|
+
React.createElement('div', { className: 'hwb-body' }, body),
|
|
233
1077
|
)
|
|
234
1078
|
}
|
|
235
1079
|
|
|
@@ -240,13 +1084,10 @@ body[data-hwb-dragging] { user-select: none; }
|
|
|
240
1084
|
type: 'button',
|
|
241
1085
|
className: 'hwb-trigger',
|
|
242
1086
|
title: 'HTML Workbench',
|
|
243
|
-
'aria-
|
|
1087
|
+
'aria-label': '打开 HTML Workbench',
|
|
1088
|
+
'aria-expanded': false,
|
|
244
1089
|
onClick: () => { store.set({ open: true }); refresh() },
|
|
245
|
-
},
|
|
246
|
-
React.createElement('rect', { x: 2, y: 4, width: 20, height: 14, rx: 2 }),
|
|
247
|
-
React.createElement('path', { d: 'M8 21h8' }),
|
|
248
|
-
React.createElement('path', { d: 'M12 18v3' }),
|
|
249
|
-
))
|
|
1090
|
+
}, I_TRIGGER)
|
|
250
1091
|
}
|
|
251
1092
|
|
|
252
1093
|
slots.inject('shell.overlay', () => slots.register(
|