diff --git a/progress.txt b/progress.txt index 6266dc8..b9ab81f 100644 --- a/progress.txt +++ b/progress.txt @@ -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 `` 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 diff --git a/src/__tests__/sorting.test.tsx b/src/__tests__/sorting.test.tsx new file mode 100644 index 0000000..969cd04 --- /dev/null +++ b/src/__tests__/sorting.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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); + }); +}); diff --git a/src/__tests__/status-bar.test.tsx b/src/__tests__/status-bar.test.tsx index 77640f6..39ba3c7 100644 --- a/src/__tests__/status-bar.test.tsx +++ b/src/__tests__/status-bar.test.tsx @@ -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", () => { @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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"); }); }); diff --git a/src/components/App.tsx b/src/components/App.tsx index 067abbc..8f7ba85 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { Box, Text, useApp } from "ink"; import Spinner from "ink-spinner"; import { TabBar } from "./TabBar.js"; @@ -80,20 +80,73 @@ export function App(): React.ReactElement { const [actionError, setActionError] = useState(null); const [actionInProgress, setActionInProgress] = useState(null); + const sortedContainers = useMemo( + () => + containers + .filter( + (c) => + !searchQuery || + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.image.toLowerCase().includes(searchQuery.toLowerCase()) || + c.status.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)), + [containers, searchQuery] + ); + + const sortedImages = useMemo( + () => + images + .filter( + (img) => + !searchQuery || + img.repository.toLowerCase().includes(searchQuery.toLowerCase()) || + img.tag.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => a.repository.localeCompare(b.repository)), + [images, searchQuery] + ); + + const sortedNetworks = useMemo( + () => + networks + .filter( + (net) => + !searchQuery || + net.name.toLowerCase().includes(searchQuery.toLowerCase()) || + net.driver.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)), + [networks, searchQuery] + ); + + const sortedVolumes = useMemo( + () => + volumes + .filter( + (vol) => + !searchQuery || + vol.name.toLowerCase().includes(searchQuery.toLowerCase()) || + vol.driver.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)), + [volumes, searchQuery] + ); + const getItemCount = useCallback((): number => { switch (activeTab) { case "containers": - return containers.length; + return sortedContainers.length; case "images": - return images.length; + return sortedImages.length; case "networks": - return networks.length; + return sortedNetworks.length; case "volumes": - return volumes.length; + return sortedVolumes.length; default: return 0; } - }, [activeTab, containers.length, images.length, networks.length, volumes.length]); + }, [activeTab, sortedContainers.length, sortedImages.length, sortedNetworks.length, sortedVolumes.length]); const handleQuit = useCallback(() => { exit(); @@ -130,7 +183,7 @@ export function App(): React.ReactElement { switch (activeTab) { case "containers": { - const container = containers[selectedIndex]; + const container = sortedContainers[selectedIndex]; if (!container) return; const details = await containerCli.inspectContainer(container.id); const curated: Record = { @@ -153,13 +206,13 @@ export function App(): React.ReactElement { break; } case "images": { - const image = images[selectedIndex]; + const image = sortedImages[selectedIndex]; if (!image) return; inspectData = await containerCli.inspectImage(image.reference); break; } case "networks": { - const network = networks[selectedIndex]; + const network = sortedNetworks[selectedIndex]; if (!network) return; inspectData = (await containerCli.inspectNetwork( network.id @@ -167,7 +220,7 @@ export function App(): React.ReactElement { break; } case "volumes": { - const volume = volumes[selectedIndex]; + const volume = sortedVolumes[selectedIndex]; if (!volume) return; inspectData = (await containerCli.inspectVolume( volume.name @@ -183,7 +236,7 @@ export function App(): React.ReactElement { } catch (err) { setActionError(err instanceof Error ? err.message : "Failed to inspect"); } - }, [activeTab, selectedIndex, containers, images, networks, volumes]); + }, [activeTab, selectedIndex, sortedContainers, sortedImages, sortedNetworks, sortedVolumes]); const handleBack = useCallback(() => { setActionError(null); @@ -222,25 +275,25 @@ export function App(): React.ReactElement { const getItemIdentifiers = useCallback(() => { switch (activeTab) { case "containers": { - const c = containers[selectedIndex]; + const c = sortedContainers[selectedIndex]; return c ? { id: c.id, name: c.name } : null; } case "images": { - const i = images[selectedIndex]; + const i = sortedImages[selectedIndex]; return i ? { id: i.reference, name: `${i.repository}:${i.tag}` } : null; } case "networks": { - const n = networks[selectedIndex]; + const n = sortedNetworks[selectedIndex]; return n ? { id: n.id, name: n.name } : null; } case "volumes": { - const v = volumes[selectedIndex]; + const v = sortedVolumes[selectedIndex]; return v ? { id: v.name, name: v.name } : null; } default: return null; } - }, [activeTab, selectedIndex, containers, images, networks, volumes]); + }, [activeTab, selectedIndex, sortedContainers, sortedImages, sortedNetworks, sortedVolumes]); const handleAction = useCallback( async (action: string) => { @@ -251,23 +304,22 @@ export function App(): React.ReactElement { return; } - if (action === "create" && (activeTab === "networks" || activeTab === "volumes")) { - setDialog({ - type: "create", - createType: activeTab === "networks" ? "network" : "volume", - }); - return; - } - - if (action === "run" && (activeTab === "containers" || activeTab === "images")) { - const image = - activeTab === "images" ? images[selectedIndex]?.reference : undefined; - setDialog({ type: "run", initialValues: image ? { image } : undefined }); + if (action === "create") { + if (activeTab === "networks" || activeTab === "volumes") { + setDialog({ + type: "create", + createType: activeTab === "networks" ? "network" : "volume", + }); + } else if (activeTab === "containers" || activeTab === "images") { + const image = + activeTab === "images" ? sortedImages[selectedIndex]?.reference : undefined; + setDialog({ type: "run", initialValues: image ? { image } : undefined }); + } return; } if (action === "edit" && activeTab === "containers") { - const container = containers[selectedIndex]; + const container = sortedContainers[selectedIndex]; if (!container) return; try { setActionInProgress("Loading container settings..."); @@ -375,7 +427,7 @@ export function App(): React.ReactElement { setActionError(err instanceof Error ? err.message : "Action failed"); } }, - [getItemIdentifiers, activeTab, refresh, handleSelect, images, selectedIndex, containers] + [getItemIdentifiers, activeTab, refresh, handleSelect, sortedImages, selectedIndex, sortedContainers] ); const handleConfirm = useCallback(async () => { @@ -557,28 +609,28 @@ export function App(): React.ReactElement { {activeTab === "containers" && ( )} {activeTab === "images" && ( )} {activeTab === "networks" && ( )} {activeTab === "volumes" && ( diff --git a/src/components/ContainersView.tsx b/src/components/ContainersView.tsx index d1c004c..28dd365 100644 --- a/src/components/ContainersView.tsx +++ b/src/components/ContainersView.tsx @@ -34,14 +34,6 @@ export function ContainersView({ selectedIndex, searchQuery, }: ContainersViewProps): React.ReactElement { - const filteredContainers = containers.filter( - (c) => - !searchQuery || - c.name.toLowerCase().includes(searchQuery.toLowerCase()) || - c.image.toLowerCase().includes(searchQuery.toLowerCase()) || - c.status.toLowerCase().includes(searchQuery.toLowerCase()) - ); - const columns = [ { key: "name", header: "NAME", width: 25 }, { key: "image", header: "IMAGE", width: 30 }, @@ -69,7 +61,7 @@ export function ContainersView({ )} diff --git a/src/components/HelpOverlay.tsx b/src/components/HelpOverlay.tsx index 8ffe478..c11ff5f 100644 --- a/src/components/HelpOverlay.tsx +++ b/src/components/HelpOverlay.tsx @@ -19,10 +19,10 @@ const HELP_SECTIONS = [ { title: "Container Actions", items: [ - { key: "n", desc: "Run new container" }, + { key: "c", desc: "Create new container" }, { key: "e", desc: "Edit container" }, { key: "s", desc: "Start container" }, - { key: "x", desc: "Stop container" }, + { key: "o", desc: "Stop container" }, { key: "R", desc: "Restart container" }, { key: "d", desc: "Delete (remove)" }, { key: "L", desc: "View logs" }, @@ -32,7 +32,7 @@ const HELP_SECTIONS = [ { title: "Image Actions", items: [ - { key: "n", desc: "Run container from image" }, + { key: "c", desc: "Create container from image" }, { key: "p", desc: "Pull image" }, { key: "d", desc: "Delete image" }, { key: "i", desc: "Inspect image" }, diff --git a/src/components/ImagesView.tsx b/src/components/ImagesView.tsx index 64be0b8..fb6f5f2 100644 --- a/src/components/ImagesView.tsx +++ b/src/components/ImagesView.tsx @@ -14,13 +14,6 @@ export function ImagesView({ selectedIndex, searchQuery, }: ImagesViewProps): React.ReactElement { - const filteredImages = images.filter( - (img) => - !searchQuery || - img.repository.toLowerCase().includes(searchQuery.toLowerCase()) || - img.tag.toLowerCase().includes(searchQuery.toLowerCase()) - ); - const columns = [ { key: "repository", header: "REPOSITORY", width: 35 }, { key: "tag", header: "TAG", width: 20 }, @@ -38,7 +31,7 @@ export function ImagesView({ )}
diff --git a/src/components/NetworksView.tsx b/src/components/NetworksView.tsx index 91c30a8..94d2c09 100644 --- a/src/components/NetworksView.tsx +++ b/src/components/NetworksView.tsx @@ -14,13 +14,6 @@ export function NetworksView({ selectedIndex, searchQuery, }: NetworksViewProps): React.ReactElement { - const filteredNetworks = networks.filter( - (net) => - !searchQuery || - net.name.toLowerCase().includes(searchQuery.toLowerCase()) || - net.driver.toLowerCase().includes(searchQuery.toLowerCase()) - ); - const columns = [ { key: "id", header: "NETWORK ID", width: 15 }, { key: "name", header: "NAME", width: 30 }, @@ -37,7 +30,7 @@ export function NetworksView({ )}
diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index fdaa81e..324d9d3 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -12,13 +12,86 @@ interface StatusBarProps { releaseStatus?: ReleaseCheckState | null; } -const TAB_ACTIONS: Record = { - containers: "n:run e:edit s:start x:stop R:restart d:delete L:logs i:inspect", - images: "n:run p:pull d:delete i:inspect", - networks: "c:create d:delete i:inspect", - volumes: "c:create d:delete i:inspect", +interface Action { + key: string; + label: string; +} + +const TAB_ACTIONS: Record = { + containers: [ + { key: "c", label: "create" }, + { key: "e", label: "edit" }, + { key: "s", label: "start" }, + { key: "o", label: "stop" }, + { key: "R", label: "Restart" }, + { key: "d", label: "delete" }, + { key: "L", label: "Logs" }, + { key: "i", label: "inspect" }, + ], + images: [ + { key: "c", label: "create" }, + { key: "p", label: "pull" }, + { key: "d", label: "delete" }, + { key: "i", label: "inspect" }, + ], + networks: [ + { key: "c", label: "create" }, + { key: "d", label: "delete" }, + { key: "i", label: "inspect" }, + ], + volumes: [ + { key: "c", label: "create" }, + { key: "d", label: "delete" }, + { key: "i", label: "inspect" }, + ], }; +export function renderAction({ key, label }: Action, dim = false): React.ReactElement { + const idx = label.indexOf(key); + if (idx === -1) { + return {key}:{label}; + } + const before = label.slice(0, idx); + const after = label.slice(idx + 1); + return ( + + {before}{label[idx]}{after} + + ); +} + +function renderActions(actions: Action[]): React.ReactElement { + return ( + + {actions.map((action, i) => ( + + {i > 0 ? " " : ""}{renderAction(action)} + + ))} + + ); +} + +const GLOBAL_ACTIONS: Action[] = [ + { key: "/", label: "/search" }, + { key: "r", label: "refresh" }, + { key: "?", label: "?help" }, + { key: "q", label: "quit" }, +]; + +function renderGlobalActions(): React.ReactElement { + return ( + + hjkl:nav + {GLOBAL_ACTIONS.map((action) => ( + + {" "}{renderAction(action, true)} + + ))} + + ); +} + function renderReleaseStatus(releaseStatus?: ReleaseCheckState | null): React.ReactNode { if (!releaseStatus) return null; @@ -64,13 +137,9 @@ export function StatusBar({ return ( - {itemCount} {activeTab} | {TAB_ACTIONS[activeTab]} + {itemCount} {activeTab} | {renderActions(TAB_ACTIONS[activeTab])} - {releaseContent ?? ( - - h/l:tabs j/k:navigate /:search r:refresh ?:help q:quit - - )} + {releaseContent ?? renderGlobalActions()} ); } diff --git a/src/components/VolumesView.tsx b/src/components/VolumesView.tsx index 74a6bf0..1527bc9 100644 --- a/src/components/VolumesView.tsx +++ b/src/components/VolumesView.tsx @@ -14,13 +14,6 @@ export function VolumesView({ selectedIndex, searchQuery, }: VolumesViewProps): React.ReactElement { - const filteredVolumes = volumes.filter( - (vol) => - !searchQuery || - vol.name.toLowerCase().includes(searchQuery.toLowerCase()) || - vol.driver.toLowerCase().includes(searchQuery.toLowerCase()) - ); - const columns = [ { key: "name", header: "VOLUME NAME", width: 35 }, { key: "driver", header: "DRIVER", width: 15 }, @@ -37,7 +30,7 @@ export function VolumesView({ )}
diff --git a/src/hooks/useKeyboard.ts b/src/hooks/useKeyboard.ts index 99c66ab..f4c8369 100644 --- a/src/hooks/useKeyboard.ts +++ b/src/hooks/useKeyboard.ts @@ -111,7 +111,7 @@ export function useKeyboard(options: UseKeyboardOptions): void { return; } - if (input === "x") { + if (input === "o") { onAction("stop"); return; } @@ -160,11 +160,6 @@ export function useKeyboard(options: UseKeyboardOptions): void { return; } - if (input === "n") { - onAction("run"); - return; - } - if (input === "e") { onAction("edit"); return;