openk8s 1.0.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -10
  3. package/src/app/__tests__/app-state.test.ts +48 -6
  4. package/src/app/__tests__/utils.test.ts +6 -15
  5. package/src/app/app-actions.ts +5 -0
  6. package/src/app/app-state.ts +11 -2
  7. package/src/app/app.tsx +57 -46
  8. package/src/app/components/detail-sections.tsx +2 -2
  9. package/src/app/components/footer.tsx +23 -30
  10. package/src/app/components/inspector.tsx +40 -83
  11. package/src/app/components/kind-rows.tsx +1 -1
  12. package/src/app/components/notification-card.tsx +145 -0
  13. package/src/app/components/notification-tray.tsx +19 -38
  14. package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
  15. package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
  16. package/src/app/components/overlays/help-overlay.tsx +171 -0
  17. package/src/app/components/overlays/index.ts +4 -1
  18. package/src/app/components/overlays/logs-dialog.tsx +47 -67
  19. package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
  20. package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
  21. package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
  22. package/src/app/components/overlays/scale-dialog.tsx +12 -67
  23. package/src/app/components/overlays/select-overlay.tsx +5 -14
  24. package/src/app/components/overlays/shared.tsx +85 -6
  25. package/src/app/components/resource-rows.tsx +1 -1
  26. package/src/app/constants.ts +24 -0
  27. package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
  28. package/src/app/hooks/keyboard/global-handlers.ts +30 -21
  29. package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
  30. package/src/app/hooks/keyboard/keys.ts +18 -0
  31. package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
  32. package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
  33. package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
  34. package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
  35. package/src/app/hooks/keyboard/shell-edit-handlers.ts +32 -16
  36. package/src/app/hooks/use-app-keyboard.ts +22 -2
  37. package/src/app/hooks/use-app-side-effects.ts +10 -7
  38. package/src/app/hooks/use-clipboard.ts +8 -10
  39. package/src/app/hooks/use-data-fetching.ts +10 -6
  40. package/src/app/hooks/use-log-stream.ts +8 -4
  41. package/src/app/hooks/use-notifications.ts +92 -0
  42. package/src/app/hooks/use-port-forward.ts +19 -4
  43. package/src/app/persistence.ts +7 -3
  44. package/src/app/syntax-theme.ts +31 -0
  45. package/src/app/theme.ts +2 -3
  46. package/src/app/use-footer-hints.ts +21 -16
  47. package/src/app/utils.ts +1 -9
  48. package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
  49. package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
  50. package/src/index.tsx +22 -2
  51. package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
  52. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +4 -6
  53. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  54. package/src/lib/k8s/detail-builders/event-builder.ts +18 -12
  55. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  56. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  57. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  58. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  59. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  60. package/src/lib/k8s/k8s-format.ts +14 -9
  61. package/src/lib/k8s/resource-detail-builder.ts +4 -7
  62. package/src/lib/k8s/resource-parser.ts +17 -2
  63. package/src/lib/k8s/types.ts +12 -4
  64. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  65. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  66. package/src/lib/kubectl/kubectl-service.ts +38 -54
  67. package/src/lib/kubectl/kubectl-types.ts +10 -1
  68. package/src/lib/kubectl/metrics-utils.ts +21 -7
  69. package/src/lib/kubectl/spawn-utils.ts +50 -11
  70. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  71. package/src/app/components/inspector-tokens.ts +0 -93
  72. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -1,5 +1,6 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
 
3
+ import { dump as dumpYaml } from "js-yaml";
3
4
  import { formatAge } from "../k8s/k8s-format";
4
5
  import { buildResourceDetail, type KubernetesResource } from "../k8s/resource-detail-builder";
5
6
  import type {
@@ -84,6 +85,24 @@ export type {
84
85
 
85
86
  const KUBECTL_TIMEOUT_MS = 15_000;
86
87
 
88
+ interface NamespacedOptions {
89
+ context: string;
90
+ namespace?: string | undefined;
91
+ namespaced?: boolean | undefined;
92
+ }
93
+
94
+ function buildContextArgs(options: NamespacedOptions): string[] {
95
+ const args = ["--context", options.context];
96
+ if (options.namespaced && options.namespace !== undefined) {
97
+ args.push("-n", options.namespace);
98
+ }
99
+ return args;
100
+ }
101
+
102
+ function buildHelmBaseArgs(options: { context: string; namespace: string }): string[] {
103
+ return ["--kube-context", options.context, "-n", options.namespace];
104
+ }
105
+
87
106
  export class KubectlService {
88
107
  async isAvailable(): Promise<boolean> {
89
108
  try {
@@ -201,25 +220,17 @@ export class KubectlService {
201
220
  }
202
221
 
203
222
  const jsonArgs = ["--context", options.context, "get", options.resourceKind.name, options.name];
204
- const yamlArgs = ["--context", options.context, "get", options.resourceKind.name, options.name];
205
- const describeArgs = ["--context", options.context, "describe", options.resourceKind.name, options.name];
206
223
 
207
224
  if (options.resourceKind.namespaced) {
208
225
  jsonArgs.push("-n", options.namespace);
209
- yamlArgs.push("-n", options.namespace);
210
- describeArgs.push("-n", options.namespace);
211
226
  }
212
227
 
213
228
  jsonArgs.push("-o", "json");
214
- yamlArgs.push("-o", "yaml");
215
229
 
216
- const [resource, yaml, describe] = await Promise.all([
217
- runJson<KubernetesResource>({ command: "kubectl", args: jsonArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
218
- runText({ command: "kubectl", args: yamlArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
219
- runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS }).catch(() => ""),
220
- ]);
230
+ const resource = await runJson<KubernetesResource>({ command: "kubectl", args: jsonArgs, timeoutMs: KUBECTL_TIMEOUT_MS });
231
+ const yamlText = dumpYaml(resource, { lineWidth: -1, noRefs: true });
221
232
 
222
- return buildResourceDetail({ resourceKind: options.resourceKind, resource, yaml, describe });
233
+ return buildResourceDetail({ resourceKind: options.resourceKind, resource, yaml: yamlText });
223
234
  }
224
235
 
225
236
  async listResourceEvents(options: ListResourceEventsOptions): Promise<EventItem[]> {
@@ -284,11 +295,7 @@ export class KubectlService {
284
295
  throw new Error(`Logs are not supported yet for ${options.resourceRef.kind}`);
285
296
  }
286
297
 
287
- const args = ["--context", options.context];
288
-
289
- if (options.namespaced) {
290
- args.push("-n", options.namespace);
291
- }
298
+ const args = buildContextArgs(options);
292
299
 
293
300
  const tail = options.logOptions?.tail ?? 120;
294
301
  const since = options.logOptions?.since;
@@ -307,7 +314,7 @@ export class KubectlService {
307
314
  }
308
315
 
309
316
  if (logTarget.includeAllPods) {
310
- args.push("--all-pods=true");
317
+ args.push("--all-containers=true");
311
318
  }
312
319
 
313
320
  if (options.container) {
@@ -325,11 +332,7 @@ export class KubectlService {
325
332
  throw new Error(`Scale is not supported yet for ${options.resourceRef.kind}`);
326
333
  }
327
334
 
328
- const args = ["--context", options.context];
329
-
330
- if (options.namespaced) {
331
- args.push("-n", options.namespace);
332
- }
335
+ const args = buildContextArgs(options);
333
336
 
334
337
  args.push("scale", normalizeResourceTarget(options.resourceRef), `--replicas=${options.replicas}`);
335
338
 
@@ -341,11 +344,7 @@ export class KubectlService {
341
344
  throw new Error(`Rollout restart is not supported yet for ${options.resourceRef.kind}`);
342
345
  }
343
346
 
344
- const args = ["--context", options.context];
345
-
346
- if (options.namespaced) {
347
- args.push("-n", options.namespace);
348
- }
347
+ const args = buildContextArgs(options);
349
348
 
350
349
  args.push("rollout", "restart", normalizeResourceTarget(options.resourceRef));
351
350
 
@@ -357,11 +356,7 @@ export class KubectlService {
357
356
  return this.uninstallHelmRelease(options);
358
357
  }
359
358
 
360
- const args = ["--context", options.context];
361
-
362
- if (options.namespaced) {
363
- args.push("-n", options.namespace);
364
- }
359
+ const args = buildContextArgs(options);
365
360
 
366
361
  args.push("delete", normalizeResourceTarget(options.resourceRef));
367
362
 
@@ -369,11 +364,7 @@ export class KubectlService {
369
364
  }
370
365
 
371
366
  startShell(options: StartShellOptions): ChildProcess {
372
- const args = ["--context", options.context];
373
-
374
- if (options.namespaced) {
375
- args.push("-n", options.namespace);
376
- }
367
+ const args = buildContextArgs(options);
377
368
 
378
369
  const kind = normalizeKind(options.resourceRef.kind);
379
370
  const target = kind === "pod" || kind === "pods" ? `pod/${options.resourceRef.name}` : normalizeResourceTarget(options.resourceRef);
@@ -384,11 +375,7 @@ export class KubectlService {
384
375
  }
385
376
 
386
377
  editResource(options: EditResourceOptions): ChildProcess {
387
- const args = ["--context", options.context];
388
-
389
- if (options.namespaced) {
390
- args.push("-n", options.namespace);
391
- }
378
+ const args = buildContextArgs(options);
392
379
 
393
380
  args.push("edit", normalizeResourceTarget(options.resourceRef));
394
381
 
@@ -529,7 +516,7 @@ export class KubectlService {
529
516
  const parsed = await runJson<SecretResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
530
517
  const encoded = parsed.data?.[options.key];
531
518
 
532
- if (!encoded) {
519
+ if (encoded === undefined) {
533
520
  throw new Error(`Key "${options.key}" not found in secret "${options.name}"`);
534
521
  }
535
522
 
@@ -547,7 +534,7 @@ export class KubectlService {
547
534
  }
548
535
 
549
536
  private async listHelmReleases(options: ListResourcesOptions): Promise<ResourceListItem[]> {
550
- const args = ["list", "--kube-context", options.context, "-n", options.namespace, "-o", "json"];
537
+ const args = ["list", ...buildHelmBaseArgs(options), "-o", "json"];
551
538
  const result = await runCommand({ command: "helm", args, timeoutMs: KUBECTL_TIMEOUT_MS });
552
539
  const items = JSON.parse(result.stdout) as HelmListItem[];
553
540
 
@@ -564,15 +551,17 @@ export class KubectlService {
564
551
  }
565
552
 
566
553
  private async getHelmReleaseDetail(options: GetResourceDetailOptions): Promise<ResourceDetail> {
567
- const baseArgs = ["--kube-context", options.context, "-n", options.namespace];
554
+ const baseArgs = buildHelmBaseArgs(options);
568
555
 
569
- const [statusResult, valuesResult] = await Promise.all([
556
+ const [statusResult, valuesResult, manifestResult] = await Promise.all([
570
557
  runCommand({ command: "helm", args: ["status", options.name, ...baseArgs, "-o", "json"], timeoutMs: KUBECTL_TIMEOUT_MS }),
571
558
  runCommand({ command: "helm", args: ["get", "values", options.name, ...baseArgs], timeoutMs: KUBECTL_TIMEOUT_MS }),
559
+ runCommand({ command: "helm", args: ["get", "manifest", options.name, ...baseArgs], timeoutMs: KUBECTL_TIMEOUT_MS }),
572
560
  ]);
573
561
 
574
562
  const status = JSON.parse(statusResult.stdout) as HelmStatusResponse;
575
- const yaml = valuesResult.stdout;
563
+ const helmValues = valuesResult.stdout;
564
+ const yaml = manifestResult.stdout;
576
565
 
577
566
  const overviewLines: string[] = [
578
567
  `name: ${status.name}`,
@@ -598,30 +587,25 @@ export class KubectlService {
598
587
  ...(notesLines.length > 0 ? [{ title: "Notes", lines: notesLines }] : []),
599
588
  ];
600
589
 
601
- const summaryLines = summarySections.flatMap((section) => [
602
- section.title,
603
- ...section.lines.map((line) => ` ${line}`),
604
- ]);
605
-
606
590
  return {
607
591
  ref: {
608
592
  kind: "helmreleases",
609
593
  name: status.name,
610
594
  namespace: status.namespace,
611
595
  },
612
- summaryLines,
613
596
  summarySections,
614
597
  yaml,
615
598
  replicas: undefined,
616
599
  portForwards: undefined,
617
600
  helmChart: status.chart.metadata.name,
601
+ helmValues,
618
602
  };
619
603
  }
620
604
 
621
605
  private async uninstallHelmRelease(options: DeleteResourceOptions): Promise<void> {
622
606
  await runCommand({
623
607
  command: "helm",
624
- args: ["uninstall", options.resourceRef.name, "--kube-context", options.context, "-n", options.namespace],
608
+ args: ["uninstall", options.resourceRef.name, ...buildHelmBaseArgs(options)],
625
609
  timeoutMs: KUBECTL_TIMEOUT_MS,
626
610
  });
627
611
  }
@@ -142,14 +142,23 @@ export interface GenericListItem {
142
142
  name?: string | undefined;
143
143
  namespace?: string | undefined;
144
144
  creationTimestamp?: string | undefined;
145
+ labels?: Record<string, string> | undefined;
145
146
  } | undefined;
146
147
  spec?: Record<string, unknown> | undefined;
147
148
  status?: Record<string, unknown> | undefined;
149
+ // Event-only fields
148
150
  type?: string | undefined;
149
151
  reason?: string | undefined;
150
152
  message?: string | undefined;
151
153
  lastTimestamp?: string | undefined;
152
154
  firstTimestamp?: string | undefined;
155
+ // RBAC fields
156
+ rules?: unknown[] | undefined;
157
+ roleRef?: Record<string, unknown> | undefined;
158
+ subjects?: unknown[] | undefined;
159
+ // ServiceAccount fields
160
+ secrets?: unknown[] | undefined;
161
+ imagePullSecrets?: unknown[] | undefined;
153
162
  }
154
163
 
155
164
  export interface GenericListResponse {
@@ -218,7 +227,7 @@ export interface HelmStatusResponse {
218
227
  }
219
228
 
220
229
  export interface ApiResourcesResponse {
221
- resources: ApiResourceItem[];
230
+ resources?: ApiResourceItem[] | undefined;
222
231
  }
223
232
 
224
233
  export interface ApiResourceItem {
@@ -8,16 +8,30 @@ export function parseCpuNano(value: string): number {
8
8
 
9
9
  export function parseMemBytes(value: string): number {
10
10
  if (!value) return 0;
11
- const units: Record<string, number> = {
12
- Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4,
13
- K: 1000, M: 1000 ** 2, G: 1000 ** 3, T: 1000 ** 4,
14
- };
15
- for (const [suffix, mult] of Object.entries(units)) {
11
+ // Order matters: multi-character (binary) suffixes must be checked before their
12
+ // single-letter decimal counterparts (e.g. "Ki" before "K").
13
+ const units: Array<[string, number]> = [
14
+ ["Ei", 1024 ** 6],
15
+ ["Pi", 1024 ** 5],
16
+ ["Ti", 1024 ** 4],
17
+ ["Gi", 1024 ** 3],
18
+ ["Mi", 1024 ** 2],
19
+ ["Ki", 1024],
20
+ ["E", 1000 ** 6],
21
+ ["P", 1000 ** 5],
22
+ ["T", 1000 ** 4],
23
+ ["G", 1000 ** 3],
24
+ ["M", 1000 ** 2],
25
+ ["K", 1000],
26
+ ];
27
+ for (const [suffix, mult] of units) {
16
28
  if (value.endsWith(suffix)) {
17
- return Number.parseFloat(value) * mult;
29
+ const n = Number.parseFloat(value);
30
+ return Number.isFinite(n) ? n * mult : 0;
18
31
  }
19
32
  }
20
- return Number.parseFloat(value) || 0;
33
+ const n = Number.parseFloat(value);
34
+ return Number.isFinite(n) ? n : 0;
21
35
  }
22
36
 
23
37
  export function formatCpu(nano: number): string {
@@ -1,14 +1,31 @@
1
1
  import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
2
+ import { parseKubectlError } from "./kubectl-helpers";
2
3
 
3
4
  const DEFAULT_TIMEOUT_MS = 15_000;
4
5
  let shutdownController: AbortController | undefined;
5
6
 
7
+ const persistentProcesses = new Set<ChildProcess>();
8
+
6
9
  export function setAbortController(controller: AbortController): void {
7
10
  shutdownController = controller;
8
11
  }
9
12
 
10
13
  export function abortRunningProcesses(): void {
11
14
  shutdownController?.abort();
15
+ for (const child of persistentProcesses) {
16
+ safeKill(child, "SIGTERM");
17
+ }
18
+ persistentProcesses.clear();
19
+ }
20
+
21
+ export function safeKill(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void {
22
+ try {
23
+ if (child.exitCode === null && !child.killed) {
24
+ child.kill(signal);
25
+ }
26
+ } catch {
27
+ // ESRCH: process already exited. Safe to ignore.
28
+ }
12
29
  }
13
30
 
14
31
  export interface RunCommandOptions {
@@ -28,23 +45,30 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
28
45
  const child = spawn(options.command, options.args, { env: process.env, stdio: ["pipe", "pipe", "pipe"] });
29
46
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
30
47
 
31
- const timeoutId = setTimeout(() => {
48
+ const onShutdown = (): void => {
32
49
  if (settled) {
33
50
  return;
34
51
  }
35
52
  settled = true;
36
- child.kill("SIGTERM");
37
- reject(new Error(`${options.command} ${options.args.slice(0, 4).join(" ")} timed out after ${timeoutMs / 1_000}s`));
38
- }, timeoutMs);
53
+ clearTimeout(timeoutId);
54
+ safeKill(child, "SIGTERM");
55
+ reject(new Error("Application shutting down"));
56
+ };
39
57
 
40
- const onShutdown = (): void => {
58
+ const timeoutId = setTimeout(() => {
41
59
  if (settled) {
42
60
  return;
43
61
  }
44
62
  settled = true;
45
- clearTimeout(timeoutId);
46
- child.kill("SIGTERM");
47
- reject(new Error("Application shutting down"));
63
+ removeAbortListener();
64
+ safeKill(child, "SIGTERM");
65
+ reject(new Error(`${options.command} ${options.args.slice(0, 4).join(" ")} timed out after ${timeoutMs / 1_000}s`));
66
+ }, timeoutMs);
67
+
68
+ const removeAbortListener = (): void => {
69
+ if (shutdownController) {
70
+ shutdownController.signal.removeEventListener("abort", onShutdown);
71
+ }
48
72
  };
49
73
 
50
74
  if (shutdownController) {
@@ -68,12 +92,15 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
68
92
 
69
93
  child.on("error", (error) => {
70
94
  clearTimeout(timeoutId);
95
+ removeAbortListener();
96
+ if (settled) return;
71
97
  settled = true;
72
98
  reject(error);
73
99
  });
74
100
 
75
101
  child.on("close", (code) => {
76
102
  clearTimeout(timeoutId);
103
+ removeAbortListener();
77
104
 
78
105
  if (settled) {
79
106
  return;
@@ -86,8 +113,7 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
86
113
  }
87
114
 
88
115
  settled = true;
89
- const message = stderr.trim() || `${options.command} ${options.args.join(" ")} failed`;
90
- reject(new Error(message));
116
+ reject(parseKubectlError(stderr, options.args, options.command));
91
117
  });
92
118
  });
93
119
  }
@@ -101,8 +127,21 @@ export function runJson<T>(options: RunCommandOptions): Promise<T> {
101
127
  }
102
128
 
103
129
  export function startPersistentProcess(options: { command: string; args: string[]; stdio?: StdioOptions }): ChildProcess {
104
- return spawn(options.command, options.args, {
130
+ const child = spawn(options.command, options.args, {
105
131
  env: process.env,
106
132
  stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
107
133
  });
134
+ persistentProcesses.add(child);
135
+ child.on("close", () => {
136
+ persistentProcesses.delete(child);
137
+ });
138
+ child.on("error", () => {
139
+ persistentProcesses.delete(child);
140
+ });
141
+ return child;
142
+ }
143
+
144
+ export function stopPersistentProcess(child: ChildProcess): void {
145
+ persistentProcesses.delete(child);
146
+ safeKill(child, "SIGINT");
108
147
  }
@@ -1,101 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { tokenizeYamlLine, tokenizeDescribeLine } from "../../components/inspector-tokens";
3
- import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../../theme";
4
-
5
- describe("tokenizeYamlLine", () => {
6
- test("empty line", () => {
7
- const tokens = tokenizeYamlLine("");
8
- expect(tokens[0]?.text).toBe(" ");
9
- });
10
-
11
- test("whitespace line", () => {
12
- const tokens = tokenizeYamlLine(" ");
13
- expect(tokens[0]?.text).toBe(" ");
14
- });
15
-
16
- test("comment line", () => {
17
- const tokens = tokenizeYamlLine("# this is a comment");
18
- expect(tokens).toHaveLength(1);
19
- expect(tokens[0]?.fg).toBe(YAML_COMMENT);
20
- });
21
-
22
- test("key: value line", () => {
23
- const tokens = tokenizeYamlLine("name: nginx");
24
- expect(tokens).toHaveLength(4);
25
- expect(tokens[0]?.text).toBe("");
26
- expect(tokens[1]?.text).toBe("name");
27
- expect(tokens[1]?.fg).toBe(YAML_KEY);
28
- expect(tokens[3]?.text).toBe("nginx");
29
- expect(tokens[3]?.fg).toBe(YAML_VALUE);
30
- });
31
-
32
- test("indented key: value", () => {
33
- const tokens = tokenizeYamlLine(" name: nginx");
34
- expect(tokens[0]?.text).toBe(" ");
35
- expect(tokens[1]?.text).toBe("name");
36
- expect(tokens[1]?.fg).toBe(YAML_KEY);
37
- });
38
-
39
- test("list item with value", () => {
40
- const tokens = tokenizeYamlLine("- name: nginx");
41
- expect(tokens[0]?.text).toBe("- ");
42
- expect(tokens[1]?.text).toBe("name");
43
- expect(tokens[1]?.fg).toBe(YAML_KEY);
44
- });
45
-
46
- test("bare list marker", () => {
47
- const tokens = tokenizeYamlLine("-");
48
- expect(tokens).toHaveLength(1);
49
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
50
- });
51
-
52
- test("key only (ends with colon)", () => {
53
- const tokens = tokenizeYamlLine("metadata:");
54
- expect(tokens).toHaveLength(3);
55
- expect(tokens[1]?.text).toBe("metadata");
56
- expect(tokens[1]?.fg).toBe(YAML_KEY);
57
- expect(tokens[2]?.text).toBe(":");
58
- });
59
-
60
- test("plain text fallthrough", () => {
61
- const tokens = tokenizeYamlLine("some random text");
62
- expect(tokens).toHaveLength(1);
63
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
64
- });
65
- });
66
-
67
- describe("tokenizeDescribeLine", () => {
68
- test("empty line", () => {
69
- const tokens = tokenizeDescribeLine("");
70
- expect(tokens[0]?.text).toBe(" ");
71
- });
72
-
73
- test("section header", () => {
74
- const tokens = tokenizeDescribeLine("Name: test-pod");
75
- expect(tokens).toHaveLength(3);
76
- expect(tokens[0]?.text).toBe("Name");
77
- expect(tokens[0]?.fg).toBe(YAML_KEY);
78
- expect(tokens[2]?.fg).toBe(YAML_VALUE);
79
- });
80
-
81
- test("indented key: value (double space after colon)", () => {
82
- const tokens = tokenizeDescribeLine(" Namespace: default");
83
- expect(tokens).toHaveLength(2);
84
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
85
- expect(tokens[1]?.fg).toBe(YAML_VALUE);
86
- });
87
-
88
- test("key: single value", () => {
89
- const tokens = tokenizeDescribeLine("key:value");
90
- expect(tokens).toHaveLength(3);
91
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
92
- expect(tokens[1]?.text).toBe(":");
93
- expect(tokens[2]?.text).toBe("value");
94
- });
95
-
96
- test("plain text", () => {
97
- const tokens = tokenizeDescribeLine("Some plain description text");
98
- expect(tokens).toHaveLength(1);
99
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
100
- });
101
- });
@@ -1,93 +0,0 @@
1
- import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../theme";
2
-
3
- export interface YamlToken {
4
- text: string;
5
- fg: string;
6
- }
7
-
8
- export function tokenizeDescribeLine(line: string): YamlToken[] {
9
- if (!line.trim()) {
10
- return [{ text: line || " ", fg: TEXT_SUBTLE }];
11
- }
12
-
13
- if (/^[A-Z][A-Za-z ]+:/.test(line.trimStart()) && !line.startsWith(" ")) {
14
- const colonIdx = line.indexOf(":");
15
- const key = line.slice(0, colonIdx);
16
- const rest = line.slice(colonIdx + 1);
17
- return [
18
- { text: key, fg: YAML_KEY },
19
- { text: ":", fg: TEXT_SUBTLE },
20
- { text: rest, fg: YAML_VALUE },
21
- ];
22
- }
23
-
24
- const colonSpaceIdx = line.indexOf(": ");
25
- if (colonSpaceIdx > 0) {
26
- const key = line.slice(0, colonSpaceIdx);
27
- const value = line.slice(colonSpaceIdx + 1);
28
- return [
29
- { text: key, fg: TEXT_SUBTLE },
30
- { text: value, fg: YAML_VALUE },
31
- ];
32
- }
33
-
34
- const colonIdx = line.indexOf(":");
35
- if (colonIdx > 0 && colonIdx < line.length - 1) {
36
- const key = line.slice(0, colonIdx);
37
- const value = line.slice(colonIdx + 1);
38
- return [
39
- { text: key, fg: TEXT_SUBTLE },
40
- { text: ":", fg: TEXT_SUBTLE },
41
- { text: value, fg: YAML_VALUE },
42
- ];
43
- }
44
-
45
- return [{ text: line, fg: TEXT_SUBTLE }];
46
- }
47
-
48
- export function tokenizeYamlLine(line: string): YamlToken[] {
49
- if (!line.trim()) {
50
- return [{ text: line || " ", fg: TEXT_SUBTLE }];
51
- }
52
-
53
- if (/^\s*#/.test(line)) {
54
- return [{ text: line, fg: YAML_COMMENT }];
55
- }
56
-
57
- const indentLen = line.length - line.trimStart().length;
58
- const indent = line.slice(0, indentLen);
59
- let rest = line.trimStart();
60
-
61
- let listMarker = "";
62
- if (rest.startsWith("- ")) {
63
- listMarker = "- ";
64
- rest = rest.slice(2);
65
- } else if (rest === "-") {
66
- return [{ text: line, fg: TEXT_SUBTLE }];
67
- }
68
-
69
- const colonSpaceIdx = rest.indexOf(": ");
70
- const isKeyOnly = rest === rest.trimEnd() && rest.endsWith(":");
71
-
72
- if (colonSpaceIdx !== -1) {
73
- const key = rest.slice(0, colonSpaceIdx);
74
- const value = rest.slice(colonSpaceIdx + 2);
75
- return [
76
- { text: indent + listMarker, fg: TEXT_SUBTLE },
77
- { text: key, fg: YAML_KEY },
78
- { text: ": ", fg: TEXT_SUBTLE },
79
- { text: value, fg: YAML_VALUE },
80
- ];
81
- }
82
-
83
- if (isKeyOnly) {
84
- const key = rest.slice(0, -1);
85
- return [
86
- { text: indent + listMarker, fg: TEXT_SUBTLE },
87
- { text: key, fg: YAML_KEY },
88
- { text: ":", fg: TEXT_SUBTLE },
89
- ];
90
- }
91
-
92
- return [{ text: line, fg: TEXT_SUBTLE }];
93
- }
@@ -1,57 +0,0 @@
1
- import { TextAttributes } from "@opentui/core";
2
-
3
- import { DANGER, GLYPHS, KEY_HINT, OVERLAY_SURFACE, PANEL_BORDER, TEXT_PRIMARY, TEXT_SUBTLE } from "../theme";
4
- import { activePortForwardLabel } from "../utils";
5
- import type { ActivePortForward } from "../../lib/k8s/types";
6
-
7
- export interface PortForwardsTrayProps {
8
- forwards: ActivePortForward[];
9
- onStop: (id: string) => void;
10
- }
11
-
12
- export function PortForwardsTray({ forwards, onStop }: PortForwardsTrayProps) {
13
- if (forwards.length === 0) {
14
- return undefined;
15
- }
16
-
17
- return (
18
- <box
19
- style={{
20
- border: true,
21
- borderColor: PANEL_BORDER,
22
- borderStyle: "rounded",
23
- backgroundColor: OVERLAY_SURFACE,
24
- paddingLeft: 1,
25
- paddingRight: 1,
26
- flexDirection: "column",
27
- }}
28
- >
29
- {/* Tray heading */}
30
- <text fg={KEY_HINT}>
31
- {GLYPHS.forward}
32
- <span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{" Port Forwards"}</span>
33
- </text>
34
- <box style={{ flexDirection: "column", width: "100%" }}>
35
- {forwards.map((forward) => {
36
- const isReady = forward.status === "ready";
37
- const statusDot = isReady ? GLYPHS.dot : GLYPHS.dotEmpty;
38
- const statusFg = isReady ? KEY_HINT : TEXT_SUBTLE;
39
-
40
- return (
41
- <box key={forward.id} style={{ flexDirection: "row", justifyContent: "space-between", width: "100%" }}>
42
- <text fg={isReady ? TEXT_PRIMARY : TEXT_SUBTLE}>
43
- <span fg={statusFg}>{statusDot}</span>
44
- {` ${activePortForwardLabel(forward)} `}
45
- <span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{forward.status}</span>
46
- </text>
47
- <text fg={DANGER} onMouseDown={() => onStop(forward.id)}>
48
- <span fg={DANGER}>{GLYPHS.stop}</span>
49
- {" stop"}
50
- </text>
51
- </box>
52
- );
53
- })}
54
- </box>
55
- </box>
56
- );
57
- }