openk8s 1.0.4 → 1.1.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 (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -10
  3. package/src/app/__tests__/app-state.test.ts +47 -5
  4. package/src/app/__tests__/utils.test.ts +5 -14
  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 +28 -43
  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 +173 -0
  17. package/src/app/components/overlays/index.ts +5 -2
  18. package/src/app/components/overlays/logs-dialog.tsx +50 -80
  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 +32 -11
  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 +45 -17
  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 +11 -5
  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-parser.test.ts +40 -2
  53. package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
  54. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  55. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  56. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  57. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  58. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  59. package/src/lib/k8s/k8s-format.ts +14 -9
  60. package/src/lib/k8s/resource-detail-builder.ts +1 -1
  61. package/src/lib/k8s/resource-parser.ts +17 -2
  62. package/src/lib/k8s/types.ts +10 -1
  63. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  64. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  65. package/src/lib/kubectl/kubectl-service.ts +39 -39
  66. package/src/lib/kubectl/kubectl-types.ts +10 -1
  67. package/src/lib/kubectl/metrics-utils.ts +21 -7
  68. package/src/lib/kubectl/spawn-utils.ts +50 -11
  69. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  70. package/src/app/components/inspector-tokens.ts +0 -93
  71. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -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 {
@@ -72,21 +74,21 @@ export function handleGlobalHotkeys(
72
74
  }
73
75
 
74
76
  // V → events tab
75
- if (key.name === "v") {
77
+ if (key.name === "v" && !key.shift) {
76
78
  dispatch({ type: "setInspectorTab", tab: "events" });
77
79
  return true;
78
80
  }
79
81
 
80
82
  // I → describe tab
81
- if (key.name === "i") {
83
+ if (key.name === "i" && !key.shift) {
82
84
  dispatch({ type: "setInspectorTab", tab: "describe" });
83
85
  return true;
84
86
  }
85
87
 
86
88
  // L → logs
87
- if (key.name === "l") {
89
+ if (key.name === "l" && !key.shift) {
88
90
  if (activeResourceRef) {
89
- if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
91
+ if (isHelmRelease(activeResourceRef)) {
90
92
  dispatch({ type: "setStatusMessage", message: "Logs not supported for Helm releases" });
91
93
  return true;
92
94
  }
@@ -106,13 +108,13 @@ export function handleGlobalHotkeys(
106
108
  }
107
109
 
108
110
  // F → port-forward overlay
109
- if (key.name === "f" && activeResourceRef && selectedKind && state.activeContext && ((activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0)) {
111
+ if (key.name === "f" && !key.shift && activeResourceRef && selectedKind && state.activeContext && ((activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0)) {
110
112
  openPortForwardOverlay();
111
113
  return true;
112
114
  }
113
115
 
114
116
  // D → delete confirm
115
- if (key.name === "d" && selectedKind && state.activeContext && deleteTargets.length > 0) {
117
+ if (key.name === "d" && !key.shift && selectedKind && state.activeContext && deleteTargets.length > 0) {
116
118
  dispatch({ type: "setOverlay", overlay: "delete-confirm" });
117
119
  return true;
118
120
  }
@@ -130,5 +132,24 @@ export function handleGlobalHotkeys(
130
132
  return true;
131
133
  }
132
134
 
135
+ // Shift+N → notification history
136
+ if (key.shift && key.name === "n") {
137
+ dispatch({ type: "setOverlay", overlay: "notification-history" });
138
+ return true;
139
+ }
140
+
141
+ // Shift+F → port forward list
142
+ if (key.shift && key.name === "f") {
143
+ dispatch({ type: "setOverlay", overlay: "port-forward-list" });
144
+ setOverlayIndex(0);
145
+ return true;
146
+ }
147
+
148
+ // ? → help
149
+ if (key.sequence === "?" || (key.name === "slash" && key.shift)) {
150
+ dispatch({ type: "setOverlay", overlay: "help" });
151
+ return true;
152
+ }
153
+
133
154
  return false;
134
155
  }
@@ -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;
@@ -3,6 +3,7 @@ import type { AppState, ActivePortForward, ResourceRef, ResourceKind, ResourceDe
3
3
  import type { StartPortForwardLifecycleOptions } from "../../utils";
4
4
  import { parseLocalPort } from "../../utils";
5
5
  import { buildPortForwardEntries } from "../../components/overlays";
6
+ import { isDown, isUp } from "./keys";
6
7
 
7
8
  export function handlePortForwardKeys(
8
9
  key: { name: string },
@@ -28,23 +29,27 @@ export function handlePortForwardKeys(
28
29
 
29
30
  const pfEntries = buildPortForwardEntries(activeDetail?.portForwards ?? [], selectedResourcePortForwards);
30
31
 
31
- if (key.name === "up" || key.name === "k") {
32
- const newIndex = Math.max(0, overlayIndex - 1);
33
- setOverlayIndex(newIndex);
34
- const entry = pfEntries[newIndex];
35
- if (entry && !entry.activeForward) {
36
- setPortForwardLocalPort(String(entry.suggestedLocalPort));
37
- }
32
+ if (isUp(key)) {
33
+ setOverlayIndex((current) => {
34
+ const newIndex = Math.max(0, current - 1);
35
+ const entry = pfEntries[newIndex];
36
+ if (entry && !entry.activeForward) {
37
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
38
+ }
39
+ return newIndex;
40
+ });
38
41
  return true;
39
42
  }
40
43
 
41
- if (key.name === "down" || key.name === "j") {
42
- const newIndex = Math.min(Math.max(0, pfEntries.length - 1), overlayIndex + 1);
43
- setOverlayIndex(newIndex);
44
- const entry = pfEntries[newIndex];
45
- if (entry && !entry.activeForward) {
46
- setPortForwardLocalPort(String(entry.suggestedLocalPort));
47
- }
44
+ if (isDown(key)) {
45
+ setOverlayIndex((current) => {
46
+ const newIndex = Math.min(Math.max(0, pfEntries.length - 1), current + 1);
47
+ const entry = pfEntries[newIndex];
48
+ if (entry && !entry.activeForward) {
49
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
50
+ }
51
+ return newIndex;
52
+ });
48
53
  return true;
49
54
  }
50
55