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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ contui
#### Actions
| Key | Action |
|-----|--------|
| `n` | Run new container (Containers/Images tab) |
| `e` | Edit container (recreate with new settings) |
| `s` | Start container |
| `x` | Stop container |
| `R` | Restart container |
Expand Down
6 changes: 6 additions & 0 deletions progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ Each entry documents: date, feature, decisions, files changed, tests, and concer
- Changes: Restricted npm package contents to dist, added version display and async release check in the UI, and documented npm install/run steps with supporting tests.
- Decisions: Pulled current version from env/package.json and used npm registry latest endpoint for update checks.
- Issues: Release workflow may still need updates to publish; requires approval to modify .github/workflows.

[2026-02-11] #12 feat: Add container creation with image selection, port mapping, and naming
- Files: src/types/index.ts, src/services/container-cli.ts, src/components/RunContainerDialog.tsx, src/components/App.tsx, src/hooks/useKeyboard.ts, src/components/HelpOverlay.tsx, src/__tests__/container-cli.test.ts, README.md
- 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
81 changes: 81 additions & 0 deletions src/__tests__/container-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,84 @@
import { buildRunArgs } from "../services/container-cli.js";

describe("buildRunArgs", () => {
it("should build command with image only", () => {
expect(buildRunArgs({ image: "nginx:latest" })).toBe("run --detach nginx:latest");
});

it("should include --name when name is provided", () => {
expect(buildRunArgs({ image: "nginx:latest", name: "my-nginx" })).toBe(
"run --detach --name my-nginx nginx:latest"
);
});

it("should include --publish for port mappings", () => {
expect(buildRunArgs({ image: "postgres:latest", ports: ["5433:5432"] })).toBe(
"run --detach --publish 5433:5432 postgres:latest"
);
});

it("should include multiple --publish flags", () => {
expect(buildRunArgs({ image: "nginx:latest", ports: ["8080:80", "8443:443"] })).toBe(
"run --detach --publish 8080:80 --publish 8443:443 nginx:latest"
);
});

it("should include --env for environment variables", () => {
expect(buildRunArgs({ image: "postgres:latest", env: ["POSTGRES_PASSWORD=secret"] })).toBe(
"run --detach --env POSTGRES_PASSWORD=secret postgres:latest"
);
});

it("should include multiple --env flags", () => {
expect(
buildRunArgs({
image: "postgres:latest",
env: ["POSTGRES_PASSWORD=secret", "POSTGRES_DB=mydb"],
})
).toBe("run --detach --env POSTGRES_PASSWORD=secret --env POSTGRES_DB=mydb postgres:latest");
});

it("should combine all options", () => {
expect(
buildRunArgs({
image: "postgres:latest",
name: "my-pg",
ports: ["5433:5432"],
env: ["POSTGRES_PASSWORD=secret"],
})
).toBe(
"run --detach --name my-pg --publish 5433:5432 --env POSTGRES_PASSWORD=secret postgres:latest"
);
});

it("should skip empty/whitespace-only name", () => {
expect(buildRunArgs({ image: "nginx:latest", name: " " })).toBe("run --detach nginx:latest");
});

it("should skip empty/whitespace-only ports", () => {
expect(buildRunArgs({ image: "nginx:latest", ports: ["", " "] })).toBe(
"run --detach nginx:latest"
);
});

it("should skip empty/whitespace-only env vars", () => {
expect(buildRunArgs({ image: "nginx:latest", env: ["", " "] })).toBe(
"run --detach nginx:latest"
);
});

it("should trim whitespace from all values", () => {
expect(
buildRunArgs({
image: " nginx:latest ",
name: " my-nginx ",
ports: [" 8080:80 "],
env: [" FOO=bar "],
})
).toBe("run --detach --name my-nginx --publish 8080:80 --env FOO=bar nginx:latest");
});
});

// Test the type parsing functions by recreating them for macOS container CLI
describe("ContainerCliService parsing (macOS)", () => {
const parseStatus = (state: string) => {
Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/status-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ describe("StatusBar", () => {
/>
);

expect(lastFrame()).toContain("Update available: v2.0.0");
const frame = lastFrame()?.replace(/\n/g, " ") ?? "";
expect(frame).toContain("Update available:");
expect(frame).toContain("v2.0.0");
});

it("shows controls when no release message is provided", () => {
Expand Down
105 changes: 98 additions & 7 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import { LogsView } from "./LogsView.js";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { CreateDialog } from "./CreateDialog.js";
import { PullDialog } from "./PullDialog.js";
import { RunContainerDialog } from "./RunContainerDialog.js";
import { useContainerData } from "../hooks/useContainerData.js";
import { useKeyboard } from "../hooks/useKeyboard.js";
import { useReleaseCheck } from "../hooks/useReleaseCheck.js";
import { containerCli } from "../services/container-cli.js";
import { getAppVersion } from "../utils/app-version.js";
import type { Tab } from "../types/index.js";
import type { RunContainerOptions, Tab } from "../types/index.js";

type DialogType = "confirm" | "create" | "pull" | "logs" | "inspect" | null;
type DialogType = "confirm" | "create" | "pull" | "run" | "logs" | "inspect" | null;

const APP_VERSION = getAppVersion();

Expand Down Expand Up @@ -60,6 +61,8 @@ interface DialogState {
logs?: string;
containerName?: string;
createType?: "network" | "volume";
initialValues?: { image?: string; name?: string; ports?: string; env?: string };
editContainerId?: string;
}

export function App(): React.ReactElement {
Expand Down Expand Up @@ -129,9 +132,24 @@ export function App(): React.ReactElement {
case "containers": {
const container = containers[selectedIndex];
if (!container) return;
inspectData = (await containerCli.inspectContainer(
container.id
)) as unknown as Record<string, unknown>;
const details = await containerCli.inspectContainer(container.id);
const curated: Record<string, unknown> = {
id: details.id,
image: details.image,
status: details.state,
created: details.created,
command: details.command || undefined,
entrypoint: details.config?.entrypoint || undefined,
env: details.config?.env || undefined,
ports: details.ports.length > 0
? details.ports.map(p => `${p.hostPort}:${p.containerPort}/${p.protocol}`)
: undefined,
network: details.networkSettings?.ipAddress
? { ip: details.networkSettings.ipAddress, gateway: details.networkSettings.gateway }
: undefined,
mounts: details.mounts?.length ? details.mounts : undefined,
};
inspectData = Object.fromEntries(Object.entries(curated).filter(([, v]) => v !== undefined));
break;
}
case "images": {
Expand All @@ -158,7 +176,10 @@ export function App(): React.ReactElement {
}
}

setDialog({ type: "inspect", data: truncateData(inspectData) as Record<string, unknown> });
setDialog({
type: "inspect",
data: activeTab === "containers" ? inspectData : truncateData(inspectData) as Record<string, unknown>,
});
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to inspect");
}
Expand Down Expand Up @@ -238,6 +259,41 @@ export function App(): React.ReactElement {
return;
}

if (action === "run" && (activeTab === "containers" || activeTab === "images")) {
const image =
activeTab === "images" ? images[selectedIndex]?.reference : undefined;
setDialog({ type: "run", initialValues: image ? { image } : undefined });
return;
}

if (action === "edit" && activeTab === "containers") {
const container = containers[selectedIndex];
if (!container) return;
try {
setActionInProgress("Loading container settings...");
const details = await containerCli.inspectContainer(container.id);
setActionInProgress(null);
const ports = details.ports
.map((p) => `${p.hostPort}:${p.containerPort}`)
.join(", ");
const env = details.config?.env?.join(", ") ?? "";
setDialog({
type: "run",
initialValues: {
image: details.image,
name: details.name,
ports,
env,
},
editContainerId: container.id,
});
} catch (err) {
setActionInProgress(null);
setActionError(err instanceof Error ? err.message : "Failed to inspect container");
}
return;
}

const identifiers = getItemIdentifiers();
if (!identifiers) return;

Expand Down Expand Up @@ -319,7 +375,7 @@ export function App(): React.ReactElement {
setActionError(err instanceof Error ? err.message : "Action failed");
}
},
[getItemIdentifiers, activeTab, refresh, handleSelect]
[getItemIdentifiers, activeTab, refresh, handleSelect, images, selectedIndex, containers]
);

const handleConfirm = useCallback(async () => {
Expand Down Expand Up @@ -370,6 +426,29 @@ export function App(): React.ReactElement {
[dialog.createType, refresh]
);

const handleRunConfirm = useCallback(
async (options: RunContainerOptions) => {
const editId = dialog.editContainerId;
setDialog({ type: null, data: undefined, logs: undefined });
try {
if (editId) {
setActionInProgress("Recreating container...");
await containerCli.stopContainer(editId).catch(() => {});
await containerCli.removeContainer(editId);
} else {
setActionInProgress(`Running ${options.image}...`);
}
await containerCli.runContainer(options);
await refresh();
setActionInProgress(null);
} catch (err) {
setActionInProgress(null);
setActionError(err instanceof Error ? err.message : "Failed to run container");
}
},
[dialog.editContainerId, refresh]
);

const handleCancel = useCallback(() => {
setDialog({ type: null, data: undefined, logs: undefined });
}, []);
Expand All @@ -386,6 +465,7 @@ export function App(): React.ReactElement {
onAction: handleAction,
isSearchMode: searchMode,
isDetailView: dialog.type === "logs" || dialog.type === "inspect",
isDialogOpen: dialog.type === "confirm" || dialog.type === "create" || dialog.type === "pull" || dialog.type === "run",
activeTab,
});

Expand Down Expand Up @@ -428,6 +508,17 @@ export function App(): React.ReactElement {
);
}

if (dialog.type === "run") {
return (
<RunContainerDialog
initialValues={dialog.initialValues}
isEdit={!!dialog.editContainerId}
onConfirm={handleRunConfirm}
onCancel={handleCancel}
/>
);
}

if (dialog.type === "logs" && dialog.logs !== undefined) {
return (
<LogsView
Expand Down
3 changes: 3 additions & 0 deletions src/components/HelpOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const HELP_SECTIONS = [
{
title: "Container Actions",
items: [
{ key: "n", desc: "Run new container" },
{ key: "e", desc: "Edit container" },
{ key: "s", desc: "Start container" },
{ key: "x", desc: "Stop container" },
{ key: "R", desc: "Restart container" },
Expand All @@ -30,6 +32,7 @@ const HELP_SECTIONS = [
{
title: "Image Actions",
items: [
{ key: "n", desc: "Run container from image" },
{ key: "p", desc: "Pull image" },
{ key: "d", desc: "Delete image" },
{ key: "i", desc: "Inspect image" },
Expand Down
Loading