@a9s/cli 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/src/App.js +30 -12
  2. package/dist/src/adapters/backStackUtils.js +14 -0
  3. package/dist/src/components/DetailPanel.js +3 -5
  4. package/dist/src/components/DiffViewer.js +3 -5
  5. package/dist/src/components/ErrorStatePanel.js +1 -1
  6. package/dist/src/components/HUD.js +2 -2
  7. package/dist/src/components/HelpPanel.js +3 -9
  8. package/dist/src/components/Table/index.js +1 -14
  9. package/dist/src/components/TableSkeleton.js +1 -5
  10. package/dist/src/components/YankHelpPanel.js +13 -4
  11. package/dist/src/features/AppMainView.integration.test.js +1 -1
  12. package/dist/src/features/AppMainView.js +4 -4
  13. package/dist/src/hooks/useAppController.js +7 -29
  14. package/dist/src/hooks/useAppData.js +2 -9
  15. package/dist/src/hooks/useHelpPanel.js +8 -4
  16. package/dist/src/hooks/usePickerManager.js +1 -9
  17. package/dist/src/hooks/usePickerTable.js +2 -9
  18. package/dist/src/hooks/useTimedFeedback.js +31 -0
  19. package/dist/src/hooks/useYankMode.js +5 -14
  20. package/dist/src/utils/aws.js +4 -0
  21. package/dist/src/utils/errorHelpers.js +11 -0
  22. package/dist/src/utils/rowUtils.js +10 -0
  23. package/dist/src/utils/scrollUtils.js +11 -0
  24. package/dist/src/utils/textUtils.js +16 -0
  25. package/dist/src/views/dynamodb/adapter.js +5 -13
  26. package/dist/src/views/dynamodb/capabilities/detailCapability.js +2 -2
  27. package/dist/src/views/dynamodb/capabilities/yankCapability.js +1 -1
  28. package/dist/src/views/iam/adapter.js +28 -23
  29. package/dist/src/views/route53/adapter.js +5 -13
  30. package/dist/src/views/route53/capabilities/detailCapability.js +2 -2
  31. package/dist/src/views/route53/capabilities/yankCapability.js +1 -1
  32. package/dist/src/views/s3/adapter.js +2 -10
  33. package/dist/src/views/s3/capabilities/actionCapability.js +1 -11
  34. package/dist/src/views/secretsmanager/adapter.js +6 -19
  35. package/dist/src/views/secretsmanager/capabilities/actionCapability.js +4 -35
  36. package/dist/src/views/secretsmanager/capabilities/detailCapability.js +2 -9
  37. package/dist/src/views/secretsmanager/capabilities/editCapability.js +5 -39
  38. package/dist/src/views/secretsmanager/capabilities/yankOptions.js +2 -9
  39. package/dist/src/views/secretsmanager/client.js +31 -0
  40. package/package.json +1 -1
package/dist/src/App.js CHANGED
@@ -26,8 +26,22 @@ import { AVAILABLE_COMMANDS } from "./constants/commands.js";
26
26
  import { buildHelpTabs, triggerToString } from "./constants/keybindings.js";
27
27
  import { useTheme } from "./contexts/ThemeContext.js";
28
28
  import { saveConfig } from "./utils/config.js";
29
+ import { readFile } from "fs/promises";
30
+ import { runAwsJsonAsync } from "./utils/aws.js";
31
+ import { debugLog } from "./utils/debugLogger.js";
29
32
  import { currentlySelectedServiceAtom, selectedRegionAtom, selectedProfileAtom, revealSecretsAtom, themeNameAtom, } from "./state/atoms.js";
30
33
  const INITIAL_AWS_PROFILE = process.env.AWS_PROFILE;
34
+ /** Convert a theme color to a hex string for OSC 11. Named colors → hex. */
35
+ function toOscColor(color) {
36
+ if (color.startsWith('#'))
37
+ return color;
38
+ const named = {
39
+ black: '#000000', white: '#ffffff', red: '#cc0000',
40
+ green: '#00cc00', blue: '#0000cc', cyan: '#00cccc',
41
+ magenta: '#cc00cc', yellow: '#cccc00', gray: '#808080',
42
+ };
43
+ return named[color] ?? '#000000';
44
+ }
31
45
  export function App({ initialService, endpointUrl }) {
32
46
  const { exit } = useApp();
33
47
  const { columns: termCols, rows: termRows } = useScreenSize();
@@ -37,6 +51,13 @@ export function App({ initialService, endpointUrl }) {
37
51
  const [revealSecrets, setRevealSecrets] = useAtom(revealSecretsAtom);
38
52
  const [themeName, setThemeName] = useAtom(themeNameAtom);
39
53
  const THEME = useTheme();
54
+ // Paint the terminal's default background so uncolored cells (border chars, gaps) inherit the theme
55
+ useEffect(() => {
56
+ process.stdout.write(`\x1b]11;${toOscColor(THEME.global.mainBg)}\x07`);
57
+ return () => {
58
+ process.stdout.write(`\x1b]111\x07`); // restore original background on unmount
59
+ };
60
+ }, [THEME.global.mainBg]);
40
61
  // Live theme preview: refs to restore original when picker is cancelled
41
62
  const themeNameRef = useRef(themeName);
42
63
  themeNameRef.current = themeName; // always in sync, not a dep
@@ -46,7 +67,7 @@ export function App({ initialService, endpointUrl }) {
46
67
  const availableRegions = useAwsRegions(selectedRegion, selectedProfile);
47
68
  const availableProfiles = useAwsProfiles();
48
69
  const { reset: resetHierarchy, updateCurrentFilter, pushLevel, popLevel } = useHierarchyState();
49
- const { state, actions } = useAppController();
70
+ const { state, actions, yankFeedbackMessage } = useAppController();
50
71
  useEffect(() => {
51
72
  if (selectedProfile === "$default") {
52
73
  if (INITIAL_AWS_PROFILE === undefined) {
@@ -291,16 +312,14 @@ export function App({ initialService, endpointUrl }) {
291
312
  setPanelScrollOffset(0);
292
313
  void (async () => {
293
314
  try {
294
- const { readFile } = await import("fs/promises");
295
315
  const newContent = await readFile(state.uploadPending.filePath, "utf-8");
296
316
  // Try to get old value from adapter (current value from AWS)
297
317
  let oldContent = "";
298
318
  const meta = state.uploadPending.metadata;
319
+ const regionArgs = selectedRegion ? ["--region", selectedRegion] : [];
299
320
  // For Secrets Manager fields: fetch the current field value
300
321
  if (meta.fieldKey && meta.secretArn) {
301
322
  try {
302
- const { runAwsJsonAsync } = await import("./utils/aws.js");
303
- const regionArgs = selectedRegion ? ["--region", selectedRegion] : [];
304
323
  const secretData = await runAwsJsonAsync([
305
324
  "secretsmanager",
306
325
  "get-secret-value",
@@ -324,15 +343,13 @@ export function App({ initialService, endpointUrl }) {
324
343
  }
325
344
  }
326
345
  }
327
- catch {
328
- // Failed to fetch
346
+ catch (err) {
347
+ debugLog("App", "upload preview fetch failed (field)", err);
329
348
  }
330
349
  }
331
350
  // For whole secrets: fetch current secret value
332
351
  else if (meta.secretArn && !meta.fieldKey) {
333
352
  try {
334
- const { runAwsJsonAsync } = await import("./utils/aws.js");
335
- const regionArgs = selectedRegion ? ["--region", selectedRegion] : [];
336
353
  const secretData = await runAwsJsonAsync([
337
354
  "secretsmanager",
338
355
  "get-secret-value",
@@ -342,13 +359,14 @@ export function App({ initialService, endpointUrl }) {
342
359
  ]);
343
360
  oldContent = secretData.SecretString || "";
344
361
  }
345
- catch {
346
- // Failed to fetch
362
+ catch (err) {
363
+ debugLog("App", "upload preview fetch failed (secret)", err);
347
364
  }
348
365
  }
349
366
  setUploadPreview({ old: oldContent, new: newContent });
350
367
  }
351
- catch {
368
+ catch (err) {
369
+ debugLog("App", "upload preview load failed", err);
352
370
  setUploadPreview({ old: "", new: "[Unable to load preview]" });
353
371
  }
354
372
  })();
@@ -504,5 +522,5 @@ export function App({ initialService, endpointUrl }) {
504
522
  });
505
523
  useMainInput(inputDispatch);
506
524
  const activePickerFilter = pickers.activePicker?.filter ?? state.filterText;
507
- return (_jsx(FullscreenBox, { children: _jsxs(Box, { flexDirection: "column", width: termCols, height: termRows, backgroundColor: THEME.global.mainBg, children: [_jsx(HUD, { serviceLabel: adapter.label, hudColor: THEME.serviceColors[adapter.id] ?? adapter.hudColor, path: path, accountName: accountName, accountId: accountId, awsProfile: awsProfile, currentIdentity: currentIdentity, region: region, terminalWidth: termCols, loading: isLoading || Boolean(state.describeState?.loading) }), _jsx(Box, { flexDirection: "row", width: "100%", flexGrow: 1, children: _jsx(AppMainView, { helpPanel: helpPanel, helpTabs: helpTabs, pickers: pickers, error: error, describeState: state.describeState, isLoading: isLoading, filteredRows: filteredRows, columns: columns, selectedIndex: navigation.selectedIndex, scrollOffset: navigation.scrollOffset, filterText: state.filterText, adapter: adapter, termCols: termCols, tableHeight: tableHeight, yankHelpOpen: state.yankHelpOpen, yankOptions: yankOptions, yankHelpRow: selectedRow, uploadPending: state.uploadPending, uploadPreview: uploadPreview, panelScrollOffset: panelScrollOffset, ...(yankHeaderMarkers ? { headerMarkers: yankHeaderMarkers } : {}) }) }), !helpPanel.helpOpen && state.yankFeedbackMessage && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: THEME.feedback.successText, children: state.yankFeedbackMessage }) })), state.pendingAction && state.pendingAction.effect.type === "prompt" && (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: THEME.feedback.promptText, children: [state.pendingAction.effect.label, " "] }), _jsx(AdvancedTextInput, { value: state.pendingAction.inputValue, onChange: (value) => actions.setPendingInputValue(value), onSubmit: () => submitPendingAction(state.pendingAction, true), focus: true })] })), state.pendingAction && state.pendingAction.effect.type === "confirm" && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: THEME.feedback.confirmText, children: [state.pendingAction.effect.message, " (y/n)"] }) })), _jsx(ModeBar, { mode: state.mode, filterText: activePickerFilter, commandText: state.commandText, commandCursorToEndToken: state.commandCursorToEndToken, hintOverride: bottomHint, pickerSearchActive: pickers.activePicker?.pickerMode === "search", onFilterChange: handleFilterChange, onCommandChange: actions.setCommandText, onFilterSubmit: handleFilterSubmit, onCommandSubmit: handleCommandSubmit })] }) }));
525
+ return (_jsx(FullscreenBox, { backgroundColor: THEME.global.mainBg, children: _jsxs(Box, { flexDirection: "column", width: termCols, height: termRows, backgroundColor: THEME.global.mainBg, children: [_jsx(HUD, { serviceLabel: adapter.label, hudColor: THEME.serviceColors[adapter.id] ?? adapter.hudColor, path: path, accountName: accountName, accountId: accountId, awsProfile: awsProfile, currentIdentity: currentIdentity, region: region, terminalWidth: termCols, loading: isLoading || Boolean(state.describeState?.loading) }), _jsx(Box, { flexDirection: "row", width: "100%", flexGrow: 1, backgroundColor: THEME.global.mainBg, children: _jsx(AppMainView, { helpPanel: helpPanel, helpTabs: helpTabs, pickers: pickers, error: error, describeState: state.describeState, isLoading: isLoading, filteredRows: filteredRows, columns: columns, selectedIndex: navigation.selectedIndex, scrollOffset: navigation.scrollOffset, filterText: state.filterText, adapter: adapter, termCols: termCols, tableHeight: tableHeight, yankHelpOpen: state.yankHelpOpen, yankOptions: yankOptions, yankHelpRow: selectedRow, uploadPending: state.uploadPending, uploadPreview: uploadPreview, panelScrollOffset: panelScrollOffset, ...(yankHeaderMarkers ? { headerMarkers: yankHeaderMarkers } : {}) }) }), !helpPanel.helpOpen && yankFeedbackMessage && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: THEME.feedback.successText, children: yankFeedbackMessage }) })), state.pendingAction && state.pendingAction.effect.type === "prompt" && (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: THEME.feedback.promptText, children: [state.pendingAction.effect.label, " "] }), _jsx(AdvancedTextInput, { value: state.pendingAction.inputValue, onChange: (value) => actions.setPendingInputValue(value), onSubmit: () => submitPendingAction(state.pendingAction, true), focus: true })] })), state.pendingAction && state.pendingAction.effect.type === "confirm" && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: THEME.feedback.confirmText, children: [state.pendingAction.effect.message, " (y/n)"] }) })), _jsx(ModeBar, { mode: state.mode, filterText: activePickerFilter, commandText: state.commandText, commandCursorToEndToken: state.commandCursorToEndToken, hintOverride: bottomHint, pickerSearchActive: pickers.activePicker?.pickerMode === "search", onFilterChange: handleFilterChange, onCommandChange: actions.setCommandText, onFilterSubmit: handleFilterSubmit, onCommandSubmit: handleCommandSubmit })] }) }));
508
526
  }
@@ -0,0 +1,14 @@
1
+ /** Create `canGoBack` and `goBack` helpers for adapters that use a level + back-stack pattern. */
2
+ export function createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack) {
3
+ const canGoBack = () => getBackStack().length > 0;
4
+ const goBack = () => {
5
+ const backStack = getBackStack();
6
+ if (backStack.length > 0) {
7
+ const newStack = backStack.slice(0, -1);
8
+ const frame = backStack[backStack.length - 1];
9
+ setBackStack(newStack);
10
+ setLevel(frame.level);
11
+ }
12
+ };
13
+ return { canGoBack, goBack };
14
+ }
@@ -1,14 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { useTheme } from "../contexts/ThemeContext.js";
4
+ import { clampScrollOffset, scrollIndicators } from "../utils/scrollUtils.js";
4
5
  export function DetailPanel({ title, fields, isLoading, scrollOffset, visibleLines, }) {
5
6
  const THEME = useTheme();
6
7
  const labelWidth = Math.max(...fields.map((f) => f.label.length), 12);
7
- // Clamp scrollOffset to valid range
8
- const clampedOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, fields.length - visibleLines)));
9
- // Show visible fields only
8
+ const clampedOffset = clampScrollOffset(scrollOffset, fields.length, visibleLines);
10
9
  const visibleFields = fields.slice(clampedOffset, clampedOffset + visibleLines);
11
- const hasMoreAbove = clampedOffset > 0;
12
- const hasMoreBelow = clampedOffset + visibleLines < fields.length;
10
+ const { hasMoreAbove, hasMoreBelow } = scrollIndicators(clampedOffset, fields.length, visibleLines);
13
11
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: THEME.panel.panelTitleText, children: title }), _jsx(Text, { color: THEME.panel.panelDividerText, children: "─".repeat(40) }), isLoading ? (_jsx(Text, { color: THEME.panel.panelHintText, children: "Loading..." })) : (_jsxs(_Fragment, { children: [hasMoreAbove && (_jsxs(Text, { color: THEME.panel.panelHintText, dimColor: true, children: ["\u2191 ", clampedOffset, " more above"] })), visibleFields.map((f) => (_jsxs(Box, { children: [_jsx(Text, { color: THEME.panel.detailFieldLabelText, children: f.label.padEnd(labelWidth + 2) }), _jsx(Text, { children: f.value })] }, f.label))), hasMoreBelow && (_jsxs(Text, { color: THEME.panel.panelHintText, dimColor: true, children: ["\u2193 ", fields.length - clampedOffset - visibleLines, " more below"] }))] })), _jsx(Text, { color: THEME.panel.panelDividerText, children: "─".repeat(40) }), _jsx(Text, { color: THEME.panel.panelHintText, children: "j/k scroll \u2022 Esc close" })] }));
14
12
  }
@@ -1,18 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { useTheme } from "../contexts/ThemeContext.js";
4
+ import { clampScrollOffset, scrollIndicators } from "../utils/scrollUtils.js";
4
5
  export function DiffViewer({ oldValue, newValue, scrollOffset, visibleLines }) {
5
6
  const THEME = useTheme();
6
7
  const oldLines = oldValue.split("\n");
7
8
  const newLines = newValue.split("\n");
8
9
  const maxLines = Math.max(oldLines.length, newLines.length);
9
- // Clamp scrollOffset to valid range
10
- const clampedOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, maxLines - visibleLines)));
11
- // Show visible lines only
10
+ const clampedOffset = clampScrollOffset(scrollOffset, maxLines, visibleLines);
12
11
  const oldDisplay = oldLines.slice(clampedOffset, clampedOffset + visibleLines).join("\n");
13
12
  const newDisplay = newLines.slice(clampedOffset, clampedOffset + visibleLines).join("\n");
14
- const hasMoreAbove = clampedOffset > 0;
15
- const hasMoreBelow = clampedOffset + visibleLines < maxLines;
13
+ const { hasMoreAbove, hasMoreBelow } = scrollIndicators(clampedOffset, maxLines, visibleLines);
16
14
  // Calculate column width
17
15
  const colWidth = 35;
18
16
  return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Box, { width: colWidth, children: _jsx(Text, { color: THEME.diff.originalHeaderText, bold: true, children: "Original" }) }), _jsx(Box, { children: _jsx(Text, { color: THEME.diff.updatedHeaderText, bold: true, children: "Updated" }) })] }), _jsxs(Box, { gap: 2, children: [_jsx(Box, { width: colWidth, children: _jsx(Text, { color: THEME.diff.diffDividerText, children: "-".repeat(30) }) }), _jsx(Text, { color: THEME.diff.diffDividerText, children: "-".repeat(30) })] }), _jsxs(Box, { gap: 2, flexDirection: "column", children: [hasMoreAbove && (_jsxs(Box, { gap: 2, children: [_jsx(Box, { width: colWidth, children: _jsxs(Text, { color: THEME.diff.diffDividerText, dimColor: true, children: ["\u2191 ", clampedOffset, " lines above"] }) }), _jsxs(Text, { color: THEME.diff.diffDividerText, dimColor: true, children: ["\u2191 ", clampedOffset, " lines above"] })] })), _jsxs(Box, { gap: 2, children: [_jsx(Box, { width: colWidth, children: _jsx(Text, { children: oldDisplay }) }), _jsx(Box, { children: _jsx(Text, { children: newDisplay }) })] }), hasMoreBelow && (_jsxs(Box, { gap: 2, children: [_jsx(Box, { width: colWidth, children: _jsxs(Text, { color: THEME.diff.diffDividerText, dimColor: true, children: ["\u2193 ", maxLines - clampedOffset - visibleLines, " more lines"] }) }), _jsxs(Text, { color: THEME.diff.diffDividerText, dimColor: true, children: ["\u2193 ", maxLines - clampedOffset - visibleLines, " more lines"] })] }))] })] }));
@@ -3,5 +3,5 @@ import { Box, Text } from "ink";
3
3
  import { useTheme } from "../contexts/ThemeContext.js";
4
4
  export function ErrorStatePanel({ title, message, hint }) {
5
5
  const THEME = useTheme();
6
- return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.error.errorBorderText, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: THEME.error.errorTitleText, children: title }), _jsx(Text, { children: message }), hint ? _jsx(Text, { color: THEME.error.errorHintText, children: hint }) : null] }) }));
6
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.error.errorBorderText, backgroundColor: THEME.global.mainBg, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: THEME.error.errorTitleText, children: title }), _jsx(Text, { children: message }), hint ? _jsx(Text, { color: THEME.error.errorHintText, children: hint }) : null] }) }));
7
7
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { useTheme } from "../contexts/ThemeContext.js";
5
+ import { truncateNoPad } from "../utils/textUtils.js";
5
6
  export function HUD({ serviceLabel, hudColor, path, accountName, accountId, awsProfile, currentIdentity, region, terminalWidth, loading = false, }) {
6
7
  const THEME = useTheme();
7
8
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -16,7 +17,6 @@ export function HUD({ serviceLabel, hudColor, path, accountName, accountId, awsP
16
17
  }, 120);
17
18
  return () => clearInterval(timer);
18
19
  }, [loading]);
19
- const truncate = (value, max) => value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value;
20
20
  const nameMaxLen = Math.max(8, terminalWidth - 44);
21
21
  const compactName = accountName.length > nameMaxLen ? `${accountName.slice(0, nameMaxLen - 1)}…` : accountName;
22
22
  const idPart = `(${accountId})`;
@@ -24,7 +24,7 @@ export function HUD({ serviceLabel, hudColor, path, accountName, accountId, awsP
24
24
  const leftTopRaw = `${compactName}${idPart}·${region}·${profilePart}`;
25
25
  const spinnerWidth = loading ? 1 : 0;
26
26
  const topPadLen = Math.max(0, terminalWidth - leftTopRaw.length - spinnerWidth);
27
- const identityLine = truncate(currentIdentity || "-", Math.max(1, terminalWidth));
27
+ const identityLine = truncateNoPad(currentIdentity || "-", Math.max(1, terminalWidth));
28
28
  const identityPadLen = Math.max(0, terminalWidth - identityLine.length);
29
29
  const label = ` ${serviceLabel.toUpperCase()} `;
30
30
  const pathDisplay = ` ${path} `;
@@ -1,13 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { useTheme } from "../contexts/ThemeContext.js";
4
- function truncate(text, maxLen) {
5
- if (text.length <= maxLen)
6
- return text;
7
- if (maxLen <= 1)
8
- return "…";
9
- return `${text.slice(0, maxLen - 1)}…`;
10
- }
4
+ import { truncateNoPad } from "../utils/textUtils.js";
11
5
  export function HelpPanel({ title, scopeLabel, tabs, activeTab, terminalWidth, maxRows, scrollOffset, }) {
12
6
  const THEME = useTheme();
13
7
  const currentTab = tabs[activeTab] ?? tabs[0];
@@ -17,7 +11,7 @@ export function HelpPanel({ title, scopeLabel, tabs, activeTab, terminalWidth, m
17
11
  const tabRow = [];
18
12
  let rowWidth = 0;
19
13
  for (let idx = 0; idx < tabs.length; idx += 1) {
20
- const shortTitle = truncate(tabs[idx]?.title ?? "Tab", 10);
14
+ const shortTitle = truncateNoPad(tabs[idx]?.title ?? "Tab", 10);
21
15
  const label = ` ${idx + 1}:${shortTitle} `;
22
16
  if (rowWidth + label.length > maxTabRowWidth)
23
17
  break;
@@ -31,5 +25,5 @@ export function HelpPanel({ title, scopeLabel, tabs, activeTab, terminalWidth, m
31
25
  return (_jsx(Text, { ...(isActive
32
26
  ? { backgroundColor: THEME.panel.activeTabBg, color: THEME.panel.activeTabText }
33
27
  : { color: THEME.panel.inactiveTabText }), bold: isActive, children: chip.label }, `chip-${chip.idx}`));
34
- }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visibleItems.map((item, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: THEME.panel.keyText, bold: true, children: truncate(item.key, keyColWidth).padEnd(keyColWidth) }), _jsx(Text, { children: truncate(item.description, descColWidth) })] }, `${item.key}-${scrollOffset + idx}`))) })] }));
28
+ }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visibleItems.map((item, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: THEME.panel.keyText, bold: true, children: truncateNoPad(item.key, keyColWidth).padEnd(keyColWidth) }), _jsx(Text, { children: truncateNoPad(item.description, descColWidth) })] }, `${item.key}-${scrollOffset + idx}`))) })] }));
35
29
  }
@@ -3,20 +3,7 @@ import React, { useMemo } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { computeColumnWidths } from "./widths.js";
5
5
  import { useTheme } from "../../contexts/ThemeContext.js";
6
- function truncate(str, maxLen) {
7
- if (str.length <= maxLen)
8
- return str.padEnd(maxLen);
9
- return str.slice(0, maxLen - 1) + "…";
10
- }
11
- function truncateNoPad(str, maxLen) {
12
- if (maxLen <= 0)
13
- return "";
14
- if (str.length <= maxLen)
15
- return str;
16
- if (maxLen === 1)
17
- return "…";
18
- return str.slice(0, maxLen - 1) + "…";
19
- }
6
+ import { truncate, truncateNoPad } from "../../utils/textUtils.js";
20
7
  function highlightMatch(text, filter, isSelected, theme) {
21
8
  if (!filter || !text)
22
9
  return [text];
@@ -3,14 +3,10 @@ import React from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { computeColumnWidths } from "./Table/widths.js";
5
5
  import { useTheme } from "../contexts/ThemeContext.js";
6
+ import { truncate } from "../utils/textUtils.js";
6
7
  function fill(len, ch = "░") {
7
8
  return ch.repeat(Math.max(1, len));
8
9
  }
9
- function truncate(str, maxLen) {
10
- if (str.length <= maxLen)
11
- return str.padEnd(maxLen);
12
- return str.slice(0, Math.max(1, maxLen - 1)) + "…";
13
- }
14
10
  export function TableSkeleton({ columns, terminalWidth, rows = 8, contextLabel, }) {
15
11
  const THEME = useTheme();
16
12
  const FRAMES = ["░", "▒", "▓"];
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Box, Text } from "ink";
4
- import { Alert, Badge, StatusMessage, UnorderedList } from "@inkjs/ui";
5
4
  import { triggerToString } from "../constants/keybindings.js";
6
5
  import { useTheme } from "../contexts/ThemeContext.js";
7
6
  export function YankHelpPanel({ options, row }) {
@@ -39,7 +38,17 @@ export function YankHelpPanel({ options, row }) {
39
38
  isActive = false;
40
39
  };
41
40
  }, [options, row]);
42
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Alert, { variant: "info", title: "Yank Options", children: "Press key to copy, Esc or ? to close" }), _jsx(Box, { height: 1 }), !row && _jsx(StatusMessage, { variant: "warning", children: "No row selected" }), _jsx(UnorderedList, { children: options.map((option) => (_jsx(UnorderedList.Item, { children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Badge, { color: THEME.panel.keyText, children: triggerToString(option.trigger) }), _jsxs(Text, { children: [" ", option.label] })] }), _jsx(Text, { color: THEME.panel.panelHintText, children: row
43
- ? ` -> ${resolvedValues[`${option.label}-${triggerToString(option.trigger)}`] ?? "(loading...)"}`
44
- : " -> (no value)" })] }) }, `${option.label}-${triggerToString(option.trigger)}`))) })] }));
41
+ const MAX_VALUE_LEN = 45;
42
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, flexGrow: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: THEME.panel.panelTitleText, children: "Yank" }), _jsx(Text, { color: THEME.panel.panelDividerText, children: "Press key to copy \u00B7 Esc to close" })] }), _jsx(Text, { color: THEME.panel.panelDividerText, children: "─".repeat(36) }), !row && _jsx(Text, { color: THEME.error.errorTitleText, children: "No row selected" }), options.map((option) => {
43
+ const id = `${option.label}-${triggerToString(option.trigger)}`;
44
+ const raw = resolvedValues[id];
45
+ const displayValue = row
46
+ ? raw != null
47
+ ? raw.length > MAX_VALUE_LEN
48
+ ? raw.slice(0, MAX_VALUE_LEN - 1) + "…"
49
+ : raw
50
+ : "(loading…)"
51
+ : "(no value)";
52
+ return (_jsxs(Box, { children: [_jsx(Text, { color: THEME.panel.keyText, bold: true, children: triggerToString(option.trigger).padEnd(5) }), _jsx(Text, { color: THEME.panel.panelHintText, children: option.label.padEnd(16) }), _jsx(Text, { color: THEME.panel.panelDividerText, children: "→ " }), _jsx(Text, { children: displayValue })] }, id));
53
+ })] }));
45
54
  }
@@ -128,7 +128,7 @@ describe("AppMainView integration", () => {
128
128
  resolve: async () => "x",
129
129
  },
130
130
  ], yankHelpRow: { id: "row", cells: { name: textCell("item") } } }));
131
- expect(lastFrame()).toContain("Yank Options");
131
+ expect(lastFrame()).toContain("Yank");
132
132
  expect(lastFrame()).toContain("copy name");
133
133
  });
134
134
  });
@@ -67,7 +67,7 @@ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState
67
67
  error,
68
68
  ]);
69
69
  if (helpPanel.helpOpen) {
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 }) }));
70
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.helpPanelBorderText, backgroundColor: THEME.global.mainBg, children: _jsx(HelpPanel, { title: "Keyboard Help", scopeLabel: "All modes reference", tabs: helpTabs, activeTab: helpPanel.helpTabIndex, terminalWidth: termCols, maxRows: helpPanel.helpVisibleRows, scrollOffset: helpPanel.helpScrollOffset }) }));
71
71
  }
72
72
  if (pickers.activePicker) {
73
73
  const ap = pickers.activePicker;
@@ -75,17 +75,17 @@ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState
75
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 }));
76
76
  }
77
77
  if (yankHelpOpen) {
78
- return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.yankPanelBorderText, children: _jsx(YankHelpPanel, { options: yankOptions, row: yankHelpRow }) }));
78
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.yankPanelBorderText, backgroundColor: THEME.global.mainBg, children: _jsx(YankHelpPanel, { options: yankOptions, row: yankHelpRow }) }));
79
79
  }
80
80
  if (uploadPending) {
81
81
  // Overhead: border 2 + header 4 + separators 2 + DiffViewer header+divider 2 = 10
82
82
  const diffVisibleLines = Math.max(1, tableHeight - 10);
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"] }) })] }));
83
+ return (_jsxs(Box, { width: "100%", borderStyle: "round", borderColor: THEME.upload.uploadBorderText, backgroundColor: THEME.global.mainBg, 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"] }) })] }));
84
84
  }
85
85
  if (describeState) {
86
86
  // Overhead: border 2 + title 1 + separator 1 + footer 2 = 6
87
87
  const detailVisibleLines = Math.max(1, tableHeight - 6);
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 }) }));
88
+ return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.detailPanelBorderText, backgroundColor: THEME.global.mainBg, children: _jsx(DetailPanel, { title: getCellValue(describeState.row.cells.name) ?? describeState.row.id, fields: describeState.fields ?? [], isLoading: describeState.loading, scrollOffset: panelScrollOffset, visibleLines: detailVisibleLines }) }));
89
89
  }
90
90
  if (isLoading) {
91
91
  return (_jsx(TableSkeleton, { columns: columns, terminalWidth: termCols, rows: 1, contextLabel: adapter.getContextLabel?.() ?? "" }));
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
1
+ import { useMemo, useReducer } from "react";
2
+ import { useTimedFeedback } from "./useTimedFeedback.js";
2
3
  export const initialAppControllerState = {
3
4
  mode: "navigate",
4
5
  filterText: "",
@@ -7,7 +8,6 @@ export const initialAppControllerState = {
7
8
  commandCursorToEndToken: 0,
8
9
  yankMode: false,
9
10
  yankHelpOpen: false,
10
- yankFeedbackMessage: null,
11
11
  uploadPending: null,
12
12
  describeState: null,
13
13
  pendingAction: null,
@@ -31,8 +31,6 @@ export function appControllerReducer(state, action) {
31
31
  return { ...state, yankMode: action.value };
32
32
  case "setYankHelpOpen":
33
33
  return { ...state, yankHelpOpen: action.value };
34
- case "setYankFeedback":
35
- return { ...state, yankFeedbackMessage: action.value };
36
34
  case "setUploadPending":
37
35
  return { ...state, uploadPending: action.value };
38
36
  case "setDescribeState":
@@ -58,18 +56,7 @@ export function appControllerReducer(state, action) {
58
56
  }
59
57
  export function useAppController() {
60
58
  const [state, dispatch] = useReducer(appControllerReducer, initialAppControllerState);
61
- const feedbackTimerRef = useRef(null);
62
- const clearFeedbackTimer = useCallback(() => {
63
- if (feedbackTimerRef.current) {
64
- clearTimeout(feedbackTimerRef.current);
65
- feedbackTimerRef.current = null;
66
- }
67
- }, []);
68
- useEffect(() => {
69
- return () => {
70
- clearFeedbackTimer();
71
- };
72
- }, [clearFeedbackTimer]);
59
+ const { feedback: yankFeedbackMessage, pushFeedback, clearFeedback } = useTimedFeedback(1500);
73
60
  const actions = useMemo(() => ({
74
61
  setMode: (mode) => dispatch({ type: "setMode", mode }),
75
62
  setFilterText: (value) => dispatch({ type: "setFilterText", value }),
@@ -82,21 +69,12 @@ export function useAppController() {
82
69
  setDescribeState: (value) => dispatch({ type: "setDescribeState", value }),
83
70
  setPendingAction: (value) => dispatch({ type: "setPendingAction", value }),
84
71
  setPendingInputValue: (value) => dispatch({ type: "setPendingInputValue", value }),
85
- clearFeedback: () => {
86
- clearFeedbackTimer();
87
- dispatch({ type: "setYankFeedback", value: null });
88
- },
89
- pushFeedback: (message, durationMs = 1500) => {
90
- clearFeedbackTimer();
91
- dispatch({ type: "setYankFeedback", value: message });
92
- feedbackTimerRef.current = setTimeout(() => {
93
- dispatch({ type: "setYankFeedback", value: null });
94
- feedbackTimerRef.current = null;
95
- }, durationMs);
96
- },
97
- }), [clearFeedbackTimer]);
72
+ clearFeedback,
73
+ pushFeedback,
74
+ }), [clearFeedback, pushFeedback]);
98
75
  return {
99
76
  state,
100
77
  actions,
78
+ yankFeedbackMessage,
101
79
  };
102
80
  }
@@ -4,6 +4,7 @@ import { useServiceView } from "./useServiceView.js";
4
4
  import { useNavigation } from "./useNavigation.js";
5
5
  import { usePickerManager } from "./usePickerManager.js";
6
6
  import { debugLog } from "../utils/debugLogger.js";
7
+ import { filterRowsByText } from "../utils/rowUtils.js";
7
8
  export function useAppData({ currentService, endpointUrl, selectedRegion, tableHeight, filterText, availableRegions, availableProfiles, }) {
8
9
  const adapter = useMemo(() => {
9
10
  debugLog(currentService, `useAppData: adapter created`);
@@ -17,15 +18,7 @@ export function useAppData({ currentService, endpointUrl, selectedRegion, tableH
17
18
  "state.adapterId": rows.length > 0 ? "has-data" : "empty",
18
19
  });
19
20
  }, [rows.length, isLoading, adapter.id]);
20
- const filteredRows = useMemo(() => {
21
- if (!filterText)
22
- return rows;
23
- const lowerFilter = filterText.toLowerCase();
24
- return rows.filter((row) => Object.values(row.cells).some((cell) => {
25
- const value = typeof cell === "string" ? cell : cell.displayName;
26
- return value.toLowerCase().includes(lowerFilter);
27
- }));
28
- }, [filterText, rows]);
21
+ const filteredRows = useMemo(() => filterRowsByText(rows, filterText), [filterText, rows]);
29
22
  const navigation = useNavigation(filteredRows.length, tableHeight);
30
23
  const selectedRow = filteredRows[navigation.selectedIndex] ?? null;
31
24
  const pickers = usePickerManager({ tableHeight, availableRegions, availableProfiles });
@@ -4,14 +4,18 @@ export function useHelpPanel(helpTabs, helpContainerHeight) {
4
4
  const [helpTabIndex, setHelpTabIndex] = useState(0);
5
5
  const [helpScrollOffset, setHelpScrollOffset] = useState(0);
6
6
  const helpTabsCount = helpTabs.length;
7
+ // Rows reserved for header/footer chrome above the list
8
+ const SCROLL_RESERVE_ROWS = 3;
9
+ // Extra row for the scroll position indicator line
10
+ const SCROLL_INDICATOR_ROW = 1;
7
11
  // Compute visible rows from container height and current tab
8
- const baseHelpVisibleRows = Math.max(1, helpContainerHeight - 3);
12
+ const baseHelpVisibleRows = Math.max(1, helpContainerHeight - SCROLL_RESERVE_ROWS);
9
13
  const activeHelpItemsCount = helpTabs[helpTabIndex]?.items.length ?? 0;
10
14
  const overflowRows = Math.max(0, activeHelpItemsCount - baseHelpVisibleRows);
11
- const scrollReserveRows = Math.min(3, overflowRows);
15
+ const scrollReserveRows = Math.min(SCROLL_RESERVE_ROWS, overflowRows);
12
16
  const helpVisibleRows = overflowRows > 0
13
- ? Math.max(1, baseHelpVisibleRows - scrollReserveRows - 1)
14
- : Math.max(1, baseHelpVisibleRows - 1);
17
+ ? Math.max(1, baseHelpVisibleRows - scrollReserveRows - SCROLL_INDICATOR_ROW)
18
+ : Math.max(1, baseHelpVisibleRows - SCROLL_INDICATOR_ROW);
15
19
  const clampTab = useCallback((idx) => ((idx % helpTabsCount) + helpTabsCount) % helpTabsCount, [helpTabsCount]);
16
20
  const open = useCallback(() => {
17
21
  setHelpScrollOffset(0);
@@ -99,15 +99,7 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
99
99
  ...theme,
100
100
  ...themeTable,
101
101
  };
102
- const activePicker = regionEntry.open
103
- ? regionEntry
104
- : profileEntry.open
105
- ? profileEntry
106
- : resourceEntry.open
107
- ? resourceEntry
108
- : themeEntry.open
109
- ? themeEntry
110
- : null;
102
+ const activePicker = [regionEntry, profileEntry, resourceEntry, themeEntry].find((e) => e.open) ?? null;
111
103
  const getEntry = (id) => {
112
104
  switch (id) {
113
105
  case "region":
@@ -1,15 +1,8 @@
1
1
  import { useMemo } from "react";
2
2
  import { useNavigation } from "./useNavigation.js";
3
+ import { filterRowsByText } from "../utils/rowUtils.js";
3
4
  export function usePickerTable({ rows, filterText, maxHeight }) {
4
- const filteredRows = useMemo(() => {
5
- if (!filterText)
6
- return rows;
7
- const lower = filterText.toLowerCase();
8
- return rows.filter((row) => Object.values(row.cells).some((cell) => {
9
- const value = typeof cell === "string" ? cell : cell.displayName;
10
- return value.toLowerCase().includes(lower);
11
- }));
12
- }, [filterText, rows]);
5
+ const filteredRows = useMemo(() => filterRowsByText(rows, filterText), [filterText, rows]);
13
6
  const nav = useNavigation(filteredRows.length, maxHeight);
14
7
  const selectedRow = filteredRows[nav.selectedIndex] ?? null;
15
8
  return {
@@ -0,0 +1,31 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ /** Manage a self-clearing feedback message with a configurable display duration. */
3
+ export function useTimedFeedback(defaultDuration = 1500) {
4
+ const [feedback, setFeedback] = useState(null);
5
+ const timerRef = useRef(null);
6
+ useEffect(() => {
7
+ return () => {
8
+ if (timerRef.current)
9
+ clearTimeout(timerRef.current);
10
+ };
11
+ }, []);
12
+ const clearFeedback = useCallback(() => {
13
+ if (timerRef.current) {
14
+ clearTimeout(timerRef.current);
15
+ timerRef.current = null;
16
+ }
17
+ setFeedback(null);
18
+ }, []);
19
+ const pushFeedback = useCallback((message, durationMs = defaultDuration) => {
20
+ if (timerRef.current) {
21
+ clearTimeout(timerRef.current);
22
+ timerRef.current = null;
23
+ }
24
+ setFeedback(message);
25
+ timerRef.current = setTimeout(() => {
26
+ setFeedback(null);
27
+ timerRef.current = null;
28
+ }, durationMs);
29
+ }, [defaultDuration]);
30
+ return { feedback, pushFeedback, clearFeedback };
31
+ }
@@ -1,23 +1,14 @@
1
- import { useState, useCallback, useEffect } from "react";
1
+ import { useState } from "react";
2
+ import { useTimedFeedback } from "./useTimedFeedback.js";
2
3
  export function useYankMode() {
3
4
  const [yankMode, setYankMode] = useState(false);
4
- const [yankFeedback, setYankFeedback] = useState(null);
5
- useEffect(() => {
6
- return () => {
7
- if (yankFeedback?.timer)
8
- clearTimeout(yankFeedback.timer);
9
- };
10
- }, [yankFeedback]);
11
- const pushYankFeedback = useCallback((message, durationMs = 1500) => {
12
- const timer = setTimeout(() => setYankFeedback(null), durationMs);
13
- setYankFeedback({ message, timer });
14
- }, []);
15
- const clearYankFeedback = useCallback(() => setYankFeedback(null), []);
5
+ const { feedback, pushFeedback: pushYankFeedback, clearFeedback: clearYankFeedback } = useTimedFeedback(1500);
6
+ // Expose feedback in the same shape callers might expect
7
+ const yankFeedback = feedback ? { message: feedback } : null;
16
8
  return {
17
9
  yankMode,
18
10
  setYankMode,
19
11
  yankFeedback,
20
- setYankFeedback,
21
12
  pushYankFeedback,
22
13
  clearYankFeedback,
23
14
  };
@@ -1,6 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
+ /** Build optional --region args for AWS CLI calls. */
5
+ export function buildRegionArgs(region) {
6
+ return region ? ["--region", region] : [];
7
+ }
4
8
  /**
5
9
  * Run an AWS CLI command asynchronously. Returns stdout or null on error/timeout.
6
10
  */
@@ -0,0 +1,11 @@
1
+ export function toErrorMessage(error) {
2
+ if (error instanceof Error)
3
+ return error.message;
4
+ return String(error);
5
+ }
6
+ export function hasCode(error, code) {
7
+ return Boolean(typeof error === "object" &&
8
+ error !== null &&
9
+ "code" in error &&
10
+ error.code === code);
11
+ }
@@ -0,0 +1,10 @@
1
+ /** Filter rows by case-insensitive substring match across all cell values. */
2
+ export function filterRowsByText(rows, filterText) {
3
+ if (!filterText)
4
+ return rows;
5
+ const lower = filterText.toLowerCase();
6
+ return rows.filter((row) => Object.values(row.cells).some((cell) => {
7
+ const value = typeof cell === "string" ? cell : cell.displayName;
8
+ return value.toLowerCase().includes(lower);
9
+ }));
10
+ }
@@ -0,0 +1,11 @@
1
+ /** Clamp a scroll offset to a valid range given total items and visible count. */
2
+ export function clampScrollOffset(offset, totalItems, visibleCount) {
3
+ return Math.max(0, Math.min(offset, Math.max(0, totalItems - visibleCount)));
4
+ }
5
+ /** Derive scroll indicator booleans from clamped offset, total, and visible count. */
6
+ export function scrollIndicators(offset, totalItems, visibleCount) {
7
+ return {
8
+ hasMoreAbove: offset > 0,
9
+ hasMoreBelow: offset + visibleCount < totalItems,
10
+ };
11
+ }