@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,205 @@
1
+ import * as React from "react";
2
+ import { Button, FormField, Select, TextInput, Textarea } from "@echothink-ui/core";
3
+ import type { APIEndpoint, APIExplorerProps, APIRequest } from "./types";
4
+ import { formatUnknown } from "./devUtils";
5
+
6
+ type ResponseState =
7
+ | { status: "idle" }
8
+ | { status: "loading" }
9
+ | { status: "success"; value: unknown }
10
+ | { status: "error"; value: unknown };
11
+
12
+ export function APIExplorer({
13
+ endpoints,
14
+ selectedId,
15
+ onSelect,
16
+ onSend,
17
+ className,
18
+ ...props
19
+ }: APIExplorerProps) {
20
+ const [internalId, setInternalId] = React.useState(selectedId ?? endpoints[0]?.id);
21
+ const selected =
22
+ endpoints.find((endpoint) => endpoint.id === (selectedId ?? internalId)) ?? endpoints[0];
23
+ const [method, setMethod] = React.useState(selected?.method ?? "GET");
24
+ const [path, setPath] = React.useState(selected?.path ?? "");
25
+ const [headers, setHeaders] = React.useState(() => formatEndpointValue(selected?.headers, "{}"));
26
+ const [body, setBody] = React.useState(() => formatEndpointValue(selected?.body, ""));
27
+ const [response, setResponse] = React.useState<ResponseState>({ status: "idle" });
28
+ const headerError = validateHeaderJson(headers);
29
+
30
+ React.useEffect(() => {
31
+ if (!selected) return;
32
+ setMethod(selected.method);
33
+ setPath(selected.path);
34
+ setHeaders(formatEndpointValue(selected.headers, "{}"));
35
+ setBody(formatEndpointValue(selected.body, ""));
36
+ }, [selected?.id]);
37
+
38
+ const choose = (endpoint: APIEndpoint) => {
39
+ setInternalId(endpoint.id);
40
+ onSelect?.(endpoint.id);
41
+ };
42
+
43
+ const send = async () => {
44
+ if (headerError || !path.trim()) return;
45
+
46
+ const request: APIRequest = { endpointId: selected?.id, method, path, headers, body };
47
+ setResponse({ status: "loading" });
48
+
49
+ try {
50
+ const result = await onSend?.(request);
51
+ setResponse({ status: "success", value: result ?? { ok: true, request } });
52
+ } catch (error) {
53
+ setResponse({ status: "error", value: formatError(error) });
54
+ }
55
+ };
56
+
57
+ const isLoading = response.status === "loading";
58
+ const responseStateLabel = response.status === "idle" ? "Ready" : response.status;
59
+
60
+ return (
61
+ <section
62
+ {...props}
63
+ className={`eth-dev-api-explorer ${className ?? ""}`}
64
+ data-eth-component="APIExplorer"
65
+ >
66
+ <nav className="eth-dev-api-explorer__nav" aria-label="Endpoints">
67
+ <div className="eth-dev-api-explorer__nav-heading">
68
+ <span>Endpoints</span>
69
+ <span>{endpoints.length}</span>
70
+ </div>
71
+ <div className="eth-dev-api-explorer__endpoint-list">
72
+ {endpoints.length ? (
73
+ endpoints.map((endpoint) => (
74
+ <button
75
+ key={endpoint.id}
76
+ type="button"
77
+ aria-current={selected?.id === endpoint.id}
78
+ onClick={() => choose(endpoint)}
79
+ >
80
+ <span
81
+ className={`eth-dev-api-explorer__method eth-dev-api-explorer__method--${methodToken(endpoint.method)}`}
82
+ >
83
+ {endpoint.method}
84
+ </span>
85
+ <span className="eth-dev-api-explorer__endpoint-copy">
86
+ <code>{endpoint.path}</code>
87
+ {endpoint.summary ? <small>{endpoint.summary}</small> : null}
88
+ </span>
89
+ </button>
90
+ ))
91
+ ) : (
92
+ <p className="eth-dev-api-explorer__empty">No endpoints configured.</p>
93
+ )}
94
+ </div>
95
+ </nav>
96
+ <form
97
+ className="eth-dev-api-explorer__request"
98
+ onSubmit={(event) => {
99
+ event.preventDefault();
100
+ void send();
101
+ }}
102
+ >
103
+ <header className="eth-dev-api-explorer__request-header">
104
+ <div>
105
+ <p>Request</p>
106
+ <h3>{selected?.summary ?? "Custom endpoint"}</h3>
107
+ </div>
108
+ {selected ? <code>{selected.id}</code> : null}
109
+ </header>
110
+ <div className="eth-dev-api-explorer__method-path">
111
+ <FormField label="Method">
112
+ <Select
113
+ value={method}
114
+ options={["GET", "POST", "PUT", "PATCH", "DELETE"].map((item) => ({
115
+ value: item,
116
+ label: item
117
+ }))}
118
+ onChange={(event) => setMethod(event.currentTarget.value)}
119
+ />
120
+ </FormField>
121
+ <FormField label="Path">
122
+ <TextInput value={path} onChange={(event) => setPath(event.currentTarget.value)} />
123
+ </FormField>
124
+ </div>
125
+ <FormField label="Headers" error={headerError}>
126
+ <Textarea
127
+ className="eth-dev-api-explorer__code-field"
128
+ value={headers}
129
+ onChange={(event) => setHeaders(event.currentTarget.value)}
130
+ rows={5}
131
+ />
132
+ </FormField>
133
+ <FormField label="Body">
134
+ <Textarea
135
+ className="eth-dev-api-explorer__code-field"
136
+ value={body}
137
+ onChange={(event) => setBody(event.currentTarget.value)}
138
+ rows={8}
139
+ />
140
+ </FormField>
141
+ <div className="eth-dev-api-explorer__actions">
142
+ <span>
143
+ {method} {path || "/"}
144
+ </span>
145
+ <Button type="submit" loading={isLoading} disabled={Boolean(headerError) || !path.trim()}>
146
+ Send
147
+ </Button>
148
+ </div>
149
+ </form>
150
+ <section className="eth-dev-api-explorer__response" aria-live="polite" aria-busy={isLoading}>
151
+ <header className="eth-dev-api-explorer__response-header">
152
+ <h3>Response</h3>
153
+ <span
154
+ className={`eth-dev-api-explorer__response-state eth-dev-api-explorer__response-state--${response.status}`}
155
+ >
156
+ {responseStateLabel}
157
+ </span>
158
+ </header>
159
+ {response.status === "idle" ? (
160
+ <div className="eth-dev-api-explorer__response-empty">No response yet.</div>
161
+ ) : response.status === "loading" ? (
162
+ <div className="eth-dev-api-explorer__response-empty">Waiting for response.</div>
163
+ ) : (
164
+ <pre>
165
+ <code>{formatUnknown(response.value)}</code>
166
+ </pre>
167
+ )}
168
+ </section>
169
+ </section>
170
+ );
171
+ }
172
+
173
+ function formatEndpointValue(value: unknown, fallback: string) {
174
+ if (value == null) return fallback;
175
+ if (typeof value === "string") return value;
176
+ return formatUnknown(value);
177
+ }
178
+
179
+ function validateHeaderJson(value: string) {
180
+ const trimmed = value.trim();
181
+ if (!trimmed) return null;
182
+
183
+ try {
184
+ const parsed = JSON.parse(trimmed);
185
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
186
+ return "Headers must be a JSON object.";
187
+ }
188
+ } catch {
189
+ return "Headers must be valid JSON.";
190
+ }
191
+
192
+ return null;
193
+ }
194
+
195
+ function formatError(error: unknown) {
196
+ if (error instanceof Error) {
197
+ return { error: error.message };
198
+ }
199
+
200
+ return error;
201
+ }
202
+
203
+ function methodToken(method: string) {
204
+ return method.toLowerCase().replace(/[^a-z0-9-]/g, "");
205
+ }
@@ -0,0 +1,54 @@
1
+ import * as React from "react";
2
+ import { Select } from "@echothink-ui/core";
3
+ import type { BranchSelectorProps } from "./types";
4
+
5
+ export function BranchSelector({
6
+ branches,
7
+ value,
8
+ onChange,
9
+ className,
10
+ disabled,
11
+ labelText = "Branch",
12
+ helperText,
13
+ density = "compact",
14
+ ...props
15
+ }: BranchSelectorProps) {
16
+ const hasBranches = branches.length > 0;
17
+ const branchCountLabel =
18
+ branches.length === 1 ? "1 branch available" : `${branches.length} branches available`;
19
+ const resolvedHelperText =
20
+ helperText === undefined
21
+ ? hasBranches
22
+ ? branchCountLabel
23
+ : "No branches available"
24
+ : helperText;
25
+
26
+ return (
27
+ <Select
28
+ {...props}
29
+ className={[
30
+ "eth-dev-branch-selector",
31
+ !hasBranches && "eth-dev-branch-selector--empty",
32
+ className
33
+ ]
34
+ .filter(Boolean)
35
+ .join(" ")}
36
+ data-eth-component="BranchSelector"
37
+ density={density}
38
+ disabled={disabled || !hasBranches}
39
+ helperText={resolvedHelperText}
40
+ labelText={labelText}
41
+ value={hasBranches ? value : ""}
42
+ options={
43
+ hasBranches
44
+ ? branches.map((branch) => ({ value: branch, label: branch }))
45
+ : [{ value: "", label: "No branches available", disabled: true }]
46
+ }
47
+ onChange={(event) => {
48
+ if (hasBranches) {
49
+ onChange?.(event.currentTarget.value);
50
+ }
51
+ }}
52
+ />
53
+ );
54
+ }
@@ -0,0 +1,127 @@
1
+ import * as React from "react";
2
+ import { Checkmark, Copy } from "@carbon/icons-react";
3
+ import { IconButton } from "@echothink-ui/core";
4
+ import type { CodeBlockProps } from "./types";
5
+ import { splitLines } from "./devUtils";
6
+
7
+ const keywordPattern =
8
+ /\b(?:async|await|break|case|catch|class|const|continue|default|else|export|extends|false|finally|for|from|function|if|import|interface|let|new|null|return|throw|true|try|type|undefined|var|while)\b/;
9
+ const tokenPattern =
10
+ /\/\/.*|\/\*.*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b(?:async|await|break|case|catch|class|const|continue|default|else|export|extends|false|finally|for|from|function|if|import|interface|let|new|null|return|throw|true|try|type|undefined|var|while)\b/g;
11
+
12
+ function getTokenKind(token: string) {
13
+ if (token.startsWith("//") || token.startsWith("/*")) return "comment";
14
+ if (token.startsWith('"') || token.startsWith("'") || token.startsWith("`")) return "string";
15
+ if (/^\d/.test(token)) return "number";
16
+ if (keywordPattern.test(token)) return "keyword";
17
+ return "plain";
18
+ }
19
+
20
+ function renderCodeLine(line: string) {
21
+ const parts: React.ReactNode[] = [];
22
+ let lastIndex = 0;
23
+ tokenPattern.lastIndex = 0;
24
+ for (let match = tokenPattern.exec(line); match; match = tokenPattern.exec(line)) {
25
+ const token = match[0];
26
+ if (match.index > lastIndex) {
27
+ parts.push(line.slice(lastIndex, match.index));
28
+ }
29
+
30
+ const kind = getTokenKind(token);
31
+ parts.push(
32
+ <span
33
+ key={`${match.index}-${token}`}
34
+ className={`eth-dev-code-block__token eth-dev-code-block__token--${kind}`}
35
+ >
36
+ {token}
37
+ </span>
38
+ );
39
+ lastIndex = match.index + token.length;
40
+ }
41
+
42
+ if (lastIndex < line.length) {
43
+ parts.push(line.slice(lastIndex));
44
+ }
45
+
46
+ return parts.length ? parts : line;
47
+ }
48
+
49
+ export function CodeBlock({
50
+ code,
51
+ language = "text",
52
+ showLineNumbers = false,
53
+ className,
54
+ ...props
55
+ }: CodeBlockProps) {
56
+ const [copied, setCopied] = React.useState(false);
57
+ const resetTimer = React.useRef<number | undefined>(undefined);
58
+ const lines = splitLines(code);
59
+
60
+ React.useEffect(() => {
61
+ return () => {
62
+ if (resetTimer.current !== undefined && typeof window !== "undefined") {
63
+ window.clearTimeout(resetTimer.current);
64
+ }
65
+ };
66
+ }, []);
67
+
68
+ const copy = React.useCallback(() => {
69
+ if (typeof navigator === "undefined" || !navigator.clipboard) return;
70
+
71
+ void navigator.clipboard
72
+ .writeText(code)
73
+ .then(() => {
74
+ if (typeof window !== "undefined") {
75
+ setCopied(true);
76
+ if (resetTimer.current !== undefined) {
77
+ window.clearTimeout(resetTimer.current);
78
+ }
79
+ resetTimer.current = window.setTimeout(() => setCopied(false), 1200);
80
+ }
81
+ })
82
+ .catch(() => {
83
+ setCopied(false);
84
+ });
85
+ }, [code]);
86
+
87
+ const normalizedLanguage = language.trim() || "text";
88
+ const languageClass = normalizedLanguage.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
89
+
90
+ return (
91
+ <section
92
+ {...props}
93
+ className={`eth-dev-code-block ${showLineNumbers ? "eth-dev-code-block--numbered" : ""} ${className ?? ""}`}
94
+ data-eth-component="CodeBlock"
95
+ data-language={normalizedLanguage}
96
+ >
97
+ <header className="eth-dev-code-block__header">
98
+ <span className="eth-dev-code-block__language">{normalizedLanguage}</span>
99
+ <IconButton
100
+ className="eth-dev-code-block__copy-button"
101
+ label={copied ? "Copied" : "Copy code"}
102
+ intent="ghost"
103
+ density="compact"
104
+ icon={copied ? <Checkmark size={16} /> : <Copy size={16} />}
105
+ onClick={copy}
106
+ />
107
+ <span className="eth-dev-code-block__copy-status" aria-live="polite">
108
+ {copied ? "Code copied" : ""}
109
+ </span>
110
+ </header>
111
+ <pre className={`eth-dev-code-block__pre language-${languageClass}`}>
112
+ <code className="eth-dev-code-block__code">
113
+ {lines.map((line, index) => (
114
+ <span key={index} className="eth-dev-code-block__line">
115
+ {showLineNumbers ? (
116
+ <span className="eth-dev-code-block__gutter" aria-hidden>
117
+ {index + 1}
118
+ </span>
119
+ ) : null}
120
+ <span className="eth-dev-code-block__line-code">{renderCodeLine(line)}</span>
121
+ </span>
122
+ ))}
123
+ </code>
124
+ </pre>
125
+ </section>
126
+ );
127
+ }
@@ -0,0 +1,95 @@
1
+ import * as React from "react";
2
+ import type { CodeEditorProps } from "./types";
3
+ import { splitLines } from "./devUtils";
4
+
5
+ export function CodeEditor({
6
+ value,
7
+ onChange,
8
+ language = "text",
9
+ readonly,
10
+ readOnly,
11
+ schemaRef,
12
+ className,
13
+ disabled,
14
+ onScroll,
15
+ rows,
16
+ wrap,
17
+ "aria-describedby": ariaDescribedBy,
18
+ "aria-invalid": ariaInvalid,
19
+ "aria-label": ariaLabel,
20
+ ...props
21
+ }: CodeEditorProps) {
22
+ const lines = splitLines(value);
23
+ const isReadonly = readonly ?? readOnly ?? false;
24
+ const isInvalid =
25
+ ariaInvalid === true ||
26
+ ariaInvalid === "true" ||
27
+ ariaInvalid === "grammar" ||
28
+ ariaInvalid === "spelling";
29
+ const languageLabel = language || "text";
30
+ const lineCount = lines.length;
31
+ const visibleRows = rows ?? Math.min(Math.max(lineCount, 8), 16);
32
+ const statusId = React.useId();
33
+ const gutterRef = React.useRef<HTMLDivElement>(null);
34
+ const describedBy = [ariaDescribedBy, statusId].filter(Boolean).join(" ") || undefined;
35
+ const modeLabel = disabled ? "Disabled" : isReadonly ? "Read-only" : "Editable";
36
+
37
+ const handleScroll = (event: React.UIEvent<HTMLTextAreaElement>) => {
38
+ if (gutterRef.current) {
39
+ gutterRef.current.scrollTop = event.currentTarget.scrollTop;
40
+ }
41
+
42
+ onScroll?.(event);
43
+ };
44
+
45
+ return (
46
+ <section
47
+ className={`eth-dev-code-editor ${className ?? ""}`}
48
+ data-eth-component="CodeEditor"
49
+ data-language={language}
50
+ data-readonly={isReadonly ? "true" : undefined}
51
+ data-disabled={disabled ? "true" : undefined}
52
+ data-invalid={isInvalid ? "true" : undefined}
53
+ data-schema-ref={schemaRef}
54
+ >
55
+ <header className="eth-dev-code-editor__header">
56
+ <div className="eth-dev-code-editor__heading">
57
+ <span className="eth-dev-code-editor__title">Code editor</span>
58
+ <span className="eth-dev-code-editor__language">{languageLabel}</span>
59
+ </div>
60
+ <div className="eth-dev-code-editor__badges" aria-hidden="true">
61
+ <span className="eth-dev-code-editor__badge">{modeLabel}</span>
62
+ {schemaRef ? <span className="eth-dev-code-editor__badge">{schemaRef}</span> : null}
63
+ </div>
64
+ </header>
65
+ <div className="eth-dev-code-editor__body">
66
+ <div ref={gutterRef} className="eth-dev-code-editor__gutter" aria-hidden="true">
67
+ {lines.map((_, index) => (
68
+ <span key={index}>{index + 1}</span>
69
+ ))}
70
+ </div>
71
+ <textarea
72
+ {...props}
73
+ aria-describedby={describedBy}
74
+ aria-invalid={ariaInvalid}
75
+ aria-label={ariaLabel ?? `${languageLabel} code editor`}
76
+ value={value}
77
+ disabled={disabled}
78
+ readOnly={isReadonly}
79
+ rows={visibleRows}
80
+ spellCheck={false}
81
+ wrap={wrap ?? "off"}
82
+ className="eth-dev-code-editor__textarea"
83
+ onChange={(event) => onChange?.(event.currentTarget.value)}
84
+ onScroll={handleScroll}
85
+ />
86
+ </div>
87
+ <footer id={statusId} className="eth-dev-code-editor__footer">
88
+ <span>{modeLabel}</span>
89
+ <span>
90
+ {lineCount} {lineCount === 1 ? "line" : "lines"} / {value.length} chars
91
+ </span>
92
+ </footer>
93
+ </section>
94
+ );
95
+ }
@@ -0,0 +1,100 @@
1
+ import * as React from "react";
2
+ import { EmptyState } from "@echothink-ui/core";
3
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
4
+ import type { CommitListProps, CommitRow } from "./types";
5
+ import { compactSha } from "./devUtils";
6
+
7
+ function authorInitials(author: string) {
8
+ const parts = author.trim().split(/\s+/).filter(Boolean);
9
+ if (!parts.length) return "?";
10
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
11
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
12
+ }
13
+
14
+ export function CommitList({ commits, className, ...props }: CommitListProps) {
15
+ const latestCommit = commits[0];
16
+ const authorCount = new Set(commits.map((commit) => commit.author)).size;
17
+ const columns: DataColumn<CommitRow>[] = [
18
+ {
19
+ key: "sha",
20
+ header: "SHA",
21
+ width: "8.5rem",
22
+ render: (row) => (
23
+ <code className="eth-dev-commit-list__sha" title={row.sha}>
24
+ {compactSha(row.sha)}
25
+ </code>
26
+ )
27
+ },
28
+ {
29
+ key: "message",
30
+ header: "Message",
31
+ render: (row) => (
32
+ <span className="eth-dev-commit-list__message" title={row.message}>
33
+ {row.message}
34
+ </span>
35
+ )
36
+ },
37
+ {
38
+ key: "author",
39
+ header: "Author",
40
+ width: "12rem",
41
+ render: (row) => (
42
+ <span className="eth-dev-commit-list__author">
43
+ <span className="eth-dev-commit-list__avatar" aria-hidden="true">
44
+ {authorInitials(row.author)}
45
+ </span>
46
+ <span className="eth-dev-commit-list__author-name">{row.author}</span>
47
+ </span>
48
+ )
49
+ },
50
+ {
51
+ key: "date",
52
+ header: "Date",
53
+ width: "10rem",
54
+ render: (row) => <span className="eth-dev-commit-list__date">{row.date}</span>
55
+ }
56
+ ];
57
+
58
+ return (
59
+ <section
60
+ {...props}
61
+ aria-label={props["aria-label"] ?? "Commit history"}
62
+ className={`eth-dev-commit-list ${className ?? ""}`}
63
+ data-eth-component="CommitList"
64
+ >
65
+ <header className="eth-dev-commit-list__header">
66
+ <div className="eth-dev-commit-list__heading">
67
+ <p>Source control</p>
68
+ <h3>Commit history</h3>
69
+ </div>
70
+ <dl className="eth-dev-commit-list__summary" aria-label="Commit history summary">
71
+ <div>
72
+ <dt>Commits</dt>
73
+ <dd>{commits.length}</dd>
74
+ </div>
75
+ <div>
76
+ <dt>Authors</dt>
77
+ <dd>{authorCount}</dd>
78
+ </div>
79
+ <div>
80
+ <dt>Latest</dt>
81
+ <dd>{latestCommit ? compactSha(latestCommit.sha) : "None"}</dd>
82
+ </div>
83
+ </dl>
84
+ </header>
85
+ <DataTable
86
+ rows={commits}
87
+ columns={columns}
88
+ rowKey="sha"
89
+ density="compact"
90
+ className="eth-dev-commit-list__table"
91
+ emptyState={
92
+ <EmptyState
93
+ title="No commits"
94
+ description="Commit history will appear when this branch receives changes."
95
+ />
96
+ }
97
+ />
98
+ </section>
99
+ );
100
+ }