mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(docker): make the container field a picker you can also type into
Typing a container name from memory is error-prone. The field becomes a native datalist: pick from the engine's containers, type to filter, or type a name that is not listed (the engine may be remote, or the container may not exist yet). A datalist gives all three natively, so no dropdown state machine is introduced. Adds listDockerContainers and GET /api/docker-hosts/:hostId/containers, following the listRemoteCodemanSessions discovery precedent: read-only and never throwing, so an unreachable daemon returns an empty list and the field degrades to plain text instead of erroring. Stopped containers stay in the list, sorted after running ones and labelled. Attaching does require a running container, but hiding stopped ones turns "my container is not in the list" into a dead end, while showing `Exited (137) 8 days ago` says exactly what to fix.
This commit is contained in:
@@ -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<SessionDocker, 'engine' | 'context' | 'daemonHost'>
|
||||
): Promise<DockerContainerInfo[]> {
|
||||
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
|
||||
|
||||
@@ -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.':
|
||||
|
||||
@@ -2838,8 +2838,9 @@
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Name</label>
|
||||
<input type="text" id="dockerContainerName" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Must be running already. <button type="button" class="btn-inline-check" id="dockerAdoptCheckBtn">Check container</button></span>
|
||||
<input type="text" id="dockerContainerName" list="dockerContainerList" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<datalist id="dockerContainerList"></datalist>
|
||||
<span class="form-hint">Pick from the running containers or type a name. <button type="button" class="btn-inline-check" id="dockerAdoptCheckBtn">Check container</button></span>
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Workdir</label>
|
||||
|
||||
@@ -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 `<datalist>`. 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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
|
||||
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<ApiResponse<AdoptedContainerProbe>> => {
|
||||
const body = parseBody(DockerAdoptPreflightSchema, req.body);
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
|
||||
Reference in New Issue
Block a user