openk8s 0.0.1 → 1.0.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 (35) hide show
  1. package/README.md +194 -40
  2. package/bin/openk8s.js +2 -0
  3. package/package.json +52 -6
  4. package/src/app/app-state.ts +461 -0
  5. package/src/app/app.tsx +708 -0
  6. package/src/app/components/inspector.tsx +449 -0
  7. package/src/app/components/kind-rows.tsx +66 -0
  8. package/src/app/components/notification-tray.tsx +59 -0
  9. package/src/app/components/overlays/delete-confirm-overlay.tsx +79 -0
  10. package/src/app/components/overlays/helm-rollback-overlay.tsx +86 -0
  11. package/src/app/components/overlays/index.ts +12 -0
  12. package/src/app/components/overlays/logs-dialog.tsx +303 -0
  13. package/src/app/components/overlays/port-forward-overlay.tsx +184 -0
  14. package/src/app/components/overlays/scale-dialog.tsx +96 -0
  15. package/src/app/components/overlays/select-overlay.tsx +68 -0
  16. package/src/app/components/overlays/shared.tsx +18 -0
  17. package/src/app/components/port-forwards-tray.tsx +57 -0
  18. package/src/app/components/resource-rows.tsx +120 -0
  19. package/src/app/hooks/use-app-keyboard.ts +723 -0
  20. package/src/app/hooks/use-app-side-effects.ts +39 -0
  21. package/src/app/hooks/use-clipboard.ts +54 -0
  22. package/src/app/hooks/use-data-fetching.ts +366 -0
  23. package/src/app/hooks/use-log-stream.ts +113 -0
  24. package/src/app/hooks/use-port-forward.ts +149 -0
  25. package/src/app/persistence.ts +44 -0
  26. package/src/app/theme.ts +95 -0
  27. package/src/app/use-polling-tick.ts +27 -0
  28. package/src/app/utils.ts +274 -0
  29. package/src/index.tsx +8 -0
  30. package/src/lib/k8s/k8s-format.ts +42 -0
  31. package/src/lib/k8s/resource-detail-builder.ts +545 -0
  32. package/src/lib/k8s/resource-parser.ts +308 -0
  33. package/src/lib/k8s/types.ts +164 -0
  34. package/src/lib/kubectl/kubectl-service.ts +1116 -0
  35. package/src/lib/kubectl/spawn-utils.ts +81 -0
@@ -0,0 +1,723 @@
1
+ import { useKeyboard, useRenderer } from "@opentui/react";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import { unlinkSync, writeFileSync } from "node:fs";
4
+ import { spawn } from "node:child_process";
5
+ import type { ChildProcess } from "node:child_process";
6
+
7
+ import type { AppAction } from "../app-state";
8
+ import type { AppState } from "../../lib/k8s/types";
9
+ import type {
10
+ ActivePortForward,
11
+ NotificationTone,
12
+ ResourceDetail,
13
+ ResourceKind,
14
+ ResourceListItem,
15
+ ResourceRef,
16
+ } from "../../lib/k8s/types";
17
+ import type { LogsDialogActions } from "../components/overlays";
18
+ import type { StartPortForwardLifecycleOptions } from "../utils";
19
+ import { buildPortForwardEntries } from "../components/overlays";
20
+ import {
21
+ deleteStatusMessage,
22
+ parseLocalPort,
23
+ statusLine,
24
+ } from "../utils";
25
+ import { KubectlService } from "../../lib/kubectl/kubectl-service";
26
+ import { INSPECTOR_TABS } from "../components/inspector";
27
+
28
+ type Renderer = ReturnType<typeof useRenderer>;
29
+
30
+ export function useAppKeyboard(
31
+ state: AppState,
32
+ dispatch: React.Dispatch<AppAction>,
33
+ renderer: Renderer,
34
+ filteredResources: ResourceListItem[],
35
+ visibleSelectedResourceName: string | undefined,
36
+ activeDetail: ResourceDetail | undefined,
37
+ activeResourceRef: ResourceRef | undefined,
38
+ activeLogsRef: { kind: string; name: string; namespaced: boolean } | undefined,
39
+ selectedKind: ResourceKind | undefined,
40
+ deleteTargets: ResourceListItem[],
41
+ selectedResourcePortForwards: ActivePortForward[],
42
+ portForwardProcessesRef: React.MutableRefObject<Map<string, ChildProcess>>,
43
+ kubectl: KubectlService,
44
+ filterMode: boolean,
45
+ overlayIndex: number,
46
+ portForwardLocalPort: string,
47
+ scaleReplicasInput: string,
48
+ logsInputMode: "tail" | "since" | undefined,
49
+ logsInputValue: string,
50
+ logsSearchMode: boolean,
51
+ setOverlayIndex: React.Dispatch<React.SetStateAction<number>>,
52
+ setFilterMode: React.Dispatch<React.SetStateAction<boolean>>,
53
+ setPortForwardLocalPort: React.Dispatch<React.SetStateAction<string>>,
54
+ setScaleReplicasInput: React.Dispatch<React.SetStateAction<string>>,
55
+ setLogsInputMode: React.Dispatch<React.SetStateAction<"tail" | "since" | undefined>>,
56
+ setLogsInputValue: React.Dispatch<React.SetStateAction<string>>,
57
+ setLogsSearchMode: React.Dispatch<React.SetStateAction<boolean>>,
58
+ setLogsSearchText: React.Dispatch<React.SetStateAction<string>>,
59
+ setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
60
+ stopPortForward: (id: string) => void,
61
+ startPortForward: (options: StartPortForwardLifecycleOptions) => void,
62
+ openPortForwardOverlay: () => void,
63
+ toast: (tone: NotificationTone, message: string) => void,
64
+ toastError: (error: unknown) => void,
65
+ copyToClipboard: (text: string, label?: string) => void,
66
+ handleToggleReveal: (id: string) => void,
67
+ logsScrollRef: React.MutableRefObject<ScrollBoxRenderable | null>,
68
+ logsActionsRef: React.MutableRefObject<LogsDialogActions | null>,
69
+ ) {
70
+ useKeyboard((key) => {
71
+ // Exit immediately on Ctrl+C — one press, no ambiguity
72
+ if (key.ctrl && key.name === "c") {
73
+ renderer.destroy();
74
+ return;
75
+ }
76
+
77
+ if (key.name === "tab") {
78
+ dispatch({ type: "cyclePane", direction: key.shift ? -1 : 1 });
79
+ return;
80
+ }
81
+
82
+ if (filterMode && key.name === "escape") {
83
+ setFilterMode(false);
84
+ return;
85
+ }
86
+
87
+ // Port-forward overlay: ESC closes; Up/Down navigate port list; Enter starts; X stops
88
+ if (state.overlay === "port-forward") {
89
+ if (key.name === "escape") {
90
+ dispatch({ type: "setOverlay", overlay: undefined });
91
+ return;
92
+ }
93
+ const pfEntries = buildPortForwardEntries(activeDetail?.portForwards ?? [], selectedResourcePortForwards);
94
+ if (key.name === "up" || key.name === "k") {
95
+ const newIndex = Math.max(0, overlayIndex - 1);
96
+ setOverlayIndex(newIndex);
97
+ const entry = pfEntries[newIndex];
98
+ if (entry && !entry.activeForward) {
99
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
100
+ }
101
+ return;
102
+ }
103
+ if (key.name === "down" || key.name === "j") {
104
+ const newIndex = Math.min(Math.max(0, pfEntries.length - 1), overlayIndex + 1);
105
+ setOverlayIndex(newIndex);
106
+ const entry = pfEntries[newIndex];
107
+ if (entry && !entry.activeForward) {
108
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
109
+ }
110
+ return;
111
+ }
112
+ const selectedEntry = pfEntries[overlayIndex];
113
+ if (key.name === "x" && selectedEntry?.activeForward) {
114
+ stopPortForward(selectedEntry.activeForward.id);
115
+ return;
116
+ }
117
+ if (key.name === "return" && activeResourceRef && selectedKind && state.activeContext && selectedEntry && !selectedEntry.activeForward) {
118
+ const localPort = parseLocalPort(portForwardLocalPort) ?? selectedEntry.suggestedLocalPort;
119
+ startPortForward({
120
+ context: state.activeContext,
121
+ namespace: state.activeNamespace,
122
+ target: activeResourceRef,
123
+ namespaced: selectedKind.namespaced,
124
+ localPort,
125
+ remotePort: selectedEntry.remotePort,
126
+ });
127
+ }
128
+ return;
129
+ }
130
+
131
+ // Scale overlay: Enter confirms, everything else is swallowed
132
+ if (state.overlay === "scale") {
133
+ if (key.name === "return" && activeResourceRef && selectedKind && state.activeContext) {
134
+ const replicas = Number.parseInt(scaleReplicasInput, 10);
135
+
136
+ if (!Number.isNaN(replicas) && replicas >= 0) {
137
+ const context = state.activeContext;
138
+ const namespace = state.activeNamespace;
139
+ const namespaced = selectedKind.namespaced;
140
+ const target = activeResourceRef;
141
+
142
+ dispatch({ type: "setStatusMessage", message: `Scaling ${statusLine({ ref: target })} to ${replicas}` });
143
+ dispatch({ type: "setError", error: undefined });
144
+ dispatch({ type: "setOverlay", overlay: undefined });
145
+
146
+ void kubectl
147
+ .scaleResource({ context, namespace, resourceRef: target, namespaced, replicas })
148
+ .then(() => {
149
+ dispatch({
150
+ type: "setStatusMessage",
151
+ message: `Scaled ${statusLine({ ref: target })} to ${replicas}`,
152
+ });
153
+ setManualRefreshNonce((current) => current + 1);
154
+ })
155
+ .catch((error: unknown) => {
156
+ toastError(error);
157
+ dispatch({
158
+ type: "setStatusMessage",
159
+ message: `Scale failed for ${statusLine({ ref: target })}`,
160
+ });
161
+ });
162
+ }
163
+ }
164
+ return;
165
+ }
166
+
167
+ // Logs overlay: multi-level ESC, scroll, and option/search hotkeys
168
+ // This block must come BEFORE the generic escape so multi-level ESC works correctly.
169
+ if (state.overlay === "logs") {
170
+ if (key.name === "escape") {
171
+ if (logsInputMode !== undefined) {
172
+ setLogsInputMode(undefined);
173
+ setLogsInputValue("");
174
+ return;
175
+ }
176
+ if (logsSearchMode) {
177
+ setLogsSearchMode(false);
178
+ setLogsSearchText("");
179
+ return;
180
+ }
181
+ // Fall through to generic escape below, which closes the overlay
182
+ } else {
183
+ // All non-escape keys are handled then swallowed
184
+ if (!logsInputMode && !logsSearchMode) {
185
+ if (key.name === "up" || key.name === "k") {
186
+ logsScrollRef.current?.scrollBy(-3);
187
+ } else if (key.name === "down" || key.name === "j") {
188
+ logsScrollRef.current?.scrollBy(3);
189
+ } else if (key.name === "c") {
190
+ const containers = activeDetail?.containers ?? [];
191
+ if (containers.length > 1) {
192
+ setOverlayIndex(0);
193
+ dispatch({ type: "setOverlay", overlay: "container-picker" });
194
+ }
195
+ } else if (key.name === "p") {
196
+ dispatch({ type: "setLogsOptions", options: { ...state.logsOptions, previous: !state.logsOptions.previous } });
197
+ } else if (key.name === "t") {
198
+ setLogsInputMode("tail");
199
+ setLogsInputValue(String(state.logsOptions.tail));
200
+ } else if (key.name === "s") {
201
+ setLogsInputMode("since");
202
+ setLogsInputValue(state.logsOptions.since ?? "");
203
+ } else if (key.sequence === "/" || key.name === "slash") {
204
+ setLogsSearchMode(true);
205
+ } else if (key.name === "n" && !key.shift) {
206
+ logsActionsRef.current?.nextMatch();
207
+ } else if (key.shift && key.name === "n") {
208
+ logsActionsRef.current?.prevMatch();
209
+ }
210
+ } else if (logsInputMode && key.name === "return") {
211
+ if (logsInputMode === "tail") {
212
+ const tail = Number.parseInt(logsInputValue, 10);
213
+ if (!Number.isNaN(tail) && tail > 0) {
214
+ dispatch({ type: "setLogsOptions", options: { ...state.logsOptions, tail } });
215
+ }
216
+ } else if (logsInputMode === "since") {
217
+ dispatch({ type: "setLogsOptions", options: { ...state.logsOptions, since: logsInputValue || undefined } });
218
+ }
219
+ setLogsInputMode(undefined);
220
+ setLogsInputValue("");
221
+ } else if (logsSearchMode && key.name === "return") {
222
+ setLogsSearchMode(false);
223
+ }
224
+ return;
225
+ }
226
+ }
227
+
228
+ // Helm rollback overlay: Enter confirms, ESC closes
229
+ if (state.overlay === "helm-rollback") {
230
+ if (key.name === "return" && activeResourceRef && state.activeContext) {
231
+ const context = state.activeContext;
232
+ const namespace = state.activeNamespace;
233
+ const name = activeResourceRef.name;
234
+ const revisionStr = state.helmRollbackRevision.trim();
235
+ const revision = revisionStr ? Number.parseInt(revisionStr, 10) : undefined;
236
+
237
+ dispatch({ type: "setOverlay", overlay: undefined });
238
+ dispatch({
239
+ type: "setStatusMessage",
240
+ message: `Rolling back ${name}${revision !== undefined ? ` to r${revision}` : " to previous"}`,
241
+ });
242
+
243
+ void kubectl
244
+ .helmRollback({ context, namespace, name, revision })
245
+ .then(() => {
246
+ toast("success", `${name} rolled back${revision !== undefined ? ` to r${revision}` : ""}`);
247
+ setManualRefreshNonce((current) => current + 1);
248
+ })
249
+ .catch(toastError);
250
+ }
251
+ if (key.name === "escape") {
252
+ dispatch({ type: "setOverlay", overlay: undefined });
253
+ }
254
+ return;
255
+ }
256
+
257
+ if (key.name === "escape") {
258
+ if (state.overlay) {
259
+ dispatch({ type: "setOverlay", overlay: undefined });
260
+ }
261
+ return;
262
+ }
263
+
264
+ // Generic list-overlay navigation (cluster-switcher, namespace-switcher, container-picker)
265
+ if (state.overlay) {
266
+ const items: Array<unknown> =
267
+ state.overlay === "cluster-switcher"
268
+ ? state.contexts
269
+ : state.overlay === "namespace-switcher"
270
+ ? state.namespaces
271
+ : state.overlay === "container-picker"
272
+ ? (activeDetail?.containers ?? [])
273
+ : [];
274
+
275
+ if (key.name === "up") {
276
+ setOverlayIndex((current) => Math.max(0, current - 1));
277
+ return;
278
+ }
279
+
280
+ if (key.name === "down") {
281
+ setOverlayIndex((current) => Math.min(items.length - 1, current + 1));
282
+ return;
283
+ }
284
+
285
+ if (state.overlay === "delete-confirm") {
286
+ if (key.name === "return" && selectedKind && state.activeContext && deleteTargets.length > 0) {
287
+ const context = state.activeContext;
288
+ const namespace = state.activeNamespace;
289
+ const namespaced = selectedKind.namespaced;
290
+ const targets = deleteTargets.map((resource) => resource.ref);
291
+
292
+ dispatch({ type: "setStatusMessage", message: deleteStatusMessage(targets, "Deleting") });
293
+
294
+ void Promise.all(
295
+ targets.map((target) => kubectl.deleteResource({ context, namespace, resourceRef: target, namespaced })),
296
+ )
297
+ .then(() => {
298
+ dispatch({ type: "clearTransientViews" });
299
+ dispatch({ type: "setSelectedResourceDetail", detail: undefined });
300
+ dispatch({ type: "setSelectedResourceName", name: undefined });
301
+ dispatch({ type: "clearSelectedResources" });
302
+ dispatch({ type: "setError", error: undefined });
303
+ dispatch({ type: "setStatusMessage", message: deleteStatusMessage(targets, "Deleted") });
304
+ setManualRefreshNonce((current) => current + 1);
305
+ })
306
+ .catch((error: unknown) => {
307
+ toastError(error);
308
+ dispatch({ type: "setStatusMessage", message: deleteStatusMessage(targets, "Delete failed for") });
309
+ });
310
+
311
+ dispatch({ type: "setOverlay", overlay: undefined });
312
+ }
313
+ return;
314
+ }
315
+
316
+ if (key.name === "return") {
317
+ if (state.overlay === "container-picker") {
318
+ const container = (activeDetail?.containers ?? [])[overlayIndex];
319
+ if (container !== undefined) {
320
+ dispatch({ type: "setLogsContainer", container });
321
+ }
322
+ dispatch({ type: "setOverlay", overlay: "logs" });
323
+ return;
324
+ }
325
+
326
+ if (state.overlay === "cluster-switcher") {
327
+ const selectedContext = state.contexts[overlayIndex];
328
+ if (selectedContext) {
329
+ dispatch({ type: "setActiveContext", activeContext: selectedContext.name });
330
+ }
331
+ }
332
+
333
+ if (state.overlay === "namespace-switcher") {
334
+ const selectedNamespace = state.namespaces[overlayIndex];
335
+ if (selectedNamespace) {
336
+ dispatch({ type: "setActiveNamespace", namespace: selectedNamespace.name });
337
+ }
338
+ }
339
+
340
+ dispatch({ type: "setOverlay", overlay: undefined });
341
+ }
342
+ return;
343
+ }
344
+
345
+ // Filter mode input
346
+ if (state.activePane === "resources" && (key.sequence === "/" || key.name === "slash")) {
347
+ setFilterMode(true);
348
+ return;
349
+ }
350
+
351
+ if (filterMode && state.activePane === "resources") {
352
+ if (key.ctrl && key.name === "u") {
353
+ dispatch({ type: "setResourceFilter", value: "" });
354
+ return;
355
+ }
356
+
357
+ if (key.name === "return") {
358
+ setFilterMode(false);
359
+ return;
360
+ }
361
+
362
+ return;
363
+ }
364
+
365
+ if (state.activePane === "resources" && !filterMode && key.ctrl && key.name === "u") {
366
+ dispatch({ type: "setResourceFilter", value: "" });
367
+ return;
368
+ }
369
+
370
+ // Global hotkeys
371
+ if (key.name === "c") {
372
+ dispatch({ type: "setOverlay", overlay: "cluster-switcher" });
373
+ setOverlayIndex(Math.max(0, state.contexts.findIndex((context) => context.name === state.activeContext)));
374
+ return;
375
+ }
376
+
377
+ if (key.name === "n") {
378
+ dispatch({ type: "setOverlay", overlay: "namespace-switcher" });
379
+ setOverlayIndex(Math.max(0, state.namespaces.findIndex((namespace) => namespace.name === state.activeNamespace)));
380
+ return;
381
+ }
382
+
383
+ if (key.name === "r" && !key.shift) {
384
+ toast("info", "Refreshing current view");
385
+ setManualRefreshNonce((current) => current + 1);
386
+ return;
387
+ }
388
+
389
+ if (key.name === "s" && !key.shift) {
390
+ dispatch({ type: "setInspectorTab", tab: "summary" });
391
+ return;
392
+ }
393
+
394
+ if (key.name === "y") {
395
+ // y when already on yaml tab → copy full YAML to clipboard
396
+ if (state.inspectorTab === "yaml" && activeDetail?.yaml) {
397
+ copyToClipboard(activeDetail.yaml, "YAML");
398
+ } else {
399
+ dispatch({ type: "setInspectorTab", tab: "yaml" });
400
+ }
401
+ return;
402
+ }
403
+
404
+ if (key.name === "v") {
405
+ dispatch({ type: "setInspectorTab", tab: "events" });
406
+ return;
407
+ }
408
+
409
+ if (key.name === "i") {
410
+ dispatch({ type: "setInspectorTab", tab: "describe" });
411
+ return;
412
+ }
413
+
414
+ // B → Helm rollback overlay (helmreleases only)
415
+ if (key.name === "b" && activeResourceRef?.kind.toLowerCase() === "helmreleases" && state.activeContext) {
416
+ dispatch({ type: "setHelmRollbackRevision", value: "" });
417
+ dispatch({ type: "setOverlay", overlay: "helm-rollback" });
418
+ return;
419
+ }
420
+
421
+ // U → Helm upgrade --reuse-values (helmreleases only)
422
+ if (key.name === "u" && activeResourceRef?.kind.toLowerCase() === "helmreleases" && selectedKind && state.activeContext) {
423
+ const chartName = activeDetail?.helmChart ?? "";
424
+ if (!chartName) {
425
+ dispatch({ type: "setStatusMessage", message: "Chart name unavailable — load the detail pane first" });
426
+ return;
427
+ }
428
+ const context = state.activeContext;
429
+ const namespace = state.activeNamespace;
430
+ const target = activeResourceRef;
431
+
432
+ dispatch({ type: "setStatusMessage", message: `Upgrading ${target.name}` });
433
+ dispatch({ type: "setError", error: undefined });
434
+ renderer.suspend();
435
+
436
+ const upgradeChild = kubectl.helmUpgrade({ context, namespace, name: target.name, chart: chartName });
437
+ upgradeChild.on("close", (code) => {
438
+ renderer.resume();
439
+ if (code === 0) {
440
+ toast("success", `${target.name} upgraded`);
441
+ setManualRefreshNonce((current) => current + 1);
442
+ } else {
443
+ dispatch({ type: "setStatusMessage", message: `Upgrade failed for ${target.name}` });
444
+ }
445
+ });
446
+ upgradeChild.on("error", (err) => {
447
+ renderer.resume();
448
+ toastError(err);
449
+ });
450
+ return;
451
+ }
452
+
453
+ if (key.name === "l") {
454
+ if (activeResourceRef) {
455
+ if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
456
+ dispatch({ type: "setStatusMessage", message: "Logs not supported for Helm releases" });
457
+ return;
458
+ }
459
+ const containers = activeDetail?.containers ?? [];
460
+ if (containers.length > 1 && !state.logsContainer) {
461
+ setOverlayIndex(0);
462
+ dispatch({ type: "setOverlay", overlay: "container-picker" });
463
+ } else {
464
+ dispatch({ type: "setOverlay", overlay: "logs" });
465
+ }
466
+ dispatch({
467
+ type: "setStatusMessage",
468
+ message: `Showing logs for ${statusLine({ ref: activeResourceRef })}`,
469
+ });
470
+ }
471
+ return;
472
+ }
473
+
474
+ if (key.name === "f" && activeResourceRef && selectedKind && state.activeContext && ((activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0)) {
475
+ openPortForwardOverlay();
476
+ return;
477
+ }
478
+
479
+ if (key.name === "d" && selectedKind && state.activeContext && deleteTargets.length > 0) {
480
+ dispatch({ type: "setOverlay", overlay: "delete-confirm" });
481
+ return;
482
+ }
483
+
484
+ if (state.activePane === "resources" && !filterMode && key.name === "space" && visibleSelectedResourceName) {
485
+ dispatch({ type: "toggleSelectedResource", name: visibleSelectedResourceName });
486
+ return;
487
+ }
488
+
489
+ if (key.name === "x" && activeResourceRef && selectedKind && state.activeContext) {
490
+ if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
491
+ dispatch({ type: "setStatusMessage", message: "Shell not supported for Helm releases" });
492
+ return;
493
+ }
494
+ dispatch({ type: "setStatusMessage", message: `Opening shell in ${statusLine({ ref: activeResourceRef })}` });
495
+ dispatch({ type: "setError", error: undefined });
496
+ renderer.suspend();
497
+
498
+ const context = state.activeContext;
499
+ const namespace = state.activeNamespace;
500
+ const namespaced = selectedKind.namespaced;
501
+ const target = activeResourceRef;
502
+
503
+ const child = kubectl.startShell({ context, namespace, resourceRef: target, namespaced });
504
+ child.on("close", () => {
505
+ renderer.resume();
506
+ dispatch({ type: "setError", error: undefined });
507
+ dispatch({ type: "setStatusMessage", message: `Returned from shell in ${statusLine({ ref: target })}` });
508
+ setManualRefreshNonce((current) => current + 1);
509
+ });
510
+ child.on("error", (error) => {
511
+ renderer.resume();
512
+ toastError(error);
513
+ dispatch({ type: "setStatusMessage", message: `Shell failed for ${statusLine({ ref: target })}` });
514
+ });
515
+ return;
516
+ }
517
+
518
+ // E → edit resource (kubectl edit) or edit helm values ($EDITOR + helm upgrade)
519
+ if (key.name === "e" && !key.shift && activeResourceRef && selectedKind && state.activeContext) {
520
+ const context = state.activeContext;
521
+ const namespace = state.activeNamespace;
522
+ const namespaced = selectedKind.namespaced;
523
+ const target = activeResourceRef;
524
+ const isHelm = target.kind.toLowerCase() === "helmreleases";
525
+
526
+ if (isHelm) {
527
+ const chartName = activeDetail?.helmChart ?? "";
528
+ const values = activeDetail?.yaml ?? "";
529
+ const tmpPath = `/tmp/openk8s-${target.name}-${Date.now()}.yaml`;
530
+ const cleanupTmp = (): void => { try { unlinkSync(tmpPath); } catch { /* ignore */ } };
531
+
532
+ if (!chartName) {
533
+ dispatch({ type: "setStatusMessage", message: "Chart name unavailable — load the detail pane first" });
534
+ return;
535
+ }
536
+
537
+ try {
538
+ writeFileSync(tmpPath, values, "utf8");
539
+ } catch (err) {
540
+ toastError(err);
541
+ return;
542
+ }
543
+
544
+ dispatch({ type: "setStatusMessage", message: `Editing values for ${target.name}` });
545
+ dispatch({ type: "setError", error: undefined });
546
+ renderer.suspend();
547
+
548
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
549
+ const editorChild = spawn(editor, [tmpPath], { env: process.env, stdio: "inherit" });
550
+
551
+ editorChild.on("close", (code) => {
552
+ if (code === 0) {
553
+ kubectl
554
+ .helmUpgradeValues({ context, namespace, name: target.name, chart: chartName, valuesFile: tmpPath })
555
+ .then(() => {
556
+ renderer.resume();
557
+ toast("success", `${target.name} upgraded`);
558
+ setManualRefreshNonce((current) => current + 1);
559
+ })
560
+ .catch((err: unknown) => {
561
+ renderer.resume();
562
+ toastError(err);
563
+ })
564
+ .finally(cleanupTmp);
565
+ } else {
566
+ renderer.resume();
567
+ dispatch({ type: "setStatusMessage", message: "Edit cancelled" });
568
+ cleanupTmp();
569
+ }
570
+ });
571
+
572
+ editorChild.on("error", (err) => {
573
+ renderer.resume();
574
+ toastError(err);
575
+ cleanupTmp();
576
+ });
577
+ } else {
578
+ dispatch({ type: "setStatusMessage", message: `Editing ${statusLine({ ref: target })}` });
579
+ dispatch({ type: "setError", error: undefined });
580
+ renderer.suspend();
581
+
582
+ const editChild = kubectl.editResource({ context, namespace, resourceRef: target, namespaced });
583
+
584
+ editChild.on("close", (code) => {
585
+ renderer.resume();
586
+ if (code === 0) {
587
+ dispatch({ type: "setStatusMessage", message: `Saved ${statusLine({ ref: target })}` });
588
+ setManualRefreshNonce((current) => current + 1);
589
+ } else if (code !== null) {
590
+ dispatch({ type: "setStatusMessage", message: `Edit cancelled for ${statusLine({ ref: target })}` });
591
+ }
592
+ });
593
+
594
+ editChild.on("error", (err) => {
595
+ renderer.resume();
596
+ toastError(err);
597
+ dispatch({ type: "setStatusMessage", message: `Edit failed for ${statusLine({ ref: target })}` });
598
+ });
599
+ }
600
+ return;
601
+ }
602
+
603
+ // Shift+S → scale dialog
604
+ if (key.shift && key.name === "s" && activeResourceRef && selectedKind && state.activeContext && kubectl.canScale(activeResourceRef)) {
605
+ setScaleReplicasInput(activeDetail?.replicas !== undefined ? String(activeDetail.replicas) : "");
606
+ dispatch({ type: "setOverlay", overlay: "scale" });
607
+ return;
608
+ }
609
+
610
+ // Shift+R → rollout restart (direct, no overlay); always captured to provide feedback
611
+ if (key.shift && key.name === "r") {
612
+ if (activeResourceRef && selectedKind && state.activeContext && kubectl.canRolloutRestart(activeResourceRef)) {
613
+ const context = state.activeContext;
614
+ const namespace = state.activeNamespace;
615
+ const namespaced = selectedKind.namespaced;
616
+ const target = activeResourceRef;
617
+
618
+ dispatch({ type: "setStatusMessage", message: `Restarting ${statusLine({ ref: target })}` });
619
+ dispatch({ type: "setError", error: undefined });
620
+
621
+ void kubectl
622
+ .rolloutRestart({ context, namespace, resourceRef: target, namespaced })
623
+ .then(() => {
624
+ dispatch({ type: "setStatusMessage", message: `Restarted ${statusLine({ ref: target })}` });
625
+ setManualRefreshNonce((current) => current + 1);
626
+ })
627
+ .catch((error: unknown) => {
628
+ toastError(error);
629
+ dispatch({
630
+ type: "setStatusMessage",
631
+ message: `Rollout restart failed for ${statusLine({ ref: target })}`,
632
+ });
633
+ });
634
+ } else {
635
+ dispatch({ type: "setStatusMessage", message: "Rollout restart not supported for this resource" });
636
+ }
637
+ return;
638
+ }
639
+
640
+ // Pane navigation
641
+ if (state.activePane === "clusters") {
642
+ const currentIndex = Math.max(0, state.resourceKinds.findIndex((kind) => kind.name === state.selectedKind));
643
+
644
+ if ((key.name === "up" || key.name === "k") && currentIndex > 0) {
645
+ const nextKind = state.resourceKinds[currentIndex - 1];
646
+ if (nextKind) {
647
+ dispatch({ type: "setSelectedKind", kind: nextKind.name });
648
+ }
649
+ return;
650
+ }
651
+
652
+ if ((key.name === "down" || key.name === "j") && currentIndex < state.resourceKinds.length - 1) {
653
+ const nextKind = state.resourceKinds[currentIndex + 1];
654
+ if (nextKind) {
655
+ dispatch({ type: "setSelectedKind", kind: nextKind.name });
656
+ }
657
+ return;
658
+ }
659
+
660
+ if (key.name === "right") {
661
+ dispatch({ type: "setActivePane", pane: "resources" });
662
+ return;
663
+ }
664
+ }
665
+
666
+ if (state.activePane === "resources") {
667
+ const currentIndex = Math.max(
668
+ 0,
669
+ filteredResources.findIndex((resource) => resource.ref.name === visibleSelectedResourceName),
670
+ );
671
+
672
+ if ((key.name === "up" || key.name === "k") && currentIndex > 0) {
673
+ const nextResource = filteredResources[currentIndex - 1];
674
+ if (nextResource) {
675
+ dispatch({ type: "setSelectedResourceName", name: nextResource.ref.name });
676
+ }
677
+ return;
678
+ }
679
+
680
+ if ((key.name === "down" || key.name === "j") && currentIndex < filteredResources.length - 1) {
681
+ const nextResource = filteredResources[currentIndex + 1];
682
+ if (nextResource) {
683
+ dispatch({ type: "setSelectedResourceName", name: nextResource.ref.name });
684
+ }
685
+ return;
686
+ }
687
+
688
+ if (key.name === "left" || key.name === "h") {
689
+ dispatch({ type: "setActivePane", pane: "clusters" });
690
+ return;
691
+ }
692
+
693
+ if (key.name === "right") {
694
+ dispatch({ type: "setActivePane", pane: "inspector" });
695
+ return;
696
+ }
697
+ }
698
+
699
+ if (state.activePane === "inspector") {
700
+ // left/right (and h) navigate between tabs; left on first tab → back to resources pane
701
+ if (key.name === "left" || key.name === "h") {
702
+ const currentIndex = INSPECTOR_TABS.findIndex((tab) => tab.id === state.inspectorTab);
703
+ if (currentIndex > 0) {
704
+ const prevTab = INSPECTOR_TABS[currentIndex - 1];
705
+ if (prevTab) dispatch({ type: "setInspectorTab", tab: prevTab.id });
706
+ } else {
707
+ dispatch({ type: "setActivePane", pane: "resources" });
708
+ }
709
+ return;
710
+ }
711
+
712
+ if (key.name === "right") {
713
+ const currentIndex = INSPECTOR_TABS.findIndex((tab) => tab.id === state.inspectorTab);
714
+ const nextTab = INSPECTOR_TABS[currentIndex + 1];
715
+ if (nextTab) {
716
+ dispatch({ type: "setInspectorTab", tab: nextTab.id });
717
+ }
718
+ return;
719
+ }
720
+ // up/down are NOT intercepted here — they fall through to the focused scrollbox
721
+ }
722
+ });
723
+ }