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.
Files changed (47) hide show
  1. package/package.json +5 -2
  2. package/src/app/__tests__/app-state.test.ts +376 -0
  3. package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
  4. package/src/app/__tests__/utils.test.ts +358 -0
  5. package/src/app/app-actions.ts +262 -0
  6. package/src/app/app-state.ts +4 -262
  7. package/src/app/app.tsx +22 -170
  8. package/src/app/components/detail-sections.tsx +131 -0
  9. package/src/app/components/footer.tsx +52 -0
  10. package/src/app/components/header.tsx +37 -0
  11. package/src/app/components/inspector-tokens.ts +93 -0
  12. package/src/app/components/inspector.tsx +3 -239
  13. package/src/app/components/resource-rows.tsx +5 -0
  14. package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
  15. package/src/app/hooks/keyboard/global-handlers.ts +134 -0
  16. package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
  17. package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
  18. package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
  19. package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
  20. package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
  21. package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
  22. package/src/app/hooks/use-app-keyboard.ts +56 -621
  23. package/src/app/hooks/use-app-side-effects.ts +1 -1
  24. package/src/app/hooks/use-clipboard.ts +1 -1
  25. package/src/app/hooks/use-data-fetching.ts +2 -9
  26. package/src/app/hooks/use-log-stream.ts +1 -1
  27. package/src/app/hooks/use-port-forward.ts +1 -1
  28. package/src/app/use-footer-hints.ts +107 -0
  29. package/src/index.tsx +4 -0
  30. package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
  31. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
  32. package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
  33. package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
  34. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
  35. package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
  36. package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
  37. package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
  38. package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
  39. package/src/lib/k8s/resource-detail-builder.ts +22 -502
  40. package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
  41. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
  42. package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
  43. package/src/lib/kubectl/kubectl-helpers.ts +246 -0
  44. package/src/lib/kubectl/kubectl-service.ts +77 -565
  45. package/src/lib/kubectl/kubectl-types.ts +248 -0
  46. package/src/lib/kubectl/metrics-utils.ts +33 -0
  47. 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 { statusTone } from "../utils";
17
- import type { EventItem, InspectorTab, PaneId, ResourceDetail, ResourceDetailLine, ResourceDetailSection } from "../../lib/k8s/types";
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
+ }