From 5a8b3c4e9ba0eacddc9c6c6ca7787fa513c0ac2a Mon Sep 17 00:00:00 2001 From: Elran Shefer <31568840+IamShobe@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:11:13 +0200 Subject: [PATCH] feat: generic adapter stack, layout budget, and profiles/regions as first-class adapters - Replace backStackUtils + useHierarchyState with createStackState generic abstraction - Add useAdapterStack hook for adapter-level navigation stack - Add useTableLayout hook and layoutBudget utilities for deterministic layout computation - Promote _profiles, _regions, and _resources to proper ServiceAdapter implementations - Update all 20+ service adapters to use the new stack/navigation API - Improve column width computation (widths.ts) --- src/App.tsx | 368 ++++++++++++------ src/adapters/ServiceAdapter.ts | 10 +- src/adapters/backStackUtils.ts | 21 - src/components/DetailPanel.tsx | 77 ++-- src/components/DiffViewer.tsx | 16 +- src/components/FilePreviewPanel.tsx | 15 +- src/components/HelpPanel.tsx | 4 + src/components/Table/widths.ts | 77 +++- src/constants/keybindings.ts | 28 +- src/constants/theme.ts | 18 + src/features/AppMainView.integration.test.tsx | 68 +--- src/features/AppMainView.tsx | 50 ++- src/hooks/inputEvents.ts | 5 +- src/hooks/mainInputScopes.test.ts | 4 +- src/hooks/mainInputScopes.ts | 7 + src/hooks/useAdapterStack.ts | 42 ++ src/hooks/useAppData.ts | 65 +--- src/hooks/useCommandRouter.ts | 18 +- src/hooks/useDetailController.ts | 12 +- src/hooks/useFilePreview.ts | 31 +- src/hooks/useHelpPanel.ts | 17 +- src/hooks/useHierarchyState.ts | 54 --- src/hooks/useInputEventProcessor.test.ts | 3 + src/hooks/useInputEventProcessor.ts | 15 + src/hooks/useKeyChord.ts | 3 + src/hooks/usePickerManager.ts | 140 +------ src/hooks/useServiceView.ts | 28 +- src/hooks/useTableLayout.ts | 54 +++ src/index.tsx | 18 +- src/services.ts | 4 +- src/state/atoms.ts | 23 +- src/types.ts | 7 + src/utils/bookmarks.ts | 41 +- src/utils/createStackState.ts | 51 +++ src/utils/heatmap.ts | 59 ++- src/utils/layoutBudget.ts | 38 ++ src/utils/rowUtils.ts | 2 +- src/views/_profiles/adapter.ts | 55 +++ src/views/_regions/adapter.ts | 55 +++ src/views/_resources/adapter.ts | 80 ++++ src/views/apigateway/adapter.ts | 22 +- src/views/cloudformation/adapter.ts | 120 ++++-- src/views/cloudwatch/adapter.ts | 68 +++- src/views/dynamodb/adapter.ts | 23 +- src/views/ebs/adapter.ts | 44 ++- src/views/ec2/adapter.ts | 47 ++- src/views/ecr/adapter.ts | 26 +- src/views/ecs/adapter.ts | 84 ++-- src/views/elb/adapter.ts | 62 ++- src/views/eventbridge/adapter.ts | 22 +- src/views/iam/adapter.ts | 23 +- src/views/lambda/adapter.ts | 54 ++- src/views/rds/adapter.ts | 44 ++- src/views/route53/adapter.ts | 23 +- src/views/s3/adapter.ts | 61 ++- src/views/secretsmanager/adapter.ts | 24 +- src/views/sns/adapter.ts | 43 +- src/views/sqs/adapter.ts | 54 ++- src/views/ssm/adapter.ts | 42 +- src/views/stepfunctions/adapter.ts | 22 +- src/views/vpc/adapter.ts | 22 +- 61 files changed, 1658 insertions(+), 955 deletions(-) delete mode 100644 src/adapters/backStackUtils.ts create mode 100644 src/hooks/useAdapterStack.ts delete mode 100644 src/hooks/useHierarchyState.ts create mode 100644 src/hooks/useTableLayout.ts create mode 100644 src/utils/createStackState.ts create mode 100644 src/utils/layoutBudget.ts create mode 100644 src/views/_profiles/adapter.ts create mode 100644 src/views/_regions/adapter.ts create mode 100644 src/views/_resources/adapter.ts diff --git a/src/App.tsx b/src/App.tsx index 09741eb..6522909 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useApp } from "ink"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import clipboardy from "clipboardy"; import { HUD } from "./components/HUD.js"; @@ -13,17 +13,22 @@ import { useAwsRegions } from "./hooks/useAwsRegions.js"; import { useAwsProfiles } from "./hooks/useAwsProfiles.js"; import { useMainInput } from "./hooks/useMainInput.js"; import { useInputEventProcessor } from "./hooks/useInputEventProcessor.js"; -import { useHierarchyState } from "./hooks/useHierarchyState.js"; import { useAppController } from "./hooks/useAppController.js"; import { useCommandRouter } from "./hooks/useCommandRouter.js"; +import { useAdapterStack } from "./hooks/useAdapterStack.js"; import { useDetailController } from "./hooks/useDetailController.js"; import { useActionController } from "./hooks/useActionController.js"; import { useUiHints } from "./hooks/useUiHints.js"; import { useAppData } from "./hooks/useAppData.js"; +import { useTableLayout } from "./hooks/useTableLayout.js"; +import { computeOuterChrome } from "./utils/layoutBudget.js"; import { deriveYankHeaderMarkers } from "./hooks/yankHeaderMarkers.js"; import { AppMainView } from "./features/AppMainView.js"; import type { ServiceId } from "./services.js"; import type { ServiceViewResult, TableRow } from "./types.js"; +import { getCellLabel } from "./types.js"; +import { computeColumnWidths } from "./components/Table/widths.js"; +import { truncateNoPad } from "./utils/textUtils.js"; import type { RelatedResource } from "./adapters/ServiceAdapter.js"; import { AVAILABLE_COMMANDS } from "./constants/commands.js"; import { buildHelpTabs, triggerToString } from "./constants/keybindings.js"; @@ -34,11 +39,9 @@ import { readFile } from "fs/promises"; import { openConsoleUrl } from "./utils/consoleUrl.js"; import { runAwsJsonAsync } from "./utils/aws.js"; import { debugLog } from "./utils/debugLogger.js"; -import { summarizeRowStatuses } from "./utils/rowUtils.js"; import { loadSearchHistory, saveSearchEntry } from "./utils/searchHistory.js"; import { loadBookmarks, toggleBookmark } from "./utils/bookmarks.js"; import { buildHistogram } from "./utils/histogram.js"; -import { isNumericColumn } from "./utils/heatmap.js"; import type { HistogramBar } from "./utils/histogram.js"; import { useFilePreview } from "./hooks/useFilePreview.js"; import { MIN_COL_WIDTH, GAP } from "./components/FilePreviewPanel.js"; @@ -48,6 +51,7 @@ import { selectedProfileAtom, revealSecretsAtom, themeNameAtom, + bookmarkRestoreAtom, } from "./state/atoms.js"; import type { ThemeName } from "./constants/theme.js"; @@ -65,20 +69,20 @@ function toOscColor(color: string): string { } interface AppProps { - initialService: ServiceId; endpointUrl: string | undefined; } -export function App({ initialService, endpointUrl }: AppProps) { +export function App({ endpointUrl }: AppProps) { const { exit } = useApp(); const { columns: termCols, rows: termRows } = useScreenSize(); + const theme = useTheme(); + const adapterStack = useAdapterStack(); + const [currentService, setCurrentService] = useAtom(currentlySelectedServiceAtom); const [selectedRegion, setSelectedRegion] = useAtom(selectedRegionAtom); const [selectedProfile, setSelectedProfile] = useAtom(selectedProfileAtom); - const [currentService, setCurrentService] = useAtom(currentlySelectedServiceAtom); const [revealSecrets, setRevealSecrets] = useAtom(revealSecretsAtom); const [themeName, setThemeName] = useAtom(themeNameAtom); - const theme = useTheme(); // Paint the terminal's default background so uncolored cells (border chars, gaps) inherit the theme useEffect(() => { @@ -102,7 +106,6 @@ export function App({ initialService, endpointUrl }: AppProps) { const availableRegions = useAwsRegions(selectedRegion, selectedProfile); const availableProfiles = useAwsProfiles(); - const { reset: resetHierarchy, updateCurrentFilter, pushLevel, popLevel } = useHierarchyState(); const { state, actions, yankFeedbackMessage } = useAppController(); useEffect(() => { @@ -117,15 +120,6 @@ export function App({ initialService, endpointUrl }: AppProps) { process.env.AWS_PROFILE = selectedProfile; }, [selectedProfile]); - useEffect(() => { - setCurrentService(initialService); - }, [initialService, setCurrentService]); - - const HUD_LINES = 3; - const MODEBAR_LINES = 1; - const HEADER_LINES = 2; - const tableHeight = Math.max(1, termRows - HUD_LINES - MODEBAR_LINES - HEADER_LINES - 4); - // Related resources state — populated when user presses g+r on a row (must be declared before useAppData) const [relatedResources, setRelatedResources] = useState([]); @@ -145,7 +139,7 @@ export function App({ initialService, endpointUrl }: AppProps) { // Bookmark state const [bookmarkedIds, setBookmarkedIds] = useState>(() => { const entries = loadBookmarks(); - return new Set(entries.filter((e) => e.serviceId === initialService).map((e) => e.rowId)); + return new Set(entries.filter((e) => e.serviceId === currentService).map((e) => e.rowId)); }); // Search history state @@ -156,9 +150,6 @@ export function App({ initialService, endpointUrl }: AppProps) { // Histogram state const [histogramState, setHistogramState] = useState<{ columnKey: string; columnLabel: string; bars: HistogramBar[] | null } | null>(null); - // File preview state - const filePreview = useFilePreview(tableHeight - 3); - const { adapter, columns, @@ -170,34 +161,66 @@ export function App({ initialService, endpointUrl }: AppProps) { refresh, path, filteredRows, - selectedRow, - navigation, - pickers, } = useAppData({ currentService, endpointUrl, selectedRegion, - tableHeight, filterText: state.filterText, availableRegions, availableProfiles, - relatedResources, tagFilter, sortState, heatmapEnabled, bookmarkedIds, }); - const isResourcesRootRef = useRef(true); + const contentBudget = termRows - computeOuterChrome({ + hasPendingPrompt: state.pendingAction?.effect.type === "prompt", + hasPendingConfirm: state.pendingAction?.effect.type === "confirm", + }); + + const { + dataRows, + navigation, + selectedRow, + pickers, + statusSummary, + } = useTableLayout({ + contentBudget, + adapter, + filteredRows, + showSearchHistory, + searchHistoryLength: searchHistory.length, + relatedResources, + }); + + // File preview state + const filePreview = useFilePreview(contentBudget); + + // Ref for restoring cursor position after adapter pop + const pendingIndexRef = useRef(null); + // Ref for highlighting a specific row by ID after bookmark restore + const pendingRowIdRef = useRef(null); + + // Stable refs so jumpToBookmark can call refresh/currentService without stale closures + const currentServiceRef = useRef(currentService); + currentServiceRef.current = currentService; + const refreshRef = useRef(refresh); + refreshRef.current = refresh; - const navigateToRoot = useCallback(() => { - isResourcesRootRef.current = true; - pickers.openPicker("resource"); - }, [pickers]); + const setBookmarkRestore = useSetAtom(bookmarkRestoreAtom); + // Restore cursor position when rows become available after adapter switch useEffect(() => { - navigateToRoot(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + if (pendingRowIdRef.current !== null && filteredRows.length > 0) { + const idx = filteredRows.findIndex((r) => r.id === pendingRowIdRef.current); + navigation.setIndex(idx >= 0 ? idx : 0); + pendingRowIdRef.current = null; + } else if (pendingIndexRef.current !== null && filteredRows.length > 0) { + navigation.setIndex(Math.min(pendingIndexRef.current, filteredRows.length - 1)); + pendingIndexRef.current = null; + } + }, [filteredRows.length, navigation]); // Refresh bookmarked IDs when service changes useEffect(() => { @@ -253,50 +276,115 @@ export function App({ initialService, endpointUrl }: AppProps) { }; }, [watchInterval, refresh]); + const resetViewState = useCallback(() => { + actions.setFilterText(""); + actions.setDescribeState(null); + actions.setSearchEntryFilter(null); + actions.setMode("navigate"); + actions.setYankMode(false); + actions.setUploadPending(null); + actions.setPendingAction(null); + setWatchInterval(null); + setTagFilterState(null); + setSortState(null); + setSelectedRowIds(new Set()); + lastToggledIndexRef.current = -1; + setHistogramState(null); + navigation.reset(); + }, [actions, navigation]); + const switchAdapter = useCallback( (serviceId: ServiceId) => { - isResourcesRootRef.current = false; + if (serviceId === "_resources") { + // Resources is always the root — clear the stack so back stops here + adapterStack.clear(); + } else { + adapterStack.push({ adapterId: currentService, filterText: state.filterText, selectedIndex: navigation.selectedIndex }); + } setCurrentService(serviceId); - actions.setFilterText(""); - actions.setDescribeState(null); - actions.setSearchEntryFilter(null); - actions.setMode("navigate"); - actions.setYankMode(false); - actions.setUploadPending(null); - actions.setPendingAction(null); - setWatchInterval(null); - setTagFilterState(null); - setSortState(null); - setSelectedRowIds(new Set()); - lastToggledIndexRef.current = -1; - setHistogramState(null); - resetHierarchy(); - navigation.reset(); + resetViewState(); + }, + [resetViewState, navigation, setCurrentService, adapterStack, currentService, state.filterText], + ); + + /** Jump to a bookmarked item: clears the adapter stack + defers level restore. */ + const jumpToBookmark = useCallback( + (entry: import("./utils/bookmarks.js").BookmarkEntry) => { + if (entry.key.length > 1) { + // Set atom for both same-service and cross-service — performFetch consumes it + setBookmarkRestore({ serviceId: entry.serviceId, key: entry.key }); + if (entry.serviceId === currentServiceRef.current) { + // Same-service: adapter won't recreate, no useEffect fires — trigger fetch manually + void refreshRef.current(); + } + // Cross-service: setCurrentService below triggers adapter recreation → useEffect → performFetch + } + adapterStack.clear(); + setCurrentService(entry.serviceId as ServiceId); + pendingRowIdRef.current = entry.rowId; + resetViewState(); }, - [actions, navigation, resetHierarchy, setCurrentService], + [resetViewState, setCurrentService, adapterStack, setBookmarkRestore], ); + const popBackToCallingAdapter = useCallback(() => { + const frame = adapterStack.pop(); + if (!frame) return; + setCurrentService(frame.adapterId); + actions.setFilterText(frame.filterText); + actions.setMode("navigate"); + pendingIndexRef.current = frame.selectedIndex; + }, [adapterStack, setCurrentService, actions]); + const navigateBack = useCallback(() => { - if (!adapter.canGoBack()) { - navigateToRoot(); + if (adapter.canGoBack()) { + // Intra-adapter back (e.g., S3 objects → buckets) + void goBack().then((restored) => { + actions.setDescribeState(null); + actions.setSearchEntryFilter(null); + actions.setFilterText(restored?.filterText ?? ""); + navigation.setIndex(restored?.selectedIndex ?? 0); + }); return; } - void goBack().then(() => { - actions.setDescribeState(null); - actions.setSearchEntryFilter(null); - const { restoredFilter, restoredIndex } = popLevel(); - actions.setFilterText(restoredFilter); - navigation.setIndex(restoredIndex); - }); - }, [actions, adapter, goBack, navigateToRoot, navigation, popLevel]); + // Inter-adapter back: pop the stack + if (adapterStack.isEmpty) return; // at root (_resources) + popBackToCallingAdapter(); + }, [actions, adapter, goBack, navigation, adapterStack.isEmpty, popBackToCallingAdapter]); const navigateIntoSelection = useCallback(() => { if (!selectedRow) return; - if (selectedRow.meta?.type === "object") return; + + // Resource adapter: enter = switch to selected service + if (currentService === "_resources") { + switchAdapter(selectedRow.id as ServiceId); + return; + } + // Region adapter: enter = set region + pop back + if (currentService === "_regions") { + setSelectedRegion(selectedRow.id); + popBackToCallingAdapter(); + return; + } + // Profile adapter: enter = set profile + pop back + if (currentService === "_profiles") { + setSelectedProfile(selectedRow.id); + popBackToCallingAdapter(); + return; + } + + if (selectedRow.meta?.type === "object") { + if (adapter.capabilities?.preview?.canPreview(selectedRow)) { + filePreview.showPreview(selectedRow, adapter); + } + return; + } + const capturedFilter = state.filterText; + const capturedIndex = navigation.selectedIndex; void select(selectedRow).then((result: ServiceViewResult) => { if (result?.action === "navigate") { actions.setSearchEntryFilter(null); - pushLevel(navigation.selectedIndex, ""); + adapter.pushUiLevel(capturedFilter, capturedIndex); actions.setFilterText(""); actions.setDescribeState(null); navigation.reset(); @@ -309,7 +397,7 @@ export function App({ initialService, endpointUrl }: AppProps) { }); } }); - }, [actions, navigation, pushLevel, select, selectedRow]); + }, [actions, adapter, navigation, select, selectedRow, currentService, switchAdapter, setSelectedRegion, setSelectedProfile, popBackToCallingAdapter, state.filterText]); const editSelection = useCallback(() => { if (!selectedRow) return; @@ -383,7 +471,7 @@ export function App({ initialService, endpointUrl }: AppProps) { () => buildHelpTabs(adapter.id, adapterBindings, uiHintsContext), [adapter.id, adapterBindings, uiHintsContext], ); - const helpContainerHeight = Math.max(1, termRows - HUD_LINES - MODEBAR_LINES); + const helpContainerHeight = contentBudget; const helpPanel = useHelpPanel(helpTabs, helpContainerHeight); const nameOption = { @@ -435,12 +523,11 @@ export function App({ initialService, endpointUrl }: AppProps) { setSelectedRegion, setSelectedProfile, switchAdapter, - openProfilePicker: () => pickers.openPicker("profile"), - openRegionPicker: () => pickers.openPicker("region"), - openResourcePicker: () => pickers.openPicker("resource"), openThemePicker: () => pickers.openPicker("theme"), openBookmarksPicker: () => pickers.openPicker("bookmarks"), - setWatch: setWatchInterval, + setWatch: (seconds: number) => { + if (!currentService.startsWith("_")) setWatchInterval(seconds); + }, clearWatch: () => setWatchInterval(null), setTagFilter: (key, value) => setTagFilterState({ key, value }), clearTagFilter: () => setTagFilterState(null), @@ -457,9 +544,8 @@ export function App({ initialService, endpointUrl }: AppProps) { setSearchHistoryIndex(-1); setSelectedRowIds(new Set()); actions.setFilterText(value); - updateCurrentFilter(value); }, - [actions, pickers, updateCurrentFilter, showSearchHistory], + [actions, pickers, showSearchHistory], ); const handleFilterSubmit = useCallback(() => { @@ -650,20 +736,68 @@ export function App({ initialService, endpointUrl }: AppProps) { actions.bumpCommandCursorToEnd(); }, [actions, state.commandText]); - const statusSummary = useMemo(() => summarizeRowStatuses(filteredRows), [filteredRows]); - const footerContent = useMemo(() => { - if (statusSummary.byColor.length === 0) return null; - const parts: React.ReactNode[] = [ - Total: {statusSummary.total}, - ]; - for (const { color, count, label } of statusSummary.byColor) { - parts.push( - ● {label}: {count} - ); + const statusRow = statusSummary.byColor.length === 0 ? null : (() => { + const parts: React.ReactNode[] = [ + Total: {statusSummary.total}, + ]; + for (const { color, count, label } of statusSummary.byColor) { + parts.push( + ● {label}: {count} + ); + } + return {parts}; + })(); + + let previewRow: React.ReactNode = null; + if (selectedRow) { + const colWidths = computeColumnWidths(columns, termCols); + const SEP = " · "; // 5 chars + // Collect segments that were truncated in the table + const segments: { label: string; value: string }[] = []; + for (let i = 0; i < columns.length; i++) { + const col = columns[i]!; + const value = getCellLabel(selectedRow.cells[col.key]) ?? ""; + const width = colWidths[i] ?? 0; + if (value.length > width - 1) { + segments.push({ label: col.label, value }); + } + } + if (segments.length > 0) { + // Budget-based rendering: fit as many segments as possible within termCols + let budget = termCols; + const fitted: React.ReactNode[] = []; + for (const seg of segments) { + const prefixLen = seg.label.length + 2; // "Label: " + const sepCost = fitted.length > 0 ? SEP.length : 0; + const minNeeded = sepCost + prefixLen + 1; // at least 1 char of value + if (budget < minNeeded) break; + + if (fitted.length > 0) { + fitted.push({SEP}); + budget -= sepCost; + } + const valueSpace = budget - prefixLen; + const displayValue = truncateNoPad(seg.value, valueSpace); + fitted.push( + + {seg.label}: + {" "}{displayValue} + + ); + budget -= prefixLen + displayValue.length; + } + if (fitted.length > 0) { + previewRow = {fitted}; + } + } } - return {parts}; - }, [statusSummary, theme.table.columnHeaderText]); + + if (!statusRow && !previewRow) return null; + if (!previewRow) return statusRow; + if (!statusRow) return previewRow; + return {statusRow}{previewRow}; + }, [statusSummary, selectedRow, columns, termCols, theme.table.columnHeaderText, theme.panel.detailFieldLabelText]); const inputRuntime = useMemo( () => ({ @@ -676,6 +810,7 @@ export function App({ initialService, endpointUrl }: AppProps) { selectedRow, helpOpen: helpPanel.helpOpen, pickerMode: pickers.activePicker?.pickerMode ?? null, + activePickerId: pickers.activePicker?.id ?? null, describeOpen: Boolean(state.describeState), uploadPending: Boolean(state.uploadPending), pendingActionType: state.pendingAction?.effect.type ?? null, @@ -702,7 +837,6 @@ export function App({ initialService, endpointUrl }: AppProps) { }, picker: { close: () => { - if (isResourcesRootRef.current && pickers.activePicker?.id === "resource") return; if (pickers.activePicker?.id === "related") setRelatedResources([]); pickers.closeActivePicker(); }, @@ -714,9 +848,6 @@ export function App({ initialService, endpointUrl }: AppProps) { bottom: () => pickers.activePicker?.toBottom(), confirm: () => pickers.confirmActivePickerSelection({ - onSelectResource: switchAdapter, - onSelectRegion: setSelectedRegion, - onSelectProfile: setSelectedProfile, onSelectTheme: (name: ThemeName) => { themePickerConfirmedRef.current = true; setThemeName(name); @@ -728,9 +859,25 @@ export function App({ initialService, endpointUrl }: AppProps) { setRelatedResources([]); }, onSelectBookmark: (entry) => { - switchAdapter(entry.serviceId as ServiceId); + jumpToBookmark(entry); }, }), + deleteItem: () => { + const ap = pickers.activePicker; + if (ap?.id !== "bookmarks" || !ap.selectedRow) return; + const entry = ap.selectedRow.meta?.bookmarkEntry as import("./utils/bookmarks.js").BookmarkEntry | undefined; + if (!entry) return; + toggleBookmark(entry); + if (entry.serviceId === currentService) { + setBookmarkedIds((prev) => { + const next = new Set(prev); + next.delete(entry.rowId); + return next; + }); + } + actions.pushFeedback("Removed bookmark", 1500); + pickers.refreshPicker("bookmarks"); + }, }, mode: { cancelSearchOrCommand: () => { @@ -809,12 +956,10 @@ export function App({ initialService, endpointUrl }: AppProps) { }, bookmarkToggle: () => { if (!selectedRow) return; - const nameCell = selectedRow.cells.name; - const label = typeof nameCell === "object" ? (nameCell?.displayName ?? selectedRow.id) : (nameCell ?? selectedRow.id); const added = toggleBookmark({ serviceId: currentService, rowId: selectedRow.id, - rowLabel: label, + key: adapter.getBookmarkKey(selectedRow), savedAt: new Date().toISOString(), }); setBookmarkedIds((prev) => { @@ -825,7 +970,7 @@ export function App({ initialService, endpointUrl }: AppProps) { actions.pushFeedback(added ? `★ Bookmarked` : `Removed bookmark`, 1500); }, showHistogram: () => { - const numericCols = columns.filter((c) => isNumericColumn(filteredRows, c.key)); + const numericCols = columns.filter((c) => c.heatmap && c.heatmap.type !== "date"); const fallback = numericCols[0] ?? columns[1]; let targetCol: typeof columns[number] | undefined; @@ -864,13 +1009,7 @@ export function App({ initialService, endpointUrl }: AppProps) { setRevealSecrets(!revealSecrets); }, previewFile: () => { - if (!selectedRow) return; - if (adapter.capabilities?.preview?.canPreview(selectedRow)) { - filePreview.showPreview(selectedRow, adapter); - } else { - // Fall back to reveal toggle for non-previewable rows (e.g. secrets) - setRevealSecrets(!revealSecrets); - } + // Now handled by Enter (navigateIntoSelection) }, showDetails: () => showDetails(selectedRow), editSelection, @@ -955,16 +1094,24 @@ export function App({ initialService, endpointUrl }: AppProps) { const visibleCols = ps.columns.slice(ps.colOffset, ps.colOffset + colsPerScreen); const col = visibleCols[colIndex]; if (!col) return; - const filterText = ps.filterText.trim().toLowerCase(); - const filteredRows = filterText - ? ps.rows.filter((r) => Object.values(r.cells).some((c) => (c?.displayName ?? "").toLowerCase().includes(filterText))) - : ps.rows; - const row = filteredRows[filePreview.previewNavigation.selectedIndex]; + const row = filePreview.getSelectedRow(); const value = row?.cells[col.key]?.displayName?.trim() ?? ""; if (!value) return; filePreview.cancelPreviewYank(); void clipboardy.write(value).then(() => actions.pushFeedback(`Copied ${col.label}`, 1500)); }, + showDetails: () => { + const ps = filePreview.previewState; + if (!ps) return; + const row = filePreview.getSelectedRow(); + if (!row) return; + const fields = ps.columns.map((col) => ({ + label: col.label, + value: row.cells[col.key]?.displayName ?? "-", + })); + setPanelScrollOffset(0); + actions.setDescribeState({ row, fields, loading: false, requestId: 0 }); + }, }, pending: { cancelPrompt: () => actions.setPendingAction(null), @@ -1017,7 +1164,8 @@ export function App({ initialService, endpointUrl }: AppProps) { filterText={state.filterText} adapter={adapter} termCols={termCols} - tableHeight={tableHeight} + tableHeight={dataRows} + contentBudget={contentBudget} yankHelpOpen={state.yankHelpOpen} yankOptions={yankOptions} yankHelpRow={selectedRow} @@ -1040,11 +1188,13 @@ export function App({ initialService, endpointUrl }: AppProps) { {...(sortState ? { sortState } : {})} /> - {!helpPanel.helpOpen && yankFeedbackMessage && ( - + + {!helpPanel.helpOpen && yankFeedbackMessage ? ( {yankFeedbackMessage} - - )} + ) : ( + + )} + {state.pendingAction && state.pendingAction.effect.type === "prompt" && ( {state.pendingAction.effect.label} diff --git a/src/adapters/ServiceAdapter.ts b/src/adapters/ServiceAdapter.ts index 5d24ffb..d8a5efa 100644 --- a/src/adapters/ServiceAdapter.ts +++ b/src/adapters/ServiceAdapter.ts @@ -1,4 +1,5 @@ import type { ColumnDef, TableRow, SelectResult } from "../types.js"; +import type { BookmarkKeyPart } from "../utils/bookmarks.js"; import type { EditCapability } from "./capabilities/EditCapability.js"; import type { DetailCapability } from "./capabilities/DetailCapability.js"; import type { YankCapability } from "./capabilities/YankCapability.js"; @@ -33,11 +34,18 @@ export interface ServiceAdapter { getRows(): Promise; onSelect(row: TableRow): Promise; canGoBack(): boolean; - goBack(): void; + goBack(): { filterText: string; selectedIndex: number } | undefined; + pushUiLevel(filterText: string, selectedIndex: number): void; getPath(): string; getContextLabel?(): string; // e.g., "🪣 Buckets" or "📦 Objects" + reset?(): void; + /** Return a structured key describing the selected row for bookmark persistence. */ + getBookmarkKey(row: TableRow): BookmarkKeyPart[]; + /** Restore navigation to the level described by a structured bookmark key. */ + restoreFromKey?(key: BookmarkKeyPart[]): void; + /** Return related resources for a selected row (e.g. Lambda → CloudWatch log group). */ getRelatedResources?(row: TableRow): RelatedResource[] | Promise; diff --git a/src/adapters/backStackUtils.ts b/src/adapters/backStackUtils.ts deleted file mode 100644 index 4157081..0000000 --- a/src/adapters/backStackUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Create `canGoBack` and `goBack` helpers for adapters that use a level + back-stack pattern. */ -export function createBackStackHelpers( - getLevel: () => Level, - setLevel: (l: Level) => void, - getBackStack: () => Frame[], - setBackStack: (s: Frame[]) => void, -): { canGoBack: () => boolean; goBack: () => void } { - const canGoBack = (): boolean => getBackStack().length > 0; - - const goBack = (): void => { - const backStack = getBackStack(); - if (backStack.length > 0) { - const newStack = backStack.slice(0, -1); - const frame = backStack[backStack.length - 1]!; - setBackStack(newStack); - setLevel(frame.level); - } - }; - - return { canGoBack, goBack }; -} diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index b39f1f9..d19595f 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -4,12 +4,15 @@ import type { DetailField } from "../adapters/ServiceAdapter.js"; import { useTheme } from "../contexts/ThemeContext.js"; import { clampScrollOffset, scrollIndicators } from "../utils/scrollUtils.js"; +// Chrome lines: border(2) + title(1) + topDivider(1) + bottomDivider(1) + hint(1) +const CHROME = 6; + interface DetailPanelProps { title: string; fields: DetailField[]; isLoading: boolean; scrollOffset: number; - visibleLines: number; + availableHeight: number; } export function DetailPanel({ @@ -17,45 +20,55 @@ export function DetailPanel({ fields, isLoading, scrollOffset, - visibleLines, + availableHeight, }: DetailPanelProps) { const theme = useTheme(); const labelWidth = Math.max(...fields.map((f) => f.label.length), 12); + const baseVisible = Math.max(1, availableHeight - CHROME); + const { hasMoreAbove, hasMoreBelow } = scrollIndicators( + clampScrollOffset(scrollOffset, fields.length, baseVisible), + fields.length, + baseVisible, + ); + const indicatorLines = (hasMoreAbove ? 1 : 0) + (hasMoreBelow ? 1 : 0); + const visibleLines = Math.max(1, baseVisible - indicatorLines); + const clampedOffset = clampScrollOffset(scrollOffset, fields.length, visibleLines); const visibleFields = fields.slice(clampedOffset, clampedOffset + visibleLines); - const { hasMoreAbove, hasMoreBelow } = scrollIndicators(clampedOffset, fields.length, visibleLines); return ( - - - {title} - - {"─".repeat(40)} - {isLoading ? ( - Loading... - ) : ( - <> - {hasMoreAbove && ( - - ↑ {clampedOffset} more above - - )} - {visibleFields.map((f) => ( - - {f.label.padEnd(labelWidth + 2)} - {f.value} - - ))} - {hasMoreBelow && ( - - ↓ {fields.length - clampedOffset - visibleLines} more below - - )} - - )} - {"─".repeat(40)} - j/k scroll • Esc close + + + + {title} + + {"─".repeat(40)} + {isLoading ? ( + Loading... + ) : ( + <> + {hasMoreAbove && ( + + ↑ {clampedOffset} more above + + )} + {visibleFields.map((f) => ( + + {f.label.padEnd(labelWidth + 2)} + {f.value} + + ))} + {hasMoreBelow && ( + + ↓ {fields.length - clampedOffset - visibleLines} more below + + )} + + )} + {"─".repeat(40)} + j/k scroll • Esc close + ); } diff --git a/src/components/DiffViewer.tsx b/src/components/DiffViewer.tsx index fedfbe0..85f0593 100644 --- a/src/components/DiffViewer.tsx +++ b/src/components/DiffViewer.tsx @@ -3,19 +3,31 @@ import { Box, Text } from "ink"; import { useTheme } from "../contexts/ThemeContext.js"; import { clampScrollOffset, scrollIndicators } from "../utils/scrollUtils.js"; +// Chrome lines: header(1) + divider(1) +const DIFF_CHROME = 2; + interface DiffViewerProps { oldValue: string; newValue: string; scrollOffset: number; - visibleLines: number; + availableHeight: number; } -export function DiffViewer({ oldValue, newValue, scrollOffset, visibleLines }: DiffViewerProps) { +export function DiffViewer({ oldValue, newValue, scrollOffset, availableHeight }: DiffViewerProps) { const theme = useTheme(); const oldLines = oldValue.split("\n"); const newLines = newValue.split("\n"); const maxLines = Math.max(oldLines.length, newLines.length); + const baseVisible = Math.max(1, availableHeight - DIFF_CHROME); + const { hasMoreAbove: preAbove, hasMoreBelow: preBelow } = scrollIndicators( + clampScrollOffset(scrollOffset, maxLines, baseVisible), + maxLines, + baseVisible, + ); + const indicatorLines = (preAbove ? 1 : 0) + (preBelow ? 1 : 0); + const visibleLines = Math.max(1, baseVisible - indicatorLines); + const clampedOffset = clampScrollOffset(scrollOffset, maxLines, visibleLines); const oldDisplay = oldLines.slice(clampedOffset, clampedOffset + visibleLines).join("\n"); const newDisplay = newLines.slice(clampedOffset, clampedOffset + visibleLines).join("\n"); diff --git a/src/components/FilePreviewPanel.tsx b/src/components/FilePreviewPanel.tsx index 77408d0..e7681ef 100644 --- a/src/components/FilePreviewPanel.tsx +++ b/src/components/FilePreviewPanel.tsx @@ -3,6 +3,7 @@ import { Box, Text } from "ink"; import { Table } from "./Table/index.js"; import { AdvancedTextInput } from "./AdvancedTextInput.js"; import { useTheme } from "../contexts/ThemeContext.js"; +import { computeTableDataRows } from "../utils/layoutBudget.js"; import type { FilePreviewState } from "../hooks/useFilePreview.js"; import type { useNavigation } from "../hooks/useNavigation.js"; @@ -16,7 +17,7 @@ interface FilePreviewPanelProps { previewState: FilePreviewState; navigation: ReturnType; termCols: number; - tableHeight: number; + availableHeight: number; onFilterChange: (text: string) => void; onFilterSubmit: () => void; previewYankMode?: boolean; @@ -36,11 +37,13 @@ export function FilePreviewPanel({ previewState, navigation, termCols, - tableHeight, + availableHeight, onFilterChange, onFilterSubmit, previewYankMode = false, }: FilePreviewPanelProps) { + // border(2) is the only chrome this component adds around the Table + const tableBudget = availableHeight - 2; const theme = useTheme(); const filteredRows = useMemo(() => filterRows(previewState), [previewState]); @@ -97,6 +100,12 @@ export function FilePreviewPanel({ ? `Loading ${previewState.fileName || "…"}` : `${previewState.fileName}${statusParts ? ` | ${statusParts}` : ""}`; + const tableMaxHeight = computeTableDataRows(tableBudget, { + hasContextLabel: true, + footerContentRows: 1, + totalRows: filteredRows.length, + }); + const footerContent = ( {previewState.filterActive ? ( @@ -143,7 +152,7 @@ export function FilePreviewPanel({ selectedIndex={navigation.selectedIndex} filterText={previewState.filterText} terminalWidth={termCols - 2} - maxHeight={tableHeight} + maxHeight={tableMaxHeight} scrollOffset={navigation.scrollOffset} contextLabel={contextLabel} footerContent={footerContent} diff --git a/src/components/HelpPanel.tsx b/src/components/HelpPanel.tsx index 3e35ae3..3449aa1 100644 --- a/src/components/HelpPanel.tsx +++ b/src/components/HelpPanel.tsx @@ -34,6 +34,8 @@ export function HelpPanel({ scrollOffset, }: HelpPanelProps) { const theme = useTheme(); + const borderColor = theme.panel.helpPanelBorderText; + const backgroundColor = theme.global.mainBg; const currentTab = tabs[activeTab] ?? tabs[0]; const keyColWidth = 12; const descColWidth = Math.max(16, terminalWidth - keyColWidth - 8); @@ -52,6 +54,7 @@ export function HelpPanel({ const visibleItems = (currentTab?.items ?? []).slice(scrollOffset, scrollOffset + listRowsBudget); return ( + {title} @@ -84,5 +87,6 @@ export function HelpPanel({ ))} + ); } diff --git a/src/components/Table/widths.ts b/src/components/Table/widths.ts index 93e8f5e..1def9b6 100644 --- a/src/components/Table/widths.ts +++ b/src/components/Table/widths.ts @@ -7,19 +7,72 @@ export function computeColumnWidths(columns: ColumnDef[], terminalWidth: number) const totalGaps = (columns.length - 1) * GAP; const available = Math.max(0, terminalWidth - totalGaps); - // Assign fixed widths first - let fixedTotal = 0; - const widths: (number | null)[] = columns.map((col) => { - if (col.width !== undefined) { - fixedTotal += col.width; - return col.width; + // Separate fixed vs flex columns + const fixedIndices: number[] = []; + const flexIndices: number[] = []; + const widths: number[] = new Array(columns.length); + + for (let i = 0; i < columns.length; i++) { + if (columns[i]!.width !== undefined) { + fixedIndices.push(i); + widths[i] = columns[i]!.width!; + } else { + flexIndices.push(i); + } + } + + const fixedTotal = fixedIndices.reduce((sum, i) => sum + widths[i]!, 0); + const flexCount = flexIndices.length; + + const flexMinTotal = flexCount * MIN_WIDTH; + + if (fixedTotal + flexMinTotal <= available) { + // Happy path: fixed columns + flex minimums fit — flex columns split the remainder + const flexAvailable = available - fixedTotal; + const flexWidth = flexCount > 0 ? Math.floor(flexAvailable / flexCount) : 0; + for (const i of flexIndices) { + widths[i] = Math.max(flexWidth, MIN_WIDTH); } - return null; - }); + } else { + // Overflow path: fixed columns exceed available space — shrink proportionally + // Give flex columns MIN_WIDTH, then shrink fixed to fill the rest + const flexBudget = flexCount * MIN_WIDTH; + const fixedBudget = Math.max(0, available - flexBudget); - const flexColumns = widths.filter((w) => w === null).length; - const flexAvailable = Math.max(available - fixedTotal, flexColumns * MIN_WIDTH); - const flexWidth = flexColumns > 0 ? Math.floor(flexAvailable / flexColumns) : 0; + for (const i of flexIndices) { + widths[i] = MIN_WIDTH; + } + + if (fixedBudget <= 0 || fixedTotal === 0) { + // No room for fixed columns at all + for (const i of fixedIndices) { + widths[i] = Math.max(columns[i]!.minWidth ?? MIN_WIDTH, MIN_WIDTH); + } + } else { + // Shrink each fixed column proportionally + let allocated = 0; + const minWidths: number[] = []; + for (const i of fixedIndices) { + const minW = Math.max(columns[i]!.minWidth ?? MIN_WIDTH, MIN_WIDTH); + minWidths.push(minW); + const proportional = Math.floor((widths[i]! / fixedTotal) * fixedBudget); + widths[i] = Math.max(proportional, minW); + allocated += widths[i]!; + } + + // Distribute rounding remainder to widest columns first + let remainder = fixedBudget - allocated; + if (remainder > 0) { + // Sort indices by current width descending for remainder distribution + const sorted = [...fixedIndices].sort((a, b) => widths[b]! - widths[a]!); + for (const i of sorted) { + if (remainder <= 0) break; + widths[i]!++; + remainder--; + } + } + } + } - return widths.map((w) => (w !== null ? w : Math.max(flexWidth, MIN_WIDTH))); + return widths; } diff --git a/src/constants/keybindings.ts b/src/constants/keybindings.ts index 3db2e6d..c192242 100644 --- a/src/constants/keybindings.ts +++ b/src/constants/keybindings.ts @@ -18,6 +18,7 @@ export type SpecialKeyName = export type KeyTrigger = | { type: "key"; char: string } // single printable char e.g. "j" + | { type: "ctrl"; char: string } // Ctrl+ e.g. "A" for Ctrl+A | { type: "special"; name: SpecialKeyName } | { type: "chord"; keys: string[] } // sequence of chars e.g. ["g","g"] | { type: "any"; of: KeyTrigger[] }; // matches any of the sub-triggers @@ -26,7 +27,10 @@ export type KeyTrigger = export function triggerToString(t: KeyTrigger): string { switch (t.type) { case "key": + if (t.char === " ") return "Space"; return t.char; + case "ctrl": + return `Ctrl+${t.char}`; case "special": return SPECIAL_DISPLAY[t.name]; case "chord": @@ -145,7 +149,7 @@ export const KEYBINDINGS: KeyBinding[] = [ action: KB.NAVIGATE_INTO, trigger: { type: "special", name: "return" }, scope: "navigate", - label: "Navigate into / select", + label: "Navigate into / preview file", shortLabel: "navigate", }, { @@ -197,15 +201,6 @@ export const KEYBINDINGS: KeyBinding[] = [ label: "Jump to related resource (e.g. Lambda → CloudWatch)", shortLabel: "related", }, - { - action: KB.PREVIEW_FILE, - trigger: { type: "key", char: "v" }, - scope: "navigate", - label: "Preview CSV/Parquet file", - shortLabel: "view", - priority: 30, - showIf: (ctx) => ctx.hasPreviewableRow && !ctx.hasHiddenSecrets, - }, { action: KB.REVEAL_TOGGLE, trigger: { type: "key", char: "v" }, @@ -252,7 +247,7 @@ export const KEYBINDINGS: KeyBinding[] = [ }, { action: KB.MULTI_SELECT_ALL, - trigger: { type: "key", char: "\x01" }, + trigger: { type: "ctrl", char: "A" }, scope: "navigate", label: "Select all visible rows", shortLabel: "select all", @@ -476,11 +471,18 @@ export const KEYBINDINGS: KeyBinding[] = [ // --- Preview --- { action: KB.PREVIEW_FILE, - trigger: { type: "key", char: "v" }, + trigger: { type: "special", name: "return" }, scope: "preview", - label: "Open: press v on a CSV/Parquet file", + label: "Open: press Enter on a CSV/Parquet file", shortLabel: "open preview", }, + { + action: KB.DETAILS, + trigger: { type: "key", char: "d" }, + scope: "preview", + label: "Detail view of selected row", + shortLabel: "details", + }, { action: KB.PREVIEW_NEXT_PAGE, trigger: { type: "key", char: "]" }, diff --git a/src/constants/theme.ts b/src/constants/theme.ts index f8d1eef..2612e2e 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -273,6 +273,9 @@ const MONOKAI_THEME = createTheme({ stepfunctions: { bg: "cyan", fg: "black" }, eventbridge: { bg: "yellow", fg: "black" }, apigateway: { bg: "magenta", fg: "white" }, + _resources: { bg: "white", fg: "black" }, + _regions: { bg: "blue", fg: "white" }, + _profiles: { bg: "magenta", fg: "white" }, }, }); @@ -315,6 +318,9 @@ const CATPPUCCIN_MOCHA_THEME = createTheme({ stepfunctions: { bg: "#89dceb", fg: "#1e1e2e" }, eventbridge: { bg: "#f9e2af", fg: "#1e1e2e" }, apigateway: { bg: "#cba6f7", fg: "#1e1e2e" }, + _resources: { bg: "#cdd6f4", fg: "#1e1e2e" }, + _regions: { bg: "#89b4fa", fg: "#1e1e2e" }, + _profiles: { bg: "#cba6f7", fg: "#1e1e2e" }, }, }); @@ -355,6 +361,9 @@ const NORD_THEME = createTheme({ stepfunctions: { bg: "#8fbcbb", fg: "#2e3440" }, eventbridge: { bg: "#ebcb8b", fg: "#2e3440" }, apigateway: { bg: "#b48ead", fg: "#eceff4" }, + _resources: { bg: "#eceff4", fg: "#2e3440" }, + _regions: { bg: "#5e81ac", fg: "#eceff4" }, + _profiles: { bg: "#b48ead", fg: "#eceff4" }, }, }); @@ -395,6 +404,9 @@ const TOKYO_NIGHT_THEME = createTheme({ stepfunctions: { bg: "#73daca", fg: "#1a1b26" }, eventbridge: { bg: "#e0af68", fg: "#1a1b26" }, apigateway: { bg: "#bb9af7", fg: "#1a1b26" }, + _resources: { bg: "#c0caf5", fg: "#1a1b26" }, + _regions: { bg: "#7aa2f7", fg: "#1a1b26" }, + _profiles: { bg: "#bb9af7", fg: "#1a1b26" }, }, }); @@ -435,6 +447,9 @@ const GRUVBOX_DARK_THEME = createTheme({ stepfunctions: { bg: "#8ec07c", fg: "#282828" }, eventbridge: { bg: "#fabd2f", fg: "#282828" }, apigateway: { bg: "#d3869b", fg: "#282828" }, + _resources: { bg: "#ebdbb2", fg: "#282828" }, + _regions: { bg: "#458588", fg: "#ebdbb2" }, + _profiles: { bg: "#d3869b", fg: "#282828" }, }, }); @@ -476,6 +491,9 @@ const DRACULA_THEME = createTheme({ stepfunctions: { bg: "#8be9fd", fg: "#282a36" }, eventbridge: { bg: "#f1fa8c", fg: "#282a36" }, apigateway: { bg: "#ff79c6", fg: "#282a36" }, + _resources: { bg: "#f8f8f2", fg: "#282a36" }, + _regions: { bg: "#bd93f9", fg: "#282a36" }, + _profiles: { bg: "#ff79c6", fg: "#282a36" }, }, }); diff --git a/src/features/AppMainView.integration.test.tsx b/src/features/AppMainView.integration.test.tsx index 646c5e2..d78e743 100644 --- a/src/features/AppMainView.integration.test.tsx +++ b/src/features/AppMainView.integration.test.tsx @@ -8,7 +8,7 @@ import { textCell } from "../types.js"; function createPickerManager(active: PickerManager["activePicker"]): PickerManager { const noop = () => {}; - const mkEntry = (id: "region" | "profile" | "resource" | "theme" | "related" | "bookmarks") => ({ + const mkEntry = (id: "theme" | "related" | "bookmarks") => ({ id, columns: [{ key: id, label: id }], contextLabel: id, @@ -34,9 +34,6 @@ function createPickerManager(active: PickerManager["activePicker"]): PickerManag }); return { - region: mkEntry("region"), - profile: mkEntry("profile"), - resource: mkEntry("resource"), theme: mkEntry("theme"), related: mkEntry("related"), bookmarks: mkEntry("bookmarks"), @@ -44,6 +41,7 @@ function createPickerManager(active: PickerManager["activePicker"]): PickerManag openPicker: (_id) => {}, closeActivePicker: noop, resetPicker: (_id) => {}, + refreshPicker: noop, confirmActivePickerSelection: (_handlers) => {}, }; } @@ -81,9 +79,11 @@ const baseProps = { getRows: async () => [], onSelect: async () => ({ action: "none" as const }), canGoBack: () => false, - goBack: () => {}, + goBack: () => undefined, + pushUiLevel: () => {}, getPath: () => "s3://", getContextLabel: () => "Buckets", + getBookmarkKey: (row: { id: string }) => [{ label: "Item", displayName: row.id, id: row.id }], }, termCols: 120, tableHeight: 20, @@ -104,15 +104,16 @@ describe("AppMainView integration", () => { it("renders picker table when picker is active", () => { const activePicker = { - ...createPickerManager(null).resource, + ...createPickerManager(null).theme, + id: "theme" as const, open: true, - contextLabel: "Select AWS Resource", + contextLabel: "Select Theme", filteredRows: [ - { id: "s3", cells: { resource: textCell("s3"), description: textCell("S3") } }, + { id: "monokai", cells: { theme: textCell("Monokai"), id: textCell("monokai") } }, ], columns: [ - { key: "resource", label: "Resource" }, - { key: "description", label: "Description" }, + { key: "theme", label: "Theme" }, + { key: "id", label: "ID" }, ], }; @@ -120,51 +121,6 @@ describe("AppMainView integration", () => { , ); - expect(lastFrame()).toContain("Select AWS Resource"); - }); - - it("renders details panel when details are present", () => { - const { lastFrame } = render( - , - ); - - expect(lastFrame()).toContain("object-1"); - }); - - it("renders yank header markers when provided", () => { - const { lastFrame } = render( - , - ); - - expect(lastFrame()).toContain("[n,k]"); - }); - - it("renders lightweight yank help panel", () => { - const { lastFrame } = render( - true, - resolve: async () => "x", - }, - ]} - yankHelpRow={{ id: "row", cells: { name: textCell("item") } }} - />, - ); - - expect(lastFrame()).toContain("Yank"); - expect(lastFrame()).toContain("copy name"); + expect(lastFrame()).toContain("Select Theme"); }); }); diff --git a/src/features/AppMainView.tsx b/src/features/AppMainView.tsx index 600ecdd..635b931 100644 --- a/src/features/AppMainView.tsx +++ b/src/features/AppMainView.tsx @@ -46,6 +46,7 @@ interface AppMainViewProps { adapter: ServiceAdapter; termCols: number; tableHeight: number; + contentBudget?: number; headerMarkers?: Record; sortState?: { colKey: string; dir: "asc" | "desc" } | null; yankHelpOpen: boolean; @@ -85,6 +86,7 @@ export function AppMainView({ adapter, termCols, tableHeight, + contentBudget = tableHeight, headerMarkers, sortState, yankHelpOpen, @@ -163,17 +165,15 @@ export function AppMainView({ if (helpPanel.helpOpen) { return ( - - - + ); } @@ -203,8 +203,10 @@ export function AppMainView({ } if (uploadPending) { - // Overhead: border 2 + header 4 + separators 2 + DiffViewer header+divider 2 = 10 - const diffVisibleLines = Math.max(1, tableHeight - 10); + // Upload dialog chrome (everything except DiffViewer content): + // border(2) + header paddingY+title+subtitle(4) + diff borderTop+paddingY(3) + confirm borderTop+paddingY+text(4) = 13 + const UPLOAD_DIALOG_CHROME = 13; + const diffAvailableHeight = Math.max(3, contentBudget - UPLOAD_DIALOG_CHROME); return ( @@ -219,7 +221,7 @@ export function AppMainView({ oldValue={uploadPreview.old} newValue={uploadPreview.new} scrollOffset={panelScrollOffset} - visibleLines={diffVisibleLines} + availableHeight={diffAvailableHeight} /> ) : ( Loading preview... @@ -243,18 +245,14 @@ export function AppMainView({ } if (describeState) { - // Overhead: border 2 + title 1 + separator 1 + footer 2 = 6 - const detailVisibleLines = Math.max(1, tableHeight - 6); return ( - - - + ); } @@ -272,7 +270,7 @@ export function AppMainView({ previewState={filePreviewState} navigation={filePreviewNavigation} termCols={termCols} - tableHeight={tableHeight - 3} + availableHeight={contentBudget} onFilterChange={onPreviewFilterChange ?? (() => {})} onFilterSubmit={onPreviewFilterSubmit ?? (() => {})} previewYankMode={filePreviewYankMode} diff --git a/src/hooks/inputEvents.ts b/src/hooks/inputEvents.ts index b33dd2b..0fa82e1 100644 --- a/src/hooks/inputEvents.ts +++ b/src/hooks/inputEvents.ts @@ -11,6 +11,7 @@ export interface InputRuntimeState { selectedRow: TableRow | null; helpOpen: boolean; pickerMode: "navigate" | "search" | null; + activePickerId: string | null; describeOpen: boolean; uploadPending: boolean; pendingActionType: "prompt" | "confirm" | null; @@ -27,7 +28,7 @@ export type InputEvent = | { scope: "help"; type: "goToTab"; input: string } | { scope: "picker"; - type: "close" | "cancelSearch" | "startSearch" | "down" | "up" | "top" | "bottom" | "confirm"; + type: "close" | "cancelSearch" | "startSearch" | "down" | "up" | "top" | "bottom" | "confirm" | "deleteItem"; } | { scope: "modal"; @@ -81,7 +82,7 @@ export type InputEvent = | "showHistogram" | "previewFile"; } - | { scope: "preview"; type: "nextPage" | "prevPage" | "scrollUp" | "scrollDown" | "colLeft" | "colRight" | "toTop" | "toBottom" } + | { scope: "preview"; type: "nextPage" | "prevPage" | "scrollUp" | "scrollDown" | "colLeft" | "colRight" | "toTop" | "toBottom" | "showDetails" } | { scope: "preview"; type: "yankColumn"; colIndex: number } | { scope: "scroll"; type: "up" | "down" } | { scope: "adapterAction"; type: "run"; actionId: string; row: TableRow | null }; diff --git a/src/hooks/mainInputScopes.test.ts b/src/hooks/mainInputScopes.test.ts index dd6886c..5367e61 100644 --- a/src/hooks/mainInputScopes.test.ts +++ b/src/hooks/mainInputScopes.test.ts @@ -17,10 +17,10 @@ describe("mainInputScopes", () => { }); it("resolves picker search consume behavior", () => { - expect(resolvePickerScopeAction(plainKey, "search", null)).toEqual({ + expect(resolvePickerScopeAction("", plainKey, "search", null, null)).toEqual({ type: "consume", }); - expect(resolvePickerScopeAction(escapeKey, "search", KB.PICKER_CLOSE)).toEqual({ + expect(resolvePickerScopeAction("", escapeKey, "search", KB.PICKER_CLOSE, null)).toEqual({ type: "close", }); }); diff --git a/src/hooks/mainInputScopes.ts b/src/hooks/mainInputScopes.ts index 77e8f84..435d274 100644 --- a/src/hooks/mainInputScopes.ts +++ b/src/hooks/mainInputScopes.ts @@ -37,12 +37,15 @@ export type PickerScopeAction = | { type: "top" } | { type: "bottom" } | { type: "confirm" } + | { type: "delete" } | { type: "none" }; export function resolvePickerScopeAction( + input: string, key: Key, pickerMode: "navigate" | "search", action: KeyAction | null, + activePickerId: string | null, ): PickerScopeAction { if (pickerMode === "search" && !key.escape) { return { type: "consume" }; @@ -64,6 +67,10 @@ export function resolvePickerScopeAction( case KB.PICKER_CONFIRM: return { type: "confirm" }; default: + // d key deletes selected item (only for bookmarks picker) + if (pickerMode === "navigate" && activePickerId === "bookmarks" && input === "d") { + return { type: "delete" }; + } return { type: "none" }; } } diff --git a/src/hooks/useAdapterStack.ts b/src/hooks/useAdapterStack.ts new file mode 100644 index 0000000..877efe2 --- /dev/null +++ b/src/hooks/useAdapterStack.ts @@ -0,0 +1,42 @@ +import { useAtomValue } from "jotai"; +import { type AdapterStackFrame } from "../state/atoms.js"; +import { createStackState } from "../utils/createStackState.js"; + +type AdapterNavFrame = { level: AdapterStackFrame | null }; + +const adapterStack = createStackState(null); + +const push = (frame: AdapterStackFrame): void => { + const cur = adapterStack.getLevel(); + adapterStack.setBackStack([...adapterStack.getBackStack(), { level: cur }]); + adapterStack.setLevel(frame); +}; + +const pop = (): AdapterStackFrame | undefined => { + const current = adapterStack.getLevel(); + adapterStack.goBack(); + return current ?? undefined; +}; + +/** Seed the stack with a root frame before React renders. Call once at startup. */ +export function seedAdapterRoot(root: AdapterStackFrame): void { + adapterStack.setBackStack([{ level: root }]); +} + +export function useAdapterStack() { + const level = useAtomValue(adapterStack.levelAtom); + const backStack = useAtomValue(adapterStack.backStackAtom); + + const stack = [ + ...backStack.map((f) => f.level).filter((l): l is AdapterStackFrame => l !== null), + ...(level ? [level] : []), + ]; + + return { + stack, + push, + pop, + clear: adapterStack.reset, + isEmpty: stack.length === 0, + }; +} diff --git a/src/hooks/useAppData.ts b/src/hooks/useAppData.ts index b5a433c..95a99d5 100644 --- a/src/hooks/useAppData.ts +++ b/src/hooks/useAppData.ts @@ -1,21 +1,21 @@ import { useMemo, useLayoutEffect } from "react"; import { SERVICE_REGISTRY } from "../services.js"; -import type { ServiceId } from "../services.js"; +import type { ServiceId, AwsServiceId } from "../services.js"; import type { ServiceAdapter, RelatedResource } from "../adapters/ServiceAdapter.js"; import { useServiceView } from "./useServiceView.js"; -import { useNavigation } from "./useNavigation.js"; -import { usePickerManager } from "./usePickerManager.js"; import { debugLog } from "../utils/debugLogger.js"; import { filterRowsByText } from "../utils/rowUtils.js"; -import { computeHeatmapColors, isNumericColumn } from "../utils/heatmap.js"; +import { applyHeatmapColors } from "../utils/heatmap.js"; import type { AwsRegionOption } from "./useAwsRegions.js"; import type { AwsProfileOption } from "./useAwsProfiles.js"; +import { createResourceAdapter } from "../views/_resources/adapter.js"; +import { createRegionAdapter } from "../views/_regions/adapter.js"; +import { createProfileAdapter } from "../views/_profiles/adapter.js"; interface UseAppDataArgs { currentService: ServiceId; endpointUrl: string | undefined; selectedRegion: string; - tableHeight: number; filterText: string; availableRegions: AwsRegionOption[]; availableProfiles: AwsProfileOption[]; @@ -30,11 +30,9 @@ export function useAppData({ currentService, endpointUrl, selectedRegion, - tableHeight, filterText, availableRegions, availableProfiles, - relatedResources, tagFilter, sortState, heatmapEnabled, @@ -42,8 +40,17 @@ export function useAppData({ }: UseAppDataArgs) { const adapter = useMemo(() => { debugLog(currentService, `useAppData: adapter created`); - return SERVICE_REGISTRY[currentService](endpointUrl, selectedRegion); - }, [currentService, endpointUrl, selectedRegion]); + switch (currentService) { + case "_resources": + return createResourceAdapter(); + case "_regions": + return createRegionAdapter(availableRegions); + case "_profiles": + return createProfileAdapter(availableProfiles); + default: + return SERVICE_REGISTRY[currentService as AwsServiceId](endpointUrl, selectedRegion); + } + }, [currentService, endpointUrl, selectedRegion, availableRegions, availableProfiles]); const { rows, columns, isLoading, error, select, edit, goBack, refresh, path } = useServiceView(adapter); @@ -89,38 +96,11 @@ export function useAppData({ [tagSortedRows, filterText], ); - // Heatmap: apply per-cell color to first numeric column when enabled - const filteredRows = useMemo(() => { - if (!heatmapEnabled) return textFilteredRows; - const numericCol = columns.find((c) => isNumericColumn(textFilteredRows, c.key)); - if (!numericCol) return textFilteredRows; - const colorMap = computeHeatmapColors(textFilteredRows, numericCol.key); - if (colorMap.size === 0) return textFilteredRows; - return textFilteredRows.map((row) => { - const color = colorMap.get(row.id); - if (!color) return row; - return { - ...row, - cells: { - ...row.cells, - [numericCol.key]: { - ...(row.cells[numericCol.key] ?? { displayName: "", type: "text" as const }), - color, - }, - }, - }; - }); - }, [textFilteredRows, heatmapEnabled, columns]); - - const navigation = useNavigation(filteredRows.length, tableHeight); - const selectedRow = filteredRows[navigation.selectedIndex] ?? null; - - const pickers = usePickerManager({ - tableHeight, - availableRegions, - availableProfiles, - ...(relatedResources !== undefined ? { relatedResources } : {}), - }); + // Heatmap: apply per-cell color to explicitly declared heatmap columns when enabled + const filteredRows = useMemo( + () => heatmapEnabled ? applyHeatmapColors(textFilteredRows, columns) : textFilteredRows, + [textFilteredRows, heatmapEnabled, columns], + ); return { adapter, @@ -134,8 +114,5 @@ export function useAppData({ refresh, path, filteredRows, - selectedRow, - navigation, - pickers, }; } diff --git a/src/hooks/useCommandRouter.ts b/src/hooks/useCommandRouter.ts index 1105399..73635a0 100644 --- a/src/hooks/useCommandRouter.ts +++ b/src/hooks/useCommandRouter.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { SERVICE_REGISTRY } from "../services.js"; -import type { ServiceId } from "../services.js"; +import type { ServiceId, AwsServiceId } from "../services.js"; export type ParsedCommand = | { type: "openProfiles" } @@ -56,7 +56,7 @@ export function parseCommand(input: string): ParsedCommand { } if (command in SERVICE_REGISTRY) { - return { type: "switchService", serviceId: command as ServiceId }; + return { type: "switchService", serviceId: command as AwsServiceId }; } return { type: "unknown" }; @@ -66,9 +66,6 @@ interface UseCommandRouterArgs { setSelectedRegion: (region: string) => void; setSelectedProfile: (profile: string) => void; switchAdapter: (serviceId: ServiceId) => void; - openProfilePicker: () => void; - openRegionPicker: () => void; - openResourcePicker: () => void; openThemePicker: () => void; openBookmarksPicker: () => void; setWatch: (seconds: number) => void; @@ -82,9 +79,6 @@ export function useCommandRouter({ setSelectedRegion, setSelectedProfile, switchAdapter, - openProfilePicker, - openRegionPicker, - openResourcePicker, openThemePicker, openBookmarksPicker, setWatch, @@ -98,13 +92,13 @@ export function useCommandRouter({ const parsed = parseCommand(input); switch (parsed.type) { case "openProfiles": - openProfilePicker(); + switchAdapter("_profiles"); return; case "openRegions": - openRegionPicker(); + switchAdapter("_regions"); return; case "openResources": - openResourcePicker(); + switchAdapter("_resources"); return; case "openThemePicker": openThemePicker(); @@ -140,6 +134,6 @@ export function useCommandRouter({ return; } }, - [setSelectedRegion, setSelectedProfile, switchAdapter, openProfilePicker, openRegionPicker, openResourcePicker, openThemePicker, openBookmarksPicker, setWatch, clearWatch, setTagFilter, clearTagFilter, exit], + [setSelectedRegion, setSelectedProfile, switchAdapter, openThemePicker, openBookmarksPicker, setWatch, clearWatch, setTagFilter, clearTagFilter, exit], ); } diff --git a/src/hooks/useDetailController.ts b/src/hooks/useDetailController.ts index 7362404..4d33460 100644 --- a/src/hooks/useDetailController.ts +++ b/src/hooks/useDetailController.ts @@ -53,14 +53,10 @@ export function useDetailController({ adapter, setDescribeState }: UseDetailCont try { const fields = adapter.capabilities?.detail ? await adapter.capabilities.detail.getDetails(selectedRow) - : [ - { - label: "Name", - value: getCellLabel(selectedRow.cells.name) ?? selectedRow.id, - }, - { label: "Type", value: String(selectedRow.meta?.type ?? "Unknown") }, - { label: "Details", value: "Not available for this service" }, - ]; + : adapter.getColumns().map((col) => ({ + label: col.label, + value: getCellLabel(selectedRow.cells[col.key]) ?? "-", + })); setDescribeState((prev) => applyDetailSuccess(prev, requestId, fields)); } catch (error) { diff --git a/src/hooks/useFilePreview.ts b/src/hooks/useFilePreview.ts index 4c80032..ffabcf5 100644 --- a/src/hooks/useFilePreview.ts +++ b/src/hooks/useFilePreview.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef } from "react"; import type { ColumnDef, TableRow } from "../types.js"; import type { ServiceAdapter } from "../adapters/ServiceAdapter.js"; import { useNavigation } from "./useNavigation.js"; +import { computeTableDataRows } from "../utils/layoutBudget.js"; import { debugLog } from "../utils/debugLogger.js"; const PAGE_SIZE = 10_000; @@ -21,9 +22,19 @@ export interface FilePreviewState { previewYankMode: boolean; } +function filterPreviewRows(ps: FilePreviewState): TableRow[] { + const text = ps.filterText.trim().toLowerCase(); + if (!text) return ps.rows; + return ps.rows.filter((r) => + Object.values(r.cells).some((c) => (c?.displayName ?? "").toLowerCase().includes(text)), + ); +} + interface UseFilePreviewReturn { previewState: FilePreviewState | null; previewNavigation: ReturnType; + /** Returns the currently selected row after applying the filter, or null. */ + getSelectedRow: () => TableRow | null; showPreview: (row: TableRow, adapter: ServiceAdapter) => void; closePreview: () => void; nextPage: () => void; @@ -37,12 +48,21 @@ interface UseFilePreviewReturn { cancelPreviewYank: () => void; } -export function useFilePreview(tableHeight: number): UseFilePreviewReturn { +export function useFilePreview(contentBudget: number): UseFilePreviewReturn { const [previewState, setPreviewState] = useState(null); + // Match FilePreviewPanel: border(2), then Table chrome via computeTableDataRows + // FilePreviewPanel always has contextLabel and 1 footerContent row + const tableBudget = contentBudget - 2; + const navPageSize = computeTableDataRows(tableBudget, { + hasContextLabel: true, + footerContentRows: 1, + totalRows: previewState?.rows.length ?? 0, + }); + const previewNavigation = useNavigation( previewState?.rows.length ?? 0, - tableHeight, + navPageSize, ); const resetRef = useRef(previewNavigation.reset); @@ -167,9 +187,16 @@ export function useFilePreview(tableHeight: number): UseFilePreviewReturn { setPreviewState((prev) => (prev ? { ...prev, previewYankMode: false } : null)); }, []); + const getSelectedRow = useCallback((): TableRow | null => { + if (!previewState) return null; + const rows = filterPreviewRows(previewState); + return rows[previewNavigation.selectedIndex] ?? null; + }, [previewState, previewNavigation.selectedIndex]); + return { previewState, previewNavigation, + getSelectedRow, showPreview, closePreview, nextPage, diff --git a/src/hooks/useHelpPanel.ts b/src/hooks/useHelpPanel.ts index fca34f6..105641a 100644 --- a/src/hooks/useHelpPanel.ts +++ b/src/hooks/useHelpPanel.ts @@ -8,20 +8,9 @@ export function useHelpPanel(helpTabs: HelpTab[], helpContainerHeight: number) { const helpTabsCount = helpTabs.length; - // Rows reserved for header/footer chrome above the list - const SCROLL_RESERVE_ROWS = 3; - // Extra row for the scroll position indicator line - const SCROLL_INDICATOR_ROW = 1; - - // Compute visible rows from container height and current tab - const baseHelpVisibleRows = Math.max(1, helpContainerHeight - SCROLL_RESERVE_ROWS); - const activeHelpItemsCount = helpTabs[helpTabIndex]?.items.length ?? 0; - const overflowRows = Math.max(0, activeHelpItemsCount - baseHelpVisibleRows); - const scrollReserveRows = Math.min(SCROLL_RESERVE_ROWS, overflowRows); - const helpVisibleRows = - overflowRows > 0 - ? Math.max(1, baseHelpVisibleRows - scrollReserveRows - SCROLL_INDICATOR_ROW) - : Math.max(1, baseHelpVisibleRows - SCROLL_INDICATOR_ROW); + // Chrome: border(2) + title(1) + scopeLabel(1) + tabRow(1) = 5 + const CHROME = 5; + const helpVisibleRows = Math.max(1, helpContainerHeight - CHROME); const clampTab = useCallback( (idx: number) => ((idx % helpTabsCount) + helpTabsCount) % helpTabsCount, diff --git a/src/hooks/useHierarchyState.ts b/src/hooks/useHierarchyState.ts deleted file mode 100644 index c60469f..0000000 --- a/src/hooks/useHierarchyState.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useCallback } from "react"; -import { useAtom } from "jotai"; -import { hierarchyStateAtom } from "../state/atoms.js"; - -export function useHierarchyState() { - const [state, setState] = useAtom(hierarchyStateAtom); - - const reset = useCallback(() => { - setState({ filters: [""], indices: [0] }); - }, [setState]); - - const updateCurrentFilter = useCallback( - (value: string) => { - setState((prev) => { - const nextFilters = - prev.filters.length === 0 ? [value] : [...prev.filters.slice(0, -1), value]; - return { ...prev, filters: nextFilters }; - }); - }, - [setState], - ); - - const pushLevel = useCallback( - (selectedIndex: number, nextFilter = "") => { - setState((prev) => ({ - filters: [...prev.filters, nextFilter], - indices: [...prev.indices, selectedIndex], - })); - }, - [setState], - ); - - const popLevel = useCallback(() => { - const nextFilters = state.filters.length > 1 ? state.filters.slice(0, -1) : state.filters; - const nextIndices = state.indices.length > 1 ? state.indices.slice(0, -1) : state.indices; - const restoredIndex = - state.indices.length > 1 - ? (state.indices[state.indices.length - 1] ?? 0) - : (state.indices[0] ?? 0); - const restoredFilter = nextFilters[nextFilters.length - 1] ?? ""; - - setState({ filters: nextFilters, indices: nextIndices }); - return { restoredFilter, restoredIndex }; - }, [setState, state.filters, state.indices]); - - return { - filters: state.filters, - indices: state.indices, - reset, - updateCurrentFilter, - pushLevel, - popLevel, - }; -} diff --git a/src/hooks/useInputEventProcessor.test.ts b/src/hooks/useInputEventProcessor.test.ts index 4b2ff12..5e1ab65 100644 --- a/src/hooks/useInputEventProcessor.test.ts +++ b/src/hooks/useInputEventProcessor.test.ts @@ -19,6 +19,7 @@ const baseRuntime: InputRuntimeState = { selectedRow: null, helpOpen: false, pickerMode: null, + activePickerId: null, describeOpen: false, uploadPending: false, pendingActionType: null, @@ -157,6 +158,7 @@ describe("applyInputEvent", () => { top: vi.fn(), bottom: vi.fn(), confirm: vi.fn(), + deleteItem: vi.fn(), }, mode: { cancelSearchOrCommand: vi.fn(), @@ -201,6 +203,7 @@ describe("applyInputEvent", () => { enterYank: vi.fn(), cancelYank: vi.fn(), yankColumn: vi.fn(), + showDetails: vi.fn(), }, scroll: { up: vi.fn(), diff --git a/src/hooks/useInputEventProcessor.ts b/src/hooks/useInputEventProcessor.ts index b691f4c..7245f97 100644 --- a/src/hooks/useInputEventProcessor.ts +++ b/src/hooks/useInputEventProcessor.ts @@ -35,6 +35,7 @@ export interface InputEventActions { top: () => void; bottom: () => void; confirm: () => void; + deleteItem: () => void; }; mode: { cancelSearchOrCommand: () => void; @@ -79,6 +80,7 @@ export interface InputEventActions { enterYank: () => void; cancelYank: () => void; yankColumn: (colIndex: number) => void; + showDetails: () => void; }; scroll: { up: () => void; @@ -148,9 +150,11 @@ export function translateRawInputEvent( if (runtime.pickerMode) { const action = resolvePickerScopeAction( + input, key, runtime.pickerMode, deps.resolve(input, key, "picker"), + runtime.activePickerId, ); switch (action.type) { @@ -176,6 +180,8 @@ export function translateRawInputEvent( return { event: { scope: "picker", type: "bottom" }, resetChord: true }; case "confirm": return { event: { scope: "picker", type: "confirm" }, resetChord: true }; + case "delete": + return { event: { scope: "picker", type: "deleteItem" }, resetChord: true }; case "none": return { event: null, resetChord: false }; } @@ -280,6 +286,9 @@ export function translateRawInputEvent( if (input === "y") { return { event: { scope: "modal", type: "enterPreviewYank" }, resetChord: true }; } + if (input === "d") { + return { event: { scope: "preview", type: "showDetails" }, resetChord: true }; + } const scrollAction = deps.resolve(input, key, "navigate"); if (scrollAction === KB.MOVE_DOWN) { return { event: { scope: "preview", type: "scrollDown" }, resetChord: false }; @@ -506,6 +515,9 @@ export function applyInputEvent(event: InputEvent, actions: InputEventActions): case "confirm": actions.picker.confirm(); return; + case "deleteItem": + actions.picker.deleteItem(); + return; } return; case "modal": @@ -669,6 +681,9 @@ export function applyInputEvent(event: InputEvent, actions: InputEventActions): case "yankColumn": actions.preview.yankColumn(event.colIndex); return; + case "showDetails": + actions.preview.showDetails(); + return; } return; case "scroll": diff --git a/src/hooks/useKeyChord.ts b/src/hooks/useKeyChord.ts index 86330af..7438fa0 100644 --- a/src/hooks/useKeyChord.ts +++ b/src/hooks/useKeyChord.ts @@ -30,6 +30,9 @@ export function matchesTrigger(input: string, inkKey: Key, trigger: KeyTrigger): switch (trigger.type) { case "key": return input === trigger.char; + case "ctrl": + // Ctrl+A = \x01, Ctrl+B = \x02, etc. + return input === String.fromCharCode(trigger.char.toUpperCase().charCodeAt(0) - 0x40); case "special": return matchSpecial(inkKey, trigger.name); case "chord": diff --git a/src/hooks/usePickerManager.ts b/src/hooks/usePickerManager.ts index 0d820b3..3374957 100644 --- a/src/hooks/usePickerManager.ts +++ b/src/hooks/usePickerManager.ts @@ -1,21 +1,18 @@ -import { useMemo } from "react"; -import type { AwsRegionOption } from "./useAwsRegions.js"; -import type { AwsProfileOption } from "./useAwsProfiles.js"; +import { useMemo, useState, useCallback } from "react"; import type { ColumnDef, TableRow } from "../types.js"; import { textCell } from "../types.js"; import { usePickerState } from "./usePickerState.js"; import { usePickerTable } from "./usePickerTable.js"; -import { SERVICE_REGISTRY } from "../services.js"; -import type { ServiceId } from "../services.js"; import { THEMES, THEME_LABELS } from "../constants/theme.js"; import type { ThemeName } from "../constants/theme.js"; +import type { ServiceId } from "../services.js"; import type { RelatedResource } from "../adapters/ServiceAdapter.js"; -import { loadBookmarks } from "../utils/bookmarks.js"; +import { loadBookmarks, getBookmarkDisplayName } from "../utils/bookmarks.js"; import type { BookmarkEntry } from "../utils/bookmarks.js"; export interface PickerEntry { // Picker identity - id: "region" | "profile" | "resource" | "theme" | "related" | "bookmarks"; + id: "theme" | "related" | "bookmarks"; columns: ColumnDef[]; contextLabel: string; // Picker open/filter/search state @@ -43,15 +40,10 @@ export interface PickerEntry { interface UsePickerManagerArgs { tableHeight: number; - availableRegions: AwsRegionOption[]; - availableProfiles: AwsProfileOption[]; relatedResources?: RelatedResource[]; } export interface PickerManager { - region: PickerEntry; - profile: PickerEntry; - resource: PickerEntry; theme: PickerEntry; related: PickerEntry; bookmarks: PickerEntry; @@ -59,10 +51,8 @@ export interface PickerManager { openPicker: (id: PickerEntry["id"]) => void; closeActivePicker: () => void; resetPicker: (id: PickerEntry["id"]) => void; + refreshPicker: (id: PickerEntry["id"]) => void; confirmActivePickerSelection: (handlers: { - onSelectResource: (resourceId: ServiceId) => void; - onSelectRegion: (region: string) => void; - onSelectProfile: (profile: string) => void; onSelectTheme: (themeName: ThemeName) => void; onSelectRelated: (serviceId: ServiceId, filterHint?: string) => void; onSelectBookmark?: (entry: BookmarkEntry) => void; @@ -71,49 +61,12 @@ export interface PickerManager { export function usePickerManager({ tableHeight, - availableRegions, - availableProfiles, relatedResources = [], }: UsePickerManagerArgs): PickerManager { - const region = usePickerState(); - const profile = usePickerState(); - const resource = usePickerState(); const theme = usePickerState(); const related = usePickerState(); const bookmarksPicker = usePickerState(); - - const regionRows = useMemo( - () => - availableRegions.map((r) => ({ - id: r.name, - cells: { region: textCell(r.name), description: textCell(r.description) }, - meta: {}, - })), - [availableRegions], - ); - - const profileRows = useMemo( - () => - availableProfiles.map((p) => ({ - id: p.name, - cells: { profile: textCell(p.name), description: textCell(p.description) }, - meta: {}, - })), - [availableProfiles], - ); - - const resourceRows = useMemo( - () => - (Object.keys(SERVICE_REGISTRY) as ServiceId[]).map((serviceId) => ({ - id: serviceId, - cells: { - resource: textCell(serviceId), - description: textCell(`${serviceId.toUpperCase()} service`), - }, - meta: {}, - })), - [], - ); + const [bookmarksRefreshToken, setBookmarksRefreshToken] = useState(0); const themeRows = useMemo( () => @@ -146,29 +99,14 @@ export function usePickerManager({ return loadBookmarks().map((entry) => ({ id: `${entry.serviceId}::${entry.rowId}`, cells: { - label: textCell(entry.rowLabel), + label: textCell(getBookmarkDisplayName(entry)), service: textCell(entry.serviceId), savedAt: textCell(entry.savedAt.slice(0, 10)), }, meta: { bookmarkEntry: entry }, })); - }, [bookmarksPicker.open]); + }, [bookmarksPicker.open, bookmarksRefreshToken]); - const regionTable = usePickerTable({ - rows: regionRows, - filterText: region.filter, - maxHeight: tableHeight, - }); - const profileTable = usePickerTable({ - rows: profileRows, - filterText: profile.filter, - maxHeight: tableHeight, - }); - const resourceTable = usePickerTable({ - rows: resourceRows, - filterText: resource.filter, - maxHeight: tableHeight, - }); const themeTable = usePickerTable({ rows: themeRows, filterText: theme.filter, @@ -187,21 +125,6 @@ export function usePickerManager({ maxHeight: tableHeight, }); - const regionColumns: ColumnDef[] = [ - { key: "region", label: "Region" }, - { key: "description", label: "Description" }, - ]; - - const profileColumns: ColumnDef[] = [ - { key: "profile", label: "Profile" }, - { key: "description", label: "Description" }, - ]; - - const resourceColumns: ColumnDef[] = [ - { key: "resource", label: "Resource" }, - { key: "description", label: "Description" }, - ]; - const themeColumns: ColumnDef[] = [ { key: "theme", label: "Theme" }, { key: "id", label: "ID" }, @@ -218,30 +141,6 @@ export function usePickerManager({ { key: "savedAt", label: "Saved", width: 12 }, ]; - const regionEntry: PickerEntry = { - id: "region", - columns: regionColumns, - contextLabel: "Select AWS Region", - ...region, - ...regionTable, - }; - - const profileEntry: PickerEntry = { - id: "profile", - columns: profileColumns, - contextLabel: "Select AWS Profile", - ...profile, - ...profileTable, - }; - - const resourceEntry: PickerEntry = { - id: "resource", - columns: resourceColumns, - contextLabel: "Select AWS Resource", - ...resource, - ...resourceTable, - }; - const themeEntry: PickerEntry = { id: "theme", columns: themeColumns, @@ -267,17 +166,11 @@ export function usePickerManager({ }; const activePicker = - [regionEntry, profileEntry, resourceEntry, themeEntry, relatedEntry, bookmarksEntry].find((e) => e.open) ?? + [themeEntry, relatedEntry, bookmarksEntry].find((e) => e.open) ?? null; const getEntry = (id: PickerEntry["id"]): PickerEntry => { switch (id) { - case "region": - return regionEntry; - case "profile": - return profileEntry; - case "resource": - return resourceEntry; case "theme": return themeEntry; case "related": @@ -307,15 +200,6 @@ export function usePickerManager({ if (!activePicker?.selectedRow) return; switch (activePicker.id) { - case "resource": - handlers.onSelectResource(activePicker.selectedRow.id as ServiceId); - break; - case "region": - handlers.onSelectRegion(activePicker.selectedRow.id); - break; - case "profile": - handlers.onSelectProfile(activePicker.selectedRow.id); - break; case "theme": handlers.onSelectTheme(activePicker.selectedRow.id as ThemeName); break; @@ -336,9 +220,6 @@ export function usePickerManager({ }; return { - region: regionEntry, - profile: profileEntry, - resource: resourceEntry, theme: themeEntry, related: relatedEntry, bookmarks: bookmarksEntry, @@ -346,6 +227,9 @@ export function usePickerManager({ openPicker, closeActivePicker, resetPicker, + refreshPicker: (id: PickerEntry["id"]) => { + if (id === "bookmarks") setBookmarksRefreshToken((n) => n + 1); + }, confirmActivePickerSelection, }; } diff --git a/src/hooks/useServiceView.ts b/src/hooks/useServiceView.ts index c6a33c1..b1a8288 100644 --- a/src/hooks/useServiceView.ts +++ b/src/hooks/useServiceView.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useLayoutEffect, useReducer } from "react"; -import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"; +import { useAtomValue, getDefaultStore } from "jotai"; import open from "open"; import { extname } from "path"; import { execFileSync } from "child_process"; @@ -7,7 +7,7 @@ import { stat } from "fs/promises"; import type { ServiceAdapter } from "../adapters/ServiceAdapter.js"; import type { TableRow, ColumnDef, SelectResult, ServiceViewResult } from "../types.js"; import { debugLog } from "../utils/debugLogger.js"; -import { adapterSessionAtom } from "../state/atoms.js"; +import { adapterSessionAtom, bookmarkRestoreAtom } from "../state/atoms.js"; const TEXT_EXTENSIONS = new Set([ ".txt", @@ -120,9 +120,12 @@ export function useServiceView(adapter: ServiceAdapter, navKey?: number) { error: null, }); + const fetchGenRef = useRef(0); + // Clear data atomically when adapter session changes (before paint) useLayoutEffect(() => { if (state.adapterId !== adapterId) { + fetchGenRef.current += 1; // invalidate in-flight fetches debugLog(adapterId, "useLayoutEffect: adapter changed, clearing data"); dispatch({ type: "ADAPTER_CHANGED", adapterId }); @@ -155,14 +158,24 @@ export function useServiceView(adapter: ServiceAdapter, navKey?: number) { ); const performFetch = useCallback(async () => { + const gen = fetchGenRef.current; debugLog(adapterId, "fetching rows..."); + // Apply pending bookmark restore before reading level state + const store = getDefaultStore(); + const pending = store.get(bookmarkRestoreAtom); + if (pending?.serviceId === adapterId && adapter.restoreFromKey) { + store.set(bookmarkRestoreAtom, null); + adapter.restoreFromKey(pending.key); + } const columns = adapter.getColumns(); dispatch({ type: "SET_ERROR", error: null }); try { const r = await adapter.getRows(); + if (fetchGenRef.current !== gen) return; // stale — discard debugLog(adapterId, `got ${r.length} rows from adapter`); dispatch({ type: "SET_DATA", rows: r, columns }); } catch (e) { + if (fetchGenRef.current !== gen) return; // stale — discard const message = e instanceof Error ? e.message : String(e); debugLog(adapterId, "fetch error", message); dispatch({ type: "SET_ERROR", error: message }); @@ -232,11 +245,12 @@ export function useServiceView(adapter: ServiceAdapter, navKey?: number) { [adapter, processResult, runWithLoading], ); - const goBack = useCallback(async () => { - if (!adapter.canGoBack()) return; - await runWithLoading(async () => { - adapter.goBack(); + const goBack = useCallback(async (): Promise<{ filterText: string; selectedIndex: number } | undefined> => { + if (!adapter.canGoBack()) return undefined; + return runWithLoading(async () => { + const restored = adapter.goBack(); await refresh(); + return restored; }); }, [adapter, refresh, runWithLoading]); diff --git a/src/hooks/useTableLayout.ts b/src/hooks/useTableLayout.ts new file mode 100644 index 0000000..7214128 --- /dev/null +++ b/src/hooks/useTableLayout.ts @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import type { TableRow } from "../types.js"; +import type { ServiceAdapter, RelatedResource } from "../adapters/ServiceAdapter.js"; +import { useNavigation } from "./useNavigation.js"; +import { usePickerManager } from "./usePickerManager.js"; +import { computeTableDataRows, computeSearchHistoryLines } from "../utils/layoutBudget.js"; +import { summarizeRowStatuses } from "../utils/rowUtils.js"; + +interface UseTableLayoutArgs { + contentBudget: number; + adapter: ServiceAdapter; + filteredRows: TableRow[]; + showSearchHistory: boolean; + searchHistoryLength: number; + relatedResources: RelatedResource[]; +} + +export function useTableLayout({ + contentBudget, + adapter, + filteredRows, + showSearchHistory, + searchHistoryLength, + relatedResources, +}: UseTableLayoutArgs) { + const hasContextLabel = Boolean(adapter.getContextLabel?.()); + const statusSummary = useMemo(() => summarizeRowStatuses(filteredRows), [filteredRows]); + const hasStatusRow = statusSummary.byColor.length > 0; + // Only reserve for status row (deterministic). Preview row is rare (only when columns + // are truncated) and the 1-line cost of not reserving is absorbed by flex layout. + const footerContentRows = hasStatusRow ? 1 : 0; + const searchHistoryLines = computeSearchHistoryLines(showSearchHistory, searchHistoryLength); + + const dataRows = computeTableDataRows(contentBudget - searchHistoryLines, { + hasContextLabel, + footerContentRows, + totalRows: filteredRows.length, + }); + + const navigation = useNavigation(filteredRows.length, dataRows); + const selectedRow = filteredRows[navigation.selectedIndex] ?? null; + const pickers = usePickerManager({ + tableHeight: dataRows, + ...(relatedResources.length > 0 ? { relatedResources } : {}), + }); + + return { + dataRows, + navigation, + selectedRow, + pickers, + statusSummary, + }; +} diff --git a/src/index.tsx b/src/index.tsx index ab70dbd..aeaad1d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,12 +1,15 @@ import React from "react"; +import { getDefaultStore } from "jotai"; import { Command, Option } from "@commander-js/extra-typings"; import { App } from "./App.js"; -import { SERVICE_REGISTRY, type ServiceId } from "./services.js"; +import { SERVICE_REGISTRY, type AwsServiceId } from "./services.js"; import { withFullscreen } from "./utils/withFullscreen.js"; import { ThemeProvider } from "./contexts/ThemeContext.js"; import { cleanupStaleBrowserProfiles } from "./utils/consoleUrl.js"; +import { currentlySelectedServiceAtom } from "./state/atoms.js"; +import { seedAdapterRoot } from "./hooks/useAdapterStack.js"; -const SERVICE_IDS = Object.keys(SERVICE_REGISTRY) as ServiceId[]; +const SERVICE_IDS = Object.keys(SERVICE_REGISTRY) as AwsServiceId[]; const program = new Command() .name("a9s") @@ -14,8 +17,7 @@ const program = new Command() .version("0.1.0") .addOption( new Option("-s, --service ", "AWS service to browse") - .choices(SERVICE_IDS) - .default("s3" satisfies ServiceId), + .choices(SERVICE_IDS), ) .addOption( new Option( @@ -29,13 +31,19 @@ program.parse(); // opts() return type is fully inferred from addOption() calls via extra-typings const options = program.opts(); +// Seed initial state before React renders — _resources is always the root +if (options.service) { + getDefaultStore().set(currentlySelectedServiceAtom, options.service); + seedAdapterRoot({ adapterId: "_resources", filterText: "", selectedIndex: 0 }); +} + // Best-effort cleanup of stale isolated browser profile dirs (non-blocking) cleanupStaleBrowserProfiles(); void (async () => { const { instance, cleanup } = withFullscreen( - + , ); diff --git a/src/services.ts b/src/services.ts index 0ebd384..377d913 100644 --- a/src/services.ts +++ b/src/services.ts @@ -51,4 +51,6 @@ export const SERVICE_REGISTRY = { createApiGatewayServiceAdapter(undefined, region), } as const; -export type ServiceId = keyof typeof SERVICE_REGISTRY; +export type AwsServiceId = keyof typeof SERVICE_REGISTRY; +export type MetaAdapterId = "_resources" | "_regions" | "_profiles"; +export type ServiceId = AwsServiceId | MetaAdapterId; diff --git a/src/state/atoms.ts b/src/state/atoms.ts index c0ecb81..a60a756 100644 --- a/src/state/atoms.ts +++ b/src/state/atoms.ts @@ -2,15 +2,17 @@ import { atom } from "jotai"; import type { ServiceId } from "../services.js"; import type { ThemeName } from "../constants/theme.js"; import { loadConfig } from "../utils/config.js"; +import type { BookmarkKeyPart } from "../utils/bookmarks.js"; -/** Persists across HMR / re-renders. Currently selected AWS service. */ -export const currentlySelectedServiceAtom = atom("s3"); +/** A frame in the adapter navigation stack, capturing state to restore on pop. */ +export interface AdapterStackFrame { + adapterId: ServiceId; + filterText: string; + selectedIndex: number; +} -/** Navigation history: parallel stacks of filter texts and selected indices per level. */ -export const hierarchyStateAtom = atom<{ filters: string[]; indices: number[] }>({ - filters: [""], - indices: [0], -}); +/** Persists across HMR / re-renders. Currently selected AWS service. */ +export const currentlySelectedServiceAtom = atom("_resources"); /** Selected AWS region. Falls back to env vars or us-east-1. */ export const selectedRegionAtom = atom( @@ -33,3 +35,10 @@ export const revealSecretsAtom = atom(false); /** Active UI theme name — initialized from ~/.config/a9s/config.json on startup. */ export const themeNameAtom = atom(loadConfig().theme ?? "monokai"); + +/** Pending bookmark level restore — consumed by performFetch before getRows(). */ +export interface BookmarkRestoreState { + serviceId: string; + key: BookmarkKeyPart[]; +} +export const bookmarkRestoreAtom = atom(null); diff --git a/src/types.ts b/src/types.ts index 8d0d4cd..7b67780 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,21 @@ +export type HeatmapColumnConfig = + | { type: "numeric" } // uses parseNumericValue (K/M/G suffixes) + | { type: "date" } // uses parseDateValue (YYYY-MM-DD or YYYY-MM-DD HH:mm:ss) + | { type: "custom"; parse: (displayValue: string) => number }; + export interface ColumnDef { key: string; label: string; width?: number; // fixed width; undefined = flex minWidth?: number; + heatmap?: HeatmapColumnConfig; // undefined = no heatmap for this column } export interface Cell { displayName: string; type?: "text" | "secret"; // defaults to 'text' if not specified color?: string; // optional per-cell foreground color (ignored when row is selected) + isHeatmap?: boolean; // true when color is from heatmap (not status) — excluded from status summary } export interface TableRow { diff --git a/src/utils/bookmarks.ts b/src/utils/bookmarks.ts index 0282d77..5938532 100644 --- a/src/utils/bookmarks.ts +++ b/src/utils/bookmarks.ts @@ -1,23 +1,50 @@ import { readFileSync, writeFileSync, mkdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; +import { z } from "zod"; +import type { TableRow } from "../types.js"; const BOOKMARKS_DIR = join(homedir(), ".a9s"); const BOOKMARKS_FILE = join(BOOKMARKS_DIR, "bookmarks.json"); const MAX_BOOKMARKS = 200; -export interface BookmarkEntry { - serviceId: string; - rowId: string; - rowLabel: string; - rowArn?: string; - savedAt: string; // ISO date +const BookmarkKeyPartSchema = z.object({ + label: z.string(), + displayName: z.string(), + id: z.string(), +}); + +const BookmarkEntrySchema = z.object({ + serviceId: z.string(), + rowId: z.string(), + key: z.array(BookmarkKeyPartSchema), + rowArn: z.string().optional(), + savedAt: z.string(), +}); + +export type BookmarkKeyPart = z.infer; +export type BookmarkEntry = z.infer; + +export function singlePartKey(label: string, row: TableRow): BookmarkKeyPart[] { + const nameCell = row.cells.name; + const name = typeof nameCell === "object" ? (nameCell?.displayName ?? row.id) : (nameCell ?? row.id); + return [{ label, displayName: name, id: row.id }]; +} + +export function getBookmarkDisplayName(entry: BookmarkEntry): string { + return entry.key.map((p) => p.displayName).join(" › "); } function loadBookmarkData(): BookmarkEntry[] { try { const content = readFileSync(BOOKMARKS_FILE, "utf-8"); - return JSON.parse(content) as BookmarkEntry[]; + const parsed: unknown = JSON.parse(content); + const result = z.array(z.unknown()).safeParse(parsed); + if (!result.success) return []; + return result.data.flatMap((item) => { + const entry = BookmarkEntrySchema.safeParse(item); + return entry.success ? [entry.data] : []; + }); } catch { return []; } diff --git a/src/utils/createStackState.ts b/src/utils/createStackState.ts new file mode 100644 index 0000000..37151df --- /dev/null +++ b/src/utils/createStackState.ts @@ -0,0 +1,51 @@ +import { atom, getDefaultStore, type Atom } from "jotai"; + +export function createStackState< + Level, + Frame extends { level: Level } +>(initialLevel: Level) { + const store = getDefaultStore(); + const levelAtom = atom(initialLevel); + const backStackAtom = atom([]); + const uiBackStackAtom = atom<{ filterText: string; selectedIndex: number }[]>([]); + + const getLevel = (): Level => store.get(levelAtom); + const setLevel = (l: Level): void => store.set(levelAtom, l); + const getBackStack = (): Frame[] => store.get(backStackAtom); + const setBackStack = (s: Frame[]): void => store.set(backStackAtom, s); + + const canGoBack = (): boolean => getBackStack().length > 0; + + const pushUiLevel = (filterText: string, selectedIndex: number): void => { + const s = store.get(uiBackStackAtom); + store.set(uiBackStackAtom, [...s, { filterText, selectedIndex }]); + }; + + const goBack = (): { filterText: string; selectedIndex: number } | undefined => { + const s = getBackStack(); + if (!s.length) return undefined; + const top = s[s.length - 1]!; + setBackStack(s.slice(0, -1)); + setLevel(top.level); + + const ui = store.get(uiBackStackAtom); + if (!ui.length) return undefined; + const uiTop = ui[ui.length - 1]!; + store.set(uiBackStackAtom, ui.slice(0, -1)); + return uiTop; + }; + + const reset = (): void => { + setLevel(initialLevel); + setBackStack([]); + store.set(uiBackStackAtom, []); + }; + + return { + getLevel, setLevel, + getBackStack, setBackStack, + canGoBack, goBack, pushUiLevel, reset, + levelAtom: levelAtom as Atom, + backStackAtom: backStackAtom as Atom, + }; +} diff --git a/src/utils/heatmap.ts b/src/utils/heatmap.ts index 07da980..33d6b55 100644 --- a/src/utils/heatmap.ts +++ b/src/utils/heatmap.ts @@ -1,14 +1,4 @@ -import type { TableRow } from "../types.js"; - -/** Check if a column is predominantly numeric (>= 80% of non-empty values parse as numbers) */ -export function isNumericColumn(rows: TableRow[], colKey: string): boolean { - const values = rows - .map((r) => r.cells[colKey]?.displayName ?? "") - .filter((v) => v !== "" && v !== "-"); - if (values.length === 0) return false; - const numericCount = values.filter((v) => !isNaN(parseFloat(v.replace(/[,KMGTkmgtB% ]/g, "")))).length; - return numericCount / values.length >= 0.8; -} +import type { TableRow, ColumnDef, HeatmapColumnConfig } from "../types.js"; /** Parse a display value to a float, stripping common suffixes */ export function parseNumericValue(v: string): number { @@ -23,27 +13,66 @@ export function parseNumericValue(v: string): number { return num; } +/** Parse a date display value to a timestamp (ms since epoch). + * Handles both ISO "YYYY-MM-DDTHH:mm:ss" and space-separated "YYYY-MM-DD HH:mm:ss". */ +export function parseDateValue(v: string): number { + const ts = Date.parse(v.replace(" ", "T")); + return isNaN(ts) ? NaN : ts; +} + /** Compute per-row heatmap colors for a numeric column */ -export function computeHeatmapColors(rows: TableRow[], colKey: string): Map { +export function computeHeatmapColors(rows: TableRow[], colKey: string, parseValue = parseNumericValue): Map { const result = new Map(); const values = rows.map((r) => ({ id: r.id, - val: parseNumericValue(r.cells[colKey]?.displayName ?? ""), + val: parseValue(r.cells[colKey]?.displayName ?? ""), })).filter((x) => !isNaN(x.val)); if (values.length === 0) return result; const min = Math.min(...values.map((x) => x.val)); const max = Math.max(...values.map((x) => x.val)); - if (max === min) return result; const colors = ["green", "cyan", "yellow", "red"]; for (const { id, val } of values) { - const ratio = (val - min) / (max - min); + const ratio = max === min ? 0 : (val - min) / (max - min); const idx = Math.min(Math.floor(ratio * colors.length), colors.length - 1); result.set(id, colors[idx]!); } return result; } + +/** Select the right parse function for a heatmap column config */ +export function getHeatmapParser(config: HeatmapColumnConfig): (v: string) => number { + if (config.type === "date") return parseDateValue; + if (config.type === "custom") return config.parse; + return parseNumericValue; +} + +/** Apply heatmap colors to all declared heatmap columns, returning updated rows */ +export function applyHeatmapColors(rows: TableRow[], columns: ColumnDef[]): TableRow[] { + const colMaps: { key: string; map: Map }[] = []; + for (const col of columns) { + if (!col.heatmap) continue; + const map = computeHeatmapColors(rows, col.key, getHeatmapParser(col.heatmap)); + if (map.size > 0) colMaps.push({ key: col.key, map }); + } + if (colMaps.length === 0) return rows; + + return rows.map((row) => { + const updatedCells = { ...row.cells }; + for (const { key, map } of colMaps) { + const color = map.get(row.id); + if (color) { + updatedCells[key] = { + ...(row.cells[key] ?? { displayName: "", type: "text" as const }), + color, + isHeatmap: true, + }; + } + } + return { ...row, cells: updatedCells }; + }); +} diff --git a/src/utils/layoutBudget.ts b/src/utils/layoutBudget.ts new file mode 100644 index 0000000..ca441a8 --- /dev/null +++ b/src/utils/layoutBudget.ts @@ -0,0 +1,38 @@ +export function computeOuterChrome(opts: { + hasPendingPrompt: boolean; + hasPendingConfirm: boolean; +}): number { + const HUD = 3; + const FEEDBACK = 1; + const MODEBAR = 1; + let total = HUD + FEEDBACK + MODEBAR; // = 5 + if (opts.hasPendingPrompt) total += 1; + if (opts.hasPendingConfirm) total += 1; + return total; +} + +export function computeTableDataRows( + contentBudget: number, + opts: { + hasContextLabel: boolean; + footerContentRows: number; // 0, 1, or 2 + totalRows: number; + }, +): number { + let chrome = 2; // header + divider (always) + if (opts.hasContextLabel) chrome += 2; // label + spacer + if (opts.footerContentRows > 0) chrome += 1 + opts.footerContentRows; // divider + content + + // Two-pass pagination resolution + const rowsWithout = Math.max(1, contentBudget - chrome); + if (opts.totalRows <= rowsWithout) return rowsWithout; // no pagination needed + return Math.max(1, contentBudget - chrome - 2); // pagination: paddingTop(1) + text(1) +} + +export function computeSearchHistoryLines( + showSearchHistory: boolean, + historyLength: number, +): number { + if (!showSearchHistory || historyLength === 0) return 0; + return Math.min(historyLength, 5) + 2; // entries + border top/bottom +} diff --git a/src/utils/rowUtils.ts b/src/utils/rowUtils.ts index e062616..d420453 100644 --- a/src/utils/rowUtils.ts +++ b/src/utils/rowUtils.ts @@ -30,7 +30,7 @@ export function summarizeRowStatuses(rows: TableRow[]): StatusSummary { for (const row of rows) { for (const cell of Object.values(row.cells)) { - if (typeof cell === "object" && cell?.color) { + if (typeof cell === "object" && cell?.color && !cell.isHeatmap) { colorCounts.set(cell.color, (colorCounts.get(cell.color) ?? 0) + 1); break; // only count first colored cell per row } diff --git a/src/views/_profiles/adapter.ts b/src/views/_profiles/adapter.ts new file mode 100644 index 0000000..758042b --- /dev/null +++ b/src/views/_profiles/adapter.ts @@ -0,0 +1,55 @@ +import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; +import type { ColumnDef, TableRow, SelectResult } from "../../types.js"; +import { textCell } from "../../types.js"; +import type { AwsProfileOption } from "../../hooks/useAwsProfiles.js"; +import { SERVICE_COLORS } from "../../constants/theme.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; + +export function createProfileAdapter(availableProfiles: AwsProfileOption[]): ServiceAdapter { + return { + id: "_profiles", + label: "Profiles", + hudColor: SERVICE_COLORS._profiles ?? { bg: "magenta", fg: "white" }, + + getColumns(): ColumnDef[] { + return [ + { key: "profile", label: "Profile" }, + { key: "description", label: "Description" }, + ]; + }, + + async getRows(): Promise { + return availableProfiles.map((p) => ({ + id: p.name, + cells: { + profile: textCell(p.name), + description: textCell(p.description), + }, + meta: {}, + })); + }, + + async onSelect(_row: TableRow): Promise { + return { action: "none" }; + }, + + canGoBack(): boolean { + return true; + }, + + goBack(): undefined { + return undefined; + }, + + pushUiLevel(_filterText: string, _selectedIndex: number): void { + // Flat adapter — never called + }, + + getPath(): string { + return "profiles"; + }, + getBookmarkKey(row: TableRow) { + return singlePartKey("Profile", row); + }, + }; +} diff --git a/src/views/_regions/adapter.ts b/src/views/_regions/adapter.ts new file mode 100644 index 0000000..891fc68 --- /dev/null +++ b/src/views/_regions/adapter.ts @@ -0,0 +1,55 @@ +import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; +import type { ColumnDef, TableRow, SelectResult } from "../../types.js"; +import { textCell } from "../../types.js"; +import type { AwsRegionOption } from "../../hooks/useAwsRegions.js"; +import { SERVICE_COLORS } from "../../constants/theme.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; + +export function createRegionAdapter(availableRegions: AwsRegionOption[]): ServiceAdapter { + return { + id: "_regions", + label: "Regions", + hudColor: SERVICE_COLORS._regions ?? { bg: "blue", fg: "white" }, + + getColumns(): ColumnDef[] { + return [ + { key: "region", label: "Region" }, + { key: "description", label: "Description" }, + ]; + }, + + async getRows(): Promise { + return availableRegions.map((r) => ({ + id: r.name, + cells: { + region: textCell(r.name), + description: textCell(r.description), + }, + meta: {}, + })); + }, + + async onSelect(_row: TableRow): Promise { + return { action: "none" }; + }, + + canGoBack(): boolean { + return true; + }, + + goBack(): undefined { + return undefined; + }, + + pushUiLevel(_filterText: string, _selectedIndex: number): void { + // Flat adapter — never called + }, + + getPath(): string { + return "regions"; + }, + getBookmarkKey(row: TableRow) { + return singlePartKey("Region", row); + }, + }; +} diff --git a/src/views/_resources/adapter.ts b/src/views/_resources/adapter.ts new file mode 100644 index 0000000..829e8a3 --- /dev/null +++ b/src/views/_resources/adapter.ts @@ -0,0 +1,80 @@ +import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; +import type { ColumnDef, TableRow, SelectResult } from "../../types.js"; +import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; +import { SERVICE_REGISTRY } from "../../services.js"; +import type { AwsServiceId } from "../../services.js"; +import { SERVICE_COLORS } from "../../constants/theme.js"; + +const SERVICE_DESCRIPTIONS: Record = { + s3: "Simple Storage Service", + route53: "DNS Web Service", + dynamodb: "NoSQL Database", + iam: "Identity & Access Management", + secretsmanager: "Secrets Manager", + ec2: "Elastic Compute Cloud", + lambda: "Serverless Functions", + ecs: "Elastic Container Service", + cloudwatch: "Monitoring & Observability", + ebs: "Elastic Block Store", + elb: "Elastic Load Balancing", + rds: "Relational Database Service", + sqs: "Simple Queue Service", + cloudformation: "Infrastructure as Code", + sns: "Simple Notification Service", + ssm: "Systems Manager", + vpc: "Virtual Private Cloud", + ecr: "Elastic Container Registry", + stepfunctions: "Step Functions", + eventbridge: "EventBridge", + apigateway: "API Gateway", +}; + +export function createResourceAdapter(): ServiceAdapter { + return { + id: "_resources", + label: "Resources", + hudColor: SERVICE_COLORS._resources ?? { bg: "white", fg: "black" }, + + getColumns(): ColumnDef[] { + return [ + { key: "resource", label: "Service" }, + { key: "description", label: "Description" }, + ]; + }, + + async getRows(): Promise { + return (Object.keys(SERVICE_REGISTRY) as AwsServiceId[]).map((serviceId) => ({ + id: serviceId, + cells: { + resource: textCell(serviceId), + description: textCell(SERVICE_DESCRIPTIONS[serviceId] ?? `${serviceId.toUpperCase()} service`), + }, + meta: {}, + })); + }, + + async onSelect(_row: TableRow): Promise { + return { action: "none" }; + }, + + canGoBack(): boolean { + return false; + }, + + goBack(): undefined { + return undefined; + }, + + pushUiLevel(_filterText: string, _selectedIndex: number): void { + // Single-level adapter — never called + }, + + getPath(): string { + return "resources"; + }, + getBookmarkKey(row: TableRow) { + return singlePartKey("Resource", row); + }, + }; +} diff --git a/src/views/apigateway/adapter.ts b/src/views/apigateway/adapter.ts index 3499965..e5d3ff4 100644 --- a/src/views/apigateway/adapter.ts +++ b/src/views/apigateway/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import { SERVICE_COLORS } from "../../constants/theme.js"; import { debugLog } from "../../utils/debugLogger.js"; import type { @@ -24,22 +24,14 @@ import type { import { createApiGatewayDetailCapability } from "./capabilities/detailCapability.js"; import { createApiGatewayYankCapability } from "./capabilities/yankCapability.js"; -export const apiGatewayLevelAtom = atom({ kind: "apis" }); -export const apiGatewayBackStackAtom = atom([]); export function createApiGatewayServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); const r = resolveRegion(region); - - const getLevel = () => store.get(apiGatewayLevelAtom); - const setLevel = (level: ApiGatewayLevel) => store.set(apiGatewayLevelAtom, level); - const getBackStack = () => store.get(apiGatewayBackStackAtom); - const setBackStack = (stack: ApiGatewayNavFrame[]) => - store.set(apiGatewayBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "apis" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -202,7 +194,6 @@ export function createApiGatewayServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); const detailCapability = createApiGatewayDetailCapability(region, getLevel); const yankCapability = createApiGatewayYankCapability(); @@ -272,12 +263,13 @@ export function createApiGatewayServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, - reset() { - setLevel({ kind: "apis" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("API", row); }, getBrowserUrl(row) { const meta = row.meta as ApiGatewayRowMeta | undefined; diff --git a/src/views/cloudformation/adapter.ts b/src/views/cloudformation/adapter.ts index 58cdc3e..b96ed8b 100644 --- a/src/views/cloudformation/adapter.ts +++ b/src/views/cloudformation/adapter.ts @@ -1,11 +1,28 @@ -import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; -import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; +import type { + ServiceAdapter, + RelatedResource, +} from "../../adapters/ServiceAdapter.js"; +import type { + ColumnDef, + TableRow, + SelectResult, + NavFrame, +} from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; -import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; -import type { AwsCloudFormationStack, AwsCloudFormationStackResource, CloudFormationLevel, CloudFormationRowMeta } from "./types.js"; +import { + runAwsJsonAsync, + buildRegionArgs, + resolveRegion, +} from "../../utils/aws.js"; +import { createStackState } from "../../utils/createStackState.js"; +import type { + AwsCloudFormationStack, + AwsCloudFormationStackResource, + CloudFormationLevel, + CloudFormationRowMeta, +} from "./types.js"; import { createCloudFormationDetailCapability } from "./capabilities/detailCapability.js"; import { createCloudFormationYankCapability } from "./capabilities/yankCapability.js"; import { createCloudFormationActionCapability } from "./capabilities/actionCapability.js"; @@ -17,20 +34,13 @@ interface CloudFormationNavFrame extends NavFrame { level: CloudFormationLevel; } -export const cloudformationLevelAtom = atom({ kind: "stacks" }); -export const cloudformationBackStackAtom = atom([]); export function createCloudFormationServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(cloudformationLevelAtom); - const setLevel = (level: CloudFormationLevel) => store.set(cloudformationLevelAtom, level); - const getBackStack = () => store.get(cloudformationBackStackAtom); - const setBackStack = (stack: CloudFormationNavFrame[]) => store.set(cloudformationBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "stacks" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -57,19 +67,25 @@ export function createCloudFormationServiceAdapter( if (level.kind === "stacks") { try { - const data = await runAwsJsonAsync<{ Stacks: AwsCloudFormationStack[] }>([ - "cloudformation", - "describe-stacks", - ...regionArgs, - ]); + const data = await runAwsJsonAsync<{ + Stacks: AwsCloudFormationStack[]; + }>(["cloudformation", "describe-stacks", ...regionArgs]); return (data.Stacks ?? []).map((stack) => ({ id: stack.StackId ?? stack.StackName, cells: { name: textCell(stack.StackName), status: statusCell(stack.StackStatus ?? "-"), - created: textCell(stack.CreationTime ? stack.CreationTime.slice(0, 19).replace("T", " ") : "-"), - updated: textCell(stack.LastUpdatedTime ? stack.LastUpdatedTime.slice(0, 19).replace("T", " ") : "-"), + created: textCell( + stack.CreationTime + ? stack.CreationTime.slice(0, 19).replace("T", " ") + : "-", + ), + updated: textCell( + stack.LastUpdatedTime + ? stack.LastUpdatedTime.slice(0, 19).replace("T", " ") + : "-", + ), description: textCell(stack.Description ?? "-"), }, meta: { @@ -90,7 +106,9 @@ export function createCloudFormationServiceAdapter( // resources level const { stackName } = level; try { - const data = await runAwsJsonAsync<{ StackResourceSummaries: AwsCloudFormationStackResource[] }>([ + const data = await runAwsJsonAsync<{ + StackResourceSummaries: AwsCloudFormationStackResource[]; + }>([ "cloudformation", "list-stack-resources", "--stack-name", @@ -116,7 +134,11 @@ export function createCloudFormationServiceAdapter( } satisfies CloudFormationRowMeta, })); } catch (e) { - debugLog("cloudformation", `getRows (resources for ${stackName}) failed`, e); + debugLog( + "cloudformation", + `getRows (resources for ${stackName}) failed`, + e, + ); return []; } }; @@ -131,7 +153,11 @@ export function createCloudFormationServiceAdapter( const newStack = [...backStack, { level, selectedIndex: 0 }]; setBackStack(newStack); - setLevel({ kind: "resources", stackName: meta.stackName, stackId: meta.stackId }); + setLevel({ + kind: "resources", + stackName: meta.stackName, + stackId: meta.stackId, + }); return { action: "navigate" }; } @@ -139,8 +165,6 @@ export function createCloudFormationServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "stacks") return "cfn://"; @@ -153,9 +177,15 @@ export function createCloudFormationServiceAdapter( return `📦 ${level.stackName}`; }; - const detailCapability = createCloudFormationDetailCapability(region, getLevel); + const detailCapability = createCloudFormationDetailCapability( + region, + getLevel, + ); const yankCapability = createCloudFormationYankCapability(); - const actionCapability = createCloudFormationActionCapability(region, getLevel); + const actionCapability = createCloudFormationActionCapability( + region, + getLevel, + ); const getRelatedResources = (row: TableRow): RelatedResource[] => { const level = getLevel(); @@ -164,7 +194,11 @@ export function createCloudFormationServiceAdapter( if (!meta || meta.type !== "stack") return []; const name = meta.stackName ?? row.id; return [ - { serviceId: "cloudwatch", label: `CloudWatch events for ${name}`, filterHint: name }, + { + serviceId: "cloudwatch", + label: `CloudWatch events for ${name}`, + filterHint: name, + }, { serviceId: "iam", label: `IAM roles for ${name}`, filterHint: name }, ]; }; @@ -188,13 +222,35 @@ export function createCloudFormationServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "stacks" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as CloudFormationRowMeta | undefined; + if (level.kind === "stacks") { + const stackName = meta?.type === "stack" ? meta.stackName : row.id; + return [{ label: "Stack", displayName: stackName, id: row.id }]; + } + // resources level + return [ + { label: "Stack", displayName: level.stackName, id: level.stackId }, + { label: "Resource", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "stacks" }); + } else if (key.length >= 2) { + const stackName = key[0]!.displayName; + const stackId = key[0]!.id ?? key[0]!.displayName; + setBackStack([{ level: { kind: "stacks" }, selectedIndex: 0 }]); + setLevel({ kind: "resources", stackName, stackId }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/cloudwatch/adapter.ts b/src/views/cloudwatch/adapter.ts index de1ed9c..fa847e4 100644 --- a/src/views/cloudwatch/adapter.ts +++ b/src/views/cloudwatch/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsLogEvent, AwsLogGroup, AwsLogStream, CloudWatchLevel, CloudWatchRowMeta } from "./types.js"; import { createCloudWatchDetailCapability } from "./capabilities/detailCapability.js"; import { createCloudWatchYankCapability } from "./capabilities/yankCapability.js"; @@ -16,8 +16,6 @@ interface CloudWatchNavFrame extends NavFrame { level: CloudWatchLevel; } -export const cloudWatchLevelAtom = atom({ kind: "log-groups" }); -export const cloudWatchBackStackAtom = atom([]); function formatBytes(bytes?: number): string { if (bytes == null) return "-"; @@ -35,29 +33,24 @@ export function createCloudWatchServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(cloudWatchLevelAtom); - const setLevel = (level: CloudWatchLevel) => store.set(cloudWatchLevelAtom, level); - const getBackStack = () => store.get(cloudWatchBackStackAtom); - const setBackStack = (stack: CloudWatchNavFrame[]) => store.set(cloudWatchBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "log-groups" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); if (level.kind === "log-groups") { return [ { key: "name", label: "Name" }, - { key: "retention", label: "Retention", width: 14 }, - { key: "stored", label: "Stored", width: 12 }, - { key: "lastEvent", label: "Last Event", width: 22 }, + { key: "retention", label: "Retention", width: 14, heatmap: { type: "numeric" } }, + { key: "stored", label: "Stored", width: 12, heatmap: { type: "numeric" } }, + { key: "lastEvent", label: "Last Event", width: 22, heatmap: { type: "date" } }, ]; } if (level.kind === "log-streams") { return [ { key: "name", label: "Name" }, - { key: "lastEvent", label: "Last Event", width: 22 }, - { key: "stored", label: "Stored", width: 12 }, + { key: "lastEvent", label: "Last Event", width: 22, heatmap: { type: "date" } }, + { key: "stored", label: "Stored", width: 12, heatmap: { type: "numeric" } }, ]; } // log-events level @@ -204,8 +197,6 @@ export function createCloudWatchServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "log-groups") return "logs://"; @@ -247,12 +238,49 @@ export function createCloudWatchServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "log-groups" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as CloudWatchRowMeta | undefined; + if (level.kind === "log-groups") { + const logGroupName = meta?.type === "log-group" ? meta.logGroupName : row.id; + return [{ label: "Log Group", displayName: logGroupName, id: row.id }]; + } + if (level.kind === "log-streams") { + const streamName = meta?.type === "log-stream" ? meta.logStreamName : row.id; + return [ + { label: "Log Group", displayName: level.logGroupName, id: level.logGroupName }, + { label: "Stream", displayName: streamName, id: row.id }, + ]; + } + // log-events level + return [ + { label: "Log Group", displayName: level.logGroupName, id: level.logGroupName }, + { label: "Stream", displayName: level.logStreamName, id: level.logStreamName }, + { label: "Event", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "log-groups" }); + } else if (key.length === 2) { + const logGroupName = key[0]!.displayName; + setBackStack([{ level: { kind: "log-groups" }, selectedIndex: 0 }]); + setLevel({ kind: "log-streams", logGroupName }); + } else if (key.length >= 3) { + const logGroupName = key[0]!.displayName; + const logStreamName = key[1]!.displayName; + setBackStack([ + { level: { kind: "log-groups" }, selectedIndex: 0 }, + { level: { kind: "log-streams", logGroupName }, selectedIndex: 0 }, + ]); + setLevel({ kind: "log-events", logGroupName, logStreamName }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/dynamodb/adapter.ts b/src/views/dynamodb/adapter.ts index effcd61..12d5215 100644 --- a/src/views/dynamodb/adapter.ts +++ b/src/views/dynamodb/adapter.ts @@ -1,10 +1,9 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom } from "jotai"; -import { getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsDynamoDBTableDescription, DynamoDBItem, @@ -31,26 +30,19 @@ interface DynamoDBNavFrame extends NavFrame { level: DynamoDBLevel; } -export const dynamoDBLevelAtom = atom({ kind: "tables" }); -export const dynamoDBBackStackAtom = atom([]); export function createDynamoDBServiceAdapter( endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset: resetLevelState } = createStackState({ kind: "tables" }); // Cache for table descriptions to avoid repeated AWS calls const tableDescriptionCache = new Map(); // Cache for scanned items const itemsCache = new Map(); - const getLevel = () => store.get(dynamoDBLevelAtom); - const setLevel = (level: DynamoDBLevel) => store.set(dynamoDBLevelAtom, level); - const getBackStack = () => store.get(dynamoDBBackStackAtom); - const setBackStack = (stack: DynamoDBNavFrame[]) => store.set(dynamoDBBackStackAtom, stack); - const getTableDescription = async (tableName: string): Promise => { const cached = tableDescriptionCache.get(tableName); if (cached) return cached; @@ -320,8 +312,6 @@ export function createDynamoDBServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "tables") return "dynamodb://"; @@ -359,15 +349,18 @@ export function createDynamoDBServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, reset() { - setLevel({ kind: "tables" }); - setBackStack([]); + resetLevelState(); tableDescriptionCache.clear(); itemsCache.clear(); }, + getBookmarkKey(row: TableRow) { + return singlePartKey("Table", row); + }, capabilities: { detail: detailCapability, yank: yankCapability, diff --git a/src/views/ebs/adapter.ts b/src/views/ebs/adapter.ts index ed9da1a..f3a3831 100644 --- a/src/views/ebs/adapter.ts +++ b/src/views/ebs/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsEBSSnapshot, AwsEBSVolume, EBSLevel, EBSRowMeta } from "./types.js"; import { createEBSDetailCapability } from "./capabilities/detailCapability.js"; import { createEBSYankCapability } from "./capabilities/yankCapability.js"; @@ -18,8 +18,6 @@ interface EBSNavFrame extends NavFrame { level: EBSLevel; } -export const ebsLevelAtom = atom({ kind: "volumes" }); -export const ebsBackStackAtom = atom([]); function formatBytes(gib?: number): string { if (gib == null) return "-"; @@ -30,20 +28,15 @@ export function createEBSServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(ebsLevelAtom); - const setLevel = (level: EBSLevel) => store.set(ebsLevelAtom, level); - const getBackStack = () => store.get(ebsBackStackAtom); - const setBackStack = (stack: EBSNavFrame[]) => store.set(ebsBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "volumes" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); if (level.kind === "volumes") { return [ { key: "volumeId", label: "Volume ID", width: 24 }, - { key: "size", label: "Size", width: 10 }, + { key: "size", label: "Size", width: 10, heatmap: { type: "numeric" } }, { key: "state", label: "State", width: 12 }, { key: "type", label: "Type", width: 8 }, { key: "az", label: "AZ", width: 18 }, @@ -55,7 +48,7 @@ export function createEBSServiceAdapter( { key: "snapshotId", label: "Snapshot ID", width: 24 }, { key: "state", label: "State", width: 12 }, { key: "progress", label: "Progress", width: 10 }, - { key: "startTime", label: "Started", width: 22 }, + { key: "startTime", label: "Started", width: 22, heatmap: { type: "date" } }, { key: "description", label: "Description" }, ]; }; @@ -159,8 +152,6 @@ export function createEBSServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "volumes") return "ebs://"; @@ -200,12 +191,31 @@ export function createEBSServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "volumes" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + if (level.kind === "volumes") { + return [{ label: "Volume", displayName: row.id, id: row.id }]; + } + // snapshots level + return [ + { label: "Volume", displayName: level.volumeId, id: level.volumeId }, + { label: "Snapshot", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "volumes" }); + } else if (key.length >= 2) { + const volumeId = key[0]!.displayName; + setBackStack([{ level: { kind: "volumes" }, selectedIndex: 0 }]); + setLevel({ kind: "snapshots", volumeId, volumeSize: 0 }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/ec2/adapter.ts b/src/views/ec2/adapter.ts index eaf8089..271855b 100644 --- a/src/views/ec2/adapter.ts +++ b/src/views/ec2/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsInstance, AwsVolume, EC2Level, EC2RowMeta } from "./types.js"; import { createEC2DetailCapability } from "./capabilities/detailCapability.js"; import { createEC2YankCapability } from "./capabilities/yankCapability.js"; @@ -18,8 +18,6 @@ interface EC2NavFrame extends NavFrame { level: EC2Level; } -export const ec2LevelAtom = atom({ kind: "instances" }); -export const ec2BackStackAtom = atom([]); function getInstanceName(instance: AwsInstance): string { return (instance.Tags ?? []).find((t) => t.Key === "Name")?.Value ?? instance.InstanceId; @@ -29,13 +27,8 @@ export function createEC2ServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(ec2LevelAtom); - const setLevel = (level: EC2Level) => store.set(ec2LevelAtom, level); - const getBackStack = () => store.get(ec2BackStackAtom); - const setBackStack = (stack: EC2NavFrame[]) => store.set(ec2BackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "instances" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -52,7 +45,7 @@ export function createEC2ServiceAdapter( // volumes level return [ { key: "volumeId", label: "Volume ID", width: 24 }, - { key: "size", label: "Size", width: 10 }, + { key: "size", label: "Size", width: 10, heatmap: { type: "numeric" } }, { key: "state", label: "State", width: 12 }, { key: "device", label: "Device", width: 12 }, ]; @@ -162,8 +155,6 @@ export function createEC2ServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "instances") return "ec2://"; @@ -241,13 +232,37 @@ export function createEC2ServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "instances" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as EC2RowMeta | undefined; + if (level.kind === "instances") { + const instanceName = typeof row.cells.name === "object" + ? (row.cells.name?.displayName ?? row.id) + : (row.cells.name ?? row.id); + return [{ label: "Instance", displayName: instanceName, id: row.id }]; + } + // volumes level + return [ + { label: "Instance", displayName: level.instanceName, id: level.instanceId }, + { label: "Volume", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "instances" }); + } else if (key.length >= 2) { + const instanceId = key[0]!.id ?? key[0]!.displayName; + const instanceName = key[0]!.displayName; + setBackStack([{ level: { kind: "instances" }, selectedIndex: 0 }]); + setLevel({ kind: "volumes", instanceId, instanceName }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/ecr/adapter.ts b/src/views/ecr/adapter.ts index aeb7a4a..907eb48 100644 --- a/src/views/ecr/adapter.ts +++ b/src/views/ecr/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsECRRepository, AwsECRImage, ECRLevel, ECRRowMeta } from "./types.js"; import { createECRDetailCapability } from "./capabilities/detailCapability.js"; import { createECRYankCapability } from "./capabilities/yankCapability.js"; @@ -15,8 +15,6 @@ interface ECRNavFrame extends NavFrame { level: ECRLevel; } -export const ecrLevelAtom = atom({ kind: "repositories" }); -export const ecrBackStackAtom = atom([]); function formatImageSize(bytes?: number): string { if (bytes == null) return "-"; @@ -28,13 +26,8 @@ export function createECRServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(ecrLevelAtom); - const setLevel = (level: ECRLevel) => store.set(ecrLevelAtom, level); - const getBackStack = () => store.get(ecrBackStackAtom); - const setBackStack = (stack: ECRNavFrame[]) => store.set(ecrBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "repositories" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -51,8 +44,8 @@ export function createECRServiceAdapter( return [ { key: "tag", label: "Tag", width: 30 }, { key: "digest", label: "Digest", width: 20 }, - { key: "size", label: "Size", width: 10 }, - { key: "pushedAt", label: "Pushed At", width: 22 }, + { key: "size", label: "Size", width: 10, heatmap: { type: "numeric" } }, + { key: "pushedAt", label: "Pushed At", width: 22, heatmap: { type: "date" } }, ]; }; @@ -180,8 +173,6 @@ export function createECRServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "repositories") return "ecr://"; @@ -217,12 +208,13 @@ export function createECRServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "repositories" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("Repository", row); }, capabilities: { detail: detailCapability, diff --git a/src/views/ecs/adapter.ts b/src/views/ecs/adapter.ts index 316b855..91bfbda 100644 --- a/src/views/ecs/adapter.ts +++ b/src/views/ecs/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsEcsCluster, AwsEcsService, AwsEcsTask, ECSLevel, ECSRowMeta } from "./types.js"; import { createECSDetailCapability } from "./capabilities/detailCapability.js"; import { createECSYankCapability } from "./capabilities/yankCapability.js"; @@ -17,8 +17,6 @@ interface ECSNavFrame extends NavFrame { level: ECSLevel; } -export const ecsLevelAtom = atom({ kind: "clusters" }); -export const ecsBackStackAtom = atom([]); function shortArn(arn: string): string { // arn:aws:ecs:us-east-1:123456789012:cluster/my-cluster → my-cluster @@ -36,13 +34,8 @@ export function createECSServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(ecsLevelAtom); - const setLevel = (level: ECSLevel) => store.set(ecsLevelAtom, level); - const getBackStack = () => store.get(ecsBackStackAtom); - const setBackStack = (stack: ECSNavFrame[]) => store.set(ecsBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "clusters" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -50,18 +43,18 @@ export function createECSServiceAdapter( return [ { key: "name", label: "Name" }, { key: "status", label: "Status", width: 10 }, - { key: "services", label: "Services", width: 10 }, - { key: "running", label: "Running", width: 10 }, - { key: "pending", label: "Pending", width: 10 }, + { key: "services", label: "Services", width: 10, heatmap: { type: "numeric" } }, + { key: "running", label: "Running", width: 10, heatmap: { type: "numeric" } }, + { key: "pending", label: "Pending", width: 10, heatmap: { type: "numeric" } }, ]; } if (level.kind === "services") { return [ { key: "name", label: "Name" }, { key: "status", label: "Status", width: 10 }, - { key: "desired", label: "Desired", width: 9 }, - { key: "running", label: "Running", width: 9 }, - { key: "pending", label: "Pending", width: 9 }, + { key: "desired", label: "Desired", width: 9, heatmap: { type: "numeric" } }, + { key: "running", label: "Running", width: 9, heatmap: { type: "numeric" } }, + { key: "pending", label: "Pending", width: 9, heatmap: { type: "numeric" } }, { key: "taskDef", label: "Task Definition", width: 30 }, ]; } @@ -69,9 +62,9 @@ export function createECSServiceAdapter( return [ { key: "taskId", label: "Task ID", width: 14 }, { key: "status", label: "Status", width: 12 }, - { key: "cpu", label: "CPU", width: 8 }, - { key: "memory", label: "Memory", width: 8 }, - { key: "startedAt", label: "Started At", width: 22 }, + { key: "cpu", label: "CPU", width: 8, heatmap: { type: "numeric" } }, + { key: "memory", label: "Memory", width: 8, heatmap: { type: "numeric" } }, + { key: "startedAt", label: "Started At", width: 22, heatmap: { type: "date" } }, ]; }; @@ -250,8 +243,6 @@ export function createECSServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "clusters") return "ecs://"; @@ -334,13 +325,58 @@ export function createECSServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "clusters" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as ECSRowMeta | undefined; + if (level.kind === "clusters") { + const clusterName = typeof row.cells.name === "object" + ? (row.cells.name?.displayName ?? row.id) + : (row.cells.name ?? row.id); + return [{ label: "Cluster", displayName: clusterName, id: row.id }]; + } + if (level.kind === "services") { + const serviceName = typeof row.cells.name === "object" + ? (row.cells.name?.displayName ?? row.id) + : (row.cells.name ?? row.id); + return [ + { label: "Cluster", displayName: level.clusterName, id: level.clusterArn }, + { label: "Service", displayName: serviceName, id: row.id }, + ]; + } + // tasks level + const shortId = meta?.type === "task" ? shortTaskArn(row.id) : row.id; + return [ + { label: "Cluster", displayName: shortArn(level.clusterArn), id: level.clusterArn }, + { label: "Service", displayName: level.serviceName, id: level.serviceArn }, + { label: "Task", displayName: shortId, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "clusters" }); + } else if (key.length === 2) { + const clusterArn = key[0]!.id ?? key[0]!.displayName; + const clusterName = key[0]!.displayName; + setBackStack([{ level: { kind: "clusters" }, selectedIndex: 0 }]); + setLevel({ kind: "services", clusterArn, clusterName }); + } else if (key.length >= 3) { + const clusterArn = key[0]!.id ?? key[0]!.displayName; + const clusterName = key[0]!.displayName; + const serviceName = key[1]!.displayName; + const serviceArn = key[1]!.id ?? key[1]!.displayName; + setBackStack([ + { level: { kind: "clusters" }, selectedIndex: 0 }, + { level: { kind: "services", clusterArn, clusterName }, selectedIndex: 0 }, + ]); + setLevel({ kind: "tasks", clusterArn, serviceName, serviceArn }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/elb/adapter.ts b/src/views/elb/adapter.ts index c209a39..9b1d609 100644 --- a/src/views/elb/adapter.ts +++ b/src/views/elb/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsLoadBalancer, AwsTargetGroup, @@ -21,8 +21,6 @@ interface ELBNavFrame extends NavFrame { level: ELBLevel; } -export const elbLevelAtom = atom({ kind: "load-balancers" }); -export const elbBackStackAtom = atom([]); function lbTypeLabel(type: string): string { switch (type) { @@ -37,13 +35,8 @@ export function createELBServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(elbLevelAtom); - const setLevel = (level: ELBLevel) => store.set(elbLevelAtom, level); - const getBackStack = () => store.get(elbBackStackAtom); - const setBackStack = (stack: ELBNavFrame[]) => store.set(elbBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "load-balancers" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -236,8 +229,6 @@ export function createELBServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "load-balancers") return "elb://"; @@ -287,13 +278,54 @@ export function createELBServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "load-balancers" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as ELBRowMeta | undefined; + if (level.kind === "load-balancers") { + const lbName = meta?.type === "load-balancer" ? meta.lbName : row.id; + return [{ label: "Load Balancer", displayName: lbName, id: row.id }]; + } + if (level.kind === "target-groups") { + const tgName = meta?.type === "target-group" ? meta.tgName : row.id; + return [ + { label: "Load Balancer", displayName: level.lbName, id: level.lbArn }, + { label: "Target Group", displayName: tgName, id: row.id }, + ]; + } + // targets level + const targetId = meta?.type === "target" ? meta.targetId : row.id; + return [ + { label: "Load Balancer", displayName: level.lbArn.split("/")[2] ?? level.lbArn, id: level.lbArn }, + { label: "Target Group", displayName: level.tgName, id: level.tgArn }, + { label: "Target", displayName: targetId, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "load-balancers" }); + } else if (key.length === 2) { + const lbArn = key[0]!.id ?? key[0]!.displayName; + const lbName = key[0]!.displayName; + setBackStack([{ level: { kind: "load-balancers" }, selectedIndex: 0 }]); + setLevel({ kind: "target-groups", lbArn, lbName, lbType: "" }); + } else if (key.length >= 3) { + const lbArn = key[0]!.id ?? key[0]!.displayName; + const lbName = key[0]!.displayName; + const tgArn = key[1]!.id ?? key[1]!.displayName; + const tgName = key[1]!.displayName; + setBackStack([ + { level: { kind: "load-balancers" }, selectedIndex: 0 }, + { level: { kind: "target-groups", lbArn, lbName, lbType: "" }, selectedIndex: 0 }, + ]); + setLevel({ kind: "targets", tgArn, tgName, lbArn }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/eventbridge/adapter.ts b/src/views/eventbridge/adapter.ts index d64d303..f4d72d3 100644 --- a/src/views/eventbridge/adapter.ts +++ b/src/views/eventbridge/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import { SERVICE_COLORS } from "../../constants/theme.js"; import { debugLog } from "../../utils/debugLogger.js"; import { statusCell } from "../../utils/statusColors.js"; @@ -25,22 +25,14 @@ import { toErrorMessage } from "../../utils/errorHelpers.js"; import { createEventBridgeDetailCapability } from "./capabilities/detailCapability.js"; import { createEventBridgeYankCapability } from "./capabilities/yankCapability.js"; -export const eventBridgeLevelAtom = atom({ kind: "buses" }); -export const eventBridgeBackStackAtom = atom([]); export function createEventBridgeServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); const r = resolveRegion(region); - - const getLevel = () => store.get(eventBridgeLevelAtom); - const setLevel = (level: EventBridgeLevel) => store.set(eventBridgeLevelAtom, level); - const getBackStack = () => store.get(eventBridgeBackStackAtom); - const setBackStack = (stack: EventBridgeNavFrame[]) => - store.set(eventBridgeBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "buses" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -152,7 +144,6 @@ export function createEventBridgeServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); const detailCapability = createEventBridgeDetailCapability(region, getLevel); const yankCapability = createEventBridgeYankCapability(); @@ -350,12 +341,13 @@ export function createEventBridgeServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, - reset() { - setLevel({ kind: "buses" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("Rule", row); }, getBrowserUrl(row) { const meta = row.meta as EventBridgeRowMeta | undefined; diff --git a/src/views/iam/adapter.ts b/src/views/iam/adapter.ts index 027c394..58ed6c3 100644 --- a/src/views/iam/adapter.ts +++ b/src/views/iam/adapter.ts @@ -2,8 +2,8 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import { runAwsJsonAsync } from "../../utils/aws.js"; import type { ColumnDef, TableRow, SelectResult } from "../../types.js"; import { textCell } from "../../types.js"; -import { atom } from "jotai"; -import { getDefaultStore } from "jotai"; +import { singlePartKey } from "../../utils/bookmarks.js"; +import { createStackState } from "../../utils/createStackState.js"; import type { IamLevel, IamNavFrame, @@ -17,18 +17,10 @@ import { createIamEditCapability } from "./capabilities/editCapability.js"; import { createIamDetailCapability } from "./capabilities/detailCapability.js"; import { createIamYankCapability } from "./capabilities/yankCapability.js"; import { SERVICE_COLORS } from "../../constants/theme.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -export const iamLevelAtom = atom({ kind: "root" }); -export const iamBackStackAtom = atom([]); export function createIamServiceAdapter(): ServiceAdapter { - const store = getDefaultStore(); - - const getLevel = () => store.get(iamLevelAtom); - const setLevel = (newLevel: IamLevel) => store.set(iamLevelAtom, newLevel); - const getBackStack = () => store.get(iamBackStackAtom); - const setBackStack = (newStack: IamNavFrame[]) => store.set(iamBackStackAtom, newStack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "root" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -224,8 +216,6 @@ export function createIamServiceAdapter(): ServiceAdapter { return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); switch (level.kind) { @@ -288,12 +278,13 @@ export function createIamServiceAdapter(): ServiceAdapter { onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "root" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("Role", row); }, capabilities: { edit: editCapability, diff --git a/src/views/lambda/adapter.ts b/src/views/lambda/adapter.ts index 79c80f8..95e8917 100644 --- a/src/views/lambda/adapter.ts +++ b/src/views/lambda/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsLambdaFunction, AwsLambdaVersion, LambdaLevel, LambdaRowMeta } from "./types.js"; import { createLambdaDetailCapability } from "./capabilities/detailCapability.js"; import { createLambdaYankCapability } from "./capabilities/yankCapability.js"; @@ -17,8 +17,6 @@ interface LambdaNavFrame extends NavFrame { level: LambdaLevel; } -export const lambdaLevelAtom = atom({ kind: "functions" }); -export const lambdaBackStackAtom = atom([]); function formatLastModified(raw?: string): string { if (!raw) return "-"; @@ -30,13 +28,8 @@ export function createLambdaServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(lambdaLevelAtom); - const setLevel = (level: LambdaLevel) => store.set(lambdaLevelAtom, level); - const getBackStack = () => store.get(lambdaBackStackAtom); - const setBackStack = (stack: LambdaNavFrame[]) => store.set(lambdaBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "functions" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -44,17 +37,17 @@ export function createLambdaServiceAdapter( return [ { key: "name", label: "Name" }, { key: "runtime", label: "Runtime", width: 15 }, - { key: "memory", label: "Memory", width: 10 }, - { key: "timeout", label: "Timeout", width: 10 }, - { key: "lastModified", label: "Last Modified", width: 22 }, + { key: "memory", label: "Memory", width: 10, heatmap: { type: "numeric" } }, + { key: "timeout", label: "Timeout", width: 10, heatmap: { type: "numeric" } }, + { key: "lastModified", label: "Last Modified", width: 22, heatmap: { type: "date" } }, ]; } // versions level return [ { key: "version", label: "Version", width: 12 }, { key: "description", label: "Description" }, - { key: "codeSize", label: "Code Size", width: 12 }, - { key: "lastModified", label: "Last Modified", width: 22 }, + { key: "codeSize", label: "Code Size", width: 12, heatmap: { type: "numeric" } }, + { key: "lastModified", label: "Last Modified", width: 22, heatmap: { type: "date" } }, ]; }; @@ -146,8 +139,6 @@ export function createLambdaServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "functions") return "lambda://"; @@ -200,13 +191,36 @@ export function createLambdaServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "functions" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as LambdaRowMeta | undefined; + if (level.kind === "functions") { + const functionName = meta?.type === "function" ? meta.functionName : row.id; + return [{ label: "Function", displayName: functionName, id: row.id }]; + } + // versions level + const version = meta?.type === "version" ? meta.version : row.id; + return [ + { label: "Function", displayName: level.functionName, id: level.functionArn }, + { label: "Version", displayName: version, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "functions" }); + } else if (key.length >= 2) { + const functionName = key[0]!.displayName; + const functionArn = key[0]!.id ?? key[0]!.displayName; + setBackStack([{ level: { kind: "functions" }, selectedIndex: 0 }]); + setLevel({ kind: "versions", functionName, functionArn }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/rds/adapter.ts b/src/views/rds/adapter.ts index 4f71b20..4537aa0 100644 --- a/src/views/rds/adapter.ts +++ b/src/views/rds/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsRDSInstance, AwsRDSSnapshot, RDSLevel, RDSRowMeta } from "./types.js"; import { createRDSDetailCapability } from "./capabilities/detailCapability.js"; import { createRDSYankCapability } from "./capabilities/yankCapability.js"; @@ -18,20 +18,13 @@ interface RDSNavFrame extends NavFrame { level: RDSLevel; } -export const rdsLevelAtom = atom({ kind: "instances" }); -export const rdsBackStackAtom = atom([]); export function createRDSServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(rdsLevelAtom); - const setLevel = (level: RDSLevel) => store.set(rdsLevelAtom, level); - const getBackStack = () => store.get(rdsBackStackAtom); - const setBackStack = (stack: RDSNavFrame[]) => store.set(rdsBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "instances" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -50,8 +43,8 @@ export function createRDSServiceAdapter( { key: "snapshotId", label: "Snapshot ID", width: 32 }, { key: "status", label: "Status", width: 14 }, { key: "type", label: "Type", width: 12 }, - { key: "created", label: "Created", width: 22 }, - { key: "size", label: "Size (GiB)", width: 12 }, + { key: "created", label: "Created", width: 22, heatmap: { type: "date" } }, + { key: "size", label: "Size (GiB)", width: 12, heatmap: { type: "numeric" } }, { key: "encrypted", label: "Encrypted" }, ]; }; @@ -156,8 +149,6 @@ export function createRDSServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "instances") return "rds://"; @@ -254,13 +245,32 @@ export function createRDSServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "instances" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + if (level.kind === "instances") { + return [{ label: "DB Instance", displayName: row.id, id: row.id }]; + } + // snapshots level + return [ + { label: "DB Instance", displayName: level.dbInstanceIdentifier, id: level.dbInstanceIdentifier }, + { label: "Snapshot", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "instances" }); + } else if (key.length >= 2) { + const dbInstanceIdentifier = key[0]!.displayName; + setBackStack([{ level: { kind: "instances" }, selectedIndex: 0 }]); + setLevel({ kind: "snapshots", dbInstanceIdentifier, dbInstanceClass: "" }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/route53/adapter.ts b/src/views/route53/adapter.ts index a988945..90c10e9 100644 --- a/src/views/route53/adapter.ts +++ b/src/views/route53/adapter.ts @@ -1,10 +1,9 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom } from "jotai"; -import { getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsHostedZone, AwsResourceRecordSet, @@ -20,20 +19,13 @@ interface Route53NavFrame extends NavFrame { level: Route53Level; } -export const route53LevelAtom = atom({ kind: "zones" }); -export const route53BackStackAtom = atom([]); export function createRoute53ServiceAdapter( endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(route53LevelAtom); - const setLevel = (level: Route53Level) => store.set(route53LevelAtom, level); - const getBackStack = () => store.get(route53BackStackAtom); - const setBackStack = (stack: Route53NavFrame[]) => store.set(route53BackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "zones" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -167,8 +159,6 @@ export function createRoute53ServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "zones") return "route53://"; @@ -204,12 +194,13 @@ export function createRoute53ServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "zones" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("Zone", row); }, capabilities: { detail: detailCapability, diff --git a/src/views/s3/adapter.ts b/src/views/s3/adapter.ts index a9dc686..13309d3 100644 --- a/src/views/s3/adapter.ts +++ b/src/views/s3/adapter.ts @@ -1,12 +1,11 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { createS3Client } from "./client.js"; import { fetchBuckets, fetchObjects, downloadObject } from "./fetcher.js"; import type { S3Client } from "@aws-sdk/client-s3"; -import { atom } from "jotai"; -import { getDefaultStore } from "jotai"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; +import { createStackState } from "../../utils/createStackState.js"; import { resolveRegion } from "../../utils/aws.js"; import { formatSize } from "./utils.js"; import { createS3EditCapability } from "./capabilities/editCapability.js"; @@ -22,18 +21,10 @@ interface S3NavFrame extends NavFrame { level: S3Level; } -export const s3LevelAtom = atom({ kind: "buckets" }); -export const s3BackStackAtom = atom([]); export function createS3ServiceAdapter(endpointUrl?: string, region?: string): ServiceAdapter { - const store = getDefaultStore(); const client: S3Client = createS3Client(endpointUrl, region); - - // Getters and setters for level/backStack from atoms - const getLevel = () => store.get(s3LevelAtom); - const setLevel = (level: S3Level) => store.set(s3LevelAtom, level); - const getBackStack = () => store.get(s3BackStackAtom); - const setBackStack = (stack: S3NavFrame[]) => store.set(s3BackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "buckets" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -41,14 +32,14 @@ export function createS3ServiceAdapter(endpointUrl?: string, region?: string): S return [ { key: "name", label: "Name" }, { key: "type", label: "Type", width: 10 }, - { key: "creationDate", label: "Creation Date", width: 22 }, + { key: "creationDate", label: "Creation Date", width: 22, heatmap: { type: "date" } }, ]; } return [ { key: "name", label: "Name" }, { key: "type", label: "Type", width: 10 }, - { key: "size", label: "Size", width: 12 }, - { key: "lastModified", label: "Last Modified", width: 22 }, + { key: "size", label: "Size", width: 12, heatmap: { type: "numeric" } }, + { key: "lastModified", label: "Last Modified", width: 22, heatmap: { type: "date" } }, ]; }; @@ -128,8 +119,6 @@ export function createS3ServiceAdapter(endpointUrl?: string, region?: string): S return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "buckets") return "s3://"; @@ -190,13 +179,45 @@ export function createS3ServiceAdapter(endpointUrl?: string, region?: string): S onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "buckets" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + if (level.kind === "buckets") { + return [{ label: "Bucket", displayName: row.id, id: row.id }]; + } + // objects level + const { bucket } = level; + const fullKey = row.id; + return [ + { label: "Bucket", displayName: bucket, id: bucket }, + { label: "Key", displayName: fullKey, id: fullKey }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + const bucket = key[0]!.id ?? key[0]!.displayName; + setBackStack([{ level: { kind: "buckets" }, selectedIndex: 0 }]); + setLevel({ kind: "objects", bucket, prefix: "" }); + } else if (key.length >= 2) { + const bucket = key[0]!.id ?? key[0]!.displayName; + const fullKey = key[1]!.id ?? key[1]!.displayName; + // Derive prefix by stripping last segment + const lastSlash = fullKey.lastIndexOf("/"); + const prefix = lastSlash >= 0 ? fullKey.slice(0, lastSlash + 1) : ""; + const parts = prefix.split("/").filter(Boolean); + const backStack: S3NavFrame[] = [{ level: { kind: "buckets" }, selectedIndex: 0 }]; + for (let i = 0; i < parts.length; i++) { + const framePrefix = i === 0 ? "" : parts.slice(0, i).join("/") + "/"; + backStack.push({ level: { kind: "objects", bucket, prefix: framePrefix }, selectedIndex: 0 }); + } + setBackStack(backStack); + setLevel({ kind: "objects", bucket, prefix }); + } }, capabilities: { edit: editCapability, diff --git a/src/views/secretsmanager/adapter.ts b/src/views/secretsmanager/adapter.ts index 52ecaf2..b4abba2 100644 --- a/src/views/secretsmanager/adapter.ts +++ b/src/views/secretsmanager/adapter.ts @@ -1,9 +1,10 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell, secretCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { atom } from "jotai"; import { getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsSecret, SecretRowMeta, SecretLevel } from "./types.js"; import { revealSecretsAtom } from "../../state/atoms.js"; import { createSecretsManagerDetailCapability } from "./capabilities/detailCapability.js"; @@ -11,7 +12,6 @@ import { createSecretsManagerYankCapability } from "./capabilities/yankCapabilit import { createSecretsManagerActionCapability } from "./capabilities/actionCapability.js"; import { createSecretsManagerEditCapability } from "./capabilities/editCapability.js"; import { getSecretValue } from "./client.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; import { SERVICE_COLORS } from "../../constants/theme.js"; import { debugLog } from "../../utils/debugLogger.js"; import { ageBandProps } from "../../utils/ageBanding.js"; @@ -20,8 +20,6 @@ interface SecretNavFrame extends NavFrame { level: SecretLevel; } -export const secretsManagerLevelAtom = atom({ kind: "secrets" }); -export const secretsManagerBackStackAtom = atom([]); function tryParseFields(secretString: string): Record | null { try { @@ -39,14 +37,9 @@ export function createSecretsManagerServiceAdapter( endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - // Getters and setters for level/backStack from atoms - const getLevel = () => store.get(secretsManagerLevelAtom); - const setLevel = (level: SecretLevel) => store.set(secretsManagerLevelAtom, level); - const getBackStack = () => store.get(secretsManagerBackStackAtom); - const setBackStack = (stack: SecretNavFrame[]) => store.set(secretsManagerBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "secrets" }); + const store = getDefaultStore(); const setReveal = (reveal: boolean) => store.set(revealSecretsAtom, reveal); const getColumns = (): ColumnDef[] => { @@ -164,8 +157,6 @@ export function createSecretsManagerServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "secrets") return "secrets://"; @@ -203,12 +194,13 @@ export function createSecretsManagerServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "secrets" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("Secret", row); }, capabilities: { edit: editCapability, diff --git a/src/views/sns/adapter.ts b/src/views/sns/adapter.ts index caf523c..376cfe1 100644 --- a/src/views/sns/adapter.ts +++ b/src/views/sns/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsSNSTopic, AwsSNSTopicAttributes, AwsSNSSubscription, SNSLevel, SNSRowMeta } from "./types.js"; import { createSNSDetailCapability } from "./capabilities/detailCapability.js"; import { createSNSYankCapability } from "./capabilities/yankCapability.js"; @@ -16,8 +16,6 @@ interface SNSNavFrame extends NavFrame { level: SNSLevel; } -export const snsLevelAtom = atom({ kind: "topics" }); -export const snsBackStackAtom = atom([]); function topicNameFromArn(arn: string): string { return arn.split(":").pop() ?? arn; @@ -27,13 +25,8 @@ export function createSNSServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(snsLevelAtom); - const setLevel = (level: SNSLevel) => store.set(snsLevelAtom, level); - const getBackStack = () => store.get(snsBackStackAtom); - const setBackStack = (stack: SNSNavFrame[]) => store.set(snsBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "topics" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -173,8 +166,6 @@ export function createSNSServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "topics") return "sns://"; @@ -243,13 +234,35 @@ export function createSNSServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "topics" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as SNSRowMeta | undefined; + if (level.kind === "topics") { + const topicName = meta?.type === "topic" ? meta.topicName : topicNameFromArn(row.id); + return [{ label: "Topic", displayName: topicName, id: row.id }]; + } + // subscriptions level + return [ + { label: "Topic", displayName: level.topicName, id: level.topicArn }, + { label: "Subscription", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "topics" }); + } else if (key.length >= 2) { + const topicArn = key[0]!.id ?? key[0]!.displayName; + const topicName = key[0]!.displayName; + setBackStack([{ level: { kind: "topics" }, selectedIndex: 0 }]); + setLevel({ kind: "subscriptions", topicArn, topicName }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/sqs/adapter.ts b/src/views/sqs/adapter.ts index 4b91157..9eb4bb2 100644 --- a/src/views/sqs/adapter.ts +++ b/src/views/sqs/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter, RelatedResource } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsSQSQueueAttributes, AwsSQSMessage, SQSLevel, SQSRowMeta } from "./types.js"; import { createSQSDetailCapability } from "./capabilities/detailCapability.js"; import { createSQSYankCapability } from "./capabilities/yankCapability.js"; @@ -16,8 +16,6 @@ interface SQSNavFrame extends NavFrame { level: SQSLevel; } -export const sqsLevelAtom = atom({ kind: "queues" }); -export const sqsBackStackAtom = atom([]); function queueNameFromUrl(url: string): string { return url.split("/").pop() ?? url; @@ -27,13 +25,8 @@ export function createSQSServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(sqsLevelAtom); - const setLevel = (level: SQSLevel) => store.set(sqsLevelAtom, level); - const getBackStack = () => store.get(sqsBackStackAtom); - const setBackStack = (stack: SQSNavFrame[]) => store.set(sqsBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "queues" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -41,8 +34,8 @@ export function createSQSServiceAdapter( return [ { key: "name", label: "Name", width: 36 }, { key: "type", label: "Type", width: 10 }, - { key: "messages", label: "Messages", width: 12 }, - { key: "inflight", label: "In-Flight", width: 12 }, + { key: "messages", label: "Messages", width: 12, heatmap: { type: "numeric" } }, + { key: "inflight", label: "In-Flight", width: 12, heatmap: { type: "numeric" } }, { key: "created", label: "Created" }, ]; } @@ -50,9 +43,9 @@ export function createSQSServiceAdapter( return [ { key: "messageId", label: "Message ID", width: 40 }, { key: "body", label: "Body", width: 52 }, - { key: "sent", label: "Sent", width: 22 }, - { key: "size", label: "Size", width: 8 }, - { key: "receiveCount", label: "Rcv Count" }, + { key: "sent", label: "Sent", width: 22, heatmap: { type: "date" } }, + { key: "size", label: "Size", width: 8, heatmap: { type: "numeric" } }, + { key: "receiveCount", label: "Rcv Count", heatmap: { type: "numeric" } }, ]; }; @@ -185,8 +178,6 @@ export function createSQSServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "queues") return "sqs://"; @@ -242,13 +233,36 @@ export function createSQSServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getRelatedResources, getBrowserUrl, - reset() { - setLevel({ kind: "queues" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as SQSRowMeta | undefined; + if (level.kind === "queues") { + // row.id is the queueUrl, queueName is from cells or meta + const queueName = meta?.type === "queue" ? meta.queueName : queueNameFromUrl(row.id); + return [{ label: "Queue", displayName: queueName, id: row.id }]; + } + // messages level + return [ + { label: "Queue", displayName: level.queueName, id: level.queueUrl }, + { label: "Message", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "queues" }); + } else if (key.length >= 2) { + const queueUrl = key[0]!.id ?? key[0]!.displayName; + const queueName = key[0]!.displayName; + setBackStack([{ level: { kind: "queues" }, selectedIndex: 0 }]); + setLevel({ kind: "messages", queueUrl, queueName }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/ssm/adapter.ts b/src/views/ssm/adapter.ts index 3cbcabc..a75ae52 100644 --- a/src/views/ssm/adapter.ts +++ b/src/views/ssm/adapter.ts @@ -1,9 +1,9 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import type { BookmarkKeyPart } from "../../utils/bookmarks.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsSSMParameter, AwsSSMParameterHistory, SSMLevel, SSMRowMeta } from "./types.js"; import { createSSMDetailCapability } from "./capabilities/detailCapability.js"; import { createSSMYankCapability } from "./capabilities/yankCapability.js"; @@ -15,8 +15,6 @@ interface SSMNavFrame extends NavFrame { level: SSMLevel; } -export const ssmLevelAtom = atom({ kind: "parameters" }); -export const ssmBackStackAtom = atom([]); function formatDate(raw?: string): string { if (!raw) return "-"; @@ -27,13 +25,8 @@ export function createSSMServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(ssmLevelAtom); - const setLevel = (level: SSMLevel) => store.set(ssmLevelAtom, level); - const getBackStack = () => store.get(ssmBackStackAtom); - const setBackStack = (stack: SSMNavFrame[]) => store.set(ssmBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "parameters" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -144,8 +137,6 @@ export function createSSMServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "parameters") return "ssm://"; @@ -182,12 +173,33 @@ export function createSSMServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "parameters" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow): BookmarkKeyPart[] { + const level = getLevel(); + const meta = row.meta as SSMRowMeta | undefined; + if (level.kind === "parameters") { + const parameterName = meta?.type === "parameter" ? meta.parameterName : row.id; + return [{ label: "Parameter", displayName: parameterName, id: row.id }]; + } + // history level + return [ + { label: "Parameter", displayName: level.parameterName, id: level.parameterName }, + { label: "Version", displayName: row.id, id: row.id }, + ]; + }, + restoreFromKey(key: BookmarkKeyPart[]): void { + if (key.length === 1) { + setBackStack([]); + setLevel({ kind: "parameters" }); + } else if (key.length >= 2) { + const parameterName = key[0]!.displayName; + setBackStack([{ level: { kind: "parameters" }, selectedIndex: 0 }]); + setLevel({ kind: "history", parameterName }); + } }, capabilities: { detail: detailCapability, diff --git a/src/views/stepfunctions/adapter.ts b/src/views/stepfunctions/adapter.ts index c8e0ef1..c48c85c 100644 --- a/src/views/stepfunctions/adapter.ts +++ b/src/views/stepfunctions/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsSFNStateMachine, AwsSFNExecution, StepFunctionsLevel, SFNRowMeta } from "./types.js"; import { createSFNDetailCapability } from "./capabilities/detailCapability.js"; import { createSFNYankCapability } from "./capabilities/yankCapability.js"; @@ -16,8 +16,6 @@ interface SFNNavFrame extends NavFrame { level: StepFunctionsLevel; } -export const sfnLevelAtom = atom({ kind: "state-machines" }); -export const sfnBackStackAtom = atom([]); function formatDuration(startDate?: string, stopDate?: string): string { if (!startDate) return "-"; @@ -34,13 +32,8 @@ export function createStepFunctionsServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(sfnLevelAtom); - const setLevel = (level: StepFunctionsLevel) => store.set(sfnLevelAtom, level); - const getBackStack = () => store.get(sfnBackStackAtom); - const setBackStack = (stack: SFNNavFrame[]) => store.set(sfnBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "state-machines" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -145,8 +138,6 @@ export function createStepFunctionsServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "state-machines") return "sfn://"; @@ -185,12 +176,13 @@ export function createStepFunctionsServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "state-machines" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("State Machine", row); }, capabilities: { detail: detailCapability, diff --git a/src/views/vpc/adapter.ts b/src/views/vpc/adapter.ts index a2e80e0..d6d79bd 100644 --- a/src/views/vpc/adapter.ts +++ b/src/views/vpc/adapter.ts @@ -1,10 +1,10 @@ import type { ServiceAdapter } from "../../adapters/ServiceAdapter.js"; import type { ColumnDef, TableRow, SelectResult, NavFrame } from "../../types.js"; import { textCell } from "../../types.js"; +import { singlePartKey } from "../../utils/bookmarks.js"; import { statusCell } from "../../utils/statusColors.js"; import { runAwsJsonAsync, buildRegionArgs, resolveRegion } from "../../utils/aws.js"; -import { createBackStackHelpers } from "../../adapters/backStackUtils.js"; -import { atom, getDefaultStore } from "jotai"; +import { createStackState } from "../../utils/createStackState.js"; import type { AwsVPC, AwsSecurityGroup, @@ -21,8 +21,6 @@ interface VPCNavFrame extends NavFrame { level: VPCLevel; } -export const vpcLevelAtom = atom({ kind: "vpcs" }); -export const vpcBackStackAtom = atom([]); function getTagName(tags?: { Key: string; Value: string }[]): string { return tags?.find((t) => t.Key === "Name")?.Value ?? ""; @@ -113,13 +111,8 @@ export function createVPCServiceAdapter( _endpointUrl?: string, region?: string, ): ServiceAdapter { - const store = getDefaultStore(); const regionArgs = buildRegionArgs(region); - - const getLevel = () => store.get(vpcLevelAtom); - const setLevel = (level: VPCLevel) => store.set(vpcLevelAtom, level); - const getBackStack = () => store.get(vpcBackStackAtom); - const setBackStack = (stack: VPCNavFrame[]) => store.set(vpcBackStackAtom, stack); + const { getLevel, setLevel, getBackStack, setBackStack, canGoBack, goBack, pushUiLevel, reset } = createStackState({ kind: "vpcs" }); const getColumns = (): ColumnDef[] => { const level = getLevel(); @@ -257,8 +250,6 @@ export function createVPCServiceAdapter( return { action: "none" }; }; - const { canGoBack, goBack } = createBackStackHelpers(getLevel, setLevel, getBackStack, setBackStack); - const getPath = (): string => { const level = getLevel(); if (level.kind === "vpcs") return "vpc://"; @@ -298,12 +289,13 @@ export function createVPCServiceAdapter( onSelect, canGoBack, goBack, + pushUiLevel, getPath, getContextLabel, getBrowserUrl, - reset() { - setLevel({ kind: "vpcs" }); - setBackStack([]); + reset, + getBookmarkKey(row: TableRow) { + return singlePartKey("VPC", row); }, capabilities: { detail: detailCapability,