openk8s 1.0.1 → 1.0.2
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.
- package/package.json +5 -2
- package/src/app/__tests__/app-state.test.ts +376 -0
- package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
- package/src/app/__tests__/utils.test.ts +358 -0
- package/src/app/app-actions.ts +262 -0
- package/src/app/app-state.ts +4 -262
- package/src/app/app.tsx +22 -170
- package/src/app/components/detail-sections.tsx +131 -0
- package/src/app/components/footer.tsx +52 -0
- package/src/app/components/header.tsx +37 -0
- package/src/app/components/inspector-tokens.ts +93 -0
- package/src/app/components/inspector.tsx +3 -239
- package/src/app/components/resource-rows.tsx +5 -0
- package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
- package/src/app/hooks/keyboard/global-handlers.ts +134 -0
- package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
- package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
- package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
- package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
- package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
- package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
- package/src/app/hooks/use-app-keyboard.ts +56 -621
- package/src/app/hooks/use-app-side-effects.ts +1 -1
- package/src/app/hooks/use-clipboard.ts +1 -1
- package/src/app/hooks/use-data-fetching.ts +2 -9
- package/src/app/hooks/use-log-stream.ts +1 -1
- package/src/app/hooks/use-port-forward.ts +1 -1
- package/src/app/use-footer-hints.ts +107 -0
- package/src/index.tsx +4 -0
- package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
- package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
- package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
- package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
- package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
- package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
- package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
- package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
- package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
- package/src/lib/k8s/resource-detail-builder.ts +22 -502
- package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
- package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
- package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
- package/src/lib/kubectl/kubectl-helpers.ts +246 -0
- package/src/lib/kubectl/kubectl-service.ts +77 -565
- package/src/lib/kubectl/kubectl-types.ts +248 -0
- package/src/lib/kubectl/metrics-utils.ts +33 -0
- package/src/lib/kubectl/spawn-utils.ts +27 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import { KEY_HINT, TEXT_MUTED, TEXT_SUBTLE, toneStyles } from "../theme";
|
|
4
|
+
|
|
5
|
+
export interface FooterProps {
|
|
6
|
+
hintRows: [string, string][][];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Footer({ hintRows }: FooterProps) {
|
|
10
|
+
const [row1, row2] = hintRows;
|
|
11
|
+
const palette = toneStyles("neutral");
|
|
12
|
+
const textFg = TEXT_SUBTLE;
|
|
13
|
+
const dimAttr = TEXT_MUTED;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<box
|
|
17
|
+
style={{
|
|
18
|
+
height: 4,
|
|
19
|
+
border: true,
|
|
20
|
+
borderColor: palette.border,
|
|
21
|
+
borderStyle: "rounded",
|
|
22
|
+
backgroundColor: palette.bg,
|
|
23
|
+
paddingLeft: 1,
|
|
24
|
+
paddingRight: 1,
|
|
25
|
+
flexDirection: "column",
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
|
|
29
|
+
<box style={{ flexDirection: "row", columnGap: 2 }}>
|
|
30
|
+
{(row1 ?? []).map(([key, label]) => (
|
|
31
|
+
<text key={`${key}:${label}`} fg={textFg} attributes={dimAttr}>
|
|
32
|
+
{"["}
|
|
33
|
+
<span fg={KEY_HINT}>{key}</span>
|
|
34
|
+
{`] ${label}`}
|
|
35
|
+
</text>
|
|
36
|
+
))}
|
|
37
|
+
</box>
|
|
38
|
+
</box>
|
|
39
|
+
<box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
|
|
40
|
+
<box style={{ flexDirection: "row", columnGap: 2 }}>
|
|
41
|
+
{(row2 ?? []).map(([key, label]) => (
|
|
42
|
+
<text key={`${key}:${label}`} fg={textFg} attributes={dimAttr}>
|
|
43
|
+
{"["}
|
|
44
|
+
<span fg={KEY_HINT}>{key}</span>
|
|
45
|
+
{`] ${label}`}
|
|
46
|
+
</text>
|
|
47
|
+
))}
|
|
48
|
+
</box>
|
|
49
|
+
</box>
|
|
50
|
+
</box>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import { PANEL_BORDER, SURFACE_ACCENT, TEXT_SUBTLE, KEY_HINT } from "../theme";
|
|
4
|
+
|
|
5
|
+
export interface HeaderProps {
|
|
6
|
+
activeContext: string | undefined;
|
|
7
|
+
activeNamespace: string;
|
|
8
|
+
selectedKindLabel: string;
|
|
9
|
+
statusMessage: string;
|
|
10
|
+
onActivate: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Header({ activeContext, activeNamespace, selectedKindLabel, statusMessage, onActivate }: HeaderProps) {
|
|
14
|
+
return (
|
|
15
|
+
<box
|
|
16
|
+
onMouseDown={onActivate}
|
|
17
|
+
style={{
|
|
18
|
+
height: 3,
|
|
19
|
+
border: true,
|
|
20
|
+
borderColor: PANEL_BORDER,
|
|
21
|
+
borderStyle: "rounded",
|
|
22
|
+
backgroundColor: SURFACE_ACCENT,
|
|
23
|
+
paddingLeft: 1,
|
|
24
|
+
paddingRight: 1,
|
|
25
|
+
justifyContent: "center",
|
|
26
|
+
alignItems: "center",
|
|
27
|
+
flexDirection: "row",
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
31
|
+
{activeContext
|
|
32
|
+
? `${activeContext} / ${activeNamespace} / ${selectedKindLabel}`
|
|
33
|
+
: statusMessage}
|
|
34
|
+
</text>
|
|
35
|
+
</box>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../theme";
|
|
2
|
+
|
|
3
|
+
export interface YamlToken {
|
|
4
|
+
text: string;
|
|
5
|
+
fg: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function tokenizeDescribeLine(line: string): YamlToken[] {
|
|
9
|
+
if (!line.trim()) {
|
|
10
|
+
return [{ text: line || " ", fg: TEXT_SUBTLE }];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (/^[A-Z][A-Za-z ]+:/.test(line.trimStart()) && !line.startsWith(" ")) {
|
|
14
|
+
const colonIdx = line.indexOf(":");
|
|
15
|
+
const key = line.slice(0, colonIdx);
|
|
16
|
+
const rest = line.slice(colonIdx + 1);
|
|
17
|
+
return [
|
|
18
|
+
{ text: key, fg: YAML_KEY },
|
|
19
|
+
{ text: ":", fg: TEXT_SUBTLE },
|
|
20
|
+
{ text: rest, fg: YAML_VALUE },
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const colonSpaceIdx = line.indexOf(": ");
|
|
25
|
+
if (colonSpaceIdx > 0) {
|
|
26
|
+
const key = line.slice(0, colonSpaceIdx);
|
|
27
|
+
const value = line.slice(colonSpaceIdx + 1);
|
|
28
|
+
return [
|
|
29
|
+
{ text: key, fg: TEXT_SUBTLE },
|
|
30
|
+
{ text: value, fg: YAML_VALUE },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const colonIdx = line.indexOf(":");
|
|
35
|
+
if (colonIdx > 0 && colonIdx < line.length - 1) {
|
|
36
|
+
const key = line.slice(0, colonIdx);
|
|
37
|
+
const value = line.slice(colonIdx + 1);
|
|
38
|
+
return [
|
|
39
|
+
{ text: key, fg: TEXT_SUBTLE },
|
|
40
|
+
{ text: ":", fg: TEXT_SUBTLE },
|
|
41
|
+
{ text: value, fg: YAML_VALUE },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function tokenizeYamlLine(line: string): YamlToken[] {
|
|
49
|
+
if (!line.trim()) {
|
|
50
|
+
return [{ text: line || " ", fg: TEXT_SUBTLE }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (/^\s*#/.test(line)) {
|
|
54
|
+
return [{ text: line, fg: YAML_COMMENT }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const indentLen = line.length - line.trimStart().length;
|
|
58
|
+
const indent = line.slice(0, indentLen);
|
|
59
|
+
let rest = line.trimStart();
|
|
60
|
+
|
|
61
|
+
let listMarker = "";
|
|
62
|
+
if (rest.startsWith("- ")) {
|
|
63
|
+
listMarker = "- ";
|
|
64
|
+
rest = rest.slice(2);
|
|
65
|
+
} else if (rest === "-") {
|
|
66
|
+
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const colonSpaceIdx = rest.indexOf(": ");
|
|
70
|
+
const isKeyOnly = rest === rest.trimEnd() && rest.endsWith(":");
|
|
71
|
+
|
|
72
|
+
if (colonSpaceIdx !== -1) {
|
|
73
|
+
const key = rest.slice(0, colonSpaceIdx);
|
|
74
|
+
const value = rest.slice(colonSpaceIdx + 2);
|
|
75
|
+
return [
|
|
76
|
+
{ text: indent + listMarker, fg: TEXT_SUBTLE },
|
|
77
|
+
{ text: key, fg: YAML_KEY },
|
|
78
|
+
{ text: ": ", fg: TEXT_SUBTLE },
|
|
79
|
+
{ text: value, fg: YAML_VALUE },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isKeyOnly) {
|
|
84
|
+
const key = rest.slice(0, -1);
|
|
85
|
+
return [
|
|
86
|
+
{ text: indent + listMarker, fg: TEXT_SUBTLE },
|
|
87
|
+
{ text: key, fg: YAML_KEY },
|
|
88
|
+
{ text: ":", fg: TEXT_SUBTLE },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
93
|
+
}
|
|
@@ -8,13 +8,10 @@ import {
|
|
|
8
8
|
ROW_SELECTED,
|
|
9
9
|
TEXT_PRIMARY,
|
|
10
10
|
TEXT_SUBTLE,
|
|
11
|
-
YAML_COMMENT,
|
|
12
|
-
YAML_KEY,
|
|
13
|
-
YAML_VALUE,
|
|
14
|
-
toneStyles,
|
|
15
11
|
} from "../theme";
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
12
|
+
import { tokenizeYamlLine, tokenizeDescribeLine } from "./inspector-tokens";
|
|
13
|
+
import { EventList, DetailSectionsInternal } from "./detail-sections";
|
|
14
|
+
import type { EventItem, InspectorTab, PaneId, ResourceDetail } from "../../lib/k8s/types";
|
|
18
15
|
|
|
19
16
|
export const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string; hotkey: string }> = [
|
|
20
17
|
{ id: "summary", label: "Summary", hotkey: "s" },
|
|
@@ -23,101 +20,6 @@ export const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string; hotkey: st
|
|
|
23
20
|
{ id: "describe", label: "Describe", hotkey: "i" },
|
|
24
21
|
];
|
|
25
22
|
|
|
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
23
|
export interface InspectorTabsProps {
|
|
122
24
|
activeTab: InspectorTab;
|
|
123
25
|
activePane: PaneId;
|
|
@@ -195,8 +97,6 @@ export function InspectorBody({
|
|
|
195
97
|
onToggleReveal,
|
|
196
98
|
onCopyText,
|
|
197
99
|
}: 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
100
|
if (activeTab === "yaml") {
|
|
201
101
|
return (
|
|
202
102
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
@@ -240,7 +140,6 @@ export function InspectorBody({
|
|
|
240
140
|
);
|
|
241
141
|
}
|
|
242
142
|
|
|
243
|
-
// Describe tab — raw kubectl describe output with light colorization
|
|
244
143
|
if (activeTab === "describe") {
|
|
245
144
|
return (
|
|
246
145
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
@@ -281,7 +180,6 @@ export function InspectorBody({
|
|
|
281
180
|
);
|
|
282
181
|
}
|
|
283
182
|
|
|
284
|
-
// Summary / Events tabs
|
|
285
183
|
return (
|
|
286
184
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
287
185
|
<box style={{ height: 1 }} />
|
|
@@ -313,137 +211,3 @@ export function InspectorBody({
|
|
|
313
211
|
</box>
|
|
314
212
|
);
|
|
315
213
|
}
|
|
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
|
-
}
|
|
@@ -49,6 +49,11 @@ export function ResourceRows({
|
|
|
49
49
|
</text>
|
|
50
50
|
) : (
|
|
51
51
|
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
52
|
+
{status === "loading" ? (
|
|
53
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
54
|
+
{"\u27F3 Refreshing..."}
|
|
55
|
+
</text>
|
|
56
|
+
) : null}
|
|
52
57
|
{resources.map((resource) => {
|
|
53
58
|
const selected = resource.ref.name === selectedName;
|
|
54
59
|
const marked = selectedNames.includes(resource.ref.name);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AppAction } from "../../app-actions";
|
|
2
|
+
|
|
3
|
+
export function handleFilterKeys(
|
|
4
|
+
key: { name: string; ctrl?: boolean; sequence?: string },
|
|
5
|
+
filterMode: boolean,
|
|
6
|
+
activePane: string,
|
|
7
|
+
dispatch: React.Dispatch<AppAction>,
|
|
8
|
+
setFilterMode: React.Dispatch<React.SetStateAction<boolean>>,
|
|
9
|
+
): boolean {
|
|
10
|
+
if (filterMode && key.name === "escape") {
|
|
11
|
+
setFilterMode(false);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (activePane === "resources" && (key.sequence === "/" || key.name === "slash")) {
|
|
16
|
+
setFilterMode(true);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (filterMode && activePane === "resources") {
|
|
21
|
+
if (key.ctrl && key.name === "u") {
|
|
22
|
+
dispatch({ type: "setResourceFilter", value: "" });
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (key.name === "return") {
|
|
27
|
+
setFilterMode(false);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (activePane === "resources" && !filterMode && key.ctrl && key.name === "u") {
|
|
35
|
+
dispatch({ type: "setResourceFilter", value: "" });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { abortRunningProcesses } from "../../../lib/kubectl/spawn-utils";
|
|
2
|
+
import type { AppAction } from "../../app-actions";
|
|
3
|
+
import type { AppState, ResourceRef, ResourceKind, ResourceDetail, NotificationTone } from "../../../lib/k8s/types";
|
|
4
|
+
import { statusLine } from "../../utils";
|
|
5
|
+
|
|
6
|
+
export function handleCtrlC(
|
|
7
|
+
key: { name: string; ctrl?: boolean },
|
|
8
|
+
renderer: { destroy: () => void },
|
|
9
|
+
): boolean {
|
|
10
|
+
if (key.ctrl && key.name === "c") {
|
|
11
|
+
abortRunningProcesses();
|
|
12
|
+
renderer.destroy();
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function handleGlobalHotkeys(
|
|
19
|
+
key: { name: string; shift?: boolean },
|
|
20
|
+
state: AppState,
|
|
21
|
+
dispatch: React.Dispatch<AppAction>,
|
|
22
|
+
activeResourceRef: ResourceRef | undefined,
|
|
23
|
+
selectedKind: ResourceKind | undefined,
|
|
24
|
+
activeDetail: ResourceDetail | undefined,
|
|
25
|
+
selectedResourcePortForwards: { length: number },
|
|
26
|
+
deleteTargets: { length: number },
|
|
27
|
+
visibleSelectedResourceName: string | undefined,
|
|
28
|
+
setOverlayIndex: React.Dispatch<React.SetStateAction<number>>,
|
|
29
|
+
openPortForwardOverlay: () => void,
|
|
30
|
+
toast: (tone: NotificationTone, message: string) => void,
|
|
31
|
+
copyToClipboard: (text: string, label?: string) => void,
|
|
32
|
+
setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
|
|
33
|
+
setScaleReplicasInput: React.Dispatch<React.SetStateAction<string>>,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (state.overlay) return false;
|
|
36
|
+
|
|
37
|
+
// C → cluster switcher
|
|
38
|
+
if (key.name === "c") {
|
|
39
|
+
dispatch({ type: "setOverlay", overlay: "cluster-switcher" });
|
|
40
|
+
setOverlayIndex(Math.max(0, state.contexts.findIndex((context) => context.name === state.activeContext)));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// N → namespace switcher
|
|
45
|
+
if (key.name === "n") {
|
|
46
|
+
dispatch({ type: "setOverlay", overlay: "namespace-switcher" });
|
|
47
|
+
setOverlayIndex(Math.max(0, state.namespaces.findIndex((namespace) => namespace.name === state.activeNamespace)));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// R → refresh
|
|
52
|
+
if (key.name === "r" && !key.shift) {
|
|
53
|
+
toast("info", "Refreshing current view");
|
|
54
|
+
setManualRefreshNonce((current) => current + 1);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// S → summary tab
|
|
59
|
+
if (key.name === "s" && !key.shift) {
|
|
60
|
+
dispatch({ type: "setInspectorTab", tab: "summary" });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Y → yaml tab
|
|
65
|
+
if (key.name === "y") {
|
|
66
|
+
if (state.inspectorTab === "yaml" && activeDetail?.yaml) {
|
|
67
|
+
copyToClipboard(activeDetail.yaml, "YAML");
|
|
68
|
+
} else {
|
|
69
|
+
dispatch({ type: "setInspectorTab", tab: "yaml" });
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// V → events tab
|
|
75
|
+
if (key.name === "v") {
|
|
76
|
+
dispatch({ type: "setInspectorTab", tab: "events" });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// I → describe tab
|
|
81
|
+
if (key.name === "i") {
|
|
82
|
+
dispatch({ type: "setInspectorTab", tab: "describe" });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// L → logs
|
|
87
|
+
if (key.name === "l") {
|
|
88
|
+
if (activeResourceRef) {
|
|
89
|
+
if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
|
|
90
|
+
dispatch({ type: "setStatusMessage", message: "Logs not supported for Helm releases" });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const containers = activeDetail?.containers ?? [];
|
|
94
|
+
if (containers.length > 1 && !state.logsContainer) {
|
|
95
|
+
setOverlayIndex(0);
|
|
96
|
+
dispatch({ type: "setOverlay", overlay: "container-picker" });
|
|
97
|
+
} else {
|
|
98
|
+
dispatch({ type: "setOverlay", overlay: "logs" });
|
|
99
|
+
}
|
|
100
|
+
dispatch({
|
|
101
|
+
type: "setStatusMessage",
|
|
102
|
+
message: `Showing logs for ${statusLine({ ref: activeResourceRef })}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// F → port-forward overlay
|
|
109
|
+
if (key.name === "f" && activeResourceRef && selectedKind && state.activeContext && ((activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0)) {
|
|
110
|
+
openPortForwardOverlay();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// D → delete confirm
|
|
115
|
+
if (key.name === "d" && selectedKind && state.activeContext && deleteTargets.length > 0) {
|
|
116
|
+
dispatch({ type: "setOverlay", overlay: "delete-confirm" });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Space → multi-select
|
|
121
|
+
if (state.activePane === "resources" && key.name === "space" && visibleSelectedResourceName) {
|
|
122
|
+
dispatch({ type: "toggleSelectedResource", name: visibleSelectedResourceName });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Shift+S → scale dialog
|
|
127
|
+
if (key.shift && key.name === "s" && activeResourceRef && selectedKind && state.activeContext) {
|
|
128
|
+
setScaleReplicasInput(activeDetail?.replicas !== undefined ? String(activeDetail.replicas) : "");
|
|
129
|
+
dispatch({ type: "setOverlay", overlay: "scale" });
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false;
|
|
134
|
+
}
|