diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index c039815d..7ad11346 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1049,6 +1049,55 @@ export interface AdoptedContainerProbe { error?: string; } +/** One container on the engine, as offered to the adoption picker. */ +export interface DockerContainerInfo { + name: string; + image: string; + running: boolean; + /** Engine's own status string, e.g. "Up 3 hours" / "Exited (0) 2 days ago". */ + status: string; +} + +/** + * List the engine's containers for the adoption picker (mirror of + * `listRemoteCodemanSessions`). Read-only and NEVER throws: an unreachable + * daemon, a missing engine or zero containers all return `[]`, because this + * feeds a convenience picker whose input the user can always type by hand. + * + * Stopped containers ARE included, sorted after running ones and carrying their + * status: adoption requires a running container, but hiding a stopped one turns + * "my container is not in the list" into a dead end with no explanation, while + * showing `my-box (Exited (0) 2 days ago)` says exactly what to fix. + */ +export async function listDockerContainers( + docker: Pick +): Promise { + if (IS_TEST_MODE) return []; + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'ps', '-a', '--format', '{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const rows = stdout + .split('\n') + .map((line) => line.split('\t')) + .filter((parts) => parts.length >= 4 && parts[0]) + .map(([name, image, state, status]) => ({ + name, + image: image || '', + running: state === 'running', + status: status || '', + })); + // Running first, then by name, so the containers a user can actually adopt + // are the ones at the top of the list. + return rows.sort((a, b) => Number(b.running) - Number(a.running) || a.name.localeCompare(b.name)); + } catch { + return []; + } +} + /** * Preflight an EXISTING container for adoption. Read-only by construction: it * runs `inspect` plus one `exec` of `command -v`, and never creates, starts or diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index f9d83ab8..30ee874d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -717,7 +717,7 @@ 'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.': '开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。', 'Container Name': '容器名称', - 'Must be running already.': '该容器必须已在运行。', + 'Pick from the running containers or type a name.': '从正在运行的容器中选择,或直接输入名称。', 'Check container': '检查容器', 'Container Workdir': '容器内工作目录', 'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 7e1024e4..f83a17d9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2838,8 +2838,9 @@
- - Must be running already. + + + Pick from the running containers or type a name.
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 8cedd647..2377c37d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2343,6 +2343,18 @@ Object.assign(CodemanApp.prototype, { if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight(); const adoptJump = document.getElementById('dockerAdoptJumpBtn'); if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt(); + // Containers come from the host profile, so switching Host ID invalidates the + // suggestions. Dropping the marker (rather than refetching here) keeps the + // fetch lazy — it happens when adopt mode is actually on. + const hostIdInput = document.getElementById('dockerHostId'); + if (hostIdInput) { + hostIdInput.onchange = () => { + delete document.getElementById('dockerContainerList')?.dataset.loadedFor; + if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions(); + }; + } + // A fresh open re-reads the engine: containers start and stop between visits. + delete document.getElementById('dockerContainerList')?.dataset.loadedFor; this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { @@ -2913,6 +2925,34 @@ Object.assign(CodemanApp.prototype, { const adopting = document.getElementById('dockerAdoptExisting')?.checked; if (adopting) modal.setAttribute('data-docker-adopt', '1'); else modal.removeAttribute('data-docker-adopt'); + if (adopting) void this._loadDockerContainerOptions(); + }, + + /** + * Fill the container-name ``. A native datalist is deliberate: the + * field must accept a free-typed name (the engine may be remote, or the + * container may not exist yet when the form is filled), and datalist gives + * type-to-filter over the suggestions without a custom dropdown. + * + * Best-effort by design — the endpoint returns [] for an unreachable daemon, + * and an empty list simply leaves the field as plain text input. + */ + async _loadDockerContainerOptions() { + const list = document.getElementById('dockerContainerList'); + if (!list) return; + const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local'; + if (list.dataset.loadedFor === hostId) return; // one fetch per host per open + const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`); + const containers = data?.containers || []; + list.textContent = ''; + for (const c of containers) { + const option = document.createElement('option'); + option.value = c.name; + // Engine-supplied strings: set as text, never as markup. + option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`; + list.appendChild(option); + } + list.dataset.loadedFor = hostId; }, /** diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 6e526995..fc2079d3 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -69,6 +69,7 @@ import { dockerContainerName, dockerDisplayPath, probeAdoptableContainer, + listDockerContainers, DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, @@ -77,7 +78,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; -import type { AdoptedContainerProbe } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -868,6 +869,27 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config * the user "not running" / "no tmux" / "codex present, claude missing" before * they commit to a case name. Read-only; never touches container lifecycle. */ + /** + * Containers on the host's engine, for the adoption picker. Read-only and + * best-effort (mirror of the remote `:hostId/sessions` discovery route): an + * unreachable daemon yields an empty list rather than an error, because the + * container name is a free-text field the user can always type by hand. + */ + app.get( + '/api/docker-hosts/:hostId/containers', + async (req): Promise> => { + const { hostId } = req.params as { hostId: string }; + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const containers = await listDockerContainers({ + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + }); + return { success: true, data: { containers } }; + } + ); + app.post('/api/docker-cases/adopt-preflight', async (req): Promise> => { const body = parseBody(DockerAdoptPreflightSchema, req.body); const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);