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
@@ -3,8 +3,10 @@ import { spawn } from "node:child_process";
3
3
 
4
4
  import type { AppAction } from "../../app-actions";
5
5
  import type { AppState, ResourceRef, ResourceKind, ResourceDetail, NotificationTone } from "../../../lib/k8s/types";
6
+ import { isHelmRelease } from "../../../lib/kubectl/kubectl-helpers";
6
7
  import { statusLine } from "../../utils";
7
8
  import { KubectlService } from "../../../lib/kubectl/kubectl-service";
9
+ import type { ToastOptions } from "../use-notifications";
8
10
 
9
11
  export function handleShellKeys(
10
12
  key: { name: string; shift?: boolean },
@@ -14,13 +16,13 @@ export function handleShellKeys(
14
16
  activeResourceRef: ResourceRef | undefined,
15
17
  selectedKind: ResourceKind | undefined,
16
18
  kubectl: KubectlService,
17
- toastError: (error: unknown) => void,
19
+ toastError: (error: unknown, options?: ToastOptions) => void,
18
20
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
19
21
  ): boolean {
20
22
  if (state.overlay) return false;
21
23
 
22
24
  if (key.name === "x" && activeResourceRef && selectedKind && state.activeContext) {
23
- if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
25
+ if (isHelmRelease(activeResourceRef)) {
24
26
  dispatch({ type: "setStatusMessage", message: "Shell not supported for Helm releases" });
25
27
  return true;
26
28
  }
@@ -35,10 +37,22 @@ export function handleShellKeys(
35
37
  const target = activeResourceRef;
36
38
 
37
39
  const child = kubectl.startShell({ context, namespace, resourceRef: target, namespaced });
38
- child.on("close", () => {
40
+
41
+ let stderrBuffer = "";
42
+ child.stderr?.on("data", (chunk: Buffer) => {
43
+ stderrBuffer += chunk.toString();
44
+ });
45
+
46
+ child.on("close", (code) => {
39
47
  renderer.resume();
40
48
  dispatch({ type: "setError", error: undefined });
41
- dispatch({ type: "setStatusMessage", message: `Returned from shell in ${statusLine({ ref: target })}` });
49
+ if (code === 0) {
50
+ dispatch({ type: "setStatusMessage", message: `Returned from shell in ${statusLine({ ref: target })}` });
51
+ } else {
52
+ const msg = stderrBuffer.trim() || `Shell exited with code ${code}`;
53
+ toastError(new Error(msg));
54
+ dispatch({ type: "setStatusMessage", message: `Shell failed for ${statusLine({ ref: target })}` });
55
+ }
42
56
  setManualRefreshNonce((current) => current + 1);
43
57
  });
44
58
  child.on("error", (error) => {
@@ -61,8 +75,8 @@ export function handleEditKeys(
61
75
  selectedKind: ResourceKind | undefined,
62
76
  activeDetail: ResourceDetail | undefined,
63
77
  kubectl: KubectlService,
64
- toast: (tone: NotificationTone, message: string) => void,
65
- toastError: (error: unknown) => void,
78
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
79
+ toastError: (error: unknown, options?: ToastOptions) => void,
66
80
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
67
81
  ): boolean {
68
82
  if (state.overlay) return false;
@@ -72,7 +86,7 @@ export function handleEditKeys(
72
86
  const namespace = state.activeNamespace;
73
87
  const namespaced = selectedKind.namespaced;
74
88
  const target = activeResourceRef;
75
- const isHelm = target.kind.toLowerCase() === "helmreleases";
89
+ const isHelm = isHelmRelease(target);
76
90
 
77
91
  if (isHelm) {
78
92
  const chartName = activeDetail?.helmChart ?? "";
@@ -99,7 +113,10 @@ export function handleEditKeys(
99
113
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
100
114
  const editorChild = spawn(editor, [tmpPath], { env: process.env, stdio: "inherit" });
101
115
 
102
- editorChild.on("close", (code) => {
116
+ let editHandled = false;
117
+ const finishEdit = (code: number | null): void => {
118
+ if (editHandled) return;
119
+ editHandled = true;
103
120
  if (code === 0) {
104
121
  kubectl
105
122
  .helmUpgradeValues({ context, namespace, name: target.name, chart: chartName, valuesFile: tmpPath })
@@ -118,9 +135,15 @@ export function handleEditKeys(
118
135
  dispatch({ type: "setStatusMessage", message: "Edit cancelled" });
119
136
  cleanupTmp();
120
137
  }
138
+ };
139
+
140
+ editorChild.on("close", (code) => {
141
+ finishEdit(code);
121
142
  });
122
143
 
123
144
  editorChild.on("error", (err) => {
145
+ if (editHandled) return;
146
+ editHandled = true;
124
147
  renderer.resume();
125
148
  toastError(err);
126
149
  cleanupTmp();
@@ -132,7 +155,10 @@ export function handleEditKeys(
132
155
 
133
156
  const editChild = kubectl.editResource({ context, namespace, resourceRef: target, namespaced });
134
157
 
158
+ let editClosed = false;
135
159
  editChild.on("close", (code) => {
160
+ if (editClosed) return;
161
+ editClosed = true;
136
162
  renderer.resume();
137
163
  if (code === 0) {
138
164
  dispatch({ type: "setStatusMessage", message: `Saved ${statusLine({ ref: target })}` });
@@ -143,6 +169,8 @@ export function handleEditKeys(
143
169
  });
144
170
 
145
171
  editChild.on("error", (err) => {
172
+ if (editClosed) return;
173
+ editClosed = true;
146
174
  renderer.resume();
147
175
  toastError(err);
148
176
  dispatch({ type: "setStatusMessage", message: `Edit failed for ${statusLine({ ref: target })}` });
@@ -151,11 +179,6 @@ export function handleEditKeys(
151
179
  return true;
152
180
  }
153
181
 
154
- // Shift+S → scale dialog
155
- if (key.shift && key.name === "s" && activeResourceRef && selectedKind && state.activeContext && kubectl.canScale(activeResourceRef)) {
156
- return false; // handled by scale handler
157
- }
158
-
159
182
  return false;
160
183
  }
161
184
 
@@ -167,11 +190,16 @@ export function handleScaleKeys(
167
190
  selectedKind: ResourceKind | undefined,
168
191
  scaleReplicasInput: string,
169
192
  kubectl: KubectlService,
170
- toastError: (error: unknown) => void,
193
+ toastError: (error: unknown, options?: ToastOptions) => void,
171
194
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
172
195
  ): boolean {
173
196
  if (state.overlay !== "scale") return false;
174
197
 
198
+ if (key.name === "escape") {
199
+ dispatch({ type: "setOverlay", overlay: undefined });
200
+ return true;
201
+ }
202
+
175
203
  if (key.name === "return" && activeResourceRef && selectedKind && state.activeContext) {
176
204
  const replicas = Number.parseInt(scaleReplicasInput, 10);
177
205
 
@@ -195,7 +223,7 @@ export function handleScaleKeys(
195
223
  setManualRefreshNonce((current) => current + 1);
196
224
  })
197
225
  .catch((error: unknown) => {
198
- toastError(error);
226
+ toastError(error, { actions: [{ label: "retry", onAction: () => { void kubectl.scaleResource({ context, namespace, resourceRef: target, namespaced, replicas }).then(() => setManualRefreshNonce((c) => c + 1)).catch(toastError); } }] });
199
227
  dispatch({
200
228
  type: "setStatusMessage",
201
229
  message: `Scale failed for ${statusLine({ ref: target })}`,
@@ -215,7 +243,7 @@ export function handleRolloutRestartKeys(
215
243
  activeResourceRef: ResourceRef | undefined,
216
244
  selectedKind: ResourceKind | undefined,
217
245
  kubectl: KubectlService,
218
- toastError: (error: unknown) => void,
246
+ toastError: (error: unknown, options?: ToastOptions) => void,
219
247
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
220
248
  ): boolean {
221
249
  if (state.overlay) return false;
@@ -237,7 +265,7 @@ export function handleRolloutRestartKeys(
237
265
  setManualRefreshNonce((current) => current + 1);
238
266
  })
239
267
  .catch((error: unknown) => {
240
- toastError(error);
268
+ toastError(error, { actions: [{ label: "retry", onAction: () => { void kubectl.rolloutRestart({ context, namespace, resourceRef: target, namespaced }).then(() => setManualRefreshNonce((c) => c + 1)).catch(toastError); } }] });
241
269
  dispatch({
242
270
  type: "setStatusMessage",
243
271
  message: `Rollout restart failed for ${statusLine({ ref: target })}`,
@@ -7,6 +7,7 @@ import type { AppState } from "../../lib/k8s/types";
7
7
  import type { ActivePortForward, NotificationTone, ResourceDetail, ResourceKind, ResourceListItem, ResourceRef } from "../../lib/k8s/types";
8
8
  import type { LogsDialogActions } from "../components/overlays";
9
9
  import type { StartPortForwardLifecycleOptions } from "../utils";
10
+ import type { ToastOptions } from "./use-notifications";
10
11
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
11
12
 
12
13
  import { handleCtrlC } from "./keyboard/global-handlers";
@@ -54,8 +55,8 @@ export function useAppKeyboard(
54
55
  stopPortForward: (id: string) => void,
55
56
  startPortForward: (options: StartPortForwardLifecycleOptions) => void,
56
57
  openPortForwardOverlay: () => void,
57
- toast: (tone: NotificationTone, message: string) => void,
58
- toastError: (error: unknown) => void,
58
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
59
+ toastError: (error: unknown, options?: ToastOptions) => void,
59
60
  copyToClipboard: (text: string, label?: string) => void,
60
61
  handleToggleReveal: (id: string) => void,
61
62
  logsScrollRef: React.MutableRefObject<ScrollBoxRenderable | null>,
@@ -115,6 +116,25 @@ export function useAppKeyboard(
115
116
  if (handleContainerPicker(key, state, dispatch, overlayIndex, activeDetail?.containers)) return;
116
117
  }
117
118
 
119
+ if (state.overlay === "notification-history") {
120
+ if (key.name === "c" && !key.shift) {
121
+ dispatch({ type: "clearNotificationHistory" });
122
+ return;
123
+ }
124
+ }
125
+
126
+ if (state.overlay === "port-forward-list") {
127
+ if (key.name === "x" && !key.shift) {
128
+ const forward = state.activePortForwards[overlayIndex];
129
+ if (forward) stopPortForward(forward.id);
130
+ return;
131
+ }
132
+ if (key.name === "c" && !key.shift) {
133
+ for (const f of state.activePortForwards) stopPortForward(f.id);
134
+ return;
135
+ }
136
+ }
137
+
118
138
  return;
119
139
  }
120
140
 
@@ -5,25 +5,21 @@ import type { AppAction } from "../app-actions";
5
5
  import type { AppState } from "../../lib/k8s/types";
6
6
  import type { ResourceRef } from "../../lib/k8s/types";
7
7
  import { savePersistedState } from "../persistence";
8
+ import { safeKill } from "../../lib/kubectl/spawn-utils";
8
9
 
9
10
  export function useAppSideEffects(
10
11
  state: AppState,
11
12
  dispatch: React.Dispatch<AppAction>,
12
13
  portForwardProcessesRef: React.MutableRefObject<Map<string, ChildProcess>>,
13
- notificationTimersRef: React.MutableRefObject<Map<string, ReturnType<typeof setTimeout>>>,
14
14
  activeResourceRef: ResourceRef | undefined,
15
15
  ) {
16
- // Kill all port-forward processes and cancel notification timers on unmount
16
+ // Kill all port-forward processes on unmount
17
17
  useEffect(() => {
18
18
  return () => {
19
19
  for (const child of portForwardProcessesRef.current.values()) {
20
- child.kill("SIGINT");
20
+ safeKill(child, "SIGINT");
21
21
  }
22
22
  portForwardProcessesRef.current.clear();
23
- for (const timer of notificationTimersRef.current.values()) {
24
- clearTimeout(timer);
25
- }
26
- notificationTimersRef.current.clear();
27
23
  };
28
24
  }, []);
29
25
 
@@ -36,4 +32,11 @@ export function useAppSideEffects(
36
32
  useEffect(() => {
37
33
  dispatch({ type: "setLogsContainer", container: undefined });
38
34
  }, [activeResourceRef?.kind, activeResourceRef?.name, activeResourceRef?.namespace]);
35
+
36
+ // Close port-forward-list overlay when no forwards remain
37
+ useEffect(() => {
38
+ if (state.overlay === "port-forward-list" && state.activePortForwards.length === 0) {
39
+ dispatch({ type: "setOverlay", overlay: undefined });
40
+ }
41
+ }, [state.overlay, state.activePortForwards.length]);
39
42
  }
@@ -3,11 +3,11 @@ import { useCallback } from "react";
3
3
 
4
4
  import type { AppAction } from "../app-actions";
5
5
  import type { NotificationTone } from "../../lib/k8s/types";
6
- import { loadError } from "../utils";
6
+ import type { ToastOptions } from "./use-notifications";
7
7
 
8
8
  export function useClipboard(
9
9
  dispatch: React.Dispatch<AppAction>,
10
- toast: (tone: NotificationTone, message: string) => void,
10
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
11
11
  ) {
12
12
  const copyToClipboard = useCallback((text: string, label?: string): void => {
13
13
  let cmd: string;
@@ -16,12 +16,12 @@ export function useClipboard(
16
16
  if (process.env.WAYLAND_DISPLAY) {
17
17
  cmd = "wl-copy";
18
18
  args = [];
19
- } else if (process.env.DISPLAY) {
20
- cmd = "xclip";
21
- args = ["-selection", "clipboard"];
22
19
  } else if (process.platform === "darwin") {
23
20
  cmd = "pbcopy";
24
21
  args = [];
22
+ } else if (process.env.DISPLAY) {
23
+ cmd = "xclip";
24
+ args = ["-selection", "clipboard"];
25
25
  } else {
26
26
  dispatch({ type: "setStatusMessage", message: "Clipboard not available on this platform" });
27
27
  return;
@@ -39,6 +39,8 @@ export function useClipboard(
39
39
  child.on("close", (code) => {
40
40
  if (code === 0) {
41
41
  toast("success", toastMessage);
42
+ } else {
43
+ dispatch({ type: "setStatusMessage", message: `Copy failed — is ${cmd} installed?` });
42
44
  }
43
45
  });
44
46
  } catch {
@@ -46,9 +48,5 @@ export function useClipboard(
46
48
  }
47
49
  }, [toast, dispatch]);
48
50
 
49
- const toastError = useCallback((error: unknown): void => {
50
- toast("danger", loadError(error).message);
51
- }, [toast]);
52
-
53
- return { copyToClipboard, toastError };
51
+ return { copyToClipboard };
54
52
  }
@@ -183,7 +183,9 @@ export function useDataFetching(
183
183
  let cancelled = false;
184
184
 
185
185
  async function loadResources(): Promise<void> {
186
- dispatch({ type: "setResourcesStatus", status: "loading" });
186
+ if (resourcesLengthRef.current === 0) {
187
+ dispatch({ type: "setResourcesStatus", status: "loading" });
188
+ }
187
189
 
188
190
  try {
189
191
  const resources = await kubectl.listResources({
@@ -335,23 +337,27 @@ export function useDataFetching(
335
337
 
336
338
  // Poll pod metrics when viewing pods (silent degradation on error)
337
339
  useEffect(() => {
340
+ let cancelled = false;
338
341
  const context = state.activeContext;
339
342
  if (!context || state.selectedKind !== "pods") return;
340
343
  void kubectl
341
344
  .getPodMetrics({ context, namespace: state.activeNamespace })
342
- .then((metrics) => dispatch({ type: "setPodMetrics", metrics }))
345
+ .then((metrics) => { if (!cancelled) dispatch({ type: "setPodMetrics", metrics }); })
343
346
  .catch(() => {/* metrics-server absent — silent degradation */});
344
- }, [state.activeContext, state.activeNamespace, state.selectedKind, metricsPollTick]);
347
+ return () => { cancelled = true; };
348
+ }, [state.activeContext, state.activeNamespace, state.selectedKind, metricsPollTick, manualRefreshNonce]);
345
349
 
346
350
  // Poll node metrics when viewing nodes (silent degradation on error)
347
351
  useEffect(() => {
352
+ let cancelled = false;
348
353
  const context = state.activeContext;
349
354
  if (!context || state.selectedKind !== "nodes") return;
350
355
  void kubectl
351
356
  .getNodeMetrics({ context })
352
- .then((metrics) => dispatch({ type: "setNodeMetrics", metrics }))
357
+ .then((metrics) => { if (!cancelled) dispatch({ type: "setNodeMetrics", metrics }); })
353
358
  .catch(() => {/* metrics-server absent — silent degradation */});
354
- }, [state.activeContext, state.selectedKind, metricsPollTick]);
359
+ return () => { cancelled = true; };
360
+ }, [state.activeContext, state.selectedKind, metricsPollTick, manualRefreshNonce]);
355
361
 
356
362
  return { resourcesLengthRef, selectedResourceNameRef, selectedResourceDetailRef, eventsLengthRef };
357
363
  }
@@ -5,6 +5,7 @@ import type { AppAction } from "../app-actions";
5
5
  import type { AppState, LoadStatus } from "../../lib/k8s/types";
6
6
  import { loadError } from "../utils";
7
7
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
8
+ import { stopPersistentProcess } from "../../lib/kubectl/spawn-utils";
8
9
 
9
10
  export function useLogStream(
10
11
  dispatch: React.Dispatch<AppAction>,
@@ -21,7 +22,7 @@ export function useLogStream(
21
22
 
22
23
  if (state.overlay !== "logs" || !context || !activeLogsTarget || !activeLogsRef || !activeLogsKey) {
23
24
  if (logStreamRef.current) {
24
- logStreamRef.current.kill();
25
+ stopPersistentProcess(logStreamRef.current);
25
26
  logStreamRef.current = undefined;
26
27
  }
27
28
  return;
@@ -29,6 +30,7 @@ export function useLogStream(
29
30
 
30
31
  let cancelled = false;
31
32
  let flushTimer: ReturnType<typeof setTimeout> | undefined;
33
+ let errorSeen = false;
32
34
 
33
35
  try {
34
36
  const { target, child } = kubectl.streamLogs({
@@ -41,7 +43,7 @@ export function useLogStream(
41
43
  });
42
44
 
43
45
  if (logStreamRef.current) {
44
- logStreamRef.current.kill();
46
+ stopPersistentProcess(logStreamRef.current);
45
47
  }
46
48
 
47
49
  logStreamRef.current = child;
@@ -76,6 +78,7 @@ export function useLogStream(
76
78
  child.stderr?.on("data", (chunk: Buffer | string) => {
77
79
  if (cancelled) return;
78
80
  buffer += chunk.toString();
81
+ errorSeen = true;
79
82
  scheduleFlush("error");
80
83
  });
81
84
 
@@ -85,7 +88,8 @@ export function useLogStream(
85
88
  clearTimeout(flushTimer);
86
89
  flushTimer = undefined;
87
90
  }
88
- flush("ready");
91
+ // Preserve an error status if stderr produced output; only flush "ready" for clean exits.
92
+ flush(errorSeen ? "error" : "ready");
89
93
  });
90
94
  } catch (error) {
91
95
  const parsed = loadError(error);
@@ -103,7 +107,7 @@ export function useLogStream(
103
107
  clearTimeout(flushTimer);
104
108
  }
105
109
  if (logStreamRef.current) {
106
- logStreamRef.current.kill();
110
+ stopPersistentProcess(logStreamRef.current);
107
111
  logStreamRef.current = undefined;
108
112
  }
109
113
  };
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+
3
+ import type { AppAction } from "../app-actions";
4
+ import type { NotificationAction, NotificationTone } from "../../lib/k8s/types";
5
+ import { loadError } from "../utils";
6
+ import { TOAST_DURATION_MS } from "../constants";
7
+
8
+ export interface ToastAction {
9
+ label: string;
10
+ onAction: () => void;
11
+ }
12
+
13
+ export interface ToastOptions {
14
+ persistent?: boolean | undefined;
15
+ actions?: ToastAction[] | undefined;
16
+ }
17
+
18
+ export interface UseNotificationsResult {
19
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => string;
20
+ toastError: (error: unknown, options?: ToastOptions) => string;
21
+ dismiss: (id: string) => void;
22
+ executeAction: (notificationId: string, actionId: string) => void;
23
+ }
24
+
25
+ export function useNotifications(dispatch: React.Dispatch<AppAction>): UseNotificationsResult {
26
+ const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
27
+ const actionsRef = useRef(new Map<string, Map<string, () => void>>());
28
+
29
+ const clearTimer = useCallback((id: string): void => {
30
+ const timer = timersRef.current.get(id);
31
+ if (timer) {
32
+ clearTimeout(timer);
33
+ timersRef.current.delete(id);
34
+ }
35
+ }, []);
36
+
37
+ const dismiss = useCallback((id: string): void => {
38
+ clearTimer(id);
39
+ actionsRef.current.delete(id);
40
+ dispatch({ type: "dismissNotification", id });
41
+ }, [clearTimer, dispatch]);
42
+
43
+ const toast = useCallback((tone: NotificationTone, message: string, options?: ToastOptions): string => {
44
+ const id = `notif-${crypto.randomUUID()}`;
45
+ const persistent = options?.persistent ?? (tone === "danger");
46
+ const actions: NotificationAction[] = (options?.actions ?? []).map((a, i) => ({
47
+ id: `action-${i}`,
48
+ label: a.label,
49
+ }));
50
+
51
+ if (options?.actions && options.actions.length > 0) {
52
+ const actionMap = new Map<string, () => void>();
53
+ options.actions.forEach((a, i) => actionMap.set(`action-${i}`, a.onAction));
54
+ actionsRef.current.set(id, actionMap);
55
+ }
56
+
57
+ dispatch({
58
+ type: "pushNotification",
59
+ notification: { id, tone, message, persistent, createdAt: Date.now(), actions },
60
+ });
61
+
62
+ if (!persistent) {
63
+ const timer = setTimeout(() => dismiss(id), TOAST_DURATION_MS);
64
+ timersRef.current.set(id, timer);
65
+ }
66
+
67
+ return id;
68
+ }, [dispatch, dismiss]);
69
+
70
+ const toastError = useCallback((error: unknown, options?: ToastOptions): string => {
71
+ return toast("danger", loadError(error).message, options);
72
+ }, [toast]);
73
+
74
+ const executeAction = useCallback((notificationId: string, actionId: string): void => {
75
+ const actionMap = actionsRef.current.get(notificationId);
76
+ const callback = actionMap?.get(actionId);
77
+ if (callback) callback();
78
+ dismiss(notificationId);
79
+ }, [dismiss]);
80
+
81
+ useEffect(() => {
82
+ return () => {
83
+ for (const timer of timersRef.current.values()) {
84
+ clearTimeout(timer);
85
+ }
86
+ timersRef.current.clear();
87
+ actionsRef.current.clear();
88
+ };
89
+ }, []);
90
+
91
+ return { toast, toastError, dismiss, executeAction };
92
+ }
@@ -6,11 +6,13 @@ import type { ActivePortForward, ResourceDetail, ResourceRef } from "../../lib/k
6
6
  import type { StartPortForwardLifecycleOptions } from "../utils";
7
7
  import { portForwardId, statusLine } from "../utils";
8
8
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
9
+ import { safeKill } from "../../lib/kubectl/spawn-utils";
10
+ import type { ToastOptions } from "./use-notifications";
9
11
 
10
12
  export function usePortForward(
11
13
  dispatch: React.Dispatch<AppAction>,
12
14
  kubectl: KubectlService,
13
- toastError: (error: unknown) => void,
15
+ toastError: (error: unknown, options?: ToastOptions) => void,
14
16
  ) {
15
17
  const portForwardProcessesRef = useRef(new Map<string, ChildProcess>());
16
18
 
@@ -22,7 +24,7 @@ export function usePortForward(
22
24
  return;
23
25
  }
24
26
 
25
- child.kill("SIGINT");
27
+ safeKill(child, "SIGINT");
26
28
  };
27
29
 
28
30
  const openPortForwardOverlay = (
@@ -108,7 +110,21 @@ export function usePortForward(
108
110
  child.stdout?.on("data", handleOutput);
109
111
  child.stderr?.on("data", handleOutput);
110
112
 
113
+ let handled = false;
114
+ const reportFailure = (): void => {
115
+ if (handled) return;
116
+ handled = true;
117
+ portForwardProcessesRef.current.delete(id);
118
+ dispatch({ type: "removeActivePortForward", id });
119
+ };
120
+
111
121
  child.on("close", (code, signal) => {
122
+ if (handled) {
123
+ portForwardProcessesRef.current.delete(id);
124
+ dispatch({ type: "removeActivePortForward", id });
125
+ return;
126
+ }
127
+ handled = true;
112
128
  portForwardProcessesRef.current.delete(id);
113
129
  dispatch({ type: "removeActivePortForward", id });
114
130
 
@@ -135,8 +151,7 @@ export function usePortForward(
135
151
  });
136
152
 
137
153
  child.on("error", (error) => {
138
- portForwardProcessesRef.current.delete(id);
139
- dispatch({ type: "removeActivePortForward", id });
154
+ reportFailure();
140
155
  toastError(error);
141
156
  dispatch({
142
157
  type: "setStatusMessage",
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join, dirname } from "node:path";
3
4
 
4
5
  export interface PersistedAppState {
@@ -7,7 +8,7 @@ export interface PersistedAppState {
7
8
  }
8
9
 
9
10
  function configPath(): string {
10
- const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
11
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir();
11
12
  return join(home, ".config", "openk8s", "state.json");
12
13
  }
13
14
 
@@ -23,9 +24,12 @@ export function loadPersistedState(): PersistedAppState {
23
24
 
24
25
  const data = parsed as Record<string, unknown>;
25
26
 
27
+ const isNonEmptyString = (value: unknown): value is string =>
28
+ typeof value === "string" && value.length > 0;
29
+
26
30
  return {
27
- activeContext: typeof data.activeContext === "string" ? data.activeContext : undefined,
28
- activeNamespace: typeof data.activeNamespace === "string" ? data.activeNamespace : undefined,
31
+ activeContext: isNonEmptyString(data.activeContext) ? data.activeContext : undefined,
32
+ activeNamespace: isNonEmptyString(data.activeNamespace) ? data.activeNamespace : undefined,
29
33
  };
30
34
  } catch {
31
35
  return {};
@@ -0,0 +1,31 @@
1
+ import { RGBA, SyntaxStyle } from "@opentui/core";
2
+
3
+ const C = {
4
+ property: "#7aa2f7",
5
+ string: "#9ece6a",
6
+ number: "#ff9e64",
7
+ boolean: "#ff9e64",
8
+ constant: "#ff9e64",
9
+ comment: "#4e5f88",
10
+ label: "#bb9af7",
11
+ type: "#f7768e",
12
+ attribute: "#7dcfff",
13
+ punctuation: "#89ddff",
14
+ default: "#d4e1f7",
15
+ };
16
+
17
+ export const yamlSyntaxStyle = SyntaxStyle.fromStyles({
18
+ property: { fg: RGBA.fromHex(C.property) },
19
+ string: { fg: RGBA.fromHex(C.string) },
20
+ number: { fg: RGBA.fromHex(C.number) },
21
+ boolean: { fg: RGBA.fromHex(C.boolean) },
22
+ "constant.builtin": { fg: RGBA.fromHex(C.constant) },
23
+ comment: { fg: RGBA.fromHex(C.comment), dim: true },
24
+ label: { fg: RGBA.fromHex(C.label) },
25
+ type: { fg: RGBA.fromHex(C.type) },
26
+ attribute: { fg: RGBA.fromHex(C.attribute) },
27
+ "punctuation.delimiter": { fg: RGBA.fromHex(C.punctuation) },
28
+ "punctuation.bracket": { fg: RGBA.fromHex(C.punctuation) },
29
+ "punctuation.special": { fg: RGBA.fromHex(C.punctuation) },
30
+ default: { fg: RGBA.fromHex(C.default) },
31
+ });
package/src/app/theme.ts CHANGED
@@ -26,7 +26,8 @@ export const ROW_SELECTED = "#152040";
26
26
  export const ROW_SELECTED_ALT = "#111c38";
27
27
 
28
28
  // ── Text ──────────────────────────────────────────────────────────────────────
29
- export const TEXT_MUTED = TextAttributes.DIM;
29
+ // Note: ATTR_DIM is a TextAttributes value (not a color); use it via the `attributes` prop.
30
+ export const ATTR_DIM = TextAttributes.DIM;
30
31
  export const TEXT_PRIMARY = "#d4e1f7";
31
32
  export const TEXT_SUBTLE = "#6474a0";
32
33
  export const FILTER_BACKGROUND = "#090e1a";
@@ -42,8 +43,6 @@ export const ACCENT = "#5b8edb";
42
43
  export const KEY_HINT = "#5b8edb";
43
44
 
44
45
  // ── YAML colorizer ────────────────────────────────────────────────────────────
45
- export const YAML_KEY = "#7aa2f7";
46
- export const YAML_VALUE = "#9ece6a";
47
46
  export const YAML_COMMENT = "#4e5f88";
48
47
 
49
48
  // ── Unicode glyphs ────────────────────────────────────────────────────────────