@a9s/cli 1.0.9 → 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.
- package/dist/src/App.js +30 -12
- package/dist/src/adapters/backStackUtils.js +14 -0
- package/dist/src/components/DetailPanel.js +3 -5
- package/dist/src/components/DiffViewer.js +3 -5
- package/dist/src/components/ErrorStatePanel.js +1 -1
- package/dist/src/components/HUD.js +2 -2
- package/dist/src/components/HelpPanel.js +3 -9
- package/dist/src/components/Table/index.js +1 -14
- package/dist/src/components/TableSkeleton.js +1 -5
- package/dist/src/components/YankHelpPanel.js +13 -4
- package/dist/src/features/AppMainView.integration.test.js +1 -1
- package/dist/src/features/AppMainView.js +4 -4
- package/dist/src/hooks/useAppController.js +7 -29
- package/dist/src/hooks/useAppData.js +2 -9
- package/dist/src/hooks/useHelpPanel.js +8 -4
- package/dist/src/hooks/usePickerManager.js +1 -9
- package/dist/src/hooks/usePickerTable.js +2 -9
- package/dist/src/hooks/useTimedFeedback.js +31 -0
- package/dist/src/hooks/useYankMode.js +5 -14
- package/dist/src/utils/aws.js +4 -0
- package/dist/src/utils/errorHelpers.js +11 -0
- package/dist/src/utils/rowUtils.js +10 -0
- package/dist/src/utils/scrollUtils.js +11 -0
- package/dist/src/utils/textUtils.js +16 -0
- package/dist/src/views/dynamodb/adapter.js +5 -13
- package/dist/src/views/dynamodb/capabilities/detailCapability.js +2 -2
- package/dist/src/views/dynamodb/capabilities/yankCapability.js +1 -1
- package/dist/src/views/iam/adapter.js +28 -23
- package/dist/src/views/route53/adapter.js +5 -13
- package/dist/src/views/route53/capabilities/detailCapability.js +2 -2
- package/dist/src/views/route53/capabilities/yankCapability.js +1 -1
- package/dist/src/views/s3/adapter.js +2 -10
- package/dist/src/views/s3/capabilities/actionCapability.js +1 -11
- package/dist/src/views/secretsmanager/adapter.js +6 -19
- package/dist/src/views/secretsmanager/capabilities/actionCapability.js +4 -35
- package/dist/src/views/secretsmanager/capabilities/detailCapability.js +2 -9
- package/dist/src/views/secretsmanager/capabilities/editCapability.js +5 -39
- package/dist/src/views/secretsmanager/capabilities/yankOptions.js +2 -9
- package/dist/src/views/secretsmanager/client.js +31 -0
- 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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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 -
|
|
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(
|
|
15
|
+
const scrollReserveRows = Math.min(SCROLL_RESERVE_ROWS, overflowRows);
|
|
12
16
|
const helpVisibleRows = overflowRows > 0
|
|
13
|
-
? Math.max(1, baseHelpVisibleRows - scrollReserveRows -
|
|
14
|
-
: Math.max(1, baseHelpVisibleRows -
|
|
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
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
};
|
package/dist/src/utils/aws.js
CHANGED
|
@@ -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
|
+
}
|