openk8s 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +5 -2
  2. package/src/app/__tests__/app-state.test.ts +376 -0
  3. package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
  4. package/src/app/__tests__/utils.test.ts +358 -0
  5. package/src/app/app-actions.ts +262 -0
  6. package/src/app/app-state.ts +4 -262
  7. package/src/app/app.tsx +22 -170
  8. package/src/app/components/detail-sections.tsx +131 -0
  9. package/src/app/components/footer.tsx +52 -0
  10. package/src/app/components/header.tsx +37 -0
  11. package/src/app/components/inspector-tokens.ts +93 -0
  12. package/src/app/components/inspector.tsx +3 -239
  13. package/src/app/components/resource-rows.tsx +5 -0
  14. package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
  15. package/src/app/hooks/keyboard/global-handlers.ts +134 -0
  16. package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
  17. package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
  18. package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
  19. package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
  20. package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
  21. package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
  22. package/src/app/hooks/use-app-keyboard.ts +56 -621
  23. package/src/app/hooks/use-app-side-effects.ts +1 -1
  24. package/src/app/hooks/use-clipboard.ts +1 -1
  25. package/src/app/hooks/use-data-fetching.ts +2 -9
  26. package/src/app/hooks/use-log-stream.ts +1 -1
  27. package/src/app/hooks/use-port-forward.ts +1 -1
  28. package/src/app/use-footer-hints.ts +107 -0
  29. package/src/index.tsx +4 -0
  30. package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
  31. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
  32. package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
  33. package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
  34. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
  35. package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
  36. package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
  37. package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
  38. package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
  39. package/src/lib/k8s/resource-detail-builder.ts +22 -502
  40. package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
  41. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
  42. package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
  43. package/src/lib/kubectl/kubectl-helpers.ts +246 -0
  44. package/src/lib/kubectl/kubectl-service.ts +77 -565
  45. package/src/lib/kubectl/kubectl-types.ts +248 -0
  46. package/src/lib/kubectl/metrics-utils.ts +33 -0
  47. package/src/lib/kubectl/spawn-utils.ts +27 -0
@@ -1,29 +1,23 @@
1
1
  import { useKeyboard, useRenderer } from "@opentui/react";
2
2
  import type { ScrollBoxRenderable } from "@opentui/core";
3
- import { unlinkSync, writeFileSync } from "node:fs";
4
- import { spawn } from "node:child_process";
5
3
  import type { ChildProcess } from "node:child_process";
6
4
 
7
- import type { AppAction } from "../app-state";
5
+ import type { AppAction } from "../app-actions";
8
6
  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";
7
+ import type { ActivePortForward, NotificationTone, ResourceDetail, ResourceKind, ResourceListItem, ResourceRef } from "../../lib/k8s/types";
17
8
  import type { LogsDialogActions } from "../components/overlays";
18
9
  import type { StartPortForwardLifecycleOptions } from "../utils";
19
- import { buildPortForwardEntries } from "../components/overlays";
20
- import {
21
- deleteStatusMessage,
22
- parseLocalPort,
23
- statusLine,
24
- } from "../utils";
25
10
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
26
- import { INSPECTOR_TABS } from "../components/inspector";
11
+
12
+ import { handleCtrlC } from "./keyboard/global-handlers";
13
+ import { handleGlobalHotkeys } from "./keyboard/global-handlers";
14
+ import { handleFilterKeys } from "./keyboard/filter-handlers";
15
+ import { handlePaneNavigation } from "./keyboard/navigation-handlers";
16
+ import { handleGenericEscape, handleOverlayNavigation, handleDeleteConfirm, handleContainerPicker } from "./keyboard/overlay-handlers";
17
+ import { handleLogsKeys } from "./keyboard/logs-handlers";
18
+ import { handlePortForwardKeys } from "./keyboard/port-forward-handlers";
19
+ import { handleHelmRollbackKeys, handleHelmUpgradeKeys } from "./keyboard/helm-handlers";
20
+ import { handleShellKeys, handleEditKeys, handleScaleKeys, handleRolloutRestartKeys } from "./keyboard/shell-edit-handlers";
27
21
 
28
22
  type Renderer = ReturnType<typeof useRenderer>;
29
23
 
@@ -68,656 +62,97 @@ export function useAppKeyboard(
68
62
  logsActionsRef: React.MutableRefObject<LogsDialogActions | null>,
69
63
  ) {
70
64
  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
- }
65
+ if (handleCtrlC(key, renderer)) return;
81
66
 
82
- if (filterMode && key.name === "escape") {
83
- setFilterMode(false);
84
- return;
67
+ // Logs overlay: special multi-level escape handling
68
+ if (state.overlay === "logs") {
69
+ if (handleLogsKeys(key, state, dispatch, logsInputMode, logsInputValue, logsSearchMode,
70
+ setLogsInputMode, setLogsInputValue, setLogsSearchMode, setLogsSearchText,
71
+ setOverlayIndex, logsScrollRef, logsActionsRef, activeDetail?.containers)) {
72
+ return;
73
+ }
85
74
  }
86
75
 
87
- // Port-forward overlay: ESC closes; Up/Down navigate port list; Enter starts; X stops
76
+ // Port-forward overlay
88
77
  if (state.overlay === "port-forward") {
89
- if (key.name === "escape") {
90
- dispatch({ type: "setOverlay", overlay: undefined });
78
+ if (handlePortForwardKeys(key, state, dispatch, overlayIndex, portForwardLocalPort,
79
+ setOverlayIndex, setPortForwardLocalPort, activeResourceRef, selectedKind,
80
+ activeDetail, selectedResourcePortForwards, stopPortForward, startPortForward)) {
91
81
  return;
92
82
  }
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
83
  }
130
84
 
131
- // Scale overlay: Enter confirms, everything else is swallowed
85
+ // Scale overlay
132
86
  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
- }
87
+ if (handleScaleKeys(key, state, dispatch, activeResourceRef, selectedKind,
88
+ scaleReplicasInput, kubectl, toastError, setManualRefreshNonce)) {
224
89
  return;
225
90
  }
226
91
  }
227
92
 
228
- // Helm rollback overlay: Enter confirms, ESC closes
93
+ // Helm rollback overlay
229
94
  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 });
95
+ if (handleHelmRollbackKeys(key, state, dispatch, activeResourceRef, kubectl,
96
+ toast, toastError, setManualRefreshNonce)) {
97
+ return;
253
98
  }
254
- return;
255
99
  }
256
100
 
257
- if (key.name === "escape") {
258
- if (state.overlay) {
259
- dispatch({ type: "setOverlay", overlay: undefined });
260
- }
101
+ // Generic escape
102
+ if (handleGenericEscape(key, state, dispatch)) {
261
103
  return;
262
104
  }
263
105
 
264
- // Generic list-overlay navigation (cluster-switcher, namespace-switcher, container-picker)
106
+ // Generic overlay navigation
265
107
  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
- }
108
+ if (handleOverlayNavigation(key, state, dispatch, overlayIndex, setOverlayIndex)) return;
284
109
 
285
110
  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;
111
+ if (handleDeleteConfirm(key, state, dispatch, selectedKind, deleteTargets, kubectl, toastError, setManualRefreshNonce)) return;
314
112
  }
315
113
 
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 });
114
+ if (state.overlay === "container-picker") {
115
+ if (handleContainerPicker(key, state, dispatch, overlayIndex, activeDetail?.containers)) return;
341
116
  }
342
- return;
343
- }
344
117
 
345
- // Filter mode input
346
- if (state.activePane === "resources" && (key.sequence === "/" || key.name === "slash")) {
347
- setFilterMode(true);
348
118
  return;
349
119
  }
350
120
 
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
- }
121
+ // Filter mode
122
+ if (handleFilterKeys(key, filterMode, state.activePane, dispatch, setFilterMode)) return;
369
123
 
370
124
  // 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
- }
125
+ if (handleGlobalHotkeys(key, state, dispatch, activeResourceRef, selectedKind, activeDetail,
126
+ selectedResourcePortForwards, deleteTargets, visibleSelectedResourceName,
127
+ setOverlayIndex, openPortForwardOverlay, toast, copyToClipboard, setManualRefreshNonce, setScaleReplicasInput)) {
401
128
  return;
402
129
  }
403
130
 
404
- if (key.name === "v") {
405
- dispatch({ type: "setInspectorTab", tab: "events" });
131
+ // Helm upgrade
132
+ if (handleHelmUpgradeKeys(key, state, dispatch, renderer, activeResourceRef, selectedKind,
133
+ activeDetail, kubectl, toast, toastError, setManualRefreshNonce)) {
406
134
  return;
407
135
  }
408
136
 
409
- if (key.name === "i") {
410
- dispatch({ type: "setInspectorTab", tab: "describe" });
137
+ // Shell
138
+ if (handleShellKeys(key, state, dispatch, renderer, activeResourceRef, selectedKind,
139
+ kubectl, toastError, setManualRefreshNonce)) {
411
140
  return;
412
141
  }
413
142
 
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" });
143
+ // Edit
144
+ if (handleEditKeys(key, state, dispatch, renderer, activeResourceRef, selectedKind,
145
+ activeDetail, kubectl, toast, toastError, setManualRefreshNonce)) {
418
146
  return;
419
147
  }
420
148
 
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
- }
149
+ // Rollout restart
150
+ if (handleRolloutRestartKeys(key, state, dispatch, activeResourceRef, selectedKind,
151
+ kubectl, toastError, setManualRefreshNonce)) {
637
152
  return;
638
153
  }
639
154
 
640
155
  // 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
- }
156
+ if (handlePaneNavigation(key, state, dispatch, filteredResources, visibleSelectedResourceName)) return;
722
157
  });
723
158
  }