The command fragment named the full command line, so a session spawned with `claude --dangerously-skip-permissions` spent the whole line on a flag. Cards now name the program only — its icon in the web client, its bare name in the TUI — and keep the full command line in the tooltip. Adds `maestro_protocol::display::command_program`, the Rust counterpart of the web's `splitCommand`.
554 lines
20 KiB
TypeScript
554 lines
20 KiB
TypeScript
import { FitAddon } from "@xterm/addon-fit";
|
||
import { Terminal } from "@xterm/xterm";
|
||
import "@xterm/xterm/css/xterm.css";
|
||
|
||
import { attachWsUrl } from "./routes.js";
|
||
import { escapeHtml } from "./html.js";
|
||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||
import { applyModsToChar, cursorSeq } from "./keys.js";
|
||
import { requestToggleDrawer } from "./drawer.js";
|
||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||
import type { ClientControl, Uuid } from "./types.js";
|
||
|
||
/**
|
||
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
|
||
*
|
||
* The connection is **decoupled from the UI**: it owns a long-lived WS that
|
||
* keeps streaming PTY output into the terminal buffer whether or not the view
|
||
* is currently mounted. The shell moves `el` between the visible content pane
|
||
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
|
||
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
|
||
* session ending) tears the socket down — so switching away and back is
|
||
* instant, with scrollback already in place.
|
||
*/
|
||
export interface AttachConnection {
|
||
readonly sessionId: Uuid;
|
||
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
||
readonly el: HTMLElement;
|
||
/** Becomes the visible view: fit to the pane, focus, sync size. */
|
||
onMount(): void;
|
||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||
onUnmount(): void;
|
||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||
focusTerminal(): void;
|
||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||
dispose(): void;
|
||
}
|
||
|
||
export interface AttachOptions {
|
||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||
onRequestClose?: (sessionId: Uuid) => void;
|
||
/** Invoked when the socket closes on its own — the session ended or the link
|
||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||
onClosed?: (sessionId: Uuid) => void;
|
||
/** Asked on every close: should the socket be retried? The shell answers
|
||
* from the catalog — see `shouldRetryClose`. */
|
||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||
}
|
||
|
||
|
||
|
||
/** What the header shows about the session (name / command). Optional — falls
|
||
* back to a short session id when the session isn't known yet. */
|
||
export interface AttachInfo {
|
||
name: string;
|
||
command: string;
|
||
}
|
||
|
||
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||
* mark for it and stays as its bare name when we do not. Arguments are
|
||
* dropped, as on the card. */
|
||
function commandFragment(command: string): string {
|
||
const { tool, program } = splitCommand(command);
|
||
const icon = tool ? toolIconSvg(tool) : null;
|
||
const text = icon ? "" : program;
|
||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||
}
|
||
|
||
export function createAttachConnection(
|
||
sessionId: Uuid,
|
||
opts: AttachOptions = {},
|
||
info?: AttachInfo,
|
||
): AttachConnection {
|
||
const el = document.createElement("div");
|
||
el.className = "attach";
|
||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||
// tooltip for when you really need it.
|
||
const titleHtml = info
|
||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||
el.innerHTML = `
|
||
<header class="attach-header">
|
||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||
</header>
|
||
<div class="attach-body"></div>
|
||
`;
|
||
|
||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||
|
||
const keybar = buildKeyBar((data) => sendInput(data));
|
||
el.appendChild(keybar.el);
|
||
|
||
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
|
||
stateEl.textContent = s;
|
||
stateEl.dataset.state = kind;
|
||
};
|
||
|
||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
|
||
// the session list is an off-canvas drawer and this is what opens it.
|
||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||
requestToggleDrawer(),
|
||
);
|
||
|
||
disconnectBtn.addEventListener("click", () => {
|
||
opts.onRequestClose?.(sessionId);
|
||
});
|
||
|
||
const term = new Terminal({
|
||
cursorBlink: true,
|
||
fontFamily:
|
||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||
fontSize: 13,
|
||
convertEol: false,
|
||
theme: {
|
||
// The terminal stays dark in both app themes (convention); colors are
|
||
// tuned to the maestro palette — signal-green cursor, slate background.
|
||
background: "#121419",
|
||
foreground: "#e6e8ec",
|
||
cursor: "#5fe3a1",
|
||
cursorAccent: "#121419",
|
||
selectionBackground: "#2b6b4f",
|
||
},
|
||
scrollback: 5000,
|
||
allowProposedApi: true,
|
||
});
|
||
const fit = new FitAddon();
|
||
term.loadAddon(fit);
|
||
term.open(body);
|
||
|
||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||
// • normal buffer → term.scrollLines walks the scrollback.
|
||
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
|
||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||
// gesture stream; only the terminal surface opts out of native page panning.
|
||
{
|
||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||
const cellH = () => {
|
||
const core = (term as unknown as {
|
||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
|
||
})._core;
|
||
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
||
};
|
||
if (vp) {
|
||
let lastY = 0;
|
||
let lastX = 0;
|
||
let remainder = 0;
|
||
vp.addEventListener(
|
||
"touchstart",
|
||
(e) => {
|
||
if (e.touches.length !== 1) return;
|
||
lastY = e.touches[0].clientY;
|
||
lastX = e.touches[0].clientX;
|
||
remainder = 0;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
vp.addEventListener(
|
||
"touchmove",
|
||
(e) => {
|
||
if (e.touches.length !== 1) return;
|
||
const ch = cellH();
|
||
const y = e.touches[0].clientY;
|
||
const px = lastY - y + remainder; // finger up → scroll toward newer
|
||
lastY = y;
|
||
const lines = Math.trunc(px / ch);
|
||
remainder = px - lines * ch;
|
||
if (lines === 0) return;
|
||
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
|
||
// reporting on — that's the signal to forward instead of scrollback.
|
||
const mouseTracked = term.modes.mouseTrackingMode !== "none";
|
||
if (mouseTracked && screen) {
|
||
// One wheel event per crossed cell, dispatched at the touch point so
|
||
// xterm computes valid cell coords for the mouse report.
|
||
const dir = lines > 0 ? 1 : -1;
|
||
for (let i = 0; i < Math.abs(lines); i++) {
|
||
screen.dispatchEvent(
|
||
new WheelEvent("wheel", {
|
||
deltaY: dir * ch,
|
||
deltaMode: 0,
|
||
clientX: lastX,
|
||
clientY: y,
|
||
bubbles: true,
|
||
cancelable: true,
|
||
}),
|
||
);
|
||
}
|
||
e.preventDefault();
|
||
} else if (term.buffer.active.type !== "alternate") {
|
||
term.scrollLines(lines);
|
||
e.preventDefault();
|
||
}
|
||
},
|
||
{ passive: false },
|
||
);
|
||
}
|
||
}
|
||
|
||
// Mobile: the special-key bar is only useful while the soft keyboard is up,
|
||
// so reveal it only when the terminal's hidden textarea is focused (CSS still
|
||
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
|
||
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
|
||
// showing/hiding the bar changes the terminal's available height.
|
||
const ta = term.textarea;
|
||
if (ta) {
|
||
ta.addEventListener("focus", () => {
|
||
keybar.el.classList.add("keybar-active");
|
||
requestAnimationFrame(tryFit);
|
||
});
|
||
ta.addEventListener("blur", () => {
|
||
keybar.el.classList.remove("keybar-active");
|
||
requestAnimationFrame(tryFit);
|
||
});
|
||
}
|
||
|
||
let mounted = false;
|
||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
||
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
|
||
const tryFit = () => {
|
||
if (!mounted) return; // parked views have no meaningful size to fit to
|
||
try {
|
||
const el2 = term.element;
|
||
const parent = el2?.parentElement;
|
||
const core = (term as unknown as {
|
||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
||
})._core;
|
||
const cell = core?._renderService?.dimensions?.css?.cell;
|
||
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
||
fit.fit(); // fall back if internals aren't ready yet
|
||
return;
|
||
}
|
||
// Measure the viewport's real inner width (clientWidth excludes any
|
||
// scrollbar), not the container's — so columns always fit inside the
|
||
// viewport whether or not xterm reserves a scrollbar. Falls back to the
|
||
// padded container box before the viewport exists.
|
||
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
||
let availW: number;
|
||
let availH: number;
|
||
if (vp && vp.clientWidth > 0) {
|
||
availW = vp.clientWidth;
|
||
availH = vp.clientHeight;
|
||
} else {
|
||
const ps = getComputedStyle(parent);
|
||
const ts = getComputedStyle(el2);
|
||
availW =
|
||
parseInt(ps.width) -
|
||
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
||
availH =
|
||
parseInt(ps.height) -
|
||
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
||
}
|
||
const cols = Math.max(2, Math.floor(availW / cell.width));
|
||
const rows = Math.max(1, Math.floor(availH / cell.height));
|
||
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
||
} catch {
|
||
try {
|
||
fit.fit();
|
||
} catch {
|
||
// ignore — fit fails if the container is detached or zero-sized
|
||
}
|
||
}
|
||
};
|
||
|
||
let ws: WebSocket;
|
||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||
let reconnectTimer: number | null = null;
|
||
|
||
const sendControl = (msg: ClientControl) => {
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify(msg));
|
||
};
|
||
|
||
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
|
||
* mobile key bar so both flow through the same path. */
|
||
const sendInput = (data: string) => {
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(new TextEncoder().encode(data));
|
||
};
|
||
|
||
const onResize = () => {
|
||
tryFit();
|
||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||
};
|
||
|
||
function connect(): void {
|
||
ws = new WebSocket(attachWsUrl(sessionId));
|
||
ws.binaryType = "arraybuffer";
|
||
|
||
ws.onopen = () => {
|
||
reconnectMs = INITIAL_RECONNECT_MS;
|
||
setState("connected", "open");
|
||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||
if (mounted) term.focus();
|
||
};
|
||
|
||
ws.onmessage = (ev) => {
|
||
if (typeof ev.data === "string") {
|
||
try {
|
||
JSON.parse(ev.data) as ClientControl;
|
||
} catch {
|
||
// ignore unknown control frames
|
||
}
|
||
return;
|
||
}
|
||
if (ev.data instanceof ArrayBuffer) {
|
||
term.write(new Uint8Array(ev.data));
|
||
} else if (ev.data instanceof Blob) {
|
||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
||
}
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
setState("error", "error");
|
||
};
|
||
|
||
ws.onclose = (ev) => {
|
||
// The web proxy relays a daemon rejection (unknown session) as an
|
||
// application close code (4000+) carrying a human reason. Show it in the
|
||
// pane — both the header and the terminal body — and KEEP the pane up so
|
||
// the user can read why, instead of letting it flash and vanish (#39).
|
||
const reason = ev.reason?.trim();
|
||
if (ev.code >= 4000 && reason && !disposed) {
|
||
setState(reason, "error");
|
||
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
||
return;
|
||
}
|
||
// An intentional dispose has already set `disposed`; eviction and user
|
||
// close handle their own pane teardown.
|
||
if (disposed) return;
|
||
|
||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||
setState("reconnecting…", "connecting");
|
||
reconnectTimer = window.setTimeout(() => {
|
||
reconnectTimer = null;
|
||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||
connect();
|
||
}, delay);
|
||
return;
|
||
}
|
||
|
||
setState(`closed (${ev.code})`, "closed");
|
||
opts.onClosed?.(sessionId);
|
||
};
|
||
}
|
||
|
||
connect();
|
||
|
||
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
|
||
// Ctrl modifier (mobile) folds into the next typed character here.
|
||
const dataDisp = term.onData((data) => {
|
||
sendInput(keybar.consume(data));
|
||
});
|
||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||
sendControl({ type: "Resize", cols, rows });
|
||
});
|
||
|
||
window.addEventListener("resize", onResize);
|
||
const visual = window.visualViewport ?? null;
|
||
visual?.addEventListener("resize", onResize);
|
||
|
||
// FitAddon only recomputes on the events we call it from — a window resize
|
||
// misses container-driven changes (sidebar drag, mobile layout settling,
|
||
// orientation, late font load). Observe the body directly so columns always
|
||
// match the real rendered width and the terminal fills edge to edge.
|
||
const ro = new ResizeObserver(() => tryFit());
|
||
ro.observe(body);
|
||
// Re-fit once the monospace font is actually loaded — cell width changes when
|
||
// the fallback is swapped out, which otherwise leaves stale columns.
|
||
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
||
|
||
let disposed = false;
|
||
|
||
return {
|
||
sessionId,
|
||
el,
|
||
onMount() {
|
||
mounted = true;
|
||
// Defer a tick so the element has its content-pane dimensions before fit.
|
||
requestAnimationFrame(() => {
|
||
tryFit();
|
||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||
if (ws.readyState === WebSocket.OPEN) term.focus();
|
||
});
|
||
},
|
||
onUnmount() {
|
||
mounted = false;
|
||
},
|
||
focusTerminal() {
|
||
term.focus();
|
||
},
|
||
dispose() {
|
||
if (disposed) return;
|
||
disposed = true;
|
||
if (reconnectTimer !== null) {
|
||
window.clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", onResize);
|
||
visual?.removeEventListener("resize", onResize);
|
||
dataDisp.dispose();
|
||
resizeDisp.dispose();
|
||
try {
|
||
ws.close();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
term.dispose();
|
||
el.remove();
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One key in the mobile key bar.
|
||
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
|
||
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
|
||
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
|
||
* - `seq` is a plain key (Esc, Tab) sent verbatim.
|
||
*/
|
||
type KeyDef =
|
||
| { label: string; mod: Modifier }
|
||
| { label: string; cursor: CursorFinal }
|
||
| { label: string; seq: string };
|
||
|
||
type Modifier = "ctrl" | "alt";
|
||
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
|
||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||
|
||
/**
|
||
* The keys the mobile soft keyboard hides, ordered to flow row-major into a
|
||
* 5-column / 2-row grid (NAP-342 review):
|
||
*
|
||
* Esc Tab Home ↑ End
|
||
* Ctrl Alt ← ↓ →
|
||
*
|
||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as
|
||
* a real-keyboard inverted-T on the right, with Home/End filling its top corners.
|
||
*/
|
||
const KEY_DEFS: KeyDef[] = [
|
||
{ label: "Esc", seq: "\x1b" },
|
||
{ label: "Tab", seq: "\t" },
|
||
{ label: "Home", cursor: "H" },
|
||
{ label: "↑", cursor: "A" },
|
||
{ label: "End", cursor: "F" },
|
||
{ label: "Ctrl", mod: "ctrl" },
|
||
{ label: "Alt", mod: "alt" },
|
||
{ label: "←", cursor: "D" },
|
||
{ label: "↓", cursor: "B" },
|
||
{ label: "→", cursor: "C" },
|
||
];
|
||
|
||
/**
|
||
* Fold a single character into its control code: letters and `@[\]^_` map to
|
||
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
|
||
*/
|
||
interface KeyBar {
|
||
readonly el: HTMLElement;
|
||
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
|
||
consume(data: string): string;
|
||
}
|
||
|
||
/**
|
||
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
|
||
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
|
||
*
|
||
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
|
||
* tapped here or typed on the soft keyboard — carries the modifier, then they
|
||
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
|
||
* the keyboard stays open.
|
||
*/
|
||
function buildKeyBar(send: (data: string) => void): KeyBar {
|
||
const el = document.createElement("div");
|
||
el.className = "attach-keybar";
|
||
el.setAttribute("role", "toolbar");
|
||
el.setAttribute("aria-label", "Special keys");
|
||
|
||
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
|
||
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
|
||
|
||
const setMod = (mod: Modifier, on: boolean) => {
|
||
armed[mod] = on;
|
||
modBtns[mod]?.setAttribute("aria-pressed", String(on));
|
||
};
|
||
const clearMods = () => {
|
||
setMod("ctrl", false);
|
||
setMod("alt", false);
|
||
};
|
||
|
||
for (const def of KEY_DEFS) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "keycap";
|
||
btn.textContent = def.label;
|
||
|
||
if ("mod" in def) {
|
||
btn.classList.add("keycap-mod");
|
||
btn.setAttribute("aria-pressed", "false");
|
||
btn.setAttribute("aria-label", `${def.label} modifier`);
|
||
modBtns[def.mod] = btn;
|
||
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
|
||
} else if ("cursor" in def) {
|
||
btn.addEventListener("click", () => {
|
||
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
|
||
clearMods();
|
||
});
|
||
} else {
|
||
btn.addEventListener("click", () => {
|
||
send(def.seq);
|
||
clearMods();
|
||
});
|
||
}
|
||
|
||
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
|
||
// preventDefault suppresses the native `:active` style, so flash an explicit
|
||
// press animation here for tactile feedback.
|
||
btn.addEventListener("pointerdown", (e) => {
|
||
e.preventDefault();
|
||
btn.classList.remove("is-pressed");
|
||
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
|
||
btn.classList.add("is-pressed");
|
||
});
|
||
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
|
||
el.appendChild(btn);
|
||
}
|
||
|
||
return {
|
||
el,
|
||
consume(data: string): string {
|
||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
||
const out = applyModsToChar(data, armed.ctrl, armed.alt);
|
||
clearMods();
|
||
return out;
|
||
}
|
||
return data;
|
||
},
|
||
};
|
||
}
|