openk8s 1.0.5 → 1.2.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 (72) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -10
  3. package/src/app/__tests__/app-state.test.ts +48 -6
  4. package/src/app/__tests__/utils.test.ts +6 -15
  5. package/src/app/app-actions.ts +5 -0
  6. package/src/app/app-state.ts +11 -2
  7. package/src/app/app.tsx +57 -46
  8. package/src/app/components/detail-sections.tsx +2 -2
  9. package/src/app/components/footer.tsx +23 -30
  10. package/src/app/components/inspector.tsx +40 -83
  11. package/src/app/components/kind-rows.tsx +1 -1
  12. package/src/app/components/notification-card.tsx +145 -0
  13. package/src/app/components/notification-tray.tsx +19 -38
  14. package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
  15. package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
  16. package/src/app/components/overlays/help-overlay.tsx +171 -0
  17. package/src/app/components/overlays/index.ts +4 -1
  18. package/src/app/components/overlays/logs-dialog.tsx +47 -67
  19. package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
  20. package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
  21. package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
  22. package/src/app/components/overlays/scale-dialog.tsx +12 -67
  23. package/src/app/components/overlays/select-overlay.tsx +5 -14
  24. package/src/app/components/overlays/shared.tsx +85 -6
  25. package/src/app/components/resource-rows.tsx +1 -1
  26. package/src/app/constants.ts +24 -0
  27. package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
  28. package/src/app/hooks/keyboard/global-handlers.ts +30 -21
  29. package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
  30. package/src/app/hooks/keyboard/keys.ts +18 -0
  31. package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
  32. package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
  33. package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
  34. package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
  35. package/src/app/hooks/keyboard/shell-edit-handlers.ts +32 -16
  36. package/src/app/hooks/use-app-keyboard.ts +22 -2
  37. package/src/app/hooks/use-app-side-effects.ts +10 -7
  38. package/src/app/hooks/use-clipboard.ts +8 -10
  39. package/src/app/hooks/use-data-fetching.ts +10 -6
  40. package/src/app/hooks/use-log-stream.ts +8 -4
  41. package/src/app/hooks/use-notifications.ts +92 -0
  42. package/src/app/hooks/use-port-forward.ts +19 -4
  43. package/src/app/persistence.ts +7 -3
  44. package/src/app/syntax-theme.ts +31 -0
  45. package/src/app/theme.ts +2 -3
  46. package/src/app/use-footer-hints.ts +21 -16
  47. package/src/app/utils.ts +1 -9
  48. package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
  49. package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
  50. package/src/index.tsx +22 -2
  51. package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
  52. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +4 -6
  53. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  54. package/src/lib/k8s/detail-builders/event-builder.ts +18 -12
  55. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  56. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  57. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  58. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  59. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  60. package/src/lib/k8s/k8s-format.ts +14 -9
  61. package/src/lib/k8s/resource-detail-builder.ts +4 -7
  62. package/src/lib/k8s/resource-parser.ts +17 -2
  63. package/src/lib/k8s/types.ts +12 -4
  64. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  65. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  66. package/src/lib/kubectl/kubectl-service.ts +38 -54
  67. package/src/lib/kubectl/kubectl-types.ts +10 -1
  68. package/src/lib/kubectl/metrics-utils.ts +21 -7
  69. package/src/lib/kubectl/spawn-utils.ts +50 -11
  70. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  71. package/src/app/components/inspector-tokens.ts +0 -93
  72. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -1,17 +1,7 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
2
 
3
- import {
4
- FILTER_BACKGROUND,
5
- GLYPHS,
6
- KEY_HINT,
7
- OVERLAY_SURFACE,
8
- PANEL_BORDER_ACTIVE,
9
- TEXT_MUTED,
10
- TEXT_PRIMARY,
11
- TEXT_SUBTLE,
12
- toneStyles,
13
- } from "../../theme";
14
- import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
3
+ import { GLYPHS, TEXT_PRIMARY, TEXT_SUBTLE, ATTR_DIM, toneStyles } from "../../theme";
4
+ import { DialogFrame, KeyHintRow, TextInput } from "./shared";
15
5
 
16
6
  export interface ScaleDialogProps {
17
7
  target: string;
@@ -24,26 +14,11 @@ export function ScaleDialog({ target, currentReplicas, replicasValue, onChange }
24
14
  const palette = toneStyles("accent");
25
15
 
26
16
  return (
27
- <box
28
- style={{
29
- position: "absolute",
30
- left: DIALOG_LEFT,
31
- top: DIALOG_TOP,
32
- width: DIALOG_WIDTH,
33
- height: 12,
34
- border: true,
35
- borderStyle: "rounded",
36
- borderColor: palette.border,
37
- backgroundColor: OVERLAY_SURFACE,
38
- padding: 1,
39
- flexDirection: "column",
40
- }}
41
- title="Scale Replicas"
42
- >
17
+ <DialogFrame title="Scale Replicas" tone="accent" height={12}>
43
18
  <text fg={palette.fg} attributes={TextAttributes.BOLD}>
44
19
  {target}
45
20
  </text>
46
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
21
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
47
22
  {currentReplicas !== undefined ? (
48
23
  <>
49
24
  {"Current "}
@@ -54,43 +29,13 @@ export function ScaleDialog({ target, currentReplicas, replicasValue, onChange }
54
29
  "Replicas unknown"
55
30
  )}
56
31
  </text>
57
- <box
58
- style={{
59
- border: true,
60
- borderColor: PANEL_BORDER_ACTIVE,
61
- borderStyle: "rounded",
62
- backgroundColor: FILTER_BACKGROUND,
63
- paddingLeft: 1,
64
- paddingRight: 1,
65
- marginTop: 1,
66
- height: 3,
67
- justifyContent: "center",
68
- alignItems: "center",
69
- flexDirection: "row",
70
- }}
71
- >
72
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
73
- Target:
74
- </text>
75
- <input
76
- focused
77
- value={replicasValue}
78
- placeholder={currentReplicas !== undefined ? String(currentReplicas) : "0"}
79
- style={{ flexGrow: 1, marginLeft: 1 }}
80
- onInput={onChange}
81
- onChange={onChange}
82
- />
83
- </box>
84
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
85
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
86
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
87
- {" scale"}
88
- </text>
89
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
90
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
91
- {" cancel"}
92
- </text>
93
- </box>
94
- </box>
32
+ <TextInput
33
+ label="Target:"
34
+ value={replicasValue}
35
+ placeholder={currentReplicas !== undefined ? String(currentReplicas) : "0"}
36
+ onChange={onChange}
37
+ />
38
+ <KeyHintRow hints={[[GLYPHS.enter, "scale"], [GLYPHS.esc, "cancel"]]} />
39
+ </DialogFrame>
95
40
  );
96
41
  }
@@ -1,13 +1,12 @@
1
1
  import {
2
2
  GLYPHS,
3
- KEY_HINT,
4
3
  OVERLAY_SURFACE,
5
4
  PANEL_BORDER_ACTIVE,
6
5
  ROW_SELECTED,
7
- TEXT_MUTED,
8
6
  TEXT_PRIMARY,
9
7
  TEXT_SUBTLE,
10
8
  } from "../../theme";
9
+ import { KeyHintRow } from "./shared";
11
10
 
12
11
  export interface SelectOverlayProps {
13
12
  title: string;
@@ -17,6 +16,7 @@ export interface SelectOverlayProps {
17
16
  }
18
17
 
19
18
  export function SelectOverlay({ title, items, selectedIndex, onChange }: SelectOverlayProps) {
19
+ const clampedIndex = items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0;
20
20
  return (
21
21
  <box
22
22
  style={{
@@ -48,21 +48,12 @@ export function SelectOverlay({ title, items, selectedIndex, onChange }: SelectO
48
48
  selectedDescriptionColor={TEXT_PRIMARY}
49
49
  showDescription
50
50
  showScrollIndicator
51
- selectedIndex={selectedIndex}
51
+ selectedIndex={clampedIndex}
52
52
  options={items}
53
- onChange={(index) => onChange(index)}
53
+ onChange={onChange}
54
54
  />
55
55
  </box>
56
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
57
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
58
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
59
- {" select"}
60
- </text>
61
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
62
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
63
- {" close"}
64
- </text>
65
- </box>
56
+ <KeyHintRow hints={[[GLYPHS.enter, "select"], [GLYPHS.esc, "close"]]} />
66
57
  </box>
67
58
  );
68
59
  }
@@ -1,18 +1,97 @@
1
- import { GLYPHS, KEY_HINT, TEXT_MUTED, TEXT_SUBTLE } from "../../theme";
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ import { FILTER_BACKGROUND, GLYPHS, KEY_HINT, OVERLAY_SURFACE, PANEL_BORDER_ACTIVE, ATTR_DIM, TEXT_SUBTLE, toneStyles } from "../../theme";
2
4
 
3
5
  export const DIALOG_LEFT = "28%";
4
6
  export const DIALOG_TOP = "20%";
5
7
  export const DIALOG_WIDTH = "44%";
6
8
 
7
- export function KeyHintRow({ hints }: { hints: [string, string][] }) {
9
+ const GLYPH_VALUES: ReadonlySet<string> = new Set<string>(Object.values(GLYPHS));
10
+
11
+ export function isGlyph(value: string): boolean {
12
+ return GLYPH_VALUES.has(value);
13
+ }
14
+
15
+ export function KeyHintRow({ hints, justifyContent = "flex-end" }: { hints: [string, string][]; justifyContent?: string }) {
8
16
  return (
9
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1 }}>
10
- {hints.map(([key, label]) => (
11
- <text key={key} fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
12
- <span fg={KEY_HINT}>{GLYPHS.enter === key || GLYPHS.esc === key ? key : key}</span>
17
+ <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: justifyContent as "flex-end" | "flex-start" | "center" }}>
18
+ {hints.map(([key, label], index) => (
19
+ <text key={`${index}:${key}`} fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
20
+ <span fg={KEY_HINT}>{key}</span>
13
21
  {` ${label}`}
14
22
  </text>
15
23
  ))}
16
24
  </box>
17
25
  );
18
26
  }
27
+
28
+ export interface TextInputProps {
29
+ label: string;
30
+ value: string;
31
+ placeholder?: string | undefined;
32
+ onChange: (value: string) => void;
33
+ focused?: boolean | undefined;
34
+ }
35
+
36
+ export function TextInput({ label, value, placeholder, onChange, focused = true }: TextInputProps) {
37
+ return (
38
+ <box
39
+ style={{
40
+ border: true,
41
+ borderColor: PANEL_BORDER_ACTIVE,
42
+ borderStyle: "rounded",
43
+ backgroundColor: FILTER_BACKGROUND,
44
+ paddingLeft: 1,
45
+ paddingRight: 1,
46
+ marginTop: 1,
47
+ height: 3,
48
+ justifyContent: "center",
49
+ alignItems: "center",
50
+ flexDirection: "row",
51
+ }}
52
+ >
53
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
54
+ {label}
55
+ </text>
56
+ <input
57
+ focused={focused}
58
+ value={value}
59
+ placeholder={placeholder ?? ""}
60
+ style={{ flexGrow: 1, marginLeft: 1 }}
61
+ onInput={onChange}
62
+ onChange={onChange}
63
+ />
64
+ </box>
65
+ );
66
+ }
67
+
68
+ export interface DialogFrameProps {
69
+ title: string;
70
+ tone: "accent" | "warning" | "danger" | "info" | "success" | "neutral";
71
+ height: number;
72
+ children: React.ReactNode;
73
+ }
74
+
75
+ export function DialogFrame({ title, tone, height, children }: DialogFrameProps) {
76
+ const palette = toneStyles(tone);
77
+ return (
78
+ <box
79
+ style={{
80
+ position: "absolute",
81
+ left: DIALOG_LEFT,
82
+ top: DIALOG_TOP,
83
+ width: DIALOG_WIDTH,
84
+ height,
85
+ border: true,
86
+ borderStyle: "rounded",
87
+ borderColor: palette.border,
88
+ backgroundColor: OVERLAY_SURFACE,
89
+ padding: 1,
90
+ flexDirection: "column",
91
+ }}
92
+ title={title}
93
+ >
94
+ {children}
95
+ </box>
96
+ );
97
+ }
@@ -67,7 +67,7 @@ export function ResourceRows({
67
67
  GLYPHS.dotEmpty;
68
68
 
69
69
  const hasActiveForward = activeForwardKeys.has(
70
- `${resource.ref.kind.toLowerCase()}:${resource.ref.name}`
70
+ `${resource.ref.kind.toLowerCase()}:${resource.ref.namespace ?? "cluster"}:${resource.ref.name}`
71
71
  );
72
72
 
73
73
  // Metrics suffix
@@ -0,0 +1,24 @@
1
+ // Polling intervals (milliseconds)
2
+ export const POLL_CLUSTER_MS = 30_000;
3
+ export const POLL_RESOURCE_MS = 5_000;
4
+ export const POLL_METRICS_MS = 15_000;
5
+
6
+ // UI
7
+ export const TOAST_DURATION_MS = 4_000;
8
+ export const INSPECTOR_MIN_WIDTH = 110;
9
+ export const KIND_PANE_WIDTH = 34;
10
+ export const INSPECTOR_PANE_WIDTH = 52;
11
+ export const TRUNCATE_DEFAULT = 26;
12
+
13
+ // Footer hint layout
14
+ export const FOOTER_PADDING = 4;
15
+ export const HINT_CELL_OVERHEAD = 3;
16
+
17
+ // Notifications
18
+ export const MAX_VISIBLE_NOTIFICATIONS = 3;
19
+ export const NOTIFICATION_HISTORY_LIMIT = 50;
20
+ export const NOTIFICATION_SLIDE_FRAMES = 6;
21
+ export const NOTIFICATION_SLIDE_INTERVAL_MS = 30;
22
+ export const NOTIFICATION_PROGRESS_INTERVAL_MS = 50;
23
+ export const NOTIFICATION_WIDTH_PCT = "40%";
24
+
@@ -1,4 +1,5 @@
1
1
  import type { AppAction } from "../../app-actions";
2
+ import { isSlash } from "./keys";
2
3
 
3
4
  export function handleFilterKeys(
4
5
  key: { name: string; ctrl?: boolean; sequence?: string },
@@ -12,7 +13,7 @@ export function handleFilterKeys(
12
13
  return true;
13
14
  }
14
15
 
15
- if (activePane === "resources" && (key.sequence === "/" || key.name === "slash")) {
16
+ if (activePane === "resources" && isSlash(key)) {
16
17
  setFilterMode(true);
17
18
  return true;
18
19
  }
@@ -1,7 +1,9 @@
1
1
  import { abortRunningProcesses } from "../../../lib/kubectl/spawn-utils";
2
+ import { isHelmRelease } from "../../../lib/kubectl/kubectl-helpers";
2
3
  import type { AppAction } from "../../app-actions";
3
4
  import type { AppState, ResourceRef, ResourceKind, ResourceDetail, NotificationTone } from "../../../lib/k8s/types";
4
5
  import { statusLine } from "../../utils";
6
+ import type { ToastOptions } from "../use-notifications";
5
7
 
6
8
  export function handleCtrlC(
7
9
  key: { name: string; ctrl?: boolean },
@@ -16,7 +18,7 @@ export function handleCtrlC(
16
18
  }
17
19
 
18
20
  export function handleGlobalHotkeys(
19
- key: { name: string; shift?: boolean },
21
+ key: { name: string; shift?: boolean; sequence?: string },
20
22
  state: AppState,
21
23
  dispatch: React.Dispatch<AppAction>,
22
24
  activeResourceRef: ResourceRef | undefined,
@@ -27,7 +29,7 @@ export function handleGlobalHotkeys(
27
29
  visibleSelectedResourceName: string | undefined,
28
30
  setOverlayIndex: React.Dispatch<React.SetStateAction<number>>,
29
31
  openPortForwardOverlay: () => void,
30
- toast: (tone: NotificationTone, message: string) => void,
32
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
31
33
  copyToClipboard: (text: string, label?: string) => void,
32
34
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
33
35
  setScaleReplicasInput: React.Dispatch<React.SetStateAction<string>>,
@@ -35,14 +37,14 @@ export function handleGlobalHotkeys(
35
37
  if (state.overlay) return false;
36
38
 
37
39
  // C → cluster switcher
38
- if (key.name === "c") {
40
+ if (key.name === "c" && !key.shift) {
39
41
  dispatch({ type: "setOverlay", overlay: "cluster-switcher" });
40
42
  setOverlayIndex(Math.max(0, state.contexts.findIndex((context) => context.name === state.activeContext)));
41
43
  return true;
42
44
  }
43
45
 
44
46
  // N → namespace switcher
45
- if (key.name === "n") {
47
+ if (key.name === "n" && !key.shift) {
46
48
  dispatch({ type: "setOverlay", overlay: "namespace-switcher" });
47
49
  setOverlayIndex(Math.max(0, state.namespaces.findIndex((namespace) => namespace.name === state.activeNamespace)));
48
50
  return true;
@@ -62,7 +64,7 @@ export function handleGlobalHotkeys(
62
64
  }
63
65
 
64
66
  // Y → yaml tab
65
- if (key.name === "y") {
67
+ if (key.name === "y" && !key.shift) {
66
68
  if (state.inspectorTab === "yaml" && activeDetail?.yaml) {
67
69
  copyToClipboard(activeDetail.yaml, "YAML");
68
70
  } else {
@@ -71,22 +73,10 @@ export function handleGlobalHotkeys(
71
73
  return true;
72
74
  }
73
75
 
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
76
  // L → logs
87
- if (key.name === "l") {
77
+ if (key.name === "l" && !key.shift) {
88
78
  if (activeResourceRef) {
89
- if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
79
+ if (isHelmRelease(activeResourceRef)) {
90
80
  dispatch({ type: "setStatusMessage", message: "Logs not supported for Helm releases" });
91
81
  return true;
92
82
  }
@@ -106,13 +96,13 @@ export function handleGlobalHotkeys(
106
96
  }
107
97
 
108
98
  // F → port-forward overlay
109
- if (key.name === "f" && activeResourceRef && selectedKind && state.activeContext && ((activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0)) {
99
+ if (key.name === "f" && !key.shift && activeResourceRef && selectedKind && state.activeContext && ((activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0)) {
110
100
  openPortForwardOverlay();
111
101
  return true;
112
102
  }
113
103
 
114
104
  // D → delete confirm
115
- if (key.name === "d" && selectedKind && state.activeContext && deleteTargets.length > 0) {
105
+ if (key.name === "d" && !key.shift && selectedKind && state.activeContext && deleteTargets.length > 0) {
116
106
  dispatch({ type: "setOverlay", overlay: "delete-confirm" });
117
107
  return true;
118
108
  }
@@ -130,5 +120,24 @@ export function handleGlobalHotkeys(
130
120
  return true;
131
121
  }
132
122
 
123
+ // Shift+N → notification history
124
+ if (key.shift && key.name === "n") {
125
+ dispatch({ type: "setOverlay", overlay: "notification-history" });
126
+ return true;
127
+ }
128
+
129
+ // Shift+F → port forward list
130
+ if (key.shift && key.name === "f") {
131
+ dispatch({ type: "setOverlay", overlay: "port-forward-list" });
132
+ setOverlayIndex(0);
133
+ return true;
134
+ }
135
+
136
+ // ? → help
137
+ if (key.sequence === "?" || (key.name === "slash" && key.shift)) {
138
+ dispatch({ type: "setOverlay", overlay: "help" });
139
+ return true;
140
+ }
141
+
133
142
  return false;
134
143
  }
@@ -1,6 +1,8 @@
1
1
  import type { AppAction } from "../../app-actions";
2
2
  import type { AppState, ResourceRef, ResourceKind, ResourceDetail, NotificationTone } from "../../../lib/k8s/types";
3
+ import { isHelmRelease } from "../../../lib/kubectl/kubectl-helpers";
3
4
  import { KubectlService } from "../../../lib/kubectl/kubectl-service";
5
+ import type { ToastOptions } from "../use-notifications";
4
6
 
5
7
  export function handleHelmRollbackKeys(
6
8
  key: { name: string },
@@ -8,8 +10,8 @@ export function handleHelmRollbackKeys(
8
10
  dispatch: React.Dispatch<AppAction>,
9
11
  activeResourceRef: ResourceRef | undefined,
10
12
  kubectl: KubectlService,
11
- toast: (tone: NotificationTone, message: string) => void,
12
- toastError: (error: unknown) => void,
13
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
14
+ toastError: (error: unknown, options?: ToastOptions) => void,
13
15
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
14
16
  ): boolean {
15
17
  if (state.overlay !== "helm-rollback") return false;
@@ -19,7 +21,8 @@ export function handleHelmRollbackKeys(
19
21
  const namespace = state.activeNamespace;
20
22
  const name = activeResourceRef.name;
21
23
  const revisionStr = state.helmRollbackRevision.trim();
22
- const revision = revisionStr ? Number.parseInt(revisionStr, 10) : undefined;
24
+ const parsedRevision = revisionStr ? Number.parseInt(revisionStr, 10) : undefined;
25
+ const revision = parsedRevision !== undefined && Number.isFinite(parsedRevision) ? parsedRevision : undefined;
23
26
 
24
27
  dispatch({ type: "setOverlay", overlay: undefined });
25
28
  dispatch({
@@ -33,7 +36,9 @@ export function handleHelmRollbackKeys(
33
36
  toast("success", `${name} rolled back${revision !== undefined ? ` to r${revision}` : ""}`);
34
37
  setManualRefreshNonce((current) => current + 1);
35
38
  })
36
- .catch(toastError);
39
+ .catch((error: unknown) => {
40
+ toastError(error, { actions: [{ label: "retry", onAction: () => { void kubectl.helmRollback({ context, namespace, name, revision }).then(() => setManualRefreshNonce((c) => c + 1)).catch(toastError); } }] });
41
+ });
37
42
 
38
43
  return true;
39
44
  }
@@ -55,21 +60,21 @@ export function handleHelmUpgradeKeys(
55
60
  selectedKind: ResourceKind | undefined,
56
61
  activeDetail: ResourceDetail | undefined,
57
62
  kubectl: KubectlService,
58
- toast: (tone: NotificationTone, message: string) => void,
59
- toastError: (error: unknown) => void,
63
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
64
+ toastError: (error: unknown, options?: ToastOptions) => void,
60
65
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
61
66
  ): boolean {
62
67
  if (state.overlay) return false;
63
68
 
64
69
  // B → Helm rollback overlay
65
- if (key.name === "b" && activeResourceRef?.kind.toLowerCase() === "helmreleases" && state.activeContext) {
70
+ if (key.name === "b" && isHelmRelease(activeResourceRef) && state.activeContext) {
66
71
  dispatch({ type: "setHelmRollbackRevision", value: "" });
67
72
  dispatch({ type: "setOverlay", overlay: "helm-rollback" });
68
73
  return true;
69
74
  }
70
75
 
71
76
  // U → Helm upgrade --reuse-values
72
- if (key.name === "u" && activeResourceRef?.kind.toLowerCase() === "helmreleases" && selectedKind && state.activeContext) {
77
+ if (key.name === "u" && isHelmRelease(activeResourceRef) && selectedKind && state.activeContext) {
73
78
  const chartName = activeDetail?.helmChart ?? "";
74
79
  if (!chartName) {
75
80
  dispatch({ type: "setStatusMessage", message: "Chart name unavailable — load the detail pane first" });
@@ -95,7 +100,7 @@ export function handleHelmUpgradeKeys(
95
100
  });
96
101
  upgradeChild.on("error", (err) => {
97
102
  renderer.resume();
98
- toastError(err);
103
+ toastError(err, { actions: [{ label: "retry", onAction: () => { kubectl.helmUpgrade({ context, namespace, name: target.name, chart: chartName }); } }] });
99
104
  });
100
105
  return true;
101
106
  }
@@ -0,0 +1,18 @@
1
+ export interface KeyEvent {
2
+ name: string;
3
+ shift?: boolean;
4
+ ctrl?: boolean;
5
+ sequence?: string;
6
+ }
7
+
8
+ export function isUp(key: KeyEvent): boolean {
9
+ return key.name === "up" || key.name === "k";
10
+ }
11
+
12
+ export function isDown(key: KeyEvent): boolean {
13
+ return key.name === "down" || key.name === "j";
14
+ }
15
+
16
+ export function isSlash(key: KeyEvent): boolean {
17
+ return key.sequence === "/" || key.name === "slash";
18
+ }
@@ -3,6 +3,7 @@ import type { ScrollBoxRenderable } from "@opentui/core";
3
3
  import type { AppAction } from "../../app-actions";
4
4
  import type { AppState } from "../../../lib/k8s/types";
5
5
  import type { LogsDialogActions } from "../../components/overlays";
6
+ import { isSlash } from "./keys";
6
7
 
7
8
  export function handleLogsKeys(
8
9
  key: { name: string; shift?: boolean; sequence?: string },
@@ -54,7 +55,7 @@ export function handleLogsKeys(
54
55
  } else if (key.name === "s") {
55
56
  setLogsInputMode("since");
56
57
  setLogsInputValue(state.logsOptions.since ?? "");
57
- } else if (key.sequence === "/" || key.name === "slash") {
58
+ } else if (isSlash(key)) {
58
59
  setLogsSearchMode(true);
59
60
  } else if (key.name === "n" && !key.shift) {
60
61
  logsActionsRef.current?.nextMatch();
@@ -74,6 +75,7 @@ export function handleLogsKeys(
74
75
  setLogsInputValue("");
75
76
  } else if (logsSearchMode && key.name === "return") {
76
77
  setLogsSearchMode(false);
78
+ setLogsSearchText("");
77
79
  }
78
80
 
79
81
  return true;
@@ -1,5 +1,6 @@
1
1
  import type { AppAction } from "../../app-actions";
2
2
  import { INSPECTOR_TABS } from "../../components/inspector";
3
+ import { isDown, isUp } from "./keys";
3
4
  import type { AppState, ResourceKind, ResourceListItem } from "../../../lib/k8s/types";
4
5
 
5
6
  export function handlePaneNavigation(
@@ -18,7 +19,7 @@ export function handlePaneNavigation(
18
19
  if (state.activePane === "clusters") {
19
20
  const currentIndex = Math.max(0, state.resourceKinds.findIndex((kind) => kind.name === state.selectedKind));
20
21
 
21
- if ((key.name === "up" || key.name === "k") && currentIndex > 0) {
22
+ if (isUp(key) && currentIndex > 0) {
22
23
  const nextKind = state.resourceKinds[currentIndex - 1];
23
24
  if (nextKind) {
24
25
  dispatch({ type: "setSelectedKind", kind: nextKind.name });
@@ -26,7 +27,7 @@ export function handlePaneNavigation(
26
27
  return true;
27
28
  }
28
29
 
29
- if ((key.name === "down" || key.name === "j") && currentIndex < state.resourceKinds.length - 1) {
30
+ if (isDown(key) && currentIndex < state.resourceKinds.length - 1) {
30
31
  const nextKind = state.resourceKinds[currentIndex + 1];
31
32
  if (nextKind) {
32
33
  dispatch({ type: "setSelectedKind", kind: nextKind.name });
@@ -49,7 +50,7 @@ export function handlePaneNavigation(
49
50
  filteredResources.findIndex((resource) => resource.ref.name === visibleSelectedResourceName),
50
51
  );
51
52
 
52
- if ((key.name === "up" || key.name === "k") && currentIndex > 0) {
53
+ if (isUp(key) && currentIndex > 0) {
53
54
  const nextResource = filteredResources[currentIndex - 1];
54
55
  if (nextResource) {
55
56
  dispatch({ type: "setSelectedResourceName", name: nextResource.ref.name });
@@ -57,7 +58,7 @@ export function handlePaneNavigation(
57
58
  return true;
58
59
  }
59
60
 
60
- if ((key.name === "down" || key.name === "j") && currentIndex < filteredResources.length - 1) {
61
+ if (isDown(key) && currentIndex < filteredResources.length - 1) {
61
62
  const nextResource = filteredResources[currentIndex + 1];
62
63
  if (nextResource) {
63
64
  dispatch({ type: "setSelectedResourceName", name: nextResource.ref.name });
@@ -2,6 +2,8 @@ import type { AppAction } from "../../app-actions";
2
2
  import type { AppState, ResourceListItem, ResourceKind, ResourceRef } from "../../../lib/k8s/types";
3
3
  import { deleteStatusMessage, statusLine } from "../../utils";
4
4
  import { KubectlService } from "../../../lib/kubectl/kubectl-service";
5
+ import { isDown, isUp } from "./keys";
6
+ import type { ToastOptions } from "../use-notifications";
5
7
 
6
8
  export function handleGenericEscape(
7
9
  key: { name: string },
@@ -31,17 +33,19 @@ export function handleOverlayNavigation(
31
33
  ? state.contexts
32
34
  : state.overlay === "namespace-switcher"
33
35
  ? state.namespaces
34
- : state.overlay === "container-picker"
35
- ? []
36
- : [];
36
+ : state.overlay === "port-forward-list"
37
+ ? state.activePortForwards
38
+ : state.overlay === "container-picker"
39
+ ? []
40
+ : [];
37
41
 
38
- if (key.name === "up") {
42
+ if (isUp(key)) {
39
43
  setOverlayIndex((current) => Math.max(0, current - 1));
40
44
  return true;
41
45
  }
42
46
 
43
- if (key.name === "down") {
44
- setOverlayIndex((current) => Math.min(items.length - 1, current + 1));
47
+ if (isDown(key)) {
48
+ setOverlayIndex((current) => Math.max(0, Math.min(items.length - 1, current + 1)));
45
49
  return true;
46
50
  }
47
51
 
@@ -79,7 +83,7 @@ export function handleDeleteConfirm(
79
83
  selectedKind: ResourceKind | undefined,
80
84
  deleteTargets: ResourceListItem[],
81
85
  kubectl: KubectlService,
82
- toastError: (error: unknown) => void,
86
+ toastError: (error: unknown, options?: ToastOptions) => void,
83
87
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
84
88
  ): boolean {
85
89
  if (state.overlay !== "delete-confirm") return false;