@1agh/maude 1.4.4 → 1.4.5
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/apps/studio/api.ts +4 -0
- package/apps/studio/canvas-comment-mount.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +62 -13
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +105 -60
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- package/apps/studio/dist/client.bundle.js +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +35 -3
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/file-plane.ts +24 -1
- package/apps/studio/sync/index.ts +87 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-selection-set.tsx +9 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +9 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
package/apps/studio/api.ts
CHANGED
|
@@ -4234,6 +4234,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4234
4234
|
}
|
|
4235
4235
|
const after = await Bun.file(abs).text();
|
|
4236
4236
|
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
4237
|
+
// This API write is complete: accepted sync need not wait for an
|
|
4238
|
+
// external editor's quiet window. It is still only a proposal, not an
|
|
4239
|
+
// acknowledgement; the normal hub validation/acceptance path owns that.
|
|
4240
|
+
if (after !== before) ctx.bus.emit('source-written', { rel, content: after });
|
|
4237
4241
|
return { ok: true, delta: res.delta, seq, ...previous };
|
|
4238
4242
|
} catch (err) {
|
|
4239
4243
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
@@ -636,7 +636,19 @@ function CanvasHmrRuntime({
|
|
|
636
636
|
lastGood.current = canvasRef.current;
|
|
637
637
|
// The new canvas rendered clean → drop any holding state.
|
|
638
638
|
setHoldingState((h) => (h.on ? { on: false } : h));
|
|
639
|
-
|
|
639
|
+
// A soft replacement keeps the iframe document, so inspect.ts does not
|
|
640
|
+
// repeat its initial loaded handshake. Re-announce only AFTER the new
|
|
641
|
+
// subtree commits: the shell then reselects the current element and reads
|
|
642
|
+
// fresh attributes/styles instead of retaining the pre-restore inspector.
|
|
643
|
+
// Failed renders keep the last good tree and must not claim a new load.
|
|
644
|
+
if (attempt > 0 && file) {
|
|
645
|
+
try {
|
|
646
|
+
window.parent.postMessage({ dgn: 'loaded', file }, '*');
|
|
647
|
+
} catch {
|
|
648
|
+
// The parent can detach while an imported canvas is committing.
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}, [attempt, file]);
|
|
640
652
|
|
|
641
653
|
const handleError = useCallback(() => {
|
|
642
654
|
setHoldingState({ on: true, message: 'render error' });
|
|
@@ -2422,6 +2422,38 @@ function isLockedElement(el: Element | null, cdId: string | null): boolean {
|
|
|
2422
2422
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2423
2423
|
// Router wire-up
|
|
2424
2424
|
|
|
2425
|
+
// The shell writes an inspector edit, THEN posts `record-edit` for it. The
|
|
2426
|
+
// write reaches disk before the shell has its response, so a Cmd+Z pressed in
|
|
2427
|
+
// that window found the stack one entry short and inverted the edit BELOW the
|
|
2428
|
+
// one on screen — refused as "changed by someone else", or worse, applied.
|
|
2429
|
+
// Before undo/redo, ask the shell to answer once its recordable writes have
|
|
2430
|
+
// settled. Messages between two windows arrive in order, so every
|
|
2431
|
+
// `record-edit` those writes produce is delivered before the answer. Bounded:
|
|
2432
|
+
// a shell that never answers costs a short wait, not the undo.
|
|
2433
|
+
export function afterShellRecords(timeoutMs = 3000): Promise<void> {
|
|
2434
|
+
if (typeof window === 'undefined' || window.parent === window) return Promise.resolve();
|
|
2435
|
+
const requestId = `ub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2436
|
+
return new Promise<void>((resolve) => {
|
|
2437
|
+
const done = () => {
|
|
2438
|
+
clearTimeout(timer);
|
|
2439
|
+
window.removeEventListener('message', onReply);
|
|
2440
|
+
resolve();
|
|
2441
|
+
};
|
|
2442
|
+
const onReply = (e: MessageEvent) => {
|
|
2443
|
+
const d = e.data as { dgn?: string; requestId?: string } | null;
|
|
2444
|
+
if (e.source === window.parent && d?.dgn === 'undo-barrier-ok' && d.requestId === requestId)
|
|
2445
|
+
done();
|
|
2446
|
+
};
|
|
2447
|
+
const timer = setTimeout(done, timeoutMs);
|
|
2448
|
+
window.addEventListener('message', onReply);
|
|
2449
|
+
try {
|
|
2450
|
+
window.parent.postMessage({ dgn: 'undo-barrier', requestId }, '*');
|
|
2451
|
+
} catch {
|
|
2452
|
+
done();
|
|
2453
|
+
}
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2425
2457
|
function CanvasRouter({
|
|
2426
2458
|
hostRef,
|
|
2427
2459
|
children,
|
|
@@ -2443,6 +2475,11 @@ function CanvasRouter({
|
|
|
2443
2475
|
// without making them a hook dependency — avoids re-binding on every undo op.
|
|
2444
2476
|
const undoStackRef = useRef(undoStack);
|
|
2445
2477
|
undoStackRef.current = undoStack;
|
|
2478
|
+
// Selection readback can refresh while the user is typing (e.g. the shell's
|
|
2479
|
+
// post-HMR halo restore). Keep the editor's lifetime tied to its host, not
|
|
2480
|
+
// the changing selection snapshot, or effect cleanup silently ends typing.
|
|
2481
|
+
const textSelectionRef = useRef(selSet);
|
|
2482
|
+
textSelectionRef.current = selSet;
|
|
2446
2483
|
|
|
2447
2484
|
// Phase: inline-edit undo (DDR-103/104 follow-up). Wire the `editSourceApplyFn`
|
|
2448
2485
|
// sink so an `edit-source` command's do()/undo() reaches the main-origin-only
|
|
@@ -2454,10 +2491,17 @@ function CanvasRouter({
|
|
|
2454
2491
|
// or any failed write) rejects, the stack keeps the entry instead of moving
|
|
2455
2492
|
// its cursor, and the user is told why.
|
|
2456
2493
|
useEffect(() => {
|
|
2494
|
+
// HMR replaces this React subtree, not the iframe or the parent write.
|
|
2495
|
+
// Drain already-sent requests after cleanup instead of losing their ACK
|
|
2496
|
+
// (and, on conflict, their user-facing refusal). Timers remain bounded.
|
|
2497
|
+
let disposed = false;
|
|
2457
2498
|
const pending = new Map<
|
|
2458
2499
|
string,
|
|
2459
2500
|
{ resolve: () => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
2460
2501
|
>();
|
|
2502
|
+
const detachIfIdle = () => {
|
|
2503
|
+
if (disposed && pending.size === 0) window.removeEventListener('message', onResult);
|
|
2504
|
+
};
|
|
2461
2505
|
const onResult = (event: MessageEvent) => {
|
|
2462
2506
|
if (event.source !== window.parent) return;
|
|
2463
2507
|
const data = event.data as
|
|
@@ -2468,6 +2512,7 @@ function CanvasRouter({
|
|
|
2468
2512
|
if (!entry) return;
|
|
2469
2513
|
pending.delete(data.requestId);
|
|
2470
2514
|
clearTimeout(entry.timer);
|
|
2515
|
+
detachIfIdle();
|
|
2471
2516
|
if (data.ok === true) {
|
|
2472
2517
|
entry.resolve();
|
|
2473
2518
|
return;
|
|
@@ -2482,11 +2527,16 @@ function CanvasRouter({
|
|
|
2482
2527
|
let sequence = 0;
|
|
2483
2528
|
const applyFn: EditSourceApplyFn = (apply) =>
|
|
2484
2529
|
new Promise<void>((resolve, reject) => {
|
|
2530
|
+
if (disposed) {
|
|
2531
|
+
reject(new Error('the canvas closed'));
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2485
2534
|
sequence += 1;
|
|
2486
2535
|
const requestId = `${Date.now().toString(36)}-${sequence}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2487
2536
|
// Generous: the shell serializes these writes behind any in-flight one.
|
|
2488
2537
|
const timer = setTimeout(() => {
|
|
2489
2538
|
pending.delete(requestId);
|
|
2539
|
+
detachIfIdle();
|
|
2490
2540
|
reject(new Error('the edit was not confirmed'));
|
|
2491
2541
|
}, 30_000);
|
|
2492
2542
|
pending.set(requestId, { resolve, reject, timer });
|
|
@@ -2496,17 +2546,14 @@ function CanvasRouter({
|
|
|
2496
2546
|
/* detached / cross-origin teardown — nothing to apply */
|
|
2497
2547
|
pending.delete(requestId);
|
|
2498
2548
|
clearTimeout(timer);
|
|
2549
|
+
detachIfIdle();
|
|
2499
2550
|
reject(new Error('the canvas is detached'));
|
|
2500
2551
|
}
|
|
2501
2552
|
});
|
|
2502
2553
|
undoSinks.setSink('editSourceApplyFn', applyFn);
|
|
2503
2554
|
return () => {
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
clearTimeout(entry.timer);
|
|
2507
|
-
entry.reject(new Error('the canvas closed'));
|
|
2508
|
-
}
|
|
2509
|
-
pending.clear();
|
|
2555
|
+
disposed = true;
|
|
2556
|
+
detachIfIdle();
|
|
2510
2557
|
undoSinks.setSink('editSourceApplyFn', undefined);
|
|
2511
2558
|
};
|
|
2512
2559
|
}, [undoSinks]);
|
|
@@ -2734,11 +2781,11 @@ function CanvasRouter({
|
|
|
2734
2781
|
}
|
|
2735
2782
|
// Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
|
|
2736
2783
|
if (m.dgn === 'undo') {
|
|
2737
|
-
void
|
|
2784
|
+
void afterShellRecords().then(() => undoStackRef.current.undo());
|
|
2738
2785
|
return;
|
|
2739
2786
|
}
|
|
2740
2787
|
if (m.dgn === 'redo') {
|
|
2741
|
-
void
|
|
2788
|
+
void afterShellRecords().then(() => undoStackRef.current.redo());
|
|
2742
2789
|
return;
|
|
2743
2790
|
}
|
|
2744
2791
|
// Phase 12 Task 4 (DDR-103) — Layers-tree round-trip. select-by-id resolves
|
|
@@ -3144,7 +3191,7 @@ function CanvasRouter({
|
|
|
3144
3191
|
// selected (the drill has reached it). Previously a dblclick on leaf
|
|
3145
3192
|
// text jumped straight to the editor from any depth, so the drill ladder
|
|
3146
3193
|
// never ran on text-bearing targets.
|
|
3147
|
-
const sel0 =
|
|
3194
|
+
const sel0 = textSelectionRef.current.selected;
|
|
3148
3195
|
const currentId = sel0.length === 1 ? sel0[0]?.id : undefined;
|
|
3149
3196
|
const leafReached = isLeafText && currentId === stamped.getAttribute('data-cd-id');
|
|
3150
3197
|
if (!leafReached) {
|
|
@@ -3167,7 +3214,9 @@ function CanvasRouter({
|
|
|
3167
3214
|
(next.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
|
|
3168
3215
|
'data-dc-screen'
|
|
3169
3216
|
) ?? null;
|
|
3170
|
-
|
|
3217
|
+
textSelectionRef.current.replace(
|
|
3218
|
+
hoverTargetToSelection({ el: next, cdId, artboardId } as HoverTarget)
|
|
3219
|
+
);
|
|
3171
3220
|
}
|
|
3172
3221
|
}
|
|
3173
3222
|
return;
|
|
@@ -3228,7 +3277,7 @@ function CanvasRouter({
|
|
|
3228
3277
|
document.removeEventListener('maude:enter-text-edit', onEnterTextEdit);
|
|
3229
3278
|
if (editing) teardown(editing);
|
|
3230
3279
|
};
|
|
3231
|
-
}, [hostRef
|
|
3280
|
+
}, [hostRef]);
|
|
3232
3281
|
|
|
3233
3282
|
// Cleanup any pending rAF on unmount.
|
|
3234
3283
|
useEffect(
|
|
@@ -3314,10 +3363,10 @@ function CanvasRouter({
|
|
|
3314
3363
|
},
|
|
3315
3364
|
onTool: ({ tool: t }) => setTool(t),
|
|
3316
3365
|
onUndo: () => {
|
|
3317
|
-
void
|
|
3366
|
+
void afterShellRecords().then(() => undoStackRef.current.undo());
|
|
3318
3367
|
},
|
|
3319
3368
|
onRedo: () => {
|
|
3320
|
-
void
|
|
3369
|
+
void afterShellRecords().then(() => undoStackRef.current.redo());
|
|
3321
3370
|
},
|
|
3322
3371
|
onEscape: () => {
|
|
3323
3372
|
// T21 — abort any mid-stroke draw FIRST. The annotations layer
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The last canvas source this server BUILT, with the locator that build
|
|
2
|
+
// produced, per file (F4).
|
|
3
|
+
//
|
|
4
|
+
// The HMR broadcaster diffs a changed canvas against it to find a text-only
|
|
5
|
+
// edit it can hand straight to the open iframe (`HmrMessage.patches`). Seeded
|
|
6
|
+
// by the module route after each build — so the memo is exactly the source the
|
|
7
|
+
// iframe renders, and its locator names that render's `data-cd-id`s — and
|
|
8
|
+
// advanced by every broadcast that carried patches. Bounded: a memo miss only
|
|
9
|
+
// means the remount renders the change without the head start.
|
|
10
|
+
|
|
11
|
+
import type { LocatorMap } from './locator.ts';
|
|
12
|
+
|
|
13
|
+
const MAX_FILES = 256;
|
|
14
|
+
const MAX_BYTES = 512 * 1024;
|
|
15
|
+
|
|
16
|
+
export interface BuiltCanvas {
|
|
17
|
+
source: string;
|
|
18
|
+
locator: LocatorMap;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const memo = new Map<string, BuiltCanvas>();
|
|
22
|
+
|
|
23
|
+
export function rememberCanvasBuild(absPath: string, built: BuiltCanvas): void {
|
|
24
|
+
memo.delete(absPath);
|
|
25
|
+
if (built.source.length > MAX_BYTES) return;
|
|
26
|
+
memo.set(absPath, built);
|
|
27
|
+
if (memo.size > MAX_FILES) {
|
|
28
|
+
const oldest = memo.keys().next().value;
|
|
29
|
+
if (oldest !== undefined) memo.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recallCanvasBuild(absPath: string): BuiltCanvas | undefined {
|
|
34
|
+
return memo.get(absPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function forgetCanvasBuilds(): void {
|
|
38
|
+
memo.clear();
|
|
39
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Text-only change detection for the F4 peer-render fast path.
|
|
2
|
+
//
|
|
3
|
+
// A teammate's text edit reaches this disk ~100 ms after they commit it, but
|
|
4
|
+
// re-importing and remounting the canvas module takes several hundred more.
|
|
5
|
+
// When the ONLY difference between the source the iframe renders and the new
|
|
6
|
+
// source is the text between an element's opening and closing tag, the iframe
|
|
7
|
+
// can show the new string at once and let the remount confirm it.
|
|
8
|
+
//
|
|
9
|
+
// DELIBERATELY PARSER-FREE. In a cell this runs in the process that holds the
|
|
10
|
+
// tenant's credentials, where canvas source is never parsed or built (DDR-209:
|
|
11
|
+
// that happens in the sandbox). So this is bounded string scanning over one
|
|
12
|
+
// changed region, and the element's identity comes from the LOCATOR the
|
|
13
|
+
// sandboxed build already produced for the rendered source — never from
|
|
14
|
+
// parsing tenant bytes here. Anything it cannot prove is a plain text run —
|
|
15
|
+
// markup, attributes, expressions, entities, several regions — returns null,
|
|
16
|
+
// and the remount alone renders the change, exactly as before.
|
|
17
|
+
|
|
18
|
+
import type { LocatorMap } from './locator.ts';
|
|
19
|
+
|
|
20
|
+
export interface TextPatch {
|
|
21
|
+
/** The element's `data-cd-id` in the rendered (before) build. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** The string React renders for the element's text child. */
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STRUCTURAL = /[<>{}&]/;
|
|
28
|
+
|
|
29
|
+
/** Babel's JSX text whitespace rule (cleanJSXElementLiteralChild). */
|
|
30
|
+
export function renderedJsxText(raw: string): string {
|
|
31
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
32
|
+
let lastNonEmpty = 0;
|
|
33
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i] as string)) lastNonEmpty = i;
|
|
34
|
+
let out = '';
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
let line = (lines[i] as string).replace(/\t/g, ' ');
|
|
37
|
+
if (i !== 0) line = line.replace(/^[ ]+/, '');
|
|
38
|
+
if (i !== lines.length - 1) line = line.replace(/[ ]+$/, '');
|
|
39
|
+
if (line) {
|
|
40
|
+
if (i !== lastNonEmpty) line += ' ';
|
|
41
|
+
out += line;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 1-based line / 0-based column, counted the way the build's locator counts. */
|
|
48
|
+
function lineCol(source: string, at: number): { line: number; col: number } {
|
|
49
|
+
let line = 1;
|
|
50
|
+
let col = 0;
|
|
51
|
+
for (let i = 0; i < at; i++) {
|
|
52
|
+
if (source.charCodeAt(i) === 10) {
|
|
53
|
+
line += 1;
|
|
54
|
+
col = 0;
|
|
55
|
+
} else col += 1;
|
|
56
|
+
}
|
|
57
|
+
return { line, col };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TAG_NAME = /^[A-Za-z][\w.:-]*/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The single text patch that turns `before`'s render into `after`'s, [] when
|
|
64
|
+
* they are identical, or null when the change is anything but the text run of
|
|
65
|
+
* one element that `locator` (the build of `before`) names.
|
|
66
|
+
*/
|
|
67
|
+
export function textOnlyPatches(
|
|
68
|
+
before: string,
|
|
69
|
+
after: string,
|
|
70
|
+
locator: LocatorMap
|
|
71
|
+
): TextPatch[] | null {
|
|
72
|
+
if (before === after) return [];
|
|
73
|
+
const limit = Math.min(before.length, after.length);
|
|
74
|
+
let p = 0;
|
|
75
|
+
while (p < limit && before.charCodeAt(p) === after.charCodeAt(p)) p++;
|
|
76
|
+
let s = 0;
|
|
77
|
+
while (
|
|
78
|
+
s < limit - p &&
|
|
79
|
+
before.charCodeAt(before.length - 1 - s) === after.charCodeAt(after.length - 1 - s)
|
|
80
|
+
)
|
|
81
|
+
s++;
|
|
82
|
+
if (STRUCTURAL.test(before.slice(p, before.length - s))) return null;
|
|
83
|
+
if (STRUCTURAL.test(after.slice(p, after.length - s))) return null;
|
|
84
|
+
|
|
85
|
+
// The text run around the change: from the `>` ending an opening tag to the
|
|
86
|
+
// `</` of a closing tag, with nothing structural in between (both sides).
|
|
87
|
+
let open = p - 1;
|
|
88
|
+
while (open >= 0 && !STRUCTURAL.test(before[open] as string)) open--;
|
|
89
|
+
if (open < 0 || before[open] !== '>' || before[open - 1] === '/') return null;
|
|
90
|
+
let close = before.length - s;
|
|
91
|
+
while (close < before.length && !STRUCTURAL.test(before[close] as string)) close++;
|
|
92
|
+
if (before[close] !== '<' || before[close + 1] !== '/') return null;
|
|
93
|
+
const closeAfter = close + (after.length - before.length);
|
|
94
|
+
|
|
95
|
+
// The opening tag this `>` ends: its `<`, a tag name, and a closing tag that
|
|
96
|
+
// names the same element.
|
|
97
|
+
const start = before.lastIndexOf('<', open);
|
|
98
|
+
if (start < 0) return null;
|
|
99
|
+
const name = TAG_NAME.exec(before.slice(start + 1))?.[0];
|
|
100
|
+
if (!name || before.slice(close + 2, close + 2 + name.length + 1) !== `${name}>`) return null;
|
|
101
|
+
|
|
102
|
+
const at = lineCol(before, start);
|
|
103
|
+
// The locator is the sandboxed build's output: trust its shape, not its size.
|
|
104
|
+
const ids = Object.entries(locator ?? {}).filter(
|
|
105
|
+
([id, e]) =>
|
|
106
|
+
/^[0-9a-f]{8}$/.test(id) &&
|
|
107
|
+
e?.line === at.line &&
|
|
108
|
+
e?.col === at.col &&
|
|
109
|
+
Array.isArray(e?.jsxPath) &&
|
|
110
|
+
e.jsxPath.at(-1) === name
|
|
111
|
+
);
|
|
112
|
+
if (ids.length !== 1) return null;
|
|
113
|
+
|
|
114
|
+
const text = renderedJsxText(after.slice(open + 1, closeAfter));
|
|
115
|
+
if (!text) return null;
|
|
116
|
+
return [{ id: (ids[0] as [string, unknown])[0], text }];
|
|
117
|
+
}
|