@worca/app 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
// ui/public/scripts-view.mjs
|
|
2
|
+
// The Scripts page (scripts-workbench design §5; script-wizard plan S1–S17): one
|
|
3
|
+
// view, `scripts`, with a hash param parsed the way #projects/<key>/… is (C1). The
|
|
4
|
+
// list renderers live here; the two wizard steps are rendered by script-wizard.mjs
|
|
5
|
+
// and the bench by script-bench-view.mjs. ONE controller that app.js mounts from
|
|
6
|
+
// showView and destroys on leave — the createMemoryController shape (C2).
|
|
7
|
+
//
|
|
8
|
+
// House rule, pinned by a test: NO explanatory prose on this page. Labels, chips,
|
|
9
|
+
// mono hints and error sentences only; the one caption the shared params form
|
|
10
|
+
// carries (script-forms.mjs PARAMS_CAPTION) is the single exception.
|
|
11
|
+
import { h } from './script-forms.mjs';
|
|
12
|
+
import {
|
|
13
|
+
renderRuntimeStep, renderWorkspace, renderInterfacePanel, renderEditorPanel, renderTile, collectScriptDraft, EDITOR_LANGUAGE,
|
|
14
|
+
originLabel, commandText, joinCommand, shellMode, advSummary,
|
|
15
|
+
} from './script-wizard.mjs';
|
|
16
|
+
import { renderBench, createBenchController } from './script-bench-view.mjs';
|
|
17
|
+
import {
|
|
18
|
+
keyFromName, inferInterface, mergeInterface, savedRows, cycleValue, PORT_TYPES, WHEN_CYCLE, INPUT_MODES, CHIP_PARAM_TYPES,
|
|
19
|
+
} from '../../src/shared/graph/script-infer.mjs';
|
|
20
|
+
import { SCRIPT_COLORS, SCRIPT_KEY_RE, RESERVED_SCRIPT_KEYS } from '../../src/shared/graph/script-meta.mjs';
|
|
21
|
+
import { iconSvgOf } from '../../src/shared/graph/script-icons.mjs';
|
|
22
|
+
import {
|
|
23
|
+
SCRIPT_TEMPLATES, SCRIPT_WIN32_TEMPLATE, SHELL_COMMAND_TEMPLATE, SCRIPT_EXAMPLES, RUNTIME_DEFAULTS, blankScriptMeta,
|
|
24
|
+
} from '../../src/shared/graph/script-templates.mjs';
|
|
25
|
+
|
|
26
|
+
// The scaffold templates live in src/shared (P3 Task 1): the Scripts page, the
|
|
27
|
+
// `worca script new` CLI and `worca plugin new-script` must emit the SAME file.
|
|
28
|
+
export { SCRIPT_TEMPLATES, SCRIPT_WIN32_TEMPLATE, SHELL_COMMAND_TEMPLATE, blankScriptMeta };
|
|
29
|
+
export { collectScriptDraft, EDITOR_LANGUAGE, originLabel, SCRIPT_COLORS };
|
|
30
|
+
|
|
31
|
+
export const SCRIPT_RUNTIME_IDS = ['node', 'shell', 'python'];
|
|
32
|
+
/** `new` is a script key the store refuses (RESERVED_SCRIPT_KEYS), so the create
|
|
33
|
+
* page can live at #scripts/new without shadowing a real script. */
|
|
34
|
+
export const RESERVED_PARAM = 'new';
|
|
35
|
+
|
|
36
|
+
/** The hash for a script's workspace (S2). */
|
|
37
|
+
export function scriptRoute(key = '') {
|
|
38
|
+
return key ? `scripts/${key}` : 'scripts';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The hash for a new script: the runtime step, or step 2 for a runtime (S2). */
|
|
42
|
+
export function newScriptRoute(runtime = '') {
|
|
43
|
+
return runtime ? `scripts/new/${runtime}` : 'scripts/new';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** '' | 'new' | 'new/<runtime>' | '<key>' | '<key>/<anything>'. parseHash already split
|
|
47
|
+
* off the view, so this splits on the FIRST '/' only; a word after a real key (an
|
|
48
|
+
* old `/source` or `/test` bookmark) is ignored — the workspace is the one page. */
|
|
49
|
+
export function parseScriptsParam(param = '') {
|
|
50
|
+
const s = String(param || '');
|
|
51
|
+
if (!s) return { mode: 'list' };
|
|
52
|
+
const i = s.indexOf('/');
|
|
53
|
+
const key = i === -1 ? s : s.slice(0, i);
|
|
54
|
+
const rest = i === -1 ? '' : s.slice(i + 1);
|
|
55
|
+
if (key === RESERVED_PARAM) {
|
|
56
|
+
const rt = rest.split('/')[0];
|
|
57
|
+
return SCRIPT_RUNTIME_IDS.includes(rt) ? { mode: 'new', step: 2, runtime: rt } : { mode: 'new', step: 1 };
|
|
58
|
+
}
|
|
59
|
+
return { mode: 'detail', key };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
/** The registry's port sentence, or the config-ports wording (spec §5.1). */
|
|
64
|
+
export function portLineOf(s) {
|
|
65
|
+
return s && s.ports === 'config' ? 'ports per card' : String((s && s.portSummary) || '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
69
|
+
|
|
70
|
+
/** This session's dot for a script (C10, W15): green when every stored case
|
|
71
|
+
* passed, red on any failure, grey when something ran, hollow when nothing did. */
|
|
72
|
+
function caseDotState(caseCount, states) {
|
|
73
|
+
if (!states || states.size === 0) return 'none';
|
|
74
|
+
const seen = [...states.values()];
|
|
75
|
+
if (seen.includes('fail')) return 'fail';
|
|
76
|
+
if (seen.length >= caseCount && seen.every((v) => v === 'pass')) return 'pass';
|
|
77
|
+
return 'ran';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A script's card (spec §5.1). Every string is textContent — a plugin's display
|
|
81
|
+
* name and description are untrusted text. */
|
|
82
|
+
export function buildScriptCard(s, { doc = globalThis.document, runtimes = {}, caseState = new Map() } = {}) {
|
|
83
|
+
const card = h(doc, 'section', 'card script-card');
|
|
84
|
+
card.dataset.scriptKey = s.key || '';
|
|
85
|
+
const main = h(doc, 'div', 'script-card-main');
|
|
86
|
+
const head = h(doc, 'div', 'script-card-head');
|
|
87
|
+
head.appendChild(h(doc, 'b', 'script-name', s.displayName || s.key || ''));
|
|
88
|
+
head.appendChild(h(doc, 'span', 'badge script-origin', originLabel(s.origin)));
|
|
89
|
+
head.appendChild(h(doc, 'span', 'chip script-runtime mono', s.runtime || ''));
|
|
90
|
+
// A python script on a host without python: a NOTICE chip, never a block (W4).
|
|
91
|
+
const probe = runtimes && runtimes[s.runtime];
|
|
92
|
+
if (s.runtime === 'python' && probe && probe.ok === false) head.appendChild(h(doc, 'span', 'chip script-warn', 'python not found'));
|
|
93
|
+
main.appendChild(head);
|
|
94
|
+
if (s.description) main.appendChild(h(doc, 'small', 'script-desc', s.description));
|
|
95
|
+
const meta = h(doc, 'div', 'script-meta');
|
|
96
|
+
meta.appendChild(h(doc, 'span', 'script-ports', portLineOf(s)));
|
|
97
|
+
const cases = Number(s.caseCount) || 0;
|
|
98
|
+
if (cases > 0) {
|
|
99
|
+
meta.appendChild(h(doc, 'span', 'chip script-cases', plural(cases, 'case')));
|
|
100
|
+
const dot = h(doc, 'i', 'script-dot');
|
|
101
|
+
dot.dataset.state = caseDotState(cases, caseState.get(s.key));
|
|
102
|
+
meta.appendChild(dot);
|
|
103
|
+
}
|
|
104
|
+
main.appendChild(meta);
|
|
105
|
+
card.appendChild(main);
|
|
106
|
+
const actions = h(doc, 'div', 'script-actions');
|
|
107
|
+
const btn = (cls, label, kind) => { const b = h(doc, 'button', `btn ${kind} btn-mini ${cls}`, label); b.type = 'button'; return b; };
|
|
108
|
+
actions.append(btn('script-open', 'Open', 'btn-ghost'), btn('script-duplicate', 'Duplicate', 'btn-ghost'));
|
|
109
|
+
if (s.origin === 'user') actions.appendChild(btn('script-delete', 'Delete', 'btn-danger'));
|
|
110
|
+
card.appendChild(actions);
|
|
111
|
+
return card;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The list pane: the topbar (title, filter, New script) and the cards. The
|
|
115
|
+
* filter is a pure argument, so a repaint never loses what was typed. */
|
|
116
|
+
export function renderScriptsList(list, { doc = globalThis.document, query = '', runtimes = {}, caseState = new Map() } = {}) {
|
|
117
|
+
const pane = h(doc, 'div', 'scripts-pane');
|
|
118
|
+
const bar = h(doc, 'div', 'topbar');
|
|
119
|
+
const left = h(doc, 'div');
|
|
120
|
+
left.appendChild(h(doc, 'h1', '', 'Scripts'));
|
|
121
|
+
const tools = h(doc, 'div', 'scripts-tools');
|
|
122
|
+
const filter = doc.createElement('input');
|
|
123
|
+
filter.type = 'search'; filter.className = 'input script-filter'; filter.value = query;
|
|
124
|
+
filter.placeholder = 'Filter scripts'; filter.setAttribute('aria-label', 'Filter scripts');
|
|
125
|
+
const add = h(doc, 'button', 'btn btn-primary btn-mini script-new', 'New script');
|
|
126
|
+
add.type = 'button';
|
|
127
|
+
tools.append(filter, add);
|
|
128
|
+
bar.append(left, tools);
|
|
129
|
+
pane.appendChild(bar);
|
|
130
|
+
const rows = h(doc, 'div', 'run-list scripts-list');
|
|
131
|
+
const all = Array.isArray(list) ? list : [];
|
|
132
|
+
const q = String(query || '').trim().toLowerCase();
|
|
133
|
+
const hit = (s) => !q || [s.key, s.displayName, s.runtime, originLabel(s.origin)]
|
|
134
|
+
.some((v) => String(v || '').toLowerCase().includes(q));
|
|
135
|
+
const shown = all.filter(hit);
|
|
136
|
+
if (!shown.length) rows.appendChild(h(doc, 'div', 'scripts-empty', q ? `No scripts match “${query}”.` : 'No scripts.'));
|
|
137
|
+
else for (const s of shown) rows.appendChild(buildScriptCard(s, { doc, runtimes, caseState }));
|
|
138
|
+
pane.appendChild(rows);
|
|
139
|
+
return pane;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
/** `GET /api/scripts/:key` answers `{ meta, source, sourceWin32, sourcePath,
|
|
144
|
+
* sourceTruncated, cases, userCases, casesWritable }` (Task 4); P1b's earlier
|
|
145
|
+
* read route spread the meta flat. Accept both — a route shape cannot break the
|
|
146
|
+
* page, and the create/update bodies use the nested one. */
|
|
147
|
+
const PAYLOAD_KEYS = ['source', 'sourceWin32', 'sourcePath', 'sourceTruncated', 'cases', 'userCases', 'casesWritable'];
|
|
148
|
+
const lf = (s) => (typeof s === 'string' ? s.replace(/\r\n|\r/g, '\n') : '');
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* ONE shape inside the page. `GET /api/scripts/:key` spreads the meta FLAT (P1b's
|
|
152
|
+
* landed contract, kept by Task 4); every WRITE route answers the store's nested
|
|
153
|
+
* `{ meta, source, … }`. This adapter runs once at the boundary, so no renderer and
|
|
154
|
+
* no controller helper ever has to ask which one it holds. Program text is
|
|
155
|
+
* normalised to LF here as well: a <textarea> hands back LF whatever it was given,
|
|
156
|
+
* and the hidden mirror of the off-screen half would otherwise keep a CRLF the
|
|
157
|
+
* visible editor cannot — the page would read as dirty after a tab hop. The store
|
|
158
|
+
* owns the on-disk ending of a `.cmd` (script-store.mjs programText).
|
|
159
|
+
*/
|
|
160
|
+
export function scriptPayload(data) {
|
|
161
|
+
const d = data && typeof data === 'object' ? data : {};
|
|
162
|
+
const nested = !!d.meta && typeof d.meta === 'object';
|
|
163
|
+
const meta = nested ? d.meta : Object.fromEntries(Object.entries(d).filter(([k]) => !PAYLOAD_KEYS.includes(k)));
|
|
164
|
+
return {
|
|
165
|
+
meta,
|
|
166
|
+
source: lf(d.source),
|
|
167
|
+
sourceWin32: lf(d.sourceWin32),
|
|
168
|
+
sourcePath: d.sourcePath || null,
|
|
169
|
+
sourceTruncated: d.sourceTruncated === true,
|
|
170
|
+
cases: Array.isArray(d.cases) ? d.cases : [],
|
|
171
|
+
userCases: Array.isArray(d.userCases) ? d.userCases : [],
|
|
172
|
+
casesWritable: d.casesWritable !== false,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
/** The copy's key: <key>Copy, then <key>Copy2, … — the first nobody holds.
|
|
178
|
+
* Matched case-INSENSITIVELY, because the store's uniqueness is (one file holds
|
|
179
|
+
* `lintCopy` and `LintCopy` on macOS and Windows): an exact-match candidate that
|
|
180
|
+
* differs from a taken key only in case answers 409 on every press, and the
|
|
181
|
+
* Duplicate button has no other key to offer. */
|
|
182
|
+
function nextCopyKey(key, list) {
|
|
183
|
+
const taken = new Set((list || []).map((s) => String((s && s.key) || '').toLowerCase()));
|
|
184
|
+
for (let i = 1; ; i += 1) {
|
|
185
|
+
const candidate = i === 1 ? `${key}Copy` : `${key}Copy${i}`;
|
|
186
|
+
if (!taken.has(candidate.toLowerCase())) return candidate;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The page controller (C2). app.js mounts ONE of these in showView('scripts') and
|
|
192
|
+
* destroys it on leave; it owns the endpoint calls and exactly four delegated
|
|
193
|
+
* listeners on `host`. `inferDelayMs` is the editor → interface debounce (S13).
|
|
194
|
+
*/
|
|
195
|
+
export function createScriptsController({
|
|
196
|
+
host, msgEl = null, api, navigate, confirm, highlight, renderMarkdown = async () => {}, modal = null,
|
|
197
|
+
ws = null, doc = globalThis.document, inferDelayMs = 150,
|
|
198
|
+
} = {}) {
|
|
199
|
+
const win = doc.defaultView || globalThis;
|
|
200
|
+
const st = {
|
|
201
|
+
param: '', mode: 'list', step: 1, runtime: 'node', key: '', query: '',
|
|
202
|
+
list: [], runtimes: null, caseState: new Map(), flash: null,
|
|
203
|
+
data: null, savedMeta: null, root: null, isNew: false, baseline: '', stepDirty: false, srcMode: '', srcTab: 'default',
|
|
204
|
+
rows: null, removed: new Set(), verdict: false, routing: false, advOpen: false, keyTouched: false, pickerInPlace: false,
|
|
205
|
+
bench: null, benchMounting: false, projects: [], inferTimer: null, saving: false,
|
|
206
|
+
};
|
|
207
|
+
// Request token: a route change, a frame poke and a write can all be in flight
|
|
208
|
+
// at once, and the LAST one issued must win however the responses land.
|
|
209
|
+
let seq = 0;
|
|
210
|
+
const say = (textValue, kind) => {
|
|
211
|
+
if (!msgEl) return;
|
|
212
|
+
msgEl.textContent = textValue || '';
|
|
213
|
+
msgEl.className = 'form-msg' + (kind ? ` ${kind}` : '');
|
|
214
|
+
};
|
|
215
|
+
const fail = (r) => say((r && r.data && r.data.error) || `HTTP ${r && r.status}`, 'err');
|
|
216
|
+
const val = (name) => { const n = st.root && st.root.querySelector(`[data-field="${name}"]`); return n ? String(n.value) : ''; };
|
|
217
|
+
const onWorkspace = () => Boolean(st.root && st.root.dataset.step === '2');
|
|
218
|
+
|
|
219
|
+
function paintList() {
|
|
220
|
+
const active = doc.activeElement;
|
|
221
|
+
const typing = !!active && host.contains(active) && active.classList && active.classList.contains('script-filter');
|
|
222
|
+
const caret = typing ? active.selectionStart : null;
|
|
223
|
+
host.replaceChildren(renderScriptsList(st.list, { doc, query: st.query, runtimes: st.runtimes || {}, caseState: st.caseState }));
|
|
224
|
+
if (!typing) return;
|
|
225
|
+
const box = host.querySelector('.script-filter');
|
|
226
|
+
if (!box || typeof box.focus !== 'function') return;
|
|
227
|
+
box.focus({ preventScroll: true });
|
|
228
|
+
try { box.setSelectionRange(caret, caret); } catch { /* not a text control */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Tear the mounted code editors and the bench down before the tree goes. */
|
|
232
|
+
function disposeDetail() {
|
|
233
|
+
unmountBench();
|
|
234
|
+
if (st.inferTimer) { win.clearTimeout(st.inferTimer); st.inferTimer = null; }
|
|
235
|
+
if (st.root && Array.isArray(st.root._editors)) for (const e of st.root._editors) e.destroy();
|
|
236
|
+
st.root = null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// From the SERVER's meta: collectScriptDraft never emits `origin`, so a meta rebuilt from the page cannot be asked.
|
|
240
|
+
const readOnlyNow = () => !st.isNew && Boolean(st.savedMeta) && st.savedMeta.origin !== 'user';
|
|
241
|
+
const currentKey = () => (st.isNew ? val('meta:key').trim() : st.key);
|
|
242
|
+
const keyOk = () => { const k = currentKey(); return SCRIPT_KEY_RE.test(k) && !RESERVED_SCRIPT_KEYS.includes(k.toLowerCase()); };
|
|
243
|
+
const nameOk = () => val('meta:displayName').trim() !== '';
|
|
244
|
+
|
|
245
|
+
// ── the interface (S3, S4, S13) ────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** The program text inference reads (S15): the command in Command mode, the default file otherwise.
|
|
248
|
+
* `fromDom`: the LIVE editor (a refresh while the workspace is up); else st.data — initInterface runs
|
|
249
|
+
* after st.data was rebuilt and BEFORE the repaint, when the DOM (if any) still shows the old program. */
|
|
250
|
+
function sourceForInference(fromDom) {
|
|
251
|
+
const meta = st.data.meta;
|
|
252
|
+
const box = fromDom && onWorkspace() ? st.root.querySelector('.script-source') : null;
|
|
253
|
+
const mode = box ? box.dataset.srcMode : (st.srcMode || shellMode(meta));
|
|
254
|
+
const command = meta.runtime === 'shell' && mode === 'command';
|
|
255
|
+
if (box) return command ? val('meta:command') : val('script:source');
|
|
256
|
+
return command ? commandText(meta.command) : st.data.source;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Rows from the code + the saved sidecar + the chips (S3); `verdict` from the code (S4). */
|
|
260
|
+
function computeRows(prevRows, fromDom) {
|
|
261
|
+
const meta = st.data.meta;
|
|
262
|
+
if (meta.ports === 'config') return { rows: savedRows(meta), verdict: Boolean(meta.verdict) };
|
|
263
|
+
const inferred = inferInterface(sourceForInference(fromDom), meta.runtime);
|
|
264
|
+
return { rows: mergeInterface({ inferred, saved: st.savedMeta, rows: prevRows, removed: st.removed }), verdict: inferred.verdict };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Fresh interface state for st.data (on load, after a runtime change, after Load example). */
|
|
268
|
+
function initInterface() {
|
|
269
|
+
const meta = st.data.meta;
|
|
270
|
+
st.removed = new Set();
|
|
271
|
+
st.routing = meta.runtime === 'shell' ? (st.isNew ? true : Boolean(meta.verdict)) : false;
|
|
272
|
+
const { rows, verdict } = computeRows(null, false);
|
|
273
|
+
st.rows = rows;
|
|
274
|
+
// S4: the code decides — but a SAVED sidecar's verdict is kept until the program is edited, so a Save that
|
|
275
|
+
// touched only the description never drops a declaration (shell routes on its exit code: st.routing above).
|
|
276
|
+
st.verdict = verdict || (!st.isNew && meta.runtime !== 'shell' && Boolean(meta.verdict));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A typed param default lives in the DOM until the panel repaints: pull it into the rows first.
|
|
280
|
+
* Only a CHANGED value is taken, so an untouched inferred default keeps following the code. */
|
|
281
|
+
function syncRowsFromDom() {
|
|
282
|
+
if (!st.root || !st.rows) return;
|
|
283
|
+
for (const r of st.rows.params) {
|
|
284
|
+
const n = st.root.querySelector(`[data-field="iface:param:${r.id}:default"]`);
|
|
285
|
+
if (n && n.value !== String(r.default ?? '')) r.default = n.value;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function repaintInterface() {
|
|
290
|
+
if (!onWorkspace()) return;
|
|
291
|
+
const old = st.root.querySelector('.wz-iface');
|
|
292
|
+
if (!old) return;
|
|
293
|
+
const meta = st.data.meta;
|
|
294
|
+
const configPorts = meta.ports === 'config';
|
|
295
|
+
old.replaceWith(renderInterfacePanel({
|
|
296
|
+
doc, runtime: meta.runtime, key: currentKey(), rows: st.rows, verdict: st.verdict, routing: st.routing, readOnly: readOnlyNow(),
|
|
297
|
+
configPorts, defaultPorts: configPorts ? meta.defaultPorts : null,
|
|
298
|
+
verdictFilename: (st.savedMeta && st.savedMeta.verdict && st.savedMeta.verdict.filename) || '',
|
|
299
|
+
}));
|
|
300
|
+
const vf = st.root.querySelector('.wz-verdict-file');
|
|
301
|
+
if (vf) { const v = collectScriptDraft(st.root).meta.verdict; vf.textContent = v && v.filename ? v.filename : '—'; }
|
|
302
|
+
syncDirty();
|
|
303
|
+
syncBenchMeta();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function refreshInterface() {
|
|
307
|
+
if (!onWorkspace() || readOnlyNow() || st.data.meta.ports === 'config') return;
|
|
308
|
+
syncRowsFromDom();
|
|
309
|
+
const { rows, verdict } = computeRows(st.rows, true);
|
|
310
|
+
st.rows = rows;
|
|
311
|
+
st.verdict = verdict;
|
|
312
|
+
repaintInterface();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The editor's live hook (createCodeEditor onInput): debounce, then re-read the code. */
|
|
316
|
+
function onSourceInput() {
|
|
317
|
+
syncDirty();
|
|
318
|
+
if (st.inferTimer) win.clearTimeout(st.inferTimer);
|
|
319
|
+
st.inferTimer = win.setTimeout(() => { st.inferTimer = null; refreshInterface(); }, inferDelayMs);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function onChipClick(el) {
|
|
323
|
+
const [kind, id, what] = String(el.dataset.chip).split(':'); // ids never hold ':' (PORT_ID_RE)
|
|
324
|
+
const side = kind === 'in' ? 'inputs' : (kind === 'out' ? 'outputs' : 'params');
|
|
325
|
+
const row = st.rows && (st.rows[side] || []).find((r) => r.id === id);
|
|
326
|
+
if (!row) return;
|
|
327
|
+
syncRowsFromDom();
|
|
328
|
+
if (what === 'type') row.type = cycleValue(kind === 'param' ? CHIP_PARAM_TYPES : PORT_TYPES, row.type);
|
|
329
|
+
else if (what === 'mode') row.mode = cycleValue(INPUT_MODES, row.mode || 'optional');
|
|
330
|
+
else if (what === 'when') row.when = cycleValue(WHEN_CYCLE, row.when || 'always');
|
|
331
|
+
repaintInterface();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function onRemoveClick(el) {
|
|
335
|
+
const [side, id] = String(el.dataset.remove).split(':');
|
|
336
|
+
if (!st.rows || !st.rows[side]) return;
|
|
337
|
+
syncRowsFromDom();
|
|
338
|
+
st.removed.add(`${side}:${id}`);
|
|
339
|
+
st.rows[side] = st.rows[side].filter((r) => r.id !== id);
|
|
340
|
+
repaintInterface();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function onRoutingClick() {
|
|
344
|
+
syncRowsFromDom();
|
|
345
|
+
st.routing = !st.routing;
|
|
346
|
+
if (!st.routing && st.rows) {
|
|
347
|
+
// Off un-mints what On minted (S4): a SAVED `pass` / `fail` the code never names goes with the
|
|
348
|
+
// switch, or it stays on disk as an output that fires always. On mints both again at collect time.
|
|
349
|
+
for (const id of ['pass', 'fail']) {
|
|
350
|
+
if (!st.rows.outputs.some((r) => r.id === id && r.inCode === false)) continue;
|
|
351
|
+
st.removed.add(`outputs:${id}`);
|
|
352
|
+
st.rows.outputs = st.rows.outputs.filter((r) => r.id !== id);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
repaintInterface();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── painting ──────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
function paintRuntimeStep() {
|
|
361
|
+
disposeDetail();
|
|
362
|
+
const root = renderRuntimeStep({ doc, runtimes: st.runtimes || {}, picked: st.runtime });
|
|
363
|
+
st.root = root;
|
|
364
|
+
host.replaceChildren(root);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `rebase` is passed on LOAD only. A repaint from the draft must NOT retake the baseline. */
|
|
368
|
+
function paintDetail({ rebase = false } = {}) {
|
|
369
|
+
disposeDetail();
|
|
370
|
+
const root = renderWorkspace(st.data, {
|
|
371
|
+
doc, runtimes: st.runtimes || {}, readOnly: readOnlyNow(), highlight, isNew: st.isNew,
|
|
372
|
+
srcMode: st.srcMode, srcTab: st.srcTab, rows: st.rows, verdict: st.verdict, routing: st.routing, advOpen: st.advOpen,
|
|
373
|
+
onSourceInput, verdictFilename: (st.savedMeta && st.savedMeta.verdict && st.savedMeta.verdict.filename) || '',
|
|
374
|
+
});
|
|
375
|
+
st.root = root;
|
|
376
|
+
st.srcMode = root.querySelector('.script-source').dataset.srcMode;
|
|
377
|
+
host.replaceChildren(root);
|
|
378
|
+
if (rebase) st.baseline = JSON.stringify(collectScriptDraft(root));
|
|
379
|
+
syncDirty();
|
|
380
|
+
syncSave();
|
|
381
|
+
void mountBench();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function syncDirty() {
|
|
385
|
+
const mark = st.root && st.root.querySelector('.script-dirty');
|
|
386
|
+
if (mark) mark.hidden = !isDirty();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function syncSave() {
|
|
390
|
+
const b = st.root && st.root.querySelector('.script-save');
|
|
391
|
+
if (b) b.disabled = !(nameOk() && keyOk());
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The tile and the file name follow the identity fields as they are typed. */
|
|
395
|
+
function syncTile() {
|
|
396
|
+
if (!onWorkspace()) return;
|
|
397
|
+
const meta = st.data.meta;
|
|
398
|
+
const key = currentKey();
|
|
399
|
+
const tile = st.root.querySelector('.wz-tile');
|
|
400
|
+
if (tile) tile.replaceWith(renderTile({ doc, name: val('meta:displayName'), key, runtime: meta.runtime, color: val('meta:color'), icon: val('meta:icon') }));
|
|
401
|
+
const file = st.root.querySelector('.wz-file');
|
|
402
|
+
const box = st.root.querySelector('.script-source');
|
|
403
|
+
if (file && !(meta.runtime === 'shell' && box && box.dataset.srcMode === 'command')) {
|
|
404
|
+
const ext = { node: '.mjs', python: '.py', shell: '.sh' }[meta.runtime] || '.mjs';
|
|
405
|
+
file.textContent = `${key || 'script'}${box && box.dataset.srcTab === 'win32' ? '.cmd' : ext}`;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function syncBenchMeta() {
|
|
410
|
+
// snapshot(), never the bare draft: the draft has no `origin`, and caseListFor keys the bench's writable case layer off it.
|
|
411
|
+
if (st.bench && onWorkspace() && !readOnlyNow()) st.bench.setMeta(snapshot().meta);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isDirty() {
|
|
415
|
+
if (!st.root || st.mode === 'list') return false;
|
|
416
|
+
if (!onWorkspace()) return st.stepDirty; // the picker: what step 2 was when we left it
|
|
417
|
+
try { return JSON.stringify(collectScriptDraft(st.root)) !== st.baseline; } catch { return false; }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── the bench (S12) ───────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
function unmountBench() {
|
|
423
|
+
if (st.bench) { st.bench.destroy(); st.bench = null; }
|
|
424
|
+
const slot = st.root && st.root.querySelector('.script-test-mount');
|
|
425
|
+
if (slot) slot.replaceChildren();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Mounted once per workspace paint, lazily; a draft needs a key the store would accept. */
|
|
429
|
+
async function mountBench() {
|
|
430
|
+
if (st.bench || st.benchMounting || !onWorkspace()) return;
|
|
431
|
+
if (st.isNew && !keyOk()) return;
|
|
432
|
+
st.benchMounting = true;
|
|
433
|
+
try { await mountBenchNow(); } finally { st.benchMounting = false; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function mountBenchNow() {
|
|
437
|
+
if (!st.projects.length) {
|
|
438
|
+
const r = await api.projects();
|
|
439
|
+
st.projects = r.ok && Array.isArray(r.data.projects) ? r.data.projects : [];
|
|
440
|
+
}
|
|
441
|
+
if (st.isNew && !keyOk()) return; // the key turned invalid while the projects call was out (S12)
|
|
442
|
+
const slot = st.root && st.root.querySelector('.script-test-mount');
|
|
443
|
+
if (!slot || st.bench) return;
|
|
444
|
+
// Refreshed IN PLACE: the bench controller writes its case list back into this exact object.
|
|
445
|
+
// A read-only page (built-in, plugin) keeps the SERVER's meta: nothing on it can change.
|
|
446
|
+
if (!readOnlyNow()) Object.assign(st.data, { meta: snapshot().meta });
|
|
447
|
+
const tree = renderBench(st.data, { doc, projects: st.projects, caseState: st.caseState, highlight, unsaved: st.isNew });
|
|
448
|
+
slot.replaceChildren(tree);
|
|
449
|
+
st.bench = createBenchController({
|
|
450
|
+
root: tree, data: st.data, api, doc, ws, confirm, renderMarkdown, highlight, modal,
|
|
451
|
+
onCaseState: (key, caseId, state) => {
|
|
452
|
+
if (!caseId) return;
|
|
453
|
+
if (!st.caseState.has(key)) st.caseState.set(key, new Map());
|
|
454
|
+
if (state === 'none') st.caseState.get(key).delete(caseId);
|
|
455
|
+
else st.caseState.get(key).set(caseId, state);
|
|
456
|
+
},
|
|
457
|
+
// W10: the bench runs what is ON SCREEN — always for a draft that is not saved yet.
|
|
458
|
+
getDraft: () => (st.isNew || isDirty() ? collectScriptDraft(st.root) : null),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── routing ───────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/** A runtime for the draft (S16): the template only while the source is untouched;
|
|
465
|
+
* the runtime's colour and icon only while they were the previous runtime's defaults. */
|
|
466
|
+
function applyRuntime(rt) {
|
|
467
|
+
const prev = onWorkspace() ? snapshot() : st.data;
|
|
468
|
+
const prevRt = prev ? prev.meta.runtime : '';
|
|
469
|
+
const prevDefaults = RUNTIME_DEFAULTS[prevRt] || {};
|
|
470
|
+
const untouched = !prev || prev.source === '' || Object.values(SCRIPT_TEMPLATES).includes(prev.source)
|
|
471
|
+
|| (prevRt === 'shell' && prev.meta.file == null && prev.source === '');
|
|
472
|
+
const wasDefaultLook = !prev || (prev.meta.color === prevDefaults.color && prev.meta.icon === iconSvgOf(prevDefaults.icon));
|
|
473
|
+
const base = prev ? { ...prev } : scriptPayload({ meta: blankScriptMeta(rt), source: SCRIPT_TEMPLATES[rt] });
|
|
474
|
+
const d = RUNTIME_DEFAULTS[rt] || RUNTIME_DEFAULTS.node;
|
|
475
|
+
base.meta = {
|
|
476
|
+
...base.meta, runtime: rt, file: null,
|
|
477
|
+
command: rt === 'shell' ? (commandText(base.meta.command) || SHELL_COMMAND_TEMPLATE) : null,
|
|
478
|
+
...(wasDefaultLook ? { color: d.color, icon: iconSvgOf(d.icon) } : {}),
|
|
479
|
+
};
|
|
480
|
+
if (untouched) base.source = SCRIPT_TEMPLATES[rt] || '';
|
|
481
|
+
base.sourceWin32 = '';
|
|
482
|
+
st.data = base;
|
|
483
|
+
st.runtime = rt;
|
|
484
|
+
st.srcMode = rt === 'shell' ? 'command' : 'file';
|
|
485
|
+
st.srcTab = 'default';
|
|
486
|
+
initInterface();
|
|
487
|
+
return !prev;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function route(param = '') {
|
|
491
|
+
const my = ++seq;
|
|
492
|
+
st.param = String(param || '');
|
|
493
|
+
const parsed = parseScriptsParam(st.param);
|
|
494
|
+
if (!st.flash) say('');
|
|
495
|
+
if (!st.runtimes) {
|
|
496
|
+
const r = await api.runtimes();
|
|
497
|
+
if (my !== seq) return;
|
|
498
|
+
st.runtimes = r.ok ? r.data : {};
|
|
499
|
+
}
|
|
500
|
+
// The same open script: a stray tab word in the hash never reloads over the draft.
|
|
501
|
+
if (parsed.mode === 'detail' && st.mode === 'detail' && st.key === parsed.key && st.root) return;
|
|
502
|
+
if (parsed.mode === 'new') {
|
|
503
|
+
const fresh = st.mode !== 'new';
|
|
504
|
+
st.mode = 'new'; st.isNew = true; st.key = ''; st.pickerInPlace = false;
|
|
505
|
+
if (fresh) {
|
|
506
|
+
// From another page — the list, or a SAVED script's workspace (browser Back after a save lands
|
|
507
|
+
// here): the old tree goes FIRST. Both arms below read the mounted tree through snapshot(),
|
|
508
|
+
// which spreads st.data.meta — null from here on — and a saved script's tree is never the draft.
|
|
509
|
+
disposeDetail();
|
|
510
|
+
st.data = null; st.savedMeta = null; st.keyTouched = false; st.stepDirty = false; st.runtime = 'node'; st.baseline = '';
|
|
511
|
+
}
|
|
512
|
+
if (parsed.step === 1) {
|
|
513
|
+
// Leaving step 2 for the picker: what is typed lives in the DOM, and paintRuntimeStep
|
|
514
|
+
// disposes it — keep it (and whether it was dirty) so a hop back loses nothing.
|
|
515
|
+
if (onWorkspace()) { st.stepDirty = isDirty(); st.data = snapshot(); }
|
|
516
|
+
st.step = 1;
|
|
517
|
+
paintRuntimeStep();
|
|
518
|
+
showFlash();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// The hash the workspace ALREADY shows (a re-fired route): what is typed lives in the DOM, and
|
|
522
|
+
// st.data only holds what the last snapshot took — never repaint the page from it.
|
|
523
|
+
if (onWorkspace() && st.data && st.data.meta.runtime === parsed.runtime) { showFlash(); return; }
|
|
524
|
+
st.step = 2;
|
|
525
|
+
const rebase = !st.data || st.data.meta.runtime !== parsed.runtime ? applyRuntime(parsed.runtime) || !st.stepDirty : false;
|
|
526
|
+
if (st.data && st.data.meta.runtime === parsed.runtime && !st.rows) initInterface();
|
|
527
|
+
paintDetail({ rebase: rebase || !st.baseline });
|
|
528
|
+
showFlash();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (parsed.mode === 'detail') {
|
|
532
|
+
st.mode = 'detail'; st.isNew = false; st.step = 2; st.pickerInPlace = false;
|
|
533
|
+
st.key = parsed.key; st.srcMode = ''; st.srcTab = 'default'; st.keyTouched = true; st.stepDirty = false;
|
|
534
|
+
const r = await api.read(st.key);
|
|
535
|
+
if (my !== seq) return;
|
|
536
|
+
if (!r.ok) {
|
|
537
|
+
disposeDetail();
|
|
538
|
+
host.replaceChildren();
|
|
539
|
+
say(r.status === 404 ? `script "${st.key}" not found` : ((r.data && r.data.error) || `HTTP ${r.status}`), 'err');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
st.data = scriptPayload(r.data);
|
|
543
|
+
st.savedMeta = st.data.meta;
|
|
544
|
+
st.runtime = st.data.meta.runtime || 'node';
|
|
545
|
+
initInterface();
|
|
546
|
+
paintDetail({ rebase: true });
|
|
547
|
+
showFlash();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
st.mode = 'list';
|
|
551
|
+
disposeDetail();
|
|
552
|
+
const l = await api.list();
|
|
553
|
+
if (my !== seq) return;
|
|
554
|
+
if (!l.ok) { st.list = []; fail(l); paintList(); return; }
|
|
555
|
+
st.list = Array.isArray(l.data.scripts) ? l.data.scripts : [];
|
|
556
|
+
paintList();
|
|
557
|
+
showFlash();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function showFlash() { if (st.flash) { say(...st.flash); st.flash = null; } }
|
|
561
|
+
const byKey = (key) => st.list.find((s) => s && s.key === key) || null;
|
|
562
|
+
|
|
563
|
+
// ── actions ───────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
async function duplicate(key) {
|
|
566
|
+
let list = st.list;
|
|
567
|
+
if (st.mode !== 'list') { const l = await api.list(); list = l.ok && Array.isArray(l.data.scripts) ? l.data.scripts : []; }
|
|
568
|
+
const r = await api.duplicate(key, nextCopyKey(key, list));
|
|
569
|
+
if (!r.ok) { fail(r); return; }
|
|
570
|
+
st.flash = [`Duplicated as "${r.data.meta.key}".`, 'ok'];
|
|
571
|
+
if (st.mode === 'list') { await route(st.param); return; }
|
|
572
|
+
await leaveDetail(scriptRoute(r.data.meta.key));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function remove(key) {
|
|
576
|
+
const s = byKey(key) || (st.mode !== 'list' && st.data ? st.data.meta : null);
|
|
577
|
+
const okToGo = await confirm({ title: 'Delete script', message: `Delete “${(s && s.displayName) || key}”?`, confirmLabel: 'Delete', danger: true });
|
|
578
|
+
if (!okToGo) return;
|
|
579
|
+
const r = await api.remove(key);
|
|
580
|
+
if (!r.ok) {
|
|
581
|
+
if (r.status === 409) {
|
|
582
|
+
await confirm({ title: 'Cannot delete script', message: (r.data && r.data.error) || 'This script is in use.', confirmLabel: 'Close', cancelLabel: 'Close' });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
fail(r);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
st.flash = [`Deleted "${key}".`, 'ok'];
|
|
589
|
+
if (st.mode === 'list') { await route(''); return; }
|
|
590
|
+
navigate(scriptRoute());
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function save() {
|
|
594
|
+
if (st.saving || !onWorkspace() || readOnlyNow()) return;
|
|
595
|
+
if (!(nameOk() && keyOk())) { syncSave(); return; }
|
|
596
|
+
const root = st.root;
|
|
597
|
+
const draft = collectScriptDraft(root);
|
|
598
|
+
st.saving = true;
|
|
599
|
+
let r;
|
|
600
|
+
try { r = st.isNew ? await api.create(draft) : await api.update(st.key, draft); } finally { st.saving = false; }
|
|
601
|
+
if (st.root !== root) return;
|
|
602
|
+
if (!r.ok) { fail(r); return; }
|
|
603
|
+
const key = (r.data.meta && r.data.meta.key) || draft.meta.key;
|
|
604
|
+
const warnings = Array.isArray(r.data.warnings) ? r.data.warnings : [];
|
|
605
|
+
if (st.isNew) {
|
|
606
|
+
st.baseline = JSON.stringify(draft); // the page is clean now: no leave-guard on the way to its own hash
|
|
607
|
+
st.flash = [`Saved "${key}".`, 'ok'];
|
|
608
|
+
navigate(scriptRoute(key));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
st.baseline = JSON.stringify(draft);
|
|
612
|
+
st.savedMeta = { ...st.savedMeta, ...draft.meta }; // the sidecar on disk is what the page just sent (origin kept)
|
|
613
|
+
st.removed = new Set();
|
|
614
|
+
syncDirty();
|
|
615
|
+
Object.assign(st.data, { meta: snapshot().meta });
|
|
616
|
+
syncBenchMeta();
|
|
617
|
+
say(warnings.length ? `Saved "${key}". ${warnings.join(' ')}` : `Saved "${key}".`, warnings.length ? 'warn' : 'ok');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function leaveDetail(hash) {
|
|
621
|
+
if (isDirty()) {
|
|
622
|
+
// One macrotask later, past the keystroke that got us here (the confirmModal capture-keydown trap).
|
|
623
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
624
|
+
const okToGo = await confirm({ title: 'Discard changes', message: 'This script has unsaved changes. Leave the page and discard them?', confirmLabel: 'Discard', danger: true });
|
|
625
|
+
if (!okToGo) return;
|
|
626
|
+
}
|
|
627
|
+
navigate(hash);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** What is ON SCREEN, as the next st.data (both program halves and the command off their fields). */
|
|
631
|
+
function snapshot() {
|
|
632
|
+
const draft = collectScriptDraft(st.root);
|
|
633
|
+
const meta = { ...st.data.meta, ...draft.meta, command: joinCommand(val('meta:command'), val('meta:commandExtra')) };
|
|
634
|
+
return { ...st.data, meta, source: val('script:source'), sourceWin32: val('script:sourceWin32') };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** A mode / platform hop swaps ONLY the editor panel (S15): the bench under it — a run in
|
|
638
|
+
* flight, its result, the typed inputs — and the rest of the page stay where they are. Every
|
|
639
|
+
* byte the page holds is kept; an empty half is filled with its template. */
|
|
640
|
+
function switchSource(next) {
|
|
641
|
+
if (!onWorkspace()) return;
|
|
642
|
+
const snap = snapshot();
|
|
643
|
+
if (next.mode === 'file' && !snap.source) snap.source = SCRIPT_TEMPLATES[snap.meta.runtime] || '';
|
|
644
|
+
if (next.tab === 'win32' && !snap.sourceWin32) snap.sourceWin32 = SCRIPT_WIN32_TEMPLATE;
|
|
645
|
+
Object.assign(st.data, snap); // in place: the mounted bench holds this object
|
|
646
|
+
if (next.mode) st.srcMode = next.mode;
|
|
647
|
+
if (next.tab) st.srcTab = next.tab;
|
|
648
|
+
syncRowsFromDom();
|
|
649
|
+
repaintEditor();
|
|
650
|
+
refreshInterface(); // the visible half changed (S15)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The editor panel alone, from st.data and st.srcMode / st.srcTab; the old code editor is torn down first. */
|
|
654
|
+
function repaintEditor() {
|
|
655
|
+
const old = st.root.querySelector('.script-source');
|
|
656
|
+
if (!old) return;
|
|
657
|
+
const editors = st.root._editors;
|
|
658
|
+
for (const e of editors) e.destroy();
|
|
659
|
+
editors.length = 0;
|
|
660
|
+
old.replaceWith(renderEditorPanel(doc, st.data, {
|
|
661
|
+
readOnly: readOnlyNow(), highlight, srcMode: st.srcMode || shellMode(st.data.meta), srcTab: st.srcTab, editors, onSourceInput, key: currentKey(),
|
|
662
|
+
}));
|
|
663
|
+
st.srcMode = st.root.querySelector('.script-source').dataset.srcMode;
|
|
664
|
+
syncDirty();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function loadExample() {
|
|
668
|
+
if (!onWorkspace() || readOnlyNow()) return;
|
|
669
|
+
const ex = SCRIPT_EXAMPLES[st.data.meta.runtime];
|
|
670
|
+
if (!ex) return;
|
|
671
|
+
if (isDirty()) {
|
|
672
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
673
|
+
const okToGo = await confirm({ title: 'Load example', message: `Replace the program and the identity with “${ex.name}”?`, confirmLabel: 'Load', danger: false });
|
|
674
|
+
if (!okToGo || !onWorkspace()) return;
|
|
675
|
+
}
|
|
676
|
+
const snap = snapshot();
|
|
677
|
+
snap.meta = {
|
|
678
|
+
...snap.meta, displayName: ex.name, description: ex.description, color: ex.color, icon: iconSvgOf(ex.icon),
|
|
679
|
+
key: st.isNew && !st.keyTouched ? keyFromName(ex.name) : snap.meta.key,
|
|
680
|
+
command: snap.meta.runtime === 'shell' ? null : snap.meta.command,
|
|
681
|
+
};
|
|
682
|
+
snap.source = ex.source;
|
|
683
|
+
snap.sourceWin32 = '';
|
|
684
|
+
st.data = snap;
|
|
685
|
+
if (snap.meta.runtime === 'shell') st.srcMode = 'file';
|
|
686
|
+
initInterface();
|
|
687
|
+
paintDetail();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The runtime pill on a SAVED script: the picker in place, Continue applies, Cancel returns. */
|
|
691
|
+
function openPickerInPlace() {
|
|
692
|
+
st.data = snapshot();
|
|
693
|
+
st.stepDirty = isDirty();
|
|
694
|
+
st.pickerInPlace = true;
|
|
695
|
+
paintRuntimeStep();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function closePickerInPlace(apply) {
|
|
699
|
+
st.pickerInPlace = false;
|
|
700
|
+
if (apply) applyRuntime(st.runtime);
|
|
701
|
+
else if (!st.rows) initInterface();
|
|
702
|
+
paintDetail();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** The read-only path's Copy: the same 1.2 s "Copied" the Agents view uses. */
|
|
706
|
+
async function copyPath(btn) {
|
|
707
|
+
const path = st.root.querySelector('.script-path');
|
|
708
|
+
if (!path || !path.textContent) return;
|
|
709
|
+
const clip = doc.defaultView.navigator && doc.defaultView.navigator.clipboard;
|
|
710
|
+
if (!clip) { say('Clipboard unavailable — select the path and copy it.', 'err'); return; }
|
|
711
|
+
try {
|
|
712
|
+
await clip.writeText(path.textContent);
|
|
713
|
+
btn.textContent = 'Copied';
|
|
714
|
+
doc.defaultView.setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
|
715
|
+
} catch (e) { say(`Copy failed: ${e.message}`, 'err'); }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── events ────────────────────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
function onClick(e) {
|
|
721
|
+
const t = e.target;
|
|
722
|
+
const hit = (cls) => (t.closest ? t.closest(`.${cls}`) : null);
|
|
723
|
+
const attr = (name) => (t.closest ? t.closest(`[${name}]`) : null);
|
|
724
|
+
if (hit('script-new')) { navigate(newScriptRoute()); return; }
|
|
725
|
+
if (st.mode === 'list') {
|
|
726
|
+
const card = hit('script-card');
|
|
727
|
+
const key = card ? card.dataset.scriptKey : '';
|
|
728
|
+
if (!key) return;
|
|
729
|
+
if (hit('script-open')) { navigate(scriptRoute(key)); return; }
|
|
730
|
+
if (hit('script-duplicate')) { void duplicate(key); return; }
|
|
731
|
+
if (hit('script-delete')) { void remove(key); }
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (t.closest && t.closest('.script-test-mount')) return; // the bench's own listener owns everything in there
|
|
735
|
+
// step 1 (the picker)
|
|
736
|
+
const rt = attr('data-runtime');
|
|
737
|
+
if (rt && rt.classList.contains('rt') && !rt.disabled) { st.runtime = rt.dataset.runtime; paintRuntimeStep(); return; }
|
|
738
|
+
if (hit('wz-continue')) {
|
|
739
|
+
if (st.pickerInPlace) { closePickerInPlace(true); return; }
|
|
740
|
+
navigate(newScriptRoute(st.runtime));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (hit('wz-cancel')) {
|
|
744
|
+
if (st.pickerInPlace) { closePickerInPlace(false); return; }
|
|
745
|
+
void leaveDetail(scriptRoute());
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// step 2 (the workspace)
|
|
749
|
+
if (hit('script-back')) { void leaveDetail(scriptRoute()); return; }
|
|
750
|
+
if (hit('script-save')) { void save(); return; }
|
|
751
|
+
if (hit('script-copy')) { void copyPath(hit('script-copy')); return; }
|
|
752
|
+
if (hit('script-duplicate')) { void duplicate(st.key); return; }
|
|
753
|
+
if (hit('script-delete')) { void remove(st.key); return; }
|
|
754
|
+
const pill = attr('data-step');
|
|
755
|
+
if (pill && pill.classList.contains('wz-step-pill') && pill.dataset.step === '1' && !pill.disabled) {
|
|
756
|
+
if (st.isNew) { st.stepDirty = isDirty(); navigate(newScriptRoute()); } else openPickerInPlace();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (hit('wz-example')) { void loadExample(); return; }
|
|
760
|
+
if (hit('wz-adv-toggle')) {
|
|
761
|
+
st.advOpen = !st.advOpen;
|
|
762
|
+
const body = st.root.querySelector('.wz-adv-body');
|
|
763
|
+
if (body) body.hidden = !st.advOpen;
|
|
764
|
+
hit('wz-adv-toggle').setAttribute('aria-expanded', st.advOpen ? 'true' : 'false');
|
|
765
|
+
const chev = st.root.querySelector('.wz-adv-toggle .chev');
|
|
766
|
+
if (chev) chev.classList.toggle('open', st.advOpen);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (readOnlyNow()) return;
|
|
770
|
+
const sw = attr('data-swatch');
|
|
771
|
+
if (sw) {
|
|
772
|
+
st.root.querySelector('[data-field="meta:color"]').value = sw.dataset.swatch;
|
|
773
|
+
for (const b of st.root.querySelectorAll('.sw')) { const on = b === sw; b.classList.toggle('sel', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); }
|
|
774
|
+
syncTile(); syncDirty();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const ico = attr('data-icon');
|
|
778
|
+
if (ico && ico.classList.contains('ico')) {
|
|
779
|
+
st.root.querySelector('[data-field="meta:icon"]').value = iconSvgOf(ico.dataset.icon);
|
|
780
|
+
for (const b of st.root.querySelectorAll('.ico')) { const on = b === ico; b.classList.toggle('sel', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); }
|
|
781
|
+
syncTile(); syncDirty();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const chipEl = attr('data-chip');
|
|
785
|
+
if (chipEl && !chipEl.disabled) { onChipClick(chipEl); return; }
|
|
786
|
+
const rm = attr('data-remove');
|
|
787
|
+
if (rm) { onRemoveClick(rm); return; }
|
|
788
|
+
if (attr('data-routing')) { onRoutingClick(); return; }
|
|
789
|
+
const mode = attr('data-src-mode');
|
|
790
|
+
if (mode && mode.tagName === 'BUTTON') { switchSource({ mode: mode.dataset.srcMode }); return; }
|
|
791
|
+
const plat = attr('data-src-tab');
|
|
792
|
+
if (plat && plat.tagName === 'BUTTON') switchSource({ tab: plat.dataset.srcTab });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function onInput(e) {
|
|
796
|
+
if (st.mode === 'list') {
|
|
797
|
+
if (!e.target.classList || !e.target.classList.contains('script-filter')) return;
|
|
798
|
+
st.query = e.target.value;
|
|
799
|
+
paintList();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (!onWorkspace() || (e.target.closest && e.target.closest('.script-test-mount'))) return;
|
|
803
|
+
const name = e.target.dataset && e.target.dataset.field;
|
|
804
|
+
if (name === 'meta:displayName') {
|
|
805
|
+
if (st.isNew && !st.keyTouched) { const k = st.root.querySelector('[data-field="meta:key"]'); if (k) k.value = keyFromName(e.target.value); }
|
|
806
|
+
identityChanged();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (name === 'meta:key') { st.keyTouched = true; identityChanged(); return; }
|
|
810
|
+
if (name === 'meta:timeoutSec' || name === 'meta:domain' || name === 'meta:order') syncAdvSummary();
|
|
811
|
+
syncDirty();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** The disclosure's one-line summary follows the three fields it summarises as they are typed (S11). */
|
|
815
|
+
function syncAdvSummary() {
|
|
816
|
+
const sum = st.root && st.root.querySelector('.wz-adv-sum');
|
|
817
|
+
if (sum) sum.textContent = advSummary(collectScriptDraft(st.root).meta);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** The name or the key changed: the tile, Save, the dirty chip, the bench's key — and a bench that was waiting for a key. */
|
|
821
|
+
function identityChanged() {
|
|
822
|
+
syncTile(); syncSave(); syncDirty();
|
|
823
|
+
// A draft's bench follows the key (S12): mounted as soon as the store would take the key, gone again while it would not.
|
|
824
|
+
if (st.isNew && !keyOk()) { unmountBench(); return; }
|
|
825
|
+
if (st.bench) syncBenchMeta(); else void mountBench();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function onChange(e) {
|
|
829
|
+
if (st.mode === 'list' || !onWorkspace()) return;
|
|
830
|
+
if (e.target.closest && e.target.closest('.script-test-mount')) return;
|
|
831
|
+
syncDirty();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function onKeyDown(e) {
|
|
835
|
+
if (e.key !== 'Escape' || st.mode === 'list') return;
|
|
836
|
+
const t = e.target;
|
|
837
|
+
if (t && t.closest && t.closest('textarea, input, select, [contenteditable="true"]')) return;
|
|
838
|
+
e.preventDefault();
|
|
839
|
+
if (st.pickerInPlace) { closePickerInPlace(false); return; }
|
|
840
|
+
void leaveDetail(scriptRoute());
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
host.addEventListener('click', onClick);
|
|
844
|
+
host.addEventListener('input', onInput);
|
|
845
|
+
host.addEventListener('change', onChange);
|
|
846
|
+
host.addEventListener('keydown', onKeyDown);
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
route,
|
|
850
|
+
onChanged() {
|
|
851
|
+
// A frame must never clobber an unsaved draft (the memory controller's rule).
|
|
852
|
+
if (st.mode !== 'list' && isDirty()) return;
|
|
853
|
+
if (st.mode === 'new') return; // a draft is never on the server
|
|
854
|
+
void route(st.param);
|
|
855
|
+
},
|
|
856
|
+
onFrame(msg) { if (st.bench) st.bench.onFrame(msg); },
|
|
857
|
+
isDirty,
|
|
858
|
+
destroy() {
|
|
859
|
+
seq += 1;
|
|
860
|
+
disposeDetail();
|
|
861
|
+
host.removeEventListener('click', onClick);
|
|
862
|
+
host.removeEventListener('input', onInput);
|
|
863
|
+
host.removeEventListener('change', onChange);
|
|
864
|
+
host.removeEventListener('keydown', onKeyDown);
|
|
865
|
+
host.replaceChildren();
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
}
|