@a9s/cli 1.0.7 → 1.0.9

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 (41) hide show
  1. package/dist/scripts/seed.js +202 -4
  2. package/dist/src/App.js +35 -3
  3. package/dist/src/components/AdvancedTextInput.js +3 -1
  4. package/dist/src/components/AutocompleteInput.js +3 -1
  5. package/dist/src/components/DetailPanel.js +3 -1
  6. package/dist/src/components/DiffViewer.js +3 -1
  7. package/dist/src/components/ErrorStatePanel.js +3 -1
  8. package/dist/src/components/HUD.js +3 -1
  9. package/dist/src/components/HelpPanel.js +6 -4
  10. package/dist/src/components/ModeBar.js +5 -8
  11. package/dist/src/components/Table/index.js +19 -26
  12. package/dist/src/components/TableSkeleton.js +3 -1
  13. package/dist/src/components/YankHelpPanel.js +3 -1
  14. package/dist/src/constants/commands.js +2 -1
  15. package/dist/src/constants/theme.js +608 -0
  16. package/dist/src/contexts/ThemeContext.js +13 -0
  17. package/dist/src/features/AppMainView.integration.test.js +1 -0
  18. package/dist/src/features/AppMainView.js +6 -4
  19. package/dist/src/hooks/useCommandRouter.js +5 -0
  20. package/dist/src/hooks/usePickerManager.js +35 -1
  21. package/dist/src/index.js +2 -1
  22. package/dist/src/services.js +2 -2
  23. package/dist/src/state/atoms.js +3 -0
  24. package/dist/src/utils/config.js +36 -0
  25. package/dist/src/views/dynamodb/adapter.js +313 -9
  26. package/dist/src/views/dynamodb/capabilities/detailCapability.js +94 -0
  27. package/dist/src/views/dynamodb/capabilities/yankCapability.js +6 -0
  28. package/dist/src/views/dynamodb/capabilities/yankOptions.js +69 -0
  29. package/dist/src/views/dynamodb/schema.js +18 -0
  30. package/dist/src/views/dynamodb/types.js +1 -0
  31. package/dist/src/views/dynamodb/utils.js +175 -0
  32. package/dist/src/views/iam/adapter.js +2 -1
  33. package/dist/src/views/route53/adapter.js +166 -9
  34. package/dist/src/views/route53/capabilities/detailCapability.js +63 -0
  35. package/dist/src/views/route53/capabilities/yankCapability.js +6 -0
  36. package/dist/src/views/route53/capabilities/yankOptions.js +58 -0
  37. package/dist/src/views/route53/schema.js +18 -0
  38. package/dist/src/views/route53/types.js +1 -0
  39. package/dist/src/views/s3/adapter.js +2 -1
  40. package/dist/src/views/secretsmanager/adapter.js +2 -1
  41. package/package.json +2 -1
@@ -33,6 +33,7 @@ function createPickerManager(active) {
33
33
  region: mkEntry("region"),
34
34
  profile: mkEntry("profile"),
35
35
  resource: mkEntry("resource"),
36
+ theme: mkEntry("theme"),
36
37
  activePicker: active,
37
38
  openPicker: (_id) => { },
38
39
  closeActivePicker: noop,
@@ -13,7 +13,9 @@ import { debugLog } from "../utils/debugLogger.js";
13
13
  import { revealSecretsAtom } from "../state/atoms.js";
14
14
  import { truncateSecretForTable } from "../utils/secretDisplay.js";
15
15
  import { getCellValue } from "../types.js";
16
+ import { useTheme } from "../contexts/ThemeContext.js";
16
17
  export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState, isLoading, filteredRows, uploadPending, uploadPreview, columns, selectedIndex, scrollOffset, filterText, adapter, termCols, tableHeight, headerMarkers, yankHelpOpen, yankOptions, yankHelpRow, panelScrollOffset, }) {
18
+ const THEME = useTheme();
17
19
  const revealSecrets = useAtomValue(revealSecretsAtom);
18
20
  // Format secret values for display ONLY - original rows (via filteredRows) stay unchanged for editing
19
21
  const displayRows = useMemo(() => {
@@ -65,7 +67,7 @@ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState
65
67
  error,
66
68
  ]);
67
69
  if (helpPanel.helpOpen) {
68
- return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: "blue", children: _jsx(HelpPanel, { title: "Keyboard Help", scopeLabel: "All modes reference", tabs: helpTabs, activeTab: helpPanel.helpTabIndex, terminalWidth: termCols, maxRows: helpPanel.helpVisibleRows, scrollOffset: helpPanel.helpScrollOffset }) }));
70
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.helpPanelBorderText, children: _jsx(HelpPanel, { title: "Keyboard Help", scopeLabel: "All modes reference", tabs: helpTabs, activeTab: helpPanel.helpTabIndex, terminalWidth: termCols, maxRows: helpPanel.helpVisibleRows, scrollOffset: helpPanel.helpScrollOffset }) }));
69
71
  }
70
72
  if (pickers.activePicker) {
71
73
  const ap = pickers.activePicker;
@@ -73,17 +75,17 @@ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState
73
75
  return (_jsx(Table, { rows: ap.filteredRows, columns: ap.columns, selectedIndex: ap.selectedIndex, filterText: ap.filter, terminalWidth: termCols, maxHeight: tableHeight, scrollOffset: ap.scrollOffset, contextLabel: ap.contextLabel }));
74
76
  }
75
77
  if (yankHelpOpen) {
76
- return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: "cyan", children: _jsx(YankHelpPanel, { options: yankOptions, row: yankHelpRow }) }));
78
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.yankPanelBorderText, children: _jsx(YankHelpPanel, { options: yankOptions, row: yankHelpRow }) }));
77
79
  }
78
80
  if (uploadPending) {
79
81
  // Overhead: border 2 + header 4 + separators 2 + DiffViewer header+divider 2 = 10
80
82
  const diffVisibleLines = Math.max(1, tableHeight - 10);
81
- return (_jsxs(Box, { width: "100%", borderStyle: "round", borderColor: "yellow", flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, paddingY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: "\u26A0 Overwrite Secret on AWS?" }), _jsx(Text, { color: "gray", children: "This will update the secret permanently." })] }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: "gray", children: uploadPreview ? (_jsx(DiffViewer, { oldValue: uploadPreview.old, newValue: uploadPreview.new, scrollOffset: panelScrollOffset, visibleLines: diffVisibleLines })) : (_jsx(Text, { color: "gray", children: "Loading preview..." })) }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: "gray", children: _jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to confirm or", " ", _jsx(Text, { bold: true, color: "red", children: "n" }), " ", "to cancel"] }) })] }));
83
+ return (_jsxs(Box, { width: "100%", borderStyle: "round", borderColor: THEME.upload.uploadBorderText, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, paddingY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: THEME.upload.uploadTitleText, children: "\u26A0 Overwrite Secret on AWS?" }), _jsx(Text, { color: THEME.upload.uploadSubtitleText, children: "This will update the secret permanently." })] }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: THEME.upload.uploadDiffDividerText, children: uploadPreview ? (_jsx(DiffViewer, { oldValue: uploadPreview.old, newValue: uploadPreview.new, scrollOffset: panelScrollOffset, visibleLines: diffVisibleLines })) : (_jsx(Text, { color: THEME.upload.uploadLoadingText, children: "Loading preview..." })) }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: THEME.upload.uploadConfirmPromptText, children: _jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: THEME.upload.uploadConfirmKeyText, children: "y" }), " ", "to confirm or", " ", _jsx(Text, { bold: true, color: THEME.upload.uploadCancelKeyText, children: "n" }), " ", "to cancel"] }) })] }));
82
84
  }
83
85
  if (describeState) {
84
86
  // Overhead: border 2 + title 1 + separator 1 + footer 2 = 6
85
87
  const detailVisibleLines = Math.max(1, tableHeight - 6);
86
- return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: "gray", children: _jsx(DetailPanel, { title: getCellValue(describeState.row.cells.name) ?? describeState.row.id, fields: describeState.fields ?? [], isLoading: describeState.loading, scrollOffset: panelScrollOffset, visibleLines: detailVisibleLines }) }));
88
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.detailPanelBorderText, children: _jsx(DetailPanel, { title: getCellValue(describeState.row.cells.name) ?? describeState.row.id, fields: describeState.fields ?? [], isLoading: describeState.loading, scrollOffset: panelScrollOffset, visibleLines: detailVisibleLines }) }));
87
89
  }
88
90
  if (isLoading) {
89
91
  return (_jsx(TableSkeleton, { columns: columns, terminalWidth: termCols, rows: 1, contextLabel: adapter.getContextLabel?.() ?? "" }));
@@ -8,6 +8,8 @@ export function parseCommand(input) {
8
8
  return { type: "openRegions" };
9
9
  if (command === "resources")
10
10
  return { type: "openResources" };
11
+ if (command === "theme")
12
+ return { type: "openThemePicker" };
11
13
  const regionMatch = command.match(/^(region|use-region)\s+([a-z0-9-]+)$/i);
12
14
  if (regionMatch?.[2]) {
13
15
  return { type: "setRegion", region: regionMatch[2].toLowerCase() };
@@ -37,6 +39,9 @@ export function useCommandRouter(args) {
37
39
  case "openResources":
38
40
  args.openResourcePicker();
39
41
  return;
42
+ case "openThemePicker":
43
+ args.openThemePicker();
44
+ return;
40
45
  case "setRegion":
41
46
  args.setSelectedRegion(parsed.region);
42
47
  return;
@@ -3,10 +3,12 @@ import { textCell } from "../types.js";
3
3
  import { usePickerState } from "./usePickerState.js";
4
4
  import { usePickerTable } from "./usePickerTable.js";
5
5
  import { SERVICE_REGISTRY } from "../services.js";
6
+ import { THEMES, THEME_LABELS } from "../constants/theme.js";
6
7
  export function usePickerManager({ tableHeight, availableRegions, availableProfiles, }) {
7
8
  const region = usePickerState();
8
9
  const profile = usePickerState();
9
10
  const resource = usePickerState();
11
+ const theme = usePickerState();
10
12
  const regionRows = useMemo(() => availableRegions.map((r) => ({
11
13
  id: r.name,
12
14
  cells: { region: textCell(r.name), description: textCell(r.description) },
@@ -25,6 +27,14 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
25
27
  },
26
28
  meta: {},
27
29
  })), []);
30
+ const themeRows = useMemo(() => Object.keys(THEMES).map((themeName) => ({
31
+ id: themeName,
32
+ cells: {
33
+ theme: textCell(THEME_LABELS[themeName]),
34
+ id: textCell(themeName),
35
+ },
36
+ meta: {},
37
+ })), []);
28
38
  const regionTable = usePickerTable({
29
39
  rows: regionRows,
30
40
  filterText: region.filter,
@@ -40,6 +50,11 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
40
50
  filterText: resource.filter,
41
51
  maxHeight: tableHeight,
42
52
  });
53
+ const themeTable = usePickerTable({
54
+ rows: themeRows,
55
+ filterText: theme.filter,
56
+ maxHeight: tableHeight,
57
+ });
43
58
  const regionColumns = [
44
59
  { key: "region", label: "Region" },
45
60
  { key: "description", label: "Description" },
@@ -52,6 +67,10 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
52
67
  { key: "resource", label: "Resource" },
53
68
  { key: "description", label: "Description" },
54
69
  ];
70
+ const themeColumns = [
71
+ { key: "theme", label: "Theme" },
72
+ { key: "id", label: "ID" },
73
+ ];
55
74
  const regionEntry = {
56
75
  id: "region",
57
76
  columns: regionColumns,
@@ -73,13 +92,22 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
73
92
  ...resource,
74
93
  ...resourceTable,
75
94
  };
95
+ const themeEntry = {
96
+ id: "theme",
97
+ columns: themeColumns,
98
+ contextLabel: "Select Theme",
99
+ ...theme,
100
+ ...themeTable,
101
+ };
76
102
  const activePicker = regionEntry.open
77
103
  ? regionEntry
78
104
  : profileEntry.open
79
105
  ? profileEntry
80
106
  : resourceEntry.open
81
107
  ? resourceEntry
82
- : null;
108
+ : themeEntry.open
109
+ ? themeEntry
110
+ : null;
83
111
  const getEntry = (id) => {
84
112
  switch (id) {
85
113
  case "region":
@@ -88,6 +116,8 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
88
116
  return profileEntry;
89
117
  case "resource":
90
118
  return resourceEntry;
119
+ case "theme":
120
+ return themeEntry;
91
121
  }
92
122
  };
93
123
  const openPicker = (id) => {
@@ -114,6 +144,9 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
114
144
  case "profile":
115
145
  handlers.onSelectProfile(activePicker.selectedRow.id);
116
146
  break;
147
+ case "theme":
148
+ handlers.onSelectTheme(activePicker.selectedRow.id);
149
+ break;
117
150
  }
118
151
  activePicker.closePicker();
119
152
  };
@@ -121,6 +154,7 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
121
154
  region: regionEntry,
122
155
  profile: profileEntry,
123
156
  resource: resourceEntry,
157
+ theme: themeEntry,
124
158
  activePicker,
125
159
  openPicker,
126
160
  closeActivePicker,
package/dist/src/index.js CHANGED
@@ -4,6 +4,7 @@ import { Command, Option } from "@commander-js/extra-typings";
4
4
  import { App } from "./App.js";
5
5
  import { SERVICE_REGISTRY } from "./services.js";
6
6
  import { withFullscreen } from "./utils/withFullscreen.js";
7
+ import { ThemeProvider } from "./contexts/ThemeContext.js";
7
8
  const SERVICE_IDS = Object.keys(SERVICE_REGISTRY);
8
9
  const program = new Command()
9
10
  .name("a9s")
@@ -17,7 +18,7 @@ program.parse();
17
18
  // opts() return type is fully inferred from addOption() calls via extra-typings
18
19
  const options = program.opts();
19
20
  void (async () => {
20
- const { instance, cleanup } = withFullscreen(_jsx(App, { initialService: options.service, endpointUrl: options.endpointUrl }));
21
+ const { instance, cleanup } = withFullscreen(_jsx(ThemeProvider, { children: _jsx(App, { initialService: options.service, endpointUrl: options.endpointUrl }) }));
21
22
  process.on("SIGINT", () => {
22
23
  cleanup();
23
24
  process.exit(0);
@@ -5,8 +5,8 @@ import { createIamServiceAdapter } from "./views/iam/adapter.js";
5
5
  import { createSecretsManagerServiceAdapter } from "./views/secretsmanager/adapter.js";
6
6
  export const SERVICE_REGISTRY = {
7
7
  s3: (endpointUrl, region) => createS3ServiceAdapter(endpointUrl, region),
8
- route53: (_endpointUrl, _region) => createRoute53ServiceAdapter(),
9
- dynamodb: (_endpointUrl, _region) => createDynamoDBServiceAdapter(),
8
+ route53: (_endpointUrl, region) => createRoute53ServiceAdapter(undefined, region),
9
+ dynamodb: (_endpointUrl, region) => createDynamoDBServiceAdapter(undefined, region),
10
10
  iam: (_endpointUrl, _region) => createIamServiceAdapter(),
11
11
  secretsmanager: (endpointUrl, region) => createSecretsManagerServiceAdapter(endpointUrl, region),
12
12
  };
@@ -1,4 +1,5 @@
1
1
  import { atom } from "jotai";
2
+ import { loadConfig } from "../utils/config.js";
2
3
  /** Persists across HMR / re-renders. Currently selected AWS service. */
3
4
  export const currentlySelectedServiceAtom = atom("s3");
4
5
  /** Current UI mode (navigate / search / command). */
@@ -25,3 +26,5 @@ export const adapterSessionAtom = atom((get) => {
25
26
  });
26
27
  /** Toggle state for revealing/hiding secret values. Persists across HMR. */
27
28
  export const revealSecretsAtom = atom(false);
29
+ /** Active UI theme name — initialized from ~/.config/a9s/config.json on startup. */
30
+ export const themeNameAtom = atom(loadConfig().theme ?? "monokai");
@@ -0,0 +1,36 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import * as YAML from "yaml";
5
+ import { z } from "zod";
6
+ import { THEMES } from "../constants/theme.js";
7
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "a9s");
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.yaml");
9
+ const THEME_NAMES = Object.keys(THEMES);
10
+ const A9sConfigSchema = z.object({
11
+ theme: z.enum(THEME_NAMES).optional(),
12
+ });
13
+ export function loadConfig() {
14
+ try {
15
+ const raw = YAML.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
16
+ return A9sConfigSchema.parse(raw);
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ export function saveConfig(update) {
23
+ try {
24
+ let existing = {};
25
+ try {
26
+ const raw = YAML.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
27
+ existing = A9sConfigSchema.parse(raw);
28
+ }
29
+ catch { }
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
31
+ fs.writeFileSync(CONFIG_PATH, YAML.stringify({ ...existing, ...update }));
32
+ }
33
+ catch {
34
+ // Best-effort — silently ignore write failures
35
+ }
36
+ }
@@ -1,22 +1,326 @@
1
1
  import { textCell } from "../../types.js";
2
- export function createDynamoDBServiceAdapter() {
3
- const getColumns = () => [{ key: "name", label: "Name" }];
2
+ import { runAwsJsonAsync } from "../../utils/aws.js";
3
+ import { atom } from "jotai";
4
+ import { getDefaultStore } from "jotai";
5
+ import { unwrapDynamoValue, formatBillingMode, formatDynamoValue, getDynamoType, extractPkValue, extractSkValue, } from "./utils.js";
6
+ import { createDynamoDBDetailCapability } from "./capabilities/detailCapability.js";
7
+ import { createDynamoDBYankCapability } from "./capabilities/yankCapability.js";
8
+ import { SERVICE_COLORS } from "../../constants/theme.js";
9
+ export const dynamoDBLevelAtom = atom({ kind: "tables" });
10
+ export const dynamoDBBackStackAtom = atom([]);
11
+ // Cache for table descriptions to avoid repeated AWS calls
12
+ const tableDescriptionCache = new Map();
13
+ // Cache for scanned items
14
+ const itemsCache = new Map();
15
+ export function createDynamoDBServiceAdapter(endpointUrl, region) {
16
+ const store = getDefaultStore();
17
+ const regionArgs = region ? ["--region", region] : [];
18
+ const getLevel = () => store.get(dynamoDBLevelAtom);
19
+ const setLevel = (level) => store.set(dynamoDBLevelAtom, level);
20
+ const getBackStack = () => store.get(dynamoDBBackStackAtom);
21
+ const setBackStack = (stack) => store.set(dynamoDBBackStackAtom, stack);
22
+ const getTableDescription = async (tableName) => {
23
+ const cached = tableDescriptionCache.get(tableName);
24
+ if (cached)
25
+ return cached;
26
+ try {
27
+ const data = await runAwsJsonAsync([
28
+ "dynamodb",
29
+ "describe-table",
30
+ "--table-name",
31
+ tableName,
32
+ ...regionArgs,
33
+ ]);
34
+ const table = data.Table;
35
+ tableDescriptionCache.set(tableName, table);
36
+ return table;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ };
42
+ const getColumns = () => {
43
+ const level = getLevel();
44
+ if (level.kind === "tables") {
45
+ return [
46
+ { key: "name", label: "Name" },
47
+ { key: "status", label: "Status", width: 12 },
48
+ { key: "items", label: "Items", width: 10 },
49
+ { key: "billing", label: "Billing", width: 25 },
50
+ { key: "gsis", label: "GSIs", width: 6 },
51
+ ];
52
+ }
53
+ if (level.kind === "items") {
54
+ const table = tableDescriptionCache.get(level.tableName);
55
+ if (!table) {
56
+ // Fallback: return standard columns that will always be populated
57
+ return [
58
+ { key: "#", label: "#", width: 4 },
59
+ { key: "pk", label: "PK", width: 20 },
60
+ { key: "sk", label: "SK", width: 20 },
61
+ { key: "size", label: "Size", width: 10 },
62
+ ];
63
+ }
64
+ const keys = table.KeySchema ?? [];
65
+ const hashKey = keys.find((k) => k.KeyType === "HASH");
66
+ const rangeKey = keys.find((k) => k.KeyType === "RANGE");
67
+ const cols = [{ key: "#", label: "#", width: 4 }];
68
+ if (hashKey) {
69
+ cols.push({ key: "pk", label: hashKey.AttributeName, width: 20 });
70
+ }
71
+ if (rangeKey) {
72
+ cols.push({ key: "sk", label: rangeKey.AttributeName, width: 20 });
73
+ }
74
+ cols.push({ key: "size", label: "Size", width: 10 });
75
+ return cols;
76
+ }
77
+ // item-fields level
78
+ return [
79
+ { key: "attribute", label: "Attribute" },
80
+ { key: "value", label: "Value", width: 50 },
81
+ { key: "type", label: "Type", width: 8 },
82
+ ];
83
+ };
4
84
  const getRows = async () => {
5
- return [{ id: "stub", cells: { name: textCell("DynamoDB not yet implemented") }, meta: {} }];
85
+ const level = getLevel();
86
+ if (level.kind === "tables") {
87
+ try {
88
+ const listData = await runAwsJsonAsync([
89
+ "dynamodb",
90
+ "list-tables",
91
+ ...regionArgs,
92
+ ]);
93
+ const tableNames = listData.TableNames ?? [];
94
+ // Describe all tables in parallel
95
+ const tables = await Promise.all(tableNames.map((name) => getTableDescription(name)));
96
+ return tables
97
+ .filter((t) => t !== null)
98
+ .map((table) => {
99
+ const statusColor = table.TableStatus === "ACTIVE"
100
+ ? "green"
101
+ : table.TableStatus === "CREATING" || table.TableStatus === "UPDATING"
102
+ ? "yellow"
103
+ : "red";
104
+ return {
105
+ id: table.TableArn,
106
+ cells: {
107
+ name: textCell(table.TableName),
108
+ status: textCell(table.TableStatus),
109
+ items: textCell(String(table.ItemCount ?? 0)),
110
+ billing: textCell(formatBillingMode(table)),
111
+ gsis: textCell(String(table.GlobalSecondaryIndexes?.length ?? 0)),
112
+ },
113
+ meta: {
114
+ type: "table",
115
+ tableName: table.TableName,
116
+ tableStatus: table.TableStatus,
117
+ tableArn: table.TableArn,
118
+ billing: formatBillingMode(table),
119
+ gsiCount: table.GlobalSecondaryIndexes?.length ?? 0,
120
+ },
121
+ };
122
+ });
123
+ }
124
+ catch {
125
+ return [];
126
+ }
127
+ }
128
+ if (level.kind === "items") {
129
+ const { tableName } = level;
130
+ try {
131
+ // Check cache
132
+ const cached = itemsCache.get(tableName);
133
+ if (cached) {
134
+ const table = cached.table;
135
+ const items = cached.items;
136
+ return items.map((item, index) => {
137
+ const pkValue = extractPkValue(item, table);
138
+ const skValue = extractSkValue(item, table);
139
+ const itemSize = JSON.stringify(item).length;
140
+ const cells = {
141
+ name: textCell(`Item ${index + 1}`),
142
+ "#": textCell(String(index + 1)),
143
+ pk: textCell(pkValue ?? "-"),
144
+ sk: textCell(skValue ?? "-"),
145
+ size: textCell(`${itemSize}B`),
146
+ };
147
+ return {
148
+ id: `${tableName}-${index}`,
149
+ cells,
150
+ meta: {
151
+ type: "item",
152
+ tableName,
153
+ itemIndex: index,
154
+ itemPkValue: pkValue ?? undefined,
155
+ itemSkValue: skValue ?? undefined,
156
+ itemSize,
157
+ itemJson: JSON.stringify(item),
158
+ },
159
+ };
160
+ });
161
+ }
162
+ // Fetch table description to get key schema
163
+ const table = await getTableDescription(tableName);
164
+ if (!table)
165
+ return [];
166
+ // Scan items
167
+ const scanData = await runAwsJsonAsync([
168
+ "dynamodb",
169
+ "scan",
170
+ "--table-name",
171
+ tableName,
172
+ "--limit",
173
+ "50",
174
+ ...regionArgs,
175
+ ]);
176
+ const items = scanData.Items ?? [];
177
+ itemsCache.set(tableName, { items, table });
178
+ return items.map((item, index) => {
179
+ const pkValue = extractPkValue(item, table);
180
+ const skValue = extractSkValue(item, table);
181
+ const itemSize = JSON.stringify(item).length;
182
+ const cells = {
183
+ name: textCell(`Item ${index + 1}`),
184
+ "#": textCell(String(index + 1)),
185
+ pk: textCell(pkValue ?? "-"),
186
+ sk: textCell(skValue ?? "-"),
187
+ size: textCell(`${itemSize}B`),
188
+ };
189
+ return {
190
+ id: `${tableName}-${index}`,
191
+ cells,
192
+ meta: {
193
+ type: "item",
194
+ tableName,
195
+ itemIndex: index,
196
+ itemPkValue: pkValue ?? undefined,
197
+ itemSkValue: skValue ?? undefined,
198
+ itemSize,
199
+ itemJson: JSON.stringify(item),
200
+ },
201
+ };
202
+ });
203
+ }
204
+ catch {
205
+ return [];
206
+ }
207
+ }
208
+ // item-fields level
209
+ if (level.kind === "item-fields") {
210
+ const { tableName, itemIndex } = level;
211
+ const cached = itemsCache.get(tableName);
212
+ if (!cached)
213
+ return [];
214
+ const item = cached.items[itemIndex];
215
+ if (!item)
216
+ return [];
217
+ return Object.entries(item).map(([attrName, attrValue]) => {
218
+ const displayValue = formatDynamoValue(attrValue);
219
+ const type = getDynamoType(attrValue);
220
+ return {
221
+ id: attrName,
222
+ cells: {
223
+ attribute: textCell(attrName),
224
+ value: textCell(displayValue),
225
+ type: textCell(type),
226
+ },
227
+ meta: {
228
+ type: "item-field",
229
+ tableName,
230
+ itemIndex,
231
+ fieldName: attrName,
232
+ fieldValue: displayValue,
233
+ fieldType: type,
234
+ fieldRawValue: unwrapDynamoValue(attrValue),
235
+ },
236
+ };
237
+ });
238
+ }
239
+ return [];
6
240
  };
7
- const onSelect = async (_row) => {
241
+ const onSelect = async (row) => {
242
+ const level = getLevel();
243
+ const backStack = getBackStack();
244
+ const meta = row.meta;
245
+ if (level.kind === "tables") {
246
+ if (!meta || meta.type !== "table") {
247
+ return { action: "none" };
248
+ }
249
+ // Clear items cache when switching tables
250
+ itemsCache.clear();
251
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
252
+ setBackStack(newStack);
253
+ setLevel({
254
+ kind: "items",
255
+ tableName: meta.tableName,
256
+ });
257
+ return { action: "navigate" };
258
+ }
259
+ if (level.kind === "items") {
260
+ if (!meta || meta.type !== "item") {
261
+ return { action: "none" };
262
+ }
263
+ const newStack = [...backStack, { level: level, selectedIndex: 0 }];
264
+ setBackStack(newStack);
265
+ setLevel({
266
+ kind: "item-fields",
267
+ tableName: meta.tableName,
268
+ itemIndex: meta.itemIndex,
269
+ });
270
+ return { action: "navigate" };
271
+ }
272
+ // item-fields level: leaf, no drill-down
8
273
  return { action: "none" };
9
274
  };
275
+ const canGoBack = () => getBackStack().length > 0;
276
+ const goBack = () => {
277
+ const backStack = getBackStack();
278
+ if (backStack.length > 0) {
279
+ const newStack = backStack.slice(0, -1);
280
+ const frame = backStack[backStack.length - 1];
281
+ setBackStack(newStack);
282
+ setLevel(frame.level);
283
+ }
284
+ };
285
+ const getPath = () => {
286
+ const level = getLevel();
287
+ if (level.kind === "tables")
288
+ return "dynamodb://";
289
+ if (level.kind === "items")
290
+ return `dynamodb://${level.tableName}`;
291
+ return `dynamodb://${level.tableName}/items`;
292
+ };
293
+ const getContextLabel = () => {
294
+ const level = getLevel();
295
+ if (level.kind === "tables")
296
+ return "⚡ Tables";
297
+ if (level.kind === "items")
298
+ return `⚡ ${level.tableName}`;
299
+ return `⚡ ${level.tableName}/items`;
300
+ };
301
+ // Compose capabilities
302
+ const detailCapability = createDynamoDBDetailCapability(region, getLevel);
303
+ const yankCapability = createDynamoDBYankCapability(region, getLevel);
10
304
  return {
11
305
  id: "dynamodb",
12
306
  label: "DynamoDB",
13
- hudColor: { bg: "green", fg: "white" },
307
+ hudColor: SERVICE_COLORS.dynamodb,
14
308
  getColumns,
15
309
  getRows,
16
310
  onSelect,
17
- canGoBack: () => false,
18
- goBack: () => { },
19
- getPath: () => "/",
20
- getContextLabel: () => "⚡ Tables",
311
+ canGoBack,
312
+ goBack,
313
+ getPath,
314
+ getContextLabel,
315
+ reset() {
316
+ setLevel({ kind: "tables" });
317
+ setBackStack([]);
318
+ tableDescriptionCache.clear();
319
+ itemsCache.clear();
320
+ },
321
+ capabilities: {
322
+ detail: detailCapability,
323
+ yank: yankCapability,
324
+ },
21
325
  };
22
326
  }