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
6 changes: 6 additions & 0 deletions progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,9 @@ Each entry documents: date, feature, decisions, files changed, tests, and concer
- Changes: Added `runContainer` CLI method with `buildRunArgs` helper, multi-field RunContainerDialog (image, name, ports, env), `n` shortcut on Containers/Images tabs with image pre-fill from Images tab
- Decisions: Exported `buildRunArgs` as standalone function for testability; used Tab key to cycle between fields in dialog; comma-separated ports/env input
- Issues: None

[2026-02-12] #14 feat: Sort lists by name and simplify status bar command display
- Files: src/components/ContainersView.tsx, ImagesView.tsx, NetworksView.tsx, VolumesView.tsx, StatusBar.tsx, HelpOverlay.tsx, src/hooks/useKeyboard.ts, src/components/App.tsx, src/__tests__/sorting.test.tsx, src/__tests__/status-bar.test.tsx
- Changes: Added alphabetical sorting to all list views, refactored status bar to use highlighted shortcut format (inverse text), renamed `n:run` to `c:create` across keyboard handler, action handler, and help overlay
- Decisions: Used `<Text inverse>` for shortcut highlighting; merged "run" and "create" actions under single "create" action routed by active tab; label "exit" for stop action to embed "x" shortcut
- Issues: None
107 changes: 107 additions & 0 deletions src/__tests__/sorting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render } from "ink-testing-library";
import { ContainersView } from "../components/ContainersView.js";
import { ImagesView } from "../components/ImagesView.js";
import { NetworksView } from "../components/NetworksView.js";
import { VolumesView } from "../components/VolumesView.js";
import type { Container, Image, Network, Volume } from "../types/index.js";

function makeContainer(name: string): Container {
return {
id: name,
name,
image: "nginx:latest",
status: "running",
state: "running",
ports: [],
created: "2024-01-01",
};
}

function makeImage(repository: string): Image {
return {
id: repository,
repository,
tag: "latest",
size: "100MB",
created: "2024-01-01",
reference: `${repository}:latest`,
};
}

function makeNetwork(name: string): Network {
return { id: name, name, driver: "bridge", scope: "local" };
}

function makeVolume(name: string): Volume {
return { name, driver: "local", mountpoint: `/var/${name}`, scope: "local" };
}

// Sorting and filtering now happens in App.tsx before data reaches view components.
// These tests verify that pre-sorted data renders in the correct order.

describe("List sorting", () => {
it("renders containers in the order provided (sorted by App)", () => {
const containers = [makeContainer("alpha"), makeContainer("mango"), makeContainer("zebra")];
const { lastFrame } = render(
<ContainersView containers={containers} selectedIndex={0} searchQuery="" />
);
const frame = lastFrame() ?? "";
const alphaIdx = frame.indexOf("alpha");
const mangoIdx = frame.indexOf("mango");
const zebraIdx = frame.indexOf("zebra");
expect(alphaIdx).toBeLessThan(mangoIdx);
expect(mangoIdx).toBeLessThan(zebraIdx);
});

it("renders images in the order provided (sorted by App)", () => {
const images = [makeImage("alpine"), makeImage("nginx"), makeImage("zookeeper")];
const { lastFrame } = render(
<ImagesView images={images} selectedIndex={0} searchQuery="" />
);
const frame = lastFrame() ?? "";
const alpineIdx = frame.indexOf("alpine");
const nginxIdx = frame.indexOf("nginx");
const zookeeperIdx = frame.indexOf("zookeeper");
expect(alpineIdx).toBeLessThan(nginxIdx);
expect(nginxIdx).toBeLessThan(zookeeperIdx);
});

it("renders networks in the order provided (sorted by App)", () => {
const networks = [makeNetwork("app-net"), makeNetwork("db-net"), makeNetwork("zoo-net")];
const { lastFrame } = render(
<NetworksView networks={networks} selectedIndex={0} searchQuery="" />
);
const frame = lastFrame() ?? "";
const appIdx = frame.indexOf("app-net");
const dbIdx = frame.indexOf("db-net");
const zooIdx = frame.indexOf("zoo-net");
expect(appIdx).toBeLessThan(dbIdx);
expect(dbIdx).toBeLessThan(zooIdx);
});

it("renders volumes in the order provided (sorted by App)", () => {
const volumes = [makeVolume("a-vol"), makeVolume("m-vol"), makeVolume("z-vol")];
const { lastFrame } = render(
<VolumesView volumes={volumes} selectedIndex={0} searchQuery="" />
);
const frame = lastFrame() ?? "";
const aIdx = frame.indexOf("a-vol");
const mIdx = frame.indexOf("m-vol");
const zIdx = frame.indexOf("z-vol");
expect(aIdx).toBeLessThan(mIdx);
expect(mIdx).toBeLessThan(zIdx);
});

it("renders pre-filtered containers without excluded items", () => {
// App.tsx filters and sorts before passing to view
const containers = [makeContainer("alpha-app"), makeContainer("zebra-app")];
const { lastFrame } = render(
<ContainersView containers={containers} selectedIndex={0} searchQuery="app" />
);
const frame = lastFrame() ?? "";
expect(frame).not.toContain("no-match");
const alphaIdx = frame.indexOf("alpha-app");
const zebraIdx = frame.indexOf("zebra-app");
expect(alphaIdx).toBeLessThan(zebraIdx);
});
});
97 changes: 95 additions & 2 deletions src/__tests__/status-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render } from "ink-testing-library";
import { StatusBar } from "../components/StatusBar.js";
import { StatusBar, renderAction } from "../components/StatusBar.js";
import type { ReleaseCheckState } from "../hooks/useReleaseCheck.js";

describe("StatusBar", () => {
Expand Down Expand Up @@ -30,6 +30,99 @@ describe("StatusBar", () => {
/>
);

expect(lastFrame()).toContain("h/l:tabs");
const frame = lastFrame() ?? "";
expect(frame).toContain("hjkl:nav");
expect(frame).toContain("search");
expect(frame).toContain("quit");
});

it("displays action labels with highlighted shortcut for containers tab", () => {
const { lastFrame } = render(
<StatusBar
activeTab="containers"
itemCount={3}
/>
);

const frame = lastFrame() ?? "";
expect(frame).toContain("create");
expect(frame).toContain("edit");
expect(frame).toContain("start");
expect(frame).toContain("stop");
expect(frame).toContain("Restart");
expect(frame).toContain("delete");
expect(frame).toContain("Logs");
expect(frame).toContain("inspect");
// Should NOT contain old format
expect(frame).not.toContain("n:run");
expect(frame).not.toContain("s:start");
});

it("displays action labels for images tab", () => {
const { lastFrame } = render(
<StatusBar
activeTab="images"
itemCount={5}
/>
);

const frame = lastFrame() ?? "";
expect(frame).toContain("create");
expect(frame).toContain("pull");
expect(frame).toContain("delete");
expect(frame).toContain("inspect");
});

it("displays action labels for networks tab", () => {
const { lastFrame } = render(
<StatusBar
activeTab="networks"
itemCount={2}
/>
);

const frame = lastFrame() ?? "";
expect(frame).toContain("create");
expect(frame).toContain("delete");
expect(frame).toContain("inspect");
});

it("displays action labels for volumes tab", () => {
const { lastFrame } = render(
<StatusBar
activeTab="volumes"
itemCount={1}
/>
);

const frame = lastFrame() ?? "";
expect(frame).toContain("create");
expect(frame).toContain("delete");
expect(frame).toContain("inspect");
});
});

describe("renderAction", () => {
it("highlights the shortcut character in a label", () => {
const { lastFrame } = render(
renderAction({ key: "c", label: "create" })
);
// The rendered output should contain "create" with 'c' highlighted (yellow underline)
expect(lastFrame()).toContain("reate");
});

it("handles mid-word shortcut character", () => {
const { lastFrame } = render(
renderAction({ key: "x", label: "exit" })
);
expect(lastFrame()).toContain("e");
expect(lastFrame()).toContain("it");
});

it("handles uppercase shortcut character", () => {
const { lastFrame } = render(
renderAction({ key: "R", label: "Restart" })
);
expect(lastFrame()).toContain("estart");
});
});
Loading