@echothink-ui/developer 0.1.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.
Files changed (56) hide show
  1. package/README.md +5 -0
  2. package/dist/components/APIExplorer.d.ts +2 -0
  3. package/dist/components/BranchSelector.d.ts +2 -0
  4. package/dist/components/CodeBlock.d.ts +2 -0
  5. package/dist/components/CodeEditor.d.ts +2 -0
  6. package/dist/components/CommitList.d.ts +2 -0
  7. package/dist/components/DiffTable.d.ts +2 -0
  8. package/dist/components/DiffViewer.d.ts +2 -0
  9. package/dist/components/EventPayloadViewer.d.ts +2 -0
  10. package/dist/components/GitRepositoryPanel.d.ts +2 -0
  11. package/dist/components/JSONViewer.d.ts +2 -0
  12. package/dist/components/LogConsole.d.ts +2 -0
  13. package/dist/components/PullRequestPanel.d.ts +2 -0
  14. package/dist/components/RequestResponseViewer.d.ts +2 -0
  15. package/dist/components/SchemaViewer.d.ts +2 -0
  16. package/dist/components/TerminalPanel.d.ts +2 -0
  17. package/dist/components/TraceTimeline.d.ts +2 -0
  18. package/dist/components/WebhookEventViewer.d.ts +2 -0
  19. package/dist/components/YAMLViewer.d.ts +2 -0
  20. package/dist/components/devUtils.d.ts +10 -0
  21. package/dist/components/types.d.ts +196 -0
  22. package/dist/index.cjs +2627 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.css +3651 -0
  25. package/dist/index.css.map +1 -0
  26. package/dist/index.d.ts +22 -0
  27. package/dist/index.js +2572 -0
  28. package/dist/index.js.map +1 -0
  29. package/package.json +43 -0
  30. package/src/components/APIExplorer.tsx +205 -0
  31. package/src/components/BranchSelector.tsx +54 -0
  32. package/src/components/CodeBlock.tsx +127 -0
  33. package/src/components/CodeEditor.tsx +95 -0
  34. package/src/components/CommitList.tsx +100 -0
  35. package/src/components/DiffTable.tsx +288 -0
  36. package/src/components/DiffViewer.tsx +145 -0
  37. package/src/components/EventPayloadViewer.tsx +91 -0
  38. package/src/components/GitRepositoryPanel.tsx +73 -0
  39. package/src/components/JSONViewer.tsx +189 -0
  40. package/src/components/LogConsole.tsx +160 -0
  41. package/src/components/PullRequestPanel.test.tsx +52 -0
  42. package/src/components/PullRequestPanel.tsx +215 -0
  43. package/src/components/RequestResponseViewer.test.tsx +45 -0
  44. package/src/components/RequestResponseViewer.tsx +169 -0
  45. package/src/components/SchemaViewer.tsx +157 -0
  46. package/src/components/TerminalPanel.test.tsx +33 -0
  47. package/src/components/TerminalPanel.tsx +134 -0
  48. package/src/components/TraceTimeline.test.tsx +63 -0
  49. package/src/components/TraceTimeline.tsx +207 -0
  50. package/src/components/WebhookEventViewer.test.tsx +57 -0
  51. package/src/components/WebhookEventViewer.tsx +184 -0
  52. package/src/components/YAMLViewer.tsx +207 -0
  53. package/src/components/devUtils.ts +81 -0
  54. package/src/components/types.ts +230 -0
  55. package/src/index.tsx +72 -0
  56. package/src/styles.css +4296 -0
@@ -0,0 +1,207 @@
1
+ import * as React from "react";
2
+ import { Badge, StatusDot, statusLabel } from "@echothink-ui/core";
3
+ import type { TraceSpan, TraceTimelineProps } from "./types";
4
+
5
+ interface TimelineItem {
6
+ span: TraceSpan;
7
+ start: number;
8
+ end: number;
9
+ depth: number;
10
+ }
11
+
12
+ export function TraceTimeline({
13
+ spans,
14
+ className,
15
+ role = "region",
16
+ "aria-label": ariaLabel = "Trace timeline",
17
+ "aria-labelledby": ariaLabelledBy,
18
+ ...props
19
+ }: TraceTimelineProps) {
20
+ const timeline = React.useMemo(() => buildTimeline(spans), [spans]);
21
+ const issueCount = spans.filter((span) => isIssueStatus(getSpanStatus(span))).length;
22
+ const activeCount = spans.filter((span) => isActiveStatus(getSpanStatus(span))).length;
23
+ const services = new Set(spans.flatMap((span) => (span.service ? [span.service] : [])));
24
+ const serviceCount = services.size;
25
+ const describedServiceCount = spans.length ? serviceCount || 1 : 0;
26
+ const traceDescription = spans.length
27
+ ? `${spans.length} spans across ${describedServiceCount} service${
28
+ describedServiceCount === 1 ? "" : "s"
29
+ }.`
30
+ : "No spans have been recorded for this trace window.";
31
+ const healthLabel = issueCount
32
+ ? `${issueCount} ${issueCount === 1 ? "issue" : "issues"}`
33
+ : activeCount
34
+ ? `${activeCount} active`
35
+ : "All complete";
36
+ const healthSeverity = issueCount ? "danger" : activeCount ? "info" : "success";
37
+ const rootClassName = ["eth-dev-trace-timeline", className].filter(Boolean).join(" ");
38
+
39
+ return (
40
+ <section
41
+ {...props}
42
+ aria-label={ariaLabelledBy ? undefined : ariaLabel}
43
+ aria-labelledby={ariaLabelledBy}
44
+ className={rootClassName}
45
+ data-eth-component="TraceTimeline"
46
+ role={role}
47
+ >
48
+ <header className="eth-dev-trace-timeline__header">
49
+ <div className="eth-dev-trace-timeline__heading">
50
+ <p className="eth-dev-trace-timeline__eyebrow">Distributed trace</p>
51
+ <h3>Trace timeline</h3>
52
+ <p>{traceDescription}</p>
53
+ </div>
54
+ <Badge severity={healthSeverity}>{healthLabel}</Badge>
55
+ </header>
56
+
57
+ <dl className="eth-dev-trace-timeline__summary" aria-label="Trace summary">
58
+ <div>
59
+ <dt>Spans</dt>
60
+ <dd>{spans.length}</dd>
61
+ </div>
62
+ <div>
63
+ <dt>Window</dt>
64
+ <dd>{formatDuration(timeline.total)}</dd>
65
+ </div>
66
+ <div>
67
+ <dt>Issues</dt>
68
+ <dd>
69
+ {issueCount ? `${issueCount} ${issueCount === 1 ? "issue" : "issues"}` : "0"}
70
+ </dd>
71
+ </div>
72
+ <div>
73
+ <dt>Services</dt>
74
+ <dd>{serviceCount}</dd>
75
+ </div>
76
+ </dl>
77
+
78
+ {spans.length ? (
79
+ <div className="eth-dev-trace-timeline__viewport">
80
+ <div className="eth-dev-trace-timeline__axis" aria-hidden="true">
81
+ <span>Span</span>
82
+ <div className="eth-dev-trace-timeline__axis-scale">
83
+ <span>0 ms</span>
84
+ <span>{formatDuration(timeline.total / 2)}</span>
85
+ <span>{formatDuration(timeline.total)}</span>
86
+ </div>
87
+ </div>
88
+
89
+ <div className="eth-dev-trace-timeline__rows" role="list" aria-label="Trace spans">
90
+ {timeline.items.map(({ span, start, end, depth }) => {
91
+ const status = getSpanStatus(span);
92
+ const startOffset = start - timeline.min;
93
+ const duration = end - start;
94
+ const left = (startOffset / timeline.total) * 100;
95
+ const width = Math.max(1, (duration / timeline.total) * 100);
96
+ const spanLabel = formatSpanLabel(span, status, startOffset, duration);
97
+
98
+ return (
99
+ <article
100
+ key={span.id}
101
+ className={`eth-dev-trace-timeline__row eth-dev-trace-timeline__row--${status}`}
102
+ role="listitem"
103
+ style={
104
+ {
105
+ "--eth-dev-trace-indent": `${depth}rem`,
106
+ "--eth-dev-trace-left": `${left}%`,
107
+ "--eth-dev-trace-width": `${width}%`
108
+ } as React.CSSProperties
109
+ }
110
+ >
111
+ <div className="eth-dev-trace-timeline__label">
112
+ <div className="eth-dev-trace-timeline__span-title">
113
+ <StatusDot status={status} label={statusLabel(status)} />
114
+ <strong>{span.name}</strong>
115
+ </div>
116
+ <div className="eth-dev-trace-timeline__span-meta">
117
+ {span.service ? <span>{span.service}</span> : null}
118
+ {span.parentId ? <span>Parent {span.parentId}</span> : null}
119
+ <span>{formatDuration(duration)}</span>
120
+ </div>
121
+ </div>
122
+ <div className="eth-dev-trace-timeline__track" role="img" aria-label={spanLabel}>
123
+ <span
124
+ aria-hidden="true"
125
+ className={`eth-dev-trace-timeline__bar eth-dev-trace-timeline__bar--${status}`}
126
+ title={spanLabel}
127
+ />
128
+ </div>
129
+ </article>
130
+ );
131
+ })}
132
+ </div>
133
+ </div>
134
+ ) : (
135
+ <div className="eth-dev-trace-timeline__empty" role="status">
136
+ <h4>No spans recorded</h4>
137
+ <p>Trace spans will appear here when instrumentation emits timing data.</p>
138
+ </div>
139
+ )}
140
+ </section>
141
+ );
142
+ }
143
+
144
+ function buildTimeline(spans: TraceSpan[]) {
145
+ if (!spans.length) return { items: [] as TimelineItem[], min: 0, total: 0 };
146
+
147
+ const spansById = new Map(spans.map((span) => [span.id, span]));
148
+ const depthById = new Map<string, number>();
149
+ const depthFor = (span: TraceSpan, seen = new Set<string>()): number => {
150
+ if (depthById.has(span.id)) return depthById.get(span.id) ?? 0;
151
+ if (!span.parentId || seen.has(span.id)) {
152
+ depthById.set(span.id, 0);
153
+ return 0;
154
+ }
155
+
156
+ const parent = spansById.get(span.parentId);
157
+ const depth = parent ? depthFor(parent, new Set(seen).add(span.id)) + 1 : 0;
158
+ depthById.set(span.id, depth);
159
+ return depth;
160
+ };
161
+
162
+ const items = spans
163
+ .map((span, index) => {
164
+ const start = Number.isFinite(span.startMs) ? span.startMs : index * 10;
165
+ const duration = Number.isFinite(span.durationMs) ? Math.max(span.durationMs, 1) : 1;
166
+ return { span, start, end: start + duration, depth: depthFor(span) };
167
+ })
168
+ .sort((a, b) => a.start - b.start || a.span.name.localeCompare(b.span.name));
169
+
170
+ const min = Math.min(...items.map((item) => item.start));
171
+ const max = Math.max(...items.map((item) => item.end));
172
+ return { items, min, total: Math.max(max - min, 1) };
173
+ }
174
+
175
+ function getSpanStatus(span: TraceSpan): NonNullable<TraceSpan["status"]> {
176
+ return span.status ?? "running";
177
+ }
178
+
179
+ function isActiveStatus(status: NonNullable<TraceSpan["status"]>) {
180
+ return status === "running" || status === "in-progress";
181
+ }
182
+
183
+ function isIssueStatus(status: NonNullable<TraceSpan["status"]>) {
184
+ return (
185
+ status === "failed" || status === "blocked" || status === "warning" || status === "stale"
186
+ );
187
+ }
188
+
189
+ function formatSpanLabel(
190
+ span: TraceSpan,
191
+ status: NonNullable<TraceSpan["status"]>,
192
+ startOffset: number,
193
+ duration: number
194
+ ) {
195
+ return `${span.name}${span.service ? ` in ${span.service}` : ""}, ${statusLabel(
196
+ status
197
+ )}, starts at ${formatDuration(startOffset)}, duration ${formatDuration(duration)}`;
198
+ }
199
+
200
+ function formatDuration(ms: number) {
201
+ if (!Number.isFinite(ms) || ms <= 0) return "0 ms";
202
+ if (ms < 1000) return `${Math.round(ms)} ms`;
203
+
204
+ const seconds = ms / 1000;
205
+ const precision = seconds < 10 ? 2 : 1;
206
+ return `${seconds.toFixed(precision).replace(/\.?0+$/, "")} s`;
207
+ }
@@ -0,0 +1,57 @@
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { WebhookEventViewer } from "./WebhookEventViewer";
4
+
5
+ const events = [
6
+ {
7
+ id: "evt_1001",
8
+ url: "https://hooks.example.com/tasks",
9
+ status: "delivered",
10
+ retries: 0,
11
+ deliveredAt: "10:42",
12
+ payload: { event: "task.created" }
13
+ },
14
+ {
15
+ id: "evt_1002",
16
+ url: "https://hooks.example.com/approvals",
17
+ status: "failed",
18
+ retries: 3,
19
+ payload: { event: "approval.granted" }
20
+ }
21
+ ];
22
+
23
+ describe("WebhookEventViewer", () => {
24
+ it("renders webhook delivery status, summary metrics, and retry action", () => {
25
+ const onRetry = vi.fn();
26
+
27
+ render(<WebhookEventViewer events={events} onRetry={onRetry} />);
28
+
29
+ const viewer = screen.getByRole("region", { name: "Webhook deliveries" });
30
+
31
+ expect(within(viewer).getByText("Webhook deliveries")).toBeTruthy();
32
+ expect(within(viewer).getByText("2 events")).toBeTruthy();
33
+ expect(within(viewer).getByText("1 failed")).toBeTruthy();
34
+ expect(within(viewer).getByText("3 retries")).toBeTruthy();
35
+ expect(within(viewer).getByText("task.created")).toBeTruthy();
36
+ expect(within(viewer).getByText("approval.granted")).toBeTruthy();
37
+ expect(within(viewer).getByText("Not delivered")).toBeTruthy();
38
+
39
+ const deliveredBadge = within(viewer)
40
+ .getAllByText("Delivered")
41
+ .find((node) => node.closest(".eth-badge"))
42
+ ?.closest(".eth-badge");
43
+ const failedBadge = within(viewer)
44
+ .getAllByText("Failed")
45
+ .find((node) => node.closest(".eth-badge"))
46
+ ?.closest(".eth-badge");
47
+
48
+ expect(deliveredBadge?.className).toContain("eth-badge--success");
49
+ expect(failedBadge?.className).toContain("eth-badge--danger");
50
+
51
+ const retryButton = within(viewer).getByRole("button", { name: "Retry" });
52
+
53
+ fireEvent.click(retryButton);
54
+
55
+ expect(onRetry).toHaveBeenCalledWith("evt_1002");
56
+ });
57
+ });
@@ -0,0 +1,184 @@
1
+ import * as React from "react";
2
+ import { Badge, EmptyState, type EthSeverity } from "@echothink-ui/core";
3
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
4
+ import type { WebhookEvent, WebhookEventViewerProps } from "./types";
5
+
6
+ export function WebhookEventViewer({
7
+ events,
8
+ onRetry,
9
+ className,
10
+ "aria-label": ariaLabel,
11
+ ...props
12
+ }: WebhookEventViewerProps) {
13
+ const failedCount = events.filter(
14
+ (event) => webhookStatusTone(event.status) === "danger"
15
+ ).length;
16
+ const retryCount = events.reduce((total, event) => total + event.retries, 0);
17
+ const columns: DataColumn<WebhookEvent>[] = [
18
+ {
19
+ key: "id",
20
+ header: "Event",
21
+ width: "16rem",
22
+ render: (row) => (
23
+ <span className="eth-dev-webhook-event-viewer__event">
24
+ <code title={row.id}>{row.id}</code>
25
+ <span>{payloadEventLabel(row.payload)}</span>
26
+ </span>
27
+ )
28
+ },
29
+ {
30
+ key: "url",
31
+ header: "Endpoint",
32
+ render: (row) => (
33
+ <code className="eth-dev-webhook-event-viewer__url" title={row.url}>
34
+ {row.url}
35
+ </code>
36
+ )
37
+ },
38
+ {
39
+ key: "status",
40
+ header: "Status",
41
+ width: "9rem",
42
+ render: (row) => (
43
+ <Badge severity={webhookStatusTone(row.status)}>
44
+ {formatWebhookStatus(row.status)}
45
+ </Badge>
46
+ )
47
+ },
48
+ {
49
+ key: "retries",
50
+ header: "Retries",
51
+ align: "end",
52
+ width: "6.5rem",
53
+ render: (row) => (
54
+ <span className="eth-dev-webhook-event-viewer__count">{row.retries}</span>
55
+ )
56
+ },
57
+ {
58
+ key: "deliveredAt",
59
+ header: "Delivered",
60
+ width: "10rem",
61
+ render: (row) =>
62
+ row.deliveredAt ? (
63
+ <time className="eth-dev-webhook-event-viewer__time">{row.deliveredAt}</time>
64
+ ) : (
65
+ <span className="eth-dev-webhook-event-viewer__missing">Not delivered</span>
66
+ )
67
+ }
68
+ ];
69
+
70
+ return (
71
+ <section
72
+ {...props}
73
+ aria-label={ariaLabel ?? "Webhook deliveries"}
74
+ className={`eth-dev-webhook-event-viewer ${className ?? ""}`.trim()}
75
+ data-eth-component="WebhookEventViewer"
76
+ >
77
+ <header className="eth-dev-webhook-event-viewer__header">
78
+ <div className="eth-dev-webhook-event-viewer__heading">
79
+ <p>Webhook events</p>
80
+ <h3>Webhook deliveries</h3>
81
+ </div>
82
+ <dl
83
+ className="eth-dev-webhook-event-viewer__summary"
84
+ aria-label="Webhook delivery summary"
85
+ >
86
+ <div>
87
+ <dt>Events</dt>
88
+ <dd>{pluralize(events.length, "event")}</dd>
89
+ </div>
90
+ <div>
91
+ <dt>Failed</dt>
92
+ <dd>{pluralize(failedCount, "failed", "failed")}</dd>
93
+ </div>
94
+ <div>
95
+ <dt>Retries</dt>
96
+ <dd>{pluralize(retryCount, "retry", "retries")}</dd>
97
+ </div>
98
+ </dl>
99
+ </header>
100
+ <DataTable
101
+ rows={events}
102
+ columns={columns}
103
+ rowKey="id"
104
+ density="compact"
105
+ className="eth-dev-webhook-event-viewer__table"
106
+ aria-label="Webhook delivery events"
107
+ rowActions={
108
+ onRetry
109
+ ? (row) =>
110
+ isRetryableWebhookStatus(row.status)
111
+ ? [
112
+ {
113
+ id: `retry-${row.id}`,
114
+ label: "Retry",
115
+ onSelect: () => onRetry(row.id)
116
+ }
117
+ ]
118
+ : []
119
+ : undefined
120
+ }
121
+ emptyState={
122
+ <EmptyState
123
+ title="No webhook events"
124
+ description="Delivery attempts and retry outcomes will appear here when webhooks fire."
125
+ />
126
+ }
127
+ />
128
+ </section>
129
+ );
130
+ }
131
+
132
+ function webhookStatusTone(status: string): EthSeverity {
133
+ const token = normalizeWebhookStatus(status);
134
+
135
+ if (["delivered", "success", "succeeded", "completed", "ok"].includes(token)) {
136
+ return "success";
137
+ }
138
+ if (isRetryableWebhookStatus(token)) return "danger";
139
+ if (["retrying", "queued", "pending", "running", "in-progress"].includes(token)) {
140
+ return "info";
141
+ }
142
+ if (["warning", "throttled", "rate-limited"].includes(token)) {
143
+ return "warning";
144
+ }
145
+ return "neutral";
146
+ }
147
+
148
+ function isRetryableWebhookStatus(status: string) {
149
+ return ["failed", "error", "timeout", "timed-out", "undelivered"].includes(
150
+ normalizeWebhookStatus(status)
151
+ );
152
+ }
153
+
154
+ function normalizeWebhookStatus(status: string) {
155
+ return status.trim().toLowerCase().replace(/_/g, "-");
156
+ }
157
+
158
+ function formatWebhookStatus(status: string) {
159
+ const normalized = normalizeWebhookStatus(status);
160
+
161
+ if (!normalized) return "Unknown";
162
+ if (normalized === "ok") return "OK";
163
+
164
+ return normalized
165
+ .split("-")
166
+ .filter(Boolean)
167
+ .map((part) => part[0].toUpperCase() + part.slice(1))
168
+ .join(" ");
169
+ }
170
+
171
+ function payloadEventLabel(payload: unknown) {
172
+ if (payload && typeof payload === "object") {
173
+ const record = payload as Record<string, unknown>;
174
+ const eventName = record.event ?? record.type ?? record.name;
175
+
176
+ if (typeof eventName === "string" && eventName.trim()) return eventName;
177
+ }
178
+
179
+ return "Webhook event";
180
+ }
181
+
182
+ function pluralize(count: number, singular: string, plural = `${singular}s`) {
183
+ return `${count} ${count === 1 ? singular : plural}`;
184
+ }
@@ -0,0 +1,207 @@
1
+ import * as React from "react";
2
+ import { Checkmark, Copy } from "@carbon/icons-react";
3
+ import { IconButton } from "@echothink-ui/core";
4
+ import type { YAMLViewerProps } from "./types";
5
+ import { CodeEditor } from "./CodeEditor";
6
+ import { splitLines } from "./devUtils";
7
+
8
+ function countYamlEntries(lines: string[]) {
9
+ return lines.filter(
10
+ (line) => /^\s*(?:-\s+)?[\w.-]+\s*:/.test(line) || /^\s*-\s+\S/.test(line)
11
+ ).length;
12
+ }
13
+
14
+ function getScalarClass(value: string) {
15
+ if (/^["'].*["']$/.test(value)) return "string";
16
+ if (/^(?:true|false)$/i.test(value)) return "boolean";
17
+ if (/^(?:null|~)$/i.test(value)) return "null";
18
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return "number";
19
+ return "plain";
20
+ }
21
+
22
+ function renderScalar(value: string) {
23
+ if (!value) return null;
24
+
25
+ const commentIndex = value.search(/(^|\s)#/);
26
+ const scalar = commentIndex >= 0 ? value.slice(0, commentIndex) : value;
27
+ const comment = commentIndex >= 0 ? value.slice(commentIndex) : "";
28
+ const scalarClass = getScalarClass(scalar.trim());
29
+
30
+ return (
31
+ <>
32
+ {scalar ? (
33
+ <span
34
+ className={`eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--${scalarClass}`}
35
+ >
36
+ {scalar}
37
+ </span>
38
+ ) : null}
39
+ {comment ? (
40
+ <span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--comment">
41
+ {comment}
42
+ </span>
43
+ ) : null}
44
+ </>
45
+ );
46
+ }
47
+
48
+ function renderYamlLine(line: string) {
49
+ if (!line.trim()) return line;
50
+
51
+ if (/^\s*#/.test(line)) {
52
+ return (
53
+ <span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--comment">
54
+ {line}
55
+ </span>
56
+ );
57
+ }
58
+
59
+ const keyMatch = line.match(/^(\s*)(-\s+)?([\w.-]+)(\s*:)(.*)$/);
60
+ if (keyMatch) {
61
+ const [, indent, listMarker = "", key, separator, rest] = keyMatch;
62
+ return (
63
+ <>
64
+ {indent}
65
+ {listMarker ? (
66
+ <span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--marker">
67
+ {listMarker}
68
+ </span>
69
+ ) : null}
70
+ <span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--key">
71
+ {key}
72
+ </span>
73
+ <span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--punctuation">
74
+ {separator}
75
+ </span>
76
+ {renderScalar(rest)}
77
+ </>
78
+ );
79
+ }
80
+
81
+ const listMatch = line.match(/^(\s*)(-\s+)(.*)$/);
82
+ if (listMatch) {
83
+ const [, indent, marker, rest] = listMatch;
84
+ return (
85
+ <>
86
+ {indent}
87
+ <span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--marker">
88
+ {marker}
89
+ </span>
90
+ {renderScalar(rest)}
91
+ </>
92
+ );
93
+ }
94
+
95
+ return renderScalar(line) ?? line;
96
+ }
97
+
98
+ export function YAMLViewer({
99
+ value,
100
+ editable = false,
101
+ onChange,
102
+ className,
103
+ "aria-label": ariaLabel,
104
+ ...props
105
+ }: YAMLViewerProps) {
106
+ const [copied, setCopied] = React.useState(false);
107
+ const resetTimer = React.useRef<number | undefined>(undefined);
108
+ const lines = splitLines(value);
109
+ const lineCount = lines.length;
110
+ const entryCount = countYamlEntries(lines);
111
+ const modeLabel = editable ? "Editable" : "Read-only";
112
+ const viewerLabel = ariaLabel ?? (editable ? "YAML editor" : "YAML document");
113
+
114
+ React.useEffect(() => {
115
+ return () => {
116
+ if (resetTimer.current !== undefined && typeof window !== "undefined") {
117
+ window.clearTimeout(resetTimer.current);
118
+ }
119
+ };
120
+ }, []);
121
+
122
+ const copy = React.useCallback(() => {
123
+ if (typeof navigator === "undefined" || !navigator.clipboard) return;
124
+
125
+ void navigator.clipboard
126
+ .writeText(value)
127
+ .then(() => {
128
+ if (typeof window !== "undefined") {
129
+ setCopied(true);
130
+ if (resetTimer.current !== undefined) {
131
+ window.clearTimeout(resetTimer.current);
132
+ }
133
+ resetTimer.current = window.setTimeout(() => setCopied(false), 1200);
134
+ }
135
+ })
136
+ .catch(() => {
137
+ setCopied(false);
138
+ });
139
+ }, [value]);
140
+
141
+ return (
142
+ <section
143
+ {...props}
144
+ aria-label={viewerLabel}
145
+ className={`eth-dev-yaml-viewer ${className ?? ""}`.trim()}
146
+ data-eth-component="YAMLViewer"
147
+ data-editable={editable ? "true" : undefined}
148
+ >
149
+ <header className="eth-dev-yaml-viewer__header">
150
+ <div className="eth-dev-yaml-viewer__heading">
151
+ <span className="eth-dev-yaml-viewer__eyebrow">YAML</span>
152
+ <span className="eth-dev-yaml-viewer__title">
153
+ {editable ? "YAML editor" : "YAML document"}
154
+ </span>
155
+ </div>
156
+ <div
157
+ className="eth-dev-yaml-viewer__meta"
158
+ role="group"
159
+ aria-label="YAML document metadata"
160
+ >
161
+ <span className="eth-dev-yaml-viewer__badge">{modeLabel}</span>
162
+ <span className="eth-dev-yaml-viewer__badge">
163
+ {lineCount} {lineCount === 1 ? "line" : "lines"}
164
+ </span>
165
+ <span className="eth-dev-yaml-viewer__badge">
166
+ {entryCount} {entryCount === 1 ? "entry" : "entries"}
167
+ </span>
168
+ <IconButton
169
+ className="eth-dev-yaml-viewer__copy-button"
170
+ label={copied ? "Copied YAML" : "Copy YAML"}
171
+ intent="ghost"
172
+ density="compact"
173
+ icon={copied ? <Checkmark size={16} /> : <Copy size={16} />}
174
+ onClick={copy}
175
+ />
176
+ <span className="eth-dev-yaml-viewer__copy-status" aria-live="polite">
177
+ {copied ? "YAML copied" : ""}
178
+ </span>
179
+ </div>
180
+ </header>
181
+ {editable ? (
182
+ <CodeEditor
183
+ value={value}
184
+ onChange={onChange}
185
+ language="yaml"
186
+ className="eth-dev-yaml-viewer__editor"
187
+ aria-label={viewerLabel}
188
+ />
189
+ ) : (
190
+ <pre className="eth-dev-yaml-viewer__pre" aria-label={viewerLabel} tabIndex={0}>
191
+ <code className="eth-dev-yaml-viewer__code">
192
+ {lines.map((line, index) => (
193
+ <span key={index} className="eth-dev-yaml-viewer__line">
194
+ <span className="eth-dev-yaml-viewer__gutter" aria-hidden="true">
195
+ {index + 1}
196
+ </span>
197
+ <span className="eth-dev-yaml-viewer__line-code">
198
+ {renderYamlLine(line)}
199
+ </span>
200
+ </span>
201
+ ))}
202
+ </code>
203
+ </pre>
204
+ )}
205
+ </section>
206
+ );
207
+ }