Skip to content

Commit 059673a

Browse files
authored
fix: detail pages sections for large things on small screens (#122)
<img width="590" height="313" alt="image" src="https://github.com/user-attachments/assets/89a0209f-5cbb-4f28-a741-10d5377d5313" />
1 parent 8419b69 commit 059673a

26 files changed

Lines changed: 2260 additions & 344 deletions

jest.components.config.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ export default {
1717
// Test environment
1818
testEnvironment: "node",
1919

20-
// Test discovery - only component tests
20+
// Test discovery - component and screen tests
2121
roots: ["<rootDir>/tests"],
22-
testMatch: ["**/__tests__/components/**/*.test.tsx"],
22+
testMatch: [
23+
"**/__tests__/components/**/*.test.tsx",
24+
"**/__tests__/screens/**/*.test.tsx",
25+
],
2326

2427
// Coverage configuration
2528
collectCoverageFrom: [

jest.router.config.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { pathsToModuleNameMapper } from "ts-jest";
2+
import { readFileSync } from "fs";
3+
import { fileURLToPath } from "url";
4+
import { dirname } from "path";
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = dirname(__filename);
8+
9+
// Read and parse tsconfig.json
10+
const tsconfig = JSON.parse(readFileSync("./tsconfig.json", "utf8"));
11+
const compilerOptions = tsconfig.compilerOptions;
12+
13+
export default {
14+
preset: "ts-jest/presets/default-esm",
15+
testEnvironment: "node",
16+
roots: ["<rootDir>/tests"],
17+
testMatch: [
18+
"**/__tests__/router/**/*.test.tsx",
19+
"**/__tests__/integration/**/*.test.tsx",
20+
],
21+
collectCoverageFrom: [
22+
"src/router/**/*.{ts,tsx}",
23+
"src/store/navigationStore.tsx",
24+
"src/store/navigationStateMachine.ts",
25+
"!src/**/*.d.ts",
26+
],
27+
setupFilesAfterEnv: ["<rootDir>/tests/setup-router.ts"],
28+
moduleNameMapper: {
29+
"^(\\.{1,2}/.*)\\.js$": "$1",
30+
...pathsToModuleNameMapper(compilerOptions.paths, {
31+
prefix: "<rootDir>/",
32+
useESM: true,
33+
}),
34+
"^figures$": "<rootDir>/tests/__mocks__/figures.ts",
35+
"^is-unicode-supported$": "<rootDir>/tests/__mocks__/is-unicode-supported.ts",
36+
"^conf$": "<rootDir>/tests/__mocks__/conf.ts",
37+
"^signal-exit$": "<rootDir>/tests/__mocks__/signal-exit.ts",
38+
},
39+
transform: {
40+
"^.+\\.tsx?$": [
41+
"ts-jest",
42+
{
43+
useESM: true,
44+
tsconfig: {
45+
...compilerOptions,
46+
rootDir: ".",
47+
module: "ESNext",
48+
moduleResolution: "Node",
49+
allowSyntheticDefaultImports: true,
50+
esModuleInterop: true,
51+
},
52+
},
53+
],
54+
},
55+
transformIgnorePatterns: [
56+
"node_modules/(?!(ink-testing-library|ink|chalk|cli-cursor|restore-cursor|onetime|mimic-fn|signal-exit|strip-ansi|ansi-regex|ansi-styles|wrap-ansi|string-width|emoji-regex|eastasianwidth|cli-boxes|camelcase|widest-line|yoga-wasm-web)/)",
57+
],
58+
extensionsToTreatAsEsm: [".ts", ".tsx"],
59+
testTimeout: 30000,
60+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
61+
clearMocks: true,
62+
restoreMocks: true,
63+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
2727
"test:components": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.components.config.js --coverage --forceExit",
2828
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.e2e.config.js --forceExit",
29+
"test:router": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.router.config.js --forceExit",
2930
"docs:commands": "pnpm run build && node scripts/generate-command-docs.js",
3031
"prepare": "husky"
3132
},

src/commands/devbox/list.tsx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
1818
import { ResourceActionsMenu } from "../../components/ResourceActionsMenu.js";
1919
import { ActionsPopup } from "../../components/ActionsPopup.js";
2020
import { getDevboxUrl } from "../../utils/url.js";
21-
import { useViewportHeight } from "../../hooks/useViewportHeight.js";
21+
import { useVerticalLayout } from "../../hooks/useVerticalLayout.js";
2222
import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
2323
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
2424
import { useListSearch } from "../../hooks/useListSearch.js";
@@ -66,21 +66,15 @@ const ListDevboxesUI = ({
6666
// Get devbox store setter to sync data for detail screen
6767
const setDevboxesInStore = useDevboxStore((state) => state.setDevboxes);
6868

69-
// Calculate overhead for viewport height:
70-
// - Breadcrumb (3 lines + marginBottom): 4 lines
71-
// - Search bar (if visible, 1 line + marginBottom): 2 lines
72-
// - Table (title + top border + header + bottom border): 4 lines
73-
// - Stats bar (marginTop + content): 2 lines
74-
// - Help bar (marginTop + content): 2 lines
75-
// - Safety buffer for edge cases: 1 line
76-
// Total: 13 lines base + 2 if searching
77-
const overhead = 13 + search.getSearchOverhead();
78-
const { viewportHeight, terminalWidth } = useViewportHeight({
79-
overhead,
80-
minHeight: 5,
69+
// Vertical layout: single source of truth for content lines and chrome modes
70+
const hasSearch = search.searchMode || !!search.submittedSearchQuery;
71+
const layout = useVerticalLayout({
72+
screenType: "list",
73+
hasSearch,
8174
});
8275

83-
const PAGE_SIZE = viewportHeight;
76+
const PAGE_SIZE = Math.max(1, layout.contentLines);
77+
const terminalWidth = layout.terminalWidth;
8478

8579
// Fetch function for pagination hook
8680
const fetchPage = React.useCallback(
@@ -669,7 +663,10 @@ const ListDevboxesUI = ({
669663
if (loading && devboxes.length === 0) {
670664
return (
671665
<>
672-
<Breadcrumb items={[{ label: "Devboxes", active: true }]} />
666+
<Breadcrumb
667+
items={[{ label: "Devboxes", active: true }]}
668+
compactMode={layout.breadcrumbMode}
669+
/>
673670
<SpinnerComponent message="Loading..." />
674671
</>
675672
);
@@ -678,7 +675,10 @@ const ListDevboxesUI = ({
678675
if (error) {
679676
return (
680677
<>
681-
<Breadcrumb items={[{ label: "Devboxes", active: true }]} />
678+
<Breadcrumb
679+
items={[{ label: "Devboxes", active: true }]}
680+
compactMode={layout.breadcrumbMode}
681+
/>
682682
<ErrorMessage message="Failed to list devboxes" error={error} />
683683
</>
684684
);
@@ -687,7 +687,10 @@ const ListDevboxesUI = ({
687687
// Main list view
688688
return (
689689
<>
690-
<Breadcrumb items={[{ label: "Devboxes", active: true }]} />
690+
<Breadcrumb
691+
items={[{ label: "Devboxes", active: true }]}
692+
compactMode={layout.breadcrumbMode}
693+
/>
691694

692695
{/* Search bar */}
693696
<SearchBar
@@ -781,6 +784,7 @@ const ListDevboxesUI = ({
781784
{/* Help Bar */}
782785
<NavigationTips
783786
showArrows
787+
displayMode={layout.navTipsMode}
784788
tips={[
785789
{
786790
icon: `${figures.arrowLeft}${figures.arrowRight}`,

src/components/Breadcrumb.tsx

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { Box, Text } from "ink";
2+
import { Box, Text, useStdout } from "ink";
33
import { colors } from "../utils/theme.js";
44
import { UpdateNotification } from "./UpdateNotification.js";
55

@@ -8,17 +8,89 @@ export interface BreadcrumbItem {
88
active?: boolean;
99
}
1010

11+
/** Display mode: full (all items, border), compact (shorter labels), or minimal (active/last only) */
12+
export type BreadcrumbCompactMode = "full" | "compact" | "minimal";
13+
1114
interface BreadcrumbProps {
1215
items: BreadcrumbItem[];
1316
showVersionCheck?: boolean;
17+
/** When set, overrides width-based mode (from vertical layout allocator) */
18+
compactMode?: BreadcrumbCompactMode;
19+
}
20+
21+
const BREADCRUMB_FULL_MIN_WIDTH = 70;
22+
const BREADCRUMB_COMPACT_MIN_WIDTH = 45;
23+
const FULL_MAX_LABEL_LENGTH = 40;
24+
const COMPACT_MAX_LABEL_LENGTH = 12;
25+
26+
function getModeFromWidth(
27+
terminalWidth: number,
28+
override?: BreadcrumbCompactMode,
29+
): BreadcrumbCompactMode {
30+
if (override !== undefined) return override;
31+
if (terminalWidth >= BREADCRUMB_FULL_MIN_WIDTH) return "full";
32+
if (terminalWidth >= BREADCRUMB_COMPACT_MIN_WIDTH) return "compact";
33+
return "minimal";
1434
}
1535

1636
export const Breadcrumb = ({
1737
items,
1838
showVersionCheck = false,
39+
compactMode: compactModeProp,
1940
}: BreadcrumbProps) => {
2041
const env = process.env.RUNLOOP_ENV?.toLowerCase();
2142
const isDevEnvironment = env === "dev";
43+
const { stdout } = useStdout();
44+
45+
const [terminalWidth, setTerminalWidth] = React.useState(() =>
46+
stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
47+
);
48+
49+
React.useEffect(() => {
50+
if (!stdout) return;
51+
const handleResize = () => {
52+
const w = stdout.columns && stdout.columns > 0 ? stdout.columns : 80;
53+
setTerminalWidth(w);
54+
};
55+
stdout.on("resize", handleResize);
56+
handleResize();
57+
return () => {
58+
stdout.off("resize", handleResize);
59+
};
60+
}, [stdout]);
61+
62+
const mode = getModeFromWidth(terminalWidth, compactModeProp);
63+
const maxLabelLen =
64+
mode === "full"
65+
? FULL_MAX_LABEL_LENGTH
66+
: mode === "compact"
67+
? COMPACT_MAX_LABEL_LENGTH
68+
: Math.max(5, terminalWidth - 10);
69+
const showBorder = mode === "full";
70+
const paddingX = mode === "full" ? 2 : mode === "compact" ? 1 : 0;
71+
72+
// Items to show: minimal = active or last only; compact/full = all with truncation
73+
const displayItems = React.useMemo(() => {
74+
if (mode === "minimal" && items.length > 0) {
75+
const active = items.find((i) => i.active) ?? items[items.length - 1];
76+
return [
77+
{
78+
...active,
79+
label:
80+
active.label.length > maxLabelLen
81+
? active.label.substring(0, maxLabelLen - 3) + "..."
82+
: active.label,
83+
},
84+
];
85+
}
86+
return items.map((item) => ({
87+
...item,
88+
label:
89+
item.label.length > maxLabelLen
90+
? item.label.substring(0, maxLabelLen - 3) + "..."
91+
: item.label,
92+
}));
93+
}, [items, mode, maxLabelLen]);
2294

2395
return (
2496
<Box
@@ -29,40 +101,31 @@ export const Breadcrumb = ({
29101
>
30102
<Box flexShrink={0}>
31103
<Box
32-
borderStyle="round"
33-
borderColor={colors.primary}
34-
paddingX={2}
104+
borderStyle={showBorder ? "round" : undefined}
105+
borderColor={showBorder ? colors.primary : undefined}
106+
paddingX={paddingX}
35107
paddingY={0}
36108
>
37109
<Text color={colors.primary} bold>
38110
rl
39111
</Text>
40-
{isDevEnvironment && (
112+
{isDevEnvironment && mode !== "minimal" && (
41113
<Text color={colors.error} bold>
42114
{" "}
43115
(dev)
44116
</Text>
45117
)}
46-
<Text color={colors.textDim}></Text>
47-
{items.map((item, index) => {
48-
// Limit label length to prevent Yoga layout engine errors
49-
const MAX_LABEL_LENGTH = 80;
50-
const truncatedLabel =
51-
item.label.length > MAX_LABEL_LENGTH
52-
? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
53-
: item.label;
54-
55-
return (
56-
<React.Fragment key={index}>
57-
<Text color={item.active ? colors.primary : colors.textDim}>
58-
{truncatedLabel}
59-
</Text>
60-
{index < items.length - 1 && (
61-
<Text color={colors.textDim}></Text>
62-
)}
63-
</React.Fragment>
64-
);
65-
})}
118+
{displayItems.length > 0 && <Text color={colors.textDim}></Text>}
119+
{displayItems.map((item, index) => (
120+
<React.Fragment key={index}>
121+
<Text color={item.active ? colors.primary : colors.textDim}>
122+
{item.label}
123+
</Text>
124+
{index < displayItems.length - 1 && (
125+
<Text color={colors.textDim}></Text>
126+
)}
127+
</React.Fragment>
128+
))}
66129
</Box>
67130
</Box>
68131
{showVersionCheck && <UpdateNotification />}

src/components/DevboxDetailPage.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React from "react";
66
import { Text } from "ink";
77
import figures from "figures";
88
import { DevboxActionsMenu } from "./DevboxActionsMenu.js";
9-
import { StateHistory } from "./StateHistory.js";
9+
import { buildStateHistorySection } from "./StateHistory.js";
1010
import {
1111
ResourceDetailPage,
1212
type DetailSection,
@@ -366,6 +366,15 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => {
366366
});
367367
}
368368

369+
// State History section (under same section control as Details/Metadata - truncates when viewport is small)
370+
const stateHistorySection = buildStateHistorySection(
371+
devbox.state_transitions,
372+
devbox.shutdown_reason ?? undefined,
373+
);
374+
if (stateHistorySection) {
375+
sections.push(stateHistorySection);
376+
}
377+
369378
return sections;
370379
};
371380

@@ -778,12 +787,6 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => {
778787
onOperation={handleOperation}
779788
onBack={onBack}
780789
buildDetailLines={buildDetailLines}
781-
additionalContent={
782-
<StateHistory
783-
stateTransitions={devbox.state_transitions}
784-
shutdownReason={devbox.shutdown_reason ?? undefined}
785-
/>
786-
}
787790
/>
788791
);
789792
};

0 commit comments

Comments
 (0)