Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 259 additions & 109 deletions src/App.tsx

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion src/adapters/ServiceAdapter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,11 +34,18 @@ export interface ServiceAdapter {
getRows(): Promise<TableRow[]>;
onSelect(row: TableRow): Promise<SelectResult>;
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<RelatedResource[]>;

Expand Down
21 changes: 0 additions & 21 deletions src/adapters/backStackUtils.ts

This file was deleted.

77 changes: 45 additions & 32 deletions src/components/DetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,71 @@ 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({
title,
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 (
<Box flexDirection="column" paddingX={1} paddingY={1}>
<Text bold color={theme.panel.panelTitleText}>
{title}
</Text>
<Text color={theme.panel.panelDividerText}>{"─".repeat(40)}</Text>
{isLoading ? (
<Text color={theme.panel.panelHintText}>Loading...</Text>
) : (
<>
{hasMoreAbove && (
<Text color={theme.panel.panelHintText} dimColor>
↑ {clampedOffset} more above
</Text>
)}
{visibleFields.map((f) => (
<Box key={f.label}>
<Text color={theme.panel.detailFieldLabelText}>{f.label.padEnd(labelWidth + 2)}</Text>
<Text>{f.value}</Text>
</Box>
))}
{hasMoreBelow && (
<Text color={theme.panel.panelHintText} dimColor>
↓ {fields.length - clampedOffset - visibleLines} more below
</Text>
)}
</>
)}
<Text color={theme.panel.panelDividerText}>{"─".repeat(40)}</Text>
<Text color={theme.panel.panelHintText}>j/k scroll • Esc close</Text>
<Box width="100%" borderStyle="round" borderColor={theme.panel.detailPanelBorderText} backgroundColor={theme.global.mainBg}>
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.panel.panelTitleText}>
{title}
</Text>
<Text color={theme.panel.panelDividerText}>{"─".repeat(40)}</Text>
{isLoading ? (
<Text color={theme.panel.panelHintText}>Loading...</Text>
) : (
<>
{hasMoreAbove && (
<Text color={theme.panel.panelHintText} dimColor>
↑ {clampedOffset} more above
</Text>
)}
{visibleFields.map((f) => (
<Box key={f.label}>
<Text color={theme.panel.detailFieldLabelText}>{f.label.padEnd(labelWidth + 2)}</Text>
<Text>{f.value}</Text>
</Box>
))}
{hasMoreBelow && (
<Text color={theme.panel.panelHintText} dimColor>
↓ {fields.length - clampedOffset - visibleLines} more below
</Text>
)}
</>
)}
<Text color={theme.panel.panelDividerText}>{"─".repeat(40)}</Text>
<Text color={theme.panel.panelHintText}>j/k scroll • Esc close</Text>
</Box>
</Box>
);
}
16 changes: 14 additions & 2 deletions src/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
15 changes: 12 additions & 3 deletions src/components/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -16,7 +17,7 @@ interface FilePreviewPanelProps {
previewState: FilePreviewState;
navigation: ReturnType<typeof useNavigation>;
termCols: number;
tableHeight: number;
availableHeight: number;
onFilterChange: (text: string) => void;
onFilterSubmit: () => void;
previewYankMode?: boolean;
Expand All @@ -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]);
Expand Down Expand Up @@ -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 = (
<Box flexDirection="row" justifyContent="space-between">
{previewState.filterActive ? (
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions src/components/HelpPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -52,6 +54,7 @@ export function HelpPanel({
const visibleItems = (currentTab?.items ?? []).slice(scrollOffset, scrollOffset + listRowsBudget);

return (
<Box width="100%" borderStyle="round" borderColor={borderColor} backgroundColor={backgroundColor}>
<Box flexDirection="column" paddingX={1} flexGrow={1}>
<Text bold color={theme.panel.panelTitleText}>
{title}
Expand Down Expand Up @@ -84,5 +87,6 @@ export function HelpPanel({
))}
</Box>
</Box>
</Box>
);
}
77 changes: 65 additions & 12 deletions src/components/Table/widths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading