Skip to content

Commit f503ff4

Browse files
committed
Add searchable live object inspector
1 parent 47adcc5 commit f503ff4

4 files changed

Lines changed: 228 additions & 35 deletions

File tree

src/components/Inspector.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { PanelRightClose, PanelRightOpen } from "lucide-react";
1+
import { useDeferredValue, useEffect, useMemo, useState } from "react";
2+
import { Check, Copy, PanelRightClose, PanelRightOpen, Search } from "lucide-react";
3+
import { copyTextToClipboard } from "../lib/clipboard";
24
import type { ResourceDetails, ResourceRow } from "../types/kube";
35
import { StatusDot } from "./status";
46

7+
type CopyStatus = "idle" | "copied" | "failed";
8+
59
export function Inspector({
610
collapsed,
711
details,
@@ -90,10 +94,88 @@ function LiveObject({
9094
detailsError: string;
9195
detailsLoading: boolean;
9296
}) {
97+
const [query, setQuery] = useState("");
98+
const [copyStatus, setCopyStatus] = useState<CopyStatus>("idle");
99+
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
100+
const yamlText = detailsLoading ? "Loading YAML..." : details.yaml || detailsError || "No YAML returned.";
101+
const yamlLines = useMemo(() => yamlText.split(/\r?\n/), [yamlText]);
102+
const queryTerms = useMemo(() => (deferredQuery ? deferredQuery.split(/\s+/) : []), [deferredQuery]);
103+
const visibleLines = useMemo(() => {
104+
if (!queryTerms.length) {
105+
return yamlLines.map((line, index) => ({ line, number: index + 1 }));
106+
}
107+
108+
return yamlLines
109+
.map((line, index) => ({ line, number: index + 1 }))
110+
.filter(({ line }) => {
111+
const haystack = line.toLowerCase();
112+
return queryTerms.every((term) => haystack.includes(term));
113+
});
114+
}, [queryTerms, yamlLines]);
115+
const CopyIcon = copyStatus === "copied" ? Check : Copy;
116+
const copyLabel = copyStatus === "copied" ? "Copied" : copyStatus === "failed" ? "Blocked" : "Copy";
117+
118+
async function copyYaml() {
119+
if (!details.yaml) {
120+
return;
121+
}
122+
123+
try {
124+
await copyTextToClipboard(details.yaml);
125+
setCopyStatus("copied");
126+
} catch {
127+
setCopyStatus("failed");
128+
}
129+
}
130+
131+
useEffect(() => {
132+
if (copyStatus === "idle") {
133+
return;
134+
}
135+
136+
const timeout = window.setTimeout(() => setCopyStatus("idle"), 1_600);
137+
return () => window.clearTimeout(timeout);
138+
}, [copyStatus]);
139+
93140
return (
94141
<details className="inspector-yaml">
95-
<summary>Live object</summary>
96-
<pre>{detailsLoading ? "Loading YAML..." : details.yaml || detailsError || "No YAML returned."}</pre>
142+
<summary>
143+
<span>Live object</span>
144+
<small>{queryTerms.length ? `${visibleLines.length}/${yamlLines.length} lines` : `${yamlLines.length} lines`}</small>
145+
</summary>
146+
<div className="inspector-yaml-toolbar">
147+
<label>
148+
<Search size={13} />
149+
<input
150+
aria-label="Find YAML"
151+
value={query}
152+
onChange={(event) => setQuery(event.target.value)}
153+
placeholder="Find YAML..."
154+
/>
155+
</label>
156+
<button
157+
className={copyStatus === "idle" ? "" : copyStatus}
158+
disabled={!details.yaml}
159+
title="Copy full YAML"
160+
type="button"
161+
onClick={copyYaml}
162+
>
163+
<CopyIcon size={13} />
164+
<span>{copyLabel}</span>
165+
</button>
166+
</div>
167+
<div aria-label="Live object YAML" className="inspector-yaml-code">
168+
{visibleLines.length ? (
169+
visibleLines.map(({ line, number }) => (
170+
<span className="yaml-line" key={number}>
171+
<span>{number}</span>
172+
<code>{line || " "}</code>
173+
</span>
174+
))
175+
) : (
176+
<span className="yaml-empty">No matching YAML lines</span>
177+
)}
178+
</div>
97179
</details>
98180
);
99181
}

src/components/PodTerminal.tsx

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react";
22
import { ArrowDownToLine, Check, Copy, History, Layers, ListFilter, Radio, Rows3, Search } from "lucide-react";
3+
import { copyTextToClipboard } from "../lib/clipboard";
34
import type { ResourceDetails } from "../types/kube";
45

56
const ansiPattern = /\u001b\[[0-9;]*m/g;
@@ -326,36 +327,6 @@ function isScrolledToBottom(element: HTMLElement) {
326327
return element.scrollHeight - element.scrollTop - element.clientHeight <= 36;
327328
}
328329

329-
async function copyTextToClipboard(text: string) {
330-
if (copyTextViaSelection(text)) {
331-
return;
332-
}
333-
334-
if (navigator.clipboard?.writeText) {
335-
await navigator.clipboard.writeText(text);
336-
return;
337-
}
338-
339-
throw new Error("Clipboard copy was rejected");
340-
}
341-
342-
function copyTextViaSelection(text: string) {
343-
const textarea = document.createElement("textarea");
344-
textarea.value = text;
345-
textarea.setAttribute("readonly", "");
346-
textarea.style.position = "fixed";
347-
textarea.style.inset = "0";
348-
textarea.style.opacity = "0";
349-
document.body.append(textarea);
350-
textarea.select();
351-
352-
try {
353-
return document.execCommand("copy");
354-
} finally {
355-
textarea.remove();
356-
}
357-
}
358-
359330
function terminalOutput(output: string, mode: LogMode, detailsLoading: boolean, detailsError: string) {
360331
if (detailsLoading && !output) {
361332
return "Connecting to pod log stream...";

src/lib/clipboard.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export async function copyTextToClipboard(text: string) {
2+
if (copyTextViaSelection(text)) {
3+
return;
4+
}
5+
6+
if (navigator.clipboard?.writeText) {
7+
await navigator.clipboard.writeText(text);
8+
return;
9+
}
10+
11+
throw new Error("Clipboard copy was rejected");
12+
}
13+
14+
function copyTextViaSelection(text: string) {
15+
const textarea = document.createElement("textarea");
16+
textarea.value = text;
17+
textarea.setAttribute("readonly", "");
18+
textarea.style.position = "fixed";
19+
textarea.style.inset = "0";
20+
textarea.style.opacity = "0";
21+
document.body.append(textarea);
22+
textarea.select();
23+
24+
try {
25+
return document.execCommand("copy");
26+
} finally {
27+
textarea.remove();
28+
}
29+
}

src/styles/base.css

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ summary:focus-visible {
4646
.code-pane,
4747
.log-pane,
4848
.event-pane,
49-
.inspector-yaml pre {
49+
.inspector-yaml-code {
5050
scrollbar-color: color-mix(in srgb, var(--green-deep) 28%, transparent) transparent;
5151
scrollbar-gutter: stable;
5252
}
@@ -3322,11 +3322,97 @@ summary:focus-visible {
33223322
}
33233323

33243324
.inspector-yaml summary {
3325+
display: flex;
3326+
align-items: center;
3327+
justify-content: space-between;
3328+
gap: 10px;
33253329
padding: 12px 14px;
33263330
cursor: pointer;
33273331
}
33283332

3329-
.inspector-yaml pre {
3333+
.inspector-yaml summary small {
3334+
flex: 0 0 auto;
3335+
color: var(--muted);
3336+
font-family: var(--font-mono);
3337+
font-size: 10px;
3338+
letter-spacing: 0.04em;
3339+
text-transform: uppercase;
3340+
}
3341+
3342+
.inspector-yaml-toolbar {
3343+
display: grid;
3344+
grid-template-columns: minmax(0, 1fr) auto;
3345+
gap: 8px;
3346+
padding: 10px;
3347+
border-block-start: 1px solid var(--line);
3348+
background: color-mix(in srgb, var(--surface) 88%, var(--surface-soft));
3349+
}
3350+
3351+
.inspector-yaml-toolbar label,
3352+
.inspector-yaml-toolbar button {
3353+
display: inline-flex;
3354+
align-items: center;
3355+
gap: 7px;
3356+
min-block-size: 30px;
3357+
border: 1px solid var(--line);
3358+
background: var(--surface);
3359+
}
3360+
3361+
.inspector-yaml-toolbar label {
3362+
min-inline-size: 0;
3363+
padding-inline: 9px;
3364+
}
3365+
3366+
.inspector-yaml-toolbar label svg,
3367+
.inspector-yaml-toolbar button svg {
3368+
flex: 0 0 auto;
3369+
color: var(--green-deep);
3370+
}
3371+
3372+
.inspector-yaml-toolbar input {
3373+
min-inline-size: 0;
3374+
border: 0;
3375+
outline: 0;
3376+
color: var(--text);
3377+
background: transparent;
3378+
}
3379+
3380+
.inspector-yaml-toolbar input::placeholder {
3381+
color: var(--muted);
3382+
}
3383+
3384+
.inspector-yaml-toolbar button {
3385+
padding-inline: 9px;
3386+
color: var(--text-soft);
3387+
cursor: pointer;
3388+
}
3389+
3390+
.inspector-yaml-toolbar button span {
3391+
font-family: var(--font-mono);
3392+
font-size: 10px;
3393+
letter-spacing: 0.04em;
3394+
text-transform: uppercase;
3395+
}
3396+
3397+
.inspector-yaml-toolbar button.copied {
3398+
color: var(--text);
3399+
background: var(--surface-soft);
3400+
box-shadow: inset 0 -2px 0 var(--green);
3401+
}
3402+
3403+
.inspector-yaml-toolbar button.failed {
3404+
color: var(--orange);
3405+
background: var(--surface-soft);
3406+
}
3407+
3408+
.inspector-yaml-toolbar button:disabled {
3409+
color: var(--muted);
3410+
cursor: default;
3411+
opacity: 0.55;
3412+
}
3413+
3414+
.inspector-yaml-code {
3415+
display: grid;
33303416
max-block-size: 360px;
33313417
margin: 0;
33323418
padding: 14px;
@@ -3339,6 +3425,31 @@ summary:focus-visible {
33393425
white-space: pre-wrap;
33403426
}
33413427

3428+
.yaml-line {
3429+
display: grid;
3430+
grid-template-columns: 34px minmax(0, 1fr);
3431+
gap: 9px;
3432+
min-inline-size: 0;
3433+
}
3434+
3435+
.yaml-line > span {
3436+
color: var(--muted);
3437+
text-align: end;
3438+
user-select: none;
3439+
}
3440+
3441+
.yaml-line code {
3442+
min-inline-size: 0;
3443+
color: var(--text-soft);
3444+
font: inherit;
3445+
white-space: pre-wrap;
3446+
overflow-wrap: anywhere;
3447+
}
3448+
3449+
.yaml-empty {
3450+
color: var(--muted);
3451+
}
3452+
33423453
.detail-list {
33433454
display: grid;
33443455
border-block-start: 1px solid var(--line);

0 commit comments

Comments
 (0)