@xenosystem/blocks 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-WE6A2DTY.js +68 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ops/index.d.ts +2235 -0
- package/dist/ops/index.js +3757 -0
- package/dist/trust/index.d.ts +780 -0
- package/dist/trust/index.js +1003 -0
- package/dist/xterm-R3GIKSHA.js +83 -0
- package/package.json +80 -58
|
@@ -0,0 +1,3757 @@
|
|
|
1
|
+
import {
|
|
2
|
+
XENO_TERMINAL_THEME_KEYS,
|
|
3
|
+
isLive,
|
|
4
|
+
measuredSize,
|
|
5
|
+
readTerminalTheme,
|
|
6
|
+
statusLabel,
|
|
7
|
+
terminalThemeVar
|
|
8
|
+
} from "../chunk-WE6A2DTY.js";
|
|
9
|
+
|
|
10
|
+
// src/ops/console/panel.ts
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
import { createRoot } from "react-dom/client";
|
|
13
|
+
import {
|
|
14
|
+
isRecord,
|
|
15
|
+
bindConfig
|
|
16
|
+
} from "@xenosystem/panel-sdk";
|
|
17
|
+
|
|
18
|
+
// src/ops/console/controller.ts
|
|
19
|
+
import { redactForLog } from "@xenosystem/panel-sdk";
|
|
20
|
+
|
|
21
|
+
// src/ops/console/types.ts
|
|
22
|
+
var LEVEL_ORDER = Object.freeze([
|
|
23
|
+
"trace",
|
|
24
|
+
"debug",
|
|
25
|
+
"info",
|
|
26
|
+
"warn",
|
|
27
|
+
"error"
|
|
28
|
+
]);
|
|
29
|
+
function meetsLevel(level, min) {
|
|
30
|
+
if (!min) return true;
|
|
31
|
+
return LEVEL_ORDER.indexOf(level) >= LEVEL_ORDER.indexOf(min);
|
|
32
|
+
}
|
|
33
|
+
function matchesSearch(record, term) {
|
|
34
|
+
if (!term) return true;
|
|
35
|
+
const needle = term.toLowerCase();
|
|
36
|
+
return record.message.toLowerCase().includes(needle) || (record.source ?? "").toLowerCase().includes(needle) || (record.scope ?? "").toLowerCase().includes(needle);
|
|
37
|
+
}
|
|
38
|
+
function collapseAdjacent(records) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const record of records) {
|
|
41
|
+
const last = out[out.length - 1];
|
|
42
|
+
if (last && last.level === record.level && last.message === record.message && last.source === record.source && last.scope === record.scope) {
|
|
43
|
+
out[out.length - 1] = { ...last, ts: record.ts, count: (last.count ?? 1) + 1 };
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
out.push({ ...record, count: 1 });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
var REDACTION_CARRY = 1024;
|
|
51
|
+
var MESSAGE_ELISION = "\u2026";
|
|
52
|
+
function spliceRedactedAppend(existing, addition, redact, carry = REDACTION_CARRY) {
|
|
53
|
+
const window = Math.max(0, carry);
|
|
54
|
+
const cut = Math.max(0, existing.length - window);
|
|
55
|
+
const head = existing.slice(0, cut);
|
|
56
|
+
const tail = existing.slice(cut);
|
|
57
|
+
return head + redact(tail + addition);
|
|
58
|
+
}
|
|
59
|
+
function truncateLogMessage(text, max) {
|
|
60
|
+
if (max <= 0 || text.length <= max) return text;
|
|
61
|
+
if (max <= MESSAGE_ELISION.length) return text.slice(text.length - max);
|
|
62
|
+
return MESSAGE_ELISION + text.slice(text.length - (max - MESSAGE_ELISION.length));
|
|
63
|
+
}
|
|
64
|
+
function formatRecord(record) {
|
|
65
|
+
const time = new Date(record.ts).toISOString();
|
|
66
|
+
const where = [record.source, record.scope].filter(Boolean).join("/");
|
|
67
|
+
const head = `${time} ${record.level.toUpperCase()}${where ? ` [${where}]` : ""} ${record.message}`;
|
|
68
|
+
if (record.data === void 0) return head;
|
|
69
|
+
return `${head}
|
|
70
|
+
${safeJson(record.data)}`;
|
|
71
|
+
}
|
|
72
|
+
function safeJson(value) {
|
|
73
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
74
|
+
try {
|
|
75
|
+
return JSON.stringify(
|
|
76
|
+
value,
|
|
77
|
+
(_k, v) => {
|
|
78
|
+
if (v && typeof v === "object") {
|
|
79
|
+
if (seen.has(v)) return "[circular]";
|
|
80
|
+
seen.add(v);
|
|
81
|
+
}
|
|
82
|
+
return v;
|
|
83
|
+
},
|
|
84
|
+
2
|
|
85
|
+
) ?? String(value);
|
|
86
|
+
} catch {
|
|
87
|
+
return String(value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/ops/console/controller.ts
|
|
92
|
+
var ConsoleController = class {
|
|
93
|
+
host;
|
|
94
|
+
listeners = /* @__PURE__ */ new Set();
|
|
95
|
+
maxRecords;
|
|
96
|
+
maxMessageChars;
|
|
97
|
+
records = [];
|
|
98
|
+
/**
|
|
99
|
+
* Record id → ABSOLUTE position, where absolute = array index + {@link origin}.
|
|
100
|
+
*
|
|
101
|
+
* Patching by id has to be O(1) or streaming is worse than the thing it replaces: a linear scan
|
|
102
|
+
* of a 5 000-record buffer per fragment, at a thousand fragments a second, is five million
|
|
103
|
+
* comparisons a second to update one line. (`runs/src/controller.ts` keeps the same index for the
|
|
104
|
+
* same reason.)
|
|
105
|
+
*
|
|
106
|
+
* The absolute/origin split is what makes the ring buffer cheap. The buffer trims from the FRONT,
|
|
107
|
+
* so storing raw array indices would mean decrementing every entry on every trim — O(n) per
|
|
108
|
+
* append once the buffer is full. An offset moves the whole map in one addition instead.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ Records with no id, or an empty one, are not indexed and therefore cannot be patched. That
|
|
111
|
+
* is the honest outcome: `id` is what "addressed" means here, and there is nothing else to
|
|
112
|
+
* address them by.
|
|
113
|
+
*/
|
|
114
|
+
index = /* @__PURE__ */ new Map();
|
|
115
|
+
/** Absolute position of `records[0]`. */
|
|
116
|
+
origin = 0;
|
|
117
|
+
filter;
|
|
118
|
+
follow;
|
|
119
|
+
collapse;
|
|
120
|
+
rev = 0;
|
|
121
|
+
dropped = 0;
|
|
122
|
+
droppedPatches = 0;
|
|
123
|
+
snapshot = null;
|
|
124
|
+
constructor(options) {
|
|
125
|
+
this.host = options.host;
|
|
126
|
+
this.maxRecords = Math.max(1, options.maxRecords ?? 5e3);
|
|
127
|
+
this.maxMessageChars = Math.max(REDACTION_CARRY * 2, options.maxMessageChars ?? 65536);
|
|
128
|
+
this.filter = options.initial?.filter ?? {};
|
|
129
|
+
this.follow = options.initial?.follow ?? options.follow ?? true;
|
|
130
|
+
this.collapse = options.initial?.collapse ?? options.collapse ?? true;
|
|
131
|
+
}
|
|
132
|
+
/* ── Subscription ──────────────────────────────────────────────────────── */
|
|
133
|
+
subscribe = (listener) => {
|
|
134
|
+
this.listeners.add(listener);
|
|
135
|
+
return () => this.listeners.delete(listener);
|
|
136
|
+
};
|
|
137
|
+
getState = () => {
|
|
138
|
+
if (!this.snapshot) {
|
|
139
|
+
const counts = {
|
|
140
|
+
trace: 0,
|
|
141
|
+
debug: 0,
|
|
142
|
+
info: 0,
|
|
143
|
+
warn: 0,
|
|
144
|
+
error: 0
|
|
145
|
+
};
|
|
146
|
+
for (const record of this.records) counts[record.level] += 1;
|
|
147
|
+
this.snapshot = {
|
|
148
|
+
rows: this.rows(),
|
|
149
|
+
sources: this.sources(),
|
|
150
|
+
filter: this.filter,
|
|
151
|
+
counts,
|
|
152
|
+
total: this.records.length,
|
|
153
|
+
follow: this.follow,
|
|
154
|
+
collapse: this.collapse,
|
|
155
|
+
dropped: this.dropped,
|
|
156
|
+
droppedPatches: this.droppedPatches
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return this.snapshot;
|
|
160
|
+
};
|
|
161
|
+
notify() {
|
|
162
|
+
this.snapshot = null;
|
|
163
|
+
for (const listener of this.listeners) listener();
|
|
164
|
+
}
|
|
165
|
+
/* ── Ingest ────────────────────────────────────────────────────────────── */
|
|
166
|
+
/**
|
|
167
|
+
* Apply a delta.
|
|
168
|
+
*
|
|
169
|
+
* @param delta - Append / patch / replace / clear.
|
|
170
|
+
* @returns `true` if applied; `false` if dropped as stale.
|
|
171
|
+
*/
|
|
172
|
+
apply(delta) {
|
|
173
|
+
if (delta.rev !== void 0 && delta.rev <= this.rev) return false;
|
|
174
|
+
if (delta.rev !== void 0) this.rev = delta.rev;
|
|
175
|
+
if (delta.clear) {
|
|
176
|
+
this.records = delta.source ? this.records.filter((r) => r.source !== delta.source) : [];
|
|
177
|
+
this.dropped = 0;
|
|
178
|
+
this.droppedPatches = 0;
|
|
179
|
+
this.reindex();
|
|
180
|
+
} else if (delta.replace) {
|
|
181
|
+
const kept = delta.source ? this.records.filter((r) => r.source !== delta.source) : [];
|
|
182
|
+
this.records = [...kept, ...delta.replace.map((r) => this.sanitize(r))];
|
|
183
|
+
this.reindex();
|
|
184
|
+
}
|
|
185
|
+
if (delta.append && delta.append.length > 0) {
|
|
186
|
+
for (const record of delta.append) {
|
|
187
|
+
const clean = this.sanitize(record);
|
|
188
|
+
this.records.push(clean);
|
|
189
|
+
this.remember(clean.id, this.origin + this.records.length - 1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (delta.patch) this.applyPatches(delta.patch);
|
|
193
|
+
if (this.records.length > this.maxRecords) {
|
|
194
|
+
const over = this.records.length - this.maxRecords;
|
|
195
|
+
for (let i = 0; i < over; i += 1) this.forget(this.records[i].id, this.origin + i);
|
|
196
|
+
this.records.splice(0, over);
|
|
197
|
+
this.origin += over;
|
|
198
|
+
this.dropped += over;
|
|
199
|
+
}
|
|
200
|
+
this.notify();
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/* ── Update by id ──────────────────────────────────────────────────────── */
|
|
204
|
+
/**
|
|
205
|
+
* Apply the `patch` map of a delta.
|
|
206
|
+
*
|
|
207
|
+
* ⚠️ Every field is checked, not cast. A wire is a user-editable connection, so the wrong shape
|
|
208
|
+
* arriving is a normal mis-wire rather than an exceptional condition — and the failure mode here
|
|
209
|
+
* is not a throw but silent corruption: a `level` of `"shout"` would add a key to the level
|
|
210
|
+
* counts that no filter can ever match and no chip can ever reveal.
|
|
211
|
+
*/
|
|
212
|
+
applyPatches(patches) {
|
|
213
|
+
if (typeof patches !== "object" || Array.isArray(patches)) return;
|
|
214
|
+
for (const [id, patch] of Object.entries(patches)) {
|
|
215
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
216
|
+
this.droppedPatches += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const at = this.positionOf(id);
|
|
220
|
+
if (at < 0) {
|
|
221
|
+
this.droppedPatches += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
this.records[at] = this.merge(this.records[at], patch);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Fold a patch into a record, redacting what the patch brought in.
|
|
229
|
+
*
|
|
230
|
+
* 🔴 The append path redacts across the JOIN, not the fragment — {@link spliceRedactedAppend}
|
|
231
|
+
* carries the reasoning. Truncation runs last so the cap counts redacted characters; see
|
|
232
|
+
* {@link truncateLogMessage} for why measuring raw text would make the retained window vary.
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ `source` is deliberately NOT redacted, matching `sanitize` on the append path: it is a
|
|
235
|
+
* stream name the host chose, and redacting it would break the per-source mute chips it keys.
|
|
236
|
+
*/
|
|
237
|
+
merge(existing, patch) {
|
|
238
|
+
const secrets = [...this.host.getSecrets?.() ?? []];
|
|
239
|
+
const redact = (text) => redactForLog(text, secrets);
|
|
240
|
+
let message = existing.message;
|
|
241
|
+
if (typeof patch.message === "string") message = redact(patch.message);
|
|
242
|
+
if (typeof patch.appendMessage === "string" && patch.appendMessage.length > 0) {
|
|
243
|
+
const carry = Math.max(REDACTION_CARRY, ...secrets.map((s) => s.length));
|
|
244
|
+
message = spliceRedactedAppend(message, patch.appendMessage, redact, carry);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
...existing,
|
|
248
|
+
// `id` is restated from the EXISTING record, never the patch: it is the virtualized row key
|
|
249
|
+
// and the argument to recordActivate, and a patch that moved it would remount the row
|
|
250
|
+
// mid-stream and repoint the host's reveal hook at a different line.
|
|
251
|
+
id: existing.id,
|
|
252
|
+
message: truncateLogMessage(message, this.maxMessageChars),
|
|
253
|
+
level: LEVEL_ORDER.includes(patch.level) ? patch.level : existing.level,
|
|
254
|
+
ts: Number.isFinite(patch.ts) ? patch.ts : existing.ts,
|
|
255
|
+
source: typeof patch.source === "string" ? patch.source : existing.source,
|
|
256
|
+
scope: typeof patch.scope === "string" ? redact(patch.scope) : existing.scope,
|
|
257
|
+
// One rule for every field: an absent key means "leave it alone". To empty a payload, send
|
|
258
|
+
// `null` — `{ data: undefined }` is indistinguishable from omitting it.
|
|
259
|
+
data: patch.data !== void 0 ? redactForLog(patch.data, secrets) : existing.data,
|
|
260
|
+
// `count` is the panel's, set by collapsing. A patch cannot claim a line repeated.
|
|
261
|
+
count: existing.count
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/* ── The id index ──────────────────────────────────────────────────────── */
|
|
265
|
+
/** Array position of `id`, or `-1` when it is unknown or has been evicted. */
|
|
266
|
+
positionOf(id) {
|
|
267
|
+
const absolute = this.index.get(id);
|
|
268
|
+
if (absolute === void 0) return -1;
|
|
269
|
+
const at = absolute - this.origin;
|
|
270
|
+
return at >= 0 && at < this.records.length ? at : -1;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Index a record.
|
|
274
|
+
*
|
|
275
|
+
* ⚠️ Last-wins when a host re-uses an id, which the contract forbids but nothing prevents. For a
|
|
276
|
+
* PATCH that is the right answer — a producer that re-used an id is talking about the line it
|
|
277
|
+
* just wrote, not one that scrolled past — and it also keeps eviction correct, because the ring
|
|
278
|
+
* trims from the front and so retires the older twin first.
|
|
279
|
+
*/
|
|
280
|
+
remember(id, absolute) {
|
|
281
|
+
if (typeof id === "string" && id.length > 0) this.index.set(id, absolute);
|
|
282
|
+
}
|
|
283
|
+
/** Drop an index entry, but only if it still points at the position being evicted. */
|
|
284
|
+
forget(id, absolute) {
|
|
285
|
+
if (this.index.get(id) === absolute) this.index.delete(id);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Rebuild the whole index.
|
|
289
|
+
*
|
|
290
|
+
* Only for `clear` and `replace`, which remove from the middle and so invalidate every position
|
|
291
|
+
* after the hole. Those are cold paths — a source restarting — and paying O(n) there is what buys
|
|
292
|
+
* O(1) on the hot ones.
|
|
293
|
+
*/
|
|
294
|
+
reindex() {
|
|
295
|
+
this.index = /* @__PURE__ */ new Map();
|
|
296
|
+
this.origin = 0;
|
|
297
|
+
for (let i = 0; i < this.records.length; i += 1) this.remember(this.records[i].id, i);
|
|
298
|
+
}
|
|
299
|
+
/** Append records directly — sugar for the common case. */
|
|
300
|
+
append(records) {
|
|
301
|
+
return this.apply({ append: [...records] });
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Redact a record on the way IN.
|
|
305
|
+
*
|
|
306
|
+
* Not at render: redacting late leaves the secret in the buffer, in search results, and in
|
|
307
|
+
* whatever `copy` puts on the clipboard.
|
|
308
|
+
*/
|
|
309
|
+
sanitize(record) {
|
|
310
|
+
const secrets = this.host.getSecrets?.() ?? [];
|
|
311
|
+
return {
|
|
312
|
+
...record,
|
|
313
|
+
/*
|
|
314
|
+
* 🔴 Truncate on THIS path too, not only on the streaming merge.
|
|
315
|
+
*
|
|
316
|
+
* `maxRecords` bounds how many lines are held; nothing bounded how long ONE line is — and
|
|
317
|
+
* the omission was invisible, because the record count never moves. Measured before this
|
|
318
|
+
* fix: with `maxMessageChars: 2048`, an appended 1,080,000-character message stored all
|
|
319
|
+
* 1,080,000, while the same text arriving as `appendMessage` fragments stored 2,048. One
|
|
320
|
+
* ingest path honoured a documented cap and the other ignored it.
|
|
321
|
+
*
|
|
322
|
+
* ⚠️ Order matters: redact, THEN truncate. Truncating first can split a secret across the
|
|
323
|
+
* elision boundary so neither half matches the registered value, and a redactor that no
|
|
324
|
+
* longer recognises what it is looking at is worse than no cap.
|
|
325
|
+
*/
|
|
326
|
+
message: truncateLogMessage(redactForLog(record.message, secrets), this.maxMessageChars),
|
|
327
|
+
scope: record.scope === void 0 ? void 0 : redactForLog(record.scope, secrets),
|
|
328
|
+
data: record.data === void 0 ? void 0 : redactForLog(record.data, secrets)
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/* ── Filtering ─────────────────────────────────────────────────────────── */
|
|
332
|
+
/** Replace the filter. */
|
|
333
|
+
setFilter(filter) {
|
|
334
|
+
this.filter = filter;
|
|
335
|
+
this.notify();
|
|
336
|
+
}
|
|
337
|
+
/** Set the search term. */
|
|
338
|
+
setSearch(search) {
|
|
339
|
+
this.filter = { ...this.filter, search };
|
|
340
|
+
this.notify();
|
|
341
|
+
}
|
|
342
|
+
/** Set the minimum severity. */
|
|
343
|
+
setLevel(minLevel) {
|
|
344
|
+
if (minLevel !== void 0 && !LEVEL_ORDER.includes(minLevel)) return false;
|
|
345
|
+
this.filter = { ...this.filter, minLevel };
|
|
346
|
+
this.notify();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
/** Show or hide one source. */
|
|
350
|
+
toggleSource(source, enabled) {
|
|
351
|
+
const muted = new Set(this.filter.mutedSources ?? []);
|
|
352
|
+
const next = enabled ?? muted.has(source);
|
|
353
|
+
if (next) muted.delete(source);
|
|
354
|
+
else muted.add(source);
|
|
355
|
+
this.filter = { ...this.filter, mutedSources: [...muted] };
|
|
356
|
+
this.notify();
|
|
357
|
+
}
|
|
358
|
+
/** The visible rows, filtered and (optionally) collapsed. */
|
|
359
|
+
rows() {
|
|
360
|
+
const muted = new Set(this.filter.mutedSources ?? []);
|
|
361
|
+
const search = (this.filter.search ?? "").trim();
|
|
362
|
+
const visible2 = this.records.filter(
|
|
363
|
+
(r) => meetsLevel(r.level, this.filter.minLevel) && !muted.has(r.source ?? "") && matchesSearch(r, search)
|
|
364
|
+
);
|
|
365
|
+
return this.collapse ? collapseAdjacent(visible2) : visible2.map((r) => ({ ...r, count: 1 }));
|
|
366
|
+
}
|
|
367
|
+
/** Every source seen, with its contribution count and current visibility. */
|
|
368
|
+
sources() {
|
|
369
|
+
const muted = new Set(this.filter.mutedSources ?? []);
|
|
370
|
+
const counts = /* @__PURE__ */ new Map();
|
|
371
|
+
for (const record of this.records) {
|
|
372
|
+
const id = record.source ?? "";
|
|
373
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
374
|
+
}
|
|
375
|
+
return [...counts.entries()].map(([id, count]) => ({
|
|
376
|
+
id,
|
|
377
|
+
label: id === "" ? "(unsourced)" : id,
|
|
378
|
+
enabled: !muted.has(id),
|
|
379
|
+
count
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
/* ── Interaction ───────────────────────────────────────────────────────── */
|
|
383
|
+
/** Follow the tail. Auto-disabled by the view when the user scrolls up. */
|
|
384
|
+
setFollow(follow) {
|
|
385
|
+
if (this.follow === follow) return;
|
|
386
|
+
this.follow = follow;
|
|
387
|
+
this.notify();
|
|
388
|
+
}
|
|
389
|
+
/** Collapse adjacent duplicates. */
|
|
390
|
+
setCollapse(collapse) {
|
|
391
|
+
if (this.collapse === collapse) return;
|
|
392
|
+
this.collapse = collapse;
|
|
393
|
+
this.notify();
|
|
394
|
+
}
|
|
395
|
+
/** Drop every record. Emits nothing — clearing a view is not an event a host acts on. */
|
|
396
|
+
clear() {
|
|
397
|
+
this.records = [];
|
|
398
|
+
this.dropped = 0;
|
|
399
|
+
this.droppedPatches = 0;
|
|
400
|
+
this.reindex();
|
|
401
|
+
this.notify();
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* A record was opened.
|
|
405
|
+
*
|
|
406
|
+
* **This is what makes the panel useful in a builder**: clicking a log line tells the host which
|
|
407
|
+
* node or panel produced it, so it can reveal the offender.
|
|
408
|
+
*/
|
|
409
|
+
activate(recordId) {
|
|
410
|
+
const record = this.records.find((r) => r.id === recordId);
|
|
411
|
+
if (!record) return false;
|
|
412
|
+
const payload = {
|
|
413
|
+
recordId,
|
|
414
|
+
source: record.source,
|
|
415
|
+
scope: record.scope
|
|
416
|
+
};
|
|
417
|
+
this.host.emit("recordActivate", payload);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
/** The copyable text of a record — already redacted, because the buffer is. */
|
|
421
|
+
copyText(recordId) {
|
|
422
|
+
const record = this.records.find((r) => r.id === recordId);
|
|
423
|
+
return record ? formatRecord(record) : null;
|
|
424
|
+
}
|
|
425
|
+
/** The copyable text of everything currently visible. */
|
|
426
|
+
copyVisible() {
|
|
427
|
+
return this.rows().map(formatRecord).join("\n");
|
|
428
|
+
}
|
|
429
|
+
/** Ask the host to open a context menu. */
|
|
430
|
+
requestContextMenu(recordId, x, y) {
|
|
431
|
+
this.host.emit("contextMenuRequest", { recordId, x, y });
|
|
432
|
+
}
|
|
433
|
+
/** The raw buffer, for `get_records`. */
|
|
434
|
+
all() {
|
|
435
|
+
return [...this.records];
|
|
436
|
+
}
|
|
437
|
+
/* ── Persistence ───────────────────────────────────────────────────────── */
|
|
438
|
+
/** Serialize. Filter and toggles — **never the records**; a log is not a document. */
|
|
439
|
+
serialize() {
|
|
440
|
+
return { filter: this.filter, follow: this.follow, collapse: this.collapse };
|
|
441
|
+
}
|
|
442
|
+
/** Restore. Does not emit. */
|
|
443
|
+
deserialize(state) {
|
|
444
|
+
if (state.filter) this.filter = state.filter;
|
|
445
|
+
if (typeof state.follow === "boolean") this.follow = state.follow;
|
|
446
|
+
if (typeof state.collapse === "boolean") this.collapse = state.collapse;
|
|
447
|
+
this.notify();
|
|
448
|
+
}
|
|
449
|
+
/** Drop listeners and the buffer. */
|
|
450
|
+
dispose() {
|
|
451
|
+
this.listeners.clear();
|
|
452
|
+
this.records = [];
|
|
453
|
+
this.index = /* @__PURE__ */ new Map();
|
|
454
|
+
this.origin = 0;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// src/ops/console/manifest.ts
|
|
459
|
+
var CONSOLE_PANEL_ID = "xeno.core.console";
|
|
460
|
+
var consoleManifest = {
|
|
461
|
+
id: CONSOLE_PANEL_ID,
|
|
462
|
+
version: "0.1.0",
|
|
463
|
+
title: "Console",
|
|
464
|
+
icon: "terminal",
|
|
465
|
+
description: "Structured log records from multiplexed sources: severity filters, search, adjacent-duplicate collapsing, follow-tail and virtualization. Every record is redacted on the way in.",
|
|
466
|
+
defaultSlot: "bottom",
|
|
467
|
+
inputs: [
|
|
468
|
+
{
|
|
469
|
+
id: "records",
|
|
470
|
+
name: "Records",
|
|
471
|
+
type: "object",
|
|
472
|
+
description: "An APPEND-oriented delta: {rev?, append?, patch?, replace?, clear?, source?}. A console that re-pushes its whole buffer per line is a non-starter. `patch` updates records BY ID \u2014 the channel a line that is still being written needs; a patch for an id the buffer does not hold is dropped, never upserted.",
|
|
473
|
+
// Fan-in by design: multiplexing several streams into one view is the point.
|
|
474
|
+
multiple: true
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
id: "filter",
|
|
478
|
+
name: "Filter",
|
|
479
|
+
type: "object",
|
|
480
|
+
description: "An externally set filter {minLevel?, search?, mutedSources?}.",
|
|
481
|
+
multiple: false
|
|
482
|
+
}
|
|
483
|
+
],
|
|
484
|
+
outputs: [
|
|
485
|
+
{
|
|
486
|
+
id: "recordActivate",
|
|
487
|
+
name: "Record Activated",
|
|
488
|
+
type: "object",
|
|
489
|
+
description: "{recordId, source?, scope?} \u2014 a log line was opened, so the host can reveal the node or panel that produced it. This is what makes the panel useful in a builder."
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
id: "contextMenuRequest",
|
|
493
|
+
name: "Context Menu Request",
|
|
494
|
+
type: "object",
|
|
495
|
+
description: "The host opens its own menu."
|
|
496
|
+
}
|
|
497
|
+
],
|
|
498
|
+
commands: [
|
|
499
|
+
{
|
|
500
|
+
id: "get_records",
|
|
501
|
+
title: "Get Records",
|
|
502
|
+
description: "Return the buffer. Already redacted \u2014 the panel never holds an unredacted value.",
|
|
503
|
+
parameters: {}
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: "filter",
|
|
507
|
+
title: "Filter",
|
|
508
|
+
description: "Set the search term.",
|
|
509
|
+
parameters: { search: { type: "string", description: "Substring over message/source/scope" } }
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
id: "set_level",
|
|
513
|
+
title: "Set Level",
|
|
514
|
+
description: "Set the minimum severity. Omit to show everything.",
|
|
515
|
+
parameters: { level: { type: "string", description: "trace|debug|info|warn|error" } }
|
|
516
|
+
},
|
|
517
|
+
{ id: "clear", title: "Clear", description: "Drop every record.", parameters: {} },
|
|
518
|
+
{
|
|
519
|
+
id: "follow_tail",
|
|
520
|
+
title: "Follow Tail",
|
|
521
|
+
description: "Stick to the newest record.",
|
|
522
|
+
parameters: { follow: { type: "boolean", description: "Desired state" } }
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: "reveal_source",
|
|
526
|
+
title: "Reveal Source",
|
|
527
|
+
description: "Emit recordActivate for a record, so the host can reveal its origin.",
|
|
528
|
+
parameters: { recordId: { type: "string", description: "Record id", required: true } }
|
|
529
|
+
}
|
|
530
|
+
],
|
|
531
|
+
config: [
|
|
532
|
+
{
|
|
533
|
+
key: "maxRecords",
|
|
534
|
+
label: "Buffer size",
|
|
535
|
+
type: "number",
|
|
536
|
+
defaultValue: 5e3,
|
|
537
|
+
description: "Ring-buffer capacity. A console is a window on recent output, not an archive.",
|
|
538
|
+
placeholder: "5000"
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
key: "maxMessageChars",
|
|
542
|
+
label: "Max line length",
|
|
543
|
+
type: "number",
|
|
544
|
+
defaultValue: 65536,
|
|
545
|
+
description: "Cap on ONE record\u2019s message, so a producer streaming into a single line cannot grow without bound while the record count stays still. The newest text is kept.",
|
|
546
|
+
placeholder: "65536"
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
key: "rowHeight",
|
|
550
|
+
label: "Row height",
|
|
551
|
+
type: "number",
|
|
552
|
+
defaultValue: 20,
|
|
553
|
+
description: "Fixed \u2014 what keeps virtualization O(1). No incumbent virtualizes at all.",
|
|
554
|
+
placeholder: "20"
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
key: "collapse",
|
|
558
|
+
label: "Collapse duplicates",
|
|
559
|
+
type: "boolean",
|
|
560
|
+
defaultValue: true,
|
|
561
|
+
description: "Collapse ADJACENT identical records into one row with a count. Adjacent-run, not global \u2014 collapsing globally would hide when a recurrence happened."
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
key: "follow",
|
|
565
|
+
label: "Follow tail",
|
|
566
|
+
type: "boolean",
|
|
567
|
+
defaultValue: true,
|
|
568
|
+
description: "Stick to the newest record. Auto-disabled when the user scrolls up."
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
key: "minLevel",
|
|
572
|
+
label: "Minimum level",
|
|
573
|
+
type: "select",
|
|
574
|
+
options: [
|
|
575
|
+
{ label: "Trace", value: "trace" },
|
|
576
|
+
{ label: "Debug", value: "debug" },
|
|
577
|
+
{ label: "Info", value: "info" },
|
|
578
|
+
{ label: "Warn", value: "warn" },
|
|
579
|
+
{ label: "Error", value: "error" }
|
|
580
|
+
],
|
|
581
|
+
description: "Hide anything less severe."
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
key: "showTimestamps",
|
|
585
|
+
label: "Timestamps",
|
|
586
|
+
type: "boolean",
|
|
587
|
+
defaultValue: true,
|
|
588
|
+
description: "Render a time column."
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
key: "emptyHint",
|
|
592
|
+
label: "Empty hint",
|
|
593
|
+
type: "text",
|
|
594
|
+
description: "Say what would produce output, not that there is none.",
|
|
595
|
+
placeholder: "Run the graph to see its output here."
|
|
596
|
+
}
|
|
597
|
+
],
|
|
598
|
+
capabilities: ["storage.local"],
|
|
599
|
+
sdk: "^1.1.0"
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// src/ops/console/react/ConsolePanelView.tsx
|
|
603
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
604
|
+
import {
|
|
605
|
+
Badge,
|
|
606
|
+
EmptyState,
|
|
607
|
+
IconButton,
|
|
608
|
+
Row,
|
|
609
|
+
RowList,
|
|
610
|
+
ScrollArea,
|
|
611
|
+
SearchField,
|
|
612
|
+
SegmentedControl,
|
|
613
|
+
StatusBar,
|
|
614
|
+
Toolbar,
|
|
615
|
+
ToolbarGroup
|
|
616
|
+
} from "@xenosystem/workbench/primitives/react";
|
|
617
|
+
import { computeWindow } from "@xenosystem/tree-core";
|
|
618
|
+
|
|
619
|
+
// src/ops/console/react/icons.tsx
|
|
620
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
621
|
+
function Svg({ children }) {
|
|
622
|
+
return /* @__PURE__ */ jsx(
|
|
623
|
+
"svg",
|
|
624
|
+
{
|
|
625
|
+
width: 11,
|
|
626
|
+
height: 11,
|
|
627
|
+
viewBox: "0 0 24 24",
|
|
628
|
+
fill: "none",
|
|
629
|
+
stroke: "currentColor",
|
|
630
|
+
strokeWidth: "1.5",
|
|
631
|
+
strokeLinecap: "round",
|
|
632
|
+
strokeLinejoin: "round",
|
|
633
|
+
"aria-hidden": "true",
|
|
634
|
+
children
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
function CopyGlyph() {
|
|
639
|
+
return /* @__PURE__ */ jsxs(Svg, { children: [
|
|
640
|
+
/* @__PURE__ */ jsx("rect", { x: "9", y: "9", width: "12", height: "12", rx: "2" }),
|
|
641
|
+
/* @__PURE__ */ jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
|
|
642
|
+
] });
|
|
643
|
+
}
|
|
644
|
+
function ClearGlyph() {
|
|
645
|
+
return /* @__PURE__ */ jsxs(Svg, { children: [
|
|
646
|
+
/* @__PURE__ */ jsx("rect", { x: "3", y: "3", width: "18", height: "18", rx: "3" }),
|
|
647
|
+
/* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })
|
|
648
|
+
] });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/ops/console/react/ConsolePanelView.tsx
|
|
652
|
+
import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
653
|
+
var TONE = {
|
|
654
|
+
trace: "neutral",
|
|
655
|
+
debug: "neutral",
|
|
656
|
+
info: "info",
|
|
657
|
+
warn: "warning",
|
|
658
|
+
error: "error"
|
|
659
|
+
};
|
|
660
|
+
function ConsolePanelView({
|
|
661
|
+
controller,
|
|
662
|
+
rowHeight = 20,
|
|
663
|
+
showTimestamps = true,
|
|
664
|
+
emptyHint
|
|
665
|
+
}) {
|
|
666
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
|
|
667
|
+
const scrollRef = useRef(null);
|
|
668
|
+
const [viewport, setViewport] = useState({ height: 0, scrollTop: 0 });
|
|
669
|
+
const [expanded, setExpanded] = useState(null);
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
const element = scrollRef.current;
|
|
672
|
+
if (!element || typeof ResizeObserver === "undefined") return;
|
|
673
|
+
const observer = new ResizeObserver(() => setViewport((v) => ({ ...v, height: element.clientHeight })));
|
|
674
|
+
observer.observe(element);
|
|
675
|
+
setViewport((v) => ({ ...v, height: element.clientHeight }));
|
|
676
|
+
return () => observer.disconnect();
|
|
677
|
+
}, []);
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
const element = scrollRef.current;
|
|
680
|
+
if (!element || !state.follow) return;
|
|
681
|
+
element.scrollTop = element.scrollHeight;
|
|
682
|
+
}, [state.rows.length, state.follow]);
|
|
683
|
+
const onScroll = useCallback(
|
|
684
|
+
(event) => {
|
|
685
|
+
const el = event.currentTarget;
|
|
686
|
+
setViewport((v) => ({ ...v, scrollTop: el.scrollTop }));
|
|
687
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight;
|
|
688
|
+
if (state.follow && !atBottom) controller.setFollow(false);
|
|
689
|
+
else if (!state.follow && atBottom) controller.setFollow(true);
|
|
690
|
+
},
|
|
691
|
+
[controller, rowHeight, state.follow]
|
|
692
|
+
);
|
|
693
|
+
const window = computeWindow({
|
|
694
|
+
scrollTop: viewport.scrollTop,
|
|
695
|
+
viewportHeight: viewport.height || rowHeight * 20,
|
|
696
|
+
rowHeight,
|
|
697
|
+
rowCount: state.rows.length
|
|
698
|
+
});
|
|
699
|
+
const slice = state.rows.slice(window.startIndex, window.endIndex);
|
|
700
|
+
return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
|
|
701
|
+
/* @__PURE__ */ jsx2(
|
|
702
|
+
Toolbar,
|
|
703
|
+
{
|
|
704
|
+
left: /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
705
|
+
/* @__PURE__ */ jsx2(
|
|
706
|
+
SearchField,
|
|
707
|
+
{
|
|
708
|
+
value: state.filter.search ?? "",
|
|
709
|
+
onChange: (term) => controller.setSearch(term),
|
|
710
|
+
placeholder: "Filter\u2026"
|
|
711
|
+
}
|
|
712
|
+
),
|
|
713
|
+
/* @__PURE__ */ jsx2(
|
|
714
|
+
SegmentedControl,
|
|
715
|
+
{
|
|
716
|
+
size: "sm",
|
|
717
|
+
label: "Minimum level",
|
|
718
|
+
options: LEVEL_ORDER.map((l) => ({ value: l, label: l.slice(0, 1).toUpperCase() })),
|
|
719
|
+
value: state.filter.minLevel ?? "trace",
|
|
720
|
+
onChange: (level) => controller.setLevel(level === "trace" ? void 0 : level)
|
|
721
|
+
}
|
|
722
|
+
)
|
|
723
|
+
] }),
|
|
724
|
+
right: /* @__PURE__ */ jsxs2(ToolbarGroup, { end: true, children: [
|
|
725
|
+
state.counts.error > 0 ? /* @__PURE__ */ jsx2(Badge, { tone: "error", children: String(state.counts.error) }) : null,
|
|
726
|
+
state.counts.warn > 0 ? /* @__PURE__ */ jsx2(Badge, { tone: "warning", children: String(state.counts.warn) }) : null,
|
|
727
|
+
/* @__PURE__ */ jsx2(
|
|
728
|
+
IconButton,
|
|
729
|
+
{
|
|
730
|
+
icon: /* @__PURE__ */ jsx2(CopyGlyph, {}),
|
|
731
|
+
label: "Copy visible",
|
|
732
|
+
onClick: () => {
|
|
733
|
+
void navigator?.clipboard?.writeText(controller.copyVisible());
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
),
|
|
737
|
+
/* @__PURE__ */ jsx2(IconButton, { icon: /* @__PURE__ */ jsx2(ClearGlyph, {}), label: "Clear", onClick: () => controller.clear() })
|
|
738
|
+
] })
|
|
739
|
+
}
|
|
740
|
+
),
|
|
741
|
+
state.sources.length > 1 ? /* @__PURE__ */ jsx2("div", { style: { display: "flex", gap: 4, padding: "2px 6px", flexWrap: "wrap" }, children: state.sources.map((source) => /* @__PURE__ */ jsx2(
|
|
742
|
+
"span",
|
|
743
|
+
{
|
|
744
|
+
onClick: () => controller.toggleSource(source.id),
|
|
745
|
+
style: { cursor: "pointer", opacity: source.enabled ? 1 : 0.4 },
|
|
746
|
+
children: /* @__PURE__ */ jsx2(Badge, { title: `${source.count} records`, children: source.label ?? source.id })
|
|
747
|
+
},
|
|
748
|
+
source.id
|
|
749
|
+
)) }) : null,
|
|
750
|
+
state.rows.length === 0 ? /* @__PURE__ */ jsx2(
|
|
751
|
+
EmptyState,
|
|
752
|
+
{
|
|
753
|
+
title: state.total > 0 ? "No matches" : "No output",
|
|
754
|
+
hint: state.total > 0 ? "Nothing matches the current filter." : emptyHint ?? "Run something to see its output here."
|
|
755
|
+
}
|
|
756
|
+
) : /* @__PURE__ */ jsx2(ScrollArea, { children: /* @__PURE__ */ jsx2("div", { ref: scrollRef, onScroll, style: { height: "100%", overflow: "auto" }, children: /* @__PURE__ */ jsx2("div", { style: { height: window.totalHeight, position: "relative" }, children: /* @__PURE__ */ jsx2("div", { style: { transform: `translateY(${window.offsetTop}px)` }, children: /* @__PURE__ */ jsx2(RowList, { role: "log", children: slice.map((record) => /* @__PURE__ */ jsx2(
|
|
757
|
+
LogRow,
|
|
758
|
+
{
|
|
759
|
+
record,
|
|
760
|
+
controller,
|
|
761
|
+
rowHeight,
|
|
762
|
+
showTimestamps,
|
|
763
|
+
expanded: expanded === record.id,
|
|
764
|
+
onToggle: () => setExpanded(expanded === record.id ? null : record.id)
|
|
765
|
+
},
|
|
766
|
+
record.id
|
|
767
|
+
)) }) }) }) }) }),
|
|
768
|
+
/* @__PURE__ */ jsx2(
|
|
769
|
+
StatusBar,
|
|
770
|
+
{
|
|
771
|
+
left: `${state.rows.length} of ${state.total}` + (state.dropped > 0 ? ` \xB7 ${state.dropped} dropped` : "") + // A patch that addressed nothing is the one streaming failure with no visible symptom:
|
|
772
|
+
// the producer sees no line grow, and a mis-keyed wire looks exactly like a dead one.
|
|
773
|
+
(state.droppedPatches > 0 ? ` \xB7 ${state.droppedPatches} unmatched` : ""),
|
|
774
|
+
right: state.follow ? "following" : void 0
|
|
775
|
+
}
|
|
776
|
+
)
|
|
777
|
+
] });
|
|
778
|
+
}
|
|
779
|
+
function LogRow({
|
|
780
|
+
record,
|
|
781
|
+
controller,
|
|
782
|
+
rowHeight,
|
|
783
|
+
showTimestamps,
|
|
784
|
+
expanded,
|
|
785
|
+
onToggle
|
|
786
|
+
}) {
|
|
787
|
+
return /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
788
|
+
/* @__PURE__ */ jsx2(
|
|
789
|
+
Row,
|
|
790
|
+
{
|
|
791
|
+
style: { height: rowHeight, alignItems: "flex-start" },
|
|
792
|
+
noIcon: true,
|
|
793
|
+
onClick: () => {
|
|
794
|
+
if (record.data !== void 0) onToggle();
|
|
795
|
+
controller.activate(record.id);
|
|
796
|
+
},
|
|
797
|
+
onContextMenu: (e) => {
|
|
798
|
+
e.preventDefault();
|
|
799
|
+
controller.requestContextMenu(record.id, e.clientX, e.clientY);
|
|
800
|
+
},
|
|
801
|
+
label: /* @__PURE__ */ jsxs2("span", { style: { display: "flex", gap: 6, alignItems: "center", minWidth: 0 }, children: [
|
|
802
|
+
showTimestamps ? /* @__PURE__ */ jsx2("span", { style: { opacity: 0.45, fontVariantNumeric: "tabular-nums", flex: "0 0 auto" }, children: new Date(record.ts).toISOString().slice(11, 23) }) : null,
|
|
803
|
+
/* @__PURE__ */ jsx2(Badge, { tone: TONE[record.level], children: record.level }),
|
|
804
|
+
record.source ? /* @__PURE__ */ jsx2("span", { style: { opacity: 0.55 }, children: record.source }) : null,
|
|
805
|
+
/* @__PURE__ */ jsx2(
|
|
806
|
+
"span",
|
|
807
|
+
{
|
|
808
|
+
style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
|
|
809
|
+
children: record.message
|
|
810
|
+
}
|
|
811
|
+
)
|
|
812
|
+
] }),
|
|
813
|
+
trailing: (record.count ?? 1) > 1 ? (
|
|
814
|
+
// The best feature in any incumbent: a run of identical lines is one row with a count.
|
|
815
|
+
/* @__PURE__ */ jsx2(Badge, { title: "Repeated consecutively", children: `x${record.count}` })
|
|
816
|
+
) : null
|
|
817
|
+
}
|
|
818
|
+
),
|
|
819
|
+
expanded && record.data !== void 0 ? /* @__PURE__ */ jsx2(
|
|
820
|
+
"pre",
|
|
821
|
+
{
|
|
822
|
+
style: {
|
|
823
|
+
margin: 0,
|
|
824
|
+
padding: "2px 10px 6px 24px",
|
|
825
|
+
fontSize: 10,
|
|
826
|
+
lineHeight: 1.4,
|
|
827
|
+
whiteSpace: "pre-wrap",
|
|
828
|
+
wordBreak: "break-all",
|
|
829
|
+
opacity: 0.8
|
|
830
|
+
},
|
|
831
|
+
children: safeJson(record.data)
|
|
832
|
+
}
|
|
833
|
+
) : null
|
|
834
|
+
] });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/ops/console/panel.ts
|
|
838
|
+
function createConsolePanel(options = {}) {
|
|
839
|
+
return {
|
|
840
|
+
manifest: consoleManifest,
|
|
841
|
+
activate(host) {
|
|
842
|
+
const config = host.config ?? {};
|
|
843
|
+
const controller = new ConsoleController({
|
|
844
|
+
host: {
|
|
845
|
+
emit: (portId, value) => host.emit(portId, value),
|
|
846
|
+
getSecrets: options.getSecrets
|
|
847
|
+
},
|
|
848
|
+
maxRecords: numberConfig(config.maxRecords, 5e3),
|
|
849
|
+
maxMessageChars: numberConfig(config.maxMessageChars, 65536),
|
|
850
|
+
collapse: config.collapse !== false,
|
|
851
|
+
follow: config.follow !== false,
|
|
852
|
+
initial: {
|
|
853
|
+
filter: typeof config.minLevel === "string" ? { minLevel: config.minLevel } : {}
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
const resolve = (config2) => ({
|
|
857
|
+
rowHeight: numberConfig(config2.rowHeight, 20),
|
|
858
|
+
showTimestamps: config2.showTimestamps !== false,
|
|
859
|
+
emptyHint: typeof config2.emptyHint === "string" ? config2.emptyHint : void 0
|
|
860
|
+
});
|
|
861
|
+
let renderConfig = resolve(host.config ?? {});
|
|
862
|
+
let unrender = null;
|
|
863
|
+
let root = null;
|
|
864
|
+
let currentEl = null;
|
|
865
|
+
const draw = (el) => {
|
|
866
|
+
unrender?.();
|
|
867
|
+
if (options.render) {
|
|
868
|
+
unrender = options.render(el, { controller, config: renderConfig });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
root = createRoot(el);
|
|
872
|
+
root.render(createElement(ConsolePanelView, { controller, ...renderConfig }));
|
|
873
|
+
unrender = () => {
|
|
874
|
+
root?.unmount();
|
|
875
|
+
root = null;
|
|
876
|
+
};
|
|
877
|
+
};
|
|
878
|
+
const unbindConfig = bindConfig(host, (config2) => {
|
|
879
|
+
renderConfig = resolve(config2);
|
|
880
|
+
if (currentEl) draw(currentEl);
|
|
881
|
+
});
|
|
882
|
+
return {
|
|
883
|
+
render(el) {
|
|
884
|
+
currentEl = el;
|
|
885
|
+
draw(el);
|
|
886
|
+
},
|
|
887
|
+
onInput(portId, value) {
|
|
888
|
+
if (portId === "records") {
|
|
889
|
+
if (Array.isArray(value)) controller.apply({ append: value });
|
|
890
|
+
else if (isRecord(value)) controller.apply(value);
|
|
891
|
+
} else if (portId === "filter") {
|
|
892
|
+
if (isRecord(value)) controller.setFilter(value);
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
async onCommand(commandId, params) {
|
|
896
|
+
switch (commandId) {
|
|
897
|
+
case "get_records":
|
|
898
|
+
return controller.all();
|
|
899
|
+
case "filter":
|
|
900
|
+
controller.setSearch(String(params.search ?? ""));
|
|
901
|
+
return true;
|
|
902
|
+
case "set_level":
|
|
903
|
+
return controller.setLevel(
|
|
904
|
+
typeof params.level === "string" ? params.level : void 0
|
|
905
|
+
);
|
|
906
|
+
case "clear":
|
|
907
|
+
controller.clear();
|
|
908
|
+
return true;
|
|
909
|
+
case "follow_tail":
|
|
910
|
+
controller.setFollow(params.follow !== false);
|
|
911
|
+
return true;
|
|
912
|
+
case "reveal_source":
|
|
913
|
+
return controller.activate(String(params.recordId));
|
|
914
|
+
default:
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
serialize() {
|
|
919
|
+
return controller.serialize();
|
|
920
|
+
},
|
|
921
|
+
deserialize(state) {
|
|
922
|
+
controller.deserialize(state ?? {});
|
|
923
|
+
},
|
|
924
|
+
dispose() {
|
|
925
|
+
unbindConfig();
|
|
926
|
+
currentEl = null;
|
|
927
|
+
unrender?.();
|
|
928
|
+
unrender = null;
|
|
929
|
+
controller.dispose();
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
function numberConfig(value, fallback) {
|
|
936
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
937
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
938
|
+
}
|
|
939
|
+
var consolePanel = createConsolePanel();
|
|
940
|
+
|
|
941
|
+
// src/ops/runs/panel.ts
|
|
942
|
+
import { createElement as createElement2 } from "react";
|
|
943
|
+
import { createRoot as createRoot2 } from "react-dom/client";
|
|
944
|
+
import {
|
|
945
|
+
isRecord as isRecord2,
|
|
946
|
+
bindConfig as bindConfig2
|
|
947
|
+
} from "@xenosystem/panel-sdk";
|
|
948
|
+
|
|
949
|
+
// src/ops/runs/controller.ts
|
|
950
|
+
import { createThrottle } from "@xenosystem/tree-core";
|
|
951
|
+
|
|
952
|
+
// src/ops/runs/types.ts
|
|
953
|
+
var RUN_STATUSES = [
|
|
954
|
+
"requested",
|
|
955
|
+
"queued",
|
|
956
|
+
"running",
|
|
957
|
+
"paused",
|
|
958
|
+
"blocked",
|
|
959
|
+
"awaiting-decision",
|
|
960
|
+
"awaiting-permission",
|
|
961
|
+
"succeeded",
|
|
962
|
+
"failed",
|
|
963
|
+
"cancelled"
|
|
964
|
+
];
|
|
965
|
+
var RUN_LIFECYCLE = {
|
|
966
|
+
requested: "active",
|
|
967
|
+
queued: "active",
|
|
968
|
+
running: "active",
|
|
969
|
+
paused: "active",
|
|
970
|
+
blocked: "waiting",
|
|
971
|
+
"awaiting-decision": "waiting",
|
|
972
|
+
"awaiting-permission": "waiting",
|
|
973
|
+
succeeded: "terminal",
|
|
974
|
+
failed: "terminal",
|
|
975
|
+
cancelled: "terminal"
|
|
976
|
+
};
|
|
977
|
+
function lifecycleOf(status) {
|
|
978
|
+
return RUN_LIFECYCLE[status] ?? "waiting";
|
|
979
|
+
}
|
|
980
|
+
var TERMINAL_STATUSES = new Set(
|
|
981
|
+
RUN_STATUSES.filter((status) => RUN_LIFECYCLE[status] === "terminal")
|
|
982
|
+
);
|
|
983
|
+
function isTerminal(status) {
|
|
984
|
+
return TERMINAL_STATUSES.has(status);
|
|
985
|
+
}
|
|
986
|
+
function isActive(status) {
|
|
987
|
+
return lifecycleOf(status) === "active";
|
|
988
|
+
}
|
|
989
|
+
function isWaiting(status) {
|
|
990
|
+
return lifecycleOf(status) === "waiting";
|
|
991
|
+
}
|
|
992
|
+
var RUN_PATH_SEPARATOR = "\0";
|
|
993
|
+
function encodeRunPath(path) {
|
|
994
|
+
return path.join(RUN_PATH_SEPARATOR);
|
|
995
|
+
}
|
|
996
|
+
function decodeRunPath(key) {
|
|
997
|
+
return key.split(RUN_PATH_SEPARATOR);
|
|
998
|
+
}
|
|
999
|
+
var DEFAULT_MAX_STEP_DEPTH = 32;
|
|
1000
|
+
var ALL_STATUSES = RUN_STATUSES;
|
|
1001
|
+
function canCancel(run) {
|
|
1002
|
+
if (run.canCancel !== void 0) return run.canCancel;
|
|
1003
|
+
return !isTerminal(run.status);
|
|
1004
|
+
}
|
|
1005
|
+
function canRetry(run) {
|
|
1006
|
+
if (run.canRetry !== void 0) return run.canRetry;
|
|
1007
|
+
if (run.status !== "failed" && run.status !== "cancelled") return false;
|
|
1008
|
+
return run.error?.retryable !== false;
|
|
1009
|
+
}
|
|
1010
|
+
function durationOf(run, now) {
|
|
1011
|
+
const from = run.executionStartedAt ?? run.startedAt;
|
|
1012
|
+
if (run.endedAt !== void 0) return Math.max(0, run.endedAt - from);
|
|
1013
|
+
if (isTerminal(run.status)) return null;
|
|
1014
|
+
return Math.max(0, now - from);
|
|
1015
|
+
}
|
|
1016
|
+
function queueDurationOf(run, now) {
|
|
1017
|
+
if (run.executionStartedAt !== void 0) {
|
|
1018
|
+
return Math.max(0, run.executionStartedAt - run.startedAt);
|
|
1019
|
+
}
|
|
1020
|
+
if (isTerminal(run.status)) return null;
|
|
1021
|
+
if (isActive(run.status) && run.status !== "requested" && run.status !== "queued") return null;
|
|
1022
|
+
return Math.max(0, now - run.startedAt);
|
|
1023
|
+
}
|
|
1024
|
+
function matchesSearch2(run, term) {
|
|
1025
|
+
if (!term) return true;
|
|
1026
|
+
const needle = term.toLowerCase();
|
|
1027
|
+
return run.label.toLowerCase().includes(needle) || (run.source ?? "").toLowerCase().includes(needle) || (run.trigger ?? "").toLowerCase().includes(needle) || (run.error?.code ?? "").toLowerCase().includes(needle);
|
|
1028
|
+
}
|
|
1029
|
+
function computeStats(runs) {
|
|
1030
|
+
let terminal = 0;
|
|
1031
|
+
let active = 0;
|
|
1032
|
+
let waiting = 0;
|
|
1033
|
+
let succeeded = 0;
|
|
1034
|
+
let failed = 0;
|
|
1035
|
+
let cancelled = 0;
|
|
1036
|
+
let durationSum = 0;
|
|
1037
|
+
let durationCount = 0;
|
|
1038
|
+
for (const run of runs) {
|
|
1039
|
+
if (isTerminal(run.status)) {
|
|
1040
|
+
terminal += 1;
|
|
1041
|
+
if (run.status === "succeeded") succeeded += 1;
|
|
1042
|
+
else if (run.status === "failed") failed += 1;
|
|
1043
|
+
else cancelled += 1;
|
|
1044
|
+
if (run.endedAt !== void 0) {
|
|
1045
|
+
durationSum += Math.max(0, run.endedAt - (run.executionStartedAt ?? run.startedAt));
|
|
1046
|
+
durationCount += 1;
|
|
1047
|
+
}
|
|
1048
|
+
} else if (isActive(run.status)) {
|
|
1049
|
+
active += 1;
|
|
1050
|
+
} else {
|
|
1051
|
+
waiting += 1;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
total: runs.length,
|
|
1056
|
+
terminal,
|
|
1057
|
+
active,
|
|
1058
|
+
waiting,
|
|
1059
|
+
succeeded,
|
|
1060
|
+
failed,
|
|
1061
|
+
cancelled,
|
|
1062
|
+
// OVER TERMINAL RUNS ONLY. Counting in-flight runs makes the rate dip every time work starts.
|
|
1063
|
+
successRate: terminal === 0 ? null : succeeded / terminal,
|
|
1064
|
+
meanDurationMs: durationCount === 0 ? null : durationSum / durationCount
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function formatDuration(ms) {
|
|
1068
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
1069
|
+
const seconds = ms / 1e3;
|
|
1070
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
1071
|
+
const s = Math.floor(seconds % 60);
|
|
1072
|
+
const m = Math.floor(seconds / 60 % 60);
|
|
1073
|
+
const h = Math.floor(seconds / 3600);
|
|
1074
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1075
|
+
return h > 0 ? `${h}h ${pad(m)}m` : `${m}m ${pad(s)}s`;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// src/ops/runs/steps.ts
|
|
1079
|
+
function resolveStepPath(steps, path) {
|
|
1080
|
+
if (!steps || path.length === 0) return null;
|
|
1081
|
+
let level = steps;
|
|
1082
|
+
let found = null;
|
|
1083
|
+
for (const id of path) {
|
|
1084
|
+
const next = level?.find((step) => step.id === id);
|
|
1085
|
+
if (!next) return null;
|
|
1086
|
+
found = next;
|
|
1087
|
+
level = next.steps;
|
|
1088
|
+
}
|
|
1089
|
+
return found;
|
|
1090
|
+
}
|
|
1091
|
+
function patchStepAtPath(steps, path, patch) {
|
|
1092
|
+
if (!steps || path.length === 0) return null;
|
|
1093
|
+
const index = steps.findIndex((step) => step.id === path[0]);
|
|
1094
|
+
if (index < 0) return null;
|
|
1095
|
+
const target = steps[index];
|
|
1096
|
+
let replacement;
|
|
1097
|
+
if (path.length === 1) {
|
|
1098
|
+
replacement = { ...target, ...patch, id: target.id };
|
|
1099
|
+
} else {
|
|
1100
|
+
const children = patchStepAtPath(target.steps, path.slice(1), patch);
|
|
1101
|
+
if (!children) return null;
|
|
1102
|
+
replacement = { ...target, steps: children };
|
|
1103
|
+
}
|
|
1104
|
+
const next = steps.slice();
|
|
1105
|
+
next[index] = replacement;
|
|
1106
|
+
return next;
|
|
1107
|
+
}
|
|
1108
|
+
function appendStepsAtPath(steps, path, incoming) {
|
|
1109
|
+
if (path.length === 0) return upsert(steps ?? [], incoming);
|
|
1110
|
+
if (!steps) return null;
|
|
1111
|
+
const index = steps.findIndex((step) => step.id === path[0]);
|
|
1112
|
+
if (index < 0) return null;
|
|
1113
|
+
const target = steps[index];
|
|
1114
|
+
const children = appendStepsAtPath(target.steps, path.slice(1), incoming);
|
|
1115
|
+
if (!children) return null;
|
|
1116
|
+
const next = steps.slice();
|
|
1117
|
+
next[index] = { ...target, steps: children };
|
|
1118
|
+
return next;
|
|
1119
|
+
}
|
|
1120
|
+
function isRunStep(value) {
|
|
1121
|
+
return typeof value === "object" && value !== null && typeof value.id === "string";
|
|
1122
|
+
}
|
|
1123
|
+
function upsert(existing, incoming) {
|
|
1124
|
+
const next = existing.slice();
|
|
1125
|
+
for (const step of incoming) {
|
|
1126
|
+
if (!isRunStep(step)) continue;
|
|
1127
|
+
const at = next.findIndex((candidate) => isRunStep(candidate) && candidate.id === step.id);
|
|
1128
|
+
if (at >= 0) next[at] = step;
|
|
1129
|
+
else next.push(step);
|
|
1130
|
+
}
|
|
1131
|
+
return next;
|
|
1132
|
+
}
|
|
1133
|
+
function flattenRunRows(runs, options) {
|
|
1134
|
+
const maxDepth = Math.max(0, options.maxDepth ?? DEFAULT_MAX_STEP_DEPTH);
|
|
1135
|
+
const rows = [];
|
|
1136
|
+
const usedKeys = /* @__PURE__ */ new Set();
|
|
1137
|
+
const keyFor = (path) => {
|
|
1138
|
+
const base = encodeRunPath(path);
|
|
1139
|
+
if (!usedKeys.has(base)) {
|
|
1140
|
+
usedKeys.add(base);
|
|
1141
|
+
return base;
|
|
1142
|
+
}
|
|
1143
|
+
let n = 2;
|
|
1144
|
+
while (usedKeys.has(`${base}#${n}`)) n += 1;
|
|
1145
|
+
const disambiguated = `${base}#${n}`;
|
|
1146
|
+
usedKeys.add(disambiguated);
|
|
1147
|
+
return disambiguated;
|
|
1148
|
+
};
|
|
1149
|
+
for (const run of runs) {
|
|
1150
|
+
const runExpanded = options.expanded.has(encodeRunPath([run.id]));
|
|
1151
|
+
const runHasChildren = (run.steps?.length ?? 0) > 0;
|
|
1152
|
+
const runTruncated = runHasChildren && maxDepth < 1 ? "depth" : void 0;
|
|
1153
|
+
rows.push({
|
|
1154
|
+
kind: "run",
|
|
1155
|
+
key: keyFor([run.id]),
|
|
1156
|
+
runId: run.id,
|
|
1157
|
+
run,
|
|
1158
|
+
depth: 0,
|
|
1159
|
+
stepPath: [],
|
|
1160
|
+
hasChildren: runHasChildren,
|
|
1161
|
+
expanded: runExpanded && runHasChildren && !runTruncated,
|
|
1162
|
+
truncated: runTruncated
|
|
1163
|
+
});
|
|
1164
|
+
if (!runHasChildren || !runExpanded || runTruncated) continue;
|
|
1165
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
1166
|
+
const walk = (steps, parentPath, depth) => {
|
|
1167
|
+
for (const step of steps) {
|
|
1168
|
+
if (!isRunStep(step)) continue;
|
|
1169
|
+
const stepPath = [...parentPath, step.id];
|
|
1170
|
+
const hasChildren = (step.steps?.length ?? 0) > 0;
|
|
1171
|
+
let truncated;
|
|
1172
|
+
if (hasChildren) {
|
|
1173
|
+
if (ancestors.has(step)) truncated = "cycle";
|
|
1174
|
+
else if (depth >= maxDepth) truncated = "depth";
|
|
1175
|
+
}
|
|
1176
|
+
const open = options.expanded.has(encodeRunPath([run.id, ...stepPath])) && hasChildren && !truncated;
|
|
1177
|
+
rows.push({
|
|
1178
|
+
kind: "step",
|
|
1179
|
+
key: keyFor([run.id, ...stepPath]),
|
|
1180
|
+
runId: run.id,
|
|
1181
|
+
run,
|
|
1182
|
+
depth,
|
|
1183
|
+
stepPath,
|
|
1184
|
+
step,
|
|
1185
|
+
hasChildren,
|
|
1186
|
+
expanded: open,
|
|
1187
|
+
truncated
|
|
1188
|
+
});
|
|
1189
|
+
if (open && step.steps) {
|
|
1190
|
+
ancestors.add(step);
|
|
1191
|
+
walk(step.steps, stepPath, depth + 1);
|
|
1192
|
+
ancestors.delete(step);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
walk(run.steps ?? [], [], 1);
|
|
1197
|
+
}
|
|
1198
|
+
return rows;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/ops/runs/controller.ts
|
|
1202
|
+
var RunsController = class {
|
|
1203
|
+
host;
|
|
1204
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1205
|
+
maxRuns;
|
|
1206
|
+
maxDepth;
|
|
1207
|
+
now;
|
|
1208
|
+
notifyThrottled;
|
|
1209
|
+
/** Runs by id — patching by id is O(1), which is the whole point of the delta. */
|
|
1210
|
+
byId = /* @__PURE__ */ new Map();
|
|
1211
|
+
order = [];
|
|
1212
|
+
filter;
|
|
1213
|
+
expanded;
|
|
1214
|
+
rev = 0;
|
|
1215
|
+
dropped = 0;
|
|
1216
|
+
snapshot = null;
|
|
1217
|
+
constructor(options) {
|
|
1218
|
+
this.host = options.host;
|
|
1219
|
+
this.maxRuns = Math.max(1, options.maxRuns ?? 500);
|
|
1220
|
+
this.maxDepth = Math.max(0, options.maxDepth ?? DEFAULT_MAX_STEP_DEPTH);
|
|
1221
|
+
this.now = options.host.now ?? (() => Date.now());
|
|
1222
|
+
this.filter = options.initial?.filter ?? {};
|
|
1223
|
+
this.expanded = new Set(options.initial?.expanded ?? []);
|
|
1224
|
+
const throttleMs = Math.max(0, options.throttleMs ?? 100);
|
|
1225
|
+
this.notifyThrottled = createThrottle(
|
|
1226
|
+
() => this.flush(),
|
|
1227
|
+
throttleMs,
|
|
1228
|
+
options.host.clock
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
/* ── Subscription ──────────────────────────────────────────────────────── */
|
|
1232
|
+
subscribe = (listener) => {
|
|
1233
|
+
this.listeners.add(listener);
|
|
1234
|
+
return () => this.listeners.delete(listener);
|
|
1235
|
+
};
|
|
1236
|
+
getState = () => {
|
|
1237
|
+
if (!this.snapshot) {
|
|
1238
|
+
const all = this.all();
|
|
1239
|
+
const counts = Object.fromEntries(RUN_STATUSES.map((s) => [s, 0]));
|
|
1240
|
+
for (const run of all) {
|
|
1241
|
+
counts[run.status] = (counts[run.status] ?? 0) + 1;
|
|
1242
|
+
}
|
|
1243
|
+
const runs = this.visible();
|
|
1244
|
+
this.snapshot = {
|
|
1245
|
+
runs,
|
|
1246
|
+
rows: flattenRunRows(runs, { expanded: this.expanded, maxDepth: this.maxDepth }),
|
|
1247
|
+
sources: this.sources(),
|
|
1248
|
+
filter: this.filter,
|
|
1249
|
+
counts,
|
|
1250
|
+
stats: computeStats(all),
|
|
1251
|
+
expanded: [...this.expanded],
|
|
1252
|
+
total: all.length,
|
|
1253
|
+
dropped: this.dropped
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
return this.snapshot;
|
|
1257
|
+
};
|
|
1258
|
+
/** Invalidate and notify immediately. */
|
|
1259
|
+
flush() {
|
|
1260
|
+
this.snapshot = null;
|
|
1261
|
+
for (const listener of this.listeners) listener();
|
|
1262
|
+
}
|
|
1263
|
+
/** Invalidate now, notify on the throttle — the hot path. */
|
|
1264
|
+
notify() {
|
|
1265
|
+
this.snapshot = null;
|
|
1266
|
+
this.notifyThrottled.call();
|
|
1267
|
+
}
|
|
1268
|
+
/** Emit any pending notification immediately. Call before reading in a test. */
|
|
1269
|
+
flushNow() {
|
|
1270
|
+
this.notifyThrottled.flush();
|
|
1271
|
+
}
|
|
1272
|
+
/* ── Ingest ────────────────────────────────────────────────────────────── */
|
|
1273
|
+
/**
|
|
1274
|
+
* Apply a delta.
|
|
1275
|
+
*
|
|
1276
|
+
* @param delta - append / patch / remove / replace / clear.
|
|
1277
|
+
* @returns `true` if applied; `false` if dropped as stale.
|
|
1278
|
+
*/
|
|
1279
|
+
apply(delta) {
|
|
1280
|
+
if (delta.rev !== void 0 && delta.rev <= this.rev) return false;
|
|
1281
|
+
if (delta.rev !== void 0) this.rev = delta.rev;
|
|
1282
|
+
if (delta.clear) {
|
|
1283
|
+
if (delta.source) {
|
|
1284
|
+
for (const [id, run] of [...this.byId]) {
|
|
1285
|
+
if (run.source === delta.source) this.byId.delete(id);
|
|
1286
|
+
}
|
|
1287
|
+
this.order = this.order.filter((id) => this.byId.has(id));
|
|
1288
|
+
} else {
|
|
1289
|
+
this.byId.clear();
|
|
1290
|
+
this.order = [];
|
|
1291
|
+
}
|
|
1292
|
+
this.dropped = 0;
|
|
1293
|
+
} else if (delta.replace) {
|
|
1294
|
+
if (delta.source) {
|
|
1295
|
+
for (const [id, run] of [...this.byId]) {
|
|
1296
|
+
if (run.source === delta.source) this.byId.delete(id);
|
|
1297
|
+
}
|
|
1298
|
+
this.order = this.order.filter((id) => this.byId.has(id));
|
|
1299
|
+
} else {
|
|
1300
|
+
this.byId.clear();
|
|
1301
|
+
this.order = [];
|
|
1302
|
+
}
|
|
1303
|
+
for (const run of delta.replace) this.insert(run);
|
|
1304
|
+
}
|
|
1305
|
+
if (delta.remove) {
|
|
1306
|
+
for (const id of delta.remove) this.byId.delete(id);
|
|
1307
|
+
this.order = this.order.filter((id) => this.byId.has(id));
|
|
1308
|
+
}
|
|
1309
|
+
if (delta.append) {
|
|
1310
|
+
for (const run of delta.append) this.insert(run);
|
|
1311
|
+
}
|
|
1312
|
+
if (delta.patch) {
|
|
1313
|
+
for (const [id, patch] of Object.entries(delta.patch)) {
|
|
1314
|
+
const existing = this.byId.get(id);
|
|
1315
|
+
if (!existing) continue;
|
|
1316
|
+
this.byId.set(id, { ...existing, ...patch, id });
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (Array.isArray(delta.appendSteps)) {
|
|
1320
|
+
for (const entry of delta.appendSteps) {
|
|
1321
|
+
if (!entry || !Array.isArray(entry.steps)) continue;
|
|
1322
|
+
const path = normalizePath(entry.path);
|
|
1323
|
+
if (!path) continue;
|
|
1324
|
+
this.applyStepAppend(entry.runId, path, entry.steps);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (Array.isArray(delta.patchSteps)) {
|
|
1328
|
+
for (const entry of delta.patchSteps) {
|
|
1329
|
+
if (!entry || !entry.patch || typeof entry.patch !== "object") continue;
|
|
1330
|
+
const path = normalizePath(entry.path);
|
|
1331
|
+
if (!path) continue;
|
|
1332
|
+
this.applyStepPatch(entry.runId, path, entry.patch);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
this.trim();
|
|
1336
|
+
this.notify();
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
insert(run) {
|
|
1340
|
+
if (!this.byId.has(run.id)) this.order.push(run.id);
|
|
1341
|
+
this.byId.set(run.id, run);
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Merge a patch into one step of one run.
|
|
1345
|
+
*
|
|
1346
|
+
* 🔴 The run object is replaced but its untouched subtrees are SHARED — `patchStepAtPath` copies
|
|
1347
|
+
* only the spine. This is the whole reason per-step addressing exists: the alternative a host had
|
|
1348
|
+
* was resending `steps` wholesale on every tick, which is the array-copy storm the delta was
|
|
1349
|
+
* designed to prevent, reintroduced one level down.
|
|
1350
|
+
*
|
|
1351
|
+
* @returns `true` when the address resolved and the patch landed.
|
|
1352
|
+
*/
|
|
1353
|
+
applyStepPatch(runId, path, patch) {
|
|
1354
|
+
const run = this.byId.get(runId);
|
|
1355
|
+
if (!run || path.length === 0) return false;
|
|
1356
|
+
if (path.length > this.maxDepth) return false;
|
|
1357
|
+
const steps = patchStepAtPath(run.steps, path, patch);
|
|
1358
|
+
if (!steps) return false;
|
|
1359
|
+
this.byId.set(runId, { ...run, steps });
|
|
1360
|
+
return true;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Add steps to a run, or to a step inside one.
|
|
1364
|
+
*
|
|
1365
|
+
* An unresolvable PARENT is refused rather than created, for the same reason a patch for an
|
|
1366
|
+
* unknown run is: a parent conjured from an append has no label and no status, so it renders as
|
|
1367
|
+
* a row that will never resolve into anything.
|
|
1368
|
+
*
|
|
1369
|
+
* @returns `true` when the parent resolved and the steps landed.
|
|
1370
|
+
*/
|
|
1371
|
+
applyStepAppend(runId, path, incoming) {
|
|
1372
|
+
const run = this.byId.get(runId);
|
|
1373
|
+
if (!run || incoming.length === 0) return false;
|
|
1374
|
+
if (path.length >= this.maxDepth) return false;
|
|
1375
|
+
const steps = appendStepsAtPath(run.steps, path, incoming);
|
|
1376
|
+
if (!steps) return false;
|
|
1377
|
+
this.byId.set(runId, { ...run, steps });
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
/** Ring-buffer the OLDEST runs away, never the newest. */
|
|
1381
|
+
trim() {
|
|
1382
|
+
if (this.order.length <= this.maxRuns) return;
|
|
1383
|
+
const sorted = this.all();
|
|
1384
|
+
const keep = new Set(sorted.slice(0, this.maxRuns).map((r) => r.id));
|
|
1385
|
+
let removed = 0;
|
|
1386
|
+
for (const id of [...this.byId.keys()]) {
|
|
1387
|
+
if (!keep.has(id)) {
|
|
1388
|
+
this.byId.delete(id);
|
|
1389
|
+
removed += 1;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
this.order = this.order.filter((id) => this.byId.has(id));
|
|
1393
|
+
this.dropped += removed;
|
|
1394
|
+
}
|
|
1395
|
+
/** Every run, newest first. */
|
|
1396
|
+
all() {
|
|
1397
|
+
return this.order.map((id) => this.byId.get(id)).filter((r) => !!r).sort((a, b) => b.startedAt - a.startedAt || (a.id < b.id ? -1 : 1));
|
|
1398
|
+
}
|
|
1399
|
+
/* ── Filtering ─────────────────────────────────────────────────────────── */
|
|
1400
|
+
/** Replace the filter. */
|
|
1401
|
+
setFilter(filter) {
|
|
1402
|
+
this.filter = filter;
|
|
1403
|
+
this.flush();
|
|
1404
|
+
}
|
|
1405
|
+
/** Set the search term. */
|
|
1406
|
+
setSearch(search) {
|
|
1407
|
+
this.filter = { ...this.filter, search };
|
|
1408
|
+
this.flush();
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Show only these statuses. Empty/absent shows everything.
|
|
1412
|
+
*
|
|
1413
|
+
* **A dead-letter view is `statuses: ['failed']` plus a search — not a second panel.** That is
|
|
1414
|
+
* exactly how one incumbent ended up maintaining two panels for one archetype.
|
|
1415
|
+
*/
|
|
1416
|
+
setStatuses(statuses) {
|
|
1417
|
+
this.filter = { ...this.filter, statuses: [...statuses] };
|
|
1418
|
+
this.flush();
|
|
1419
|
+
}
|
|
1420
|
+
/** Show or hide one source. */
|
|
1421
|
+
toggleSource(source, enabled) {
|
|
1422
|
+
const muted = new Set(this.filter.mutedSources ?? []);
|
|
1423
|
+
const next = enabled ?? muted.has(source);
|
|
1424
|
+
if (next) muted.delete(source);
|
|
1425
|
+
else muted.add(source);
|
|
1426
|
+
this.filter = { ...this.filter, mutedSources: [...muted] };
|
|
1427
|
+
this.flush();
|
|
1428
|
+
}
|
|
1429
|
+
/** The visible runs, newest first. */
|
|
1430
|
+
visible() {
|
|
1431
|
+
const statuses = this.filter.statuses;
|
|
1432
|
+
const wanted = statuses && statuses.length > 0 ? new Set(statuses) : null;
|
|
1433
|
+
const muted = new Set(this.filter.mutedSources ?? []);
|
|
1434
|
+
const search = (this.filter.search ?? "").trim();
|
|
1435
|
+
return this.all().filter(
|
|
1436
|
+
(run) => (!wanted || wanted.has(run.status)) && !muted.has(run.source ?? "") && matchesSearch2(run, search)
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
/** Every source seen. */
|
|
1440
|
+
sources() {
|
|
1441
|
+
const muted = new Set(this.filter.mutedSources ?? []);
|
|
1442
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1443
|
+
for (const run of this.byId.values()) {
|
|
1444
|
+
const id = run.source ?? "";
|
|
1445
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
1446
|
+
}
|
|
1447
|
+
return [...counts.entries()].map(([id, count]) => ({
|
|
1448
|
+
id,
|
|
1449
|
+
label: id === "" ? "(unsourced)" : id,
|
|
1450
|
+
enabled: !muted.has(id),
|
|
1451
|
+
count
|
|
1452
|
+
}));
|
|
1453
|
+
}
|
|
1454
|
+
/* ── Intents ───────────────────────────────────────────────────────────── */
|
|
1455
|
+
/**
|
|
1456
|
+
* Ask the host to cancel a run.
|
|
1457
|
+
*
|
|
1458
|
+
* Refuses a run that cannot be cancelled, and **does not flip the status locally**: the run
|
|
1459
|
+
* becomes `cancelled` when the host says so, not when the panel asks.
|
|
1460
|
+
*/
|
|
1461
|
+
cancel(runId, step) {
|
|
1462
|
+
const run = this.byId.get(runId);
|
|
1463
|
+
if (!run || !canCancel(run)) return false;
|
|
1464
|
+
this.host.emit("action", { runId, action: "cancel", ...targetOf(step) });
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
/** Ask the host to retry a run. */
|
|
1468
|
+
retry(runId, step) {
|
|
1469
|
+
const run = this.byId.get(runId);
|
|
1470
|
+
if (!run || !canRetry(run)) return false;
|
|
1471
|
+
this.host.emit("action", { runId, action: "retry", ...targetOf(step) });
|
|
1472
|
+
return true;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Clear TERMINAL runs from the view.
|
|
1476
|
+
*
|
|
1477
|
+
* Only terminal ones: clearing a running download would remove the row while the transfer
|
|
1478
|
+
* continues, and the user would have no way back to it.
|
|
1479
|
+
*
|
|
1480
|
+
* @returns How many were removed.
|
|
1481
|
+
*/
|
|
1482
|
+
clearTerminal() {
|
|
1483
|
+
let removed = 0;
|
|
1484
|
+
for (const [id, run] of [...this.byId]) {
|
|
1485
|
+
if (isTerminal(run.status)) {
|
|
1486
|
+
this.byId.delete(id);
|
|
1487
|
+
removed += 1;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (removed > 0) {
|
|
1491
|
+
this.order = this.order.filter((id) => this.byId.has(id));
|
|
1492
|
+
this.host.emit("action", { runId: "", action: "clear" });
|
|
1493
|
+
this.flush();
|
|
1494
|
+
}
|
|
1495
|
+
return removed;
|
|
1496
|
+
}
|
|
1497
|
+
/** A run was opened — the reveal hook. */
|
|
1498
|
+
activate(runId, step) {
|
|
1499
|
+
const run = this.byId.get(runId);
|
|
1500
|
+
if (!run) return false;
|
|
1501
|
+
this.host.emit("runActivate", {
|
|
1502
|
+
runId,
|
|
1503
|
+
source: run.source,
|
|
1504
|
+
...targetOf(step)
|
|
1505
|
+
});
|
|
1506
|
+
return true;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Expand or collapse a run, or one step inside it.
|
|
1510
|
+
*
|
|
1511
|
+
* @param runId - The run.
|
|
1512
|
+
* @param stepPath - Address of a step within it. Absent ⇒ the run's own row.
|
|
1513
|
+
* @returns `true` when the target existed and its state flipped.
|
|
1514
|
+
*
|
|
1515
|
+
* ⚠️ The key for a run is its bare id, so a `RunsPanelState` written before step expansion
|
|
1516
|
+
* existed restores unchanged — those stored strings ARE the encoded run-level paths.
|
|
1517
|
+
*
|
|
1518
|
+
* 🔴 A step path is resolved before it is stored. An address that names nothing would otherwise
|
|
1519
|
+
* accumulate in a set that is serialized, so a host with a churning step tree would grow its
|
|
1520
|
+
* persisted state without bound and never be able to tell which entries still meant anything.
|
|
1521
|
+
*/
|
|
1522
|
+
toggleExpanded(runId, stepPath) {
|
|
1523
|
+
const run = this.byId.get(runId);
|
|
1524
|
+
if (!run) return false;
|
|
1525
|
+
if (stepPath && stepPath.length > 0 && !resolveStepPath(run.steps, stepPath)) return false;
|
|
1526
|
+
const key = encodeRunPath([runId, ...stepPath ?? []]);
|
|
1527
|
+
if (this.expanded.has(key)) this.expanded.delete(key);
|
|
1528
|
+
else this.expanded.add(key);
|
|
1529
|
+
this.flush();
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
/** Ask the host to open a context menu. */
|
|
1533
|
+
requestContextMenu(runId, x, y) {
|
|
1534
|
+
this.host.emit("contextMenuRequest", { runId, x, y });
|
|
1535
|
+
}
|
|
1536
|
+
/* ── Persistence ───────────────────────────────────────────────────────── */
|
|
1537
|
+
/** Serialize. Filter and expansion — **never the runs**; an execution log is not a document. */
|
|
1538
|
+
serialize() {
|
|
1539
|
+
return { filter: this.filter, expanded: [...this.expanded] };
|
|
1540
|
+
}
|
|
1541
|
+
/** Restore. Does not emit. */
|
|
1542
|
+
deserialize(state) {
|
|
1543
|
+
if (state.filter) this.filter = state.filter;
|
|
1544
|
+
if (state.expanded) this.expanded = new Set(state.expanded);
|
|
1545
|
+
this.flush();
|
|
1546
|
+
}
|
|
1547
|
+
/** Drop listeners, timers and the buffer. */
|
|
1548
|
+
dispose() {
|
|
1549
|
+
this.notifyThrottled.cancel();
|
|
1550
|
+
this.listeners.clear();
|
|
1551
|
+
this.byId.clear();
|
|
1552
|
+
this.order = [];
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
function normalizePath(path) {
|
|
1556
|
+
if (path === void 0 || path === null) return [];
|
|
1557
|
+
if (!Array.isArray(path)) return null;
|
|
1558
|
+
return path.every((id) => typeof id === "string") ? path : null;
|
|
1559
|
+
}
|
|
1560
|
+
function targetOf(step) {
|
|
1561
|
+
if (step === void 0) return {};
|
|
1562
|
+
const path = typeof step === "string" ? [step] : [...step];
|
|
1563
|
+
if (path.length === 0) return {};
|
|
1564
|
+
return { stepId: path[path.length - 1], stepPath: path };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/ops/runs/manifest.ts
|
|
1568
|
+
var RUNS_PANEL_ID = "xeno.core.runs";
|
|
1569
|
+
var runsManifest = {
|
|
1570
|
+
id: RUNS_PANEL_ID,
|
|
1571
|
+
// 0.2.0: steps became a TREE, the status union widened, and a step became addressable in a
|
|
1572
|
+
// delta. Every change is additive — a 0.1.0 host that sets none of it behaves identically.
|
|
1573
|
+
version: "0.2.0",
|
|
1574
|
+
title: "Runs",
|
|
1575
|
+
icon: "play-circle",
|
|
1576
|
+
description: "Ordered executions with lifecycle state, progress and per-item actions. Not an undo stack (that is history) and not document snapshots (that is versions).",
|
|
1577
|
+
defaultSlot: "bottom",
|
|
1578
|
+
inputs: [
|
|
1579
|
+
{
|
|
1580
|
+
id: "runs",
|
|
1581
|
+
name: "Runs",
|
|
1582
|
+
type: "object",
|
|
1583
|
+
description: "A delta: {rev?, append?, patch?, appendSteps?, patchSteps?, remove?, replace?, clear?, source?}. `patch` addresses runs BY ID and `patchSteps` addresses one step by PATH ({runId, path: string[], patch}) \u2014 re-pushing the whole list per progress tick, or a run\u2019s whole `steps` array per step tick, is the failure mode this exists to avoid.",
|
|
1584
|
+
// Fan-in: several producers (graph execution, downloads, generation queues) in one view.
|
|
1585
|
+
multiple: true
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
id: "filter",
|
|
1589
|
+
name: "Filter",
|
|
1590
|
+
type: "object",
|
|
1591
|
+
description: "An externally set filter {statuses?, mutedSources?, search?}.",
|
|
1592
|
+
multiple: false
|
|
1593
|
+
}
|
|
1594
|
+
],
|
|
1595
|
+
outputs: [
|
|
1596
|
+
{
|
|
1597
|
+
id: "action",
|
|
1598
|
+
name: "Action",
|
|
1599
|
+
type: "object",
|
|
1600
|
+
description: "{runId, action: cancel|retry|clear, stepId?, stepPath?} \u2014 an INTENT. The panel never executes or cancels anything, and never optimistically flips a status. `stepId` is the last element of `stepPath`; a nested step needs the path, because one step id may occur under several parents."
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
id: "runActivate",
|
|
1604
|
+
name: "Run Activated",
|
|
1605
|
+
type: "object",
|
|
1606
|
+
description: "{runId, source?, stepId?, stepPath?} \u2014 a run or one of its steps was opened, so the host can reveal the node, panel or file behind it."
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
id: "contextMenuRequest",
|
|
1610
|
+
name: "Context Menu Request",
|
|
1611
|
+
type: "object",
|
|
1612
|
+
description: "The host opens its own menu."
|
|
1613
|
+
}
|
|
1614
|
+
],
|
|
1615
|
+
commands: [
|
|
1616
|
+
{ id: "get_runs", title: "Get Runs", description: "Return every held run, newest first.", parameters: {} },
|
|
1617
|
+
{
|
|
1618
|
+
id: "get_stats",
|
|
1619
|
+
title: "Get Stats",
|
|
1620
|
+
description: "Aggregates. successRate is computed over TERMINAL runs only, and is null when none have finished.",
|
|
1621
|
+
parameters: {}
|
|
1622
|
+
},
|
|
1623
|
+
{
|
|
1624
|
+
id: "filter_runs",
|
|
1625
|
+
title: "Filter",
|
|
1626
|
+
description: "Set the search term.",
|
|
1627
|
+
parameters: { search: { type: "string", description: "Substring over label/source/trigger/error code" } }
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
id: "set_statuses",
|
|
1631
|
+
title: "Set Statuses",
|
|
1632
|
+
description: 'Show only these statuses. A dead-letter view is ["failed"] \u2014 not a separate panel.',
|
|
1633
|
+
parameters: { statuses: { type: "array", description: "XenoRunStatus[]; empty shows all", required: true } }
|
|
1634
|
+
},
|
|
1635
|
+
{
|
|
1636
|
+
id: "cancel_run",
|
|
1637
|
+
title: "Cancel Run",
|
|
1638
|
+
description: "Ask the host to cancel. Refused for a terminal run.",
|
|
1639
|
+
parameters: {
|
|
1640
|
+
runId: { type: "string", description: "Run id", required: true },
|
|
1641
|
+
stepId: { type: "string", description: "Target one TOP-LEVEL step. Ambiguous once steps nest \u2014 use stepPath." },
|
|
1642
|
+
stepPath: { type: "array", description: "Step ids from the run down to the target. Wins over stepId when both are given." }
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
{
|
|
1646
|
+
id: "retry_run",
|
|
1647
|
+
title: "Retry Run",
|
|
1648
|
+
description: "Ask the host to retry. Refused when the error says it is not retryable.",
|
|
1649
|
+
parameters: {
|
|
1650
|
+
runId: { type: "string", description: "Run id", required: true },
|
|
1651
|
+
stepId: { type: "string", description: "Target one TOP-LEVEL step. Ambiguous once steps nest \u2014 use stepPath." },
|
|
1652
|
+
stepPath: { type: "array", description: "Step ids from the run down to the target. Wins over stepId when both are given." }
|
|
1653
|
+
}
|
|
1654
|
+
},
|
|
1655
|
+
{
|
|
1656
|
+
id: "clear_finished",
|
|
1657
|
+
title: "Clear Finished",
|
|
1658
|
+
description: "Remove TERMINAL runs from the view. Never removes a running one \u2014 the work would continue with no row.",
|
|
1659
|
+
parameters: {}
|
|
1660
|
+
},
|
|
1661
|
+
{
|
|
1662
|
+
id: "reveal_run",
|
|
1663
|
+
title: "Reveal Run",
|
|
1664
|
+
description: "Emit runActivate so the host can reveal the origin.",
|
|
1665
|
+
parameters: { runId: { type: "string", description: "Run id", required: true } }
|
|
1666
|
+
}
|
|
1667
|
+
],
|
|
1668
|
+
config: [
|
|
1669
|
+
{
|
|
1670
|
+
key: "maxRuns",
|
|
1671
|
+
label: "Buffer size",
|
|
1672
|
+
type: "number",
|
|
1673
|
+
defaultValue: 500,
|
|
1674
|
+
description: "Ring capacity; the oldest are dropped. Recent activity, not an archive.",
|
|
1675
|
+
placeholder: "500"
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
key: "throttleMs",
|
|
1679
|
+
label: "Update throttle",
|
|
1680
|
+
type: "number",
|
|
1681
|
+
defaultValue: 100,
|
|
1682
|
+
description: "Minimum ms between re-projections. Twenty rows patching at 60 Hz is 1 200 re-filters a second without it.",
|
|
1683
|
+
placeholder: "100"
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
key: "maxDepth",
|
|
1687
|
+
label: "Nesting depth",
|
|
1688
|
+
type: "number",
|
|
1689
|
+
defaultValue: 32,
|
|
1690
|
+
description: "How deep below a run the step tree is rendered. A cut subtree is marked as cut, never silently shown as a leaf.",
|
|
1691
|
+
placeholder: "32"
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
key: "rowHeight",
|
|
1695
|
+
label: "Row height",
|
|
1696
|
+
type: "number",
|
|
1697
|
+
defaultValue: 24,
|
|
1698
|
+
description: "Fixed \u2014 what keeps virtualization O(1).",
|
|
1699
|
+
placeholder: "24"
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
key: "showStats",
|
|
1703
|
+
label: "Show summary",
|
|
1704
|
+
type: "boolean",
|
|
1705
|
+
defaultValue: true,
|
|
1706
|
+
description: "Render the success-rate and mean-duration strip."
|
|
1707
|
+
},
|
|
1708
|
+
{
|
|
1709
|
+
key: "showProgress",
|
|
1710
|
+
label: "Show progress bars",
|
|
1711
|
+
type: "boolean",
|
|
1712
|
+
defaultValue: true,
|
|
1713
|
+
description: "Render a bar for runs that report progress."
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
key: "defaultStatuses",
|
|
1717
|
+
label: "Opening filter",
|
|
1718
|
+
type: "json",
|
|
1719
|
+
description: 'Statuses shown on mount. ["failed"] makes this a dead-letter queue.',
|
|
1720
|
+
placeholder: '["failed"]'
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
key: "emptyHint",
|
|
1724
|
+
label: "Empty hint",
|
|
1725
|
+
type: "text",
|
|
1726
|
+
description: "Say what would produce a run, not that there are none.",
|
|
1727
|
+
placeholder: "Run the workflow to see its executions here."
|
|
1728
|
+
}
|
|
1729
|
+
],
|
|
1730
|
+
capabilities: ["storage.local"],
|
|
1731
|
+
sdk: "^1.1.0"
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1734
|
+
// src/ops/runs/react/RunsPanelView.tsx
|
|
1735
|
+
import { useSyncExternalStore as useSyncExternalStore2 } from "react";
|
|
1736
|
+
import {
|
|
1737
|
+
Badge as Badge2,
|
|
1738
|
+
EmptyState as EmptyState2,
|
|
1739
|
+
IconButton as IconButton2,
|
|
1740
|
+
ProportionBar,
|
|
1741
|
+
Row as Row2,
|
|
1742
|
+
RowList as RowList2,
|
|
1743
|
+
ScrollArea as ScrollArea2,
|
|
1744
|
+
SearchField as SearchField2,
|
|
1745
|
+
StatusBar as StatusBar2,
|
|
1746
|
+
TextButton,
|
|
1747
|
+
Toolbar as Toolbar2,
|
|
1748
|
+
ToolbarGroup as ToolbarGroup2
|
|
1749
|
+
} from "@xenosystem/workbench/primitives/react";
|
|
1750
|
+
|
|
1751
|
+
// src/ops/runs/react/icons.tsx
|
|
1752
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
1753
|
+
function Svg2({ children }) {
|
|
1754
|
+
return /* @__PURE__ */ jsx3(
|
|
1755
|
+
"svg",
|
|
1756
|
+
{
|
|
1757
|
+
width: 11,
|
|
1758
|
+
height: 11,
|
|
1759
|
+
viewBox: "0 0 24 24",
|
|
1760
|
+
fill: "none",
|
|
1761
|
+
stroke: "currentColor",
|
|
1762
|
+
strokeWidth: "1.5",
|
|
1763
|
+
strokeLinecap: "round",
|
|
1764
|
+
strokeLinejoin: "round",
|
|
1765
|
+
"aria-hidden": "true",
|
|
1766
|
+
children
|
|
1767
|
+
}
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
function CancelGlyph() {
|
|
1771
|
+
return /* @__PURE__ */ jsx3(Svg2, { children: /* @__PURE__ */ jsx3("rect", { x: "5", y: "5", width: "14", height: "14", rx: "2" }) });
|
|
1772
|
+
}
|
|
1773
|
+
function RetryGlyph() {
|
|
1774
|
+
return /* @__PURE__ */ jsxs3(Svg2, { children: [
|
|
1775
|
+
/* @__PURE__ */ jsx3("path", { d: "M21 12a9 9 0 1 1-3.36-7" }),
|
|
1776
|
+
/* @__PURE__ */ jsx3("path", { d: "M21 3v6h-6" })
|
|
1777
|
+
] });
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/ops/runs/react/RunsPanelView.tsx
|
|
1781
|
+
import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1782
|
+
var TONE2 = {
|
|
1783
|
+
requested: "neutral",
|
|
1784
|
+
queued: "neutral",
|
|
1785
|
+
running: "info",
|
|
1786
|
+
paused: "warning",
|
|
1787
|
+
blocked: "warning",
|
|
1788
|
+
"awaiting-decision": "warning",
|
|
1789
|
+
"awaiting-permission": "warning",
|
|
1790
|
+
succeeded: "success",
|
|
1791
|
+
failed: "error",
|
|
1792
|
+
cancelled: "neutral"
|
|
1793
|
+
};
|
|
1794
|
+
function toneOf(status) {
|
|
1795
|
+
return TONE2[status] ?? "neutral";
|
|
1796
|
+
}
|
|
1797
|
+
function RunsPanelView({
|
|
1798
|
+
controller,
|
|
1799
|
+
showStats = true,
|
|
1800
|
+
showProgress = true,
|
|
1801
|
+
emptyHint,
|
|
1802
|
+
now = () => Date.now()
|
|
1803
|
+
}) {
|
|
1804
|
+
const state = useSyncExternalStore2(controller.subscribe, controller.getState, controller.getState);
|
|
1805
|
+
const active = new Set(state.filter.statuses ?? []);
|
|
1806
|
+
return /* @__PURE__ */ jsxs4("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
|
|
1807
|
+
/* @__PURE__ */ jsx4(
|
|
1808
|
+
Toolbar2,
|
|
1809
|
+
{
|
|
1810
|
+
left: /* @__PURE__ */ jsx4(
|
|
1811
|
+
SearchField2,
|
|
1812
|
+
{
|
|
1813
|
+
value: state.filter.search ?? "",
|
|
1814
|
+
onChange: (term) => controller.setSearch(term),
|
|
1815
|
+
placeholder: "Filter runs\u2026"
|
|
1816
|
+
}
|
|
1817
|
+
),
|
|
1818
|
+
right: /* @__PURE__ */ jsx4(ToolbarGroup2, { end: true, children: /* @__PURE__ */ jsx4(TextButton, { onClick: () => controller.clearTerminal(), children: "Clear finished" }) })
|
|
1819
|
+
}
|
|
1820
|
+
),
|
|
1821
|
+
/* @__PURE__ */ jsxs4("div", { style: { display: "flex", gap: 4, padding: "2px 6px", flexWrap: "wrap" }, children: [
|
|
1822
|
+
ALL_STATUSES.filter((s) => state.counts[s] > 0).map((status) => /* @__PURE__ */ jsx4(
|
|
1823
|
+
"span",
|
|
1824
|
+
{
|
|
1825
|
+
onClick: () => controller.setStatuses(
|
|
1826
|
+
active.has(status) ? [...active].filter((s) => s !== status) : [...active, status]
|
|
1827
|
+
),
|
|
1828
|
+
style: { cursor: "pointer", opacity: active.size === 0 || active.has(status) ? 1 : 0.4 },
|
|
1829
|
+
children: /* @__PURE__ */ jsx4(Badge2, { tone: TONE2[status], children: `${status} ${state.counts[status]}` })
|
|
1830
|
+
},
|
|
1831
|
+
status
|
|
1832
|
+
)),
|
|
1833
|
+
state.sources.length > 1 ? state.sources.map((source) => /* @__PURE__ */ jsx4(
|
|
1834
|
+
"span",
|
|
1835
|
+
{
|
|
1836
|
+
onClick: () => controller.toggleSource(source.id),
|
|
1837
|
+
style: { cursor: "pointer", opacity: source.enabled ? 1 : 0.4 },
|
|
1838
|
+
children: /* @__PURE__ */ jsx4(Badge2, { title: `${source.count} runs`, children: source.label })
|
|
1839
|
+
},
|
|
1840
|
+
source.id
|
|
1841
|
+
)) : null
|
|
1842
|
+
] }),
|
|
1843
|
+
state.rows.length === 0 ? /* @__PURE__ */ jsx4(
|
|
1844
|
+
EmptyState2,
|
|
1845
|
+
{
|
|
1846
|
+
title: state.total > 0 ? "No matching runs" : "No runs yet",
|
|
1847
|
+
hint: state.total > 0 ? "Nothing matches the current filter." : emptyHint ?? "Run something to see it here."
|
|
1848
|
+
}
|
|
1849
|
+
) : /* @__PURE__ */ jsx4(ScrollArea2, { children: /* @__PURE__ */ jsx4(RowList2, { role: "list", children: state.rows.map(
|
|
1850
|
+
(row) => row.kind === "run" ? /* @__PURE__ */ jsx4(RunRow, { row, controller, showProgress, now }, row.key) : /* @__PURE__ */ jsx4(StepRow, { row, controller, showProgress }, row.key)
|
|
1851
|
+
) }) }),
|
|
1852
|
+
/* @__PURE__ */ jsx4(
|
|
1853
|
+
StatusBar2,
|
|
1854
|
+
{
|
|
1855
|
+
left: `${state.runs.length} of ${state.total}${state.dropped > 0 ? ` \xB7 ${state.dropped} dropped` : ""}`,
|
|
1856
|
+
right: showStats && state.stats.terminal > 0 ? `${Math.round((state.stats.successRate ?? 0) * 100)}% ok${state.stats.meanDurationMs !== null ? ` \xB7 ~${formatDuration(state.stats.meanDurationMs)}` : ""}` : (
|
|
1857
|
+
// `null` success rate is NOT 0% — nothing has finished, which is a different fact.
|
|
1858
|
+
state.stats.active > 0 ? `${state.stats.active} active` : void 0
|
|
1859
|
+
)
|
|
1860
|
+
}
|
|
1861
|
+
)
|
|
1862
|
+
] });
|
|
1863
|
+
}
|
|
1864
|
+
function TruncationBadge({ reason }) {
|
|
1865
|
+
return /* @__PURE__ */ jsx4(
|
|
1866
|
+
Badge2,
|
|
1867
|
+
{
|
|
1868
|
+
tone: "warning",
|
|
1869
|
+
title: reason === "cycle" ? "This step is nested inside itself \u2014 the panel stopped here rather than repeat it." : "Nesting is deeper than this panel will render.",
|
|
1870
|
+
children: reason === "cycle" ? "cycle" : "deeper\u2026"
|
|
1871
|
+
}
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
function RowLabel({
|
|
1875
|
+
status,
|
|
1876
|
+
text,
|
|
1877
|
+
trailing
|
|
1878
|
+
}) {
|
|
1879
|
+
return /* @__PURE__ */ jsxs4("span", { style: { display: "flex", gap: 6, alignItems: "center", minWidth: 0 }, children: [
|
|
1880
|
+
/* @__PURE__ */ jsx4(Badge2, { tone: toneOf(status), children: status }),
|
|
1881
|
+
/* @__PURE__ */ jsx4("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: text }),
|
|
1882
|
+
trailing
|
|
1883
|
+
] });
|
|
1884
|
+
}
|
|
1885
|
+
function RunRow({
|
|
1886
|
+
row,
|
|
1887
|
+
controller,
|
|
1888
|
+
showProgress,
|
|
1889
|
+
now
|
|
1890
|
+
}) {
|
|
1891
|
+
const run = row.run;
|
|
1892
|
+
const duration = durationOf(run, now());
|
|
1893
|
+
return /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
1894
|
+
/* @__PURE__ */ jsx4(
|
|
1895
|
+
Row2,
|
|
1896
|
+
{
|
|
1897
|
+
noIcon: true,
|
|
1898
|
+
onClick: () => {
|
|
1899
|
+
if (row.hasChildren) controller.toggleExpanded(run.id);
|
|
1900
|
+
controller.activate(run.id);
|
|
1901
|
+
},
|
|
1902
|
+
onContextMenu: (e) => {
|
|
1903
|
+
e.preventDefault();
|
|
1904
|
+
controller.requestContextMenu(run.id, e.clientX, e.clientY);
|
|
1905
|
+
},
|
|
1906
|
+
label: /* @__PURE__ */ jsx4(
|
|
1907
|
+
RowLabel,
|
|
1908
|
+
{
|
|
1909
|
+
status: run.status,
|
|
1910
|
+
text: run.label,
|
|
1911
|
+
trailing: /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
1912
|
+
run.trigger ? /* @__PURE__ */ jsx4("span", { style: { opacity: 0.5, fontSize: 9 }, children: run.trigger }) : null,
|
|
1913
|
+
(run.retryCount ?? 0) > 0 ? /* @__PURE__ */ jsx4(Badge2, { title: "Retries so far", children: `retry x${run.retryCount}` }) : null,
|
|
1914
|
+
row.truncated ? /* @__PURE__ */ jsx4(TruncationBadge, { reason: row.truncated }) : null
|
|
1915
|
+
] })
|
|
1916
|
+
}
|
|
1917
|
+
),
|
|
1918
|
+
meta: showProgress && run.progress !== void 0 ? /* @__PURE__ */ jsx4("span", { style: { width: 48, display: "inline-flex" }, children: /* @__PURE__ */ jsx4(
|
|
1919
|
+
ProportionBar,
|
|
1920
|
+
{
|
|
1921
|
+
label: `${Math.round(run.progress * 100)}%`,
|
|
1922
|
+
segments: [
|
|
1923
|
+
{ value: run.progress, opacity: 0.45 },
|
|
1924
|
+
{ value: Math.max(0, 1 - run.progress), opacity: 0.06 }
|
|
1925
|
+
]
|
|
1926
|
+
}
|
|
1927
|
+
) }) : duration !== null ? /* @__PURE__ */ jsx4("span", { style: { fontVariantNumeric: "tabular-nums", opacity: 0.6 }, children: formatDuration(duration) }) : null,
|
|
1928
|
+
trailing: /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
1929
|
+
canCancel(run) ? /* @__PURE__ */ jsx4(
|
|
1930
|
+
IconButton2,
|
|
1931
|
+
{
|
|
1932
|
+
size: "sm",
|
|
1933
|
+
icon: /* @__PURE__ */ jsx4(CancelGlyph, {}),
|
|
1934
|
+
label: `Cancel ${run.label}`,
|
|
1935
|
+
onClick: () => controller.cancel(run.id)
|
|
1936
|
+
}
|
|
1937
|
+
) : null,
|
|
1938
|
+
canRetry(run) ? /* @__PURE__ */ jsx4(
|
|
1939
|
+
IconButton2,
|
|
1940
|
+
{
|
|
1941
|
+
size: "sm",
|
|
1942
|
+
icon: /* @__PURE__ */ jsx4(RetryGlyph, {}),
|
|
1943
|
+
label: `Retry ${run.label}`,
|
|
1944
|
+
onClick: () => controller.retry(run.id)
|
|
1945
|
+
}
|
|
1946
|
+
) : null
|
|
1947
|
+
] })
|
|
1948
|
+
}
|
|
1949
|
+
),
|
|
1950
|
+
run.error ? /* @__PURE__ */ jsxs4("div", { style: { padding: "0 10px 3px 24px", fontSize: 10, opacity: 0.75 }, children: [
|
|
1951
|
+
/* @__PURE__ */ jsx4(Badge2, { tone: "error", children: run.error.code }),
|
|
1952
|
+
" ",
|
|
1953
|
+
run.error.message
|
|
1954
|
+
] }) : null
|
|
1955
|
+
] });
|
|
1956
|
+
}
|
|
1957
|
+
function StepRow({
|
|
1958
|
+
row,
|
|
1959
|
+
controller,
|
|
1960
|
+
showProgress
|
|
1961
|
+
}) {
|
|
1962
|
+
const step = row.step;
|
|
1963
|
+
const elapsed = step.startedAt !== void 0 && step.endedAt !== void 0 ? step.endedAt - step.startedAt : null;
|
|
1964
|
+
return /* @__PURE__ */ jsx4(
|
|
1965
|
+
Row2,
|
|
1966
|
+
{
|
|
1967
|
+
noIcon: true,
|
|
1968
|
+
style: { paddingLeft: 12 + row.depth * 12, opacity: 0.85 },
|
|
1969
|
+
onClick: () => {
|
|
1970
|
+
if (row.hasChildren) controller.toggleExpanded(row.runId, row.stepPath);
|
|
1971
|
+
controller.activate(row.runId, row.stepPath);
|
|
1972
|
+
},
|
|
1973
|
+
label: /* @__PURE__ */ jsx4(
|
|
1974
|
+
RowLabel,
|
|
1975
|
+
{
|
|
1976
|
+
status: step.status,
|
|
1977
|
+
text: step.label,
|
|
1978
|
+
trailing: row.truncated ? /* @__PURE__ */ jsx4(TruncationBadge, { reason: row.truncated }) : null
|
|
1979
|
+
}
|
|
1980
|
+
),
|
|
1981
|
+
meta: showProgress && step.progress !== void 0 ? /* @__PURE__ */ jsx4("span", { style: { width: 48, display: "inline-flex" }, children: /* @__PURE__ */ jsx4(
|
|
1982
|
+
ProportionBar,
|
|
1983
|
+
{
|
|
1984
|
+
label: `${Math.round(step.progress * 100)}%`,
|
|
1985
|
+
segments: [
|
|
1986
|
+
{ value: step.progress, opacity: 0.45 },
|
|
1987
|
+
{ value: Math.max(0, 1 - step.progress), opacity: 0.06 }
|
|
1988
|
+
]
|
|
1989
|
+
}
|
|
1990
|
+
) }) : elapsed !== null ? /* @__PURE__ */ jsx4("span", { style: { fontVariantNumeric: "tabular-nums", opacity: 0.6 }, children: formatDuration(elapsed) }) : null
|
|
1991
|
+
}
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// src/ops/runs/panel.ts
|
|
1996
|
+
function createRunsPanel(options = {}) {
|
|
1997
|
+
return {
|
|
1998
|
+
manifest: runsManifest,
|
|
1999
|
+
activate(host) {
|
|
2000
|
+
const config = host.config ?? {};
|
|
2001
|
+
const defaultStatuses = parseJson(config.defaultStatuses);
|
|
2002
|
+
const controller = new RunsController({
|
|
2003
|
+
host: { emit: (portId, value) => host.emit(portId, value) },
|
|
2004
|
+
maxRuns: numberConfig2(config.maxRuns, 500),
|
|
2005
|
+
throttleMs: numberConfig2(config.throttleMs, 100, true),
|
|
2006
|
+
maxDepth: numberConfig2(config.maxDepth, 32),
|
|
2007
|
+
initial: { filter: defaultStatuses ? { statuses: defaultStatuses } : {}, expanded: [] }
|
|
2008
|
+
});
|
|
2009
|
+
const resolve = (config2) => ({
|
|
2010
|
+
showStats: config2.showStats !== false,
|
|
2011
|
+
showProgress: config2.showProgress !== false,
|
|
2012
|
+
rowHeight: numberConfig2(config2.rowHeight, 24),
|
|
2013
|
+
emptyHint: typeof config2.emptyHint === "string" ? config2.emptyHint : void 0
|
|
2014
|
+
});
|
|
2015
|
+
let renderConfig = resolve(host.config ?? {});
|
|
2016
|
+
let unrender = null;
|
|
2017
|
+
let root = null;
|
|
2018
|
+
let currentEl = null;
|
|
2019
|
+
const draw = (el) => {
|
|
2020
|
+
unrender?.();
|
|
2021
|
+
if (options.render) {
|
|
2022
|
+
unrender = options.render(el, { controller, config: renderConfig });
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
root = createRoot2(el);
|
|
2026
|
+
root.render(
|
|
2027
|
+
createElement2(RunsPanelView, {
|
|
2028
|
+
controller,
|
|
2029
|
+
showStats: renderConfig.showStats,
|
|
2030
|
+
showProgress: renderConfig.showProgress,
|
|
2031
|
+
emptyHint: renderConfig.emptyHint
|
|
2032
|
+
})
|
|
2033
|
+
);
|
|
2034
|
+
unrender = () => {
|
|
2035
|
+
root?.unmount();
|
|
2036
|
+
root = null;
|
|
2037
|
+
};
|
|
2038
|
+
};
|
|
2039
|
+
const unbindConfig = bindConfig2(host, (config2) => {
|
|
2040
|
+
renderConfig = resolve(config2);
|
|
2041
|
+
if (currentEl) draw(currentEl);
|
|
2042
|
+
});
|
|
2043
|
+
return {
|
|
2044
|
+
render(el) {
|
|
2045
|
+
currentEl = el;
|
|
2046
|
+
draw(el);
|
|
2047
|
+
},
|
|
2048
|
+
onInput(portId, value) {
|
|
2049
|
+
if (portId === "runs") {
|
|
2050
|
+
if (Array.isArray(value)) controller.apply({ append: value });
|
|
2051
|
+
else if (isRecord2(value)) controller.apply(value);
|
|
2052
|
+
} else if (portId === "filter") {
|
|
2053
|
+
if (isRecord2(value)) controller.setFilter(value);
|
|
2054
|
+
}
|
|
2055
|
+
},
|
|
2056
|
+
async onCommand(commandId, params) {
|
|
2057
|
+
switch (commandId) {
|
|
2058
|
+
case "get_runs":
|
|
2059
|
+
controller.flushNow();
|
|
2060
|
+
return controller.all();
|
|
2061
|
+
case "get_stats":
|
|
2062
|
+
controller.flushNow();
|
|
2063
|
+
return controller.getState().stats;
|
|
2064
|
+
case "filter_runs":
|
|
2065
|
+
controller.setSearch(String(params.search ?? ""));
|
|
2066
|
+
return true;
|
|
2067
|
+
case "set_statuses":
|
|
2068
|
+
controller.setStatuses(
|
|
2069
|
+
Array.isArray(params.statuses) ? params.statuses : []
|
|
2070
|
+
);
|
|
2071
|
+
return true;
|
|
2072
|
+
case "cancel_run":
|
|
2073
|
+
return controller.cancel(String(params.runId), stepTarget(params));
|
|
2074
|
+
case "retry_run":
|
|
2075
|
+
return controller.retry(String(params.runId), stepTarget(params));
|
|
2076
|
+
case "clear_finished":
|
|
2077
|
+
return controller.clearTerminal();
|
|
2078
|
+
case "reveal_run":
|
|
2079
|
+
return controller.activate(String(params.runId));
|
|
2080
|
+
default:
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
},
|
|
2084
|
+
serialize() {
|
|
2085
|
+
return controller.serialize();
|
|
2086
|
+
},
|
|
2087
|
+
deserialize(state) {
|
|
2088
|
+
controller.deserialize(state ?? {});
|
|
2089
|
+
},
|
|
2090
|
+
dispose() {
|
|
2091
|
+
unbindConfig();
|
|
2092
|
+
currentEl = null;
|
|
2093
|
+
unrender?.();
|
|
2094
|
+
unrender = null;
|
|
2095
|
+
controller.dispose();
|
|
2096
|
+
}
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
function stepTarget(params) {
|
|
2102
|
+
if (Array.isArray(params.stepPath)) {
|
|
2103
|
+
const path = params.stepPath.filter((s) => typeof s === "string");
|
|
2104
|
+
if (path.length > 0) return path;
|
|
2105
|
+
}
|
|
2106
|
+
return typeof params.stepId === "string" ? params.stepId : void 0;
|
|
2107
|
+
}
|
|
2108
|
+
function parseJson(value) {
|
|
2109
|
+
if (value === void 0 || value === null) return void 0;
|
|
2110
|
+
if (typeof value !== "string") return value;
|
|
2111
|
+
try {
|
|
2112
|
+
return JSON.parse(value);
|
|
2113
|
+
} catch {
|
|
2114
|
+
return void 0;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
function numberConfig2(value, fallback, allowZero = false) {
|
|
2118
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
2119
|
+
if (!Number.isFinite(n)) return fallback;
|
|
2120
|
+
return allowZero ? Math.max(0, n) : n > 0 ? n : fallback;
|
|
2121
|
+
}
|
|
2122
|
+
var runsPanel = createRunsPanel();
|
|
2123
|
+
|
|
2124
|
+
// src/ops/terminal/panel.ts
|
|
2125
|
+
import { createElement as createElement3 } from "react";
|
|
2126
|
+
import { createRoot as createRoot3 } from "react-dom/client";
|
|
2127
|
+
import {
|
|
2128
|
+
bindConfig as bindConfig3
|
|
2129
|
+
} from "@xenosystem/panel-sdk";
|
|
2130
|
+
|
|
2131
|
+
// src/ops/terminal/controller.ts
|
|
2132
|
+
var counter = 0;
|
|
2133
|
+
var defaultMakeId = () => `term-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
2134
|
+
var TerminalController = class {
|
|
2135
|
+
host;
|
|
2136
|
+
makeId;
|
|
2137
|
+
stopOnClose;
|
|
2138
|
+
scrollback;
|
|
2139
|
+
instances = /* @__PURE__ */ new Map();
|
|
2140
|
+
order = [];
|
|
2141
|
+
activeId = null;
|
|
2142
|
+
fontSize;
|
|
2143
|
+
search = null;
|
|
2144
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2145
|
+
snapshot = null;
|
|
2146
|
+
constructor(options) {
|
|
2147
|
+
this.host = options.host;
|
|
2148
|
+
this.makeId = options.host.makeId ?? defaultMakeId;
|
|
2149
|
+
this.scrollback = options.scrollback ?? 5e3;
|
|
2150
|
+
this.fontSize = options.fontSize ?? 12;
|
|
2151
|
+
this.stopOnClose = options.stopOnClose !== false;
|
|
2152
|
+
}
|
|
2153
|
+
/* ── Subscription ──────────────────────────────────────────────────────── */
|
|
2154
|
+
subscribe = (listener) => {
|
|
2155
|
+
this.listeners.add(listener);
|
|
2156
|
+
return () => this.listeners.delete(listener);
|
|
2157
|
+
};
|
|
2158
|
+
getState = () => {
|
|
2159
|
+
if (!this.snapshot) {
|
|
2160
|
+
const instances = this.order.map((id) => this.instances.get(id)).filter((i) => Boolean(i)).map((i) => ({
|
|
2161
|
+
session: i.session,
|
|
2162
|
+
active: i.session.id === this.activeId,
|
|
2163
|
+
cols: i.cols,
|
|
2164
|
+
rows: i.rows
|
|
2165
|
+
}));
|
|
2166
|
+
this.snapshot = {
|
|
2167
|
+
instances,
|
|
2168
|
+
activeId: this.activeId,
|
|
2169
|
+
fontSize: this.fontSize,
|
|
2170
|
+
search: this.search
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
return this.snapshot;
|
|
2174
|
+
};
|
|
2175
|
+
notify() {
|
|
2176
|
+
this.snapshot = null;
|
|
2177
|
+
for (const listener of this.listeners) listener();
|
|
2178
|
+
}
|
|
2179
|
+
emit(intent) {
|
|
2180
|
+
this.host.emit("intent", intent);
|
|
2181
|
+
}
|
|
2182
|
+
/* ── Instances ─────────────────────────────────────────────────────────── */
|
|
2183
|
+
/**
|
|
2184
|
+
* Open a terminal instance.
|
|
2185
|
+
*
|
|
2186
|
+
* **No `start` intent is emitted here.** The panel cannot say how big the terminal is until an
|
|
2187
|
+
* emulator has attached and measured, and a PTY started at a guessed size renders its first
|
|
2188
|
+
* screen wrong. `start` goes out from {@link attach}, once.
|
|
2189
|
+
*
|
|
2190
|
+
* @param id - An explicit id, or one is minted.
|
|
2191
|
+
* @param session - Opening session fields.
|
|
2192
|
+
* @returns The instance id.
|
|
2193
|
+
*/
|
|
2194
|
+
open(id = this.makeId(), session = {}) {
|
|
2195
|
+
if (this.instances.has(id)) return id;
|
|
2196
|
+
this.instances.set(id, {
|
|
2197
|
+
session: { id, status: "idle", ...session },
|
|
2198
|
+
emulator: null,
|
|
2199
|
+
cols: null,
|
|
2200
|
+
rows: null,
|
|
2201
|
+
pending: [],
|
|
2202
|
+
started: false,
|
|
2203
|
+
unsubscribers: []
|
|
2204
|
+
});
|
|
2205
|
+
this.order.push(id);
|
|
2206
|
+
if (this.activeId === null) this.activeId = id;
|
|
2207
|
+
this.notify();
|
|
2208
|
+
return id;
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Close an instance.
|
|
2212
|
+
*
|
|
2213
|
+
* @param id - The instance.
|
|
2214
|
+
* @returns Whether it existed.
|
|
2215
|
+
*/
|
|
2216
|
+
close(id) {
|
|
2217
|
+
const instance = this.instances.get(id);
|
|
2218
|
+
if (!instance) return false;
|
|
2219
|
+
this.detach(id);
|
|
2220
|
+
this.instances.delete(id);
|
|
2221
|
+
this.order = this.order.filter((x) => x !== id);
|
|
2222
|
+
if (this.activeId === id) this.activeId = this.order[this.order.length - 1] ?? null;
|
|
2223
|
+
if (this.stopOnClose && instance.started) this.emit({ type: "stop", id });
|
|
2224
|
+
this.notify();
|
|
2225
|
+
return true;
|
|
2226
|
+
}
|
|
2227
|
+
/** Bring an instance to the front. */
|
|
2228
|
+
setActive(id) {
|
|
2229
|
+
if (id !== null && !this.instances.has(id)) return;
|
|
2230
|
+
this.activeId = id;
|
|
2231
|
+
this.notify();
|
|
2232
|
+
}
|
|
2233
|
+
/** Instance ids, in open order. */
|
|
2234
|
+
ids() {
|
|
2235
|
+
return [...this.order];
|
|
2236
|
+
}
|
|
2237
|
+
/* ── The emulator seam ─────────────────────────────────────────────────── */
|
|
2238
|
+
/**
|
|
2239
|
+
* Attach an emulator to an instance and wire it up.
|
|
2240
|
+
*
|
|
2241
|
+
* Emits `start` **only once a real grid has been measured** — the fix for a mistake both
|
|
2242
|
+
* incumbents make in opposite directions (a fixed 80×24 guess, or a synchronous fit before
|
|
2243
|
+
* layout settles).
|
|
2244
|
+
*
|
|
2245
|
+
* @param id - The instance.
|
|
2246
|
+
* @param emulator - The emulator.
|
|
2247
|
+
* @param element - Where it mounts.
|
|
2248
|
+
* @returns A detach function.
|
|
2249
|
+
*/
|
|
2250
|
+
attach(id, emulator, element) {
|
|
2251
|
+
const instance = this.instances.get(id);
|
|
2252
|
+
if (!instance) return () => {
|
|
2253
|
+
};
|
|
2254
|
+
this.detach(id);
|
|
2255
|
+
instance.emulator = emulator;
|
|
2256
|
+
emulator.open(element);
|
|
2257
|
+
instance.unsubscribers.push(
|
|
2258
|
+
// Raw pass-through, control characters included. A terminal that interpreted Ctrl+C itself
|
|
2259
|
+
// would be a terminal that cannot interrupt anything.
|
|
2260
|
+
emulator.onData((data) => {
|
|
2261
|
+
if (isLive(instance.session.status)) this.emit({ type: "input", id, data });
|
|
2262
|
+
}),
|
|
2263
|
+
emulator.onResize((size) => {
|
|
2264
|
+
instance.cols = size.cols;
|
|
2265
|
+
instance.rows = size.rows;
|
|
2266
|
+
if (instance.started) this.emit({ type: "resize", id, cols: size.cols, rows: size.rows });
|
|
2267
|
+
this.notify();
|
|
2268
|
+
})
|
|
2269
|
+
);
|
|
2270
|
+
for (const chunk of instance.pending) emulator.write(chunk);
|
|
2271
|
+
instance.pending = [];
|
|
2272
|
+
this.fit(id);
|
|
2273
|
+
return () => this.detach(id);
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Re-fit an instance, and start it if it has never been measured before.
|
|
2277
|
+
*
|
|
2278
|
+
* @param id - The instance.
|
|
2279
|
+
* @returns The measured grid, or `null`.
|
|
2280
|
+
*/
|
|
2281
|
+
fit(id) {
|
|
2282
|
+
const instance = this.instances.get(id);
|
|
2283
|
+
if (!instance?.emulator) return null;
|
|
2284
|
+
const size = instance.emulator.fit();
|
|
2285
|
+
if (!measuredSize(size)) return null;
|
|
2286
|
+
instance.cols = size.cols;
|
|
2287
|
+
instance.rows = size.rows;
|
|
2288
|
+
if (!instance.started) {
|
|
2289
|
+
instance.started = true;
|
|
2290
|
+
instance.session = { ...instance.session, status: "starting" };
|
|
2291
|
+
this.emit({ type: "start", id, cols: size.cols, rows: size.rows, cwd: instance.session.cwd });
|
|
2292
|
+
} else {
|
|
2293
|
+
this.emit({ type: "resize", id, cols: size.cols, rows: size.rows });
|
|
2294
|
+
}
|
|
2295
|
+
this.notify();
|
|
2296
|
+
return size;
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Detach an instance's emulator.
|
|
2300
|
+
*
|
|
2301
|
+
* **Never stops the session.** A panel can unmount because a tab was hidden, a layout changed,
|
|
2302
|
+
* or React StrictMode double-invoked an effect; killing a shell for any of those would lose work
|
|
2303
|
+
* the user did not ask to lose.
|
|
2304
|
+
*
|
|
2305
|
+
* @param id - The instance.
|
|
2306
|
+
*/
|
|
2307
|
+
detach(id) {
|
|
2308
|
+
const instance = this.instances.get(id);
|
|
2309
|
+
if (!instance) return;
|
|
2310
|
+
for (const off of instance.unsubscribers) off();
|
|
2311
|
+
instance.unsubscribers = [];
|
|
2312
|
+
instance.emulator?.dispose();
|
|
2313
|
+
instance.emulator = null;
|
|
2314
|
+
}
|
|
2315
|
+
/** Ask the host to replay its buffer — the reattach path. */
|
|
2316
|
+
requestSnapshot(id) {
|
|
2317
|
+
if (!this.instances.has(id)) return false;
|
|
2318
|
+
this.emit({ type: "snapshot", id });
|
|
2319
|
+
return true;
|
|
2320
|
+
}
|
|
2321
|
+
/* ── Host-pushed state ─────────────────────────────────────────────────── */
|
|
2322
|
+
/**
|
|
2323
|
+
* Write host output into a terminal.
|
|
2324
|
+
*
|
|
2325
|
+
* @param payload - `{id, data}`.
|
|
2326
|
+
* @returns Whether the instance exists.
|
|
2327
|
+
*/
|
|
2328
|
+
write(payload) {
|
|
2329
|
+
const instance = this.instances.get(payload.id);
|
|
2330
|
+
if (!instance) return false;
|
|
2331
|
+
if (instance.emulator) instance.emulator.write(payload.data);
|
|
2332
|
+
else {
|
|
2333
|
+
instance.pending.push(payload.data);
|
|
2334
|
+
if (instance.pending.length > 512) instance.pending.splice(0, instance.pending.length - 512);
|
|
2335
|
+
}
|
|
2336
|
+
return true;
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Update a session's host-reported state.
|
|
2340
|
+
*
|
|
2341
|
+
* The panel never sets `running` or `exited` itself — it asked, and it waits to be told.
|
|
2342
|
+
*
|
|
2343
|
+
* @param session - Fields to merge, keyed by `id`.
|
|
2344
|
+
* @returns Whether the instance exists.
|
|
2345
|
+
*/
|
|
2346
|
+
setSession(session) {
|
|
2347
|
+
const instance = this.instances.get(session.id);
|
|
2348
|
+
if (!instance) return false;
|
|
2349
|
+
instance.session = { ...instance.session, ...session };
|
|
2350
|
+
this.notify();
|
|
2351
|
+
return true;
|
|
2352
|
+
}
|
|
2353
|
+
/** Replace a terminal's contents — used when a snapshot arrives. */
|
|
2354
|
+
replace(id, data) {
|
|
2355
|
+
const instance = this.instances.get(id);
|
|
2356
|
+
if (!instance) return false;
|
|
2357
|
+
instance.emulator?.clear();
|
|
2358
|
+
instance.pending = [];
|
|
2359
|
+
return this.write({ id, data });
|
|
2360
|
+
}
|
|
2361
|
+
/* ── View state ────────────────────────────────────────────────────────── */
|
|
2362
|
+
/** Set the font size on every attached emulator. */
|
|
2363
|
+
setFontSize(size) {
|
|
2364
|
+
this.fontSize = Math.max(6, Math.min(32, size));
|
|
2365
|
+
for (const [id, instance] of this.instances) {
|
|
2366
|
+
instance.emulator?.setFontSize?.(this.fontSize);
|
|
2367
|
+
this.fit(id);
|
|
2368
|
+
}
|
|
2369
|
+
this.notify();
|
|
2370
|
+
}
|
|
2371
|
+
/** Open, update, or close the find bar. */
|
|
2372
|
+
setSearch(query) {
|
|
2373
|
+
this.search = query;
|
|
2374
|
+
this.notify();
|
|
2375
|
+
}
|
|
2376
|
+
/** Run a search in the active terminal. */
|
|
2377
|
+
find(direction = "next") {
|
|
2378
|
+
const instance = this.activeId ? this.instances.get(this.activeId) : null;
|
|
2379
|
+
if (!instance?.emulator?.search || !this.search) return false;
|
|
2380
|
+
return instance.emulator.search(this.search, direction);
|
|
2381
|
+
}
|
|
2382
|
+
/** The active instance's session, if any. */
|
|
2383
|
+
active() {
|
|
2384
|
+
return this.activeId ? this.instances.get(this.activeId)?.session ?? null : null;
|
|
2385
|
+
}
|
|
2386
|
+
/* ── Lifecycle ─────────────────────────────────────────────────────────── */
|
|
2387
|
+
/**
|
|
2388
|
+
* Serialize.
|
|
2389
|
+
*
|
|
2390
|
+
* Which tab was in front, and the font size. **Never the scrollback** — terminal output routinely
|
|
2391
|
+
* contains tokens, connection strings and keys that a user pasted or a tool printed, and `.xapp`
|
|
2392
|
+
* is a plain JSON file. `XENO AUTH - SPEC.md` L9 keeps secrets out of exactly this kind of store.
|
|
2393
|
+
*/
|
|
2394
|
+
serialize() {
|
|
2395
|
+
return { activeId: this.activeId, fontSize: this.fontSize };
|
|
2396
|
+
}
|
|
2397
|
+
/** Restore view preferences. Sessions are not restored — the host re-declares them. */
|
|
2398
|
+
deserialize(state) {
|
|
2399
|
+
if (!state || typeof state !== "object") return;
|
|
2400
|
+
const s = state;
|
|
2401
|
+
if (typeof s.fontSize === "number") this.fontSize = s.fontSize;
|
|
2402
|
+
if (typeof s.activeId === "string" || s.activeId === null) this.activeId = s.activeId ?? null;
|
|
2403
|
+
this.notify();
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Tear down.
|
|
2407
|
+
*
|
|
2408
|
+
* Detaches every emulator and **stops nothing**. Sessions outlive the panel by design; the host
|
|
2409
|
+
* decides when a process dies.
|
|
2410
|
+
*/
|
|
2411
|
+
dispose() {
|
|
2412
|
+
for (const id of [...this.instances.keys()]) this.detach(id);
|
|
2413
|
+
this.instances.clear();
|
|
2414
|
+
this.order = [];
|
|
2415
|
+
this.activeId = null;
|
|
2416
|
+
this.listeners.clear();
|
|
2417
|
+
}
|
|
2418
|
+
};
|
|
2419
|
+
|
|
2420
|
+
// src/ops/terminal/manifest.ts
|
|
2421
|
+
import { WELL_KNOWN_PORT_SCHEMAS } from "@xenosystem/panel-sdk";
|
|
2422
|
+
var TERMINAL_PANEL_ID = "xeno.core.terminal";
|
|
2423
|
+
var terminalManifest = {
|
|
2424
|
+
id: TERMINAL_PANEL_ID,
|
|
2425
|
+
version: "0.1.0",
|
|
2426
|
+
title: "Terminal",
|
|
2427
|
+
icon: "square-terminal",
|
|
2428
|
+
description: "The emulator surface: xterm instance, fit-to-PTY sizing, canonical theme, selection, search and scrollback. The host owns the PTY \u2014 the panel never spawns a process.",
|
|
2429
|
+
defaultSlot: "bottom",
|
|
2430
|
+
inputs: [
|
|
2431
|
+
{
|
|
2432
|
+
id: "data",
|
|
2433
|
+
name: "Data",
|
|
2434
|
+
type: "object",
|
|
2435
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.TERMINAL_DATA,
|
|
2436
|
+
description: "{id, data} \u2014 raw output for one instance, escape sequences intact. Never pre-parsed; the emulator is the parser.",
|
|
2437
|
+
multiple: true
|
|
2438
|
+
},
|
|
2439
|
+
{
|
|
2440
|
+
id: "session",
|
|
2441
|
+
name: "Session",
|
|
2442
|
+
type: "object",
|
|
2443
|
+
description: "{id, status, cwd?, exitCode?, pty?, message?}. The host is authoritative \u2014 the panel never sets `running` or `exited` itself.",
|
|
2444
|
+
multiple: true
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
id: "snapshot",
|
|
2448
|
+
name: "Snapshot",
|
|
2449
|
+
type: "object",
|
|
2450
|
+
description: "{id, data} \u2014 a full buffer replay, for reattaching to a session that outlived the panel.",
|
|
2451
|
+
multiple: false
|
|
2452
|
+
}
|
|
2453
|
+
],
|
|
2454
|
+
outputs: [
|
|
2455
|
+
{
|
|
2456
|
+
id: "intent",
|
|
2457
|
+
name: "Intent",
|
|
2458
|
+
type: "object",
|
|
2459
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.TERMINAL_INTENT,
|
|
2460
|
+
description: "start | input | resize | stop | snapshot, always addressed by instance id. `start` carries a MEASURED grid \u2014 the panel does not ask until it can say how big the terminal is."
|
|
2461
|
+
}
|
|
2462
|
+
],
|
|
2463
|
+
commands: [
|
|
2464
|
+
{
|
|
2465
|
+
id: "get_sessions",
|
|
2466
|
+
title: "Get Sessions",
|
|
2467
|
+
description: "Return every instance with its status and grid. No output text \u2014 see the note on scrollback in the doc pack.",
|
|
2468
|
+
parameters: {}
|
|
2469
|
+
},
|
|
2470
|
+
{
|
|
2471
|
+
id: "open",
|
|
2472
|
+
title: "Open Terminal",
|
|
2473
|
+
description: "Open a new instance. Starting is still gated on a real measurement.",
|
|
2474
|
+
parameters: { cwd: { type: "string", description: "Working directory" } }
|
|
2475
|
+
},
|
|
2476
|
+
{
|
|
2477
|
+
id: "close",
|
|
2478
|
+
title: "Close Terminal",
|
|
2479
|
+
description: "Close an instance, and ask the host to stop it.",
|
|
2480
|
+
parameters: { id: { type: "string", description: "Instance id", required: true } }
|
|
2481
|
+
},
|
|
2482
|
+
{
|
|
2483
|
+
id: "set_active",
|
|
2484
|
+
title: "Set Active",
|
|
2485
|
+
description: "Bring an instance to the front.",
|
|
2486
|
+
parameters: { id: { type: "string", description: "Instance id", required: true } }
|
|
2487
|
+
},
|
|
2488
|
+
{
|
|
2489
|
+
id: "find",
|
|
2490
|
+
title: "Find",
|
|
2491
|
+
description: "Search the active terminal\u2019s scrollback.",
|
|
2492
|
+
parameters: {
|
|
2493
|
+
query: { type: "string", description: "Text to find", required: true },
|
|
2494
|
+
direction: { type: "string", description: "next | previous" }
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
// NOTE: there is deliberately no `input` command. An agent that could write to a terminal
|
|
2498
|
+
// could run any command the shell can run, with the user's credentials, without ever passing
|
|
2499
|
+
// a tool-approval check — the panel would become a hole straight through the agent's own
|
|
2500
|
+
// permission model. Agents drive shells through the agent SDK's tool surface, which is
|
|
2501
|
+
// approvable; this panel is where a HUMAN types.
|
|
2502
|
+
],
|
|
2503
|
+
config: [
|
|
2504
|
+
{
|
|
2505
|
+
key: "scrollback",
|
|
2506
|
+
label: "Scrollback lines",
|
|
2507
|
+
type: "number",
|
|
2508
|
+
defaultValue: 5e3,
|
|
2509
|
+
description: "Lines the emulator retains."
|
|
2510
|
+
},
|
|
2511
|
+
{
|
|
2512
|
+
key: "fontSize",
|
|
2513
|
+
label: "Font size",
|
|
2514
|
+
type: "number",
|
|
2515
|
+
defaultValue: 12,
|
|
2516
|
+
description: "Terminal font size in px."
|
|
2517
|
+
},
|
|
2518
|
+
{
|
|
2519
|
+
key: "stopOnClose",
|
|
2520
|
+
label: "Stop on close",
|
|
2521
|
+
type: "boolean",
|
|
2522
|
+
defaultValue: true,
|
|
2523
|
+
description: "Ask the host to end a session when its tab is closed. Closing a tab is explicit; unmounting is not, and never stops anything."
|
|
2524
|
+
},
|
|
2525
|
+
{
|
|
2526
|
+
key: "multiInstance",
|
|
2527
|
+
label: "Multiple terminals",
|
|
2528
|
+
type: "boolean",
|
|
2529
|
+
defaultValue: true,
|
|
2530
|
+
description: "Show the tab strip. Both existing implementations need more than one terminal."
|
|
2531
|
+
}
|
|
2532
|
+
],
|
|
2533
|
+
capabilities: ["storage.local"],
|
|
2534
|
+
sdk: "^1.1.0"
|
|
2535
|
+
};
|
|
2536
|
+
|
|
2537
|
+
// src/ops/terminal/react/TerminalPanelView.tsx
|
|
2538
|
+
import { useEffect as useEffect2, useRef as useRef2, useSyncExternalStore as useSyncExternalStore3 } from "react";
|
|
2539
|
+
import {
|
|
2540
|
+
Badge as Badge3,
|
|
2541
|
+
EmptyState as EmptyState3,
|
|
2542
|
+
IconButton as IconButton3,
|
|
2543
|
+
SearchField as SearchField3,
|
|
2544
|
+
StatusBar as StatusBar3,
|
|
2545
|
+
TextButton as TextButton2,
|
|
2546
|
+
Toolbar as Toolbar3,
|
|
2547
|
+
ToolbarGroup as ToolbarGroup3
|
|
2548
|
+
} from "@xenosystem/workbench/primitives/react";
|
|
2549
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
2550
|
+
function CloseIcon() {
|
|
2551
|
+
return /* @__PURE__ */ jsx5("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: /* @__PURE__ */ jsx5("path", { d: "M18 6 6 18M6 6l12 12" }) });
|
|
2552
|
+
}
|
|
2553
|
+
function PlusIcon() {
|
|
2554
|
+
return /* @__PURE__ */ jsx5("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: /* @__PURE__ */ jsx5("path", { d: "M12 5v14M5 12h14" }) });
|
|
2555
|
+
}
|
|
2556
|
+
function TerminalSurface({
|
|
2557
|
+
id,
|
|
2558
|
+
controller,
|
|
2559
|
+
createEmulator,
|
|
2560
|
+
visible: visible2
|
|
2561
|
+
}) {
|
|
2562
|
+
const ref = useRef2(null);
|
|
2563
|
+
useEffect2(() => {
|
|
2564
|
+
const element = ref.current;
|
|
2565
|
+
if (!element) return;
|
|
2566
|
+
let detach = null;
|
|
2567
|
+
let cancelled = false;
|
|
2568
|
+
void createEmulator().then((emulator) => {
|
|
2569
|
+
if (cancelled) {
|
|
2570
|
+
emulator.dispose();
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
detach = controller.attach(id, emulator, element);
|
|
2574
|
+
});
|
|
2575
|
+
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => controller.fit(id)) : null;
|
|
2576
|
+
if (observer) observer.observe(element);
|
|
2577
|
+
return () => {
|
|
2578
|
+
cancelled = true;
|
|
2579
|
+
observer?.disconnect();
|
|
2580
|
+
detach?.();
|
|
2581
|
+
};
|
|
2582
|
+
}, [id, controller, createEmulator]);
|
|
2583
|
+
useEffect2(() => {
|
|
2584
|
+
if (visible2) controller.fit(id);
|
|
2585
|
+
}, [visible2, id, controller]);
|
|
2586
|
+
return /* @__PURE__ */ jsx5(
|
|
2587
|
+
"div",
|
|
2588
|
+
{
|
|
2589
|
+
ref,
|
|
2590
|
+
"data-terminal": id,
|
|
2591
|
+
style: {
|
|
2592
|
+
// Hidden, not unmounted: unmounting would dispose the emulator and lose the on-screen
|
|
2593
|
+
// buffer every time the user glanced at another tab.
|
|
2594
|
+
display: visible2 ? "block" : "none",
|
|
2595
|
+
position: "absolute",
|
|
2596
|
+
inset: 0
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
);
|
|
2600
|
+
}
|
|
2601
|
+
function TerminalPanelView({
|
|
2602
|
+
controller,
|
|
2603
|
+
createEmulator,
|
|
2604
|
+
multiInstance = true
|
|
2605
|
+
}) {
|
|
2606
|
+
const state = useSyncExternalStore3(controller.subscribe, controller.getState, controller.getState);
|
|
2607
|
+
const active = state.instances.find((i) => i.active);
|
|
2608
|
+
if (!createEmulator) {
|
|
2609
|
+
return /* @__PURE__ */ jsx5(
|
|
2610
|
+
EmptyState3,
|
|
2611
|
+
{
|
|
2612
|
+
title: "No emulator configured",
|
|
2613
|
+
hint: "Pass `createEmulator` (or use `createXtermEmulator`) so the panel has something to render into."
|
|
2614
|
+
}
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
return /* @__PURE__ */ jsxs5("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
|
|
2618
|
+
multiInstance ? /* @__PURE__ */ jsx5(
|
|
2619
|
+
Toolbar3,
|
|
2620
|
+
{
|
|
2621
|
+
left: /* @__PURE__ */ jsx5("div", { style: { display: "flex", gap: 2, alignItems: "center", overflowX: "auto" }, children: state.instances.map((instance) => {
|
|
2622
|
+
const label = statusLabel(instance.session);
|
|
2623
|
+
return /* @__PURE__ */ jsxs5(
|
|
2624
|
+
"div",
|
|
2625
|
+
{
|
|
2626
|
+
style: { display: "flex", alignItems: "center", gap: 2 },
|
|
2627
|
+
children: [
|
|
2628
|
+
/* @__PURE__ */ jsx5(
|
|
2629
|
+
TextButton2,
|
|
2630
|
+
{
|
|
2631
|
+
strong: instance.active,
|
|
2632
|
+
onClick: () => controller.setActive(instance.session.id),
|
|
2633
|
+
children: instance.session.title ?? instance.session.id.slice(0, 8)
|
|
2634
|
+
}
|
|
2635
|
+
),
|
|
2636
|
+
label ? /* @__PURE__ */ jsx5(Badge3, { tone: instance.session.status === "failed" ? "error" : "neutral", children: label }) : null,
|
|
2637
|
+
/* @__PURE__ */ jsx5(
|
|
2638
|
+
IconButton3,
|
|
2639
|
+
{
|
|
2640
|
+
label: "Close terminal",
|
|
2641
|
+
size: "sm",
|
|
2642
|
+
icon: /* @__PURE__ */ jsx5(CloseIcon, {}),
|
|
2643
|
+
onClick: () => controller.close(instance.session.id)
|
|
2644
|
+
}
|
|
2645
|
+
)
|
|
2646
|
+
]
|
|
2647
|
+
},
|
|
2648
|
+
instance.session.id
|
|
2649
|
+
);
|
|
2650
|
+
}) }),
|
|
2651
|
+
right: /* @__PURE__ */ jsxs5(ToolbarGroup3, { end: true, children: [
|
|
2652
|
+
/* @__PURE__ */ jsx5(IconButton3, { label: "New terminal", icon: /* @__PURE__ */ jsx5(PlusIcon, {}), onClick: () => controller.open() }),
|
|
2653
|
+
/* @__PURE__ */ jsx5(TextButton2, { onClick: () => controller.setSearch(state.search === null ? "" : null), children: "Find" })
|
|
2654
|
+
] })
|
|
2655
|
+
}
|
|
2656
|
+
) : null,
|
|
2657
|
+
state.search !== null ? /* @__PURE__ */ jsx5(
|
|
2658
|
+
Toolbar3,
|
|
2659
|
+
{
|
|
2660
|
+
left: /* @__PURE__ */ jsx5(
|
|
2661
|
+
SearchField3,
|
|
2662
|
+
{
|
|
2663
|
+
value: state.search,
|
|
2664
|
+
onChange: (value) => controller.setSearch(value),
|
|
2665
|
+
placeholder: "Find in terminal",
|
|
2666
|
+
autoFocus: true
|
|
2667
|
+
}
|
|
2668
|
+
),
|
|
2669
|
+
right: /* @__PURE__ */ jsxs5(ToolbarGroup3, { end: true, children: [
|
|
2670
|
+
/* @__PURE__ */ jsx5(TextButton2, { onClick: () => controller.find("previous"), children: "Previous" }),
|
|
2671
|
+
/* @__PURE__ */ jsx5(TextButton2, { onClick: () => controller.find("next"), children: "Next" })
|
|
2672
|
+
] })
|
|
2673
|
+
}
|
|
2674
|
+
) : null,
|
|
2675
|
+
/* @__PURE__ */ jsx5("div", { style: { position: "relative", flex: 1, minHeight: 0 }, children: state.instances.length === 0 ? /* @__PURE__ */ jsx5(EmptyState3, { title: "No terminal", hint: "Open one to get a shell." }) : state.instances.map((instance) => /* @__PURE__ */ jsx5(
|
|
2676
|
+
TerminalSurface,
|
|
2677
|
+
{
|
|
2678
|
+
id: instance.session.id,
|
|
2679
|
+
controller,
|
|
2680
|
+
createEmulator,
|
|
2681
|
+
visible: instance.active
|
|
2682
|
+
},
|
|
2683
|
+
instance.session.id
|
|
2684
|
+
)) }),
|
|
2685
|
+
/* @__PURE__ */ jsx5(
|
|
2686
|
+
StatusBar3,
|
|
2687
|
+
{
|
|
2688
|
+
left: active?.session.cwd,
|
|
2689
|
+
right: active && active.cols ? `${active.cols}\xD7${active.rows}` : void 0
|
|
2690
|
+
}
|
|
2691
|
+
)
|
|
2692
|
+
] });
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// src/ops/terminal/panel.ts
|
|
2696
|
+
function createTerminalPanel(options = {}) {
|
|
2697
|
+
return {
|
|
2698
|
+
manifest: terminalManifest,
|
|
2699
|
+
activate(host) {
|
|
2700
|
+
const config = host.config ?? {};
|
|
2701
|
+
const controller = new TerminalController({
|
|
2702
|
+
host: { emit: (portId, value) => host.emit(portId, value) },
|
|
2703
|
+
scrollback: typeof config.scrollback === "number" ? config.scrollback : void 0,
|
|
2704
|
+
fontSize: typeof config.fontSize === "number" ? config.fontSize : void 0,
|
|
2705
|
+
stopOnClose: config.stopOnClose !== false
|
|
2706
|
+
});
|
|
2707
|
+
const resolve = (config2) => ({
|
|
2708
|
+
multiInstance: config2.multiInstance !== false
|
|
2709
|
+
});
|
|
2710
|
+
let renderConfig = resolve(host.config ?? {});
|
|
2711
|
+
const createEmulator = options.createEmulator ?? (async () => {
|
|
2712
|
+
const { createXtermEmulator } = await import("../xterm-R3GIKSHA.js");
|
|
2713
|
+
return createXtermEmulator({
|
|
2714
|
+
fontSize: controller.getState().fontSize,
|
|
2715
|
+
scrollback: controller.scrollback
|
|
2716
|
+
});
|
|
2717
|
+
});
|
|
2718
|
+
let unrender = null;
|
|
2719
|
+
let root = null;
|
|
2720
|
+
let currentEl = null;
|
|
2721
|
+
const draw = (el) => {
|
|
2722
|
+
unrender?.();
|
|
2723
|
+
if (options.render) {
|
|
2724
|
+
unrender = options.render(el, { controller, config: renderConfig });
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
root = createRoot3(el);
|
|
2728
|
+
root.render(
|
|
2729
|
+
createElement3(TerminalPanelView, { controller, createEmulator, ...renderConfig })
|
|
2730
|
+
);
|
|
2731
|
+
unrender = () => {
|
|
2732
|
+
root?.unmount();
|
|
2733
|
+
root = null;
|
|
2734
|
+
};
|
|
2735
|
+
};
|
|
2736
|
+
const unbindConfig = bindConfig3(host, (config2) => {
|
|
2737
|
+
renderConfig = resolve(config2);
|
|
2738
|
+
if (currentEl) draw(currentEl);
|
|
2739
|
+
});
|
|
2740
|
+
return {
|
|
2741
|
+
render(el) {
|
|
2742
|
+
currentEl = el;
|
|
2743
|
+
draw(el);
|
|
2744
|
+
},
|
|
2745
|
+
onInput(portId, value) {
|
|
2746
|
+
if (portId === "data") {
|
|
2747
|
+
const payload = value;
|
|
2748
|
+
if (payload?.id && typeof payload.data === "string") controller.write(payload);
|
|
2749
|
+
} else if (portId === "session") {
|
|
2750
|
+
const payload = value;
|
|
2751
|
+
if (typeof payload?.id === "string") {
|
|
2752
|
+
controller.open(payload.id, payload);
|
|
2753
|
+
controller.setSession(payload);
|
|
2754
|
+
}
|
|
2755
|
+
} else if (portId === "snapshot") {
|
|
2756
|
+
const payload = value;
|
|
2757
|
+
if (payload?.id && typeof payload.data === "string") {
|
|
2758
|
+
controller.replace(payload.id, payload.data);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
},
|
|
2762
|
+
async onCommand(commandId, params) {
|
|
2763
|
+
switch (commandId) {
|
|
2764
|
+
case "get_sessions": {
|
|
2765
|
+
const state = controller.getState();
|
|
2766
|
+
return state.instances.map((i) => ({
|
|
2767
|
+
id: i.session.id,
|
|
2768
|
+
status: i.session.status,
|
|
2769
|
+
title: i.session.title,
|
|
2770
|
+
cwd: i.session.cwd,
|
|
2771
|
+
cols: i.cols,
|
|
2772
|
+
rows: i.rows
|
|
2773
|
+
}));
|
|
2774
|
+
}
|
|
2775
|
+
case "open":
|
|
2776
|
+
return controller.open(void 0, {
|
|
2777
|
+
cwd: typeof params.cwd === "string" ? params.cwd : void 0
|
|
2778
|
+
});
|
|
2779
|
+
case "close":
|
|
2780
|
+
return controller.close(String(params.id));
|
|
2781
|
+
case "set_active":
|
|
2782
|
+
controller.setActive(String(params.id));
|
|
2783
|
+
return true;
|
|
2784
|
+
case "find":
|
|
2785
|
+
controller.setSearch(String(params.query ?? ""));
|
|
2786
|
+
return controller.find(params.direction === "previous" ? "previous" : "next");
|
|
2787
|
+
default:
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
},
|
|
2791
|
+
serialize() {
|
|
2792
|
+
return controller.serialize();
|
|
2793
|
+
},
|
|
2794
|
+
deserialize(state) {
|
|
2795
|
+
controller.deserialize(state);
|
|
2796
|
+
},
|
|
2797
|
+
dispose() {
|
|
2798
|
+
unbindConfig();
|
|
2799
|
+
currentEl = null;
|
|
2800
|
+
unrender?.();
|
|
2801
|
+
unrender = null;
|
|
2802
|
+
controller.dispose();
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
var terminalPanel = createTerminalPanel();
|
|
2809
|
+
|
|
2810
|
+
// src/ops/diff/panel.ts
|
|
2811
|
+
import { createElement as createElement4 } from "react";
|
|
2812
|
+
import { createRoot as createRoot4 } from "react-dom/client";
|
|
2813
|
+
import {
|
|
2814
|
+
bindConfig as bindConfig4
|
|
2815
|
+
} from "@xenosystem/panel-sdk";
|
|
2816
|
+
|
|
2817
|
+
// src/ops/diff/types.ts
|
|
2818
|
+
function isSettled(state) {
|
|
2819
|
+
return state === "applied";
|
|
2820
|
+
}
|
|
2821
|
+
function needsAttention(state) {
|
|
2822
|
+
return state === "conflict" || state === "failed";
|
|
2823
|
+
}
|
|
2824
|
+
function countChanges(file) {
|
|
2825
|
+
if (file.additions !== void 0 && file.deletions !== void 0) {
|
|
2826
|
+
return { additions: file.additions, deletions: file.deletions };
|
|
2827
|
+
}
|
|
2828
|
+
let additions = 0;
|
|
2829
|
+
let deletions = 0;
|
|
2830
|
+
for (const hunk of file.hunks) {
|
|
2831
|
+
for (const line of hunk.lines) {
|
|
2832
|
+
if (line.kind === "added") additions += 1;
|
|
2833
|
+
else if (line.kind === "removed") deletions += 1;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
return { additions, deletions };
|
|
2837
|
+
}
|
|
2838
|
+
function splitRows(hunk) {
|
|
2839
|
+
const rows = [];
|
|
2840
|
+
let index = 0;
|
|
2841
|
+
while (index < hunk.lines.length) {
|
|
2842
|
+
const line = hunk.lines[index];
|
|
2843
|
+
if (line.kind === "context") {
|
|
2844
|
+
rows.push({ left: line, right: line });
|
|
2845
|
+
index += 1;
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
const removed = [];
|
|
2849
|
+
const added = [];
|
|
2850
|
+
while (index < hunk.lines.length && hunk.lines[index].kind !== "context") {
|
|
2851
|
+
if (hunk.lines[index].kind === "removed") removed.push(hunk.lines[index]);
|
|
2852
|
+
else added.push(hunk.lines[index]);
|
|
2853
|
+
index += 1;
|
|
2854
|
+
}
|
|
2855
|
+
const height = Math.max(removed.length, added.length);
|
|
2856
|
+
for (let i = 0; i < height; i += 1) {
|
|
2857
|
+
rows.push({ left: removed[i] ?? null, right: added[i] ?? null });
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
return rows;
|
|
2861
|
+
}
|
|
2862
|
+
function flattenHunks(model) {
|
|
2863
|
+
const out = [];
|
|
2864
|
+
for (const file of model.files) {
|
|
2865
|
+
for (const hunk of file.hunks) out.push({ path: file.path, hunkId: hunk.id });
|
|
2866
|
+
}
|
|
2867
|
+
return out;
|
|
2868
|
+
}
|
|
2869
|
+
function omissionLabel(omission) {
|
|
2870
|
+
switch (omission) {
|
|
2871
|
+
case "binary":
|
|
2872
|
+
return "binary \u2014 not compared";
|
|
2873
|
+
case "tooLarge":
|
|
2874
|
+
return "too large \u2014 not compared";
|
|
2875
|
+
case "unreadable":
|
|
2876
|
+
return "could not be read";
|
|
2877
|
+
case "identical":
|
|
2878
|
+
return "no changes";
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// src/ops/diff/controller.ts
|
|
2883
|
+
var EMPTY = { files: [], status: "idle" };
|
|
2884
|
+
var DiffController = class {
|
|
2885
|
+
host;
|
|
2886
|
+
reviewEnabled;
|
|
2887
|
+
model = EMPTY;
|
|
2888
|
+
rev = -1;
|
|
2889
|
+
decisions = /* @__PURE__ */ new Map();
|
|
2890
|
+
view;
|
|
2891
|
+
collapsed = /* @__PURE__ */ new Set();
|
|
2892
|
+
wrap = false;
|
|
2893
|
+
showWhitespace = false;
|
|
2894
|
+
cursor = null;
|
|
2895
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2896
|
+
snapshot = null;
|
|
2897
|
+
constructor(options) {
|
|
2898
|
+
this.host = options.host;
|
|
2899
|
+
this.view = options.view ?? "unified";
|
|
2900
|
+
this.reviewEnabled = options.review === true;
|
|
2901
|
+
}
|
|
2902
|
+
/* ── Subscription ──────────────────────────────────────────────────────── */
|
|
2903
|
+
subscribe = (listener) => {
|
|
2904
|
+
this.listeners.add(listener);
|
|
2905
|
+
return () => this.listeners.delete(listener);
|
|
2906
|
+
};
|
|
2907
|
+
getState = () => {
|
|
2908
|
+
if (!this.snapshot) {
|
|
2909
|
+
let additions = 0;
|
|
2910
|
+
let deletions = 0;
|
|
2911
|
+
let hunks = 0;
|
|
2912
|
+
const files = this.model.files.map((file) => {
|
|
2913
|
+
const counts = countChanges(file);
|
|
2914
|
+
additions += counts.additions;
|
|
2915
|
+
deletions += counts.deletions;
|
|
2916
|
+
hunks += file.hunks.length;
|
|
2917
|
+
const fileDecisions = {};
|
|
2918
|
+
for (const hunk of file.hunks) {
|
|
2919
|
+
const decision = this.decisions.get(this.key(file.path, hunk.id));
|
|
2920
|
+
if (decision) fileDecisions[hunk.id] = decision;
|
|
2921
|
+
}
|
|
2922
|
+
return {
|
|
2923
|
+
file,
|
|
2924
|
+
collapsed: this.collapsed.has(file.path),
|
|
2925
|
+
decisions: fileDecisions,
|
|
2926
|
+
additions: counts.additions,
|
|
2927
|
+
deletions: counts.deletions
|
|
2928
|
+
};
|
|
2929
|
+
});
|
|
2930
|
+
const all = [...this.decisions.values()];
|
|
2931
|
+
const status = this.model.status ?? (this.model.files.length > 0 ? "ready" : "idle");
|
|
2932
|
+
this.snapshot = {
|
|
2933
|
+
files,
|
|
2934
|
+
status,
|
|
2935
|
+
message: this.model.message ?? null,
|
|
2936
|
+
view: this.view,
|
|
2937
|
+
wrap: this.wrap,
|
|
2938
|
+
showWhitespace: this.showWhitespace,
|
|
2939
|
+
oldLabel: this.model.oldLabel ?? "Original",
|
|
2940
|
+
newLabel: this.model.newLabel ?? "Modified",
|
|
2941
|
+
totals: { files: this.model.files.length, additions, deletions, hunks },
|
|
2942
|
+
review: this.reviewEnabled && hunks > 0 ? {
|
|
2943
|
+
decided: all.filter((d) => isSettled(d.state)).length,
|
|
2944
|
+
total: hunks,
|
|
2945
|
+
conflicts: all.filter((d) => d.state === "conflict").length,
|
|
2946
|
+
pending: all.filter((d) => d.state === "pending").length
|
|
2947
|
+
} : null,
|
|
2948
|
+
// 🔴 "Nothing changed" is claimed ONLY when the comparison actually ran and every file was
|
|
2949
|
+
// compared. A binary file, a file past the size ceiling and an unreadable one all have zero
|
|
2950
|
+
// hunks and none of them means the content is the same.
|
|
2951
|
+
identical: status === "ready" && hunks === 0 && this.model.files.length > 0 && this.model.files.every((f) => f.omitted === void 0 || f.omitted === "identical"),
|
|
2952
|
+
cursor: this.cursor
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
return this.snapshot;
|
|
2956
|
+
};
|
|
2957
|
+
notify() {
|
|
2958
|
+
this.snapshot = null;
|
|
2959
|
+
for (const listener of this.listeners) listener();
|
|
2960
|
+
}
|
|
2961
|
+
key(path, hunkId) {
|
|
2962
|
+
return `${path}\0${hunkId}`;
|
|
2963
|
+
}
|
|
2964
|
+
/* ── Host-pushed state ─────────────────────────────────────────────────── */
|
|
2965
|
+
/**
|
|
2966
|
+
* Install a comparison.
|
|
2967
|
+
*
|
|
2968
|
+
* @param model - The host-computed diff.
|
|
2969
|
+
* @returns Whether it was applied (an older `rev` is ignored).
|
|
2970
|
+
*/
|
|
2971
|
+
setModel(model) {
|
|
2972
|
+
if (model.rev !== void 0 && model.rev <= this.rev) return false;
|
|
2973
|
+
if (model.rev !== void 0) this.rev = model.rev;
|
|
2974
|
+
this.model = { ...model, files: model.files ?? [] };
|
|
2975
|
+
const live = /* @__PURE__ */ new Set();
|
|
2976
|
+
for (const file of this.model.files) {
|
|
2977
|
+
for (const hunk of file.hunks) live.add(this.key(file.path, hunk.id));
|
|
2978
|
+
}
|
|
2979
|
+
for (const key of [...this.decisions.keys()]) {
|
|
2980
|
+
if (!live.has(key)) this.decisions.delete(key);
|
|
2981
|
+
}
|
|
2982
|
+
if (this.cursor && !live.has(this.key(this.cursor.path, this.cursor.hunkId))) this.cursor = null;
|
|
2983
|
+
if (!this.cursor) this.cursor = flattenHunks(this.model)[0] ?? null;
|
|
2984
|
+
this.notify();
|
|
2985
|
+
return true;
|
|
2986
|
+
}
|
|
2987
|
+
/** Set the comparison's status without replacing the model. */
|
|
2988
|
+
setStatus(status, message) {
|
|
2989
|
+
this.model = { ...this.model, status, message };
|
|
2990
|
+
if (status === "computing" || status === "failed") {
|
|
2991
|
+
this.model = { ...this.model, files: status === "failed" ? [] : this.model.files };
|
|
2992
|
+
}
|
|
2993
|
+
this.notify();
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Record a host-reported decision outcome.
|
|
2997
|
+
*
|
|
2998
|
+
* The **only** way a decision reaches `applied`. The panel writes `pending` when it asks, and
|
|
2999
|
+
* nothing else.
|
|
3000
|
+
*
|
|
3001
|
+
* @param decision - The outcome.
|
|
3002
|
+
* @returns Whether it was recorded.
|
|
3003
|
+
*/
|
|
3004
|
+
setDecision(decision) {
|
|
3005
|
+
if (!decision?.hunkId || !decision.path) return false;
|
|
3006
|
+
this.decisions.set(this.key(decision.path, decision.hunkId), decision);
|
|
3007
|
+
this.notify();
|
|
3008
|
+
return true;
|
|
3009
|
+
}
|
|
3010
|
+
/** Record several at once. */
|
|
3011
|
+
setDecisions(decisions) {
|
|
3012
|
+
for (const decision of decisions) {
|
|
3013
|
+
if (decision?.hunkId && decision.path) {
|
|
3014
|
+
this.decisions.set(this.key(decision.path, decision.hunkId), decision);
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
this.notify();
|
|
3018
|
+
}
|
|
3019
|
+
/* ── Decisions ─────────────────────────────────────────────────────────── */
|
|
3020
|
+
/**
|
|
3021
|
+
* Decide a hunk.
|
|
3022
|
+
*
|
|
3023
|
+
* Emits an intent and marks the decision `pending`. **It does not apply anything and does not
|
|
3024
|
+
* render as accepted.** The surveyed host runs a two-phase confirm and a checkpointed filesystem
|
|
3025
|
+
* transaction whose outcome may be `applied`, `failed` or `conflict`; a panel that showed a tick
|
|
3026
|
+
* on click would be asserting the outcome of work that has not started.
|
|
3027
|
+
*
|
|
3028
|
+
* @param path - The file.
|
|
3029
|
+
* @param hunkId - The hunk.
|
|
3030
|
+
* @param action - accept, reject or comment.
|
|
3031
|
+
* @param comment - For `comment`, or a note on either.
|
|
3032
|
+
* @returns Whether an intent was emitted.
|
|
3033
|
+
*/
|
|
3034
|
+
decide(path, hunkId, action, comment) {
|
|
3035
|
+
if (!this.reviewEnabled) return false;
|
|
3036
|
+
const file = this.model.files.find((f) => f.path === path);
|
|
3037
|
+
if (!file?.hunks.some((h) => h.id === hunkId)) return false;
|
|
3038
|
+
const existing = this.decisions.get(this.key(path, hunkId));
|
|
3039
|
+
if (existing?.state === "pending") return false;
|
|
3040
|
+
this.decisions.set(this.key(path, hunkId), {
|
|
3041
|
+
path,
|
|
3042
|
+
hunkId,
|
|
3043
|
+
action,
|
|
3044
|
+
comment,
|
|
3045
|
+
state: "pending",
|
|
3046
|
+
updatedAt: Date.now()
|
|
3047
|
+
});
|
|
3048
|
+
this.host.emit("decision", { path, hunkId, action, comment });
|
|
3049
|
+
this.notify();
|
|
3050
|
+
return true;
|
|
3051
|
+
}
|
|
3052
|
+
/** Clear a decision locally so it can be re-made — the remedy for a conflict. */
|
|
3053
|
+
clearDecision(path, hunkId) {
|
|
3054
|
+
const removed = this.decisions.delete(this.key(path, hunkId));
|
|
3055
|
+
if (removed) this.notify();
|
|
3056
|
+
return removed;
|
|
3057
|
+
}
|
|
3058
|
+
/** Every recorded decision. */
|
|
3059
|
+
allDecisions() {
|
|
3060
|
+
return [...this.decisions.values()];
|
|
3061
|
+
}
|
|
3062
|
+
/* ── The hunk cursor ───────────────────────────────────────────────────── */
|
|
3063
|
+
/** Move to the next hunk, across file boundaries. */
|
|
3064
|
+
nextHunk() {
|
|
3065
|
+
return this.moveCursor(1);
|
|
3066
|
+
}
|
|
3067
|
+
/** Move to the previous hunk. */
|
|
3068
|
+
previousHunk() {
|
|
3069
|
+
return this.moveCursor(-1);
|
|
3070
|
+
}
|
|
3071
|
+
moveCursor(delta) {
|
|
3072
|
+
const all = flattenHunks(this.model);
|
|
3073
|
+
if (all.length === 0) return null;
|
|
3074
|
+
const at = this.cursor ? all.findIndex((h) => h.path === this.cursor?.path && h.hunkId === this.cursor?.hunkId) : -1;
|
|
3075
|
+
const next = Math.max(0, Math.min(all.length - 1, at + delta));
|
|
3076
|
+
this.cursor = all[next] ?? null;
|
|
3077
|
+
if (this.cursor) this.emitNavigate(this.cursor.path, this.cursor.hunkId);
|
|
3078
|
+
this.notify();
|
|
3079
|
+
return this.cursor;
|
|
3080
|
+
}
|
|
3081
|
+
/** Point at a specific hunk. */
|
|
3082
|
+
focusHunk(path, hunkId) {
|
|
3083
|
+
const file = this.model.files.find((f) => f.path === path);
|
|
3084
|
+
if (!file?.hunks.some((h) => h.id === hunkId)) return false;
|
|
3085
|
+
this.cursor = { path, hunkId };
|
|
3086
|
+
this.emitNavigate(path, hunkId);
|
|
3087
|
+
this.notify();
|
|
3088
|
+
return true;
|
|
3089
|
+
}
|
|
3090
|
+
emitNavigate(path, hunkId) {
|
|
3091
|
+
const file = this.model.files.find((f) => f.path === path);
|
|
3092
|
+
const hunk = file?.hunks.find((h) => h.id === hunkId);
|
|
3093
|
+
this.host.emit("navigate", { path, hunkId, line: hunk?.newStart ?? hunk?.oldStart });
|
|
3094
|
+
}
|
|
3095
|
+
/* ── View state ────────────────────────────────────────────────────────── */
|
|
3096
|
+
/** Unified or split. */
|
|
3097
|
+
setView(view) {
|
|
3098
|
+
this.view = view;
|
|
3099
|
+
this.notify();
|
|
3100
|
+
}
|
|
3101
|
+
/** Collapse or expand a file. */
|
|
3102
|
+
setCollapsed(path, collapsed) {
|
|
3103
|
+
if (collapsed) this.collapsed.add(path);
|
|
3104
|
+
else this.collapsed.delete(path);
|
|
3105
|
+
this.notify();
|
|
3106
|
+
}
|
|
3107
|
+
/** Soft-wrap long lines. */
|
|
3108
|
+
setWrap(wrap) {
|
|
3109
|
+
this.wrap = wrap;
|
|
3110
|
+
this.notify();
|
|
3111
|
+
}
|
|
3112
|
+
/** Render whitespace characters. */
|
|
3113
|
+
setShowWhitespace(show) {
|
|
3114
|
+
this.showWhitespace = show;
|
|
3115
|
+
this.notify();
|
|
3116
|
+
}
|
|
3117
|
+
/** Ask the host for a context menu. */
|
|
3118
|
+
requestContextMenu(path, hunkId, x, y) {
|
|
3119
|
+
this.host.emit("contextMenuRequest", { path, hunkId, x, y });
|
|
3120
|
+
}
|
|
3121
|
+
/* ── Lifecycle ─────────────────────────────────────────────────────────── */
|
|
3122
|
+
/**
|
|
3123
|
+
* Serialize.
|
|
3124
|
+
*
|
|
3125
|
+
* View preferences only. **Never the model and never the decisions** — the model is a comparison
|
|
3126
|
+
* of content that has almost certainly moved on, and a restored decision would claim a review
|
|
3127
|
+
* outcome the host never confirmed.
|
|
3128
|
+
*/
|
|
3129
|
+
serialize() {
|
|
3130
|
+
return {
|
|
3131
|
+
view: this.view,
|
|
3132
|
+
collapsed: [...this.collapsed],
|
|
3133
|
+
wrap: this.wrap,
|
|
3134
|
+
showWhitespace: this.showWhitespace
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
/** Restore view preferences. */
|
|
3138
|
+
deserialize(state) {
|
|
3139
|
+
if (!state || typeof state !== "object") return;
|
|
3140
|
+
const s = state;
|
|
3141
|
+
if (s.view === "unified" || s.view === "split") this.view = s.view;
|
|
3142
|
+
if (Array.isArray(s.collapsed)) this.collapsed = new Set(s.collapsed);
|
|
3143
|
+
if (typeof s.wrap === "boolean") this.wrap = s.wrap;
|
|
3144
|
+
if (typeof s.showWhitespace === "boolean") this.showWhitespace = s.showWhitespace;
|
|
3145
|
+
this.notify();
|
|
3146
|
+
}
|
|
3147
|
+
/** Tear down. */
|
|
3148
|
+
dispose() {
|
|
3149
|
+
this.listeners.clear();
|
|
3150
|
+
this.model = EMPTY;
|
|
3151
|
+
this.decisions.clear();
|
|
3152
|
+
}
|
|
3153
|
+
};
|
|
3154
|
+
|
|
3155
|
+
// src/ops/diff/manifest.ts
|
|
3156
|
+
import { WELL_KNOWN_PORT_SCHEMAS as WELL_KNOWN_PORT_SCHEMAS2 } from "@xenosystem/panel-sdk";
|
|
3157
|
+
var DIFF_PANEL_ID = "xeno.core.diff";
|
|
3158
|
+
var diffManifest = {
|
|
3159
|
+
id: DIFF_PANEL_ID,
|
|
3160
|
+
version: "0.1.0",
|
|
3161
|
+
title: "Diff",
|
|
3162
|
+
icon: "file-diff",
|
|
3163
|
+
description: "Renders a host-computed comparison \u2014 unified or split, line or word marks \u2014 with optional hunk-level accept/reject as intents. Bundles no diff engine and no editor.",
|
|
3164
|
+
defaultSlot: "center",
|
|
3165
|
+
inputs: [
|
|
3166
|
+
{
|
|
3167
|
+
id: "model",
|
|
3168
|
+
name: "Model",
|
|
3169
|
+
type: "object",
|
|
3170
|
+
schema: WELL_KNOWN_PORT_SCHEMAS2.DIFF_MODEL,
|
|
3171
|
+
description: "{files[], status?, message?, oldLabel?, newLabel?, rev?}. A file with no hunks carries `omitted` when the reason is not equality \u2014 binary, too large, unreadable.",
|
|
3172
|
+
multiple: false
|
|
3173
|
+
},
|
|
3174
|
+
{
|
|
3175
|
+
id: "decisions",
|
|
3176
|
+
name: "Decisions",
|
|
3177
|
+
type: "array",
|
|
3178
|
+
schema: WELL_KNOWN_PORT_SCHEMAS2.DIFF_DECISION,
|
|
3179
|
+
description: "Host-reported outcomes. The ONLY way a decision reaches `applied`; the panel writes `pending` when it asks and nothing else.",
|
|
3180
|
+
multiple: true
|
|
3181
|
+
},
|
|
3182
|
+
{
|
|
3183
|
+
id: "status",
|
|
3184
|
+
name: "Status",
|
|
3185
|
+
type: "object",
|
|
3186
|
+
description: "{status, message?} \u2014 idle | computing | ready | failed, out of band, so a host never has to express a failed comparison as an empty file list.",
|
|
3187
|
+
multiple: false
|
|
3188
|
+
}
|
|
3189
|
+
],
|
|
3190
|
+
outputs: [
|
|
3191
|
+
{
|
|
3192
|
+
id: "decision",
|
|
3193
|
+
name: "Decision",
|
|
3194
|
+
type: "object",
|
|
3195
|
+
schema: WELL_KNOWN_PORT_SCHEMAS2.DIFF_DECISION_INTENT,
|
|
3196
|
+
description: "{path, hunkId, action, comment?}. An INTENT \u2014 the host runs its confirm-and-commit and reports back `applied` / `failed` / `conflict`."
|
|
3197
|
+
},
|
|
3198
|
+
{
|
|
3199
|
+
id: "navigate",
|
|
3200
|
+
name: "Navigate",
|
|
3201
|
+
type: "object",
|
|
3202
|
+
description: "{path, hunkId, line?} \u2014 reveal this hunk in the editor."
|
|
3203
|
+
},
|
|
3204
|
+
{
|
|
3205
|
+
id: "contextMenuRequest",
|
|
3206
|
+
name: "Context Menu",
|
|
3207
|
+
type: "object",
|
|
3208
|
+
description: "{path, hunkId, x, y}. Deliberately untagged \u2014 the payload is panel-local."
|
|
3209
|
+
}
|
|
3210
|
+
],
|
|
3211
|
+
commands: [
|
|
3212
|
+
{
|
|
3213
|
+
id: "get_summary",
|
|
3214
|
+
title: "Get Summary",
|
|
3215
|
+
description: 'Return per-file counts, omission reasons and review progress. Includes `identical` so a caller cannot mistake "not compared" for "unchanged".',
|
|
3216
|
+
parameters: {}
|
|
3217
|
+
},
|
|
3218
|
+
{
|
|
3219
|
+
id: "next_hunk",
|
|
3220
|
+
title: "Next Hunk",
|
|
3221
|
+
description: "Move the cursor to the next hunk. Clamps at the end; never wraps.",
|
|
3222
|
+
parameters: {}
|
|
3223
|
+
},
|
|
3224
|
+
{
|
|
3225
|
+
id: "previous_hunk",
|
|
3226
|
+
title: "Previous Hunk",
|
|
3227
|
+
description: "Move the cursor back.",
|
|
3228
|
+
parameters: {}
|
|
3229
|
+
},
|
|
3230
|
+
{
|
|
3231
|
+
id: "set_view",
|
|
3232
|
+
title: "Set View",
|
|
3233
|
+
description: "unified | split.",
|
|
3234
|
+
parameters: { view: { type: "string", description: "unified | split", required: true } }
|
|
3235
|
+
}
|
|
3236
|
+
// NOTE: `accept` and `reject` are deliberately NOT commands. A hunk decision rewrites the
|
|
3237
|
+
// user's files, and the surveyed host implements it as a two-phase confirm precisely because a
|
|
3238
|
+
// human is meant to be in the loop. An agent-invocable accept would let an agent approve its
|
|
3239
|
+
// OWN patch — the review step exists to check the agent, and the agent must not be able to
|
|
3240
|
+
// operate the thing that checks it.
|
|
3241
|
+
],
|
|
3242
|
+
config: [
|
|
3243
|
+
{
|
|
3244
|
+
key: "view",
|
|
3245
|
+
label: "Default view",
|
|
3246
|
+
type: "select",
|
|
3247
|
+
defaultValue: "unified",
|
|
3248
|
+
options: [
|
|
3249
|
+
{ label: "Unified", value: "unified" },
|
|
3250
|
+
{ label: "Split", value: "split" }
|
|
3251
|
+
],
|
|
3252
|
+
description: "Unified reads better inline; split reads better for wide rewrites."
|
|
3253
|
+
},
|
|
3254
|
+
{
|
|
3255
|
+
key: "review",
|
|
3256
|
+
label: "Allow accept/reject",
|
|
3257
|
+
type: "boolean",
|
|
3258
|
+
defaultValue: false,
|
|
3259
|
+
description: "Offer hunk decisions. Off by default \u2014 most diffs are read-only, and a review surface that appears where nobody can act is worse than none."
|
|
3260
|
+
},
|
|
3261
|
+
{
|
|
3262
|
+
key: "wrap",
|
|
3263
|
+
label: "Wrap long lines",
|
|
3264
|
+
type: "boolean",
|
|
3265
|
+
defaultValue: false,
|
|
3266
|
+
description: "Soft-wrap instead of scrolling horizontally."
|
|
3267
|
+
},
|
|
3268
|
+
{
|
|
3269
|
+
key: "showWhitespace",
|
|
3270
|
+
label: "Show whitespace",
|
|
3271
|
+
type: "boolean",
|
|
3272
|
+
defaultValue: false,
|
|
3273
|
+
description: "Render spaces and tabs \u2014 the only way to read a whitespace-only change."
|
|
3274
|
+
},
|
|
3275
|
+
{
|
|
3276
|
+
key: "contextLines",
|
|
3277
|
+
label: "Context lines",
|
|
3278
|
+
type: "number",
|
|
3279
|
+
defaultValue: 3,
|
|
3280
|
+
description: "Advisory: passed to the host, which computes the hunks."
|
|
3281
|
+
}
|
|
3282
|
+
],
|
|
3283
|
+
capabilities: ["storage.local"],
|
|
3284
|
+
sdk: "^1.1.0"
|
|
3285
|
+
};
|
|
3286
|
+
|
|
3287
|
+
// src/ops/diff/react/DiffPanelView.tsx
|
|
3288
|
+
import { useSyncExternalStore as useSyncExternalStore4 } from "react";
|
|
3289
|
+
import {
|
|
3290
|
+
Badge as Badge4,
|
|
3291
|
+
EmptyState as EmptyState4,
|
|
3292
|
+
ScrollArea as ScrollArea3,
|
|
3293
|
+
SegmentedControl as SegmentedControl2,
|
|
3294
|
+
StatusBar as StatusBar4,
|
|
3295
|
+
TextButton as TextButton3,
|
|
3296
|
+
Toolbar as Toolbar4,
|
|
3297
|
+
ToolbarGroup as ToolbarGroup4
|
|
3298
|
+
} from "@xenosystem/workbench/primitives/react";
|
|
3299
|
+
import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
3300
|
+
var MARK = {
|
|
3301
|
+
added: { background: "var(--xeno-hover)" },
|
|
3302
|
+
removed: { background: "var(--xeno-border-subtle)" },
|
|
3303
|
+
context: {}
|
|
3304
|
+
};
|
|
3305
|
+
var SIGIL = { added: "+", removed: "-", context: " " };
|
|
3306
|
+
function visible(text, show) {
|
|
3307
|
+
return show ? text.replace(/\t/g, "\u2192 ").replace(/ /g, "\xB7") : text;
|
|
3308
|
+
}
|
|
3309
|
+
function LineText({
|
|
3310
|
+
line,
|
|
3311
|
+
language,
|
|
3312
|
+
highlighter,
|
|
3313
|
+
showWhitespace
|
|
3314
|
+
}) {
|
|
3315
|
+
if (line.segments?.length) {
|
|
3316
|
+
return /* @__PURE__ */ jsx6(Fragment3, { children: line.segments.map((segment, i) => /* @__PURE__ */ jsx6(
|
|
3317
|
+
"span",
|
|
3318
|
+
{
|
|
3319
|
+
style: segment.kind === "equal" ? void 0 : {
|
|
3320
|
+
background: "var(--xeno-border)",
|
|
3321
|
+
borderRadius: 2,
|
|
3322
|
+
textDecoration: segment.kind === "delete" ? "line-through" : void 0
|
|
3323
|
+
},
|
|
3324
|
+
children: visible(segment.text, showWhitespace)
|
|
3325
|
+
},
|
|
3326
|
+
i
|
|
3327
|
+
)) });
|
|
3328
|
+
}
|
|
3329
|
+
const tokens = highlighter?.highlight(line.text, language) ?? null;
|
|
3330
|
+
if (tokens) {
|
|
3331
|
+
return /* @__PURE__ */ jsx6(Fragment3, { children: tokens.map((token, i) => /* @__PURE__ */ jsx6("span", { className: token.token ? `xeno-tok-${token.token}` : void 0, children: visible(token.text, showWhitespace) }, i)) });
|
|
3332
|
+
}
|
|
3333
|
+
return /* @__PURE__ */ jsx6(Fragment3, { children: visible(line.text, showWhitespace) });
|
|
3334
|
+
}
|
|
3335
|
+
var GUTTER = {
|
|
3336
|
+
display: "inline-block",
|
|
3337
|
+
width: 40,
|
|
3338
|
+
textAlign: "right",
|
|
3339
|
+
paddingRight: 6,
|
|
3340
|
+
opacity: 0.35,
|
|
3341
|
+
fontVariantNumeric: "tabular-nums",
|
|
3342
|
+
userSelect: "none"
|
|
3343
|
+
};
|
|
3344
|
+
function DecisionChip({ decision }) {
|
|
3345
|
+
if (!decision) return null;
|
|
3346
|
+
if (decision.state === "pending") return /* @__PURE__ */ jsx6(Badge4, { children: "deciding\u2026" });
|
|
3347
|
+
if (decision.state === "conflict") {
|
|
3348
|
+
return /* @__PURE__ */ jsx6(Badge4, { tone: "error", title: decision.message ?? "The file changed since this was reviewed.", children: "conflict \u2014 re-review" });
|
|
3349
|
+
}
|
|
3350
|
+
if (decision.state === "failed") {
|
|
3351
|
+
return /* @__PURE__ */ jsx6(Badge4, { tone: "error", title: decision.message ?? void 0, children: "failed" });
|
|
3352
|
+
}
|
|
3353
|
+
if (isSettled(decision.state)) {
|
|
3354
|
+
return /* @__PURE__ */ jsx6(Badge4, { tone: "success", children: decision.action === "accept" ? "accepted" : "rejected" });
|
|
3355
|
+
}
|
|
3356
|
+
return null;
|
|
3357
|
+
}
|
|
3358
|
+
function DiffPanelView({ controller, highlighter, review = false }) {
|
|
3359
|
+
const state = useSyncExternalStore4(controller.subscribe, controller.getState, controller.getState);
|
|
3360
|
+
if (state.status === "idle") {
|
|
3361
|
+
return /* @__PURE__ */ jsx6(EmptyState4, { title: "Diff", hint: "Nothing to compare yet." });
|
|
3362
|
+
}
|
|
3363
|
+
if (state.status === "computing") {
|
|
3364
|
+
return /* @__PURE__ */ jsx6(EmptyState4, { title: "Comparing\u2026", hint: "The host is computing the differences." });
|
|
3365
|
+
}
|
|
3366
|
+
if (state.status === "failed") {
|
|
3367
|
+
return /* @__PURE__ */ jsx6(
|
|
3368
|
+
EmptyState4,
|
|
3369
|
+
{
|
|
3370
|
+
title: "Comparison failed",
|
|
3371
|
+
hint: state.message ?? "The host could not compute the differences."
|
|
3372
|
+
}
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
if (state.identical) {
|
|
3376
|
+
return /* @__PURE__ */ jsx6(EmptyState4, { title: "No changes", hint: "The two sides are identical." });
|
|
3377
|
+
}
|
|
3378
|
+
const mono = "var(--xeno-font-mono)";
|
|
3379
|
+
const renderLine = (line, file, key) => /* @__PURE__ */ jsxs6(
|
|
3380
|
+
"div",
|
|
3381
|
+
{
|
|
3382
|
+
style: {
|
|
3383
|
+
...MARK[line.kind],
|
|
3384
|
+
whiteSpace: state.wrap ? "pre-wrap" : "pre",
|
|
3385
|
+
fontFamily: mono,
|
|
3386
|
+
fontSize: 11,
|
|
3387
|
+
lineHeight: "16px"
|
|
3388
|
+
},
|
|
3389
|
+
children: [
|
|
3390
|
+
/* @__PURE__ */ jsx6("span", { style: GUTTER, children: line.oldLineNumber ?? "" }),
|
|
3391
|
+
/* @__PURE__ */ jsx6("span", { style: GUTTER, children: line.newLineNumber ?? "" }),
|
|
3392
|
+
/* @__PURE__ */ jsxs6("span", { style: { opacity: 0.45, userSelect: "none" }, children: [
|
|
3393
|
+
SIGIL[line.kind],
|
|
3394
|
+
" "
|
|
3395
|
+
] }),
|
|
3396
|
+
/* @__PURE__ */ jsx6(
|
|
3397
|
+
LineText,
|
|
3398
|
+
{
|
|
3399
|
+
line,
|
|
3400
|
+
language: file.file.language,
|
|
3401
|
+
highlighter,
|
|
3402
|
+
showWhitespace: state.showWhitespace
|
|
3403
|
+
}
|
|
3404
|
+
)
|
|
3405
|
+
]
|
|
3406
|
+
},
|
|
3407
|
+
key
|
|
3408
|
+
);
|
|
3409
|
+
const renderSplitCell = (line, file, side) => /* @__PURE__ */ jsxs6(
|
|
3410
|
+
"div",
|
|
3411
|
+
{
|
|
3412
|
+
style: {
|
|
3413
|
+
flex: 1,
|
|
3414
|
+
minWidth: 0,
|
|
3415
|
+
...line ? MARK[line.kind] : { opacity: 0.25 },
|
|
3416
|
+
whiteSpace: state.wrap ? "pre-wrap" : "pre",
|
|
3417
|
+
overflow: "hidden",
|
|
3418
|
+
fontFamily: mono,
|
|
3419
|
+
fontSize: 11,
|
|
3420
|
+
lineHeight: "16px"
|
|
3421
|
+
},
|
|
3422
|
+
children: [
|
|
3423
|
+
/* @__PURE__ */ jsx6("span", { style: GUTTER, children: (side === "l" ? line?.oldLineNumber : line?.newLineNumber) ?? "" }),
|
|
3424
|
+
line ? /* @__PURE__ */ jsx6(
|
|
3425
|
+
LineText,
|
|
3426
|
+
{
|
|
3427
|
+
line,
|
|
3428
|
+
language: file.file.language,
|
|
3429
|
+
highlighter,
|
|
3430
|
+
showWhitespace: state.showWhitespace
|
|
3431
|
+
}
|
|
3432
|
+
) : null
|
|
3433
|
+
]
|
|
3434
|
+
}
|
|
3435
|
+
);
|
|
3436
|
+
return /* @__PURE__ */ jsxs6("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
|
|
3437
|
+
/* @__PURE__ */ jsx6(
|
|
3438
|
+
Toolbar4,
|
|
3439
|
+
{
|
|
3440
|
+
left: /* @__PURE__ */ jsx6(
|
|
3441
|
+
SegmentedControl2,
|
|
3442
|
+
{
|
|
3443
|
+
label: "View",
|
|
3444
|
+
value: state.view,
|
|
3445
|
+
onChange: (value) => controller.setView(value),
|
|
3446
|
+
options: [
|
|
3447
|
+
{ label: "Unified", value: "unified" },
|
|
3448
|
+
{ label: "Split", value: "split" }
|
|
3449
|
+
]
|
|
3450
|
+
}
|
|
3451
|
+
),
|
|
3452
|
+
right: /* @__PURE__ */ jsxs6(ToolbarGroup4, { end: true, children: [
|
|
3453
|
+
/* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.setWrap(!state.wrap), children: state.wrap ? "No wrap" : "Wrap" }),
|
|
3454
|
+
/* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.setShowWhitespace(!state.showWhitespace), children: state.showWhitespace ? "Hide \u2423" : "Show \u2423" }),
|
|
3455
|
+
/* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.previousHunk(), children: "Prev" }),
|
|
3456
|
+
/* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.nextHunk(), children: "Next" })
|
|
3457
|
+
] })
|
|
3458
|
+
}
|
|
3459
|
+
),
|
|
3460
|
+
state.review && state.review.conflicts > 0 ? /* @__PURE__ */ jsx6("div", { style: { padding: "2px 8px", fontSize: 9 }, children: /* @__PURE__ */ jsxs6(Badge4, { tone: "error", children: [
|
|
3461
|
+
state.review.conflicts,
|
|
3462
|
+
" conflict",
|
|
3463
|
+
state.review.conflicts === 1 ? "" : "s",
|
|
3464
|
+
" \u2014 the file changed since review"
|
|
3465
|
+
] }) }) : null,
|
|
3466
|
+
/* @__PURE__ */ jsx6(ScrollArea3, { children: state.files.map((file) => /* @__PURE__ */ jsxs6("div", { style: { borderBottom: "1px solid var(--xeno-border-subtle)" }, children: [
|
|
3467
|
+
/* @__PURE__ */ jsxs6(
|
|
3468
|
+
"div",
|
|
3469
|
+
{
|
|
3470
|
+
style: {
|
|
3471
|
+
display: "flex",
|
|
3472
|
+
alignItems: "center",
|
|
3473
|
+
gap: 6,
|
|
3474
|
+
padding: "3px 8px",
|
|
3475
|
+
position: "sticky",
|
|
3476
|
+
top: 0,
|
|
3477
|
+
background: "var(--xeno-surface)",
|
|
3478
|
+
zIndex: 1
|
|
3479
|
+
},
|
|
3480
|
+
children: [
|
|
3481
|
+
/* @__PURE__ */ jsx6(TextButton3, { onClick: () => controller.setCollapsed(file.file.path, !file.collapsed), children: file.collapsed ? "\u25B8" : "\u25BE" }),
|
|
3482
|
+
/* @__PURE__ */ jsx6("span", { style: { fontSize: 11, fontWeight: 600 }, children: file.file.oldPath && file.file.oldPath !== file.file.path ? `${file.file.oldPath} \u2192 ${file.file.path}` : file.file.path }),
|
|
3483
|
+
/* @__PURE__ */ jsx6(Badge4, { children: file.file.status }),
|
|
3484
|
+
/* @__PURE__ */ jsxs6("span", { style: { fontSize: 10, fontVariantNumeric: "tabular-nums", opacity: 0.6 }, children: [
|
|
3485
|
+
"+",
|
|
3486
|
+
file.additions,
|
|
3487
|
+
" \u2212",
|
|
3488
|
+
file.deletions
|
|
3489
|
+
] })
|
|
3490
|
+
]
|
|
3491
|
+
}
|
|
3492
|
+
),
|
|
3493
|
+
file.collapsed ? null : file.file.omitted ? (
|
|
3494
|
+
// 🔴 Never an empty body. "Binary — not compared" and "no changes" are different
|
|
3495
|
+
// facts, and a reviewer who reads the first as the second approves something nobody
|
|
3496
|
+
// looked at.
|
|
3497
|
+
/* @__PURE__ */ jsx6("div", { style: { padding: "6px 12px", fontSize: 10, opacity: 0.6 }, children: omissionLabel(file.file.omitted) })
|
|
3498
|
+
) : file.file.hunks.map((hunk) => {
|
|
3499
|
+
const decision = file.decisions[hunk.id];
|
|
3500
|
+
const focused = state.cursor?.path === file.file.path && state.cursor.hunkId === hunk.id;
|
|
3501
|
+
return /* @__PURE__ */ jsxs6(
|
|
3502
|
+
"div",
|
|
3503
|
+
{
|
|
3504
|
+
onContextMenu: (event) => {
|
|
3505
|
+
event.preventDefault();
|
|
3506
|
+
controller.requestContextMenu(
|
|
3507
|
+
file.file.path,
|
|
3508
|
+
hunk.id,
|
|
3509
|
+
event.clientX,
|
|
3510
|
+
event.clientY
|
|
3511
|
+
);
|
|
3512
|
+
},
|
|
3513
|
+
style: {
|
|
3514
|
+
borderLeft: focused ? "2px solid var(--xeno-active)" : "2px solid transparent",
|
|
3515
|
+
opacity: isSettled(decision?.state ?? "undecided") ? 0.55 : 1
|
|
3516
|
+
},
|
|
3517
|
+
children: [
|
|
3518
|
+
/* @__PURE__ */ jsxs6(
|
|
3519
|
+
"div",
|
|
3520
|
+
{
|
|
3521
|
+
style: {
|
|
3522
|
+
display: "flex",
|
|
3523
|
+
alignItems: "center",
|
|
3524
|
+
gap: 6,
|
|
3525
|
+
padding: "1px 8px",
|
|
3526
|
+
fontSize: 9,
|
|
3527
|
+
opacity: 0.55
|
|
3528
|
+
},
|
|
3529
|
+
children: [
|
|
3530
|
+
/* @__PURE__ */ jsx6("span", { style: { fontFamily: mono }, children: hunk.header ?? `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` }),
|
|
3531
|
+
/* @__PURE__ */ jsx6(DecisionChip, { decision }),
|
|
3532
|
+
review ? /* @__PURE__ */ jsx6("span", { style: { marginLeft: "auto", display: "flex", gap: 4 }, children: needsAttention(decision?.state ?? "undecided") ? /* @__PURE__ */ jsx6(
|
|
3533
|
+
TextButton3,
|
|
3534
|
+
{
|
|
3535
|
+
onClick: () => controller.clearDecision(file.file.path, hunk.id),
|
|
3536
|
+
children: "Re-review"
|
|
3537
|
+
}
|
|
3538
|
+
) : /* @__PURE__ */ jsxs6(Fragment3, { children: [
|
|
3539
|
+
/* @__PURE__ */ jsx6(
|
|
3540
|
+
TextButton3,
|
|
3541
|
+
{
|
|
3542
|
+
disabled: decision?.state === "pending",
|
|
3543
|
+
onClick: () => controller.decide(file.file.path, hunk.id, "accept"),
|
|
3544
|
+
children: "Accept"
|
|
3545
|
+
}
|
|
3546
|
+
),
|
|
3547
|
+
/* @__PURE__ */ jsx6(
|
|
3548
|
+
TextButton3,
|
|
3549
|
+
{
|
|
3550
|
+
disabled: decision?.state === "pending",
|
|
3551
|
+
onClick: () => controller.decide(file.file.path, hunk.id, "reject"),
|
|
3552
|
+
children: "Reject"
|
|
3553
|
+
}
|
|
3554
|
+
)
|
|
3555
|
+
] }) }) : null
|
|
3556
|
+
]
|
|
3557
|
+
}
|
|
3558
|
+
),
|
|
3559
|
+
state.view === "unified" ? hunk.lines.map((line, i) => renderLine(line, file, `${hunk.id}-${i}`)) : splitRows(hunk).map((row, i) => /* @__PURE__ */ jsxs6("div", { style: { display: "flex", gap: 1 }, children: [
|
|
3560
|
+
renderSplitCell(row.left, file, "l"),
|
|
3561
|
+
renderSplitCell(row.right, file, "r")
|
|
3562
|
+
] }, `${hunk.id}-s${i}`))
|
|
3563
|
+
]
|
|
3564
|
+
},
|
|
3565
|
+
hunk.id
|
|
3566
|
+
);
|
|
3567
|
+
})
|
|
3568
|
+
] }, file.file.path)) }),
|
|
3569
|
+
/* @__PURE__ */ jsx6(
|
|
3570
|
+
StatusBar4,
|
|
3571
|
+
{
|
|
3572
|
+
left: `${state.totals.files} file${state.totals.files === 1 ? "" : "s"} \xB7 +${state.totals.additions} \u2212${state.totals.deletions}`,
|
|
3573
|
+
right: state.review ? `${state.review.decided}/${state.review.total} reviewed` : `${state.oldLabel} \u2192 ${state.newLabel}`
|
|
3574
|
+
}
|
|
3575
|
+
)
|
|
3576
|
+
] });
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// src/ops/diff/panel.ts
|
|
3580
|
+
function createDiffPanel(options = {}) {
|
|
3581
|
+
return {
|
|
3582
|
+
manifest: diffManifest,
|
|
3583
|
+
activate(host) {
|
|
3584
|
+
const config = host.config ?? {};
|
|
3585
|
+
const controller = new DiffController({
|
|
3586
|
+
host: { emit: (portId, value) => host.emit(portId, value) },
|
|
3587
|
+
view: config.view ?? "unified",
|
|
3588
|
+
review: config.review === true
|
|
3589
|
+
});
|
|
3590
|
+
if (config.wrap === true) controller.setWrap(true);
|
|
3591
|
+
if (config.showWhitespace === true) controller.setShowWhitespace(true);
|
|
3592
|
+
const resolve = (config2) => ({ review: config2.review === true });
|
|
3593
|
+
let renderConfig = resolve(host.config ?? {});
|
|
3594
|
+
let unrender = null;
|
|
3595
|
+
let root = null;
|
|
3596
|
+
let currentEl = null;
|
|
3597
|
+
const draw = (el) => {
|
|
3598
|
+
unrender?.();
|
|
3599
|
+
if (options.render) {
|
|
3600
|
+
unrender = options.render(el, { controller, config: renderConfig });
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
root = createRoot4(el);
|
|
3604
|
+
root.render(
|
|
3605
|
+
createElement4(DiffPanelView, {
|
|
3606
|
+
controller,
|
|
3607
|
+
highlighter: options.highlighter,
|
|
3608
|
+
...renderConfig
|
|
3609
|
+
})
|
|
3610
|
+
);
|
|
3611
|
+
unrender = () => {
|
|
3612
|
+
root?.unmount();
|
|
3613
|
+
root = null;
|
|
3614
|
+
};
|
|
3615
|
+
};
|
|
3616
|
+
const unbindConfig = bindConfig4(host, (config2) => {
|
|
3617
|
+
renderConfig = resolve(config2);
|
|
3618
|
+
if (currentEl) draw(currentEl);
|
|
3619
|
+
});
|
|
3620
|
+
return {
|
|
3621
|
+
render(el) {
|
|
3622
|
+
currentEl = el;
|
|
3623
|
+
draw(el);
|
|
3624
|
+
},
|
|
3625
|
+
onInput(portId, value) {
|
|
3626
|
+
if (portId === "model") {
|
|
3627
|
+
if (value && typeof value === "object" && Array.isArray(value.files)) {
|
|
3628
|
+
controller.setModel(value);
|
|
3629
|
+
}
|
|
3630
|
+
} else if (portId === "decisions") {
|
|
3631
|
+
if (Array.isArray(value)) controller.setDecisions(value);
|
|
3632
|
+
else if (value && typeof value === "object") controller.setDecision(value);
|
|
3633
|
+
} else if (portId === "status") {
|
|
3634
|
+
const payload = value;
|
|
3635
|
+
if (payload?.status) controller.setStatus(payload.status, payload.message);
|
|
3636
|
+
}
|
|
3637
|
+
},
|
|
3638
|
+
async onCommand(commandId, params) {
|
|
3639
|
+
switch (commandId) {
|
|
3640
|
+
case "get_summary": {
|
|
3641
|
+
const state = controller.getState();
|
|
3642
|
+
return {
|
|
3643
|
+
status: state.status,
|
|
3644
|
+
identical: state.identical,
|
|
3645
|
+
totals: state.totals,
|
|
3646
|
+
review: state.review,
|
|
3647
|
+
files: state.files.map((f) => {
|
|
3648
|
+
const counts = countChanges(f.file);
|
|
3649
|
+
return {
|
|
3650
|
+
path: f.file.path,
|
|
3651
|
+
status: f.file.status,
|
|
3652
|
+
omitted: f.file.omitted,
|
|
3653
|
+
hunks: f.file.hunks.length,
|
|
3654
|
+
...counts
|
|
3655
|
+
};
|
|
3656
|
+
})
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
case "next_hunk":
|
|
3660
|
+
return controller.nextHunk();
|
|
3661
|
+
case "previous_hunk":
|
|
3662
|
+
return controller.previousHunk();
|
|
3663
|
+
case "set_view":
|
|
3664
|
+
controller.setView(params.view === "split" ? "split" : "unified");
|
|
3665
|
+
return true;
|
|
3666
|
+
default:
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
},
|
|
3670
|
+
serialize() {
|
|
3671
|
+
return controller.serialize();
|
|
3672
|
+
},
|
|
3673
|
+
deserialize(state) {
|
|
3674
|
+
controller.deserialize(state);
|
|
3675
|
+
},
|
|
3676
|
+
dispose() {
|
|
3677
|
+
unbindConfig();
|
|
3678
|
+
currentEl = null;
|
|
3679
|
+
unrender?.();
|
|
3680
|
+
unrender = null;
|
|
3681
|
+
controller.dispose();
|
|
3682
|
+
}
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
}
|
|
3687
|
+
var diffPanel = createDiffPanel();
|
|
3688
|
+
export {
|
|
3689
|
+
ALL_STATUSES,
|
|
3690
|
+
CONSOLE_PANEL_ID,
|
|
3691
|
+
ConsoleController,
|
|
3692
|
+
ConsolePanelView,
|
|
3693
|
+
DEFAULT_MAX_STEP_DEPTH,
|
|
3694
|
+
DIFF_PANEL_ID,
|
|
3695
|
+
DiffController,
|
|
3696
|
+
DiffPanelView,
|
|
3697
|
+
LEVEL_ORDER,
|
|
3698
|
+
MESSAGE_ELISION,
|
|
3699
|
+
REDACTION_CARRY,
|
|
3700
|
+
RUNS_PANEL_ID,
|
|
3701
|
+
RUN_PATH_SEPARATOR,
|
|
3702
|
+
RUN_STATUSES,
|
|
3703
|
+
RunsController,
|
|
3704
|
+
RunsPanelView,
|
|
3705
|
+
TERMINAL_PANEL_ID,
|
|
3706
|
+
TERMINAL_STATUSES,
|
|
3707
|
+
TerminalController,
|
|
3708
|
+
TerminalPanelView,
|
|
3709
|
+
XENO_TERMINAL_THEME_KEYS,
|
|
3710
|
+
appendStepsAtPath,
|
|
3711
|
+
canCancel,
|
|
3712
|
+
canRetry,
|
|
3713
|
+
collapseAdjacent,
|
|
3714
|
+
computeStats,
|
|
3715
|
+
consoleManifest,
|
|
3716
|
+
consolePanel,
|
|
3717
|
+
countChanges,
|
|
3718
|
+
createConsolePanel,
|
|
3719
|
+
createDiffPanel,
|
|
3720
|
+
createRunsPanel,
|
|
3721
|
+
createTerminalPanel,
|
|
3722
|
+
decodeRunPath,
|
|
3723
|
+
diffManifest,
|
|
3724
|
+
diffPanel,
|
|
3725
|
+
durationOf,
|
|
3726
|
+
encodeRunPath,
|
|
3727
|
+
flattenHunks,
|
|
3728
|
+
flattenRunRows,
|
|
3729
|
+
formatDuration,
|
|
3730
|
+
formatRecord,
|
|
3731
|
+
isActive,
|
|
3732
|
+
isLive,
|
|
3733
|
+
isSettled,
|
|
3734
|
+
isTerminal,
|
|
3735
|
+
isWaiting,
|
|
3736
|
+
lifecycleOf,
|
|
3737
|
+
matchesSearch2 as matchesRunSearch,
|
|
3738
|
+
matchesSearch,
|
|
3739
|
+
measuredSize,
|
|
3740
|
+
meetsLevel,
|
|
3741
|
+
needsAttention,
|
|
3742
|
+
omissionLabel,
|
|
3743
|
+
patchStepAtPath,
|
|
3744
|
+
queueDurationOf,
|
|
3745
|
+
readTerminalTheme,
|
|
3746
|
+
resolveStepPath,
|
|
3747
|
+
runsManifest,
|
|
3748
|
+
runsPanel,
|
|
3749
|
+
safeJson,
|
|
3750
|
+
spliceRedactedAppend,
|
|
3751
|
+
splitRows,
|
|
3752
|
+
statusLabel,
|
|
3753
|
+
terminalManifest,
|
|
3754
|
+
terminalPanel,
|
|
3755
|
+
terminalThemeVar,
|
|
3756
|
+
truncateLogMessage
|
|
3757
|
+
};
|