openk8s 0.0.1 → 1.0.1

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 (35) hide show
  1. package/README.md +187 -36
  2. package/bin/openk8s.js +2 -0
  3. package/package.json +52 -6
  4. package/src/app/app-state.ts +461 -0
  5. package/src/app/app.tsx +708 -0
  6. package/src/app/components/inspector.tsx +449 -0
  7. package/src/app/components/kind-rows.tsx +66 -0
  8. package/src/app/components/notification-tray.tsx +59 -0
  9. package/src/app/components/overlays/delete-confirm-overlay.tsx +79 -0
  10. package/src/app/components/overlays/helm-rollback-overlay.tsx +86 -0
  11. package/src/app/components/overlays/index.ts +12 -0
  12. package/src/app/components/overlays/logs-dialog.tsx +303 -0
  13. package/src/app/components/overlays/port-forward-overlay.tsx +184 -0
  14. package/src/app/components/overlays/scale-dialog.tsx +96 -0
  15. package/src/app/components/overlays/select-overlay.tsx +68 -0
  16. package/src/app/components/overlays/shared.tsx +18 -0
  17. package/src/app/components/port-forwards-tray.tsx +57 -0
  18. package/src/app/components/resource-rows.tsx +120 -0
  19. package/src/app/hooks/use-app-keyboard.ts +723 -0
  20. package/src/app/hooks/use-app-side-effects.ts +39 -0
  21. package/src/app/hooks/use-clipboard.ts +54 -0
  22. package/src/app/hooks/use-data-fetching.ts +366 -0
  23. package/src/app/hooks/use-log-stream.ts +113 -0
  24. package/src/app/hooks/use-port-forward.ts +149 -0
  25. package/src/app/persistence.ts +44 -0
  26. package/src/app/theme.ts +95 -0
  27. package/src/app/use-polling-tick.ts +27 -0
  28. package/src/app/utils.ts +274 -0
  29. package/src/index.tsx +8 -0
  30. package/src/lib/k8s/k8s-format.ts +42 -0
  31. package/src/lib/k8s/resource-detail-builder.ts +545 -0
  32. package/src/lib/k8s/resource-parser.ts +308 -0
  33. package/src/lib/k8s/types.ts +164 -0
  34. package/src/lib/kubectl/kubectl-service.ts +1116 -0
  35. package/src/lib/kubectl/spawn-utils.ts +81 -0
@@ -0,0 +1,449 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ import {
4
+ GLYPHS,
5
+ KEY_HINT,
6
+ PANEL_BORDER,
7
+ PANEL_BORDER_ACTIVE,
8
+ ROW_SELECTED,
9
+ TEXT_PRIMARY,
10
+ TEXT_SUBTLE,
11
+ YAML_COMMENT,
12
+ YAML_KEY,
13
+ YAML_VALUE,
14
+ toneStyles,
15
+ } from "../theme";
16
+ import { statusTone } from "../utils";
17
+ import type { EventItem, InspectorTab, PaneId, ResourceDetail, ResourceDetailLine, ResourceDetailSection } from "../../lib/k8s/types";
18
+
19
+ export const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string; hotkey: string }> = [
20
+ { id: "summary", label: "Summary", hotkey: "s" },
21
+ { id: "yaml", label: "YAML", hotkey: "y" },
22
+ { id: "events", label: "Events", hotkey: "v" },
23
+ { id: "describe", label: "Describe", hotkey: "i" },
24
+ ];
25
+
26
+ interface YamlToken {
27
+ text: string;
28
+ fg: string;
29
+ }
30
+
31
+ function tokenizeDescribeLine(line: string): YamlToken[] {
32
+ if (!line.trim()) {
33
+ return [{ text: line || " ", fg: TEXT_SUBTLE }];
34
+ }
35
+
36
+ // Section header lines (e.g. "Name:", "Labels:", or "Events:")
37
+ if (/^[A-Z][A-Za-z ]+:/.test(line.trimStart()) && !line.startsWith(" ")) {
38
+ const colonIdx = line.indexOf(":");
39
+ const key = line.slice(0, colonIdx);
40
+ const rest = line.slice(colonIdx + 1);
41
+ return [
42
+ { text: key, fg: YAML_KEY },
43
+ { text: ":", fg: TEXT_SUBTLE },
44
+ { text: rest, fg: YAML_VALUE },
45
+ ];
46
+ }
47
+
48
+ // Indented key: value lines
49
+ const colonSpaceIdx = line.indexOf(": ");
50
+ if (colonSpaceIdx > 0) {
51
+ const key = line.slice(0, colonSpaceIdx);
52
+ const value = line.slice(colonSpaceIdx + 1);
53
+ return [
54
+ { text: key, fg: TEXT_SUBTLE },
55
+ { text: value, fg: YAML_VALUE },
56
+ ];
57
+ }
58
+
59
+ const colonIdx = line.indexOf(":");
60
+ if (colonIdx > 0 && colonIdx < line.length - 1) {
61
+ const key = line.slice(0, colonIdx);
62
+ const value = line.slice(colonIdx + 1);
63
+ return [
64
+ { text: key, fg: TEXT_SUBTLE },
65
+ { text: ":", fg: TEXT_SUBTLE },
66
+ { text: value, fg: YAML_VALUE },
67
+ ];
68
+ }
69
+
70
+ return [{ text: line, fg: TEXT_SUBTLE }];
71
+ }
72
+
73
+ function tokenizeYamlLine(line: string): YamlToken[] { if (!line.trim()) {
74
+ return [{ text: line || " ", fg: TEXT_SUBTLE }];
75
+ }
76
+
77
+ if (/^\s*#/.test(line)) {
78
+ return [{ text: line, fg: YAML_COMMENT }];
79
+ }
80
+
81
+ const indentLen = line.length - line.trimStart().length;
82
+ const indent = line.slice(0, indentLen);
83
+ let rest = line.trimStart();
84
+
85
+ // Strip list marker
86
+ let listMarker = "";
87
+ if (rest.startsWith("- ")) {
88
+ listMarker = "- ";
89
+ rest = rest.slice(2);
90
+ } else if (rest === "-") {
91
+ return [{ text: line, fg: TEXT_SUBTLE }];
92
+ }
93
+
94
+ // "key: value" or "key:"
95
+ const colonSpaceIdx = rest.indexOf(": ");
96
+ const isKeyOnly = rest === rest.trimEnd() && rest.endsWith(":");
97
+
98
+ if (colonSpaceIdx !== -1) {
99
+ const key = rest.slice(0, colonSpaceIdx);
100
+ const value = rest.slice(colonSpaceIdx + 2);
101
+ return [
102
+ { text: indent + listMarker, fg: TEXT_SUBTLE },
103
+ { text: key, fg: YAML_KEY },
104
+ { text: ": ", fg: TEXT_SUBTLE },
105
+ { text: value, fg: YAML_VALUE },
106
+ ];
107
+ }
108
+
109
+ if (isKeyOnly) {
110
+ const key = rest.slice(0, -1);
111
+ return [
112
+ { text: indent + listMarker, fg: TEXT_SUBTLE },
113
+ { text: key, fg: YAML_KEY },
114
+ { text: ":", fg: TEXT_SUBTLE },
115
+ ];
116
+ }
117
+
118
+ return [{ text: line, fg: TEXT_SUBTLE }];
119
+ }
120
+
121
+ export interface InspectorTabsProps {
122
+ activeTab: InspectorTab;
123
+ activePane: PaneId;
124
+ onSelect: (tab: InspectorTab) => void;
125
+ }
126
+
127
+ export function InspectorTabs({ activeTab, activePane, onSelect }: InspectorTabsProps) {
128
+ return (
129
+ <box style={{ flexDirection: "row", marginBottom: 0 }}>
130
+ {INSPECTOR_TABS.map((tab) => {
131
+ const selected = tab.id === activeTab;
132
+ const isActive = selected && activePane === "inspector";
133
+
134
+ return (
135
+ <box key={tab.id} onMouseDown={() => onSelect(tab.id)} style={{ flexGrow: 1, flexDirection: "column" }}>
136
+ <box
137
+ style={{
138
+ height: 2,
139
+ backgroundColor: selected ? ROW_SELECTED : undefined,
140
+ paddingLeft: 1,
141
+ paddingRight: 1,
142
+ justifyContent: "center",
143
+ alignItems: "center",
144
+ }}
145
+ >
146
+ <text
147
+ fg={selected ? TEXT_PRIMARY : TEXT_SUBTLE}
148
+ attributes={isActive ? TextAttributes.BOLD : TextAttributes.DIM}
149
+ >
150
+ <span fg={selected ? KEY_HINT : TEXT_SUBTLE}>
151
+ {selected ? GLYPHS.dot : GLYPHS.dotEmpty}
152
+ </span>
153
+ {` ${tab.label}`}
154
+ </text>
155
+ </box>
156
+ <box
157
+ style={{
158
+ height: 1,
159
+ backgroundColor: selected ? PANEL_BORDER_ACTIVE : PANEL_BORDER,
160
+ }}
161
+ />
162
+ </box>
163
+ );
164
+ })}
165
+ </box>
166
+ );
167
+ }
168
+
169
+ export interface InspectorBodyProps {
170
+ activeTab: InspectorTab;
171
+ detail?: ResourceDetail | undefined;
172
+ detailStatus: string;
173
+ events: EventItem[];
174
+ eventsStatus: string;
175
+ fallbackLines: string[];
176
+ active: boolean;
177
+ onActivate: () => void;
178
+ revealedDetailLineIds: string[];
179
+ revealedSecretValues: Record<string, string>;
180
+ onToggleReveal: (id: string) => void;
181
+ onCopyText: (text: string, label?: string) => void;
182
+ }
183
+
184
+ export function InspectorBody({
185
+ activeTab,
186
+ detail,
187
+ detailStatus,
188
+ events,
189
+ eventsStatus,
190
+ fallbackLines,
191
+ active,
192
+ onActivate,
193
+ revealedDetailLineIds,
194
+ revealedSecretValues,
195
+ onToggleReveal,
196
+ onCopyText,
197
+ }: InspectorBodyProps) {
198
+ // YAML tab — scrollX enables horizontal scrolling (no maxWidth constraint on content).
199
+ // The 1-row gap is a non-scrollable spacer inside the wrapper, before the scrollbox.
200
+ if (activeTab === "yaml") {
201
+ return (
202
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
203
+ <box style={{ height: 1 }} />
204
+ <scrollbox
205
+ focused={active}
206
+ scrollX
207
+ horizontalScrollbarOptions={{ showArrows: false }}
208
+ onMouseDown={() => {
209
+ onActivate();
210
+ if (detail?.yaml) onCopyText(detail.yaml, "YAML");
211
+ }}
212
+ flexGrow={1}
213
+ >
214
+ <box style={{ flexDirection: "column", paddingRight: 1 }}>
215
+ {detail ? (
216
+ detail.yaml.split("\n").map((line, index) => {
217
+ const tokens = tokenizeYamlLine(line);
218
+ return (
219
+ <box key={`${index}:${line}`} style={{ flexDirection: "row" }}>
220
+ {tokens.map((token, tokenIndex) => (
221
+ <text key={tokenIndex} fg={token.fg} wrapMode="none">
222
+ {token.text}
223
+ </text>
224
+ ))}
225
+ </box>
226
+ );
227
+ })
228
+ ) : detailStatus === "loading" ? (
229
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
230
+ Loading YAML...
231
+ </text>
232
+ ) : (
233
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
234
+ No YAML available
235
+ </text>
236
+ )}
237
+ </box>
238
+ </scrollbox>
239
+ </box>
240
+ );
241
+ }
242
+
243
+ // Describe tab — raw kubectl describe output with light colorization
244
+ if (activeTab === "describe") {
245
+ return (
246
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
247
+ <box style={{ height: 1 }} />
248
+ <scrollbox
249
+ focused={active}
250
+ scrollX
251
+ horizontalScrollbarOptions={{ showArrows: false }}
252
+ onMouseDown={onActivate}
253
+ flexGrow={1}
254
+ >
255
+ <box style={{ flexDirection: "column", paddingRight: 1 }}>
256
+ {detail?.describe ? (
257
+ detail.describe.split("\n").map((line, index) => {
258
+ const tokens = tokenizeDescribeLine(line);
259
+ return (
260
+ <box key={`${index}:${line}`} style={{ flexDirection: "row" }}>
261
+ {tokens.map((token, tokenIndex) => (
262
+ <text key={tokenIndex} fg={token.fg} wrapMode="none">
263
+ {token.text}
264
+ </text>
265
+ ))}
266
+ </box>
267
+ );
268
+ })
269
+ ) : detailStatus === "loading" ? (
270
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
271
+ Loading describe output...
272
+ </text>
273
+ ) : (
274
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
275
+ No describe output available
276
+ </text>
277
+ )}
278
+ </box>
279
+ </scrollbox>
280
+ </box>
281
+ );
282
+ }
283
+
284
+ // Summary / Events tabs
285
+ return (
286
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
287
+ <box style={{ height: 1 }} />
288
+ <scrollbox focused={active} onMouseDown={onActivate} flexGrow={1}>
289
+ <box style={{ flexDirection: "column", width: "100%", paddingRight: 1 }}>
290
+ {activeTab === "events" ? (
291
+ <EventList events={events} status={eventsStatus} />
292
+ ) : detail ? (
293
+ <DetailSectionsInternal
294
+ sections={detail.summarySections}
295
+ revealedIds={revealedDetailLineIds}
296
+ revealedSecretValues={revealedSecretValues}
297
+ onToggleReveal={onToggleReveal}
298
+ onCopyText={onCopyText}
299
+ />
300
+ ) : detailStatus === "loading" ? (
301
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
302
+ Loading resource detail...
303
+ </text>
304
+ ) : (
305
+ fallbackLines.map((line) => (
306
+ <text key={line} fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
307
+ {line}
308
+ </text>
309
+ ))
310
+ )}
311
+ </box>
312
+ </scrollbox>
313
+ </box>
314
+ );
315
+ }
316
+
317
+ interface EventListProps {
318
+ events: EventItem[];
319
+ status: string;
320
+ }
321
+
322
+ function EventList({ events, status }: EventListProps) {
323
+ if (status === "loading") {
324
+ return (
325
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
326
+ Loading events...
327
+ </text>
328
+ );
329
+ }
330
+
331
+ if (events.length === 0) {
332
+ return (
333
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
334
+ No related events
335
+ </text>
336
+ );
337
+ }
338
+
339
+ return (
340
+ <box style={{ flexDirection: "column", width: "100%" }}>
341
+ {events.map((event, index) => {
342
+ const isWarning = event.type.toLowerCase() === "warning";
343
+ const palette = toneStyles(isWarning ? "warning" : "info");
344
+ const typeGlyph = isWarning ? GLYPHS.warn : GLYPHS.dot;
345
+
346
+ return (
347
+ <box key={`${event.reason}:${event.age}:${index}`} style={{ flexDirection: "column", marginBottom: 1 }}>
348
+ <text fg={palette.fg}>
349
+ <span fg={palette.border}>{typeGlyph}</span>
350
+ {` ${event.type} `}
351
+ <span fg={TEXT_SUBTLE}>{GLYPHS.sep}</span>
352
+ {` ${event.reason} ${event.age}`}
353
+ </text>
354
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{` ${event.source} ${event.message}`}</text>
355
+ </box>
356
+ );
357
+ })}
358
+ </box>
359
+ );
360
+ }
361
+
362
+ interface DetailSectionsInternalProps {
363
+ sections: ResourceDetailSection[];
364
+ revealedIds: string[];
365
+ revealedSecretValues: Record<string, string>;
366
+ onToggleReveal: (id: string) => void;
367
+ onCopyText: (text: string, label?: string) => void;
368
+ }
369
+
370
+ function DetailSectionsInternal({ sections, revealedIds, revealedSecretValues, onToggleReveal, onCopyText }: DetailSectionsInternalProps) {
371
+ return (
372
+ <box style={{ flexDirection: "column", width: "100%" }}>
373
+ {sections.map((section) => (
374
+ <box key={section.title} style={{ flexDirection: "column", marginBottom: 1 }}>
375
+ {/* Section header: ─ Title */}
376
+ <text fg={KEY_HINT}>
377
+ {"─ "}
378
+ <span fg={TEXT_PRIMARY} attributes={TextAttributes.BOLD}>{section.title}</span>
379
+ </text>
380
+ {section.lines.map((line, index) => {
381
+ // Narrow to structured line early so the rest is clean
382
+ const structuredLine: ResourceDetailLine | undefined =
383
+ typeof line === "string" ? undefined : line;
384
+ const lineId = structuredLine?.id ?? line as string;
385
+ const isRevealable = !!(structuredLine?.revealable && structuredLine.revealedText);
386
+ const isRevealed = isRevealable && revealedIds.includes(structuredLine!.id);
387
+
388
+ // Prefer the live-fetched secret value; fall back to the static revealedText placeholder
389
+ const fetchedValue = structuredLine ? revealedSecretValues[structuredLine.id] : undefined;
390
+
391
+ // raw line.text — no "[click to reveal]" suffix
392
+ const rawText = structuredLine?.text ?? (line as string);
393
+ const displayText = isRevealable && isRevealed
394
+ ? (fetchedValue ?? structuredLine!.revealedText ?? rawText)
395
+ : rawText;
396
+ // What gets copied: fetched value > revealedText > rawText
397
+ const copyValue = fetchedValue ?? structuredLine?.revealedText ?? rawText;
398
+ // Show [copy] on all structured lines (env vars) regardless of revealable status
399
+ const hasCopyButton = structuredLine !== undefined;
400
+
401
+ // Short label for the "copied" toast: extract var name before "=" or first word
402
+ const copyLabel: string | undefined = structuredLine
403
+ ? (() => {
404
+ const eqIdx = rawText.indexOf("=");
405
+ if (eqIdx > 0) return rawText.slice(0, eqIdx);
406
+ const spaceIdx = rawText.indexOf(" ");
407
+ return spaceIdx > 0 ? rawText.slice(0, spaceIdx) : rawText;
408
+ })()
409
+ : undefined;
410
+
411
+ return (
412
+ <box
413
+ key={`${section.title}:${index}:${lineId}`}
414
+ style={{ flexDirection: "row", width: "100%" }}
415
+ >
416
+ <box
417
+ onMouseDown={isRevealable ? () => onToggleReveal(structuredLine!.id) : undefined}
418
+ style={{ flexGrow: 1 }}
419
+ >
420
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
421
+ {" "}
422
+ {displayText}
423
+ </text>
424
+ </box>
425
+ {isRevealable && (
426
+ <box
427
+ onMouseDown={() => onToggleReveal(structuredLine!.id)}
428
+ style={{ paddingLeft: 1 }}
429
+ >
430
+ <text fg={KEY_HINT} attributes={TextAttributes.DIM}>
431
+ {isRevealed ? "[hide]" : "[reveal]"}
432
+ </text>
433
+ </box>
434
+ )}
435
+ {hasCopyButton && (
436
+ <box onMouseDown={() => onCopyText(copyValue, copyLabel)} style={{ paddingLeft: 1 }}>
437
+ <text fg={KEY_HINT} attributes={TextAttributes.DIM}>
438
+ {"[copy]"}
439
+ </text>
440
+ </box>
441
+ )}
442
+ </box>
443
+ );
444
+ })}
445
+ </box>
446
+ ))}
447
+ </box>
448
+ );
449
+ }
@@ -0,0 +1,66 @@
1
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
2
+ import { useEffect, useRef } from "react";
3
+
4
+ import { GLYPHS, KEY_HINT, ROW_SELECTED_ALT, TEXT_PRIMARY, TEXT_SUBTLE } from "../theme";
5
+ import { kindDescription } from "../utils";
6
+ import type { ResourceKind } from "../../lib/k8s/types";
7
+
8
+ export interface KindRowsProps {
9
+ resourceKinds: ResourceKind[];
10
+ selectedKind: string;
11
+ onSelect: (kind: string) => void;
12
+ onActivate: () => void;
13
+ }
14
+
15
+ export function KindRows({ resourceKinds, selectedKind, onSelect, onActivate }: KindRowsProps) {
16
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
17
+
18
+ useEffect(() => {
19
+ scrollRef.current?.scrollChildIntoView(`kind:${selectedKind}`);
20
+ }, [selectedKind]);
21
+
22
+ if (resourceKinds.length === 0) {
23
+ return (
24
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
25
+ No discovered resources
26
+ </text>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <scrollbox ref={scrollRef} onMouseDown={onActivate} style={{ flexGrow: 1 }}>
32
+ <box style={{ flexDirection: "column", width: "100%" }}>
33
+ {resourceKinds.map((kind) => {
34
+ const selected = kind.name === selectedKind;
35
+
36
+ return (
37
+ <box
38
+ id={`kind:${kind.name}`}
39
+ key={kind.name}
40
+ onMouseDown={() => {
41
+ onActivate();
42
+ onSelect(kind.name);
43
+ }}
44
+ style={{
45
+ width: "100%",
46
+ flexDirection: "column",
47
+ paddingLeft: 1,
48
+ paddingRight: 1,
49
+ backgroundColor: selected ? ROW_SELECTED_ALT : undefined,
50
+ }}
51
+ >
52
+ <text fg={selected ? TEXT_PRIMARY : TEXT_SUBTLE} attributes={selected ? TextAttributes.BOLD : TextAttributes.DIM}>
53
+ <span fg={selected ? KEY_HINT : "transparent"}>{GLYPHS.cursor}</span>
54
+ {` ${kind.name}`}
55
+ </text>
56
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
57
+ {" "}
58
+ {kindDescription(kind)}
59
+ </text>
60
+ </box>
61
+ );
62
+ })}
63
+ </box>
64
+ </scrollbox>
65
+ );
66
+ }
@@ -0,0 +1,59 @@
1
+ import { GLYPHS, OVERLAY_SURFACE, TEXT_SUBTLE, toneStyles } from "../theme";
2
+ import type { Notification } from "../../lib/k8s/types";
3
+
4
+ export interface NotificationTrayProps {
5
+ notifications: Notification[];
6
+ onDismiss: (id: string) => void;
7
+ }
8
+
9
+ export function NotificationTray({ notifications, onDismiss }: NotificationTrayProps) {
10
+ if (notifications.length === 0) {
11
+ return undefined;
12
+ }
13
+
14
+ // Show at most 3, most recent last (rendered top-to-bottom)
15
+ const visible = notifications.slice(-3);
16
+
17
+ return (
18
+ <box
19
+ style={{
20
+ position: "absolute",
21
+ left: "55%",
22
+ top: 3,
23
+ width: "44%",
24
+ flexDirection: "column",
25
+ rowGap: 1,
26
+ }}
27
+ >
28
+ {visible.map((notification) => {
29
+ const palette = toneStyles(notification.tone);
30
+
31
+ return (
32
+ <box
33
+ key={notification.id}
34
+ style={{
35
+ border: true,
36
+ borderColor: palette.border,
37
+ borderStyle: "rounded",
38
+ backgroundColor: OVERLAY_SURFACE,
39
+ paddingLeft: 1,
40
+ paddingRight: 1,
41
+ paddingTop: 0,
42
+ paddingBottom: 0,
43
+ flexDirection: "row",
44
+ justifyContent: "space-between",
45
+ alignItems: "center",
46
+ }}
47
+ >
48
+ <text fg={palette.fg} wrapMode="word" style={{ flexShrink: 1, flexGrow: 1 }}>
49
+ {notification.message}
50
+ </text>
51
+ <text fg={TEXT_SUBTLE} onMouseDown={() => onDismiss(notification.id)}>
52
+ {` ${GLYPHS.stop}`}
53
+ </text>
54
+ </box>
55
+ );
56
+ })}
57
+ </box>
58
+ );
59
+ }
@@ -0,0 +1,79 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ import {
4
+ DANGER,
5
+ GLYPHS,
6
+ KEY_HINT,
7
+ OVERLAY_SURFACE,
8
+ TEXT_MUTED,
9
+ TEXT_SUBTLE,
10
+ toneStyles,
11
+ } from "../../theme";
12
+ import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
13
+ import { statusLine } from "../../utils";
14
+ import type { ResourceRef } from "../../../lib/k8s/types";
15
+
16
+ export interface DeleteConfirmOverlayProps {
17
+ targets: ResourceRef[];
18
+ }
19
+
20
+ export function DeleteConfirmOverlay({ targets }: DeleteConfirmOverlayProps) {
21
+ const palette = toneStyles("danger");
22
+ const title =
23
+ targets.length <= 1 ? `Delete ${statusLine({ ref: targets[0] })}?` : `Delete ${targets.length} selected resources?`;
24
+ const overlayHeight = Math.min(22, Math.max(9, targets.length + 8));
25
+
26
+ return (
27
+ <box
28
+ style={{
29
+ position: "absolute",
30
+ left: DIALOG_LEFT,
31
+ top: DIALOG_TOP,
32
+ width: DIALOG_WIDTH,
33
+ height: overlayHeight,
34
+ border: true,
35
+ borderStyle: "rounded",
36
+ borderColor: palette.border,
37
+ backgroundColor: OVERLAY_SURFACE,
38
+ padding: 1,
39
+ flexDirection: "column",
40
+ }}
41
+ title="Confirm Delete"
42
+ >
43
+ <text fg={palette.fg} attributes={TextAttributes.BOLD}>
44
+ {title}
45
+ </text>
46
+ {targets.length <= 1 ? (
47
+ <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
48
+ Runs kubectl delete on the current selection.
49
+ </text>
50
+ ) : (
51
+ <scrollbox style={{ flexGrow: 1, marginTop: 1 }}>
52
+ <box style={{ flexDirection: "column", width: "100%" }}>
53
+ {targets.map((target) => (
54
+ <text
55
+ key={`${target.kind}:${target.namespace ?? "cluster"}:${target.name}`}
56
+ fg={TEXT_SUBTLE}
57
+ attributes={TEXT_MUTED}
58
+ >
59
+ <span fg={DANGER}>{GLYPHS.cross}</span>
60
+ {" "}
61
+ {statusLine({ ref: target })}
62
+ </text>
63
+ ))}
64
+ </box>
65
+ </scrollbox>
66
+ )}
67
+ <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
68
+ <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
69
+ <span fg={DANGER}>{GLYPHS.enter}</span>
70
+ {" confirm"}
71
+ </text>
72
+ <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
73
+ <span fg={KEY_HINT}>{GLYPHS.esc}</span>
74
+ {" cancel"}
75
+ </text>
76
+ </box>
77
+ </box>
78
+ );
79
+ }